import type { Award, Profile } from "@/payload-types"; import { notifyUser } from "@/lib/notifications"; type PayloadType = Awaited>; /** A single row in `profile.progression.awards`. */ export interface AwardGrantEntry { id?: string | null; award: number | Award; awardedBy?: number | null | Profile; awardedAt: string; awardReason?: string | null; } interface ProgressionDoc { progression?: { awards?: AwardGrantEntry[] | null } | null; } /** Extract the numeric award id from a grant entry, populated or not. */ function awardIdOf(entry: AwardGrantEntry): number | null { const award = entry.award; if (typeof award === "number") return award; if (award && typeof award.id === "number") return award.id; return null; } /** * Stable identity key for a grant row: the generated row id when present, * otherwise a composite of award id + issue date. The admin UI preserves row * ids when editing existing rows, so reason-only edits never count as new * grants; the composite fallback covers docs whose rows carry no id. */ function keyOf(entry: AwardGrantEntry): string { if (entry.id) return `row:${entry.id}`; return `aw:${awardIdOf(entry) ?? "?"}:${entry.awardedAt}`; } function countKeys(entries: AwardGrantEntry[]): Map { const counts = new Map(); for (const entry of entries) { const key = keyOf(entry); counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; } /** * Return the grant rows present in `after` but not in `before`. Multiset * diff — re-granting the same award twice is two grants. */ export function diffGrantedAwards( before: ProgressionDoc | null | undefined, after: ProgressionDoc | null | undefined, ): AwardGrantEntry[] { const beforeCounts = countKeys(before?.progression?.awards ?? []); const seen = new Map(); const granted: AwardGrantEntry[] = []; for (const entry of after?.progression?.awards ?? []) { const key = keyOf(entry); const seenCount = seen.get(key) ?? 0; seen.set(key, seenCount + 1); if (seenCount >= (beforeCounts.get(key) ?? 0)) granted.push(entry); } return granted; } /** Resolve the display name of the profile that issued a grant, if any. */ async function issuerLabel( payload: PayloadType, awardedBy: AwardGrantEntry["awardedBy"], ): Promise { let profileId: number | null = null; if (typeof awardedBy === "number") profileId = awardedBy; else if (awardedBy && typeof awardedBy === "object" && typeof awardedBy.id === "number") { profileId = awardedBy.id; } if (!profileId) return null; try { const issuerProfile = await payload.findByID({ collection: "profiles", id: profileId, depth: 1, overrideAccess: true, }); const userRef = (issuerProfile as unknown as { user?: number | { id?: number } | null }).user; const userId = typeof userRef === "object" ? userRef?.id : userRef; if (typeof userId !== "number") return null; const issuer = await payload.findByID({ collection: "users", id: userId, depth: 0, overrideAccess: true, }); const user = issuer as unknown as { displayName?: string | null; username?: string }; return user.displayName || user.username || null; } catch { return null; } } /** * Notify the profile owner about award rows added since the previous save. * Called from the Profiles afterChange hook; never throws — a notification * failure must not break the admin's save. */ export async function notifyAwardGrants( payload: PayloadType, previousDoc: Profile | null | undefined, doc: Profile, ): Promise { try { const newEntries = diffGrantedAwards(previousDoc, doc); if (newEntries.length === 0) return; const userId = typeof doc.user === "object" ? doc.user?.id : doc.user; if (typeof userId !== "number") return; const awardIds = [ ...new Set(newEntries.map(awardIdOf).filter((id): id is number => id !== null)), ]; let awards: Award[] = []; if (awardIds.length > 0) { const res = await payload.find({ collection: "awards", where: { id: { in: awardIds } }, depth: 0, overrideAccess: true, }); awards = res.docs; } const user = (await payload.findByID({ collection: "users", id: userId, depth: 0, overrideAccess: true, })) as unknown as { username?: string }; const link = user?.username ? `/profile/${user.username}` : undefined; for (const entry of newEntries) { const awardId = awardIdOf(entry); const award = awards.find((a) => a.id === awardId); const parts: string[] = []; if (entry.awardReason) parts.push(entry.awardReason); const issuer = await issuerLabel(payload, entry.awardedBy); parts.push(issuer ? `Issued by ${issuer}.` : "On behalf of the unit."); await notifyUser(payload, { userId, type: "award:granted", title: `Award received: ${award?.name ?? "an award"}`, message: parts.join(" "), link, }); } } catch (err) { console.error("[Awards] Failed to send award-grant notifications:", err); } }