From 3f7ef38e456f1c4b9b7768378f38110ad6cda7de Mon Sep 17 00:00:00 2001 From: digital-pro Date: Mon, 9 Feb 2026 17:04:22 -0800 Subject: [PATCH 1/2] harden assignment fan-out against malformed administration payloads Normalize and validate administration data before persistence, sanitize assignment write payloads in fan-out paths, and document the disappearing-assignment issue with root cause and mitigation details. Co-authored-by: Cursor --- .../README_DISAPPEARING_ASSIGNMENTS.md | 67 ++++++++ .../src/assignments/assignment-utils.ts | 80 ++++----- .../levante-admin/src/upsertAdministration.ts | 153 ++++++++++++------ functions/levante-admin/src/utils/utils.ts | 12 +- 4 files changed, 224 insertions(+), 88 deletions(-) create mode 100644 functions/levante-admin/README_DISAPPEARING_ASSIGNMENTS.md diff --git a/functions/levante-admin/README_DISAPPEARING_ASSIGNMENTS.md b/functions/levante-admin/README_DISAPPEARING_ASSIGNMENTS.md new file mode 100644 index 0000000..0e9a16e --- /dev/null +++ b/functions/levante-admin/README_DISAPPEARING_ASSIGNMENTS.md @@ -0,0 +1,67 @@ +# Fix disappearing assignments + +## Issue summary + +Some assignment creation flows appeared to succeed in the UI, but one or more user assignment documents were missing under: + +- `users/{uid}/assignments/{administrationId}` + +This was reported as "disappearing assignments". + +## What we found + +Two different behaviors were mixed together: + +1. Expected non-assignment due to conditions + - Some administrations intentionally assign only users that match an `assigned` condition (for example `userType == student`). + - In those cases, parent/teacher users are correctly not assigned. + +2. Real backend risk in async fan-out + - Assignment fan-out runs asynchronously in `updateAssignmentsForOrgChunk`. + - If the payload includes undefined values or malformed org arrays, Firestore writes can fail and prevent some assignment docs from being written. + +## Why this can look like a UI success + +Administration creation and assignment fan-out are separate phases: + +1. administration doc is created/updated +2. fan-out tasks are enqueued and processed later + +If phase 1 succeeds and phase 2 partially fails, users can see successful creation while assignment docs are incomplete. + +## Changes in this fix + +### 1) Harden upsert payloads (`src/upsertAdministration.ts`) + +- Normalize org ID arrays (`districts`, `schools`, `classes`, `groups`) to non-empty string IDs only. +- Strip undefined recursively from assessments and legal fields. +- Normalize assessment `params` to an object. +- Validate required assessment fields (`taskId`, `variantId`, `variantName`). +- Reject invalid dates explicitly. +- Ensure update payload preserves a valid `createdBy` value. + +These changes reduce malformed data entering downstream fan-out. + +### 2) Harden assignment writes (`src/assignments/assignment-utils.ts`) + +- Use cleaned assessments when building assignment docs. +- Sanitize assignment documents before `transaction.set(...)` in both add and update flows. + +This prevents transaction failures caused by nested undefined fields. + +### 3) Improve recursive sanitizer (`src/utils/utils.ts`) + +- `removeUndefinedFields()` now recursively cleans nested arrays, not only top-level array items. + +This closes a gap where undefined values inside array objects could survive and break Firestore writes. + +## Why this fixes disappearing assignments + +The core failure mode was bad shape/undefined data reaching Firestore write paths in async fan-out. +By normalizing and validating input early and sanitizing assignment payloads immediately before write, task execution is significantly less likely to fail partway through chunk processing. + +## Operational recommendation + +- Monitor `updateAssignmentsForOrgChunk` error rates and alert on repeated payload-shape failures. +- Keep an audit test that compares expected eligible users against actual assignment docs. + diff --git a/functions/levante-admin/src/assignments/assignment-utils.ts b/functions/levante-admin/src/assignments/assignment-utils.ts index 91a510a..2e69c39 100644 --- a/functions/levante-admin/src/assignments/assignment-utils.ts +++ b/functions/levante-admin/src/assignments/assignment-utils.ts @@ -47,7 +47,7 @@ import { const removeAssignmentFromUser = async ( userUid: string, administrationId: string, - transaction: Transaction + transaction: Transaction, ) => { const db = getFirestore(); const assignmentRef = db @@ -71,7 +71,7 @@ const removeAssignmentFromUser = async ( export const removeAssignmentFromUsers = async ( users: string[], administrationId: string, - transaction: Transaction + transaction: Transaction, ) => { if (!users.length) { return []; @@ -83,8 +83,8 @@ export const removeAssignmentFromUsers = async ( return Promise.all( _map(users, (user) => - removeAssignmentFromUser(user, administrationId, transaction) - ) + removeAssignmentFromUser(user, administrationId, transaction), + ), ); }; @@ -101,7 +101,7 @@ const prepareNewAssignment = async ( userUid: string, administrationId: string, administrationData: IAdministration, - transaction: Transaction + transaction: Transaction, ) => { const db = getFirestore(); const userRef = db.collection("users").doc(userUid); @@ -118,7 +118,7 @@ const prepareNewAssignment = async ( for (const orgName of ORG_NAMES) { usersAssigningOrgs[orgName] = _intersection( userOrgs[orgName]?.current, - assigningOrgs[orgName] + assigningOrgs[orgName], ); } @@ -225,7 +225,7 @@ const prepareNewAssignment = async ( assignmentPath: assignmentRef.path, originalSummary: summarizeAssessmentsForLog(assessments), cleanedSummary: summarizeAssessmentsForLog(cleanedAssessments), - } + }, ); } @@ -244,7 +244,7 @@ const prepareNewAssignment = async ( sequential: administrationData.sequential ?? false, assigningOrgs: usersAssigningOrgs, readOrgs: userReadOrgs, - assessments, + assessments: cleanedAssessments, progress, userData: userDataCopy, testData: administrationData.testData ?? false, @@ -271,7 +271,7 @@ export const addAssignmentToUsers = async ( users: string[], administrationId: string, administrationData: IAdministration, - transaction: Transaction + transaction: Transaction, ) => { console.log("hit addAssignmentToUsers"); const assignments = await Promise.all( @@ -280,17 +280,20 @@ export const addAssignmentToUsers = async ( user, administrationId, administrationData, - transaction - ) - ) + transaction, + ), + ), ); return _map(assignments, ([assignmentRef, assignmentData]) => { if (assignmentRef && assignmentData) { + const cleanedAssignmentData = removeUndefinedFields(assignmentData); logger.debug(`Adding new assignment at ${assignmentRef.path}`, { - assignmentSummary: summarizeAssignmentForLog(assignmentData), + assignmentSummary: summarizeAssignmentForLog(cleanedAssignmentData), + }); + return transaction.set(assignmentRef, cleanedAssignmentData, { + merge: true, }); - return transaction.set(assignmentRef, assignmentData, { merge: true }); } else { return transaction; } @@ -312,7 +315,7 @@ export const removeOrgsFromAssignment = async ( userUid: string, administrationId: string, orgsToRemove: IOrgsList, - transaction: Transaction + transaction: Transaction, ) => { const db = getFirestore(); const userDocRef = db.collection("users").doc(userUid); @@ -325,14 +328,14 @@ export const removeOrgsFromAssignment = async ( for (const orgName of Object.keys(assigningOrgs)) { assigningOrgs[orgName] = _without( assigningOrgs[orgName], - ...(orgsToRemove[orgName] ?? []) + ...(orgsToRemove[orgName] ?? []), ); } const numRemainingAssigningOrgs = _reduce( assigningOrgs, (sum, value) => sum + value.length, - 0 + 0, ); if (numRemainingAssigningOrgs > 0) { @@ -340,13 +343,13 @@ export const removeOrgsFromAssignment = async ( return [assignmentRef, assigningOrgs, readOrgs] as [ DocumentReference, IOrgsList | undefined, - IOrgsList | undefined + IOrgsList | undefined, ]; } else { return [assignmentRef, undefined, undefined] as [ DocumentReference, IOrgsList | undefined, - IOrgsList | undefined + IOrgsList | undefined, ]; } } else { @@ -367,7 +370,7 @@ export const removeOrgsFromAssignments = async ( users: string[], administrationIds: string[], orgsToRemove: IOrgsList, - transaction: Transaction + transaction: Transaction, ) => { const assignments = await Promise.all( _flatten( @@ -377,11 +380,11 @@ export const removeOrgsFromAssignments = async ( user, administrationId, orgsToRemove, - transaction + transaction, ); }); - }) - ) + }), + ), ); return _map(assignments, ([assignmentRef, assigningOrgs, readOrgs]) => { @@ -423,7 +426,7 @@ export const updateAssignmentForUser = async ( userUid: string, administrationId: string, administrationData: IAdministration, - transaction: Transaction + transaction: Transaction, ) => { const db = getFirestore(); const userRef = db.collection("users").doc(userUid); @@ -449,7 +452,7 @@ export const updateAssignmentForUser = async ( for (const orgName of ORG_NAMES) { usersAssigningOrgs[orgName] = _intersection( userOrgs[orgName]?.current, - assigningOrgs[orgName] + assigningOrgs[orgName], ); } @@ -466,13 +469,13 @@ export const updateAssignmentForUser = async ( const administrationAssessments = _get( administrationData, "assessments", - [] + [], ); // Initialize updatedAssessments by preserving any existingAssessments that // have been started. const updatedAssessments = existingAssessments.filter( - (assessment) => assessment.startedOn || assessment.runId + (assessment) => assessment.startedOn || assessment.runId, ); // Then iterate through the administration assessments and add any that @@ -493,7 +496,7 @@ export const updateAssignmentForUser = async ( // ``optional`` condition and update the assessment's optional // parameter. const assessmentIdx = updatedAssessments.findIndex( - (a) => a.taskId === _assessment.taskId + (a) => a.taskId === _assessment.taskId, ); let isOptional: boolean = false; @@ -595,7 +598,7 @@ export const updateAssignmentForUser = async ( assignmentPath: assignmentRef.path, originalSummary: summarizeAssessmentsForLog(updatedAssessments), cleanedSummary: summarizeAssessmentsForLog(cleanedAssessments), - } + }, ); } @@ -621,7 +624,7 @@ export const updateAssignmentForUser = async ( return [assignmentRef, assignmentData] as [ DocumentReference, - DocumentData + DocumentData, ]; } else { return [assignmentRef, undefined]; @@ -631,7 +634,7 @@ export const updateAssignmentForUser = async ( userUid, administrationId, administrationData, - transaction + transaction, ); } }; @@ -653,7 +656,7 @@ export const updateAssignmentForUsers = async ( users: string[], administrationId: string, administrationData: IAdministration, - transaction: Transaction + transaction: Transaction, ) => { const assignments = await Promise.all( _map(users, (user) => @@ -661,17 +664,20 @@ export const updateAssignmentForUsers = async ( user, administrationId, administrationData, - transaction - ) - ) + transaction, + ), + ), ); return _map(assignments, ([assignmentRef, assignmentData]) => { if (assignmentRef && assignmentData) { + const cleanedAssignmentData = removeUndefinedFields(assignmentData); logger.info(`Updating or creating assignment ${assignmentRef.path}`, { - assignmentSummary: summarizeAssignmentForLog(assignmentData), + assignmentSummary: summarizeAssignmentForLog(cleanedAssignmentData), + }); + return transaction.set(assignmentRef, cleanedAssignmentData, { + merge: true, }); - return transaction.set(assignmentRef, assignmentData, { merge: true }); } else if (assignmentRef) { return transaction.delete(assignmentRef); } else { diff --git a/functions/levante-admin/src/upsertAdministration.ts b/functions/levante-admin/src/upsertAdministration.ts index a2b0e04..34900cb 100644 --- a/functions/levante-admin/src/upsertAdministration.ts +++ b/functions/levante-admin/src/upsertAdministration.ts @@ -8,6 +8,7 @@ import { HttpsError } from "firebase-functions/v2/https"; import { logger } from "firebase-functions/v2"; import type { IAssessment, IOrgsList } from "./interfaces.js"; // Assuming necessary types/helpers are in common import type { Class, Group, School } from "../firestore-schema.js"; +import { removeUndefinedFields } from "./utils/utils.js"; interface UpsertAdministrationData { name: string; @@ -51,9 +52,42 @@ interface IAdministrationDoc { creatorName: string; } +const normalizeIdList = (ids: unknown): string[] => { + if (!Array.isArray(ids)) return []; + return [...new Set(ids)] + .filter((id): id is string => typeof id === "string") + .map((id) => id.trim()) + .filter((id) => id.length > 0); +}; + +const normalizeOrgs = (orgs?: IOrgsList): Required => { + return { + districts: normalizeIdList(orgs?.districts), + schools: normalizeIdList(orgs?.schools), + classes: normalizeIdList(orgs?.classes), + groups: normalizeIdList(orgs?.groups), + }; +}; + +const normalizeAssessments = (assessments: IAssessment[]): IAssessment[] => { + return assessments.map((assessment) => { + const cleanedAssessment = removeUndefinedFields(assessment) as IAssessment; + const params = + cleanedAssessment.params && + typeof cleanedAssessment.params === "object" && + !Array.isArray(cleanedAssessment.params) + ? cleanedAssessment.params + : {}; + return { + ...cleanedAssessment, + params, + }; + }); +}; + export const upsertAdministrationHandler = async ( callerAdminUid: string, - data: UpsertAdministrationData + data: UpsertAdministrationData, ) => { logger.info("Administration upsert started", { callerUid: callerAdminUid }); const db = getFirestore(); @@ -67,12 +101,7 @@ export const upsertAdministrationHandler = async ( dateOpen, dateClose, sequential = true, - orgs = { - districts: [], - schools: [], - classes: [], - groups: [], - }, + orgs, tags = [], administrationId, isTestData = false, @@ -80,16 +109,41 @@ export const upsertAdministrationHandler = async ( creatorName, } = data as UpsertAdministrationData; + const normalizedOrgs = normalizeOrgs(orgs); + const rawAssessments = Array.isArray(assessments) ? assessments : []; + const cleanedAssessments = normalizeAssessments( + removeUndefinedFields(rawAssessments) as IAssessment[], + ); + const cleanedLegal = removeUndefinedFields(legal ?? {}); + const cleanedTags = normalizeIdList(tags); + if ( !name || - !assessments || - !Array.isArray(assessments) || + !cleanedAssessments || + !Array.isArray(cleanedAssessments) || + cleanedAssessments.length === 0 || !dateOpen || !dateClose ) { throw new HttpsError( "invalid-argument", - "Missing required fields: name, assessments, dateOpen, dateClose." + "Missing required fields: name, assessments, dateOpen, dateClose.", + ); + } + + const hasInvalidAssessment = cleanedAssessments.some( + (assessment) => + typeof assessment.taskId !== "string" || + !assessment.taskId.trim() || + typeof assessment.variantId !== "string" || + !assessment.variantId.trim() || + typeof assessment.variantName !== "string" || + !assessment.variantName.trim(), + ); + if (hasInvalidAssessment) { + throw new HttpsError( + "invalid-argument", + "Assessments must include taskId, variantId, and variantName.", ); } @@ -98,20 +152,26 @@ export const upsertAdministrationHandler = async ( try { const dateOpenObj = new Date(dateOpen); const dateCloseObj = new Date(dateClose); + if ( + Number.isNaN(dateOpenObj.getTime()) || + Number.isNaN(dateCloseObj.getTime()) + ) { + throw new Error("Invalid date"); + } dateOpenedTs = Timestamp.fromDate(dateOpenObj); dateClosedTs = Timestamp.fromDate(dateCloseObj); } catch (e: unknown) { throw new HttpsError( "invalid-argument", - "Invalid date format for dateOpen or dateClose. Use ISO 8601 format." + "Invalid date format for dateOpen or dateClose. Use ISO 8601 format.", ); } if (dateClosedTs.toMillis() < dateOpenedTs.toMillis()) { throw new HttpsError( "invalid-argument", - `The end date cannot be before the start date: ${dateClose} < ${dateOpen}` + `The end date cannot be before the start date: ${dateClose} < ${dateOpen}`, ); } @@ -131,46 +191,47 @@ export const upsertAdministrationHandler = async ( if (!existingDoc.exists) { throw new HttpsError( "not-found", - `Administration with ID ${administrationId} not found for update.` + `Administration with ID ${administrationId} not found for update.`, ); } + const existingData = existingDoc.data() as Partial; // Prepare data for update (merge: true will handle partial updates) - const updateData: Partial = { + const updateData: Partial = removeUndefinedFields({ // Use Partial for updates name, publicName: publicName ?? name, normalizedName, - // createdBy should not be updated - groups: orgs.groups ?? [], - classes: orgs.classes ?? [], - schools: orgs.schools ?? [], - districts: orgs.districts ?? [], + createdBy: existingData.createdBy ?? callerAdminUid, + groups: normalizedOrgs.groups, + classes: normalizedOrgs.classes, + schools: normalizedOrgs.schools, + districts: normalizedOrgs.districts, // dateCreated should not be updated dateOpened: dateOpenedTs, dateClosed: dateClosedTs, - assessments: assessments, + assessments: cleanedAssessments, sequential: sequential, - tags: tags, - legal: legal, + tags: cleanedTags, + legal: cleanedLegal, testData: isTestData ?? false, // Explicitly construct org lists for update readOrgs: { // Re-enabled - districts: orgs.districts ?? [], - schools: orgs.schools ?? [], - classes: orgs.classes ?? [], - groups: orgs.groups ?? [], + districts: normalizedOrgs.districts, + schools: normalizedOrgs.schools, + classes: normalizedOrgs.classes, + groups: normalizedOrgs.groups, }, minimalOrgs: { // Re-enabled - districts: orgs.districts ?? [], - schools: orgs.schools ?? [], - classes: orgs.classes ?? [], - groups: orgs.groups ?? [], + districts: normalizedOrgs.districts, + schools: normalizedOrgs.schools, + classes: normalizedOrgs.classes, + groups: normalizedOrgs.groups, }, updatedAt: FieldValue.serverTimestamp() as Timestamp, - }; + }); // --- Write 1 (Update Path) --- Update administration doc using transaction.update() transaction.update(administrationDocRef, updateData); // Switched from set with merge to update @@ -186,30 +247,30 @@ export const upsertAdministrationHandler = async ( const siteId = data.siteId; // Prepare Administration Data for creation - const administrationData: IAdministrationDoc = { + const administrationData: IAdministrationDoc = removeUndefinedFields({ name, publicName: publicName ?? name, normalizedName, createdBy: callerAdminUid, - creatorName: creatorName, - groups: orgs.groups ?? [], - classes: orgs.classes ?? [], - schools: orgs.schools ?? [], - districts: orgs.districts ?? [], + creatorName: creatorName ?? "", + groups: normalizedOrgs.groups, + classes: normalizedOrgs.classes, + schools: normalizedOrgs.schools, + districts: normalizedOrgs.districts, dateCreated: FieldValue.serverTimestamp() as Timestamp, dateOpened: dateOpenedTs, dateClosed: dateClosedTs, - assessments: assessments, + assessments: cleanedAssessments, sequential: sequential, - tags: tags, - legal: legal, + tags: cleanedTags, + legal: cleanedLegal, testData: isTestData ?? false, - readOrgs: orgs, - minimalOrgs: orgs, + readOrgs: normalizedOrgs, + minimalOrgs: normalizedOrgs, siteId, createdAt: FieldValue.serverTimestamp() as Timestamp, updatedAt: FieldValue.serverTimestamp() as Timestamp, - }; + }); // --- Write 1 (Create Path) --- Create administration doc transaction.set(administrationDocRef, administrationData); // Use set without merge for creation @@ -218,13 +279,13 @@ export const upsertAdministrationHandler = async ( // --- Write 2 (Create Path) --- Update user if they exist transaction.update(userDocRef, { "adminData.administrationsCreated": FieldValue.arrayUnion( - administrationDocRef.id + administrationDocRef.id, ), }); } else { // Log if user doc doesn't exist, but don't throw error logger.warn( - `User document ${callerAdminUid} not found. Cannot add administration ${administrationDocRef.id} to created list.` + `User document ${callerAdminUid} not found. Cannot add administration ${administrationDocRef.id} to created list.`, ); } } @@ -245,7 +306,7 @@ export const upsertAdministrationHandler = async ( } throw new HttpsError( "internal", - `Failed to upsert administration: ${error.message}` + `Failed to upsert administration: ${error.message}`, ); } }; diff --git a/functions/levante-admin/src/utils/utils.ts b/functions/levante-admin/src/utils/utils.ts index 4f34529..cfed98f 100644 --- a/functions/levante-admin/src/utils/utils.ts +++ b/functions/levante-admin/src/utils/utils.ts @@ -52,7 +52,7 @@ export const pluralizeFirestoreCollection = (singular: string) => { if (plural) return plural; throw new Error( - `There is no plural Firestore collection for the ${singular}` + `There is no plural Firestore collection for the ${singular}`, ); }; @@ -196,7 +196,9 @@ export const doesDocExist = async (docRef, transaction) => { */ export const removeUndefinedFields = (obj: any): any => { if (Array.isArray(obj)) { - return _without(obj, undefined); + return obj + .map((value) => removeUndefinedFields(value)) + .filter((value) => value !== undefined); } else if (obj && typeof obj === "object") { return Object.entries(obj).reduce((acc, [key, value]) => { const cleanedValue = removeUndefinedFields(value); @@ -217,7 +219,7 @@ export const removeUndefinedFields = (obj: any): any => { * @returns {Date} The parsed Date instance. */ export const parseTimestamp = ( - timestamp: Date | Timestamp | undefined | null + timestamp: Date | Timestamp | undefined | null, ): Date => { if (!timestamp) return new Date(NaN); @@ -228,8 +230,8 @@ export const parseTimestamp = ( return new Date( new Timestamp( (timestamp as any)._seconds, - (timestamp as any)._nanoseconds - ).toDate() + (timestamp as any)._nanoseconds, + ).toDate(), ); } From 7968aafa66339d2ac6131cdf212e783a2dfdf4db Mon Sep 17 00:00:00 2001 From: digital-pro Date: Mon, 9 Feb 2026 17:14:50 -0800 Subject: [PATCH 2/2] revert non-essential formatting churn in assignment fix Keep only the functional disappearing-assignment hardening changes and drop formatting-only edits from touched files. Co-authored-by: Cursor --- .../src/assignments/assignment-utils.ts | 64 +++++++++---------- .../levante-admin/src/upsertAdministration.ts | 22 +++---- functions/levante-admin/src/utils/utils.ts | 8 +-- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/functions/levante-admin/src/assignments/assignment-utils.ts b/functions/levante-admin/src/assignments/assignment-utils.ts index 2e69c39..1523e0f 100644 --- a/functions/levante-admin/src/assignments/assignment-utils.ts +++ b/functions/levante-admin/src/assignments/assignment-utils.ts @@ -47,7 +47,7 @@ import { const removeAssignmentFromUser = async ( userUid: string, administrationId: string, - transaction: Transaction, + transaction: Transaction ) => { const db = getFirestore(); const assignmentRef = db @@ -71,7 +71,7 @@ const removeAssignmentFromUser = async ( export const removeAssignmentFromUsers = async ( users: string[], administrationId: string, - transaction: Transaction, + transaction: Transaction ) => { if (!users.length) { return []; @@ -83,8 +83,8 @@ export const removeAssignmentFromUsers = async ( return Promise.all( _map(users, (user) => - removeAssignmentFromUser(user, administrationId, transaction), - ), + removeAssignmentFromUser(user, administrationId, transaction) + ) ); }; @@ -101,7 +101,7 @@ const prepareNewAssignment = async ( userUid: string, administrationId: string, administrationData: IAdministration, - transaction: Transaction, + transaction: Transaction ) => { const db = getFirestore(); const userRef = db.collection("users").doc(userUid); @@ -118,7 +118,7 @@ const prepareNewAssignment = async ( for (const orgName of ORG_NAMES) { usersAssigningOrgs[orgName] = _intersection( userOrgs[orgName]?.current, - assigningOrgs[orgName], + assigningOrgs[orgName] ); } @@ -225,7 +225,7 @@ const prepareNewAssignment = async ( assignmentPath: assignmentRef.path, originalSummary: summarizeAssessmentsForLog(assessments), cleanedSummary: summarizeAssessmentsForLog(cleanedAssessments), - }, + } ); } @@ -271,7 +271,7 @@ export const addAssignmentToUsers = async ( users: string[], administrationId: string, administrationData: IAdministration, - transaction: Transaction, + transaction: Transaction ) => { console.log("hit addAssignmentToUsers"); const assignments = await Promise.all( @@ -280,9 +280,9 @@ export const addAssignmentToUsers = async ( user, administrationId, administrationData, - transaction, - ), - ), + transaction + ) + ) ); return _map(assignments, ([assignmentRef, assignmentData]) => { @@ -315,7 +315,7 @@ export const removeOrgsFromAssignment = async ( userUid: string, administrationId: string, orgsToRemove: IOrgsList, - transaction: Transaction, + transaction: Transaction ) => { const db = getFirestore(); const userDocRef = db.collection("users").doc(userUid); @@ -328,14 +328,14 @@ export const removeOrgsFromAssignment = async ( for (const orgName of Object.keys(assigningOrgs)) { assigningOrgs[orgName] = _without( assigningOrgs[orgName], - ...(orgsToRemove[orgName] ?? []), + ...(orgsToRemove[orgName] ?? []) ); } const numRemainingAssigningOrgs = _reduce( assigningOrgs, (sum, value) => sum + value.length, - 0, + 0 ); if (numRemainingAssigningOrgs > 0) { @@ -343,13 +343,13 @@ export const removeOrgsFromAssignment = async ( return [assignmentRef, assigningOrgs, readOrgs] as [ DocumentReference, IOrgsList | undefined, - IOrgsList | undefined, + IOrgsList | undefined ]; } else { return [assignmentRef, undefined, undefined] as [ DocumentReference, IOrgsList | undefined, - IOrgsList | undefined, + IOrgsList | undefined ]; } } else { @@ -370,7 +370,7 @@ export const removeOrgsFromAssignments = async ( users: string[], administrationIds: string[], orgsToRemove: IOrgsList, - transaction: Transaction, + transaction: Transaction ) => { const assignments = await Promise.all( _flatten( @@ -380,11 +380,11 @@ export const removeOrgsFromAssignments = async ( user, administrationId, orgsToRemove, - transaction, + transaction ); }); - }), - ), + }) + ) ); return _map(assignments, ([assignmentRef, assigningOrgs, readOrgs]) => { @@ -426,7 +426,7 @@ export const updateAssignmentForUser = async ( userUid: string, administrationId: string, administrationData: IAdministration, - transaction: Transaction, + transaction: Transaction ) => { const db = getFirestore(); const userRef = db.collection("users").doc(userUid); @@ -452,7 +452,7 @@ export const updateAssignmentForUser = async ( for (const orgName of ORG_NAMES) { usersAssigningOrgs[orgName] = _intersection( userOrgs[orgName]?.current, - assigningOrgs[orgName], + assigningOrgs[orgName] ); } @@ -469,13 +469,13 @@ export const updateAssignmentForUser = async ( const administrationAssessments = _get( administrationData, "assessments", - [], + [] ); // Initialize updatedAssessments by preserving any existingAssessments that // have been started. const updatedAssessments = existingAssessments.filter( - (assessment) => assessment.startedOn || assessment.runId, + (assessment) => assessment.startedOn || assessment.runId ); // Then iterate through the administration assessments and add any that @@ -496,7 +496,7 @@ export const updateAssignmentForUser = async ( // ``optional`` condition and update the assessment's optional // parameter. const assessmentIdx = updatedAssessments.findIndex( - (a) => a.taskId === _assessment.taskId, + (a) => a.taskId === _assessment.taskId ); let isOptional: boolean = false; @@ -598,7 +598,7 @@ export const updateAssignmentForUser = async ( assignmentPath: assignmentRef.path, originalSummary: summarizeAssessmentsForLog(updatedAssessments), cleanedSummary: summarizeAssessmentsForLog(cleanedAssessments), - }, + } ); } @@ -624,7 +624,7 @@ export const updateAssignmentForUser = async ( return [assignmentRef, assignmentData] as [ DocumentReference, - DocumentData, + DocumentData ]; } else { return [assignmentRef, undefined]; @@ -634,7 +634,7 @@ export const updateAssignmentForUser = async ( userUid, administrationId, administrationData, - transaction, + transaction ); } }; @@ -656,7 +656,7 @@ export const updateAssignmentForUsers = async ( users: string[], administrationId: string, administrationData: IAdministration, - transaction: Transaction, + transaction: Transaction ) => { const assignments = await Promise.all( _map(users, (user) => @@ -664,9 +664,9 @@ export const updateAssignmentForUsers = async ( user, administrationId, administrationData, - transaction, - ), - ), + transaction + ) + ) ); return _map(assignments, ([assignmentRef, assignmentData]) => { diff --git a/functions/levante-admin/src/upsertAdministration.ts b/functions/levante-admin/src/upsertAdministration.ts index 34900cb..257395e 100644 --- a/functions/levante-admin/src/upsertAdministration.ts +++ b/functions/levante-admin/src/upsertAdministration.ts @@ -87,7 +87,7 @@ const normalizeAssessments = (assessments: IAssessment[]): IAssessment[] => { export const upsertAdministrationHandler = async ( callerAdminUid: string, - data: UpsertAdministrationData, + data: UpsertAdministrationData ) => { logger.info("Administration upsert started", { callerUid: callerAdminUid }); const db = getFirestore(); @@ -112,7 +112,7 @@ export const upsertAdministrationHandler = async ( const normalizedOrgs = normalizeOrgs(orgs); const rawAssessments = Array.isArray(assessments) ? assessments : []; const cleanedAssessments = normalizeAssessments( - removeUndefinedFields(rawAssessments) as IAssessment[], + removeUndefinedFields(rawAssessments) as IAssessment[] ); const cleanedLegal = removeUndefinedFields(legal ?? {}); const cleanedTags = normalizeIdList(tags); @@ -127,7 +127,7 @@ export const upsertAdministrationHandler = async ( ) { throw new HttpsError( "invalid-argument", - "Missing required fields: name, assessments, dateOpen, dateClose.", + "Missing required fields: name, assessments, dateOpen, dateClose." ); } @@ -138,12 +138,12 @@ export const upsertAdministrationHandler = async ( typeof assessment.variantId !== "string" || !assessment.variantId.trim() || typeof assessment.variantName !== "string" || - !assessment.variantName.trim(), + !assessment.variantName.trim() ); if (hasInvalidAssessment) { throw new HttpsError( "invalid-argument", - "Assessments must include taskId, variantId, and variantName.", + "Assessments must include taskId, variantId, and variantName." ); } @@ -164,14 +164,14 @@ export const upsertAdministrationHandler = async ( } catch (e: unknown) { throw new HttpsError( "invalid-argument", - "Invalid date format for dateOpen or dateClose. Use ISO 8601 format.", + "Invalid date format for dateOpen or dateClose. Use ISO 8601 format." ); } if (dateClosedTs.toMillis() < dateOpenedTs.toMillis()) { throw new HttpsError( "invalid-argument", - `The end date cannot be before the start date: ${dateClose} < ${dateOpen}`, + `The end date cannot be before the start date: ${dateClose} < ${dateOpen}` ); } @@ -191,7 +191,7 @@ export const upsertAdministrationHandler = async ( if (!existingDoc.exists) { throw new HttpsError( "not-found", - `Administration with ID ${administrationId} not found for update.`, + `Administration with ID ${administrationId} not found for update.` ); } const existingData = existingDoc.data() as Partial; @@ -279,13 +279,13 @@ export const upsertAdministrationHandler = async ( // --- Write 2 (Create Path) --- Update user if they exist transaction.update(userDocRef, { "adminData.administrationsCreated": FieldValue.arrayUnion( - administrationDocRef.id, + administrationDocRef.id ), }); } else { // Log if user doc doesn't exist, but don't throw error logger.warn( - `User document ${callerAdminUid} not found. Cannot add administration ${administrationDocRef.id} to created list.`, + `User document ${callerAdminUid} not found. Cannot add administration ${administrationDocRef.id} to created list.` ); } } @@ -306,7 +306,7 @@ export const upsertAdministrationHandler = async ( } throw new HttpsError( "internal", - `Failed to upsert administration: ${error.message}`, + `Failed to upsert administration: ${error.message}` ); } }; diff --git a/functions/levante-admin/src/utils/utils.ts b/functions/levante-admin/src/utils/utils.ts index cfed98f..d2ad7b6 100644 --- a/functions/levante-admin/src/utils/utils.ts +++ b/functions/levante-admin/src/utils/utils.ts @@ -52,7 +52,7 @@ export const pluralizeFirestoreCollection = (singular: string) => { if (plural) return plural; throw new Error( - `There is no plural Firestore collection for the ${singular}`, + `There is no plural Firestore collection for the ${singular}` ); }; @@ -219,7 +219,7 @@ export const removeUndefinedFields = (obj: any): any => { * @returns {Date} The parsed Date instance. */ export const parseTimestamp = ( - timestamp: Date | Timestamp | undefined | null, + timestamp: Date | Timestamp | undefined | null ): Date => { if (!timestamp) return new Date(NaN); @@ -230,8 +230,8 @@ export const parseTimestamp = ( return new Date( new Timestamp( (timestamp as any)._seconds, - (timestamp as any)._nanoseconds, - ).toDate(), + (timestamp as any)._nanoseconds + ).toDate() ); }