1
0
Fork 0
polaris-task-force/src/lib/awards/index.ts
Z8MB1E 9323a7ce08 feat(notifications): notify users when awards are granted via profile edit
Add a Profiles afterChange hook that diffs progression.awards against previousDoc (row id, falling back to award+date composite key) and sends an award:granted notification including the grant reason and issuer. Register award:granted as a muteable notification type.
2026-08-27 14:01:16 -04:00

162 lines
5.1 KiB
TypeScript

import type { Award, Profile } from "@/payload-types";
import { notifyUser } from "@/lib/notifications";
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
/** 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<string, number> {
const counts = new Map<string, number>();
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<string, number>();
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<string | null> {
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<void> {
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);
}
}