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.
162 lines
5.1 KiB
TypeScript
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);
|
|
}
|
|
}
|