diff --git a/src/collections/users/Profiles.ts b/src/collections/users/Profiles.ts index dade91f..ea0e2f2 100644 --- a/src/collections/users/Profiles.ts +++ b/src/collections/users/Profiles.ts @@ -1,6 +1,7 @@ import { CollectionConfig } from "payload"; import { Award } from "@/payload-types"; import { broadcastToUser } from "@/lib/realtime/bus"; +import { notifyAwardGrants } from "@/lib/awards"; import { requirePermission, hasPermission } from "@/utils/access-control/hasPermission"; export const Profiles: CollectionConfig = { @@ -32,6 +33,9 @@ export const Profiles: CollectionConfig = { broadcastToUser("profile-update", userId, { profileId: doc.id }); } }, + async ({ doc, previousDoc, req }) => { + await notifyAwardGrants(req.payload, previousDoc, doc); + }, ], }, fields: [ diff --git a/src/lib/awards/index.ts b/src/lib/awards/index.ts new file mode 100644 index 0000000..bc7fbc2 --- /dev/null +++ b/src/lib/awards/index.ts @@ -0,0 +1,162 @@ +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); + } +} diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts index 148fb51..0efe2fd 100644 --- a/src/lib/notifications/index.ts +++ b/src/lib/notifications/index.ts @@ -67,6 +67,8 @@ export function notificationLabel(type?: string | null): string { return "Listing sold"; case "market:expired": return "Listing expired"; + case "award:granted": + return "Award received"; case "account:discord-request": return "Discord request"; case "finance:deposit": diff --git a/src/lib/notifications/notificationTypes.ts b/src/lib/notifications/notificationTypes.ts index a60cb2a..85ef832 100644 --- a/src/lib/notifications/notificationTypes.ts +++ b/src/lib/notifications/notificationTypes.ts @@ -7,6 +7,7 @@ export const MUTEABLE_NOTIFICATION_TYPES = [ { label: "Negotiations closed", value: "market:closed" }, { label: "Listing sold", value: "market:sold" }, { label: "Listing expired", value: "market:expired" }, + { label: "Award received", value: "award:granted" }, { label: "Discord requests", value: "account:discord-request" }, { label: "Bank deposit", value: "finance:deposit" }, { label: "Bank withdrawal", value: "finance:withdraw" }, diff --git a/src/payload-types.ts b/src/payload-types.ts index 2a87460..69e345b 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1157,6 +1157,7 @@ export interface User { | 'market:closed' | 'market:sold' | 'market:expired' + | 'award:granted' | 'account:discord-request' | 'finance:deposit' | 'finance:withdraw' @@ -1187,6 +1188,7 @@ export interface User { | 'market:closed' | 'market:sold' | 'market:expired' + | 'award:granted' | 'account:discord-request' | 'finance:deposit' | 'finance:withdraw' diff --git a/tests/int/award-notifications.int.spec.ts b/tests/int/award-notifications.int.spec.ts new file mode 100644 index 0000000..4017351 --- /dev/null +++ b/tests/int/award-notifications.int.spec.ts @@ -0,0 +1,226 @@ +import { getPayload, Payload } from "payload"; +import config from "@/payload.config"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { Award, Profile, User } from "@/payload-types"; +import { AwardGrantEntry, diffGrantedAwards } from "@/lib/awards"; + +let payload: Payload; + +const RUN = `aw-${Date.now().toString(36)}`; + +function richText(text: string) { + return { + root: { + type: "root", + format: "" as const, + indent: 0, + version: 1, + children: [ + { + type: "paragraph", + format: "" as const, + indent: 0, + version: 1, + children: [ + { type: "text", format: 0, style: "", mode: "normal" as const, text, version: 1 }, + ], + direction: "ltr" as const, + }, + ], + direction: "ltr" as const, + }, + }; +} + +describe("Award grant notifications", () => { + let user: User; + let profileId: number; + let medalId: number; + let ribbonId: number; + + const countGrantNotifications = async (userId: number) => { + const res = await payload.find({ + collection: "user-notifications", + where: { user: { equals: userId }, type: { equals: "award:granted" } }, + limit: 50, + overrideAccess: true, + depth: 0, + }); + return res.docs as Array<{ + id: number; + title?: string | null; + message?: string | null; + link?: string | null; + }>; + }; + + const setAwards = async (awards: AwardGrantEntry[]) => { + await payload.update({ + collection: "profiles", + id: profileId, + data: { progression: { awards } }, + overrideAccess: true, + depth: 0, + }); + }; + + beforeAll(async () => { + const payloadConfig = await config; + payload = await getPayload({ config: payloadConfig }); + + const makeAward = async (name: string, type: "medal" | "ribbon"): Promise => { + const created = (await payload.create({ + collection: "awards", + data: { + name, + description: richText(`${RUN} test award`), + type, + }, + overrideAccess: true, + depth: 0, + })) as unknown as Award; + return created.id; + }; + + medalId = await makeAward(`${RUN} Test Medal`, "medal"); + ribbonId = await makeAward(`${RUN} Test Ribbon`, "ribbon"); + + user = (await payload.create({ + collection: "users", + data: { + username: `${RUN}-operator`, + discordUsername: `${RUN}-operator-discord`, + displayName: `${RUN} Operator`, + steamId: `7656119${Math.floor(Math.random() * 1e9)}`, + password: "Test123", + }, + overrideAccess: true, + depth: 0, + })) as unknown as User; + + // The Users afterChange hook auto-creates a profile for new users. + const profiles = await payload.find({ + collection: "profiles", + where: { user: { equals: user.id } }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + if (profiles.docs.length === 0) throw new Error("Test setup failed: profile was not auto-created."); + profileId = profiles.docs[0].id; + }); + + afterAll(async () => { + await payload.delete({ collection: "profiles", id: profileId, overrideAccess: true }).catch(() => {}); + await payload.delete({ collection: "users", id: user.id, overrideAccess: true }).catch(() => {}); + await payload.delete({ collection: "awards", id: medalId, overrideAccess: true }).catch(() => {}); + await payload.delete({ collection: "awards", id: ribbonId, overrideAccess: true }).catch(() => {}); + }); + + it("notifies the user when an award is granted", async () => { + await setAwards([{ award: medalId, awardedAt: new Date().toISOString() }]); + const docs = await countGrantNotifications(user.id); + expect(docs).toHaveLength(1); + expect(docs[0].title).toBe(`Award received: ${RUN} Test Medal`); + expect(docs[0].link).toBe(`/profile/${user.username}`); + }); + + it("does not notify when an existing entry is edited", async () => { + const current = await payload.findByID({ + collection: "profiles", + id: profileId, + depth: 0, + overrideAccess: true, + }); + const entries = (current as unknown as Profile).progression?.awards ?? []; + expect(entries).toHaveLength(1); + await setAwards([{ ...entries[0], awardReason: "For exceptional service." }]); + expect(await countGrantNotifications(user.id)).toHaveLength(1); + }); + + it("does not notify when an award is revoked", async () => { + await setAwards([]); + expect(await countGrantNotifications(user.id)).toHaveLength(1); + }); + + it("notifies again when a revoked award is re-granted", async () => { + await setAwards([ + { award: medalId, awardedAt: new Date().toISOString(), awardReason: "For exceptional service." }, + ]); + expect(await countGrantNotifications(user.id)).toHaveLength(2); + }); + + it("notifies once per newly granted award in a single update", async () => { + const current = await payload.findByID({ + collection: "profiles", + id: profileId, + depth: 0, + overrideAccess: true, + }); + const existing = (current as unknown as Profile).progression?.awards ?? []; + await setAwards([ + ...existing, + { award: ribbonId, awardedAt: new Date().toISOString() }, + { award: medalId, awardedAt: new Date(Date.now() + 60_000).toISOString() }, + ]); + expect(await countGrantNotifications(user.id)).toHaveLength(4); + }); + + it("includes the reason and unit attribution in the message", async () => { + const docs = await countGrantNotifications(user.id); + expect(docs.some((d) => d.message?.includes("For exceptional service."))).toBe(true); + // No issuer on any of these grants, so they are attributed to the unit. + expect(docs.every((d) => d.message?.includes("On behalf of the unit."))).toBe(true); + }); +}); + +describe("diffGrantedAwards (pure)", () => { + const entry = (over: Partial<{ id?: string; awardId?: number; at?: string }>) => ({ + id: over.id, + award: over.awardId ?? 1, + awardedAt: over.at ?? "2026-01-01T00:00:00.000Z", + }); + + it("returns empty when nothing changed", () => { + const doc = { progression: { awards: [entry({ id: "a" })] } }; + expect(diffGrantedAwards(doc, doc)).toHaveLength(0); + }); + + it("detects new rows by row id", () => { + const before = { progression: { awards: [entry({ id: "a" })] } }; + const after = { progression: { awards: [entry({ id: "a" }), entry({ id: "b", awardId: 2 })] } }; + expect(diffGrantedAwards(before, after).map((e) => e.id)).toEqual(["b"]); + }); + + it("falls back to award+date identity when rows have no id", () => { + const before = { progression: { awards: [entry({ awardId: 1 })] } }; + // Same award + date, still no ids → not a new grant (reason-only edits keep the date). + const after = { progression: { awards: [entry({ awardId: 1 })] } }; + expect(diffGrantedAwards(before, after)).toHaveLength(0); + // New issue date → new grant. + const regranted = { progression: { awards: [entry({ awardId: 1, at: "2026-02-01T00:00:00.000Z" })] } }; + expect(diffGrantedAwards(before, regranted)).toHaveLength(1); + }); + + it("handles the same award granted twice (multiset)", () => { + const before = { + progression: { awards: [entry({ id: "a", awardId: 1 }), entry({ id: "b", awardId: 1 })] }, + }; + const after = { + progression: { + awards: [ + entry({ id: "a", awardId: 1 }), + entry({ id: "b", awardId: 1 }), + entry({ id: "c", awardId: 1 }), + ], + }, + }; + expect(diffGrantedAwards(before, after).map((e) => e.id)).toEqual(["c"]); + }); + + it("treats a created profile with awards as grants", () => { + const after = { progression: { awards: [entry({ id: "a" })] } }; + expect(diffGrantedAwards(null, after)).toHaveLength(1); + }); +});