1
0
Fork 0

feat(users): add discordId and Discord notification preferences

This commit is contained in:
Jason Fraley 2026-08-12 12:38:43 -04:00
parent bbfbd5966c
commit 83879687ae
5 changed files with 123 additions and 22 deletions

View file

@ -195,6 +195,8 @@ export async function updatePreferences(input: {
mutedAll: boolean; mutedAll: boolean;
mutedTypes: string[]; mutedTypes: string[];
showCallsign: boolean; showCallsign: boolean;
discordEnabled: boolean;
discordMutedTypes: string[];
}): Promise<ActionResult> { }): Promise<ActionResult> {
try { try {
const { payload, user } = await authenticate(); const { payload, user } = await authenticate();
@ -203,6 +205,9 @@ export async function updatePreferences(input: {
const mutedTypes = input.mutedTypes.filter((t): t is MuteableNotificationType => const mutedTypes = input.mutedTypes.filter((t): t is MuteableNotificationType =>
(MUTEABLE_NOTIFICATION_VALUES as readonly string[]).includes(t), (MUTEABLE_NOTIFICATION_VALUES as readonly string[]).includes(t),
); );
const discordMutedTypes = input.discordMutedTypes.filter((t): t is MuteableNotificationType =>
(MUTEABLE_NOTIFICATION_VALUES as readonly string[]).includes(t),
);
await payload.update({ await payload.update({
collection: "users", collection: "users",
@ -211,6 +216,10 @@ export async function updatePreferences(input: {
preferences: { preferences: {
notifications: { mutedAll: input.mutedAll, mutedTypes }, notifications: { mutedAll: input.mutedAll, mutedTypes },
display: { showCallsign: input.showCallsign }, display: { showCallsign: input.showCallsign },
discord: {
enabled: input.discordEnabled,
mutedTypes: discordMutedTypes,
},
}, },
}, },
overrideAccess: true, overrideAccess: true,

View file

@ -3,13 +3,7 @@ import { getPayload } from "payload";
import { headers as nextHeaders } from "next/headers"; import { headers as nextHeaders } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { UserCogIcon } from "lucide-react"; import { UserCogIcon } from "lucide-react";
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { AccountProfileForm } from "@/components/frontend/account/AccountProfileForm"; import { AccountProfileForm } from "@/components/frontend/account/AccountProfileForm";
import { ChangePasswordForm } from "@/components/frontend/account/ChangePasswordForm"; import { ChangePasswordForm } from "@/components/frontend/account/ChangePasswordForm";
import { DiscordUsernameRequestForm } from "@/components/frontend/account/DiscordUsernameRequestForm"; import { DiscordUsernameRequestForm } from "@/components/frontend/account/DiscordUsernameRequestForm";
@ -38,6 +32,7 @@ export default async function AccountPage() {
}); });
const notifications = fullUser.preferences?.notifications; const notifications = fullUser.preferences?.notifications;
const discord = fullUser.preferences?.discord;
return ( return (
<div className="p-5 flex flex-col gap-6"> <div className="p-5 flex flex-col gap-6">
@ -82,9 +77,7 @@ export default async function AccountPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<DiscordUsernameRequestForm <DiscordUsernameRequestForm currentDiscordUsername={fullUser.discordUsername ?? ""} />
currentDiscordUsername={fullUser.discordUsername ?? ""}
/>
</CardContent> </CardContent>
</Card> </Card>
@ -98,6 +91,8 @@ export default async function AccountPage() {
mutedAll={notifications?.mutedAll ?? false} mutedAll={notifications?.mutedAll ?? false}
mutedTypes={notifications?.mutedTypes ?? []} mutedTypes={notifications?.mutedTypes ?? []}
showCallsign={fullUser.preferences?.display?.showCallsign ?? false} showCallsign={fullUser.preferences?.display?.showCallsign ?? false}
discordEnabled={discord?.enabled ?? false}
discordMutedTypes={discord?.mutedTypes ?? []}
/> />
</CardContent> </CardContent>
</Card> </Card>

View file

@ -48,9 +48,7 @@ export const Users: CollectionConfig = {
overrideAccess: true, overrideAccess: true,
}); });
} catch (e) { } catch (e) {
payload.logger.error( payload.logger.error(`[Users] Failed to create profile for user ${userId}: ${e}`);
`[Users] Failed to create profile for user ${userId}: ${e}`,
);
} }
return doc; return doc;
@ -102,6 +100,13 @@ export const Users: CollectionConfig = {
required: true, required: true,
unique: true, unique: true,
}, },
{
name: "discordId",
type: "text",
label: "Discord User ID",
unique: true,
index: true,
},
{ {
name: "displayName", name: "displayName",
type: "text", type: "text",
@ -207,6 +212,26 @@ export const Users: CollectionConfig = {
}, },
], ],
}, },
{
name: "discord",
label: "Discord",
type: "group",
fields: [
{
name: "enabled",
label: "Discord 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 })),
},
],
},
], ],
}, },
], ],

View file

