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.
This commit is contained in:
parent
d740d43800
commit
9323a7ce08
6 changed files with 397 additions and 0 deletions
|
|
@ -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: [
|
||||
|
|
|
|||
162
src/lib/awards/index.ts
Normal file
162
src/lib/awards/index.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
226
tests/int/award-notifications.int.spec.ts
Normal file
226
tests/int/award-notifications.int.spec.ts
Normal file
|
|
@ -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<number> => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue