Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions apps/bot/src/notifications.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -24,6 +31,7 @@ type PendingRow = {
id: string;
type: string;
payload: unknown;
guildId: string | null;
user: { discordId: string } | null;
};

Expand Down Expand Up @@ -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<boolean> {
if (!row.guildId) return true;
const payload = row.payload as Partial<SubmissionReviewNotification>;
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<ButtonBuilder>().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<boolean> {
if (row.type === "submission_review") return deliverReview(client, row);

const discordId = row.user?.discordId;
if (!discordId) return true;

Expand Down Expand Up @@ -91,13 +152,14 @@ export async function deliverPendingNotifications(client: Client): Promise<void>
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[];
Expand Down
8 changes: 7 additions & 1 deletion apps/bot/src/urls.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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");
});
Expand Down
5 changes: 5 additions & 0 deletions apps/bot/src/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
}
55 changes: 53 additions & 2 deletions apps/web/src/app/api/forms/[slug]/submit/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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, unknown>): 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
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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 });
}
Original file line number Diff line number Diff line change
@@ -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;
24 changes: 14 additions & 10 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
}

Expand Down
19 changes: 17 additions & 2 deletions packages/shared/src/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;