diff --git a/apps/bot/src/review-actions.ts b/apps/bot/src/review-actions.ts index 8a74ef8..cbc78ab 100644 --- a/apps/bot/src/review-actions.ts +++ b/apps/bot/src/review-actions.ts @@ -2,6 +2,7 @@ import { changeSubmissionStatus, prisma } from "@msk-forms/db"; import { DEFAULT_STATUSES, parseBotConfig, + parseFormSettings, type StatusChangeNotification, } from "@msk-forms/shared"; import { @@ -53,7 +54,7 @@ export async function handleReviewButton(interaction: ButtonInteraction): Promis status: true, userId: true, guildId: true, - form: { select: { title: true } }, + form: { select: { title: true, settings: true } }, guild: { select: { discordGuildId: true, botConfig: true } }, }, }); @@ -83,7 +84,10 @@ export async function handleReviewButton(interaction: ButtonInteraction): Promis }); if (changed && action === "accept" && submission.userId) { - const roleId = parseBotConfig(submission.guild.botConfig).acceptedRoleId; + // Per-form accepted role overrides the guild-wide default. + const roleId = + parseFormSettings(submission.form.settings).acceptedRoleId ?? + parseBotConfig(submission.guild.botConfig).acceptedRoleId; if (roleId) await grantRole(interaction, submission.userId, roleId); } diff --git a/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts b/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts index ecbd193..a6dfb14 100644 --- a/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts @@ -49,6 +49,7 @@ export async function PATCH( status: input.status, visibility: input.visibility, schema: input.spec as Prisma.InputJsonValue, + settings: (input.settings ?? {}) as Prisma.InputJsonValue, version: { increment: 1 }, }, }); diff --git a/apps/web/src/app/api/guilds/[guildId]/forms/route.ts b/apps/web/src/app/api/guilds/[guildId]/forms/route.ts index f910dc0..4770b44 100644 --- a/apps/web/src/app/api/guilds/[guildId]/forms/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/forms/route.ts @@ -40,6 +40,7 @@ export async function POST( status: input.status, visibility: input.visibility, schema: input.spec as Prisma.InputJsonValue, + settings: (input.settings ?? {}) as Prisma.InputJsonValue, createdById: user.id, }, select: { id: true }, diff --git a/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx index e08d3f7..3761da8 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/[formId]/edit/page.tsx @@ -1,6 +1,8 @@ import { Card } from "@msk-forms/ui"; import { notFound } from "next/navigation"; +import { parseFormSettings } from "@msk-forms/shared"; + import { FormBuilder } from "@/components/builder/form-builder"; import { requireUser } from "@/lib/auth"; import { getFormForEdit, parseFormSpec } from "@/lib/forms"; @@ -46,6 +48,7 @@ export default async function EditFormPage({ slug: form.slug, status: form.status, visibility: form.visibility, + acceptedRoleId: parseFormSettings(form.settings).acceptedRoleId ?? "", pages: spec?.pages.length ? spec.pages : [{ id: "p1", title: "", fields: [] }], }} /> diff --git a/apps/web/src/app/dashboard/[guildId]/forms/new/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/new/page.tsx index 195d22b..7a9414e 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/new/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/new/page.tsx @@ -38,6 +38,7 @@ export default async function NewFormPage({ slug: "", status: "draft", visibility: "public", + acceptedRoleId: "", pages: [{ id: "p1", title: "", fields: [] }], }} /> diff --git a/apps/web/src/components/builder/form-builder.tsx b/apps/web/src/components/builder/form-builder.tsx index 11b5098..2c17b4b 100644 --- a/apps/web/src/components/builder/form-builder.tsx +++ b/apps/web/src/components/builder/form-builder.tsx @@ -18,6 +18,7 @@ export interface FormBuilderInitial { slug: string; status: string; visibility: string; + acceptedRoleId: string; pages: FormPage[]; } @@ -53,6 +54,7 @@ export function FormBuilder({ const [slug, setSlug] = useState(initial.slug); const [status, setStatus] = useState(initial.status); const [visibility, setVisibility] = useState(initial.visibility); + const [acceptedRoleId, setAcceptedRoleId] = useState(initial.acceptedRoleId); const [pages, setPages] = useState(initial.pages); const [addType, setAddType] = useState("short_text"); const [error, setError] = useState(null); @@ -122,6 +124,7 @@ export function FormBuilder({ status, visibility, spec, + settings: acceptedRoleId.trim() ? { acceptedRoleId: acceptedRoleId.trim() } : {}, }; setSaving(true); @@ -196,6 +199,13 @@ export function FormBuilder({ /> + + setAcceptedRoleId(e.target.value)} + /> + {pages.map((page, pi) => ( diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index fdc9b1d..b009312 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -137,7 +137,7 @@ const en = { reviewChannel: "Review channel ID", reviewChannelHint: "New submissions are posted here for your team to review.", acceptedRole: "Accepted role ID", - acceptedRoleHint: "Granted to the applicant when a submission is accepted.", + acceptedRoleHint: "Default role granted on acceptance. Each form can override this in its settings.", idHint: "Enable Developer Mode in Discord (Settings → Advanced), then right-click a channel/role → Copy ID.", postName: "Posting name", postNameHint: "When set, the bot posts forms & review embeds under this name with your logo (via a webhook). Needs the Manage Webhooks permission. Leave empty to post as the bot.", @@ -150,6 +150,7 @@ const en = { status: "Status", visibility: "Visibility", statusDraft: "Draft", statusLive: "Live", statusClosed: "Closed", statusArchived: "Archived", visPublic: "Public", visAuth: "Login required", + acceptedRole: "Accepted role ID", acceptedRoleHint: "Granted on acceptance for this form (overrides the guild default in Bot settings). Leave empty to use the default.", addField: "Add field", add: "Add", page: "Page", addPage: "Add page", removePage: "Remove page", pageTitlePh: "Page title (optional)…", saving: "Saving…", saveChanges: "Save changes", createForm: "Create form", cancel: "Cancel", @@ -330,7 +331,7 @@ const de: Dictionary = { reviewChannel: "Review-Kanal-ID", reviewChannelHint: "Neue Einreichungen werden hier für dein Team zur Prüfung gepostet.", acceptedRole: "Annahme-Rollen-ID", - acceptedRoleHint: "Wird dem Bewerber bei Annahme der Einreichung vergeben.", + acceptedRoleHint: "Standard-Rolle bei Annahme. Jedes Formular kann das in seinen Einstellungen überschreiben.", idHint: "Aktiviere den Entwicklermodus in Discord (Einstellungen → Erweitert), dann Rechtsklick auf Kanal/Rolle → ID kopieren.", postName: "Anzeigename beim Posten", postNameHint: "Wenn gesetzt, postet der Bot Formulare & Review-Embeds unter diesem Namen mit deinem Logo (per Webhook). Benötigt die Berechtigung zum Verwalten von Webhooks. Leer lassen, um als Bot zu posten.", @@ -343,6 +344,7 @@ const de: Dictionary = { status: "Status", visibility: "Sichtbarkeit", statusDraft: "Entwurf", statusLive: "Live", statusClosed: "Geschlossen", statusArchived: "Archiviert", visPublic: "Öffentlich", visAuth: "Anmeldung erforderlich", + acceptedRole: "Annahme-Rollen-ID", acceptedRoleHint: "Wird bei Annahme für dieses Formular vergeben (überschreibt den Guild-Standard in den Bot-Einstellungen). Leer lassen = Standard verwenden.", addField: "Feld hinzufügen", add: "Hinzufügen", page: "Seite", addPage: "Seite hinzufügen", removePage: "Seite entfernen", pageTitlePh: "Seitentitel (optional)…", saving: "Wird gespeichert…", saveChanges: "Änderungen speichern", createForm: "Formular erstellen", cancel: "Abbrechen", diff --git a/apps/web/src/lib/form-input.ts b/apps/web/src/lib/form-input.ts index 5102737..3b08214 100644 --- a/apps/web/src/lib/form-input.ts +++ b/apps/web/src/lib/form-input.ts @@ -1,4 +1,4 @@ -import { formSpecSchema } from "@msk-forms/shared"; +import { formSettingsSchema, formSpecSchema } from "@msk-forms/shared"; import { z } from "zod"; /** @@ -17,6 +17,7 @@ export const formInputSchema = z.object({ status: z.enum(["draft", "live", "closed", "archived"]), visibility: z.enum(["public", "authenticated", "password", "role_required"]), spec: formSpecSchema, + settings: formSettingsSchema.optional(), }); export type FormInput = z.infer; diff --git a/apps/web/src/lib/forms.ts b/apps/web/src/lib/forms.ts index 68b3120..660a138 100644 --- a/apps/web/src/lib/forms.ts +++ b/apps/web/src/lib/forms.ts @@ -66,6 +66,7 @@ export async function getFormForEdit(formId: string, guildId: string) { status: true, visibility: true, schema: true, + settings: true, }, }); if (!form || form.guildId !== guildId) return null; diff --git a/packages/shared/src/form-settings.ts b/packages/shared/src/form-settings.ts new file mode 100644 index 0000000..64a34a0 --- /dev/null +++ b/packages/shared/src/form-settings.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +/** + * Per-form settings stored in `Form.settings` (JSON). Currently a per-form + * accepted role that overrides the guild-wide `botConfig.acceptedRoleId`. + */ +const snowflake = z.string().regex(/^\d{17,20}$/, "Enter a valid Discord ID."); + +export const formSettingsSchema = z.object({ + /** Role granted on acceptance for THIS form (overrides the guild default). */ + acceptedRoleId: snowflake.optional(), +}); + +export type FormSettings = z.infer; + +/** Parse a stored Form.settings JSON blob, falling back to empty settings. */ +export function parseFormSettings(json: unknown): FormSettings { + const result = formSettingsSchema.safeParse(json); + return result.success ? result.data : {}; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 84d40fd..84eb4a1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,7 @@ // Extensionless exports so Turbopack resolves them when web bundles this // package (a `.js` suffix on a `.ts` source breaks Turbopack resolution). export * from "./form-spec"; +export * from "./form-settings"; export * from "./conditions"; export * from "./constants"; export * from "./notifications";