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
19 changes: 15 additions & 4 deletions apps/bot/src/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type PendingRow = {
function buildMessage(
row: PendingRow,
locale: string | undefined,
baseUrl: string,
): {
embeds: EmbedBuilder[];
components: ActionRowBuilder<ButtonBuilder>[];
Expand All @@ -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)
Expand Down Expand Up @@ -254,19 +255,29 @@ async function deliverOne(client: Client, row: PendingRow): Promise<boolean> {
// 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/app/s/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const branding = parseBranding(submission.form.guild.branding);
const brand = brandStyle(branding);
Expand Down
12 changes: 10 additions & 2 deletions apps/web/src/components/review/review-panel.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<SubmissionAction["kind"] | null>(null);
Expand Down Expand Up @@ -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}
</Button>
</div>
<Checkbox
id="hide-status-change"
checked={hidden}
onChange={(e) => setHidden(e.target.checked)}
label={t.hideFromApplicant}
/>
<p className="ms-[1.9rem] text-xs text-muted-foreground">{t.hideFromApplicantHint}</p>
</Field>

<Field label={t.noteLabel} hint={t.noteHint}>
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/lib/submission-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) }),
]);
Expand Down
9 changes: 8 additions & 1 deletion packages/db/src/submission-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -50,6 +56,7 @@ export async function changeSubmissionStatus(
actorName = null,
toStatusLabel = null,
notify = null,
eventVisibility = "public",
} = args;

return prisma.$transaction(async (tx) => {
Expand Down Expand Up @@ -83,7 +90,7 @@ export async function changeSubmissionStatus(
type: "status_change",
fromStatus: current.status,
toStatus,
visibility: "public",
visibility: eventVisibility,
},
});

Expand Down