diff --git a/package-lock.json b/package-lock.json index 40a244ea..336fda8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,22 @@ { "name": "@bdelab/roar-firekit", - "version": "4.6.0", + "version": "4.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bdelab/roar-firekit", - "version": "4.6.0", + "version": "4.8.0", "license": "ISC", "dependencies": { + "@bdelab/roar-firekit": "^4.1.1", "crc-32": "^1.2.2", "dot-object": "^2.1.4", "firebase": "^9.23.0", + "link": "^2.1.0", "lodash": "^4.17.21", - "vue": "^3.3.4" + "vue": "^3.3.4", + "web-vitals": "^3.4.0" }, "devDependencies": { "@faker-js/faker": "^7.6.0", @@ -27,6 +30,7 @@ "eslint-config-prettier": "^8.5.0", "jest": "^29.5.0", "prettier": "^2.5.1", + "process": "^0.11.10", "ts-jest": "^29.1.0", "typedoc": "^0.22.13", "typescript": "^4.6.2" @@ -645,6 +649,18 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@bdelab/roar-firekit": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@bdelab/roar-firekit/-/roar-firekit-4.1.1.tgz", + "integrity": "sha512-JN/2KZqz+2peblXRcQXZsSM8rawZTNo14gCc64apnIy82ESNaLL+qBtKWY1N23J8nx6lJPFnVzfbm5UiMsnvoQ==", + "dependencies": { + "crc-32": "^1.2.2", + "dot-object": "^2.1.4", + "firebase": "^9.23.0", + "lodash": "^4.17.21", + "vue": "^3.3.4" + } + }, "node_modules/@eslint/eslintrc": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.3.tgz", @@ -4977,6 +4993,17 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/link": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/link/-/link-2.1.0.tgz", + "integrity": "sha512-6jgX7ejPBOQaKsFY/9aFEg0HW0JyFuMGDoN+KQX1W94t+8Fi5xwlSlGouRMDqDlrE46drqu4PYduAG7tUwdF7Q==", + "bin": { + "link": "dist/cli.js" + }, + "funding": { + "url": "https://github.com/privatenumber/link?sponsor=1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5570,6 +5597,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6340,6 +6376,11 @@ "makeerror": "1.0.12" } }, + "node_modules/web-vitals": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-3.4.0.tgz", + "integrity": "sha512-n9fZ5/bG1oeDkyxLWyep0eahrNcPDF6bFqoyispt7xkW0xhDzpUBTgyDKqWDi1twT0MgH4HvvqzpUyh0ZxZV4A==" + }, "node_modules/websocket-driver": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", diff --git a/package.json b/package.json index 276100f7..a11313cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bdelab/roar-firekit", - "version": "4.6.0", + "version": "4.8.0", "description": "A library to facilitate Firebase authentication and Cloud Firestore interaction for ROAR apps", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -53,6 +53,7 @@ "eslint-config-prettier": "^8.5.0", "jest": "^29.5.0", "prettier": "^2.5.1", + "process": "^0.11.10", "ts-jest": "^29.1.0", "typedoc": "^0.22.13", "typescript": "^4.6.2" @@ -61,10 +62,13 @@ "lib/**/*" ], "dependencies": { + "@bdelab/roar-firekit": "^4.1.1", "crc-32": "^1.2.2", "dot-object": "^2.1.4", "firebase": "^9.23.0", + "link": "^2.1.0", "lodash": "^4.17.21", - "vue": "^3.3.4" + "vue": "^3.3.4", + "web-vitals": "^3.4.0" } } diff --git a/src/firestore/app/appkit.ts b/src/firestore/app/appkit.ts index c1a781ec..d90b20f7 100644 --- a/src/firestore/app/appkit.ts +++ b/src/firestore/app/appkit.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { onAuthStateChanged } from 'firebase/auth'; import { updateDoc, arrayRemove, arrayUnion } from 'firebase/firestore'; - +import { ref, getDownloadURL } from 'firebase/storage'; import { IComputedScores, IRawScores, RoarRun } from './run'; import { ITaskVariantInfo, RoarTaskVariant } from './task'; import { IUserInfo, IUserUpdateInput, RoarAppUser } from './user'; @@ -303,4 +303,13 @@ export class RoarAppkit { throw new Error('This run has not started. Use the startRun method first.'); } } + + async getStorageDownloadUrl(filePath: string) { + if (!this._initialized) { + await this._init(); + } + + const storageRef = ref(this.firebaseProject!.storage, filePath); + return getDownloadURL(storageRef); + } } diff --git a/src/firestore/firekit.ts b/src/firestore/firekit.ts index f6c5cf58..1ff222ac 100644 --- a/src/firestore/firekit.ts +++ b/src/firestore/firekit.ts @@ -73,12 +73,6 @@ enum AuthProviderType { USERNAME = 'username', } -const RoarProviderId = { - ...ProviderId, - CLEVER: 'oidc.clever', - ROAR_ADMIN_PROJECT: 'oidc.gse-roar-admin', -}; - interface ICreateUserInput { dob: string; grade: string; @@ -104,6 +98,21 @@ interface ICreateUserInput { group: { id: string; abbreviation?: string } | null; } +interface CreateParentInput { + name: { + first: string; + last: string; + }; +} + +export interface ChildData { + email: string; + password: string; + userData: ICreateUserInput; + familyId: string; + orgCode: string; +} + interface ICurrentAssignments { assigned: string[]; started: string[]; @@ -161,6 +170,14 @@ export class RoarFirekit { this.listenerUpdateCallback = listenerUpdateCallback ?? (() => {}); } + private _getProviderIds() { + return { + ...ProviderId, + CLEVER: 'oidc.clever', + ROAR_ADMIN_PROJECT: `oidc.${this.roarConfig.admin.projectId}`, + }; + } + private _scrubAuthProperties() { this.userData = undefined; this.roarAppUserInfo = undefined; @@ -270,6 +287,7 @@ export class RoarFirekit { async (doc) => { const data = doc.data(); this._adminOrgs = data?.claims?.adminOrgs; + this._superAdmin = data?.claims?.super_admin; if (data?.lastUpdated) { const lastUpdated = new Date(data!.lastUpdated); if (!firekit.claimsLastUpdated || lastUpdated > firekit.claimsLastUpdated) { @@ -301,7 +319,6 @@ export class RoarFirekit { if (user) { const idTokenResult = await user.getIdTokenResult(false); if (_type === 'admin') { - this._superAdmin = Boolean(idTokenResult.claims.super_admin); this._idTokenReceived = true; } this._idTokens[_type] = idTokenResult.token; @@ -420,7 +437,8 @@ export class RoarFirekit { this._verifyInit(); return signInWithEmailLink(this.admin!.auth, email, emailLink) .then(async (userCredential) => { - const roarAdminProvider = new OAuthProvider(RoarProviderId.ROAR_ADMIN_PROJECT); + const roarProviderIds = this._getProviderIds(); + const roarAdminProvider = new OAuthProvider(roarProviderIds.ROAR_ADMIN_PROJECT); const roarAdminIdToken = await getIdToken(userCredential.user); const roarAdminCredential = roarAdminProvider.credential({ idToken: roarAdminIdToken, @@ -448,7 +466,8 @@ export class RoarFirekit { if (provider === AuthProviderType.GOOGLE) { authProvider = new GoogleAuthProvider(); } else if (provider === AuthProviderType.CLEVER) { - authProvider = new OAuthProvider(RoarProviderId.CLEVER); + const roarProviderIds = this._getProviderIds(); + authProvider = new OAuthProvider(roarProviderIds.CLEVER); } else { throw new Error(`provider must be one of ${allowedProviders.join(', ')}. Received ${provider} instead.`); } @@ -476,7 +495,8 @@ export class RoarFirekit { // TODO: Find a way to put this in the onAuthStateChanged handler oAuthAccessToken = credential?.accessToken; - const roarAdminProvider = new OAuthProvider(RoarProviderId.ROAR_ADMIN_PROJECT); + const roarProviderIds = this._getProviderIds(); + const roarAdminProvider = new OAuthProvider(roarProviderIds.ROAR_ADMIN_PROJECT); const roarAdminIdToken = await getIdToken(adminUserCredential.user); const roarAdminCredential = roarAdminProvider.credential({ idToken: roarAdminIdToken, @@ -511,7 +531,8 @@ export class RoarFirekit { if (provider === AuthProviderType.GOOGLE) { authProvider = new GoogleAuthProvider(); } else if (provider === AuthProviderType.CLEVER) { - authProvider = new OAuthProvider(RoarProviderId.CLEVER); + const roarProviderIds = this._getProviderIds(); + authProvider = new OAuthProvider(roarProviderIds.CLEVER); } else { throw new Error(`provider must be one of ${allowedProviders.join(', ')}. Received ${provider} instead.`); } @@ -536,21 +557,22 @@ export class RoarFirekit { .then(async (adminUserCredential) => { if (adminUserCredential !== null) { const providerId = adminUserCredential.providerId; - if (providerId === RoarProviderId.GOOGLE) { + const roarProviderIds = this._getProviderIds(); + if (providerId === roarProviderIds.GOOGLE) { const credential = GoogleAuthProvider.credentialFromResult(adminUserCredential); // This gives you a Google Access Token. You can use it to access Google APIs. // TODO: Find a way to put this in the onAuthStateChanged handler authProvider = AuthProviderType.GOOGLE; oAuthAccessToken = credential?.accessToken; return credential; - } else if (providerId === RoarProviderId.CLEVER) { + } else if (providerId === roarProviderIds.CLEVER) { const credential = OAuthProvider.credentialFromResult(adminUserCredential); // This gives you a Clever Access Token. You can use it to access Clever APIs. // TODO: Find a way to put this in the onAuthStateChanged handler authProvider = AuthProviderType.CLEVER; oAuthAccessToken = credential?.accessToken; - const roarAdminProvider = new OAuthProvider(RoarProviderId.ROAR_ADMIN_PROJECT); + const roarAdminProvider = new OAuthProvider(roarProviderIds.ROAR_ADMIN_PROJECT); const roarAdminIdToken = await getIdToken(adminUserCredential.user); const roarAdminCredential = roarAdminProvider.credential({ idToken: roarAdminIdToken, @@ -626,11 +648,11 @@ export class RoarFirekit { return { admin: { headers: { Authorization: `Bearer ${this._idTokens.admin}` }, - baseURL: 'https://firestore.googleapis.com/v1/projects/gse-roar-admin/databases/(default)/documents', + baseURL: `https://firestore.googleapis.com/v1/projects/${this.roarConfig.admin.projectId}/databases/(default)/documents`, }, app: { headers: { Authorization: `Bearer ${this._idTokens.app}` }, - baseURL: 'https://firestore.googleapis.com/v1/projects/gse-roar-assessment/databases/(default)/documents', + baseURL: `https://firestore.googleapis.com/v1/projects/${this.roarConfig.app.projectId}/databases/(default)/documents`, }, }; } @@ -876,29 +898,16 @@ export class RoarFirekit { throw new Error(`Could not find assessment with taskId ${taskId} in administration ${administrationId}`); } - // Create the run in the assessment Firestore, record the runId and then - // pass it to the app - const runRef = doc(this.dbRefs!.app.runs); - const runId = runRef.id; - // Check the assignment to see if none of the assessments have been // started yet. If not, start the assignment const assignmentDocRef = doc(this.dbRefs!.admin.assignments, administrationId); const assignmentDocSnap = await transaction.get(assignmentDocRef); if (assignmentDocSnap.exists()) { const assignedAssessments = assignmentDocSnap.data().assessments as IAssignedAssessmentData[]; - const allRunIdsForThisTask = assignedAssessments.find((a) => a.taskId === taskId)?.allRunIds || []; - allRunIdsForThisTask.push(runId); - - const assessmentUpdateData: { startedOn: Date; allRunIds: string[]; runId?: string } = { + const assessmentUpdateData = { startedOn: new Date(), - allRunIds: allRunIdsForThisTask, }; - if (allRunIdsForThisTask.length === 1) { - assessmentUpdateData.runId = runId; - } - // Append runId to `allRunIds` for this assessment // in the userId/assignments collection await this._updateAssignedAssessment(administrationId, taskId, assessmentUpdateData, transaction); @@ -949,7 +958,6 @@ export class RoarFirekit { assigningOrgs, readOrgs, assignmentId: administrationId, - runId, taskInfo, }); } else { @@ -1254,6 +1262,55 @@ export class RoarFirekit { await cloudCreateStudent({ email, password, userData: userDocData }); } + async createNewFamily( + caretakerEmail: string, + caretakerPassword: string, + caretakerUserData: CreateParentInput, + children: ChildData[], + ) { + // Format children objects + const formattedChildren = children.map((child) => { + const returnChild = { + email: child.email, + password: child.password, + }; + // Create a PID for the student. + const emailCheckSum = crc32String(child.email!); + const pidParts: string[] = []; + pidParts.push(emailCheckSum); + _set(returnChild, 'userData.assessmentPid', pidParts.join('-')); + + // Move attributes into the studentData object. + _set(returnChild, 'userData.username', child.email.split('@')[0]); + if (_get(child, 'userData.name')) _set(returnChild, 'userData.name', child.userData.name); + if (_get(child, 'userData.gender')) _set(returnChild, 'userData.studentData.gender', child.userData.gender); + if (_get(child, 'userData.grade')) _set(returnChild, 'userData.studentData.grade', child.userData.grade); + if (_get(child, 'userData.dob')) _set(returnChild, 'userData.studentData.dob', child.userData.dob); + if (_get(child, 'userData.state_id')) _set(returnChild, 'userData.studentData.state_id', child.userData.state_id); + if (_get(child, 'userData.hispanic_ethnicity')) + _set(returnChild, 'userData.studentData.hispanic_ethnicity', child.userData.hispanic_ethnicity); + if (_get(child, 'userData.ell_status')) + _set(returnChild, 'userData.studentData.ell_status', child.userData.ell_status); + if (_get(child, 'userData.iep_status')) + _set(returnChild, 'userData.studentData.iep_status', child.userData.iep_status); + if (_get(child, 'userData.frl_status')) + _set(returnChild, 'userData.studentData.frl_status', child.userData.frl_status); + if (_get(child, 'userData.race')) _set(returnChild, 'userData.studentData.race', child.userData.race); + if (_get(child, 'userData.home_language')) + _set(returnChild, 'userData.studentData.home_language', child.userData.home_language); + return returnChild; + }); + + // Call cloud function + const cloudCreateFamily = httpsCallable(this.admin!.functions, 'createnewfamily'); + await cloudCreateFamily({ + caretakerEmail, + caretakerPassword, + caretakerUserData, + children: formattedChildren, + }); + } + async createStudentWithUsernamePassword(username: string, password: string, userData: ICreateUserInput) { this._verifyAuthentication(); this._verifyAdmin(); diff --git a/src/firestore/interfaces.ts b/src/firestore/interfaces.ts index 7d682b19..fbaa23b5 100644 --- a/src/firestore/interfaces.ts +++ b/src/firestore/interfaces.ts @@ -2,6 +2,8 @@ import { FirebaseApp } from 'firebase/app'; import { Auth, User } from 'firebase/auth'; import { DocumentData, Firestore, Timestamp } from 'firebase/firestore'; import { Functions } from 'firebase/functions'; +import { FirebaseStorage } from 'firebase/storage'; +import { FirebasePerformance } from 'firebase/performance'; import { FirebaseConfigData } from './util'; export interface IRoarConfigData { @@ -14,6 +16,8 @@ export interface IFirekit { db: Firestore; auth: Auth; functions: Functions; + storage: FirebaseStorage; + perf?: FirebasePerformance; user?: User; claimsLastUpdated?: Date; } @@ -171,6 +175,7 @@ export interface IClass extends DocumentData { } export interface IFamily extends DocumentData { + name: string; [x: string]: unknown; } diff --git a/src/firestore/util.ts b/src/firestore/util.ts index ca49063b..005142cd 100644 --- a/src/firestore/util.ts +++ b/src/firestore/util.ts @@ -10,6 +10,8 @@ import { } from 'firebase/auth'; import { connectFirestoreEmulator, Firestore, getFirestore } from 'firebase/firestore'; import { Functions, connectFunctionsEmulator, getFunctions } from 'firebase/functions'; +import { getStorage, FirebaseStorage } from 'firebase/storage'; +import { getPerformance, FirebasePerformance } from 'firebase/performance'; import _chunk from 'lodash/chunk'; import _difference from 'lodash/difference'; import _flatten from 'lodash/flatten'; @@ -117,7 +119,7 @@ export interface MarkRawConfig { functions?: boolean; } -type FirebaseProduct = Auth | Firestore | Functions; +type FirebaseProduct = Auth | Firestore | Functions | FirebaseStorage; export const initializeFirebaseProject = async ( config: FirebaseConfigData, @@ -139,6 +141,7 @@ export const initializeFirebaseProject = async ( const auth = optionallyMarkRaw('auth', getAuth(app)); const db = optionallyMarkRaw('db', getFirestore(app)); const functions = optionallyMarkRaw('functions', getFunctions(app)); + const storage = optionallyMarkRaw('storage', getStorage(app)); connectFirestoreEmulator(db, '127.0.0.1', ports.db); connectFunctionsEmulator(functions, '127.0.0.1', ports.functions); @@ -154,14 +157,26 @@ export const initializeFirebaseProject = async ( auth, db, functions, + storage, }; } else { const app = safeInitializeApp(config as RealConfigData, name); + let performance: FirebasePerformance | undefined = undefined; + try { + performance = getPerformance(app); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + if (error.code !== 'performance/FB not default') { + throw error; + } + } const kit = { firebaseApp: app, auth: optionallyMarkRaw('auth', getAuth(app)), db: optionallyMarkRaw('db', getFirestore(app)), functions: optionallyMarkRaw('functions', getFunctions(app)), + storage: optionallyMarkRaw('storage', getStorage(app)), + perf: performance, }; // Auth state persistence is set with ``setPersistence`` and specifies how a