From 43afd0edb5b58ece0a6a10ec137646f91563bee2 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Thu, 2 Jul 2026 09:43:31 +0200 Subject: [PATCH] feat: custom-domain status links in DMs + hideable status changes Two customer suggestions. 1) Status DM links now point at the guild's verified custom domain when it has one, so applicants stay on the community's own domain. The bot resolves the submission's guild (already looked up for the DM language) and builds the status URL from `https://` when `customDomainVerifiedAt` is set, falling back to the primary host otherwise. Reviewer/dashboard links are unchanged (the dashboard only lives on the primary host). 2) A reviewer can hide a status change from the applicant. The review panel gains a "Hide this change from the applicant" checkbox on the status control: when ticked, the status_change event is recorded as internal and no DM is queued, so it never appears on the applicant's status page or timeline. The status page now derives the shown status from the last applicant-visible status_change event (falling back to the stored status for legacy rows), so a hidden change updates the status for the team without revealing it. The guild activity log still records it. - shared: `hidden` on the status action; changeSubmissionStatus gains `eventVisibility` - i18n: review.hideFromApplicant/Hint in all 7 locales --- apps/bot/src/notifications.ts | 19 ++++++++++--- .../submissions/[id]/events/route.ts | 27 ++++++++++--------- apps/web/src/app/s/[id]/page.tsx | 9 ++++++- .../src/components/review/review-panel.tsx | 12 +++++++-- apps/web/src/i18n/dictionaries.ts | 7 +++++ apps/web/src/lib/submission-action.ts | 12 ++++++--- packages/db/src/submission-status.ts | 9 ++++++- 7 files changed, 72 insertions(+), 23 deletions(-) diff --git a/apps/bot/src/notifications.ts b/apps/bot/src/notifications.ts index 849fa8d..0faecea 100644 --- a/apps/bot/src/notifications.ts +++ b/apps/bot/src/notifications.ts @@ -47,6 +47,7 @@ type PendingRow = { function buildMessage( row: PendingRow, locale: string | undefined, + baseUrl: string, ): { embeds: EmbedBuilder[]; components: ActionRowBuilder[]; @@ -55,7 +56,7 @@ function buildMessage( if (!payload?.submissionId) return null; const s = dmStrings(locale); - const url = statusUrl(config.apiBaseUrl, payload.submissionId); + const url = statusUrl(baseUrl, payload.submissionId); const embed = new EmbedBuilder() .setColor(MSK_GREEN) .setTitle(payload.formTitle ?? s.title) @@ -254,19 +255,29 @@ async function deliverOne(client: Client, row: PendingRow): Promise { // Applicant DMs speak the guild's configured bot language when it's set (the // community deliberately chose it, so the whole bot — embeds, logs AND DMs — // stays in that language). Only when the guild left it unset do we fall back - // to the applicant's own Discord locale, then to English. + // to the applicant's own Discord locale, then to English. The status link + // points at the guild's verified custom domain when it has one, so applicants + // stay on the community's own domain instead of the primary host. let dmLocale = row.user?.locale || undefined; + let baseUrl = config.apiBaseUrl; const p = row.payload as { submissionId?: string }; if (p.submissionId) { const sub = await prisma.submission.findUnique({ where: { id: p.submissionId }, - select: { guild: { select: { botConfig: true } } }, + select: { + guild: { + select: { botConfig: true, customDomain: true, customDomainVerifiedAt: true }, + }, + }, }); const guildLocale = parseBotConfig(sub?.guild.botConfig).locale; if (guildLocale) dmLocale = guildLocale; + if (sub?.guild.customDomain && sub.guild.customDomainVerifiedAt) { + baseUrl = `https://${sub.guild.customDomain}`; + } } - const message = buildMessage(row, dmLocale); + const message = buildMessage(row, dmLocale, baseUrl); if (!message) { console.warn(`[bot] notification ${row.id} has an unrecognised payload — dropping.`); return true; diff --git a/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/events/route.ts b/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/events/route.ts index 25b548a..14341bd 100644 --- a/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/events/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/submissions/[id]/events/route.ts @@ -77,26 +77,29 @@ export async function POST( } // Idempotent, race-safe transition + event + applicant DM (skip anonymous). + // When the reviewer hides the change, the event is internal and no DM is + // queued — the applicant neither sees nor is notified of it. const labels = (await getDict()).statusLabels; const toStatusLabel = resolveStatus(action.status, defs, labels).label; - const notify: StatusChangeNotification | null = submission.userId - ? { - submissionId: id, - formTitle: submission.form.title, - toStatus: action.status, - toStatusLabel, - } - : null; + const notify: StatusChangeNotification | null = + submission.userId && !action.hidden + ? { + submissionId: id, + formTitle: submission.form.title, + toStatus: action.status, + toStatusLabel, + } + : null; await changeSubmissionStatus({ submissionId: id, toStatus: action.status, actorUserId: user.id, actorName: user.username, toStatusLabel, - notify: - submission.userId && notify - ? { userId: submission.userId, type: "status_change", payload: notify } - : null, + eventVisibility: action.hidden ? "internal" : "public", + notify: notify + ? { userId: submission.userId!, type: "status_change", payload: notify } + : null, }); return NextResponse.json({ ok: true }); } diff --git a/apps/web/src/app/s/[id]/page.tsx b/apps/web/src/app/s/[id]/page.tsx index ee32a2e..65d33c0 100644 --- a/apps/web/src/app/s/[id]/page.tsx +++ b/apps/web/src/app/s/[id]/page.tsx @@ -36,7 +36,14 @@ export default async function SubmissionStatusPage({ const dict = await getDict(); const t = dict.status; - const status = resolveStatus(submission.status, submission.statusDefs, dict.statusLabels); + // Show the last status the reviewer made visible, not necessarily the raw + // current status: a reviewer can mark a status change "hidden" (an internal + // event, no DM), and it must not surface here. Fall back to the stored status + // for legacy submissions with no public status_change event. + const lastVisibleStatus = + [...submission.events].reverse().find((e) => e.type === "status_change" && e.toStatus) + ?.toStatus ?? submission.status; + const status = resolveStatus(lastVisibleStatus, submission.statusDefs, dict.statusLabels); const answers = (submission.answers ?? {}) as Record; const branding = parseBranding(submission.form.guild.branding); const brand = brandStyle(branding); diff --git a/apps/web/src/components/review/review-panel.tsx b/apps/web/src/components/review/review-panel.tsx index 59dcff9..321b1be 100644 --- a/apps/web/src/components/review/review-panel.tsx +++ b/apps/web/src/components/review/review-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Card, Field, Select, Textarea } from "@msk-forms/ui"; +import { Button, Card, Checkbox, Field, Select, Textarea } from "@msk-forms/ui"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -29,6 +29,7 @@ export function ReviewPanel({ }) { const router = useRouter(); const [status, setStatus] = useState(currentStatus); + const [hidden, setHidden] = useState(false); const [note, setNote] = useState(""); const [message, setMessage] = useState(""); const [pending, setPending] = useState(null); @@ -76,11 +77,18 @@ export function ReviewPanel({ type="button" className="shrink-0" disabled={pending !== null || status === currentStatus} - onClick={() => submit({ kind: "status", status })} + onClick={() => submit({ kind: "status", status, hidden })} > {pending === "status" ? t.saving : t.apply} + setHidden(e.target.checked)} + label={t.hideFromApplicant} + /> +

{t.hideFromApplicantHint}

diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index f673f57..56931c9 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -307,6 +307,7 @@ const en = { answers: "Answers", timeline: "Activity", noEvents: "No activity yet.", actions: "Review actions", changeStatus: "Change status", apply: "Apply", + hideFromApplicant: "Hide this change from the applicant", hideFromApplicantHint: "No DM, and it won't show on their status page. The status still updates for your team.", noteLabel: "Internal note", noteHint: "Only visible to your team.", notePlaceholder: "Add a note for your team…", saveNote: "Save note", messageLabel: "Message to applicant", messageHint: "Shown to the applicant on their status page.", @@ -852,6 +853,7 @@ const de: Dictionary = { answers: "Antworten", timeline: "Aktivität", noEvents: "Noch keine Aktivität.", actions: "Review-Aktionen", changeStatus: "Status ändern", apply: "Übernehmen", + hideFromApplicant: "Diese Änderung vor dem Bewerber verbergen", hideFromApplicantHint: "Keine DM, und sie erscheint nicht auf seiner Status-Seite. Für dein Team wird der Status trotzdem aktualisiert.", noteLabel: "Interne Notiz", noteHint: "Nur für dein Team sichtbar.", notePlaceholder: "Notiz für dein Team hinzufügen…", saveNote: "Notiz speichern", messageLabel: "Nachricht an Bewerber", messageHint: "Wird dem Bewerber auf seiner Status-Seite angezeigt.", @@ -1395,6 +1397,7 @@ const hu: Dictionary = { answers: "Válaszok", timeline: "Tevékenység", noEvents: "Még nincs tevékenység.", actions: "Bírálati műveletek", changeStatus: "Állapot módosítása", apply: "Alkalmaz", + hideFromApplicant: "Változás elrejtése a jelentkező elől", hideFromApplicantHint: "Nincs DM, és nem jelenik meg az állapotoldalán. A csapatod számára az állapot így is frissül.", noteLabel: "Belső megjegyzés", noteHint: "Csak a csapatod látja.", notePlaceholder: "Adj hozzá megjegyzést a csapatnak…", saveNote: "Megjegyzés mentése", messageLabel: "Üzenet a jelentkezőnek", messageHint: "Megjelenik a jelentkező állapotoldalán.", @@ -1938,6 +1941,7 @@ const fr: Dictionary = { answers: "Réponses", timeline: "Activité", noEvents: "Aucune activité pour l'instant.", actions: "Actions d'évaluation", changeStatus: "Changer le statut", apply: "Appliquer", + hideFromApplicant: "Masquer ce changement au candidat", hideFromApplicantHint: "Pas de DM, et il n'apparaît pas sur sa page de statut. Le statut est quand même mis à jour pour votre équipe.", noteLabel: "Note interne", noteHint: "Visible uniquement par votre équipe.", notePlaceholder: "Ajouter une note pour votre équipe…", saveNote: "Enregistrer la note", messageLabel: "Message au candidat", messageHint: "Affiché au candidat sur sa page de statut.", @@ -2482,6 +2486,7 @@ const es: Dictionary = { answers: "Respuestas", timeline: "Actividad", noEvents: "Aún no hay actividad.", actions: "Acciones de revisión", changeStatus: "Cambiar estado", apply: "Aplicar", + hideFromApplicant: "Ocultar este cambio al candidato", hideFromApplicantHint: "Sin DM, y no aparece en su página de estado. El estado se actualiza igualmente para tu equipo.", noteLabel: "Nota interna", noteHint: "Solo visible para tu equipo.", notePlaceholder: "Agrega una nota para tu equipo…", saveNote: "Guardar nota", messageLabel: "Mensaje al solicitante", messageHint: "Se muestra al solicitante en su página de estado.", @@ -3026,6 +3031,7 @@ const pt: Dictionary = { answers: "Respostas", timeline: "Atividade", noEvents: "Nenhuma atividade ainda.", actions: "Ações de avaliação", changeStatus: "Alterar status", apply: "Aplicar", + hideFromApplicant: "Ocultar esta alteração do candidato", hideFromApplicantHint: "Sem DM, e não aparece na página de status dele. O status é atualizado mesmo assim para a sua equipe.", noteLabel: "Nota interna", noteHint: "Visível apenas para sua equipe.", notePlaceholder: "Adicione uma nota para sua equipe…", saveNote: "Salvar nota", messageLabel: "Mensagem ao candidato", messageHint: "Exibida ao candidato na página de status.", @@ -3570,6 +3576,7 @@ const pl: Dictionary = { answers: "Odpowiedzi", timeline: "Aktywność", noEvents: "Brak aktywności.", actions: "Akcje recenzji", changeStatus: "Zmień status", apply: "Zastosuj", + hideFromApplicant: "Ukryj tę zmianę przed kandydatem", hideFromApplicantHint: "Bez DM i nie pojawi się na jego stronie statusu. Status i tak zostanie zaktualizowany dla twojego zespołu.", noteLabel: "Notatka wewnętrzna", noteHint: "Widoczna tylko dla Twojego zespołu.", notePlaceholder: "Dodaj notatkę dla zespołu…", saveNote: "Zapisz notatkę", messageLabel: "Wiadomość do kandydata", messageHint: "Wyświetlana kandydatowi na jego stronie statusu.", diff --git a/apps/web/src/lib/submission-action.ts b/apps/web/src/lib/submission-action.ts index 54d0378..5c0ba4c 100644 --- a/apps/web/src/lib/submission-action.ts +++ b/apps/web/src/lib/submission-action.ts @@ -2,13 +2,19 @@ import { z } from "zod"; /** * A reviewer action against a submission. One of: - * - `status` — move the submission to another status (creates a public - * status_change event + updates Submission.status) + * - `status` — move the submission to another status (updates + * Submission.status; records a status_change event and DMs the + * applicant unless `hidden` keeps the change internal) * - `note` — add an internal note (visible only to the team) * - `message` — send a message to the applicant (visible on their status page) */ export const submissionActionSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("status"), status: z.string().min(1).max(64) }), + z.object({ + kind: z.literal("status"), + status: z.string().min(1).max(64), + /** Keep the change internal: no applicant DM, hidden from their status page. */ + hidden: z.boolean().optional(), + }), z.object({ kind: z.literal("note"), message: z.string().min(1).max(4000) }), z.object({ kind: z.literal("message"), message: z.string().min(1).max(4000) }), ]); diff --git a/packages/db/src/submission-status.ts b/packages/db/src/submission-status.ts index 0a04c76..176c97d 100644 --- a/packages/db/src/submission-status.ts +++ b/packages/db/src/submission-status.ts @@ -23,6 +23,12 @@ export interface ChangeSubmissionStatusArgs { toStatusLabel?: string | null; /** When set, queue an outbox DM for the applicant in the same transaction. */ notify?: StatusChangeOutboxNotification | null; + /** + * Visibility of the recorded status_change event. `"internal"` hides the + * change from the applicant's status page (pair it with `notify: null` for a + * fully silent change). Defaults to `"public"`. + */ + eventVisibility?: "public" | "internal"; } export interface ChangeSubmissionStatusResult { @@ -50,6 +56,7 @@ export async function changeSubmissionStatus( actorName = null, toStatusLabel = null, notify = null, + eventVisibility = "public", } = args; return prisma.$transaction(async (tx) => { @@ -83,7 +90,7 @@ export async function changeSubmissionStatus( type: "status_change", fromStatus: current.status, toStatus, - visibility: "public", + visibility: eventVisibility, }, });