diff --git a/apps/bot/src/notifications.ts b/apps/bot/src/notifications.ts index c3e415a..c988eeb 100644 --- a/apps/bot/src/notifications.ts +++ b/apps/bot/src/notifications.ts @@ -1,5 +1,10 @@ import { prisma } from "@msk-forms/db"; -import type { MessageNotification, StatusChangeNotification } from "@msk-forms/shared"; +import { + parseBotConfig, + type MessageNotification, + type StatusChangeNotification, + type SubmissionReviewNotification, +} from "@msk-forms/shared"; import { ActionRowBuilder, ButtonBuilder, @@ -10,12 +15,14 @@ import { } from "discord.js"; import { config } from "./config.js"; -import { statusUrl } from "./urls.js"; +import { dashboardSubmissionUrl, statusUrl } from "./urls.js"; const MSK_GREEN = 0x00e676; const BATCH = 25; /** Discord error code: "Cannot send messages to this user" (DMs closed / no mutual guild). */ const CANNOT_DM = 50007; +/** Permanent channel errors: Unknown Channel / Missing Access / Missing Permissions. */ +const CHANNEL_GONE = [10003, 50001, 50013]; /** Guards against overlapping ticks if a batch outlives the poll interval. */ let running = false; @@ -24,6 +31,7 @@ type PendingRow = { id: string; type: string; payload: unknown; + guildId: string | null; user: { discordId: string } | null; }; @@ -61,7 +69,60 @@ function buildMessage(row: PendingRow): { * read — on success, when there's no recipient, or on a permanent "can't DM" * error. Returns false for transient failures so the next tick retries. */ +/** + * Post the "new submission" review embed to the guild's configured review + * channel. Drops (marks read) when there's no guild, no configured channel, or + * the channel is permanently unreachable; retries on transient errors. + */ +async function deliverReview(client: Client, row: PendingRow): Promise { + if (!row.guildId) return true; + const payload = row.payload as Partial; + if (!payload?.submissionId) return true; + + const guild = await prisma.guild.findUnique({ + where: { id: row.guildId }, + select: { botConfig: true }, + }); + const channelId = parseBotConfig(guild?.botConfig).reviewChannelId; + if (!channelId) return true; // no review channel configured → nothing to do + + try { + const channel = await client.channels.fetch(channelId); + if (!channel?.isTextBased() || !channel.isSendable()) { + console.warn(`[bot] review channel ${channelId} not usable — dropping ${row.id}.`); + return true; + } + + const url = dashboardSubmissionUrl(config.apiBaseUrl, row.guildId, payload.submissionId); + const lines = [`**Applicant:** ${payload.applicantName ?? "Anonymous"}`]; + if (payload.preview?.length) lines.push("", ...payload.preview.map((l) => `• ${l}`)); + + const embed = new EmbedBuilder() + .setColor(MSK_GREEN) + .setTitle(`New submission — ${payload.formTitle ?? "form"}`) + .setURL(url) + .setDescription(lines.join("\n").slice(0, 4000)) + .setFooter({ text: "MSK Forms" }); + + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel("Open in dashboard").setURL(url), + ); + + await channel.send({ embeds: [embed], components: [buttons] }); + return true; + } catch (err) { + if (err instanceof DiscordAPIError && CHANNEL_GONE.includes(Number(err.code))) { + console.warn(`[bot] review channel ${channelId} gone (${err.code}) — dropping ${row.id}.`); + return true; + } + console.error(`[bot] failed to post review embed to ${channelId}:`, err); + return false; + } +} + async function deliverOne(client: Client, row: PendingRow): Promise { + if (row.type === "submission_review") return deliverReview(client, row); + const discordId = row.user?.discordId; if (!discordId) return true; @@ -91,13 +152,14 @@ export async function deliverPendingNotifications(client: Client): Promise running = true; try { const pending = (await prisma.notification.findMany({ - where: { readAt: null, type: { in: ["status_change", "message"] } }, + where: { readAt: null, type: { in: ["status_change", "message", "submission_review"] } }, orderBy: { createdAt: "asc" }, take: BATCH, select: { id: true, type: true, payload: true, + guildId: true, user: { select: { discordId: true } }, }, })) as PendingRow[]; diff --git a/apps/bot/src/urls.test.ts b/apps/bot/src/urls.test.ts index f61804f..6ec07c0 100644 --- a/apps/bot/src/urls.test.ts +++ b/apps/bot/src/urls.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { dashboardUrl, formUrl, statusUrl } from "./urls.js"; +import { dashboardSubmissionUrl, dashboardUrl, formUrl, statusUrl } from "./urls.js"; describe("url builders", () => { it("builds form and status links", () => { @@ -17,6 +17,12 @@ describe("url builders", () => { expect(dashboardUrl("https://x.de", "g1")).toBe("https://x.de/dashboard/g1/forms"); }); + it("builds a dashboard submission link", () => { + expect(dashboardSubmissionUrl("https://x.de", "g1", "s1")).toBe( + "https://x.de/dashboard/g1/submissions/s1", + ); + }); + it("normalises a trailing slash on the base", () => { expect(formUrl("http://localhost:3000/", "s")).toBe("http://localhost:3000/f/s"); }); diff --git a/apps/bot/src/urls.ts b/apps/bot/src/urls.ts index 426f775..965cdc4 100644 --- a/apps/bot/src/urls.ts +++ b/apps/bot/src/urls.ts @@ -16,3 +16,8 @@ export function statusUrl(base: string, submissionId: string): string { export function dashboardUrl(base: string, guildId?: string): string { return guildId ? `${trim(base)}/dashboard/${guildId}/forms` : `${trim(base)}/dashboard`; } + +/** Reviewer detail link for a submission in the dashboard. */ +export function dashboardSubmissionUrl(base: string, guildId: string, submissionId: string): string { + return `${trim(base)}/dashboard/${guildId}/submissions/${submissionId}`; +} diff --git a/apps/web/src/app/api/forms/[slug]/submit/route.ts b/apps/web/src/app/api/forms/[slug]/submit/route.ts index 1bb38c1..80297c8 100644 --- a/apps/web/src/app/api/forms/[slug]/submit/route.ts +++ b/apps/web/src/app/api/forms/[slug]/submit/route.ts @@ -1,5 +1,12 @@ import { prisma, type Prisma } from "@msk-forms/db"; -import { buildAnswerSchema, FILE_FIELD_TYPES, type FileAnswer } from "@msk-forms/shared"; +import { + buildAnswerSchema, + FILE_FIELD_TYPES, + type FileAnswer, + type FormField, + type FormSpec, + type SubmissionReviewNotification, +} from "@msk-forms/shared"; import { NextResponse, type NextRequest } from "next/server"; import { getCurrentUser } from "@/lib/auth"; @@ -14,6 +21,30 @@ export const dynamic = "force-dynamic"; const SUBMIT_LIMIT = 8; const SUBMIT_WINDOW_SECONDS = 60; +const LAYOUT_TYPES = ["section_break", "heading", "paragraph", "image_block", "divider", "spacer"]; + +/** Short "Label: value" lines for the bot's review embed (first few fields). */ +function buildPreview(spec: FormSpec, data: Record): string[] { + const labelFor = (field: FormField, v: string) => + field.options?.find((o) => o.value === v)?.label ?? v; + const valueOf = (field: FormField, value: unknown): string => { + if (value === undefined || value === null || value === "") return "—"; + if (typeof value === "boolean") return value ? "Yes" : "No"; + if (Array.isArray(value)) return value.map((v) => labelFor(field, String(v))).join(", "); + if (typeof value === "object" && value && "name" in value) { + return String((value as { name: unknown }).name); + } + if (field.options) return labelFor(field, String(value)); + return String(value); + }; + + return spec.pages + .flatMap((p) => p.fields) + .filter((f) => !LAYOUT_TYPES.includes(f.type)) + .slice(0, 6) + .map((f) => `${f.label ?? f.id}: ${valueOf(f, data[f.id]).slice(0, 100)}`); +} + /** * Public submission endpoint. Validates answers server-side against the form's * spec (never trust the client), creates the submission and its initial @@ -61,7 +92,7 @@ export async function POST( const form = await prisma.form.findUnique({ where: { slug }, - select: { id: true, guildId: true, status: true, schema: true }, + select: { id: true, guildId: true, status: true, schema: true, title: true }, }); if (!form || form.status !== "live") { @@ -120,5 +151,25 @@ export async function POST( select: { id: true }, }); + // Outbox: announce the new submission in the guild's review channel (the bot + // delivers it). Best-effort — never fail the submit if this can't be queued. + try { + const payload: SubmissionReviewNotification = { + submissionId: submission.id, + formTitle: form.title, + applicantName: user?.username ?? "Anonymous", + preview: buildPreview(spec, data), + }; + await prisma.notification.create({ + data: { + guildId: form.guildId, + type: "submission_review", + payload: payload as unknown as Prisma.InputJsonValue, + }, + }); + } catch (err) { + console.error("[submit] failed to queue review notification:", (err as Error).message); + } + return NextResponse.json({ submissionId: submission.id }, { status: 201 }); } diff --git a/packages/db/prisma/migrations/20260620170000_notification_channel_target/migration.sql b/packages/db/prisma/migrations/20260620170000_notification_channel_target/migration.sql new file mode 100644 index 0000000..66cfd1e --- /dev/null +++ b/packages/db/prisma/migrations/20260620170000_notification_channel_target/migration.sql @@ -0,0 +1,14 @@ +-- Allow channel-targeted outbox notifications (e.g. the new-submission review +-- embed posted to a guild's review channel), not just user DMs. + +-- A notification may now have no recipient user (it targets a guild channel). +ALTER TABLE "notifications" ALTER COLUMN "user_id" DROP NOT NULL; + +-- Optional guild target. +ALTER TABLE "notifications" ADD COLUMN "guild_id" UUID; + +-- Index for the bot's "unread for this guild" poll. +CREATE INDEX "notifications_guild_id_read_at_idx" ON "notifications"("guild_id", "read_at"); + +-- Foreign key to the guild (cascade on guild deletion). +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_guild_id_fkey" FOREIGN KEY ("guild_id") REFERENCES "guilds"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index cbd59d0..f43efa8 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -98,11 +98,12 @@ model Guild { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - members GuildMember[] - forms Form[] - submissions Submission[] - formStatuses FormStatusDef[] - apiKeys ApiKey[] + members GuildMember[] + forms Form[] + submissions Submission[] + formStatuses FormStatusDef[] + apiKeys ApiKey[] + notifications Notification[] @@map("guilds") } @@ -240,16 +241,19 @@ model FileUpload { } model Notification { - id String @id @default(uuid()) @db.Uuid - userId String @map("user_id") @db.Uuid + id String @id @default(uuid()) @db.Uuid + userId String? @map("user_id") @db.Uuid + guildId String? @map("guild_id") @db.Uuid type String - payload Json @default("{}") + payload Json @default("{}") readAt DateTime? @map("read_at") - createdAt DateTime @default(now()) @map("created_at") + createdAt DateTime @default(now()) @map("created_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + guild Guild? @relation(fields: [guildId], references: [id], onDelete: Cascade) @@index([userId, readAt]) + @@index([guildId, readAt]) @@map("notifications") } diff --git a/packages/shared/src/notifications.ts b/packages/shared/src/notifications.ts index 2b883d9..eafec55 100644 --- a/packages/shared/src/notifications.ts +++ b/packages/shared/src/notifications.ts @@ -5,7 +5,7 @@ * discriminates which payload shape `payload` holds. */ -export const NOTIFICATION_TYPES = ["status_change", "message"] as const; +export const NOTIFICATION_TYPES = ["status_change", "message", "submission_review"] as const; export type NotificationType = (typeof NOTIFICATION_TYPES)[number]; interface BaseNotification { @@ -26,4 +26,19 @@ export interface MessageNotification extends BaseNotification { message: string; } -export type NotificationPayload = StatusChangeNotification | MessageNotification; +/** + * A new submission to announce in the guild's review channel. Channel-targeted + * (the Notification carries `guildId`, not a recipient user). + */ +export interface SubmissionReviewNotification { + submissionId: string; + formTitle: string; + applicantName: string; + /** Short "Field: value" lines for an at-a-glance preview in the embed. */ + preview: string[]; +} + +export type NotificationPayload = + | StatusChangeNotification + | MessageNotification + | SubmissionReviewNotification;