From 5ee98d61da9aa68b40e8386c0fce2d1a2736fba2 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Tue, 11 Aug 2026 23:25:09 -0400 Subject: [PATCH] feat(users): add trusted role, preferences, and profile/account auto-provisioning - Add trusted role and isTrusted access helper - Add notification muting and display preferences to the user schema - Auto-create a profile and personal bank account when a user signs up - Add idempotent backfillProfiles seed script for existing members --- src/collections/users/Users.ts | 99 +++++++++++++++++++ src/lib/notifications/notificationTypes.ts | 13 +++ src/payload-types.ts | 38 +++++++- src/payload.config.ts | 4 + src/tools/seed/backfillProfiles.ts | 106 +++++++++++++++++++++ src/utils/access-control/isRole.ts | 4 + 6 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 src/lib/notifications/notificationTypes.ts create mode 100644 src/tools/seed/backfillProfiles.ts diff --git a/src/collections/users/Users.ts b/src/collections/users/Users.ts index eccbcd0..c8be969 100644 --- a/src/collections/users/Users.ts +++ b/src/collections/users/Users.ts @@ -1,7 +1,62 @@ import type { CollectionConfig } from "payload"; import { isAdmin, isDeveloper } from "@/utils/access-control/isRole"; +import { ensurePersonalAccount } from "@/lib/banking/index"; +import { MUTEABLE_NOTIFICATION_TYPES } from "@/lib/notifications/notificationTypes"; export const Users: CollectionConfig = { + hooks: { + afterChange: [ + async ({ req, doc, operation }) => { + if (operation !== "create") return doc; + + const payload = req.payload; + const userId = doc.id as number; + + try { + await ensurePersonalAccount(payload, userId); + } catch (e) { + payload.logger.error( + `[Users] Failed to create personal bank account for user ${userId}: ${e}`, + ); + } + + const ranks = await payload.find({ + collection: "ranks", + where: { + name: { + like: "Recruit", + }, + }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const recruit = (ranks as { docs: Array<{ id: number }> }).docs[0]; + + try { + await payload.create({ + collection: "profiles", + data: { + user: userId, + rank: recruit?.id ?? null, + progression: { + qualifications: [], + experience: 0, + awards: [], + } as any, + }, + overrideAccess: true, + }); + } catch (e) { + payload.logger.error( + `[Users] Failed to create profile for user ${userId}: ${e}`, + ); + } + + return doc; + }, + ], + }, slug: "users", access: { admin: isAdmin, @@ -93,6 +148,10 @@ export const Users: CollectionConfig = { label: "User", value: "user", }, + { + label: "Trusted", + value: "trusted", + }, { label: "Admin", value: "admin", @@ -110,5 +169,45 @@ export const Users: CollectionConfig = { : options; },*/ }, + { + name: "preferences", + label: "Preferences", + type: "group", + fields: [ + { + name: "notifications", + label: "Notifications", + type: "group", + fields: [ + { + name: "mutedAll", + label: "Mute all in-app notifications", + type: "checkbox", + defaultValue: false, + }, + { + name: "mutedTypes", + label: "Muted notification types", + type: "select", + hasMany: true, + options: MUTEABLE_NOTIFICATION_TYPES.map((t) => ({ label: t.label, value: t.value })), + }, + ], + }, + { + name: "display", + label: "Display", + type: "group", + fields: [ + { + name: "showCallsign", + label: "Show callsign (username) instead of display name in the sidebar", + type: "checkbox", + defaultValue: false, + }, + ], + }, + ], + }, ], }; diff --git a/src/lib/notifications/notificationTypes.ts b/src/lib/notifications/notificationTypes.ts new file mode 100644 index 0000000..af6191d --- /dev/null +++ b/src/lib/notifications/notificationTypes.ts @@ -0,0 +1,13 @@ +export const MUTEABLE_NOTIFICATION_TYPES = [ + { label: "Market offers", value: "market:offer" }, + { label: "Counteroffers", value: "market:counter" }, + { label: "Offer accepted", value: "market:accept" }, + { label: "Offer rejected", value: "market:reject" }, + { label: "Offer withdrawn", value: "market:withdrawn" }, + { label: "Negotiations closed", value: "market:closed" }, + { label: "Listing sold", value: "market:sold" }, + { label: "Listing expired", value: "market:expired" }, + { label: "Discord requests", value: "account:discord-request" }, +] as const; +export type MuteableNotificationType = (typeof MUTEABLE_NOTIFICATION_TYPES)[number]["value"]; +export const MUTEABLE_NOTIFICATION_VALUES = MUTEABLE_NOTIFICATION_TYPES.map((t) => t.value); diff --git a/src/payload-types.ts b/src/payload-types.ts index 2ae045b..fb20277 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1215,7 +1215,28 @@ export interface User { displayName: string; payloadDisplayName?: string | null; steamId: string; - roles?: ('guest' | 'user' | 'admin' | 'developer')[] | null; + roles?: ('guest' | 'user' | 'trusted' | 'admin' | 'developer')[] | null; + preferences?: { + notifications?: { + mutedAll?: boolean | null; + mutedTypes?: + | ( + | 'market:offer' + | 'market:counter' + | 'market:accept' + | 'market:reject' + | 'market:withdrawn' + | 'market:closed' + | 'market:sold' + | 'market:expired' + | 'account:discord-request' + )[] + | null; + }; + display?: { + showCallsign?: boolean | null; + }; + }; updatedAt: string; createdAt: string; email?: string | null; @@ -2485,6 +2506,21 @@ export interface UsersSelect { payloadDisplayName?: T; steamId?: T; roles?: T; + preferences?: + | T + | { + notifications?: + | T + | { + mutedAll?: T; + mutedTypes?: T; + }; + display?: + | T + | { + showCallsign?: T; + }; + }; updatedAt?: T; createdAt?: T; email?: T; diff --git a/src/payload.config.ts b/src/payload.config.ts index eee07ac..6314ae0 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -44,6 +44,9 @@ import { UserNotifications } from "@/collections/users/UserNotifications"; import { nodemailerAdapter } from "@payloadcms/email-nodemailer"; import nodemailer from "nodemailer"; +import { isTrusted, isDeveloper } from "@/utils/access-control/isRole"; // Used below for Users admin access + + const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); @@ -64,6 +67,7 @@ export default buildConfig({ }, ], }, + components: { beforeDashboard: ["@/components/static/BeforeDashboardText"], header: ["@/components/static/VersionOverlay"], diff --git a/src/tools/seed/backfillProfiles.ts b/src/tools/seed/backfillProfiles.ts new file mode 100644 index 0000000..ba9bb00 --- /dev/null +++ b/src/tools/seed/backfillProfiles.ts @@ -0,0 +1,106 @@ +import { getPayload } from "payload"; +import config from "@payload-config"; + +// Usage: `bun run src/tools/seed/backfillProfiles.ts`. +// Creates a profile for every user that doesn't have one yet. Idempotent. +export const backfillProfiles = async () => { + const payload = await getPayload({ config }); + + payload.logger.info("Backfilling missing profiles..."); + + // Resolve the Recruit rank the same way the Users.ts afterChange hook does. + const ranks = await payload.find({ + collection: "ranks", + where: { + name: { + like: "Recruit", + }, + }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + const recruitRankId = (ranks as { docs: Array<{ id: number }> }).docs[0]?.id ?? null; + payload.logger.info( + `Resolved Recruit rank: ${recruitRankId === null ? "none found (will use null)" : `id=${recruitRankId}`}`, + ); + + // Enumerate all users. + const usersResult = await payload.find({ + collection: "users", + limit: 500, + depth: 0, + overrideAccess: true, + }); + const users = usersResult.docs as Array<{ id: number; username: string }>; + payload.logger.info(`Found ${users.length} users.`); + + // Count existing profiles before we start. + const profilesBefore = await payload.find({ + collection: "profiles", + limit: 1, + depth: 0, + overrideAccess: true, + }); + const profilesBeforeCount = profilesBefore.totalDocs; + payload.logger.info(`Profiles before: ${profilesBeforeCount}`); + + let created = 0; + for (const user of users) { + // Check if this user already has a profile. + const existing = await payload.find({ + collection: "profiles", + where: { + user: { + equals: user.id, + }, + }, + limit: 1, + depth: 0, + overrideAccess: true, + }); + + if (existing.totalDocs > 0) { + continue; + } + + try { + await payload.create({ + collection: "profiles", + data: { + user: user.id, + rank: recruitRankId, + progression: { + qualifications: [], + experience: 0, + awards: [], + } as any, + }, + overrideAccess: true, + }); + created++; + payload.logger.info(`Created profile for user id=${user.id} username="${user.username}"`); + } catch (e) { + payload.logger.error( + `Failed to create profile for user id=${user.id} username="${user.username}": ${e}`, + ); + } + } + + // Count profiles after. + const profilesAfter = await payload.find({ + collection: "profiles", + limit: 1, + depth: 0, + overrideAccess: true, + }); + const profilesAfterCount = profilesAfter.totalDocs; + + payload.logger.info("--- backfill summary ---"); + payload.logger.info(`Users: ${users.length}`); + payload.logger.info(`Profiles before: ${profilesBeforeCount}`); + payload.logger.info(`Profiles created: ${created}`); + payload.logger.info(`Profiles after: ${profilesAfterCount}`); +}; + +await backfillProfiles(); \ No newline at end of file diff --git a/src/utils/access-control/isRole.ts b/src/utils/access-control/isRole.ts index 9a3cee0..5ac0c33 100644 --- a/src/utils/access-control/isRole.ts +++ b/src/utils/access-control/isRole.ts @@ -16,3 +16,7 @@ export function isUser({ req: { user } }: { req: PayloadRequest }) { export function isAdmin({ req: { user } }: { req: PayloadRequest }) { return hasRoles(["admin"], user); } + +export function isTrusted({ req: { user } }: { req: PayloadRequest }) { + return hasRoles(["trusted", "admin"], user); +}