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:
parent
35e1354043
commit
5ee98d61da
6 changed files with 263 additions and 1 deletions
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
13
src/lib/notifications/notificationTypes.ts
Normal file
13
src/lib/notifications/notificationTypes.ts
Normal 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);
|
||||
|
|
@ -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<T extends boolean = true> {
|
|||
payloadDisplayName?: T;
|
||||
steamId?: T;
|
||||
roles?: T;
|
||||
preferences?:
|
||||
| T
|
||||
| {
|
||||
notifications?:
|
||||
| T
|
||||
| {
|
||||
mutedAll?: T;
|
||||
mutedTypes?: T;
|
||||
};
|
||||
display?:
|
||||
| T
|
||||
| {
|
||||
showCallsign?: T;
|
||||
};
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
106
src/tools/seed/backfillProfiles.ts
Normal file
106
src/tools/seed/backfillProfiles.ts
Normal 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();
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue