diff --git a/src/app/api/admin/notifications/reminder-check/route.ts b/src/app/api/admin/notifications/reminder-check/route.ts index 01d6eca4c..56ebabcfb 100644 --- a/src/app/api/admin/notifications/reminder-check/route.ts +++ b/src/app/api/admin/notifications/reminder-check/route.ts @@ -3,7 +3,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { parseScheduleRecurrence } from "@/lib/medication-schedule"; -import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; import { getUserTodayBounds, getDayOfWeekInTz } from "@/lib/timezone"; export const dynamic = "force-dynamic"; @@ -138,20 +138,36 @@ export const POST = apiHandler(async () => { const timeWindow = `${schedule.windowStart}–${schedule.windowEnd}`; try { + // v1.4.27 F21 — each notification renders in the affected + // user's `User.locale` (the recipient is the user whose dose + // is overdue, not the admin running the check). + // `dispatchLocalisedNotification` resolves the locale from + // the userId inside the helper. if (status === "missed") { - await dispatchNotification({ - eventType: "MEDICATION_REMINDER", + await dispatchLocalisedNotification({ userId: med.user.id, - title: `Verpasst: ${med.name}`, - message: `${med.name} (${doseInfo}, ${timeWindow}) wurde als verpasst markiert.`, + eventType: "MEDICATION_REMINDER", + titleKey: "notifications.admin.reminderCheckMissedTitle", + messageKey: "notifications.admin.reminderCheckMissedBody", + params: { + medication: med.name, + dose: doseInfo, + window: timeWindow, + }, metadata: { medicationId: med.id }, }); } else { - await dispatchNotification({ - eventType: "MEDICATION_REMINDER", + await dispatchLocalisedNotification({ userId: med.user.id, - title: `Erinnerung: ${med.name}`, - message: `Erinnerung: ${med.name} (${doseInfo}, ${timeWindow}) wurde noch nicht eingenommen. Seit ${minutesPastEnd} Min überfällig.`, + eventType: "MEDICATION_REMINDER", + titleKey: "notifications.admin.reminderCheckOverdueTitle", + messageKey: "notifications.admin.reminderCheckOverdueBody", + params: { + medication: med.name, + dose: doseInfo, + window: timeWindow, + minutes: minutesPastEnd, + }, metadata: { medicationId: med.id }, }); } diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts index 09e35cf81..5bc872dbe 100644 --- a/src/app/api/admin/notifications/test/route.ts +++ b/src/app/api/admin/notifications/test/route.ts @@ -12,6 +12,8 @@ import type { ChannelType, NotificationPayload, } from "@/lib/notifications/types"; +import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; export const dynamic = "force-dynamic"; @@ -59,6 +61,11 @@ async function ensureTelegramChannel(userId: string): Promise { return true; } +function isLocale(value: string | null | undefined): value is Locale { + return value === "de" || value === "en" || value === "fr" || + value === "es" || value === "it" || value === "pl"; +} + export const POST = apiHandler(async () => { const { user } = await requireAdmin(); annotate({ action: { name: "admin.notifications.test" } }); @@ -84,12 +91,26 @@ export const POST = apiHandler(async () => { }); } + // v1.4.27 F21 — resolve the admin's persisted locale so the test + // notification arrives in their preferred language. The test route + // talks to the senders directly (not the dispatcher) because it + // needs per-channel success / error feedback for the UI, so we + // compose the payload locally rather than via + // `dispatchLocalisedNotification`. + const adminLocaleRow = await prisma.user.findUnique({ + where: { id: user.id }, + select: { locale: true }, + }); + const adminLocale: Locale = isLocale(adminLocaleRow?.locale) + ? adminLocaleRow.locale + : defaultLocale; + const t = getServerTranslator(adminLocale).t; + const payload: NotificationPayload = { eventType: "SYSTEM_ALERT", userId: user.id, - title: "Test-Notification", - message: - "HealthLog Test: If you see this message, your notifications are working!", + title: t("notifications.admin.testNotificationTitle"), + message: t("notifications.admin.testNotificationBody"), }; const results: Array<{ diff --git a/src/app/api/internal/deploy-webhook/__tests__/route.test.ts b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts index 4019b6323..04a21adae 100644 --- a/src/app/api/internal/deploy-webhook/__tests__/route.test.ts +++ b/src/app/api/internal/deploy-webhook/__tests__/route.test.ts @@ -9,7 +9,7 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/db", () => ({ prisma: { - user: { findMany: vi.fn() }, + user: { findMany: vi.fn(), findUnique: vi.fn() }, }, })); @@ -21,6 +21,14 @@ vi.mock("@/lib/notifications/dispatcher", () => ({ dispatchNotification: vi.fn().mockResolvedValue(undefined), })); +// v1.4.27 F21 — the deploy-webhook route now wraps every admin alert +// in `dispatchLocalisedNotification`, which delegates to the base +// dispatcher. Mock the localised helper so the route-level tests stay +// hermetic and we can assert recipient-specific calls directly. +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: vi.fn().mockResolvedValue(undefined), +})); + vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), rateLimitHeaders: vi.fn(() => ({})), @@ -38,6 +46,7 @@ import { POST, GET } from "../route"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; import { checkRateLimit } from "@/lib/rate-limit"; import { getEvent } from "@/lib/logging/context"; @@ -142,10 +151,11 @@ describe("POST /api/internal/deploy-webhook", () => { }), }), ); + expect(dispatchLocalisedNotification).not.toHaveBeenCalled(); expect(dispatchNotification).not.toHaveBeenCalled(); }); - it("failure payload writes system.deploy.failure audit row AND fans a SYSTEM_ALERT to every admin", async () => { + it("failure payload writes system.deploy.failure audit row AND fans a localised SYSTEM_ALERT to every admin", async () => { vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ { id: "admin-1" }, { id: "admin-2" }, @@ -176,20 +186,30 @@ describe("POST /api/internal/deploy-webhook", () => { }), ); - expect(dispatchNotification).toHaveBeenCalledTimes(2); - expect(dispatchNotification).toHaveBeenCalledWith( + // v1.4.27 F21 — the route now wraps every admin alert in + // `dispatchLocalisedNotification`, which composes the title and + // body from translation keys against the admin's locale before + // delegating to the base dispatcher. The test asserts the per-admin + // call carries the right translation keys and params; the actual + // string composition is covered in admin-locale.test.ts. + expect(dispatchLocalisedNotification).toHaveBeenCalledTimes(2); + expect(dispatchLocalisedNotification).toHaveBeenCalledWith( expect.objectContaining({ - eventType: "SYSTEM_ALERT", userId: "admin-1", - title: expect.stringContaining("Deploy failed"), - message: expect.stringContaining("container exited 137"), + titleKey: "notifications.admin.deployFailedTitle", + messageKey: "notifications.admin.deployFailedBody", + params: expect.objectContaining({ + application: "HealthLog", + error: "container exited 137", + deployment: "dep-456", + }), metadata: expect.objectContaining({ source: "deploy-webhook", deploymentUuid: "dep-456", }), }), ); - expect(dispatchNotification).toHaveBeenCalledWith( + expect(dispatchLocalisedNotification).toHaveBeenCalledWith( expect.objectContaining({ userId: "admin-2" }), ); }); @@ -208,7 +228,7 @@ describe("POST /api/internal/deploy-webhook", () => { "system.deploy.unknown", expect.any(Object), ); - expect(dispatchNotification).not.toHaveBeenCalled(); + expect(dispatchLocalisedNotification).not.toHaveBeenCalled(); }); it("emits a Wide Event warning when a deploy fails but no admin user exists", async () => { @@ -229,7 +249,7 @@ describe("POST /api/internal/deploy-webhook", () => { expect(addWarning).toHaveBeenCalledWith( expect.stringMatching(/no admin user/i), ); - expect(dispatchNotification).not.toHaveBeenCalled(); + expect(dispatchLocalisedNotification).not.toHaveBeenCalled(); }); it("returns 429 and skips audit + alert when rate-limited", async () => { @@ -247,7 +267,7 @@ describe("POST /api/internal/deploy-webhook", () => { ); expect(res.status).toBe(429); expect(auditLog).not.toHaveBeenCalled(); - expect(dispatchNotification).not.toHaveBeenCalled(); + expect(dispatchLocalisedNotification).not.toHaveBeenCalled(); }); it("tolerates malformed (non-string) status fields by treating them as unknown", async () => { @@ -257,7 +277,7 @@ describe("POST /api/internal/deploy-webhook", () => { expect(res.status).toBe(200); const body = (await res.json()) as { outcome: string }; expect(body.outcome).toBe("unknown"); - expect(dispatchNotification).not.toHaveBeenCalled(); + expect(dispatchLocalisedNotification).not.toHaveBeenCalled(); }); it("preserves the full Coolify payload in the audit-log raw field for forward compat", async () => { diff --git a/src/app/api/internal/deploy-webhook/route.ts b/src/app/api/internal/deploy-webhook/route.ts index 452367d26..12baf3bb0 100644 --- a/src/app/api/internal/deploy-webhook/route.ts +++ b/src/app/api/internal/deploy-webhook/route.ts @@ -5,7 +5,7 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { getClientIp, safeJson } from "@/lib/api-response"; -import { dispatchNotification } from "@/lib/notifications/dispatcher"; +import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; import { prisma } from "@/lib/db"; /** @@ -119,22 +119,24 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise { return; } - const title = `Deploy failed: ${event.applicationName}`; - const message = [ - `The Coolify deploy for ${event.applicationName} reported a failure.`, - event.error ? `Error: ${event.error}` : null, - event.deploymentUuid ? `Deployment: ${event.deploymentUuid}` : null, - "Logs: https://apps-01.bombeck.io", - ] - .filter((line): line is string => line !== null) - .join("\n"); - + // v1.4.27 F21 — each admin sees the alert in their own User.locale. + // The body is composed inside `dispatchLocalisedNotification` from + // translation keys; the static "Error:" / "Deployment:" / "Logs:" + // labels are emitted as a single param-substituted body key so all + // six locale bundles can render them natively. The deployment-uuid + // and error lines fall back to an empty string when missing so the + // surrounding template still composes cleanly. for (const admin of admins) { - await dispatchNotification({ - eventType: "SYSTEM_ALERT", + await dispatchLocalisedNotification({ userId: admin.id, - title, - message, + titleKey: "notifications.admin.deployFailedTitle", + messageKey: "notifications.admin.deployFailedBody", + params: { + application: event.applicationName, + error: event.error ?? "", + deployment: event.deploymentUuid ?? "", + logsUrl: "https://apps-01.bombeck.io", + }, metadata: { source: "deploy-webhook", applicationName: event.applicationName, diff --git a/src/app/api/settings/__tests__/telegram-test-locale.test.ts b/src/app/api/settings/__tests__/telegram-test-locale.test.ts new file mode 100644 index 000000000..304dd3834 --- /dev/null +++ b/src/app/api/settings/__tests__/telegram-test-locale.test.ts @@ -0,0 +1,202 @@ +/** + * v1.4.27 F21 — settings/telegram/test locale path. + * + * Asserts the per-user Telegram test message renders in the user's + * persisted `User.locale` rather than the hardcoded English string + * the pre-fix route used. The actual translator behaviour is covered + * by `notifications/__tests__/admin-locale.test.ts`; this file is + * the route-level smoke test that confirms the wiring. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ ok: true, data }), + apiError: (message: string, status: number) => ({ + ok: false, + error: message, + status, + }), +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: (s: string) => `decrypted(${s})`, +})); + +const sendTelegramMessageMock = vi.fn(); +vi.mock("@/lib/telegram", () => ({ + sendTelegramMessage: (...args: unknown[]) => sendTelegramMessageMock(...args), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), +})); + +const tCalls: Array<{ locale: string; key: string }> = []; +vi.mock("@/lib/i18n/server-translator", () => ({ + getServerTranslator: (locale: string) => ({ + locale, + t: (key: string) => { + tCalls.push({ locale, key }); + const fakeStrings: Record> = { + de: { + "notifications.user.telegramTestBody": + "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.", + }, + en: { + "notifications.user.telegramTestBody": + "HealthLog: connection successful. Telegram notifications are active.", + }, + fr: { + "notifications.user.telegramTestBody": + "HealthLog: connexion réussie. Les notifications Telegram sont actives.", + }, + }; + return (fakeStrings[locale] ?? fakeStrings.en)[key] ?? key; + }, + }), +})); + +import { POST } from "../telegram/test/route"; +import { prisma } from "@/lib/db"; +import { requireAuth } from "@/lib/api-handler"; +import { checkRateLimit } from "@/lib/rate-limit"; + +beforeEach(() => { + vi.resetAllMocks(); + tCalls.length = 0; + vi.mocked(requireAuth).mockResolvedValue({ + user: { id: "user-1" }, + } as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 5, + resetAt: Date.now() + 60_000, + } as never); + sendTelegramMessageMock.mockResolvedValue({ ok: true }); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("POST /api/settings/telegram/test — locale-aware message", () => { + it("sends the German body when User.locale=de", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + telegramBotToken: "enc-token", + telegramChatId: "chat-1", + locale: "de", + } as never); + + const result = await POST(new Request("http://x") as never); + + expect(sendTelegramMessageMock).toHaveBeenCalledTimes(1); + const [, , body] = sendTelegramMessageMock.mock.calls[0]; + expect(body).toBe( + "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv.", + ); + expect(tCalls).toEqual([ + { + locale: "de", + key: "notifications.user.telegramTestBody", + }, + ]); + expect((result as { ok: boolean }).ok).toBe(true); + }); + + it("sends the French body when User.locale=fr", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + telegramBotToken: "enc-token", + telegramChatId: "chat-1", + locale: "fr", + } as never); + + await POST(new Request("http://x") as never); + + const [, , body] = sendTelegramMessageMock.mock.calls[0]; + expect(body).toBe( + "HealthLog: connexion réussie. Les notifications Telegram sont actives.", + ); + }); + + it("falls back to the default locale (en) when User.locale is null", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + telegramBotToken: "enc-token", + telegramChatId: "chat-1", + locale: null, + } as never); + + await POST(new Request("http://x") as never); + + const [, , body] = sendTelegramMessageMock.mock.calls[0]; + expect(body).toBe( + "HealthLog: connection successful. Telegram notifications are active.", + ); + expect(tCalls[0].locale).toBe("en"); + }); + + it("falls back to the default locale (en) when User.locale is unsupported", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + telegramBotToken: "enc-token", + telegramChatId: "chat-1", + locale: "klingon", + } as never); + + await POST(new Request("http://x") as never); + + expect(tCalls[0].locale).toBe("en"); + }); + + it("returns a 422 error when bot token is not configured", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + telegramBotToken: null, + telegramChatId: null, + locale: "de", + } as never); + + const result = await POST(new Request("http://x") as never); + + expect(sendTelegramMessageMock).not.toHaveBeenCalled(); + expect((result as { status: number }).status).toBe(422); + }); + + it("returns a 429 error when rate-limited", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + + const result = await POST(new Request("http://x") as never); + + expect(sendTelegramMessageMock).not.toHaveBeenCalled(); + expect((result as { status: number }).status).toBe(429); + }); + + it("returns a 422 error when the Telegram send fails", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + telegramBotToken: "enc-token", + telegramChatId: "chat-1", + locale: "en", + } as never); + sendTelegramMessageMock.mockResolvedValueOnce({ ok: false }); + + const result = await POST(new Request("http://x") as never); + + expect((result as { status: number }).status).toBe(422); + }); +}); diff --git a/src/app/api/settings/telegram/test/route.ts b/src/app/api/settings/telegram/test/route.ts index 2c69ca029..64eb2bac3 100644 --- a/src/app/api/settings/telegram/test/route.ts +++ b/src/app/api/settings/telegram/test/route.ts @@ -5,6 +5,13 @@ import { sendTelegramMessage } from "@/lib/telegram"; import { checkRateLimit } from "@/lib/rate-limit"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { defaultLocale, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +function isLocale(value: string | null | undefined): value is Locale { + return value === "de" || value === "en" || value === "fr" || + value === "es" || value === "it" || value === "pl"; +} /** * Send a test Telegram message to verify the bot token and chat ID. @@ -19,18 +26,27 @@ export const POST = apiHandler(async () => { const dbUser = await prisma.user.findUnique({ where: { id: user.id }, - select: { telegramBotToken: true, telegramChatId: true }, + select: { telegramBotToken: true, telegramChatId: true, locale: true }, }); if (!dbUser?.telegramBotToken || !dbUser?.telegramChatId) { return apiError("Bot token and chat ID must be saved first", 422); } + // v1.4.27 F21 — the test body now reads from the user's persisted + // locale rather than the hardcoded English string. The route talks + // to `sendTelegramMessage` directly (it needs the boolean send + // result for the UI) so we compose the body via the server + // translator rather than going through the dispatcher. + const userLocale: Locale = isLocale(dbUser.locale) + ? dbUser.locale + : defaultLocale; + const t = getServerTranslator(userLocale).t; const botToken = decrypt(dbUser.telegramBotToken); const result = await sendTelegramMessage( botToken, dbUser.telegramChatId, - "HealthLog: Connection successful! Telegram notifications are active.", + t("notifications.user.telegramTestBody"), ); if (!result.ok) {