@ -12,17 +12,23 @@ interface PreferencesFormProps {
mutedAll: boolean; mutedAll: boolean;
mutedTypes: string[]; mutedTypes: string[];
showCallsign: boolean; showCallsign: boolean;
discordEnabled: boolean;
discordMutedTypes: string[];
} }
export function PreferencesForm({ export function PreferencesForm({
mutedAll: initialMutedAll, mutedAll: initialMutedAll,
mutedTypes: initialMutedTypes, mutedTypes: initialMutedTypes,
showCallsign: initialShowCallsign, showCallsign: initialShowCallsign,
discordEnabled: initialDiscordEnabled,
discordMutedTypes: initialDiscordMutedTypes,
}: PreferencesFormProps) { }: PreferencesFormProps) {
const router = useRouter(); const router = useRouter();
const [mutedAll, setMutedAll] = useState(initialMutedAll); const [mutedAll, setMutedAll] = useState(initialMutedAll);
const [mutedTypes, setMutedTypes] = useState<string[]>(initialMutedTypes); const [mutedTypes, setMutedTypes] = useState<string[]>(initialMutedTypes);
const [showCallsign, setShowCallsign] = useState(initialShowCallsign); const [showCallsign, setShowCallsign] = useState(initialShowCallsign);
const [discordEnabled, setDiscordEnabled] = useState(initialDiscordEnabled);
const [discordMutedTypes, setDiscordMutedTypes] = useState<string[]>(initialDiscordMutedTypes);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null); const [success, setSuccess] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -33,6 +39,12 @@ export function PreferencesForm({
); );
}; };
const toggleDiscordType = (value: string, checked: boolean) => {
setDiscordMutedTypes((prev) =>
checked ? [...new Set([...prev, value])] : prev.filter((t) => t !== value),
);
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
@ -40,7 +52,13 @@ export function PreferencesForm({
setSuccess(null); setSuccess(null);
try { try {
const res = await updatePreferences({ mutedAll, mutedTypes, showCallsign }); const res = await updatePreferences({
mutedAll,
mutedTypes,
showCallsign,
discordEnabled,
discordMutedTypes,
});
if (!res.success) { if (!res.success) {
setError(res.error ?? "Failed to save preferences."); setError(res.error ?? "Failed to save preferences.");
return; return;
@ -59,10 +77,7 @@ export function PreferencesForm({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Notifications</h3> <h3 className="text-sm font-medium">Notifications</h3>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<Checkbox <Checkbox checked={mutedAll} onCheckedChange={(c) => setMutedAll(c === true)} />
checked={mutedAll}
onCheckedChange={(c) => setMutedAll(c === true)}
/>
Mute all in-app notifications Mute all in-app notifications
</label> </label>
<div <div
@ -87,14 +102,39 @@ export function PreferencesForm({
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Display</h3> <h3 className="text-sm font-medium">Display</h3>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<Checkbox <Checkbox checked={showCallsign} onCheckedChange={(c) => setShowCallsign(c === true)} />
checked={showCallsign}
onCheckedChange={(c) => setShowCallsign(c === true)}
/>
Show callsign (username) instead of display name Show callsign (username) instead of display name
</label> </label>
</div> </div>
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Discord</h3>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={discordEnabled}
onCheckedChange={(c) => setDiscordEnabled(c === true)}
/>
Discord notifications (DM me about app events)
</label>
<div
className={
discordEnabled
? "flex flex-col gap-3 pl-6"
: "flex flex-col gap-3 pl-6 opacity-50 pointer-events-none"
}
>
{MUTEABLE_NOTIFICATION_TYPES.map((t) => (
<label key={t.value} className="flex items-center gap-2 text-sm">
<Checkbox
checked={discordMutedTypes.includes(t.value)}
onCheckedChange={(c) => toggleDiscordType(t.value, c === true)}
/>
Mute {t.label.toLowerCase()}
</label>
))}
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
{success && <p className="text-sm text-emerald-500">{success}</p>} {success && <p className="text-sm text-emerald-500">{success}</p>}
<div> <div>

View file

@ -1212,6 +1212,7 @@ export interface GameEventLog {
export interface User { export interface User {
id: number; id: number;
discordUsername: string; discordUsername: string;
discordId?: string | null;
displayName: string; displayName: string;
payloadDisplayName?: string | null; payloadDisplayName?: string | null;
steamId: string; steamId: string;
@ -1230,12 +1231,36 @@ export interface User {
| 'market:sold' | 'market:sold'
| 'market:expired' | 'market:expired'
| 'account:discord-request' | 'account:discord-request'
| 'finance:deposit'
| 'finance:withdraw'
| 'finance:transfer'
| 'shipment:completed'
)[] )[]
| null; | null;
}; };
display?: { display?: {
showCallsign?: boolean | null; showCallsign?: boolean | null;
}; };
discord?: {
enabled?: boolean | null;
mutedTypes?:
| (
| 'market:offer'
| 'market:counter'
| 'market:accept'
| 'market:reject'
| 'market:withdrawn'
| 'market:closed'
| 'market:sold'
| 'market:expired'
| 'account:discord-request'
| 'finance:deposit'
| 'finance:withdraw'
| 'finance:transfer'
| 'shipment:completed'
)[]
| null;
};
}; };
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
@ -2502,6 +2527,7 @@ export interface GameEventLogsSelect<T extends boolean = true> {
*/ */
export interface UsersSelect<T extends boolean = true> { export interface UsersSelect<T extends boolean = true> {
discordUsername?: T; discordUsername?: T;
discordId?: T;
displayName?: T; displayName?: T;
payloadDisplayName?: T; payloadDisplayName?: T;
steamId?: T; steamId?: T;
@ -2520,6 +2546,12 @@ export interface UsersSelect<T extends boolean = true> {
| { | {
showCallsign?: T; showCallsign?: T;
}; };
discord?:
| T
| {
enabled?: T;
mutedTypes?: T;
};
}; };
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;