1
0
Fork 0

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
This commit is contained in:
Jason Fraley 2026-08-11 23:25:09 -04:00
parent 35e1354043
commit 5ee98d61da
6 changed files with 263 additions and 1 deletions

View file

@ -1,7 +1,62 @@
import type { CollectionConfig } from "payload"; import type { CollectionConfig } from "payload";
import { isAdmin, isDeveloper } from "@/utils/access-control/isRole"; 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 = { 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", slug: "users",
access: { access: {
admin: isAdmin, admin: isAdmin,
@ -93,6 +148,10 @@ export const Users: CollectionConfig = {
label: "User", label: "User",
value: "user", value: "user",
}, },
{
label: "Trusted",
value: "trusted",
},
{ {
label: "Admin", label: "Admin",
value: "admin", value: "admin",
@ -110,5 +169,45 @@ export const Users: CollectionConfig = {
: options; : 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,
},
],
},
],
},
], ],
}; };

View file

@ -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);

View file

@ -1215,7 +1215,28 @@ export interface User {
displayName: string; displayName: string;
payloadDisplayName?: string | null; payloadDisplayName?: string | null;
steamId: string; 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; updatedAt: string;
createdAt: string; createdAt: string;
email?: string | null; email?: string | null;
@ -2485,6 +2506,21 @@ export interface UsersSelect<T extends boolean = true> {
payloadDisplayName?: T; payloadDisplayName?: T;
steamId?: T; steamId?: T;
roles?: T; roles?: T;
preferences?:
| T
| {
notifications?:
| T
| {
mutedAll?: T;
mutedTypes?: T;
};
display?:
| T
| {
showCallsign?: T;
};
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
email?: T; email?: T;

View file

@ -44,6 +44,9 @@ import { UserNotifications } from "@/collections/users/UserNotifications";
import { nodemailerAdapter } from "@payloadcms/email-nodemailer"; import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
import nodemailer from "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 filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename); const dirname = path.dirname(filename);
@ -64,6 +67,7 @@ export default buildConfig({
}, },
], ],
}, },
components: { components: {
beforeDashboard: ["@/components/static/BeforeDashboardText"], beforeDashboard: ["@/components/static/BeforeDashboardText"],
header: ["@/components/static/VersionOverlay"], header: ["@/components/static/VersionOverlay"],

View file

@ -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();

View file

@ -16,3 +16,7 @@ export function isUser({ req: { user } }: { req: PayloadRequest }) {
export function isAdmin({ req: { user } }: { req: PayloadRequest }) { export function isAdmin({ req: { user } }: { req: PayloadRequest }) {
return hasRoles(["admin"], user); return hasRoles(["admin"], user);
} }
export function isTrusted({ req: { user } }: { req: PayloadRequest }) {
return hasRoles(["trusted", "admin"], user);
}