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 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
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 }) {
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue