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, }, });