diff --git a/CHANGELOG.md b/CHANGELOG.md index 2462605f..66a0de1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project will be documented in this file. +## [28.0] - 2026-XX-XX + +### New Features + +- **Template i18n**: Auto-select email content based on contact language (#268) + - **Liquid `t` filter**: Use `{{ "key" | t }}` in templates to reference translation keys + - **Placeholder support**: Pass dynamic values with `{{ "greeting" | t: name: contact.first_name }}` + - **Nested keys**: Dot-separated key paths (e.g., `welcome.heading`, `cta.button`) + - **Per-template translations**: Store translation key-value maps per locale as part of the template + - **Workspace translations**: Shared translation catalog available to all templates in a workspace + - **Automatic locale resolution**: Fallback chain from `contact.language` → base language → template default → workspace default + - **Translations panel**: Manage translation keys and per-locale values in the template editor + - **Import/Export**: Bulk upload/download translations as JSON files per locale +- **Workspace language settings**: Configure default language and supported languages in workspace settings + +### Database Migration + +- Added `translations` JSONB column and `default_language` VARCHAR column to `templates` table (workspace migration) +- Created `workspace_translations` table for workspace-level shared translations (workspace migration) + ## [27.2] - 2026-02-21 - **Contacts**: Fixed panic (502) when calling `/api/contacts.list` without the `limit` parameter (#264) diff --git a/CLAUDE.md b/CLAUDE.md index 3bcb6786..197ac9eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,19 @@ func (m *V6Migration) UpdateSystem(ctx context.Context, config *config.Config, d - **MJML Support**: gomjml v0.10.0 for email rendering - **HTML Parsing**: PuerkitoBio/goquery v1.10.3 +#### Translation Filter (v28.0+) + +Templates can reference translatable strings using the Liquid `t` filter: + +```liquid +{{ "welcome.heading" | t }} +{{ "welcome.greeting" | t: name: contact.first_name }} +``` + +Translations are stored per-locale as nested JSON on the template's `translations` field. The system resolves the best locale from `contact.language` with a fallback chain: exact match → base language → template default → workspace default. + +Workspace-level shared translations (in the `workspace_translations` table) serve as a fallback when a key is not found in the template's own translations. Template translations take priority over workspace translations. + ### Observability & Monitoring - **Logging**: Zerolog v1.33.0 (structured logging) diff --git a/config/config.go b/config/config.go index 31a6bbae..d3c20d82 100644 --- a/config/config.go +++ b/config/config.go @@ -14,7 +14,7 @@ import ( "github.com/spf13/viper" ) -const VERSION = "27.2" +const VERSION = "28.0" type Config struct { Server ServerConfig diff --git a/console/src/components/settings/LanguageSettings.tsx b/console/src/components/settings/LanguageSettings.tsx new file mode 100644 index 00000000..6f3b8dba --- /dev/null +++ b/console/src/components/settings/LanguageSettings.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react' +import { Button, Form, Select, App, Descriptions } from 'antd' +import { useLingui } from '@lingui/react/macro' +import { Workspace } from '../../services/api/types' +import { workspaceService } from '../../services/api/workspace' +import { SettingsSectionHeader } from './SettingsSectionHeader' + +const LANGUAGE_OPTIONS = [ + { value: 'en', label: 'English' }, + { value: 'fr', label: 'French' }, + { value: 'de', label: 'German' }, + { value: 'es', label: 'Spanish' }, + { value: 'pt', label: 'Portuguese' }, + { value: 'pt-BR', label: 'Portuguese (Brazil)' }, + { value: 'it', label: 'Italian' }, + { value: 'nl', label: 'Dutch' }, + { value: 'ja', label: 'Japanese' }, + { value: 'ko', label: 'Korean' }, + { value: 'zh', label: 'Chinese' }, + { value: 'ru', label: 'Russian' }, + { value: 'ar', label: 'Arabic' }, + { value: 'hi', label: 'Hindi' }, + { value: 'tr', label: 'Turkish' }, + { value: 'pl', label: 'Polish' }, + { value: 'sv', label: 'Swedish' }, + { value: 'da', label: 'Danish' }, + { value: 'fi', label: 'Finnish' }, + { value: 'nb', label: 'Norwegian' } +] + +interface LanguageSettingsProps { + workspace: Workspace | null + onWorkspaceUpdate: (workspace: Workspace) => void + isOwner: boolean +} + +export function LanguageSettings({ workspace, onWorkspaceUpdate, isOwner }: LanguageSettingsProps) { + const { t } = useLingui() + const [savingSettings, setSavingSettings] = useState(false) + const [formTouched, setFormTouched] = useState(false) + const [form] = Form.useForm() + const { message } = App.useApp() + + useEffect(() => { + if (!isOwner) return + + form.setFieldsValue({ + default_language: workspace?.settings.default_language || 'en', + supported_languages: workspace?.settings.supported_languages || ['en'] + }) + setFormTouched(false) + }, [workspace, form, isOwner]) + + const handleSaveSettings = async (values: { + default_language: string + supported_languages: string[] + }) => { + if (!workspace) return + + // Ensure the default language is included in supported languages + let supportedLanguages = values.supported_languages || [] + if (!supportedLanguages.includes(values.default_language)) { + supportedLanguages = [values.default_language, ...supportedLanguages] + } + + setSavingSettings(true) + try { + await workspaceService.update({ + ...workspace, + settings: { + ...workspace.settings, + default_language: values.default_language, + supported_languages: supportedLanguages + } + }) + + // Refresh the workspace data + const response = await workspaceService.get(workspace.id) + + // Update the parent component with the new workspace data + onWorkspaceUpdate(response.workspace) + + setFormTouched(false) + message.success(t`Language settings updated successfully`) + } catch (error: unknown) { + console.error('Failed to update language settings', error) + const errorMessage = (error as Error)?.message || t`Failed to update language settings` + message.error(errorMessage) + } finally { + setSavingSettings(false) + } + } + + const handleFormChange = () => { + setFormTouched(true) + } + + const getLabelForLanguage = (code: string) => { + const option = LANGUAGE_OPTIONS.find((o) => o.value === code) + return option ? option.label : code + } + + if (!isOwner) { + const defaultLang = workspace?.settings.default_language || 'en' + const supportedLangs = workspace?.settings.supported_languages || ['en'] + + return ( + <> + + + + + {getLabelForLanguage(defaultLang)} + + + + {supportedLangs.map((lang) => getLabelForLanguage(lang)).join(', ')} + + + + ) + } + + return ( + <> + + +
+ + + + + + + +
+ + ) +} diff --git a/console/src/components/settings/SettingsSidebar.tsx b/console/src/components/settings/SettingsSidebar.tsx index 4f0deb18..07ca33b9 100644 --- a/console/src/components/settings/SettingsSidebar.tsx +++ b/console/src/components/settings/SettingsSidebar.tsx @@ -4,7 +4,8 @@ import { TagsOutlined, SettingOutlined, ExclamationCircleOutlined, - MailOutlined + MailOutlined, + GlobalOutlined } from '@ant-design/icons' import { useLingui } from '@lingui/react/macro' @@ -15,6 +16,7 @@ export type SettingsSection = | 'custom-fields' | 'smtp-relay' | 'general' + | 'languages' | 'blog' | 'danger-zone' @@ -107,6 +109,11 @@ export function SettingsSidebar({ activeSection, onSectionChange, isOwner }: Set icon: , label: t`SMTP Relay` }, + { + key: 'languages', + icon: , + label: t`Languages` + }, { key: 'general', icon: , diff --git a/console/src/pages/WorkspaceSettingsPage.tsx b/console/src/pages/WorkspaceSettingsPage.tsx index 379d301e..fe547281 100644 --- a/console/src/pages/WorkspaceSettingsPage.tsx +++ b/console/src/pages/WorkspaceSettingsPage.tsx @@ -13,6 +13,7 @@ import { BlogSettings } from '../components/settings/BlogSettings' import { WebhooksSettings } from '../components/settings/WebhooksSettings' import { useAuth } from '../contexts/AuthContext' import { DeleteWorkspaceSection } from '../components/settings/DeleteWorkspace' +import { LanguageSettings } from '../components/settings/LanguageSettings' import { SettingsSidebar, SettingsSection } from '../components/settings/SettingsSidebar' const { Sider, Content } = Layout @@ -37,6 +38,7 @@ export function WorkspaceSettingsPage() { 'custom-fields', 'smtp-relay', 'general', + 'languages', 'blog', 'danger-zone' ] @@ -144,6 +146,14 @@ export function WorkspaceSettingsPage() { isOwner={isOwner} /> ) + case 'languages': + return ( + + ) case 'blog': return ( settings?: Record + translations?: Record> // locale → nested key-value + default_language?: string created_at: string updated_at: string } diff --git a/console/src/services/api/workspace-translations.ts b/console/src/services/api/workspace-translations.ts new file mode 100644 index 00000000..bf78b5aa --- /dev/null +++ b/console/src/services/api/workspace-translations.ts @@ -0,0 +1,43 @@ +import { api } from './client' + +export interface WorkspaceTranslation { + locale: string + content: Record + created_at: string + updated_at: string +} + +export interface UpsertWorkspaceTranslationRequest { + workspace_id: string + locale: string + content: Record +} + +export interface ListWorkspaceTranslationsResponse { + translations: WorkspaceTranslation[] +} + +export interface DeleteWorkspaceTranslationRequest { + workspace_id: string + locale: string +} + +export interface WorkspaceTranslationsApi { + list: (workspaceId: string) => Promise + upsert: (params: UpsertWorkspaceTranslationRequest) => Promise + delete: (params: DeleteWorkspaceTranslationRequest) => Promise +} + +export const workspaceTranslationsApi: WorkspaceTranslationsApi = { + list: async (workspaceId: string) => { + return api.get( + `/api/workspace_translations.list?workspace_id=${workspaceId}` + ) + }, + upsert: async (params: UpsertWorkspaceTranslationRequest) => { + return api.post('/api/workspace_translations.upsert', params) + }, + delete: async (params: DeleteWorkspaceTranslationRequest) => { + return api.post('/api/workspace_translations.delete', params) + } +} diff --git a/docs/plans/2026-02-24-template-i18n-design.md b/docs/plans/2026-02-24-template-i18n-design.md new file mode 100644 index 00000000..91f473bc --- /dev/null +++ b/docs/plans/2026-02-24-template-i18n-design.md @@ -0,0 +1,298 @@ +# Template-level i18n — Design Document + +**Issue**: [#268](https://github.com/Notifuse/notifuse/issues/268) +**Branch**: `feat/template-i18n` +**Date**: 2026-02-24 + +## Problem + +Contacts have a `language` field (synced via notification center since v27.2), but there is no built-in way to send email content in that language. Current workarounds — duplicating templates per language or using verbose `{% if contact.language == "fr" %}` blocks — are manual, error-prone, and don't scale. + +## Solution + +Translation keys with a string catalog, using a Liquid `t` filter. Templates reference translatable strings via `{{ "key" | t }}`, and translations are stored as nested JSON per locale alongside the template. The system resolves the correct locale at render time based on `contact.language`. + +## Design Decisions + +| Decision | Choice | +|---|---| +| Approach | Translation keys with string catalog (Option A from issue) | +| Scope | Both template-level and workspace-level translations | +| Resolution | Same `{{ "key" \| t }}` syntax; template-first, then workspace fallback | +| Key format | Nested, dot-separated (e.g., `welcome.heading`) | +| Syntax | Liquid filter: `{{ "key" \| t }}` with placeholder support via named args | +| Language config | Workspace default + per-template override | +| Storage | Inline JSONB on existing `templates` table + new `workspace_translations` table | +| Editor UX (v1) | Translations panel alongside the visual editor | + +## 1. Liquid `t` Filter + +### Syntax + +```liquid + +{{ "welcome.heading" | t }} + + +{{ "welcome.greeting" | t: name: contact.first_name }} + + +Subject: {{ "welcome.subject" | t }} +``` + +### Translation JSON (per locale) + +```json +{ + "welcome": { + "heading": "Welcome!", + "greeting": "Hello {{ name }}!", + "subject": "Welcome to Notifuse" + } +} +``` + +Placeholder values like `{{ name }}` inside translation strings are interpolated with the named arguments passed to the filter. + +### Implementation + +Register a `TranslationFilters` struct on the `SecureLiquidEngine` via `env.RegisterFilter()`: + +```go +type TranslationFilters struct { + translations map[string]interface{} // merged: template (priority) + workspace + locale string +} + +func (tf *TranslationFilters) T(key interface{}, args ...interface{}) interface{} { + keyStr := fmt.Sprintf("%v", key) + + // 1. Resolve nested key via dot-path traversal + value := resolveNestedKey(tf.translations, keyStr) + if value == "" { + return "[Missing translation: " + keyStr + "]" + } + + // 2. Interpolate placeholders if named args provided + if len(args) > 0 { + value = interpolatePlaceholders(value, args) + } + + return value +} +``` + +### Locale Resolution (fallback chain) + +``` +1. contact.language exact match (e.g., "pt-BR") +2. contact.language base match (e.g., "pt") +3. template.default_language (if set, overrides workspace) +4. workspace.default_language (e.g., "en") +``` + +### Translation Merging at Render Time + +``` +1. Load workspace translations for resolved locale +2. Load template translations for resolved locale +3. Deep-merge: template translations override workspace translations +4. Pass merged map to TranslationFilters +``` + +A template key `welcome.heading` shadows a workspace key `welcome.heading`, but a workspace key `common.footer` is accessible if the template doesn't define it. + +## 2. Data Model & Storage + +### Modified: `Template` struct + +```go +type Template struct { + // ... existing fields ... + Translations map[string]map[string]interface{} `json:"translations"` // locale → nested key-value + DefaultLanguage *string `json:"default_language"` // nullable, overrides workspace +} +``` + +### Modified: `WorkspaceSettings` struct (inside Workspace.Settings JSONB) + +```go +type WorkspaceSettings struct { + // ... existing fields ... + DefaultLanguage string `json:"default_language,omitempty"` // e.g., "en" + SupportedLanguages []string `json:"supported_languages,omitempty"` // e.g., ["en", "fr", "de"] +} +``` + +### New: `WorkspaceTranslation` entity + +```go +type WorkspaceTranslation struct { + Locale string `json:"locale"` + Content map[string]interface{} `json:"content"` // nested key-value + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} +``` + +## 3. Database Migration (V28) + +Non-breaking, additive migration. Existing templates get empty `{}` translations and `NULL` default_language (inheriting workspace default). + +### System database + +No schema changes needed. `default_language` and `supported_languages` are added to the `WorkspaceSettings` Go struct. Since `WorkspaceSettings` is stored as JSONB in the existing `workspaces.settings` column, the new fields are automatically handled — existing workspaces will have these fields absent in JSON, and Go will deserialize them as zero values (falling back to `"en"` and `["en"]` via helper methods). + +### Workspace database + +```sql +ALTER TABLE templates + ADD COLUMN IF NOT EXISTS translations JSONB NOT NULL DEFAULT '{}'::jsonb; +ALTER TABLE templates + ADD COLUMN IF NOT EXISTS default_language VARCHAR(10); + +CREATE TABLE IF NOT EXISTS workspace_translations ( + locale VARCHAR(10) NOT NULL PRIMARY KEY, + content JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); +``` + +## 4. API Surface + +### New endpoints (workspace translations) + +``` +POST /api/workspace_translations.upsert — create/update translations for a locale +GET /api/workspace_translations.list — list all workspace translations +POST /api/workspace_translations.delete — delete translations for a locale +POST /api/workspace_translations.import — bulk import JSON per locale +GET /api/workspace_translations.export — export all translations as JSON +``` + +### Modified endpoints (templates) + +Template translations are part of the existing `templates.create` and `templates.update` payloads via the new `translations` field. No new template-specific endpoints needed. + +### Transactional API (no changes) + +Language resolution is automatic from `contact.language`: + +```json +{ + "template_id": "welcome_email", + "contact": { "email": "user@example.com", "language": "fr" } +} +``` + +### Send flow + +``` +SendNotification() + → resolve template + → resolve locale from contact.language + fallback chain + → load workspace translations for locale + → merge template translations (priority) over workspace translations + → register TranslationFilters with merged map + → render Liquid (subject + body) — t filter resolves keys + → compile MJML → HTML + → send +``` + +Broadcasts follow the same flow, but per-contact in the batch loop. Template + workspace translations are loaded once; only locale resolution changes per contact. + +## 5. Frontend — Translations Panel + +New component `console/src/components/templates/TranslationsPanel.tsx`, integrated into the existing template editor drawer. + +### Behavior + +- A "Translations" tab/button in the template editor +- Collapsible list of translation keys grouped by nested prefix +- Each key expands to show input fields per supported language (from workspace `supported_languages`) +- Default language value is required, others optional +- "Add Key" button for creating new keys with dot-path input +- Import/Export buttons for bulk JSON per locale + +### Wireframe + +``` +┌─ Translations Panel ──────────────────────────┐ +│ │ +│ Language: [en ▾] (preview selector) │ +│ │ +│ ▼ welcome │ +│ heading │ +│ en: [Welcome! ] ✓ │ +│ fr: [Bienvenue ! ] │ +│ de: [Willkommen! ] │ +│ │ +│ greeting │ +│ en: [Hello {{ name }}! ] ✓ │ +│ fr: [Bonjour {{ name }} ! ] │ +│ de: [ ] ⚠ │ +│ │ +│ ▼ cta │ +│ button │ +│ en: [Get Started ] ✓ │ +│ fr: [Commencer ] │ +│ de: [Loslegen ] │ +│ │ +│ [+ Add Key] [Import JSON] [Export JSON] │ +└────────────────────────────────────────────────┘ + +✓ = default language (required) +⚠ = missing translation (will fall back to default) +``` + +### Workspace translations UI + +Not in v1 scope. Workspace-level translations are managed via API (import/export JSON). A dedicated settings page can come later. + +## 6. Import/Export Format + +Per-locale JSON files with nested structure, matching internal storage: + +```json +// en.json +{ + "welcome": { + "heading": "Welcome!", + "greeting": "Hello {{ name }}!" + }, + "cta": { + "button": "Get Started" + } +} +``` + +Export produces one JSON file per locale. Import uses upsert semantics: new keys are added, existing keys are overwritten, absent keys are untouched. Both template-level and workspace-level translations use the same format. + +## 7. Testing Strategy + +| Layer | What to test | +|---|---| +| Domain | `Template.Validate()` with translations, locale fallback resolution, nested key resolution, placeholder interpolation | +| Service | Translation merging (template over workspace), `CompileTemplate` with translations, language resolution from contact | +| Repository | CRUD with translations JSONB, `workspace_translations` table operations | +| HTTP | New `workspace_translations` endpoints, template create/update with translations | +| Liquid filter | `T` filter: simple key, nested key, missing key fallback, placeholders with named args, locale resolution chain | +| Migration | V28 idempotency | +| Frontend | TranslationsPanel component, import/export flow | + +### Edge cases + +- Contact with no `language` set → falls back to workspace default +- Contact with `pt-BR` when only `pt` translations exist → base language match +- Translation key exists in workspace but not template → workspace value used +- Translation value contains Liquid (`{{ contact.first_name }}`) → rendered correctly after `t` filter resolves +- Empty translations `{}` → template renders with fallback markers (`[Missing translation: key]`) + +## Prior Art + +- **Novu**: Translation keys (`{{t.key}}`) with i18next under the hood. Enterprise feature. Uses preprocessing trick to protect keys from Liquid rendering. No placeholder support in filter syntax. +- **Shopify**: `{{ "key" | t: name: value }}` filter with named args for placeholders. Nested JSON locale files. Our approach is closest to this. +- **Symfony/Twig**: `{{ "key" | trans({"%name%": value}) }}` filter. Similar concept, different placeholder syntax. + +Our design takes Shopify's filter approach (most natural for Liquid) with Novu's dual-scope model (template + workspace translations) and clean fallback chain. diff --git a/docs/plans/2026-02-24-template-i18n-docs.md b/docs/plans/2026-02-24-template-i18n-docs.md new file mode 100644 index 00000000..b0531a56 --- /dev/null +++ b/docs/plans/2026-02-24-template-i18n-docs.md @@ -0,0 +1,513 @@ +# Template i18n Documentation Update Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Update all project documentation to reflect the template i18n feature (v28.0). + +**Architecture:** Update OpenAPI specs (schemas + paths + root), CHANGELOG, and CLAUDE.md. No external docs site changes (docs.notifuse.com is maintained separately). + +**Tech Stack:** YAML (OpenAPI 3.0.3), Markdown. + +**Design doc:** `docs/plans/2026-02-24-template-i18n-design.md` + +--- + +## Task 1: Update OpenAPI Template Schema + +Add `translations` and `default_language` fields to the Template schema, and update Create/Update/Compile request types. + +**Files:** +- Modify: `openapi/components/schemas/template.yaml` + +### Step 1: Add fields to Template schema + +After the `settings` property (line 64), add: + +```yaml + translations: + type: object + additionalProperties: + type: object + additionalProperties: true + description: | + Per-locale translation key-value maps. Keys are dot-separated paths (e.g., "welcome.heading"). + Values are strings that may contain {{ placeholder }} syntax for named arguments. + Outer keys are locale codes (e.g., "en", "fr", "pt-BR"). + example: + en: + welcome: + heading: "Welcome!" + greeting: "Hello {{ name }}!" + fr: + welcome: + heading: "Bienvenue !" + greeting: "Bonjour {{ name }} !" + default_language: + type: string + nullable: true + description: Override the workspace default language for this template. When null, inherits from workspace settings. + example: en + maxLength: 10 +``` + +### Step 2: Add to CreateTemplateRequest properties + +After `settings` (line 199), add: + +```yaml + translations: + type: object + additionalProperties: + type: object + additionalProperties: true + description: Per-locale translation key-value maps + default_language: + type: string + nullable: true + description: Override the workspace default language for this template + maxLength: 10 +``` + +### Step 3: Add to UpdateTemplateRequest properties + +Same addition as CreateTemplateRequest, after `settings` (line 260). + +### Step 4: Add `translations` to CompileTemplateRequest + +After `channel` (line 310), add: + +```yaml + translations: + type: object + additionalProperties: true + description: Merged translations map for a specific locale, used by the Liquid `t` filter during compilation +``` + +### Step 5: Verify YAML is valid + +Run: `cd /var/www/forks/notifuse && python3 -c "import yaml; yaml.safe_load(open('openapi/components/schemas/template.yaml'))" 2>&1 || echo "YAML invalid"` +Expected: No errors. + +### Step 6: Commit + +```bash +git add openapi/components/schemas/template.yaml +git commit -m "docs: add translations fields to OpenAPI template schema" +``` + +--- + +## Task 2: Create OpenAPI Workspace Translations Schema + +New schema file for the workspace translations API types. + +**Files:** +- Create: `openapi/components/schemas/workspace-translation.yaml` + +### Step 1: Create the schema file + +```yaml +WorkspaceTranslation: + type: object + properties: + locale: + type: string + description: Locale code (e.g., "en", "fr", "pt-BR") + example: en + maxLength: 10 + content: + type: object + additionalProperties: true + description: | + Nested key-value translation map. Keys use dot-separated paths. + Values are strings, optionally containing {{ placeholder }} syntax. + example: + common: + greeting: "Hello" + footer: "Unsubscribe from our emails" + created_at: + type: string + format: date-time + description: When the translation was created + updated_at: + type: string + format: date-time + description: When the translation was last updated + required: + - locale + - content + +UpsertWorkspaceTranslationRequest: + type: object + required: + - workspace_id + - locale + - content + properties: + workspace_id: + type: string + description: The ID of the workspace + example: ws_1234567890 + locale: + type: string + description: Locale code + example: fr + maxLength: 10 + content: + type: object + additionalProperties: true + description: Nested key-value translation map + example: + common: + greeting: "Bonjour" + footer: "Se désabonner de nos emails" + +DeleteWorkspaceTranslationRequest: + type: object + required: + - workspace_id + - locale + properties: + workspace_id: + type: string + description: The ID of the workspace + example: ws_1234567890 + locale: + type: string + description: Locale code to delete + example: fr +``` + +### Step 2: Commit + +```bash +git add openapi/components/schemas/workspace-translation.yaml +git commit -m "docs: add OpenAPI schema for workspace translations" +``` + +--- + +## Task 3: Create OpenAPI Workspace Translations Paths + +New paths file for the workspace translations API endpoints. + +**Files:** +- Create: `openapi/paths/workspace-translations.yaml` + +### Step 1: Create the paths file + +```yaml +/api/workspace_translations.list: + get: + summary: List workspace translations + description: Retrieves all workspace-level translations. Returns one entry per locale with its nested key-value content. + operationId: listWorkspaceTranslations + security: + - BearerAuth: [] + parameters: + - name: workspace_id + in: query + required: true + schema: + type: string + description: The ID of the workspace + example: ws_1234567890 + responses: + '200': + description: List of workspace translations retrieved successfully + content: + application/json: + schema: + type: object + properties: + translations: + type: array + items: + $ref: '../components/schemas/workspace-translation.yaml#/WorkspaceTranslation' + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' + +/api/workspace_translations.upsert: + post: + summary: Create or update workspace translation + description: | + Creates or updates translations for a specific locale at the workspace level. + If translations for the locale already exist, they are replaced. + Workspace translations are shared across all templates and resolved when a template + uses `{{ "key" | t }}` and the key is not found in the template's own translations. + operationId: upsertWorkspaceTranslation + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '../components/schemas/workspace-translation.yaml#/UpsertWorkspaceTranslationRequest' + responses: + '200': + description: Translation upserted successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' + +/api/workspace_translations.delete: + post: + summary: Delete workspace translation + description: Deletes all translations for a specific locale at the workspace level. + operationId: deleteWorkspaceTranslation + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '../components/schemas/workspace-translation.yaml#/DeleteWorkspaceTranslationRequest' + responses: + '200': + description: Translation deleted successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' +``` + +### Step 2: Commit + +```bash +git add openapi/paths/workspace-translations.yaml +git commit -m "docs: add OpenAPI paths for workspace translations API" +``` + +--- + +## Task 4: Update OpenAPI Root File + +Register the new schemas and paths in the root `openapi.yaml`. + +**Files:** +- Modify: `openapi/openapi.yaml` + +### Step 1: Add workspace translation paths + +After the templates paths block (after line 75: `/api/templates.compile`), add: + +```yaml + /api/workspace_translations.list: + $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.list' + /api/workspace_translations.upsert: + $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.upsert' + /api/workspace_translations.delete: + $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.delete' +``` + +### Step 2: Add workspace translation schema refs + +After the template schema refs in the `components.schemas` section (after line 220: `TrackingSettings`), add: + +```yaml + WorkspaceTranslation: + $ref: './components/schemas/workspace-translation.yaml#/WorkspaceTranslation' + UpsertWorkspaceTranslationRequest: + $ref: './components/schemas/workspace-translation.yaml#/UpsertWorkspaceTranslationRequest' + DeleteWorkspaceTranslationRequest: + $ref: './components/schemas/workspace-translation.yaml#/DeleteWorkspaceTranslationRequest' +``` + +### Step 3: Commit + +```bash +git add openapi/openapi.yaml +git commit -m "docs: register workspace translations in OpenAPI root" +``` + +--- + +## Task 5: Update CHANGELOG + +Add v28.0 entry to the changelog. + +**Files:** +- Modify: `CHANGELOG.md` + +### Step 1: Add v28.0 entry at the top (after line 3) + +```markdown +## [28.0] - 2026-XX-XX + +### New Features + +- **Template i18n**: Auto-select email content based on contact language (#268) + - **Liquid `t` filter**: Use `{{ "key" | t }}` in templates to reference translation keys + - **Placeholder support**: Pass dynamic values with `{{ "greeting" | t: name: contact.first_name }}` + - **Nested keys**: Dot-separated key paths (e.g., `welcome.heading`, `cta.button`) + - **Per-template translations**: Store translation key-value maps per locale as part of the template + - **Workspace translations**: Shared translation catalog available to all templates in a workspace + - **Automatic locale resolution**: Fallback chain from `contact.language` → base language → template default → workspace default + - **Translations panel**: Manage translation keys and per-locale values in the template editor + - **Import/Export**: Bulk upload/download translations as JSON files per locale +- **Workspace language settings**: Configure default language and supported languages in workspace settings + +### Database Migration + +- Added `translations` JSONB column and `default_language` VARCHAR column to `templates` table (workspace migration) +- Created `workspace_translations` table for workspace-level shared translations (workspace migration) +``` + +Use the actual release date when shipping. The `XX-XX` placeholder should be replaced at release time. + +### Step 2: Commit + +```bash +git add CHANGELOG.md +git commit -m "docs: add v28.0 changelog entry for template i18n" +``` + +--- + +## Task 6: Update CLAUDE.md — Migration Section + +Update the migration documentation section in CLAUDE.md to reflect V28 as the latest migration and add template i18n context. + +**Files:** +- Modify: `CLAUDE.md` + +### Step 1: Update the migration example + +In the CLAUDE.md section "Creating Database Migrations", the example shows V7. Update the comment about the current version number. Find the text: + +``` +2. **Create Migration File**: Create a new file in `internal/migrations/` (e.g., `v7.go`) +``` + +No change needed here — this is a generic example and doesn't reference a specific current version. But ensure the VERSION constant reference is accurate. Search for any mention of `VERSION = "27.2"` or similar — there shouldn't be one, as CLAUDE.md references it generically. + +### Step 2: Add template i18n to the "Available Data Structure" context + +In the CLAUDE.md section about templates or wherever the available template variables are documented, note that the `t` filter is now available: + +This may not be explicitly documented in CLAUDE.md. If there's a section about Liquid templating or available filters, add: + +```markdown +#### Translation Filter (v28.0+) + +Templates can reference translatable strings using the Liquid `t` filter: + +```liquid +{{ "welcome.heading" | t }} +{{ "welcome.greeting" | t: name: contact.first_name }} +``` + +Translations are stored per-locale as nested JSON on the template's `translations` field. The system resolves the best locale from `contact.language` with a fallback chain: exact match → base language → template default → workspace default. +``` + +### Step 3: Commit + +```bash +git add CLAUDE.md +git commit -m "docs: update CLAUDE.md with template i18n documentation" +``` + +--- + +## Task 7: Update Design Doc with Migration Correction + +Fix the design doc migration section — `WorkspaceSettings` is stored as JSONB in the `settings` column, so `default_language` and `supported_languages` go into the struct, not as separate table columns. The design doc currently shows `ALTER TABLE workspaces ADD COLUMN` which is incorrect. + +**Files:** +- Modify: `docs/plans/2026-02-24-template-i18n-design.md` + +### Step 1: Fix Section 3 (Database Migration) + +Replace the "System database" SQL block with: + +```markdown +### System database + +No schema changes needed. `default_language` and `supported_languages` are added to the `WorkspaceSettings` Go struct. Since `WorkspaceSettings` is stored as JSONB in the existing `workspaces.settings` column, the new fields are automatically handled — existing workspaces will have these fields absent in JSON, and Go will deserialize them as zero values (falling back to `"en"` and `["en"]` via helper methods). +``` + +### Step 2: Update Section 2 (Data Model) + +Replace the "Modified: `Workspace` struct" subsection to clarify these fields go on `WorkspaceSettings`, not `Workspace`: + +```markdown +### Modified: `WorkspaceSettings` struct (inside Workspace.Settings JSONB) + +```go +type WorkspaceSettings struct { + // ... existing fields ... + DefaultLanguage string `json:"default_language,omitempty"` // e.g., "en" + SupportedLanguages []string `json:"supported_languages,omitempty"` // e.g., ["en", "fr", "de"] +} +``` +``` + +### Step 3: Commit + +```bash +git add docs/plans/2026-02-24-template-i18n-design.md +git commit -m "docs: fix design doc migration section — language settings go in WorkspaceSettings JSONB" +``` + +--- + +## Summary + +| Task | File(s) | What changes | +|------|---------|-------------| +| 1 | `openapi/components/schemas/template.yaml` | Add `translations`, `default_language` to Template + request schemas | +| 2 | `openapi/components/schemas/workspace-translation.yaml` (new) | WorkspaceTranslation + request/response types | +| 3 | `openapi/paths/workspace-translations.yaml` (new) | `.list`, `.upsert`, `.delete` endpoint definitions | +| 4 | `openapi/openapi.yaml` | Register new paths and schemas | +| 5 | `CHANGELOG.md` | v28.0 entry with feature list + migration notes | +| 6 | `CLAUDE.md` | Add `t` filter documentation to templates/Liquid section | +| 7 | `docs/plans/2026-02-24-template-i18n-design.md` | Fix migration section (WorkspaceSettings JSONB, not new columns) | + +Tasks 1-4 are the OpenAPI updates (sequential — schemas before paths before root). +Task 5-6 are independent markdown updates. +Task 7 is a correction to an existing doc. diff --git a/docs/plans/2026-02-24-template-i18n-external-docs-design.md b/docs/plans/2026-02-24-template-i18n-external-docs-design.md new file mode 100644 index 00000000..48d0639c --- /dev/null +++ b/docs/plans/2026-02-24-template-i18n-external-docs-design.md @@ -0,0 +1,52 @@ +# Template i18n External Docs Update — Design Document + +**Related**: `docs/plans/2026-02-24-template-i18n-design.md` (feature design), `docs/plans/2026-02-24-template-i18n-docs.md` (internal docs plan) +**Repo**: https://github.com/Notifuse/docs (cloned to `/var/www/forks/notifuse-docs`) +**Framework**: Mintlify (MDX pages, `docs.json` nav, `openapi.json` API spec) +**Date**: 2026-02-24 + +## Goal + +Update the public docs site (docs.notifuse.com) to document the template i18n feature (v28.0). Users need to understand how to use the `t` filter, manage translations, and configure language settings. + +## Scope + +### New page + +**`features/template-translations.mdx`** — Dedicated guide for template internationalization. + +Sections: +1. **Overview** — What it does and why (auto-select email content based on contact language) +2. **The `t` Filter** — Liquid syntax with examples: simple key lookup, placeholder interpolation, usage in subject lines +3. **Translation Keys** — Dot-separated key paths, nested JSON structure, example per-locale JSON +4. **Per-Template Translations** — Translations panel in the template editor, managing keys and values per locale +5. **Workspace Translations** — Shared translation catalog available to all templates, API-managed, acts as fallback +6. **Locale Resolution** — Fallback chain: contact.language exact → base language → template default → workspace default +7. **Import/Export** — JSON format per locale, upsert semantics for import, one file per locale on export +8. **Best Practices** — Start with default language, use workspace translations for repeated strings (footers, CTAs), keep key naming consistent + +### Existing page updates + +| Page | What to add | +|------|-------------| +| `features/templates.mdx` | New "Translations" section after Liquid Syntax: brief `t` filter example + link to `features/template-translations` | +| `features/workspaces.mdx` | New "Language Settings" section: default language, supported languages, how they affect template rendering | +| `features/contacts.mdx` | Expand `language` field row description to mention it drives automatic locale resolution for translated templates | +| `features/transactional-api.mdx` | Add "Multi-Language Support" note: language is auto-resolved from `contact.language`, no API changes needed | +| `features/broadcast-campaigns.mdx` | Add "Multi-Language Support" note: per-contact language resolution happens automatically in the batch loop | + +### Navigation & API spec + +| File | What to change | +|------|----------------| +| `docs.json` | Add `features/template-translations` to Features group (after `features/templates`). Add "Workspace Translations" group to API Reference tab with list/upsert/delete endpoints. | +| `openapi.json` | Add 3 workspace_translations endpoints (GET list, POST upsert, POST delete). Update Template schema + CreateTemplateRequest + UpdateTemplateRequest with `translations` and `default_language` fields. Update CompileTemplateRequest with `translations` field. | + +## Design Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Dedicated page vs. expand templates.mdx | Dedicated page | Templates page is already 133 lines; i18n is a self-contained feature with enough depth for its own page | +| Where to place in nav | After "Templates" in Features | Natural reading order — learn about templates first, then translations | +| openapi.json updates | Yes | Keeps API Reference tab current; workspace_translations endpoints need to be discoverable | +| Existing page updates | Brief mentions + links | Avoids duplicating content; each page acknowledges the feature exists and links to the dedicated guide | diff --git a/docs/plans/2026-02-24-template-i18n-external-docs.md b/docs/plans/2026-02-24-template-i18n-external-docs.md new file mode 100644 index 00000000..71b0d481 --- /dev/null +++ b/docs/plans/2026-02-24-template-i18n-external-docs.md @@ -0,0 +1,839 @@ +# Template i18n External Docs Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Update the public docs site (docs.notifuse.com) to document the template i18n feature (v28.0). + +**Architecture:** Add a dedicated `features/template-translations.mdx` page as the primary guide, with brief mentions and links from existing pages (templates, workspaces, contacts, transactional API, broadcasts). Update `openapi.json` with workspace_translations endpoints and template schema changes. Update `docs.json` navigation. + +**Tech Stack:** Mintlify (MDX), OpenAPI 3.0.3 JSON. + +**Design doc:** `docs/plans/2026-02-24-template-i18n-external-docs-design.md` + +**Repo:** `/var/www/forks/notifuse-docs` (cloned from https://github.com/Notifuse/docs) + +--- + +## Task 1: Create the dedicated template translations page + +The main deliverable. New MDX page covering the full i18n feature. + +**Files:** +- Create: `features/template-translations.mdx` + +### Step 1: Create the page + +Create `features/template-translations.mdx` with this content: + +```mdx +--- +title: Template Translations +description: 'Send emails in your contacts'' preferred language using translation keys. Define translations per template or share them across your workspace, and Notifuse automatically selects the right language at send time.' +--- + +## Overview + +Template translations let you send email content in each contact's preferred language without duplicating templates. Instead of creating separate templates per language, you write a single template using **translation keys** and provide translations for each supported locale. + +When an email is sent, Notifuse automatically resolves the correct locale based on the contact's `language` field and renders the template with the matching translations. + +## The `t` Filter + +Use the Liquid `t` filter to reference translation keys in your templates: + +### Simple Key Lookup + +```liquid +{{ "welcome.heading" | t }} +``` + +If the contact's language is `fr`, this renders the French translation for the key `welcome.heading`. + +### Placeholders + +Pass dynamic values into translation strings using named arguments: + +```liquid +{{ "welcome.greeting" | t: name: contact.first_name }} +``` + +With the translation string `"Hello {{ name }}!"` and a contact named Sarah, this renders: **Hello Sarah!** + +### Subject Lines + +The `t` filter works in email subject lines too: + +```liquid +{{ "welcome.subject" | t }} +``` + +### Full Example + +**Template:** + +```liquid +{{ "welcome.heading" | t }} + +{{ "welcome.greeting" | t: name: contact.first_name }} + +{{ "welcome.body" | t }} + +{{ "cta.button" | t }} +``` + +**English translations:** + +```json +{ + "welcome": { + "heading": "Welcome!", + "greeting": "Hello {{ name }}!", + "body": "Thanks for joining us." + }, + "cta": { + "button": "Get Started" + } +} +``` + +**French translations:** + +```json +{ + "welcome": { + "heading": "Bienvenue !", + "greeting": "Bonjour {{ name }} !", + "body": "Merci de nous avoir rejoints." + }, + "cta": { + "button": "Commencer" + } +} +``` + +## Translation Keys + +Keys use **dot-separated paths** that map to a nested JSON structure. This keeps translations organized: + +| Key | JSON Path | +|-----|-----------| +| `welcome.heading` | `{ "welcome": { "heading": "..." } }` | +| `welcome.greeting` | `{ "welcome": { "greeting": "..." } }` | +| `cta.button` | `{ "cta": { "button": "..." } }` | +| `footer.unsubscribe` | `{ "footer": { "unsubscribe": "..." } }` | + +If a key is missing for the resolved locale, the template renders `[Missing translation: key.name]` so you can spot untranslated strings. + +## Per-Template Translations + +Each template has its own `translations` field — a JSON object keyed by locale code, containing the nested key-value translations for that locale. + +### Managing Translations in the Editor + +The template editor includes a **Translations panel** where you can: + +- Add, edit, and remove translation keys +- Provide values for each supported language +- See which keys are missing translations (shown with a warning indicator) +- Preview the template in different languages + +### Import / Export + +You can bulk-manage translations using JSON files: + +- **Export**: Downloads one JSON file per locale (e.g., `en.json`, `fr.json`) +- **Import**: Upload a JSON file for a specific locale. New keys are added, existing keys are overwritten, absent keys are left untouched. + +The JSON format matches the nested key structure: + +```json +{ + "welcome": { + "heading": "Welcome!", + "greeting": "Hello {{ name }}!" + }, + "cta": { + "button": "Get Started" + } +} +``` + +## Workspace Translations + +Workspace translations are a **shared translation catalog** available to all templates in a workspace. They act as a fallback — if a template doesn't define a key, the workspace translation is used instead. + +This is useful for strings that appear across many templates: + +- Footer text (`footer.unsubscribe`, `footer.company_name`) +- Common CTAs (`cta.learn_more`, `cta.contact_us`) +- Legal text (`legal.privacy`, `legal.terms`) + +Workspace translations are managed via the API: + +- `GET /api/workspace_translations.list` — List all workspace translations +- `POST /api/workspace_translations.upsert` — Create or update translations for a locale +- `POST /api/workspace_translations.delete` — Delete translations for a locale + +See the [API Reference](/api-reference) for full endpoint documentation. + +### Resolution Priority + +When a template uses `{{ "key" | t }}`, the system looks for the key in this order: + +1. **Template translations** for the resolved locale +2. **Workspace translations** for the resolved locale + +Template translations always take priority. A template key `welcome.heading` shadows a workspace key `welcome.heading`, but a workspace key `footer.unsubscribe` is accessible if the template doesn't define it. + +## Locale Resolution + +When an email is sent, Notifuse determines which locale to use with this fallback chain: + +| Priority | Source | Example | +|----------|--------|---------| +| 1 | Contact's `language` (exact match) | `pt-BR` → uses `pt-BR` translations | +| 2 | Contact's `language` (base language) | `pt-BR` → falls back to `pt` if no `pt-BR` | +| 3 | Template's `default_language` | If set, overrides the workspace default | +| 4 | Workspace's default language | Configured in workspace settings (e.g., `en`) | + +**Examples:** + +- Contact has `language: "fr"` → French translations are used +- Contact has `language: "pt-BR"`, no `pt-BR` translations exist, but `pt` does → Portuguese translations are used +- Contact has no `language` set → Falls back to the template default, then the workspace default +- Template has `default_language: "de"` → German is used as the fallback instead of the workspace default + +### Setting the Template Default Language + +Each template can optionally set a `default_language` that overrides the workspace default. This is useful when a template is primarily written in a specific language that differs from the workspace default. + +## Workspace Language Settings + +Configure language defaults in your workspace settings: + +- **Default Language**: The fallback language used when a contact has no `language` field set (e.g., `en`) +- **Supported Languages**: The list of languages your workspace supports (e.g., `["en", "fr", "de", "es"]`). This determines which locale columns appear in the translations panel. + +## Best Practices + +- **Start with your default language**: Always provide complete translations for your workspace's default language first. This ensures every contact sees content, even if their language isn't supported yet. +- **Use workspace translations for shared strings**: Footer text, legal disclaimers, and common CTAs should live in workspace translations to avoid duplication across templates. +- **Keep key names consistent**: Use a predictable naming convention like `section.element` (e.g., `welcome.heading`, `cta.button`, `footer.unsubscribe`). +- **Set `contact.language` on your contacts**: The i18n system relies on the contact's `language` field. Set it via the API, CSV import, or let the [Notification Center](/features/notification-center) auto-detect it. +- **Test with preview**: Use the template editor's language preview selector to check how your email looks in each supported language before sending. +``` + +### Step 2: Commit + +```bash +cd /var/www/forks/notifuse-docs +git add features/template-translations.mdx +git commit -m "docs: add template translations feature page" +``` + +--- + +## Task 2: Update templates.mdx with translation mention + +Add a brief section linking to the dedicated translations page. + +**Files:** +- Modify: `features/templates.mdx` + +### Step 1: Add Translations section + +After the "Available Data Structure" section (after line 133, at the end of the file), add: + +```mdx + +## Translations + +Templates support built-in internationalization using the Liquid `t` filter. Instead of duplicating templates per language, define translation keys and provide per-locale values: + +```liquid +{{ "welcome.heading" | t }} +{{ "welcome.greeting" | t: name: contact.first_name }} +``` + +Notifuse automatically selects the right language based on the contact's `language` field. For the full guide, see [Template Translations](/features/template-translations). +``` + +### Step 2: Commit + +```bash +cd /var/www/forks/notifuse-docs +git add features/templates.mdx +git commit -m "docs: add translations section to templates page" +``` + +--- + +## Task 3: Update workspaces.mdx with language settings + +Add a Language Settings section to the workspaces page. + +**Files:** +- Modify: `features/workspaces.mdx` + +### Step 1: Add Language Settings section + +After the "Multi-Tenant Architecture" section (after line 34, at the end of the file), add: + +```mdx + +## Language Settings + +Each workspace can configure language defaults that apply to all templates: + +- **Default Language**: The fallback language used when a contact has no `language` field set (e.g., `en`). All templates will use this as their final fallback. +- **Supported Languages**: The list of languages your workspace supports (e.g., English, French, German). This determines which locale columns appear in the template translations panel. + +These settings work with [Template Translations](/features/template-translations) to automatically send emails in each contact's preferred language. +``` + +### Step 2: Commit + +```bash +cd /var/www/forks/notifuse-docs +git add features/workspaces.mdx +git commit -m "docs: add language settings section to workspaces page" +``` + +--- + +## Task 4: Update contacts.mdx language field description + +Expand the `language` field description to mention its role in i18n. + +**Files:** +- Modify: `features/contacts.mdx` + +### Step 1: Update the language field row + +In the Contact Fields table (line 25), find: + +``` +| `language` | String | Preferred language | +``` + +Replace with: + +``` +| `language` | String | Preferred language (drives [automatic locale resolution](/features/template-translations#locale-resolution) for translated templates) | +``` + +### Step 2: Commit + +```bash +cd /var/www/forks/notifuse-docs +git add features/contacts.mdx +git commit -m "docs: expand language field description with i18n link" +``` + +--- + +## Task 5: Update transactional-api.mdx with multi-language note + +Add a note about automatic language resolution. + +**Files:** +- Modify: `features/transactional-api.mdx` + +### Step 1: Add Multi-Language Support section + +After the "Key Features" section's last subsection (after "Email Delivery Options", before "## API Endpoint" at line 57), add: + +```mdx + +### Multi-Language Support + +If your templates use [translation keys](/features/template-translations), Notifuse automatically selects the right language based on `contact.language`. No API changes are needed — just make sure your contacts have a `language` field set: + +```json +{ + "notification": { + "contact": { + "email": "user@example.com", + "language": "fr" + } + } +} +``` + +The contact's language is resolved through a [fallback chain](/features/template-translations#locale-resolution): exact match → base language → template default → workspace default. +``` + +### Step 2: Commit + +```bash +cd /var/www/forks/notifuse-docs +git add features/transactional-api.mdx +git commit -m "docs: add multi-language support note to transactional API page" +``` + +--- + +## Task 6: Update broadcast-campaigns.mdx with multi-language note + +Add a note about per-contact language resolution in broadcasts. + +**Files:** +- Modify: `features/broadcast-campaigns.mdx` + +### Step 1: Add Multi-Language Support section + +Before the "## Best Practices" section (before line 358), add: + +```mdx + +## Multi-Language Support + +If your broadcast template uses [translation keys](/features/template-translations), each recipient automatically receives the email in their preferred language based on their `language` field. + +The template and workspace translations are loaded once per broadcast. For each recipient, only the locale resolution changes — selecting the right translation set based on the contact's language. This means multi-language broadcasts have no significant performance overhead. + +See [Template Translations](/features/template-translations) for how to set up translation keys and manage per-locale content. + +``` + +### Step 2: Commit + +```bash +cd /var/www/forks/notifuse-docs +git add features/broadcast-campaigns.mdx +git commit -m "docs: add multi-language support note to broadcast campaigns page" +``` + +--- + +## Task 7: Update docs.json navigation + +Register the new page and API endpoints in the Mintlify navigation. + +**Files:** +- Modify: `docs.json` + +### Step 1: Add template-translations to Features nav + +In the `docs.json` file, find the Features group pages array. After `"features/templates"` (line 43), add `"features/template-translations"` as the next entry. + +Before: +```json + "features/templates", + "features/broadcast-campaigns", +``` + +After: +```json + "features/templates", + "features/template-translations", + "features/broadcast-campaigns", +``` + +### Step 2: Add Workspace Translations group to API Reference tab + +In the API Reference tab's groups array, after the Templates group (after line 153), add a new group: + +```json + { + "group": "Workspace Translations", + "openapi": "openapi.json", + "pages": [ + "GET /api/workspace_translations.list", + "POST /api/workspace_translations.upsert", + "POST /api/workspace_translations.delete" + ] + }, +``` + +### Step 3: Commit + +```bash +cd /var/www/forks/notifuse-docs +git add docs.json +git commit -m "docs: add template translations to navigation" +``` + +--- + +## Task 8: Update openapi.json — Template schemas + +Add `translations` and `default_language` fields to template-related schemas. + +**Files:** +- Modify: `openapi.json` + +### Step 1: Add fields to Template schema + +In the `Template` schema (components → schemas → Template → properties), after the `settings` property, add: + +```json + "translations": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + }, + "description": "Per-locale translation key-value maps. Keys are dot-separated paths (e.g., \"welcome.heading\"). Values are strings that may contain {{ placeholder }} syntax for named arguments. Outer keys are locale codes (e.g., \"en\", \"fr\", \"pt-BR\").", + "example": { + "en": { + "welcome": { + "heading": "Welcome!", + "greeting": "Hello {{ name }}!" + } + }, + "fr": { + "welcome": { + "heading": "Bienvenue !", + "greeting": "Bonjour {{ name }} !" + } + } + } + }, + "default_language": { + "type": "string", + "nullable": true, + "description": "Override the workspace default language for this template. When null, inherits from workspace settings.", + "example": "en", + "maxLength": 10 + }, +``` + +### Step 2: Add fields to CreateTemplateRequest schema + +In the `CreateTemplateRequest` schema (components → schemas → CreateTemplateRequest → properties), after the `settings` property, add: + +```json + "translations": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + }, + "description": "Per-locale translation key-value maps" + }, + "default_language": { + "type": "string", + "nullable": true, + "description": "Override the workspace default language for this template", + "maxLength": 10 + }, +``` + +### Step 3: Add fields to UpdateTemplateRequest schema + +Same as CreateTemplateRequest — add `translations` and `default_language` after `settings`. + +### Step 4: Add translations to CompileTemplateRequest schema + +In the `CompileTemplateRequest` schema, after the `channel` property, add: + +```json + "translations": { + "type": "object", + "additionalProperties": true, + "description": "Merged translations map for a specific locale, used by the Liquid t filter during compilation" + }, +``` + +### Step 5: Verify JSON is valid + +Run: `cd /var/www/forks/notifuse-docs && python3 -c "import json; json.load(open('openapi.json'))" 2>&1 || echo "JSON invalid"` + +### Step 6: Commit + +```bash +cd /var/www/forks/notifuse-docs +git add openapi.json +git commit -m "docs: add translations fields to OpenAPI template schemas" +``` + +--- + +## Task 9: Update openapi.json — Workspace Translations endpoints + +Add the three workspace translation API endpoints and their schemas. + +**Files:** +- Modify: `openapi.json` + +### Step 1: Add WorkspaceTranslation schema + +In the `components.schemas` section, add a new `WorkspaceTranslation` schema: + +```json + "WorkspaceTranslation": { + "type": "object", + "properties": { + "locale": { + "type": "string", + "description": "Locale code (e.g., \"en\", \"fr\", \"pt-BR\")", + "example": "en", + "maxLength": 10 + }, + "content": { + "type": "object", + "additionalProperties": true, + "description": "Nested key-value translation map. Keys use dot-separated paths. Values are strings, optionally containing {{ placeholder }} syntax.", + "example": { + "common": { + "greeting": "Hello", + "footer": "Unsubscribe from our emails" + } + } + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "When the translation was created" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "description": "When the translation was last updated" + } + }, + "required": ["locale", "content"] + }, + "UpsertWorkspaceTranslationRequest": { + "type": "object", + "required": ["workspace_id", "locale", "content"], + "properties": { + "workspace_id": { + "type": "string", + "description": "The ID of the workspace", + "example": "ws_1234567890" + }, + "locale": { + "type": "string", + "description": "Locale code", + "example": "fr", + "maxLength": 10 + }, + "content": { + "type": "object", + "additionalProperties": true, + "description": "Nested key-value translation map", + "example": { + "common": { + "greeting": "Bonjour", + "footer": "Se désabonner de nos emails" + } + } + } + } + }, + "DeleteWorkspaceTranslationRequest": { + "type": "object", + "required": ["workspace_id", "locale"], + "properties": { + "workspace_id": { + "type": "string", + "description": "The ID of the workspace", + "example": "ws_1234567890" + }, + "locale": { + "type": "string", + "description": "Locale code to delete", + "example": "fr" + } + } + }, +``` + +### Step 2: Add workspace_translations.list path + +In the `paths` section, add: + +```json + "/api/workspace_translations.list": { + "get": { + "summary": "List workspace translations", + "description": "Retrieves all workspace-level translations. Returns one entry per locale with its nested key-value content.", + "operationId": "listWorkspaceTranslations", + "security": [{ "BearerAuth": [] }], + "parameters": [ + { + "name": "workspace_id", + "in": "query", + "required": true, + "schema": { "type": "string" }, + "description": "The ID of the workspace", + "example": "ws_1234567890" + } + ], + "responses": { + "200": { + "description": "List of workspace translations retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "translations": { + "type": "array", + "items": { "$ref": "#/components/schemas/WorkspaceTranslation" } + } + } + } + } + } + }, + "400": { + "description": "Bad request - validation failed", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + } + } + } + }, +``` + +### Step 3: Add workspace_translations.upsert path + +```json + "/api/workspace_translations.upsert": { + "post": { + "summary": "Create or update workspace translation", + "description": "Creates or updates translations for a specific locale at the workspace level. If translations for the locale already exist, they are replaced. Workspace translations are shared across all templates and resolved when a template uses {{ \"key\" | t }} and the key is not found in the template's own translations.", + "operationId": "upsertWorkspaceTranslation", + "security": [{ "BearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/UpsertWorkspaceTranslationRequest" } + } + } + }, + "responses": { + "200": { + "description": "Translation upserted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true } + } + } + } + } + }, + "400": { + "description": "Bad request - validation failed", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + } + } + } + }, +``` + +### Step 4: Add workspace_translations.delete path + +```json + "/api/workspace_translations.delete": { + "post": { + "summary": "Delete workspace translation", + "description": "Deletes all translations for a specific locale at the workspace level.", + "operationId": "deleteWorkspaceTranslation", + "security": [{ "BearerAuth": [] }], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/DeleteWorkspaceTranslationRequest" } + } + } + }, + "responses": { + "200": { + "description": "Translation deleted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true } + } + } + } + } + }, + "400": { + "description": "Bad request - validation failed", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ErrorResponse" } + } + } + } + } + } + }, +``` + +### Step 5: Verify JSON is valid + +Run: `cd /var/www/forks/notifuse-docs && python3 -c "import json; json.load(open('openapi.json'))" 2>&1 || echo "JSON invalid"` + +### Step 6: Commit + +```bash +cd /var/www/forks/notifuse-docs +git add openapi.json +git commit -m "docs: add workspace translations API to OpenAPI spec" +``` + +--- + +## Summary + +| Task | File(s) | What changes | +|------|---------|-------------| +| 1 | `features/template-translations.mdx` (new) | Full i18n guide: t filter, keys, locale resolution, workspace translations, import/export, best practices | +| 2 | `features/templates.mdx` | Brief Translations section with `t` filter example + link | +| 3 | `features/workspaces.mdx` | Language Settings section (default language, supported languages) | +| 4 | `features/contacts.mdx` | Expand `language` field description with i18n link | +| 5 | `features/transactional-api.mdx` | Multi-Language Support note under Key Features | +| 6 | `features/broadcast-campaigns.mdx` | Multi-Language Support section before Best Practices | +| 7 | `docs.json` | Add page to Features nav + Workspace Translations API group | +| 8 | `openapi.json` | Add translations/default_language to Template schemas | +| 9 | `openapi.json` | Add workspace_translations endpoints + schemas | + +Tasks 1-6 are MDX page changes (Task 1 is the big one, 2-6 are small additions). +Task 7 is navigation config. +Tasks 8-9 are OpenAPI spec updates. + +All tasks are independent except: Task 7 depends on Task 1 (page must exist before adding to nav), and Task 9 depends on Task 8 (both modify the same file sequentially). diff --git a/docs/plans/2026-02-24-template-i18n-implementation.md b/docs/plans/2026-02-24-template-i18n-implementation.md new file mode 100644 index 00000000..caf1c83a --- /dev/null +++ b/docs/plans/2026-02-24-template-i18n-implementation.md @@ -0,0 +1,1949 @@ +# Template i18n Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add template-level i18n using a Liquid `t` filter so emails are automatically sent in the contact's language. + +**Architecture:** Translation keys (`{{ "key" | t }}`) stored as nested JSON per locale in the template's `translations` JSONB column. Workspace-level shared translations in a new `workspace_translations` table. A custom Liquid filter resolves keys at render time with a locale fallback chain (contact.language → base language → template default → workspace default). No changes to the transactional API — language selection is automatic. + +**Tech Stack:** Go 1.25 (backend), liquidgo (Liquid engine), PostgreSQL JSONB, React 18 + Ant Design + TypeScript (frontend), Vitest (frontend tests), Go standard testing + testify + gomock (backend tests). + +**Design doc:** `docs/plans/2026-02-24-template-i18n-design.md` + +--- + +## Task 1: Translation Utility Functions (Domain Layer) + +Core helper functions for locale resolution, nested key lookup, placeholder interpolation, and translation merging. These are pure functions with no dependencies — the foundation for everything else. + +**Files:** +- Create: `internal/domain/translation.go` +- Create: `internal/domain/translation_test.go` + +### Step 1: Write failing tests for `ResolveNestedKey` + +This function traverses a nested `map[string]interface{}` using a dot-separated key path and returns the string value. + +```go +// internal/domain/translation_test.go +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveNestedKey(t *testing.T) { + translations := map[string]interface{}{ + "welcome": map[string]interface{}{ + "heading": "Welcome!", + "greeting": "Hello {{ name }}!", + }, + "cta": map[string]interface{}{ + "button": "Get Started", + }, + "flat_key": "Flat value", + } + + tests := []struct { + name string + data map[string]interface{} + key string + expected string + }{ + {"nested key", translations, "welcome.heading", "Welcome!"}, + {"deeper nested", translations, "welcome.greeting", "Hello {{ name }}!"}, + {"different group", translations, "cta.button", "Get Started"}, + {"flat key", translations, "flat_key", "Flat value"}, + {"missing key", translations, "welcome.missing", ""}, + {"missing group", translations, "nonexistent.key", ""}, + {"empty key", translations, "", ""}, + {"nil data", nil, "welcome.heading", ""}, + {"empty data", map[string]interface{}{}, "welcome.heading", ""}, + {"key pointing to map not string", translations, "welcome", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := ResolveNestedKey(tc.data, tc.key) + assert.Equal(t, tc.expected, result) + }) + } +} +``` + +### Step 2: Run tests to verify they fail + +Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestResolveNestedKey -v` +Expected: Compilation error — `ResolveNestedKey` undefined. + +### Step 3: Implement `ResolveNestedKey` + +```go +// internal/domain/translation.go +package domain + +import ( + "strings" +) + +// ResolveNestedKey traverses a nested map using a dot-separated key path +// and returns the string value. Returns empty string if key not found or value is not a string. +func ResolveNestedKey(data map[string]interface{}, key string) string { + if data == nil || key == "" { + return "" + } + + parts := strings.Split(key, ".") + var current interface{} = data + + for _, part := range parts { + m, ok := current.(map[string]interface{}) + if !ok { + return "" + } + current, ok = m[part] + if !ok { + return "" + } + } + + if str, ok := current.(string); ok { + return str + } + return "" +} +``` + +### Step 4: Run tests to verify they pass + +Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestResolveNestedKey -v` +Expected: All PASS. + +### Step 5: Write failing tests for `InterpolatePlaceholders` + +This function replaces `{{ name }}` style placeholders in a translation value with provided key-value arguments. + +```go +// Append to internal/domain/translation_test.go + +func TestInterpolatePlaceholders(t *testing.T) { + tests := []struct { + name string + value string + args map[string]interface{} + expected string + }{ + { + "single placeholder", + "Hello {{ name }}!", + map[string]interface{}{"name": "John"}, + "Hello John!", + }, + { + "multiple placeholders", + "{{ greeting }} {{ name }}, welcome to {{ site }}!", + map[string]interface{}{"greeting": "Hello", "name": "Jane", "site": "Notifuse"}, + "Hello Jane, welcome to Notifuse!", + }, + { + "no placeholders", + "Hello World!", + map[string]interface{}{"name": "John"}, + "Hello World!", + }, + { + "placeholder without matching arg", + "Hello {{ name }}!", + map[string]interface{}{}, + "Hello {{ name }}!", + }, + { + "nil args", + "Hello {{ name }}!", + nil, + "Hello {{ name }}!", + }, + { + "no spaces in placeholder", + "Hello {{name}}!", + map[string]interface{}{"name": "John"}, + "Hello John!", + }, + { + "extra spaces in placeholder", + "Hello {{ name }}!", + map[string]interface{}{"name": "John"}, + "Hello John!", + }, + { + "numeric value", + "You have {{ count }} items", + map[string]interface{}{"count": 5}, + "You have 5 items", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := InterpolatePlaceholders(tc.value, tc.args) + assert.Equal(t, tc.expected, result) + }) + } +} +``` + +### Step 6: Run tests to verify they fail + +Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestInterpolatePlaceholders -v` +Expected: Compilation error. + +### Step 7: Implement `InterpolatePlaceholders` + +```go +// Append to internal/domain/translation.go + +import ( + "fmt" + "regexp" + "strings" +) + +var placeholderRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`) + +// InterpolatePlaceholders replaces {{ key }} placeholders in a translation value +// with the corresponding values from the args map. +func InterpolatePlaceholders(value string, args map[string]interface{}) string { + if args == nil || len(args) == 0 { + return value + } + + return placeholderRegex.ReplaceAllStringFunc(value, func(match string) string { + submatch := placeholderRegex.FindStringSubmatch(match) + if len(submatch) < 2 { + return match + } + key := submatch[1] + if val, ok := args[key]; ok { + return fmt.Sprintf("%v", val) + } + return match // leave unresolved placeholders as-is + }) +} +``` + +### Step 8: Run tests to verify they pass + +Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestInterpolatePlaceholders -v` +Expected: All PASS. + +### Step 9: Write failing tests for `ResolveLocale` + +The locale fallback chain: exact match → base language → template default → workspace default. + +```go +// Append to internal/domain/translation_test.go + +func TestResolveLocale(t *testing.T) { + tests := []struct { + name string + contactLanguage string + availableLocales []string + templateDefault *string + workspaceDefault string + expected string + }{ + { + "exact match", + "fr", + []string{"en", "fr", "de"}, + nil, + "en", + "fr", + }, + { + "exact match with region", + "pt-BR", + []string{"en", "pt-BR", "pt"}, + nil, + "en", + "pt-BR", + }, + { + "base language fallback", + "pt-BR", + []string{"en", "pt"}, + nil, + "en", + "pt", + }, + { + "template default fallback", + "ja", + []string{"en", "fr"}, + strPtr("fr"), + "en", + "fr", + }, + { + "workspace default fallback", + "ja", + []string{"en", "fr"}, + nil, + "en", + "en", + }, + { + "empty contact language uses workspace default", + "", + []string{"en", "fr"}, + nil, + "en", + "en", + }, + { + "case insensitive match", + "FR", + []string{"en", "fr"}, + nil, + "en", + "fr", + }, + { + "workspace default when no locales available", + "fr", + []string{}, + nil, + "en", + "en", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := ResolveLocale(tc.contactLanguage, tc.availableLocales, tc.templateDefault, tc.workspaceDefault) + assert.Equal(t, tc.expected, result) + }) + } +} + +func strPtr(s string) *string { + return &s +} +``` + +### Step 10: Run tests to verify they fail + +Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestResolveLocale -v` +Expected: Compilation error. + +### Step 11: Implement `ResolveLocale` + +```go +// Append to internal/domain/translation.go + +// ResolveLocale determines the best locale to use given a contact's language preference, +// available translation locales, and fallback defaults. +// Fallback chain: exact match → base language → template default → workspace default. +func ResolveLocale(contactLanguage string, availableLocales []string, templateDefault *string, workspaceDefault string) string { + if contactLanguage == "" { + if templateDefault != nil && *templateDefault != "" { + return *templateDefault + } + return workspaceDefault + } + + contactLang := strings.ToLower(contactLanguage) + + // 1. Exact match (case-insensitive) + for _, locale := range availableLocales { + if strings.ToLower(locale) == contactLang { + return locale + } + } + + // 2. Base language match (e.g., "pt-BR" → "pt") + if idx := strings.Index(contactLang, "-"); idx > 0 { + baseLang := contactLang[:idx] + for _, locale := range availableLocales { + if strings.ToLower(locale) == baseLang { + return locale + } + } + } + + // 3. Template default language + if templateDefault != nil && *templateDefault != "" { + return *templateDefault + } + + // 4. Workspace default language + return workspaceDefault +} +``` + +### Step 12: Run tests to verify they pass + +Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestResolveLocale -v` +Expected: All PASS. + +### Step 13: Write failing tests for `MergeTranslations` + +Deep-merges two translation maps. Template translations take priority over workspace translations. + +```go +// Append to internal/domain/translation_test.go + +func TestMergeTranslations(t *testing.T) { + tests := []struct { + name string + base map[string]interface{} + override map[string]interface{} + expected map[string]interface{} + }{ + { + "override wins", + map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base"}}, + map[string]interface{}{"welcome": map[string]interface{}{"heading": "Override"}}, + map[string]interface{}{"welcome": map[string]interface{}{"heading": "Override"}}, + }, + { + "deep merge adds missing keys", + map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base"}}, + map[string]interface{}{"welcome": map[string]interface{}{"body": "Override body"}}, + map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base", "body": "Override body"}}, + }, + { + "nil base", + nil, + map[string]interface{}{"key": "value"}, + map[string]interface{}{"key": "value"}, + }, + { + "nil override", + map[string]interface{}{"key": "value"}, + nil, + map[string]interface{}{"key": "value"}, + }, + { + "both nil", + nil, + nil, + map[string]interface{}{}, + }, + { + "disjoint keys", + map[string]interface{}{"a": "1"}, + map[string]interface{}{"b": "2"}, + map[string]interface{}{"a": "1", "b": "2"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := MergeTranslations(tc.base, tc.override) + assert.Equal(t, tc.expected, result) + }) + } +} +``` + +### Step 14: Run tests, verify fail, implement, verify pass + +Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestMergeTranslations -v` + +```go +// Append to internal/domain/translation.go + +// MergeTranslations deep-merges two translation maps. Values in override take priority. +func MergeTranslations(base, override map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + + // Copy base + for k, v := range base { + result[k] = v + } + + // Merge override + for k, v := range override { + if baseVal, exists := result[k]; exists { + // If both are maps, deep merge + baseMap, baseIsMap := baseVal.(map[string]interface{}) + overrideMap, overrideIsMap := v.(map[string]interface{}) + if baseIsMap && overrideIsMap { + result[k] = MergeTranslations(baseMap, overrideMap) + continue + } + } + result[k] = v + } + + return result +} +``` + +Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -run TestMergeTranslations -v` +Expected: All PASS. + +### Step 15: Commit + +```bash +git add internal/domain/translation.go internal/domain/translation_test.go +git commit -m "feat(i18n): add translation utility functions + +Locale resolution, nested key lookup, placeholder interpolation, +and translation merging — pure functions with full test coverage." +``` + +--- + +## Task 2: Liquid `t` Filter + +Register a custom `T` filter on the `SecureLiquidEngine` that resolves translation keys during Liquid rendering. + +**Files:** +- Create: `pkg/notifuse_mjml/translation_filter.go` +- Create: `pkg/notifuse_mjml/translation_filter_test.go` +- Modify: `pkg/notifuse_mjml/liquid_secure.go` (add `RegisterTranslations` method) + +### Step 1: Write failing tests for the translation filter + +```go +// pkg/notifuse_mjml/translation_filter_test.go +package notifuse_mjml + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTranslationFilter_SimpleKey(t *testing.T) { + engine := NewSecureLiquidEngine() + translations := map[string]interface{}{ + "welcome": map[string]interface{}{ + "heading": "Welcome!", + }, + } + engine.RegisterTranslations(translations) + + result, err := engine.Render(`{{ "welcome.heading" | t }}`, map[string]interface{}{}) + require.NoError(t, err) + assert.Equal(t, "Welcome!", result) +} + +func TestTranslationFilter_MissingKey(t *testing.T) { + engine := NewSecureLiquidEngine() + translations := map[string]interface{}{} + engine.RegisterTranslations(translations) + + result, err := engine.Render(`{{ "missing.key" | t }}`, map[string]interface{}{}) + require.NoError(t, err) + assert.Equal(t, "[Missing translation: missing.key]", result) +} + +func TestTranslationFilter_WithPlaceholders(t *testing.T) { + engine := NewSecureLiquidEngine() + translations := map[string]interface{}{ + "welcome": map[string]interface{}{ + "greeting": "Hello {{ name }}, welcome to {{ site }}!", + }, + } + engine.RegisterTranslations(translations) + + // The liquidgo filter receives named keyword args as a map + result, err := engine.Render( + `{{ "welcome.greeting" | t: name: "John", site: "Notifuse" }}`, + map[string]interface{}{}, + ) + require.NoError(t, err) + assert.Equal(t, "Hello John, welcome to Notifuse!", result) +} + +func TestTranslationFilter_WithContactVariable(t *testing.T) { + engine := NewSecureLiquidEngine() + translations := map[string]interface{}{ + "welcome": map[string]interface{}{ + "greeting": "Hello {{ name }}!", + }, + } + engine.RegisterTranslations(translations) + + result, err := engine.Render( + `{{ "welcome.greeting" | t: name: contact.first_name }}`, + map[string]interface{}{ + "contact": map[string]interface{}{ + "first_name": "Alice", + }, + }, + ) + require.NoError(t, err) + assert.Equal(t, "Hello Alice!", result) +} + +func TestTranslationFilter_FlatKey(t *testing.T) { + engine := NewSecureLiquidEngine() + translations := map[string]interface{}{ + "flat_key": "Flat value", + } + engine.RegisterTranslations(translations) + + result, err := engine.Render(`{{ "flat_key" | t }}`, map[string]interface{}{}) + require.NoError(t, err) + assert.Equal(t, "Flat value", result) +} + +func TestTranslationFilter_NoRegistration(t *testing.T) { + // When no translations registered, t filter should return missing translation marker + engine := NewSecureLiquidEngine() + + result, err := engine.Render(`{{ "some.key" | t }}`, map[string]interface{}{}) + require.NoError(t, err) + assert.Equal(t, "[Missing translation: some.key]", result) +} +``` + +### Step 2: Run tests to verify they fail + +Run: `cd /var/www/forks/notifuse && go test ./pkg/notifuse_mjml/ -run TestTranslationFilter -v` +Expected: Compilation error. + +### Step 3: Implement the translation filter + +```go +// pkg/notifuse_mjml/translation_filter.go +package notifuse_mjml + +import ( + "fmt" + + "github.com/Notifuse/notifuse/internal/domain" +) + +// TranslationFilters provides the Liquid `t` filter for resolving translation keys. +// Register with SecureLiquidEngine.RegisterTranslations(). +type TranslationFilters struct { + translations map[string]interface{} +} + +// T is the Liquid filter: {{ "welcome.heading" | t }} +// With placeholders: {{ "welcome.greeting" | t: name: "John" }} +// +// liquidgo calls this method with: +// - input: the piped value (the translation key string) +// - args: variadic positional args (unused for now) +// +// liquidgo passes keyword args (name: value) as the last element +// in args if it's a map[string]interface{}. +func (tf *TranslationFilters) T(input interface{}, args ...interface{}) interface{} { + keyStr := fmt.Sprintf("%v", input) + + value := domain.ResolveNestedKey(tf.translations, keyStr) + if value == "" { + return "[Missing translation: " + keyStr + "]" + } + + // Check if last arg is a keyword args map + var kwargs map[string]interface{} + if len(args) > 0 { + if m, ok := args[len(args)-1].(map[string]interface{}); ok { + kwargs = m + } + } + + if len(kwargs) > 0 { + value = domain.InterpolatePlaceholders(value, kwargs) + } + + return value +} +``` + +### Step 4: Add `RegisterTranslations` to `SecureLiquidEngine` + +Modify `pkg/notifuse_mjml/liquid_secure.go`. Add this method after the existing methods: + +```go +// RegisterTranslations registers translation data for the Liquid t filter. +// Must be called before Render. Translations should be a merged map (template + workspace). +func (s *SecureLiquidEngine) RegisterTranslations(translations map[string]interface{}) { + if translations == nil { + translations = map[string]interface{}{} + } + filter := &TranslationFilters{translations: translations} + s.env.RegisterFilter(filter) +} +``` + +### Step 5: Run tests to verify they pass + +Run: `cd /var/www/forks/notifuse && go test ./pkg/notifuse_mjml/ -run TestTranslationFilter -v` +Expected: All PASS. (Note: the keyword args test may need adjustment based on how liquidgo passes them — see Step 6.) + +### Step 6: Debug and fix keyword args if needed + +liquidgo's filter invocation passes keyword args differently depending on the parsing mode. Check how they arrive in the `T` method by adding a temporary debug log. The `laxParseFilterExpressions` function in `liquidgo/liquid/variable.go:272` shows: `result = []interface{}{filterName, filterArgs}` where `keywordArgs` is appended as element [2] if present. At invocation time (`variable.go:360-390`), positional args are passed as separate params and keyword args as the final map. Adjust the `T` method signature if the args arrive differently. + +### Step 7: Run all existing Liquid tests to verify no regressions + +Run: `cd /var/www/forks/notifuse && go test ./pkg/notifuse_mjml/ -v` +Expected: All existing tests still pass. + +### Step 8: Commit + +```bash +git add pkg/notifuse_mjml/translation_filter.go pkg/notifuse_mjml/translation_filter_test.go pkg/notifuse_mjml/liquid_secure.go +git commit -m "feat(i18n): add Liquid t filter for translation key resolution + +Registers a TranslationFilters struct on the Liquid engine that resolves +nested keys with {{ \"key\" | t }} syntax and supports placeholders +via named args: {{ \"key\" | t: name: contact.first_name }}" +``` + +--- + +## Task 3: Domain Model Changes + +Add `Translations` and `DefaultLanguage` fields to the `Template` struct, and `DefaultLanguage`/`SupportedLanguages` to `WorkspaceSettings`. Add `WorkspaceTranslation` entity. + +**Files:** +- Modify: `internal/domain/template.go` (Template struct, Validate, scan helpers, request types) +- Modify: `internal/domain/template_test.go` (update tests) +- Modify: `internal/domain/workspace.go` (WorkspaceSettings struct) +- Create: `internal/domain/workspace_translation.go` +- Create: `internal/domain/workspace_translation_test.go` + +### Step 1: Add fields to `Template` struct + +In `internal/domain/template.go`, add two fields to the `Template` struct (after `Settings`): + +```go +type Template struct { + // ... existing fields through Settings ... + Settings MapOfAny `json:"settings"` + Translations MapOfInterfaces `json:"translations"` // locale → nested key-value map + DefaultLanguage *string `json:"default_language"` // overrides workspace default if set + CreatedAt time.Time `json:"created_at"` + // ... rest unchanged ... +} +``` + +Note: `Translations` needs a custom type that implements `sql.Scanner` and `driver.Valuer` for JSONB storage. Define `MapOfInterfaces` as `map[string]map[string]interface{}` with scanner methods, or reuse the existing `MapOfAny` pattern and cast at usage sites. The simplest approach: store as `MapOfAny` (which is `map[string]interface{}` with JSONB scan/value support already implemented) and cast the inner values at read time. + +Actually, the cleanest approach is to store `Translations` as `MapOfAny` since JSONB deserialization produces `map[string]interface{}` naturally: + +```go +Translations MapOfAny `json:"translations"` // {locale: {nested key-value}} +DefaultLanguage *string `json:"default_language"` // overrides workspace default +``` + +### Step 2: Add `Translations` to `EmailTemplate` scan/serialization + +The `Translations` field uses `MapOfAny` which already has `Scan()` and `Value()` methods. The `DefaultLanguage` is a nullable `*string` which maps to `sql.NullString` in the scanner. + +### Step 3: Update `Template.Validate()` to validate translations + +Add validation in the `Validate()` method: + +```go +// Validate translations if provided +if w.Translations != nil { + for locale, content := range w.Translations { + if locale == "" { + return fmt.Errorf("invalid template: translation locale cannot be empty") + } + if len(locale) > 10 { + return fmt.Errorf("invalid template: translation locale '%s' exceeds max length of 10", locale) + } + if content == nil { + return fmt.Errorf("invalid template: translation content for locale '%s' cannot be nil", locale) + } + } +} + +// Validate default_language if set +if w.DefaultLanguage != nil && *w.DefaultLanguage != "" { + if len(*w.DefaultLanguage) > 10 { + return fmt.Errorf("invalid template: default_language exceeds max length of 10") + } +} +``` + +### Step 4: Update `CreateTemplateRequest` and `UpdateTemplateRequest` + +These request types include the `Template` field, so `Translations` and `DefaultLanguage` flow through automatically via JSON deserialization. No changes needed to request types. + +### Step 5: Add language fields to `WorkspaceSettings` + +In `internal/domain/workspace.go`, add to the `WorkspaceSettings` struct (after `BlogSettings`): + +```go +type WorkspaceSettings struct { + // ... existing fields ... + BlogSettings *BlogSettings `json:"blog_settings,omitempty"` + DefaultLanguage string `json:"default_language,omitempty"` // e.g., "en" + SupportedLanguages []string `json:"supported_languages,omitempty"` // e.g., ["en", "fr", "de"] + + // decoded secret key, not stored in the database + SecretKey string `json:"-"` +} +``` + +Since `WorkspaceSettings` is stored as JSONB in the `workspaces` table, existing workspaces will have these fields absent in JSON. Go will deserialize them as zero values (`""` and `nil`). Add a helper to get the effective default language: + +```go +// GetDefaultLanguage returns the workspace's default language, defaulting to "en" if not set. +func (ws *WorkspaceSettings) GetDefaultLanguage() string { + if ws.DefaultLanguage != "" { + return ws.DefaultLanguage + } + return "en" +} + +// GetSupportedLanguages returns the workspace's supported languages, defaulting to ["en"] if not set. +func (ws *WorkspaceSettings) GetSupportedLanguages() []string { + if len(ws.SupportedLanguages) > 0 { + return ws.SupportedLanguages + } + return []string{"en"} +} +``` + +### Step 6: Create `WorkspaceTranslation` entity + +```go +// internal/domain/workspace_translation.go +package domain + +import ( + "context" + "fmt" + "time" +) + +// WorkspaceTranslation represents translations for a single locale at the workspace level. +type WorkspaceTranslation struct { + Locale string `json:"locale"` + Content MapOfAny `json:"content"` // nested key-value translation map + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Validate validates the workspace translation. +func (wt *WorkspaceTranslation) Validate() error { + if wt.Locale == "" { + return fmt.Errorf("locale is required") + } + if len(wt.Locale) > 10 { + return fmt.Errorf("locale exceeds max length of 10") + } + if wt.Content == nil { + return fmt.Errorf("content is required") + } + return nil +} + +// WorkspaceTranslationRepository defines the data access interface for workspace translations. +type WorkspaceTranslationRepository interface { + Upsert(ctx context.Context, workspaceID string, translation *WorkspaceTranslation) error + GetByLocale(ctx context.Context, workspaceID string, locale string) (*WorkspaceTranslation, error) + List(ctx context.Context, workspaceID string) ([]*WorkspaceTranslation, error) + Delete(ctx context.Context, workspaceID string, locale string) error +} + +// Request/Response types for workspace translations API +type UpsertWorkspaceTranslationRequest struct { + WorkspaceID string `json:"workspace_id"` + Locale string `json:"locale"` + Content MapOfAny `json:"content"` +} + +func (r *UpsertWorkspaceTranslationRequest) Validate() error { + if r.WorkspaceID == "" { + return fmt.Errorf("workspace_id is required") + } + if r.Locale == "" { + return fmt.Errorf("locale is required") + } + if len(r.Locale) > 10 { + return fmt.Errorf("locale exceeds max length of 10") + } + if r.Content == nil { + return fmt.Errorf("content is required") + } + return nil +} + +type ListWorkspaceTranslationsRequest struct { + WorkspaceID string `json:"workspace_id"` +} + +type DeleteWorkspaceTranslationRequest struct { + WorkspaceID string `json:"workspace_id"` + Locale string `json:"locale"` +} +``` + +### Step 7: Write tests for new domain types + +```go +// internal/domain/workspace_translation_test.go +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWorkspaceTranslation_Validate(t *testing.T) { + tests := []struct { + name string + wt WorkspaceTranslation + expectErr bool + }{ + {"valid", WorkspaceTranslation{Locale: "en", Content: MapOfAny{"key": "value"}}, false}, + {"empty locale", WorkspaceTranslation{Locale: "", Content: MapOfAny{"key": "value"}}, true}, + {"locale too long", WorkspaceTranslation{Locale: "12345678901", Content: MapOfAny{"key": "value"}}, true}, + {"nil content", WorkspaceTranslation{Locale: "en", Content: nil}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.wt.Validate() + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestWorkspaceSettings_GetDefaultLanguage(t *testing.T) { + ws := &WorkspaceSettings{} + assert.Equal(t, "en", ws.GetDefaultLanguage()) + + ws.DefaultLanguage = "fr" + assert.Equal(t, "fr", ws.GetDefaultLanguage()) +} + +func TestWorkspaceSettings_GetSupportedLanguages(t *testing.T) { + ws := &WorkspaceSettings{} + assert.Equal(t, []string{"en"}, ws.GetSupportedLanguages()) + + ws.SupportedLanguages = []string{"en", "fr", "de"} + assert.Equal(t, []string{"en", "fr", "de"}, ws.GetSupportedLanguages()) +} +``` + +### Step 8: Run all domain tests + +Run: `cd /var/www/forks/notifuse && go test ./internal/domain/ -v` +Expected: All PASS (new and existing tests). + +### Step 9: Commit + +```bash +git add internal/domain/translation.go internal/domain/translation_test.go internal/domain/template.go internal/domain/template_test.go internal/domain/workspace.go internal/domain/workspace_translation.go internal/domain/workspace_translation_test.go +git commit -m "feat(i18n): add translation fields to domain models + +Template: translations (JSONB) + default_language. +WorkspaceSettings: default_language + supported_languages. +New WorkspaceTranslation entity with repository interface." +``` + +--- + +## Task 4: Database Migration V28 + +Add `translations` and `default_language` columns to the workspace `templates` table. Create `workspace_translations` table. No system database changes needed (workspace language settings are in the existing JSONB `settings` column). + +**Files:** +- Create: `internal/migrations/v28.go` +- Create: `internal/migrations/v28_test.go` +- Modify: `config/config.go` (bump VERSION to "28.0") +- Modify: `internal/database/schema/` (update workspace table schema for new installs) + +### Step 1: Create V28 migration + +```go +// internal/migrations/v28.go +package migrations + +import ( + "context" + "fmt" + + "github.com/Notifuse/notifuse/config" + "github.com/Notifuse/notifuse/internal/domain" +) + +// V28Migration adds template i18n support. +// +// This migration adds: +// - translations: JSONB column on templates for per-locale translation key-value maps +// - default_language: VARCHAR column on templates for per-template language override +// - workspace_translations: new table for workspace-level shared translations +type V28Migration struct{} + +func (m *V28Migration) GetMajorVersion() float64 { + return 28.0 +} + +func (m *V28Migration) HasSystemUpdate() bool { + return false +} + +func (m *V28Migration) HasWorkspaceUpdate() bool { + return true +} + +func (m *V28Migration) ShouldRestartServer() bool { + return false +} + +func (m *V28Migration) UpdateSystem(ctx context.Context, cfg *config.Config, db DBExecutor) error { + return nil +} + +func (m *V28Migration) UpdateWorkspace(ctx context.Context, cfg *config.Config, workspace *domain.Workspace, db DBExecutor) error { + // Add translations column to templates table + _, err := db.ExecContext(ctx, ` + ALTER TABLE templates + ADD COLUMN IF NOT EXISTS translations JSONB NOT NULL DEFAULT '{}'::jsonb + `) + if err != nil { + return fmt.Errorf("failed to add translations column: %w", err) + } + + // Add default_language column to templates table + _, err = db.ExecContext(ctx, ` + ALTER TABLE templates + ADD COLUMN IF NOT EXISTS default_language VARCHAR(10) DEFAULT NULL + `) + if err != nil { + return fmt.Errorf("failed to add default_language column: %w", err) + } + + // Create workspace_translations table + _, err = db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS workspace_translations ( + locale VARCHAR(10) NOT NULL PRIMARY KEY, + content JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return fmt.Errorf("failed to create workspace_translations table: %w", err) + } + + return nil +} + +func init() { + Register(&V28Migration{}) +} +``` + +### Step 2: Write migration test + +Follow the existing pattern from `v27_test.go`: + +```go +// internal/migrations/v28_test.go +package migrations + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Notifuse/notifuse/config" + "github.com/Notifuse/notifuse/internal/domain" + "github.com/stretchr/testify/assert" +) + +func TestV28Migration_GetMajorVersion(t *testing.T) { + m := &V28Migration{} + assert.Equal(t, 28.0, m.GetMajorVersion()) +} + +func TestV28Migration_HasSystemUpdate(t *testing.T) { + m := &V28Migration{} + assert.False(t, m.HasSystemUpdate()) +} + +func TestV28Migration_HasWorkspaceUpdate(t *testing.T) { + m := &V28Migration{} + assert.True(t, m.HasWorkspaceUpdate()) +} + +func TestV28Migration_UpdateWorkspace(t *testing.T) { + m := &V28Migration{} + cfg := &config.Config{} + workspace := &domain.Workspace{ID: "test"} + + t.Run("success", func(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + // Expect: add translations column + mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0)) + // Expect: add default_language column + mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0)) + // Expect: create workspace_translations table + mock.ExpectExec("CREATE TABLE IF NOT EXISTS workspace_translations").WillReturnResult(sqlmock.NewResult(0, 0)) + + err = m.UpdateWorkspace(context.Background(), cfg, workspace, db) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("translations column error", func(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + mock.ExpectExec("ALTER TABLE templates").WillReturnError(fmt.Errorf("db error")) + + err = m.UpdateWorkspace(context.Background(), cfg, workspace, db) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to add translations column") + }) +} +``` + +### Step 3: Bump VERSION in config + +In `config/config.go` line 17, change: + +```go +const VERSION = "28.0" +``` + +### Step 4: Update workspace DB init schema + +In `internal/database/schema/` (the workspace tables file), add the `translations` and `default_language` columns to the `templates` CREATE TABLE statement, and add the `workspace_translations` CREATE TABLE. This ensures new workspace databases get the correct schema on first creation. + +### Step 5: Run migration tests + +Run: `cd /var/www/forks/notifuse && go test ./internal/migrations/ -run TestV28 -v` +Expected: All PASS. + +### Step 6: Commit + +```bash +git add internal/migrations/v28.go internal/migrations/v28_test.go config/config.go internal/database/schema/ +git commit -m "feat(i18n): add V28 migration for template translations + +Adds translations JSONB and default_language columns to templates table. +Creates workspace_translations table for shared translations." +``` + +--- + +## Task 5: Repository Layer + +Update the template repository to read/write the new columns. Create the workspace translations repository. + +**Files:** +- Modify: `internal/repository/template_postgres.go` (add new columns to INSERT/SELECT, update scanner) +- Modify: `internal/repository/template_postgres_test.go` +- Create: `internal/repository/workspace_translation_postgres.go` +- Create: `internal/repository/workspace_translation_postgres_test.go` + +### Step 1: Update template repository — scanner + +In `internal/repository/template_postgres.go`, update `scanTemplate()` to scan the two new columns. Add them after `settings`: + +```go +func scanTemplate(scanner interface{ Scan(dest ...interface{}) error }) (*domain.Template, error) { + var ( + template domain.Template + templateMacroID sql.NullString + integrationID sql.NullString + defaultLanguage sql.NullString + ) + + err := scanner.Scan( + &template.ID, + &template.Name, + &template.Version, + &template.Channel, + &template.Email, + &template.Web, + &template.Category, + &templateMacroID, + &integrationID, + &template.TestData, + &template.Settings, + &template.Translations, // NEW + &defaultLanguage, // NEW + &template.CreatedAt, + &template.UpdatedAt, + ) + // ... existing null handling ... + if defaultLanguage.Valid { + template.DefaultLanguage = &defaultLanguage.String + } + // ... +} +``` + +### Step 2: Update template repository — INSERT columns + +Add `translations` and `default_language` to the `CreateTemplate` and `UpdateTemplate` INSERT statements. Follow the existing squirrel pattern. + +### Step 3: Update template repository — SELECT columns + +Add `translations` and `default_language` to all SELECT column lists (in `GetTemplateByID`, `GetTemplates`, etc.). + +### Step 4: Create workspace translations repository + +```go +// internal/repository/workspace_translation_postgres.go +package repository + +import ( + "context" + "database/sql" + "fmt" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/Notifuse/notifuse/internal/domain" +) + +type WorkspaceTranslationPostgresRepository struct { + getWorkspaceDB func(workspaceID string) (*sql.DB, error) +} + +func NewWorkspaceTranslationPostgresRepository( + getWorkspaceDB func(workspaceID string) (*sql.DB, error), +) *WorkspaceTranslationPostgresRepository { + return &WorkspaceTranslationPostgresRepository{getWorkspaceDB: getWorkspaceDB} +} + +func (r *WorkspaceTranslationPostgresRepository) Upsert(ctx context.Context, workspaceID string, translation *domain.WorkspaceTranslation) error { + db, err := r.getWorkspaceDB(workspaceID) + if err != nil { + return fmt.Errorf("failed to get workspace db: %w", err) + } + + now := time.Now() + query, args, err := sq.Insert("workspace_translations"). + Columns("locale", "content", "created_at", "updated_at"). + Values(translation.Locale, translation.Content, now, now). + Suffix("ON CONFLICT (locale) DO UPDATE SET content = EXCLUDED.content, updated_at = EXCLUDED.updated_at"). + PlaceholderFormat(sq.Dollar). + ToSql() + if err != nil { + return fmt.Errorf("failed to build query: %w", err) + } + + _, err = db.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to upsert workspace translation: %w", err) + } + + return nil +} + +func (r *WorkspaceTranslationPostgresRepository) GetByLocale(ctx context.Context, workspaceID string, locale string) (*domain.WorkspaceTranslation, error) { + db, err := r.getWorkspaceDB(workspaceID) + if err != nil { + return nil, fmt.Errorf("failed to get workspace db: %w", err) + } + + query, args, err := sq.Select("locale", "content", "created_at", "updated_at"). + From("workspace_translations"). + Where(sq.Eq{"locale": locale}). + PlaceholderFormat(sq.Dollar). + ToSql() + if err != nil { + return nil, fmt.Errorf("failed to build query: %w", err) + } + + var wt domain.WorkspaceTranslation + err = db.QueryRowContext(ctx, query, args...).Scan( + &wt.Locale, + &wt.Content, + &wt.CreatedAt, + &wt.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil // not found is not an error — fallback to empty + } + if err != nil { + return nil, fmt.Errorf("failed to get workspace translation: %w", err) + } + + return &wt, nil +} + +func (r *WorkspaceTranslationPostgresRepository) List(ctx context.Context, workspaceID string) ([]*domain.WorkspaceTranslation, error) { + db, err := r.getWorkspaceDB(workspaceID) + if err != nil { + return nil, fmt.Errorf("failed to get workspace db: %w", err) + } + + query, args, err := sq.Select("locale", "content", "created_at", "updated_at"). + From("workspace_translations"). + OrderBy("locale ASC"). + PlaceholderFormat(sq.Dollar). + ToSql() + if err != nil { + return nil, fmt.Errorf("failed to build query: %w", err) + } + + rows, err := db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("failed to list workspace translations: %w", err) + } + defer rows.Close() + + var translations []*domain.WorkspaceTranslation + for rows.Next() { + var wt domain.WorkspaceTranslation + if err := rows.Scan(&wt.Locale, &wt.Content, &wt.CreatedAt, &wt.UpdatedAt); err != nil { + return nil, fmt.Errorf("failed to scan workspace translation: %w", err) + } + translations = append(translations, &wt) + } + + return translations, rows.Err() +} + +func (r *WorkspaceTranslationPostgresRepository) Delete(ctx context.Context, workspaceID string, locale string) error { + db, err := r.getWorkspaceDB(workspaceID) + if err != nil { + return fmt.Errorf("failed to get workspace db: %w", err) + } + + query, args, err := sq.Delete("workspace_translations"). + Where(sq.Eq{"locale": locale}). + PlaceholderFormat(sq.Dollar). + ToSql() + if err != nil { + return fmt.Errorf("failed to build query: %w", err) + } + + _, err = db.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to delete workspace translation: %w", err) + } + + return nil +} +``` + +### Step 5: Write repository tests with sqlmock + +Follow the existing pattern in `template_postgres_test.go`. Test Upsert, GetByLocale (found + not found), List, Delete. + +### Step 6: Run repository tests + +Run: `cd /var/www/forks/notifuse && go test ./internal/repository/ -v` +Expected: All PASS. + +### Step 7: Generate mocks + +Run `go generate` or manually create mock for `WorkspaceTranslationRepository` interface using gomock, following the existing mock patterns. + +### Step 8: Commit + +```bash +git add internal/repository/template_postgres.go internal/repository/template_postgres_test.go internal/repository/workspace_translation_postgres.go internal/repository/workspace_translation_postgres_test.go +git commit -m "feat(i18n): add repository layer for translations + +Update template repository with translations/default_language columns. +New WorkspaceTranslationPostgresRepository with full CRUD + sqlmock tests." +``` + +--- + +## Task 6: Service Layer — Workspace Translations + +Create the workspace translations service and wire it into the rendering pipeline. + +**Files:** +- Create: `internal/service/workspace_translation_service.go` +- Create: `internal/service/workspace_translation_service_test.go` + +### Step 1: Create workspace translation service + +```go +// internal/service/workspace_translation_service.go +package service + +import ( + "context" + "fmt" + "time" + + "github.com/Notifuse/notifuse/internal/domain" + "github.com/Notifuse/notifuse/pkg/logger" +) + +type WorkspaceTranslationService struct { + repo domain.WorkspaceTranslationRepository + authService domain.AuthService + logger logger.Logger +} + +func NewWorkspaceTranslationService( + repo domain.WorkspaceTranslationRepository, + authService domain.AuthService, + logger logger.Logger, +) *WorkspaceTranslationService { + return &WorkspaceTranslationService{ + repo: repo, + authService: authService, + logger: logger, + } +} + +func (s *WorkspaceTranslationService) Upsert(ctx context.Context, req domain.UpsertWorkspaceTranslationRequest) error { + if err := req.Validate(); err != nil { + return err + } + + // Authenticate + if ctx.Value(domain.SystemCallKey) == nil { + var err error + ctx, _, _, err = s.authService.AuthenticateUserForWorkspace(ctx, req.WorkspaceID) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + } + + now := time.Now() + translation := &domain.WorkspaceTranslation{ + Locale: req.Locale, + Content: req.Content, + CreatedAt: now, + UpdatedAt: now, + } + + return s.repo.Upsert(ctx, req.WorkspaceID, translation) +} + +func (s *WorkspaceTranslationService) List(ctx context.Context, workspaceID string) ([]*domain.WorkspaceTranslation, error) { + if ctx.Value(domain.SystemCallKey) == nil { + var err error + ctx, _, _, err = s.authService.AuthenticateUserForWorkspace(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("authentication failed: %w", err) + } + } + + return s.repo.List(ctx, workspaceID) +} + +func (s *WorkspaceTranslationService) GetByLocale(ctx context.Context, workspaceID string, locale string) (*domain.WorkspaceTranslation, error) { + return s.repo.GetByLocale(ctx, workspaceID, locale) +} + +func (s *WorkspaceTranslationService) Delete(ctx context.Context, workspaceID string, locale string) error { + if ctx.Value(domain.SystemCallKey) == nil { + var err error + ctx, _, _, err = s.authService.AuthenticateUserForWorkspace(ctx, workspaceID) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + } + + return s.repo.Delete(ctx, workspaceID, locale) +} +``` + +### Step 2: Write service tests with gomock + +Follow the pattern in `template_service_test.go`. Test auth, validation, and delegation to repo. + +### Step 3: Run service tests + +Run: `cd /var/www/forks/notifuse && go test ./internal/service/ -run TestWorkspaceTranslation -v` +Expected: All PASS. + +### Step 4: Commit + +```bash +git add internal/service/workspace_translation_service.go internal/service/workspace_translation_service_test.go +git commit -m "feat(i18n): add workspace translation service + +CRUD operations for workspace-level translations with auth + validation." +``` + +--- + +## Task 7: Wire Translations Into Rendering Pipeline + +The critical integration point. Modify `CompileTemplate`, `SendEmailForTemplate`, and broadcast senders to resolve locale, merge translations, and register the `t` filter. + +**Files:** +- Modify: `pkg/notifuse_mjml/template_compilation.go` (accept translations in CompileTemplateRequest) +- Modify: `pkg/notifuse_mjml/converter.go` (pass translations to Liquid engine) +- Modify: `internal/domain/template.go` (add Translations to CompileTemplateRequest) +- Modify: `internal/service/email_service.go` (resolve locale, merge translations before compilation) +- Modify: `internal/service/broadcast/queue_message_sender.go` (same) + +### Step 1: Add `Translations` to `CompileTemplateRequest` + +In `internal/domain/template.go`, find `CompileTemplateRequest` (currently in `pkg/notifuse_mjml/template_compilation.go`) and add: + +```go +type CompileTemplateRequest struct { + // ... existing fields ... + Translations map[string]interface{} // merged translations for resolved locale (optional) +} +``` + +### Step 2: Pass translations through the compilation pipeline + +In `pkg/notifuse_mjml/template_compilation.go`, when `PreserveLiquid` is false and `req.Translations` is non-nil, create the engine with translations registered: + +In `ConvertJSONToMJMLWithData` (or wherever the Liquid engine is created), before rendering: + +```go +if req.Translations != nil && len(req.Translations) > 0 { + engine.RegisterTranslations(req.Translations) +} +``` + +The key integration point is in `processLiquidContent` (converter.go) which creates a new `SecureLiquidEngine`. This function needs to accept an optional translations map and register it before rendering. + +Update `processLiquidContent` signature: + +```go +func processLiquidContent(content string, templateData map[string]interface{}, context string) (string, error) +``` + +to: + +```go +func processLiquidContentWithTranslations(content string, templateData map[string]interface{}, context string, translations map[string]interface{}) (string, error) +``` + +And in the new function, after creating the engine: + +```go +engine := NewSecureLiquidEngine() +if translations != nil { + engine.RegisterTranslations(translations) +} +``` + +Keep the original `processLiquidContent` as a wrapper that passes `nil` for translations to maintain backward compatibility. + +Also update `ProcessLiquidTemplate` (the public function used by email_service.go for subject rendering): + +```go +func ProcessLiquidTemplateWithTranslations(content string, templateData map[string]interface{}, context string, translations map[string]interface{}) (string, error) { + return processLiquidContentWithTranslations(content, templateData, context, translations) +} +``` + +### Step 3: Wire translations into `SendEmailForTemplate` + +In `internal/service/email_service.go`, in `SendEmailForTemplate()`, after getting the template (line ~258) and before building the compile request (line ~310): + +```go +// Resolve locale and merge translations +var mergedTranslations map[string]interface{} +if template.Translations != nil && len(template.Translations) > 0 { + // Get contact language from the template data + contactLang := "" + if contactData, ok := request.MessageData.Data["contact"].(map[string]interface{}); ok { + if lang, ok := contactData["language"].(string); ok { + contactLang = lang + } + } + + // Get workspace settings for default language + workspaceDefaultLang := workspace.Settings.GetDefaultLanguage() + + // Get available locales from template translations + availableLocales := make([]string, 0, len(template.Translations)) + for locale := range template.Translations { + availableLocales = append(availableLocales, locale) + } + + // Resolve best locale + resolvedLocale := domain.ResolveLocale(contactLang, availableLocales, template.DefaultLanguage, workspaceDefaultLang) + + // Get template translations for resolved locale + templateTranslations, _ := template.Translations[resolvedLocale].(map[string]interface{}) + + // Get workspace translations for resolved locale (best effort) + var workspaceTranslations map[string]interface{} + wsTranslation, err := s.workspaceTranslationRepo.GetByLocale(ctx, request.WorkspaceID, resolvedLocale) + if err == nil && wsTranslation != nil { + workspaceTranslations = wsTranslation.Content + } + + // Merge: workspace base, template override + mergedTranslations = domain.MergeTranslations(workspaceTranslations, templateTranslations) +} +``` + +Then pass `mergedTranslations` into the compile request and the subject rendering call. + +### Step 4: Wire translations into broadcast sender + +Same pattern in `internal/service/broadcast/queue_message_sender.go` in `buildQueueEntry()`. The template and workspace translations should be loaded once per batch (not per recipient). Only the locale resolution changes per contact. + +### Step 5: Write integration-style tests + +Test the full flow: template with translations → compile with contact language → verify correct translation appears in output. + +### Step 6: Run all tests + +Run: `cd /var/www/forks/notifuse && make test-unit` +Expected: All PASS. + +### Step 7: Commit + +```bash +git add pkg/notifuse_mjml/template_compilation.go pkg/notifuse_mjml/converter.go internal/domain/template.go internal/service/email_service.go internal/service/broadcast/queue_message_sender.go +git commit -m "feat(i18n): wire translations into rendering pipeline + +Resolve locale from contact.language, merge template + workspace +translations, register t filter on Liquid engine before compilation. +Works for both transactional and broadcast sends." +``` + +--- + +## Task 8: HTTP Handler — Workspace Translations API + +Add REST endpoints for workspace translation CRUD. + +**Files:** +- Create: `internal/http/workspace_translation_handler.go` +- Create: `internal/http/workspace_translation_handler_test.go` +- Modify: `internal/http/router.go` (or wherever routes are registered) + +### Step 1: Create handler + +Follow the existing pattern from `template_handler.go`. Create handlers for: + +- `POST /api/workspace_translations.upsert` — JSON body: `{workspace_id, locale, content}` +- `GET /api/workspace_translations.list` — query param: `workspace_id` +- `POST /api/workspace_translations.delete` — JSON body: `{workspace_id, locale}` + +### Step 2: Register routes + +Add routes in the router file, following the existing pattern with auth middleware. + +### Step 3: Write handler tests + +Use `httptest.NewRecorder()` and follow the pattern in `template_handler_test.go`. + +### Step 4: Run handler tests + +Run: `cd /var/www/forks/notifuse && go test ./internal/http/ -run TestWorkspaceTranslation -v` +Expected: All PASS. + +### Step 5: Commit + +```bash +git add internal/http/workspace_translation_handler.go internal/http/workspace_translation_handler_test.go internal/http/router.go +git commit -m "feat(i18n): add workspace translations API endpoints + +POST workspace_translations.upsert, GET .list, POST .delete" +``` + +--- + +## Task 9: Dependency Wiring + +Wire the new repository, service, and handler into the application bootstrap. + +**Files:** +- Modify: `cmd/api/main.go` (or wherever DI wiring happens) + +### Step 1: Find the DI wiring location + +Look at `cmd/api/main.go` or `cmd/api/server.go` for where repositories and services are constructed. Add: + +```go +// Repository +workspaceTranslationRepo := repository.NewWorkspaceTranslationPostgresRepository(getWorkspaceDB) + +// Service +workspaceTranslationService := service.NewWorkspaceTranslationService(workspaceTranslationRepo, authService, appLogger) + +// Handler +workspaceTranslationHandler := http.NewWorkspaceTranslationHandler(workspaceTranslationService) +``` + +Also wire `workspaceTranslationRepo` into `EmailService` (it needs it for locale resolution during send). + +### Step 2: Verify the application compiles and starts + +Run: `cd /var/www/forks/notifuse && go build ./cmd/api/` +Expected: Builds successfully. + +### Step 3: Commit + +```bash +git add cmd/api/ +git commit -m "feat(i18n): wire translation dependencies into application bootstrap" +``` + +--- + +## Task 10: Frontend — API Types & Service + +Add TypeScript types and API functions for translations. + +**Files:** +- Modify: `console/src/services/api/template.ts` (add translations fields to Template interface) +- Create: `console/src/services/api/workspace-translations.ts` + +### Step 1: Update Template interface + +In `console/src/services/api/template.ts`, add to the `Template` interface: + +```typescript +export interface Template { + // ... existing fields ... + translations?: Record> // locale → nested key-value + default_language?: string +} +``` + +### Step 2: Create workspace translations API service + +```typescript +// console/src/services/api/workspace-translations.ts +import { apiClient } from './client' + +export interface WorkspaceTranslation { + locale: string + content: Record + created_at: string + updated_at: string +} + +export async function listWorkspaceTranslations(workspaceId: string): Promise { + const response = await apiClient.get('/api/workspace_translations.list', { + params: { workspace_id: workspaceId }, + }) + return response.data.translations || [] +} + +export async function upsertWorkspaceTranslation( + workspaceId: string, + locale: string, + content: Record +): Promise { + await apiClient.post('/api/workspace_translations.upsert', { + workspace_id: workspaceId, + locale, + content, + }) +} + +export async function deleteWorkspaceTranslation( + workspaceId: string, + locale: string +): Promise { + await apiClient.post('/api/workspace_translations.delete', { + workspace_id: workspaceId, + locale, + }) +} +``` + +### Step 3: Commit + +```bash +git add console/src/services/api/template.ts console/src/services/api/workspace-translations.ts +git commit -m "feat(i18n): add frontend API types and service for translations" +``` + +--- + +## Task 11: Frontend — Translations Panel Component + +Build the translations management UI panel for the template editor. + +**Files:** +- Create: `console/src/components/templates/TranslationsPanel.tsx` +- Modify: `console/src/components/templates/CreateTemplateDrawer.tsx` (integrate panel) + +### Step 1: Build the TranslationsPanel component + +Key features: +- Displays translation keys grouped by nested prefix (collapsible tree) +- Input fields per supported language for each key +- Default language marked as required (checkmark indicator) +- Missing translations shown with warning indicator +- "Add Key" button with dot-path input +- "Delete Key" button per key +- Import/Export JSON buttons + +Use Ant Design components: `Collapse`, `Input`, `Button`, `Upload`, `Tag`, `Tooltip`, `Space`. + +Use the workspace's `supported_languages` to determine which locale columns to show. + +The component receives and updates the `translations` field on the Template object (controlled state, lifting state up to the parent drawer). + +### Step 2: Integrate into CreateTemplateDrawer + +Add a "Translations" tab alongside existing tabs in the template editor. When selected, show the `TranslationsPanel` with the current template's translations. + +Use `useLingui()` for all user-facing strings (following the i18n pattern established in the console). + +### Step 3: Test the panel + +Write a Vitest test for the TranslationsPanel component: +- Renders translation keys +- Adding a key works +- Deleting a key works +- Import JSON works +- Export JSON produces correct output + +### Step 4: Run frontend tests + +Run: `cd /var/www/forks/notifuse/console && pnpm test` +Expected: All PASS. + +### Step 5: Commit + +```bash +git add console/src/components/templates/TranslationsPanel.tsx console/src/components/templates/CreateTemplateDrawer.tsx +git commit -m "feat(i18n): add translations panel to template editor + +Collapsible key tree with per-locale inputs, add/delete keys, +JSON import/export. Integrated as a tab in the template editor drawer." +``` + +--- + +## Task 12: Frontend — Workspace Language Settings + +Add language configuration to the workspace settings page. + +**Files:** +- Modify: `console/src/pages/WorkspaceSettingsPage.tsx` (add language settings section) +- Create: `console/src/components/settings/LanguageSettings.tsx` + +### Step 1: Create LanguageSettings component + +A settings section that allows: +- Setting the workspace default language (dropdown with common language codes) +- Managing supported languages (tag-based multi-select) + +Uses Ant Design `Select` with predefined language options (en, fr, de, es, pt, it, nl, ja, ko, zh, ru, ar, etc.). + +### Step 2: Add to WorkspaceSettingsPage + +Add `'languages'` to the `validSections` array and render the `LanguageSettings` component when that section is active. + +### Step 3: Run frontend tests + +Run: `cd /var/www/forks/notifuse/console && pnpm test` +Expected: All PASS. + +### Step 4: Extract i18n strings + +Run: `cd /var/www/forks/notifuse/console && pnpm run lingui:extract` + +### Step 5: Commit + +```bash +git add console/src/pages/WorkspaceSettingsPage.tsx console/src/components/settings/LanguageSettings.tsx console/src/i18n/ +git commit -m "feat(i18n): add workspace language settings UI + +Default language and supported languages configuration in workspace settings." +``` + +--- + +## Task 13: Final Integration Testing & Cleanup + +End-to-end verification that the full pipeline works. + +**Files:** +- Run all backend tests +- Run all frontend tests +- Manual smoke test checklist + +### Step 1: Run full backend test suite + +Run: `cd /var/www/forks/notifuse && make test-unit` +Expected: All PASS. + +### Step 2: Run frontend tests + +Run: `cd /var/www/forks/notifuse/console && pnpm test` +Expected: All PASS. + +### Step 3: Run linting + +Run: `cd /var/www/forks/notifuse/console && pnpm run lint` +Expected: No errors. + +### Step 4: Build check + +Run: `cd /var/www/forks/notifuse && go build ./cmd/api/` +Run: `cd /var/www/forks/notifuse/console && pnpm run build` +Expected: Both build successfully. + +### Step 5: Manual smoke test checklist + +- [ ] Create workspace, set default language to "en", supported languages to ["en", "fr"] +- [ ] Create template with `{{ "welcome.heading" | t }}` in content +- [ ] Add translations: en → "Welcome!", fr → "Bienvenue !" +- [ ] Preview template — shows English +- [ ] Send transactional email to contact with language "fr" — receives French content +- [ ] Send transactional email to contact with no language — receives English (default) +- [ ] Send transactional email to contact with language "pt-BR" — receives English (fallback) +- [ ] Test placeholder: `{{ "greeting" | t: name: contact.first_name }}` with translation "Hello {{ name }}!" +- [ ] Import/export JSON translations round-trip +- [ ] Workspace translations: create shared key, reference from template, verify it resolves + +### Step 6: Final commit + +```bash +git commit -m "feat(i18n): template-level internationalization + +Implements issue #268. Templates can now use {{ \"key\" | t }} syntax +to reference translation keys. Translations stored as nested JSON +per locale. Automatic language resolution from contact.language +with fallback chain. Workspace-level shared translations supported." +``` + +--- + +## Summary + +| Task | Description | Layer | +|------|-------------|-------| +| 1 | Translation utility functions | Domain | +| 2 | Liquid `t` filter | Rendering engine | +| 3 | Domain model changes | Domain | +| 4 | V28 database migration | Migration | +| 5 | Repository layer | Repository | +| 6 | Workspace translation service | Service | +| 7 | Wire into rendering pipeline | Service + Rendering | +| 8 | HTTP handler for workspace translations | HTTP | +| 9 | Dependency wiring | Bootstrap | +| 10 | Frontend API types & service | Frontend | +| 11 | Translations panel component | Frontend | +| 12 | Workspace language settings | Frontend | +| 13 | Integration testing & cleanup | Testing | + +Tasks 1-2 are independent and can be done in parallel. +Tasks 3-6 must be sequential (domain → migration → repo → service). +Tasks 7-9 depend on 1-6. +Tasks 10-12 depend on 3 (types) but can start frontend work after Task 3. +Task 13 depends on everything. diff --git a/go.mod b/go.mod index 82949279..6c0407af 100644 --- a/go.mod +++ b/go.mod @@ -117,3 +117,6 @@ require ( // Use Notifuse fork until PR is merged: https://github.com/preslavrachev/gomjml/pull/33 replace github.com/preslavrachev/gomjml => github.com/Notifuse/gomjml v0.0.0-20260130090101-a038317c31c2 + +// Use local liquidgo with keyword args support for Liquid filters +replace github.com/Notifuse/liquidgo => /var/www/forks/liquidgo diff --git a/internal/database/init.go b/internal/database/init.go index a3f6c696..5d394f6c 100644 --- a/internal/database/init.go +++ b/internal/database/init.go @@ -146,6 +146,8 @@ func InitializeWorkspaceDatabase(db *sql.DB) error { integration_id VARCHAR(255), test_data JSONB, settings JSONB, + translations JSONB NOT NULL DEFAULT '{}'::jsonb, + default_language VARCHAR(10) DEFAULT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP WITH TIME ZONE, @@ -469,6 +471,12 @@ func InitializeWorkspaceDatabase(db *sql.DB) error { `CREATE INDEX IF NOT EXISTS idx_email_queue_retry ON email_queue(next_retry_at) WHERE status = 'failed' AND attempts < max_attempts`, `CREATE INDEX IF NOT EXISTS idx_email_queue_source ON email_queue(source_type, source_id, status)`, `CREATE INDEX IF NOT EXISTS idx_email_queue_integration ON email_queue(integration_id, status)`, + `CREATE TABLE IF NOT EXISTS workspace_translations ( + locale VARCHAR(10) NOT NULL PRIMARY KEY, + content JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + )`, } // Run all table creation queries diff --git a/internal/domain/template.go b/internal/domain/template.go index 42834637..b445892a 100644 --- a/internal/domain/template.go +++ b/internal/domain/template.go @@ -77,6 +77,8 @@ type Template struct { IntegrationID *string `json:"integration_id,omitempty"` // Set if template is managed by an integration (e.g., Supabase) TestData MapOfAny `json:"test_data,omitempty"` Settings MapOfAny `json:"settings,omitempty"` // Channels specific 3rd-party settings + Translations MapOfAny `json:"translations"` // {locale: {nested key-value}} + DefaultLanguage *string `json:"default_language"` // overrides workspace default CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `json:"deleted_at,omitempty"` @@ -148,6 +150,28 @@ func (t *Template) Validate() error { } } + // Validate translations if provided + if t.Translations != nil { + for locale, content := range t.Translations { + if locale == "" { + return fmt.Errorf("invalid template: translation locale cannot be empty") + } + if len(locale) > 10 { + return fmt.Errorf("invalid template: translation locale '%s' exceeds max length of 10", locale) + } + if content == nil { + return fmt.Errorf("invalid template: translation content for locale '%s' cannot be nil", locale) + } + } + } + + // Validate default_language if set + if t.DefaultLanguage != nil && *t.DefaultLanguage != "" { + if len(*t.DefaultLanguage) > 10 { + return fmt.Errorf("invalid template: default_language exceeds max length of 10") + } + } + return nil } diff --git a/internal/domain/template_test.go b/internal/domain/template_test.go index 2cf3e161..5ab8435f 100644 --- a/internal/domain/template_test.go +++ b/internal/domain/template_test.go @@ -367,6 +367,61 @@ func TestTemplate_Validate(t *testing.T) { }, wantErr: true, }, + { + name: "valid template with translations", + template: func() *Template { + t := createValidTemplate() + t.Translations = MapOfAny{ + "fr": map[string]any{"subject": "Bonjour"}, + "de": map[string]any{"subject": "Hallo"}, + } + return t + }(), + wantErr: false, + }, + { + name: "invalid template - empty translation locale", + template: func() *Template { + t := createValidTemplate() + t.Translations = MapOfAny{ + "": map[string]any{"subject": "Bonjour"}, + } + return t + }(), + wantErr: true, + }, + { + name: "invalid template - translation locale too long", + template: func() *Template { + t := createValidTemplate() + t.Translations = MapOfAny{ + "12345678901": map[string]any{"subject": "Bonjour"}, + } + return t + }(), + wantErr: true, + }, + { + name: "invalid template - nil translation content", + template: func() *Template { + t := createValidTemplate() + t.Translations = MapOfAny{ + "fr": nil, + } + return t + }(), + wantErr: true, + }, + { + name: "invalid template - default_language too long", + template: func() *Template { + t := createValidTemplate() + longLang := "12345678901" + t.DefaultLanguage = &longLang + return t + }(), + wantErr: true, + }, } for _, tt := range tests { diff --git a/internal/domain/translation.go b/internal/domain/translation.go new file mode 100644 index 00000000..202890cb --- /dev/null +++ b/internal/domain/translation.go @@ -0,0 +1,121 @@ +package domain + +import ( + "fmt" + "regexp" + "strings" +) + +// ResolveNestedKey traverses a nested map using a dot-separated key path +// and returns the string value. Returns empty string if key not found or value is not a string. +func ResolveNestedKey(data map[string]interface{}, key string) string { + if data == nil || key == "" { + return "" + } + + parts := strings.Split(key, ".") + var current interface{} = data + + for _, part := range parts { + m, ok := current.(map[string]interface{}) + if !ok { + return "" + } + current, ok = m[part] + if !ok { + return "" + } + } + + if str, ok := current.(string); ok { + return str + } + return "" +} + +var placeholderRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`) + +// InterpolatePlaceholders replaces {{ key }} placeholders in a translation value +// with the corresponding values from the args map. +func InterpolatePlaceholders(value string, args map[string]interface{}) string { + if args == nil || len(args) == 0 { + return value + } + + return placeholderRegex.ReplaceAllStringFunc(value, func(match string) string { + submatch := placeholderRegex.FindStringSubmatch(match) + if len(submatch) < 2 { + return match + } + key := submatch[1] + if val, ok := args[key]; ok { + return fmt.Sprintf("%v", val) + } + return match // leave unresolved placeholders as-is + }) +} + +// ResolveLocale determines the best locale to use given a contact's language preference, +// available translation locales, and fallback defaults. +// Fallback chain: exact match -> base language -> template default -> workspace default. +func ResolveLocale(contactLanguage string, availableLocales []string, templateDefault *string, workspaceDefault string) string { + if contactLanguage == "" { + if templateDefault != nil && *templateDefault != "" { + return *templateDefault + } + return workspaceDefault + } + + contactLang := strings.ToLower(contactLanguage) + + // 1. Exact match (case-insensitive) + for _, locale := range availableLocales { + if strings.ToLower(locale) == contactLang { + return locale + } + } + + // 2. Base language match (e.g., "pt-BR" -> "pt") + if idx := strings.Index(contactLang, "-"); idx > 0 { + baseLang := contactLang[:idx] + for _, locale := range availableLocales { + if strings.ToLower(locale) == baseLang { + return locale + } + } + } + + // 3. Template default language + if templateDefault != nil && *templateDefault != "" { + return *templateDefault + } + + // 4. Workspace default language + return workspaceDefault +} + +// MergeTranslations deep-merges two translation maps. Values in override take priority. +func MergeTranslations(base, override map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + + // Copy base + for k, v := range base { + result[k] = v + } + + // Merge override + for k, v := range override { + if baseVal, exists := result[k]; exists { + // If both are maps, deep merge + baseMap, baseIsMap := baseVal.(map[string]interface{}) + overrideMap, overrideIsMap := v.(map[string]interface{}) + if baseIsMap && overrideIsMap { + result[k] = MergeTranslations(baseMap, overrideMap) + continue + } + } + result[k] = v + } + + return result +} diff --git a/internal/domain/translation_test.go b/internal/domain/translation_test.go new file mode 100644 index 00000000..daa4de17 --- /dev/null +++ b/internal/domain/translation_test.go @@ -0,0 +1,250 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveNestedKey(t *testing.T) { + translations := map[string]interface{}{ + "welcome": map[string]interface{}{ + "heading": "Welcome!", + "greeting": "Hello {{ name }}!", + }, + "cta": map[string]interface{}{ + "button": "Get Started", + }, + "flat_key": "Flat value", + } + + tests := []struct { + name string + data map[string]interface{} + key string + expected string + }{ + {"nested key", translations, "welcome.heading", "Welcome!"}, + {"deeper nested", translations, "welcome.greeting", "Hello {{ name }}!"}, + {"different group", translations, "cta.button", "Get Started"}, + {"flat key", translations, "flat_key", "Flat value"}, + {"missing key", translations, "welcome.missing", ""}, + {"missing group", translations, "nonexistent.key", ""}, + {"empty key", translations, "", ""}, + {"nil data", nil, "welcome.heading", ""}, + {"empty data", map[string]interface{}{}, "welcome.heading", ""}, + {"key pointing to map not string", translations, "welcome", ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := ResolveNestedKey(tc.data, tc.key) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestInterpolatePlaceholders(t *testing.T) { + tests := []struct { + name string + value string + args map[string]interface{} + expected string + }{ + { + "single placeholder", + "Hello {{ name }}!", + map[string]interface{}{"name": "John"}, + "Hello John!", + }, + { + "multiple placeholders", + "{{ greeting }} {{ name }}, welcome to {{ site }}!", + map[string]interface{}{"greeting": "Hello", "name": "Jane", "site": "Notifuse"}, + "Hello Jane, welcome to Notifuse!", + }, + { + "no placeholders", + "Hello World!", + map[string]interface{}{"name": "John"}, + "Hello World!", + }, + { + "placeholder without matching arg", + "Hello {{ name }}!", + map[string]interface{}{}, + "Hello {{ name }}!", + }, + { + "nil args", + "Hello {{ name }}!", + nil, + "Hello {{ name }}!", + }, + { + "no spaces in placeholder", + "Hello {{name}}!", + map[string]interface{}{"name": "John"}, + "Hello John!", + }, + { + "extra spaces in placeholder", + "Hello {{ name }}!", + map[string]interface{}{"name": "John"}, + "Hello John!", + }, + { + "numeric value", + "You have {{ count }} items", + map[string]interface{}{"count": 5}, + "You have 5 items", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := InterpolatePlaceholders(tc.value, tc.args) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestResolveLocale(t *testing.T) { + tests := []struct { + name string + contactLanguage string + availableLocales []string + templateDefault *string + workspaceDefault string + expected string + }{ + { + "exact match", + "fr", + []string{"en", "fr", "de"}, + nil, + "en", + "fr", + }, + { + "exact match with region", + "pt-BR", + []string{"en", "pt-BR", "pt"}, + nil, + "en", + "pt-BR", + }, + { + "base language fallback", + "pt-BR", + []string{"en", "pt"}, + nil, + "en", + "pt", + }, + { + "template default fallback", + "ja", + []string{"en", "fr"}, + strPtr("fr"), + "en", + "fr", + }, + { + "workspace default fallback", + "ja", + []string{"en", "fr"}, + nil, + "en", + "en", + }, + { + "empty contact language uses workspace default", + "", + []string{"en", "fr"}, + nil, + "en", + "en", + }, + { + "case insensitive match", + "FR", + []string{"en", "fr"}, + nil, + "en", + "fr", + }, + { + "workspace default when no locales available", + "fr", + []string{}, + nil, + "en", + "en", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := ResolveLocale(tc.contactLanguage, tc.availableLocales, tc.templateDefault, tc.workspaceDefault) + assert.Equal(t, tc.expected, result) + }) + } +} + +func strPtr(s string) *string { + return &s +} + +func TestMergeTranslations(t *testing.T) { + tests := []struct { + name string + base map[string]interface{} + override map[string]interface{} + expected map[string]interface{} + }{ + { + "override wins", + map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base"}}, + map[string]interface{}{"welcome": map[string]interface{}{"heading": "Override"}}, + map[string]interface{}{"welcome": map[string]interface{}{"heading": "Override"}}, + }, + { + "deep merge adds missing keys", + map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base"}}, + map[string]interface{}{"welcome": map[string]interface{}{"body": "Override body"}}, + map[string]interface{}{"welcome": map[string]interface{}{"heading": "Base", "body": "Override body"}}, + }, + { + "nil base", + nil, + map[string]interface{}{"key": "value"}, + map[string]interface{}{"key": "value"}, + }, + { + "nil override", + map[string]interface{}{"key": "value"}, + nil, + map[string]interface{}{"key": "value"}, + }, + { + "both nil", + nil, + nil, + map[string]interface{}{}, + }, + { + "disjoint keys", + map[string]interface{}{"a": "1"}, + map[string]interface{}{"b": "2"}, + map[string]interface{}{"a": "1", "b": "2"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := MergeTranslations(tc.base, tc.override) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/internal/domain/workspace.go b/internal/domain/workspace.go index c5f56b02..a505f3bc 100644 --- a/internal/domain/workspace.go +++ b/internal/domain/workspace.go @@ -334,6 +334,8 @@ type WorkspaceSettings struct { CustomFieldLabels map[string]string `json:"custom_field_labels,omitempty"` BlogEnabled bool `json:"blog_enabled"` // Enable blog feature at workspace level BlogSettings *BlogSettings `json:"blog_settings,omitempty"` // Blog styling and SEO settings + DefaultLanguage string `json:"default_language,omitempty"` // e.g., "en" + SupportedLanguages []string `json:"supported_languages,omitempty"` // e.g., ["en", "fr", "de"] // decoded secret key, not stored in the database SecretKey string `json:"-"` @@ -455,6 +457,20 @@ func (ws *WorkspaceSettings) ValidateCustomFieldLabels() error { return nil } +func (ws *WorkspaceSettings) GetDefaultLanguage() string { + if ws.DefaultLanguage != "" { + return ws.DefaultLanguage + } + return "en" +} + +func (ws *WorkspaceSettings) GetSupportedLanguages() []string { + if len(ws.SupportedLanguages) > 0 { + return ws.SupportedLanguages + } + return []string{"en"} +} + type Workspace struct { ID string `json:"id"` Name string `json:"name"` diff --git a/internal/domain/workspace_translation.go b/internal/domain/workspace_translation.go new file mode 100644 index 00000000..39ecc57f --- /dev/null +++ b/internal/domain/workspace_translation.go @@ -0,0 +1,65 @@ +package domain + +import ( + "context" + "fmt" + "time" +) + +type WorkspaceTranslation struct { + Locale string `json:"locale"` + Content MapOfAny `json:"content"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (wt *WorkspaceTranslation) Validate() error { + if wt.Locale == "" { + return fmt.Errorf("locale is required") + } + if len(wt.Locale) > 10 { + return fmt.Errorf("locale exceeds max length of 10") + } + if wt.Content == nil { + return fmt.Errorf("content is required") + } + return nil +} + +type WorkspaceTranslationRepository interface { + Upsert(ctx context.Context, workspaceID string, translation *WorkspaceTranslation) error + GetByLocale(ctx context.Context, workspaceID string, locale string) (*WorkspaceTranslation, error) + List(ctx context.Context, workspaceID string) ([]*WorkspaceTranslation, error) + Delete(ctx context.Context, workspaceID string, locale string) error +} + +type UpsertWorkspaceTranslationRequest struct { + WorkspaceID string `json:"workspace_id"` + Locale string `json:"locale"` + Content MapOfAny `json:"content"` +} + +func (r *UpsertWorkspaceTranslationRequest) Validate() error { + if r.WorkspaceID == "" { + return fmt.Errorf("workspace_id is required") + } + if r.Locale == "" { + return fmt.Errorf("locale is required") + } + if len(r.Locale) > 10 { + return fmt.Errorf("locale exceeds max length of 10") + } + if r.Content == nil { + return fmt.Errorf("content is required") + } + return nil +} + +type ListWorkspaceTranslationsRequest struct { + WorkspaceID string `json:"workspace_id"` +} + +type DeleteWorkspaceTranslationRequest struct { + WorkspaceID string `json:"workspace_id"` + Locale string `json:"locale"` +} diff --git a/internal/domain/workspace_translation_test.go b/internal/domain/workspace_translation_test.go new file mode 100644 index 00000000..e570f4b0 --- /dev/null +++ b/internal/domain/workspace_translation_test.go @@ -0,0 +1,72 @@ +package domain + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWorkspaceTranslation_Validate(t *testing.T) { + tests := []struct { + name string + wt WorkspaceTranslation + expectErr bool + }{ + {"valid", WorkspaceTranslation{Locale: "en", Content: MapOfAny{"key": "value"}}, false}, + {"empty locale", WorkspaceTranslation{Locale: "", Content: MapOfAny{"key": "value"}}, true}, + {"locale too long", WorkspaceTranslation{Locale: "12345678901", Content: MapOfAny{"key": "value"}}, true}, + {"nil content", WorkspaceTranslation{Locale: "en", Content: nil}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.wt.Validate() + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestWorkspaceSettings_GetDefaultLanguage(t *testing.T) { + ws := &WorkspaceSettings{} + assert.Equal(t, "en", ws.GetDefaultLanguage()) + + ws.DefaultLanguage = "fr" + assert.Equal(t, "fr", ws.GetDefaultLanguage()) +} + +func TestWorkspaceSettings_GetSupportedLanguages(t *testing.T) { + ws := &WorkspaceSettings{} + assert.Equal(t, []string{"en"}, ws.GetSupportedLanguages()) + + ws.SupportedLanguages = []string{"en", "fr", "de"} + assert.Equal(t, []string{"en", "fr", "de"}, ws.GetSupportedLanguages()) +} + +func TestUpsertWorkspaceTranslationRequest_Validate(t *testing.T) { + tests := []struct { + name string + req UpsertWorkspaceTranslationRequest + expectErr bool + }{ + {"valid", UpsertWorkspaceTranslationRequest{WorkspaceID: "ws1", Locale: "en", Content: MapOfAny{"key": "value"}}, false}, + {"empty workspace_id", UpsertWorkspaceTranslationRequest{WorkspaceID: "", Locale: "en", Content: MapOfAny{"key": "value"}}, true}, + {"empty locale", UpsertWorkspaceTranslationRequest{WorkspaceID: "ws1", Locale: "", Content: MapOfAny{"key": "value"}}, true}, + {"locale too long", UpsertWorkspaceTranslationRequest{WorkspaceID: "ws1", Locale: "12345678901", Content: MapOfAny{"key": "value"}}, true}, + {"nil content", UpsertWorkspaceTranslationRequest{WorkspaceID: "ws1", Locale: "en", Content: nil}, true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.req.Validate() + if tc.expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/migrations/manager_test.go b/internal/migrations/manager_test.go index 8829fdf5..b715f171 100644 --- a/internal/migrations/manager_test.go +++ b/internal/migrations/manager_test.go @@ -539,9 +539,9 @@ func TestManager_RunMigrations_AdditionalCoverage(t *testing.T) { }, } - // Mock GetCurrentDBVersion to return current version (27 - up to date) + // Mock GetCurrentDBVersion to return current version (28 - up to date) mock.ExpectQuery("SELECT value FROM settings WHERE key = 'db_version'"). - WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("27")) + WillReturnRows(sqlmock.NewRows([]string{"value"}).AddRow("28")) err = manager.RunMigrations(context.Background(), cfg, db) diff --git a/internal/migrations/v28.go b/internal/migrations/v28.go new file mode 100644 index 00000000..a2f52565 --- /dev/null +++ b/internal/migrations/v28.go @@ -0,0 +1,60 @@ +package migrations + +import ( + "context" + "fmt" + + "github.com/Notifuse/notifuse/config" + "github.com/Notifuse/notifuse/internal/domain" +) + +// V28Migration adds template i18n support. +type V28Migration struct{} + +func (m *V28Migration) GetMajorVersion() float64 { return 28.0 } +func (m *V28Migration) HasSystemUpdate() bool { return false } +func (m *V28Migration) HasWorkspaceUpdate() bool { return true } +func (m *V28Migration) ShouldRestartServer() bool { return false } + +func (m *V28Migration) UpdateSystem(ctx context.Context, cfg *config.Config, db DBExecutor) error { + return nil +} + +func (m *V28Migration) UpdateWorkspace(ctx context.Context, cfg *config.Config, workspace *domain.Workspace, db DBExecutor) error { + // Add translations column to templates table + _, err := db.ExecContext(ctx, ` + ALTER TABLE templates + ADD COLUMN IF NOT EXISTS translations JSONB NOT NULL DEFAULT '{}'::jsonb + `) + if err != nil { + return fmt.Errorf("failed to add translations column: %w", err) + } + + // Add default_language column to templates table + _, err = db.ExecContext(ctx, ` + ALTER TABLE templates + ADD COLUMN IF NOT EXISTS default_language VARCHAR(10) DEFAULT NULL + `) + if err != nil { + return fmt.Errorf("failed to add default_language column: %w", err) + } + + // Create workspace_translations table + _, err = db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS workspace_translations ( + locale VARCHAR(10) NOT NULL PRIMARY KEY, + content JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `) + if err != nil { + return fmt.Errorf("failed to create workspace_translations table: %w", err) + } + + return nil +} + +func init() { + Register(&V28Migration{}) +} diff --git a/internal/migrations/v28_test.go b/internal/migrations/v28_test.go new file mode 100644 index 00000000..7221efe4 --- /dev/null +++ b/internal/migrations/v28_test.go @@ -0,0 +1,91 @@ +package migrations + +import ( + "context" + "fmt" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Notifuse/notifuse/config" + "github.com/Notifuse/notifuse/internal/domain" + "github.com/stretchr/testify/assert" +) + +func TestV28Migration_GetMajorVersion(t *testing.T) { + m := &V28Migration{} + assert.Equal(t, 28.0, m.GetMajorVersion()) +} + +func TestV28Migration_HasSystemUpdate(t *testing.T) { + m := &V28Migration{} + assert.False(t, m.HasSystemUpdate()) +} + +func TestV28Migration_HasWorkspaceUpdate(t *testing.T) { + m := &V28Migration{} + assert.True(t, m.HasWorkspaceUpdate()) +} + +func TestV28Migration_ShouldRestartServer(t *testing.T) { + m := &V28Migration{} + assert.False(t, m.ShouldRestartServer()) +} + +func TestV28Migration_UpdateWorkspace(t *testing.T) { + m := &V28Migration{} + cfg := &config.Config{} + workspace := &domain.Workspace{ID: "test"} + + t.Run("success", func(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("CREATE TABLE IF NOT EXISTS workspace_translations").WillReturnResult(sqlmock.NewResult(0, 0)) + + err = m.UpdateWorkspace(context.Background(), cfg, workspace, db) + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) + }) + + t.Run("translations column error", func(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + mock.ExpectExec("ALTER TABLE templates").WillReturnError(fmt.Errorf("db error")) + + err = m.UpdateWorkspace(context.Background(), cfg, workspace, db) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to add translations column") + }) + + t.Run("default_language column error", func(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("ALTER TABLE templates").WillReturnError(fmt.Errorf("db error")) + + err = m.UpdateWorkspace(context.Background(), cfg, workspace, db) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to add default_language column") + }) + + t.Run("workspace_translations table error", func(t *testing.T) { + db, mock, err := sqlmock.New() + assert.NoError(t, err) + defer db.Close() + + mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("ALTER TABLE templates").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec("CREATE TABLE IF NOT EXISTS workspace_translations").WillReturnError(fmt.Errorf("db error")) + + err = m.UpdateWorkspace(context.Background(), cfg, workspace, db) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create workspace_translations table") + }) +} diff --git a/internal/service/workspace_translation_service.go b/internal/service/workspace_translation_service.go new file mode 100644 index 00000000..da4b9282 --- /dev/null +++ b/internal/service/workspace_translation_service.go @@ -0,0 +1,104 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/Notifuse/notifuse/internal/domain" + "github.com/Notifuse/notifuse/pkg/logger" +) + +type WorkspaceTranslationService struct { + repo domain.WorkspaceTranslationRepository + authService domain.AuthService + logger logger.Logger +} + +func NewWorkspaceTranslationService( + repo domain.WorkspaceTranslationRepository, + authService domain.AuthService, + logger logger.Logger, +) *WorkspaceTranslationService { + return &WorkspaceTranslationService{ + repo: repo, + authService: authService, + logger: logger, + } +} + +func (s *WorkspaceTranslationService) Upsert(ctx context.Context, req domain.UpsertWorkspaceTranslationRequest) error { + if err := req.Validate(); err != nil { + return err + } + + if ctx.Value(domain.SystemCallKey) == nil { + var err error + var userWorkspace *domain.UserWorkspace + ctx, _, userWorkspace, err = s.authService.AuthenticateUserForWorkspace(ctx, req.WorkspaceID) + if err != nil { + return fmt.Errorf("failed to authenticate user: %w", err) + } + if !userWorkspace.HasPermission(domain.PermissionResourceWorkspace, domain.PermissionTypeWrite) { + return domain.NewPermissionError( + domain.PermissionResourceWorkspace, + domain.PermissionTypeWrite, + "Insufficient permissions: write access to workspace required", + ) + } + } + + now := time.Now().UTC() + translation := &domain.WorkspaceTranslation{ + Locale: req.Locale, + Content: req.Content, + CreatedAt: now, + UpdatedAt: now, + } + + return s.repo.Upsert(ctx, req.WorkspaceID, translation) +} + +func (s *WorkspaceTranslationService) List(ctx context.Context, workspaceID string) ([]*domain.WorkspaceTranslation, error) { + if ctx.Value(domain.SystemCallKey) == nil { + var err error + var userWorkspace *domain.UserWorkspace + ctx, _, userWorkspace, err = s.authService.AuthenticateUserForWorkspace(ctx, workspaceID) + if err != nil { + return nil, fmt.Errorf("failed to authenticate user: %w", err) + } + if !userWorkspace.HasPermission(domain.PermissionResourceWorkspace, domain.PermissionTypeRead) { + return nil, domain.NewPermissionError( + domain.PermissionResourceWorkspace, + domain.PermissionTypeRead, + "Insufficient permissions: read access to workspace required", + ) + } + } + + return s.repo.List(ctx, workspaceID) +} + +func (s *WorkspaceTranslationService) GetByLocale(ctx context.Context, workspaceID string, locale string) (*domain.WorkspaceTranslation, error) { + return s.repo.GetByLocale(ctx, workspaceID, locale) +} + +func (s *WorkspaceTranslationService) Delete(ctx context.Context, workspaceID string, locale string) error { + if ctx.Value(domain.SystemCallKey) == nil { + var err error + var userWorkspace *domain.UserWorkspace + ctx, _, userWorkspace, err = s.authService.AuthenticateUserForWorkspace(ctx, workspaceID) + if err != nil { + return fmt.Errorf("failed to authenticate user: %w", err) + } + if !userWorkspace.HasPermission(domain.PermissionResourceWorkspace, domain.PermissionTypeWrite) { + return domain.NewPermissionError( + domain.PermissionResourceWorkspace, + domain.PermissionTypeWrite, + "Insufficient permissions: write access to workspace required", + ) + } + } + + return s.repo.Delete(ctx, workspaceID, locale) +} diff --git a/internal/service/workspace_translation_service_test.go b/internal/service/workspace_translation_service_test.go new file mode 100644 index 00000000..f0a8af55 --- /dev/null +++ b/internal/service/workspace_translation_service_test.go @@ -0,0 +1,402 @@ +package service_test + +import ( + "context" + "errors" + "testing" + + "github.com/Notifuse/notifuse/internal/domain" + domainmocks "github.com/Notifuse/notifuse/internal/domain/mocks" + "github.com/Notifuse/notifuse/internal/service" + pkgmocks "github.com/Notifuse/notifuse/pkg/mocks" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func setupWorkspaceTranslationServiceTest(ctrl *gomock.Controller) ( + *service.WorkspaceTranslationService, + *domainmocks.MockWorkspaceTranslationRepository, + *domainmocks.MockAuthService, + *pkgmocks.MockLogger, +) { + mockRepo := domainmocks.NewMockWorkspaceTranslationRepository(ctrl) + mockAuthService := domainmocks.NewMockAuthService(ctrl) + mockLogger := pkgmocks.NewMockLogger(ctrl) + + svc := service.NewWorkspaceTranslationService(mockRepo, mockAuthService, mockLogger) + return svc, mockRepo, mockAuthService, mockLogger +} + +// --------------------------------------------------------------------------- +// Upsert +// --------------------------------------------------------------------------- + +func TestWorkspaceTranslationService_Upsert(t *testing.T) { + ctx := context.Background() + workspaceID := "ws-123" + userID := "user-1" + + validReq := domain.UpsertWorkspaceTranslationRequest{ + WorkspaceID: workspaceID, + Locale: "fr", + Content: domain.MapOfAny{"greeting": "Bonjour"}, + } + + t.Run("Success", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{ + UserID: userID, + WorkspaceID: workspaceID, + Role: "member", + Permissions: domain.UserPermissions{ + domain.PermissionResourceWorkspace: {Read: true, Write: true}, + }, + }, nil) + + mockRepo.EXPECT().Upsert(ctx, workspaceID, gomock.Any()).Return(nil) + + err := svc.Upsert(ctx, validReq) + assert.NoError(t, err) + }) + + t.Run("Validation error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, _, _, _ := setupWorkspaceTranslationServiceTest(ctrl) + + invalidReq := domain.UpsertWorkspaceTranslationRequest{ + WorkspaceID: "", + Locale: "fr", + Content: domain.MapOfAny{"greeting": "Bonjour"}, + } + + err := svc.Upsert(ctx, invalidReq) + assert.Error(t, err) + assert.Contains(t, err.Error(), "workspace_id is required") + }) + + t.Run("Auth error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + authErr := errors.New("auth error") + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, nil, nil, authErr) + + err := svc.Upsert(ctx, validReq) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to authenticate user") + }) + + t.Run("Permission error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{ + UserID: userID, + WorkspaceID: workspaceID, + Role: "member", + Permissions: domain.UserPermissions{ + domain.PermissionResourceWorkspace: {Read: true, Write: false}, + }, + }, nil) + + err := svc.Upsert(ctx, validReq) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Insufficient permissions") + }) + + t.Run("System call bypass", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl) + + systemCtx := context.WithValue(ctx, domain.SystemCallKey, true) + mockRepo.EXPECT().Upsert(systemCtx, workspaceID, gomock.Any()).Return(nil) + + err := svc.Upsert(systemCtx, validReq) + assert.NoError(t, err) + }) + + t.Run("Repo error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{ + UserID: userID, + WorkspaceID: workspaceID, + Role: "member", + Permissions: domain.UserPermissions{ + domain.PermissionResourceWorkspace: {Read: true, Write: true}, + }, + }, nil) + + repoErr := errors.New("db error") + mockRepo.EXPECT().Upsert(ctx, workspaceID, gomock.Any()).Return(repoErr) + + err := svc.Upsert(ctx, validReq) + assert.Error(t, err) + assert.Equal(t, repoErr, err) + }) +} + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +func TestWorkspaceTranslationService_List(t *testing.T) { + ctx := context.Background() + workspaceID := "ws-123" + userID := "user-1" + + t.Run("Success", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{ + UserID: userID, + WorkspaceID: workspaceID, + Role: "member", + Permissions: domain.UserPermissions{ + domain.PermissionResourceWorkspace: {Read: true, Write: false}, + }, + }, nil) + + expected := []*domain.WorkspaceTranslation{ + {Locale: "fr", Content: domain.MapOfAny{"greeting": "Bonjour"}}, + {Locale: "es", Content: domain.MapOfAny{"greeting": "Hola"}}, + } + mockRepo.EXPECT().List(ctx, workspaceID).Return(expected, nil) + + result, err := svc.List(ctx, workspaceID) + assert.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("Auth error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + authErr := errors.New("auth error") + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, nil, nil, authErr) + + result, err := svc.List(ctx, workspaceID) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "failed to authenticate user") + }) + + t.Run("Permission error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{ + UserID: userID, + WorkspaceID: workspaceID, + Role: "member", + Permissions: domain.UserPermissions{ + domain.PermissionResourceWorkspace: {Read: false, Write: false}, + }, + }, nil) + + result, err := svc.List(ctx, workspaceID) + assert.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "Insufficient permissions") + }) + + t.Run("System call bypass", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl) + + systemCtx := context.WithValue(ctx, domain.SystemCallKey, true) + expected := []*domain.WorkspaceTranslation{ + {Locale: "fr", Content: domain.MapOfAny{"greeting": "Bonjour"}}, + } + mockRepo.EXPECT().List(systemCtx, workspaceID).Return(expected, nil) + + result, err := svc.List(systemCtx, workspaceID) + assert.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("Repo error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{ + UserID: userID, + WorkspaceID: workspaceID, + Role: "member", + Permissions: domain.UserPermissions{ + domain.PermissionResourceWorkspace: {Read: true, Write: false}, + }, + }, nil) + + repoErr := errors.New("db error") + mockRepo.EXPECT().List(ctx, workspaceID).Return(nil, repoErr) + + result, err := svc.List(ctx, workspaceID) + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, repoErr, err) + }) +} + +// --------------------------------------------------------------------------- +// GetByLocale +// --------------------------------------------------------------------------- + +func TestWorkspaceTranslationService_GetByLocale(t *testing.T) { + ctx := context.Background() + workspaceID := "ws-123" + + t.Run("Success", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl) + + expected := &domain.WorkspaceTranslation{ + Locale: "fr", + Content: domain.MapOfAny{"greeting": "Bonjour"}, + } + mockRepo.EXPECT().GetByLocale(ctx, workspaceID, "fr").Return(expected, nil) + + result, err := svc.GetByLocale(ctx, workspaceID, "fr") + assert.NoError(t, err) + assert.Equal(t, expected, result) + }) + + t.Run("Repo error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl) + + repoErr := errors.New("db error") + mockRepo.EXPECT().GetByLocale(ctx, workspaceID, "fr").Return(nil, repoErr) + + result, err := svc.GetByLocale(ctx, workspaceID, "fr") + assert.Error(t, err) + assert.Nil(t, result) + assert.Equal(t, repoErr, err) + }) + + t.Run("Not found returns nil", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl) + + mockRepo.EXPECT().GetByLocale(ctx, workspaceID, "xx").Return(nil, nil) + + result, err := svc.GetByLocale(ctx, workspaceID, "xx") + assert.NoError(t, err) + assert.Nil(t, result) + }) +} + +// --------------------------------------------------------------------------- +// Delete +// --------------------------------------------------------------------------- + +func TestWorkspaceTranslationService_Delete(t *testing.T) { + ctx := context.Background() + workspaceID := "ws-123" + userID := "user-1" + locale := "fr" + + t.Run("Success", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{ + UserID: userID, + WorkspaceID: workspaceID, + Role: "member", + Permissions: domain.UserPermissions{ + domain.PermissionResourceWorkspace: {Read: true, Write: true}, + }, + }, nil) + + mockRepo.EXPECT().Delete(ctx, workspaceID, locale).Return(nil) + + err := svc.Delete(ctx, workspaceID, locale) + assert.NoError(t, err) + }) + + t.Run("Auth error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + authErr := errors.New("auth error") + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, nil, nil, authErr) + + err := svc.Delete(ctx, workspaceID, locale) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to authenticate user") + }) + + t.Run("Permission error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, _, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{ + UserID: userID, + WorkspaceID: workspaceID, + Role: "member", + Permissions: domain.UserPermissions{ + domain.PermissionResourceWorkspace: {Read: true, Write: false}, + }, + }, nil) + + err := svc.Delete(ctx, workspaceID, locale) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Insufficient permissions") + }) + + t.Run("System call bypass", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, _, _ := setupWorkspaceTranslationServiceTest(ctrl) + + systemCtx := context.WithValue(ctx, domain.SystemCallKey, true) + mockRepo.EXPECT().Delete(systemCtx, workspaceID, locale).Return(nil) + + err := svc.Delete(systemCtx, workspaceID, locale) + assert.NoError(t, err) + }) + + t.Run("Repo error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + svc, mockRepo, mockAuth, _ := setupWorkspaceTranslationServiceTest(ctrl) + + mockAuth.EXPECT().AuthenticateUserForWorkspace(ctx, workspaceID).Return(ctx, &domain.User{ID: userID}, &domain.UserWorkspace{ + UserID: userID, + WorkspaceID: workspaceID, + Role: "member", + Permissions: domain.UserPermissions{ + domain.PermissionResourceWorkspace: {Read: true, Write: true}, + }, + }, nil) + + repoErr := errors.New("db error") + mockRepo.EXPECT().Delete(ctx, workspaceID, locale).Return(repoErr) + + err := svc.Delete(ctx, workspaceID, locale) + assert.Error(t, err) + assert.Equal(t, repoErr, err) + }) +} diff --git a/openapi/components/schemas/template.yaml b/openapi/components/schemas/template.yaml index 20db8151..91792d4b 100644 --- a/openapi/components/schemas/template.yaml +++ b/openapi/components/schemas/template.yaml @@ -62,6 +62,30 @@ Template: type: object additionalProperties: true description: Channel-specific third-party settings + translations: + type: object + additionalProperties: + type: object + additionalProperties: true + description: | + Per-locale translation key-value maps. Keys are dot-separated paths (e.g., "welcome.heading"). + Values are strings that may contain {{ placeholder }} syntax for named arguments. + Outer keys are locale codes (e.g., "en", "fr", "pt-BR"). + example: + en: + welcome: + heading: "Welcome!" + greeting: "Hello {{ name }}!" + fr: + welcome: + heading: "Bienvenue !" + greeting: "Bonjour {{ name }} !" + default_language: + type: string + nullable: true + description: Override the workspace default language for this template. When null, inherits from workspace settings. + example: en + maxLength: 10 created_at: type: string format: date-time @@ -197,6 +221,17 @@ CreateTemplateRequest: type: object additionalProperties: true description: Channel-specific settings + translations: + type: object + additionalProperties: + type: object + additionalProperties: true + description: Per-locale translation key-value maps + default_language: + type: string + nullable: true + description: Override the workspace default language for this template + maxLength: 10 UpdateTemplateRequest: type: object @@ -258,6 +293,17 @@ UpdateTemplateRequest: type: object additionalProperties: true description: Channel-specific settings + translations: + type: object + additionalProperties: + type: object + additionalProperties: true + description: Per-locale translation key-value maps + default_language: + type: string + nullable: true + description: Override the workspace default language for this template + maxLength: 10 DeleteTemplateRequest: type: object @@ -308,6 +354,10 @@ CompileTemplateRequest: - email - web description: Channel filter for block visibility + translations: + type: object + additionalProperties: true + description: Merged translations map for a specific locale, used by the Liquid `t` filter during compilation CompileTemplateResponse: type: object diff --git a/openapi/components/schemas/workspace-translation.yaml b/openapi/components/schemas/workspace-translation.yaml new file mode 100644 index 00000000..dbd628dc --- /dev/null +++ b/openapi/components/schemas/workspace-translation.yaml @@ -0,0 +1,69 @@ +WorkspaceTranslation: + type: object + properties: + locale: + type: string + description: Locale code (e.g., "en", "fr", "pt-BR") + example: en + maxLength: 10 + content: + type: object + additionalProperties: true + description: | + Nested key-value translation map. Keys use dot-separated paths. + Values are strings, optionally containing {{ placeholder }} syntax. + example: + common: + greeting: "Hello" + footer: "Unsubscribe from our emails" + created_at: + type: string + format: date-time + description: When the translation was created + updated_at: + type: string + format: date-time + description: When the translation was last updated + required: + - locale + - content + +UpsertWorkspaceTranslationRequest: + type: object + required: + - workspace_id + - locale + - content + properties: + workspace_id: + type: string + description: The ID of the workspace + example: ws_1234567890 + locale: + type: string + description: Locale code + example: fr + maxLength: 10 + content: + type: object + additionalProperties: true + description: Nested key-value translation map + example: + common: + greeting: "Bonjour" + footer: "Se désabonner de nos emails" + +DeleteWorkspaceTranslationRequest: + type: object + required: + - workspace_id + - locale + properties: + workspace_id: + type: string + description: The ID of the workspace + example: ws_1234567890 + locale: + type: string + description: Locale code to delete + example: fr diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 5cf74f40..0915f0cc 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -73,6 +73,12 @@ paths: $ref: './paths/templates.yaml#/~1api~1templates.delete' /api/templates.compile: $ref: './paths/templates.yaml#/~1api~1templates.compile' + /api/workspace_translations.list: + $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.list' + /api/workspace_translations.upsert: + $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.upsert' + /api/workspace_translations.delete: + $ref: './paths/workspace-translations.yaml#/~1api~1workspace_translations.delete' /api/customEvents.import: $ref: './paths/custom-events.yaml#/~1api~1customEvents.import' /api/webhookSubscriptions.create: @@ -218,6 +224,12 @@ components: $ref: './components/schemas/template.yaml#/CompileTemplateResponse' TrackingSettings: $ref: './components/schemas/template.yaml#/TrackingSettings' + WorkspaceTranslation: + $ref: './components/schemas/workspace-translation.yaml#/WorkspaceTranslation' + UpsertWorkspaceTranslationRequest: + $ref: './components/schemas/workspace-translation.yaml#/UpsertWorkspaceTranslationRequest' + DeleteWorkspaceTranslationRequest: + $ref: './components/schemas/workspace-translation.yaml#/DeleteWorkspaceTranslationRequest' CustomEvent: $ref: './components/schemas/custom-event.yaml#/CustomEvent' ImportCustomEventsRequest: diff --git a/openapi/paths/workspace-translations.yaml b/openapi/paths/workspace-translations.yaml new file mode 100644 index 00000000..1ece5363 --- /dev/null +++ b/openapi/paths/workspace-translations.yaml @@ -0,0 +1,117 @@ +/api/workspace_translations.list: + get: + summary: List workspace translations + description: Retrieves all workspace-level translations. Returns one entry per locale with its nested key-value content. + operationId: listWorkspaceTranslations + security: + - BearerAuth: [] + parameters: + - name: workspace_id + in: query + required: true + schema: + type: string + description: The ID of the workspace + example: ws_1234567890 + responses: + '200': + description: List of workspace translations retrieved successfully + content: + application/json: + schema: + type: object + properties: + translations: + type: array + items: + $ref: '../components/schemas/workspace-translation.yaml#/WorkspaceTranslation' + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' + +/api/workspace_translations.upsert: + post: + summary: Create or update workspace translation + description: | + Creates or updates translations for a specific locale at the workspace level. + If translations for the locale already exist, they are replaced. + Workspace translations are shared across all templates and resolved when a template + uses `{{ "key" | t }}` and the key is not found in the template's own translations. + operationId: upsertWorkspaceTranslation + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '../components/schemas/workspace-translation.yaml#/UpsertWorkspaceTranslationRequest' + responses: + '200': + description: Translation upserted successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' + +/api/workspace_translations.delete: + post: + summary: Delete workspace translation + description: Deletes all translations for a specific locale at the workspace level. + operationId: deleteWorkspaceTranslation + security: + - BearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '../components/schemas/workspace-translation.yaml#/DeleteWorkspaceTranslationRequest' + responses: + '200': + description: Translation deleted successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + '400': + description: Bad request - validation failed + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '../components/schemas/common.yaml#/ErrorResponse' diff --git a/pkg/notifuse_mjml/liquid_secure.go b/pkg/notifuse_mjml/liquid_secure.go index 448af9d5..752a0843 100644 --- a/pkg/notifuse_mjml/liquid_secure.go +++ b/pkg/notifuse_mjml/liquid_secure.go @@ -28,6 +28,9 @@ func NewSecureLiquidEngine() *SecureLiquidEngine { env := liquid.NewEnvironment() tags.RegisterStandardTags(env) + // Register default empty translation filter so the t filter is always available + env.RegisterFilter(&TranslationFilters{translations: map[string]interface{}{}}) + return &SecureLiquidEngine{ timeout: DefaultRenderTimeout, maxSize: DefaultMaxTemplateSize, @@ -40,6 +43,9 @@ func NewSecureLiquidEngineWithOptions(timeout time.Duration, maxSize int) *Secur env := liquid.NewEnvironment() tags.RegisterStandardTags(env) + // Register default empty translation filter so the t filter is always available + env.RegisterFilter(&TranslationFilters{translations: map[string]interface{}{}}) + return &SecureLiquidEngine{ timeout: timeout, maxSize: maxSize, @@ -95,6 +101,16 @@ func (s *SecureLiquidEngine) RenderWithTimeout(content string, data map[string]i } } +// RegisterTranslations registers translation data for the Liquid t filter. +// Must be called before Render. Translations should be a merged map (template + workspace). +func (s *SecureLiquidEngine) RegisterTranslations(translations map[string]interface{}) { + if translations == nil { + translations = map[string]interface{}{} + } + filter := &TranslationFilters{translations: translations} + s.env.RegisterFilter(filter) +} + // Render is a convenience method that calls RenderWithTimeout func (s *SecureLiquidEngine) Render(content string, data map[string]interface{}) (string, error) { return s.RenderWithTimeout(content, data) diff --git a/pkg/notifuse_mjml/translation_filter.go b/pkg/notifuse_mjml/translation_filter.go new file mode 100644 index 00000000..59aca6c4 --- /dev/null +++ b/pkg/notifuse_mjml/translation_filter.go @@ -0,0 +1,95 @@ +package notifuse_mjml + +import ( + "fmt" + "regexp" + "strings" +) + +// translationPlaceholderRegex matches {{ key }} placeholders in translation values. +var translationPlaceholderRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`) + +// TranslationFilters provides the Liquid `t` filter for resolving translation keys. +// Register with SecureLiquidEngine.RegisterTranslations(). +type TranslationFilters struct { + translations map[string]interface{} +} + +// T is the Liquid filter: {{ "welcome.heading" | t }} +// With placeholders: {{ "welcome.greeting" | t: name: "John" }} +// +// liquidgo calls this method with: +// - input: the piped value (the translation key string) +// - args: variadic positional args followed by an optional keyword args map +// +// liquidgo passes keyword args (name: value) as the last element +// in args if it's a map[string]interface{}. +func (tf *TranslationFilters) T(input interface{}, args ...interface{}) interface{} { + keyStr := fmt.Sprintf("%v", input) + + value := resolveNestedKey(tf.translations, keyStr) + if value == "" { + return "[Missing translation: " + keyStr + "]" + } + + // Check if last arg is a keyword args map + var kwargs map[string]interface{} + if len(args) > 0 { + if m, ok := args[len(args)-1].(map[string]interface{}); ok { + kwargs = m + } + } + + if len(kwargs) > 0 { + value = interpolatePlaceholders(value, kwargs) + } + + return value +} + +// resolveNestedKey traverses a nested map using a dot-separated key path +// and returns the string value. Returns empty string if key not found or value is not a string. +func resolveNestedKey(data map[string]interface{}, key string) string { + if data == nil || key == "" { + return "" + } + + parts := strings.Split(key, ".") + var current interface{} = data + + for _, part := range parts { + m, ok := current.(map[string]interface{}) + if !ok { + return "" + } + current, ok = m[part] + if !ok { + return "" + } + } + + if str, ok := current.(string); ok { + return str + } + return "" +} + +// interpolatePlaceholders replaces {{ key }} placeholders in a translation value +// with the corresponding values from the args map. +func interpolatePlaceholders(value string, args map[string]interface{}) string { + if args == nil || len(args) == 0 { + return value + } + + return translationPlaceholderRegex.ReplaceAllStringFunc(value, func(match string) string { + submatch := translationPlaceholderRegex.FindStringSubmatch(match) + if len(submatch) < 2 { + return match + } + key := submatch[1] + if val, ok := args[key]; ok { + return fmt.Sprintf("%v", val) + } + return match // leave unresolved placeholders as-is + }) +} diff --git a/pkg/notifuse_mjml/translation_filter_test.go b/pkg/notifuse_mjml/translation_filter_test.go new file mode 100644 index 00000000..be4faa14 --- /dev/null +++ b/pkg/notifuse_mjml/translation_filter_test.go @@ -0,0 +1,92 @@ +package notifuse_mjml + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTranslationFilter_SimpleKey(t *testing.T) { + engine := NewSecureLiquidEngine() + translations := map[string]interface{}{ + "welcome": map[string]interface{}{ + "heading": "Welcome!", + }, + } + engine.RegisterTranslations(translations) + + result, err := engine.Render(`{{ "welcome.heading" | t }}`, map[string]interface{}{}) + require.NoError(t, err) + assert.Equal(t, "Welcome!", result) +} + +func TestTranslationFilter_MissingKey(t *testing.T) { + engine := NewSecureLiquidEngine() + translations := map[string]interface{}{} + engine.RegisterTranslations(translations) + + result, err := engine.Render(`{{ "missing.key" | t }}`, map[string]interface{}{}) + require.NoError(t, err) + assert.Equal(t, "[Missing translation: missing.key]", result) +} + +func TestTranslationFilter_WithPlaceholders(t *testing.T) { + engine := NewSecureLiquidEngine() + translations := map[string]interface{}{ + "welcome": map[string]interface{}{ + "greeting": "Hello {{ name }}, welcome to {{ site }}!", + }, + } + engine.RegisterTranslations(translations) + + // The liquidgo filter receives named keyword args as a map + result, err := engine.Render( + `{{ "welcome.greeting" | t: name: "John", site: "Notifuse" }}`, + map[string]interface{}{}, + ) + require.NoError(t, err) + assert.Equal(t, "Hello John, welcome to Notifuse!", result) +} + +func TestTranslationFilter_WithContactVariable(t *testing.T) { + engine := NewSecureLiquidEngine() + translations := map[string]interface{}{ + "welcome": map[string]interface{}{ + "greeting": "Hello {{ name }}!", + }, + } + engine.RegisterTranslations(translations) + + result, err := engine.Render( + `{{ "welcome.greeting" | t: name: contact.first_name }}`, + map[string]interface{}{ + "contact": map[string]interface{}{ + "first_name": "Alice", + }, + }, + ) + require.NoError(t, err) + assert.Equal(t, "Hello Alice!", result) +} + +func TestTranslationFilter_FlatKey(t *testing.T) { + engine := NewSecureLiquidEngine() + translations := map[string]interface{}{ + "flat_key": "Flat value", + } + engine.RegisterTranslations(translations) + + result, err := engine.Render(`{{ "flat_key" | t }}`, map[string]interface{}{}) + require.NoError(t, err) + assert.Equal(t, "Flat value", result) +} + +func TestTranslationFilter_NoRegistration(t *testing.T) { + // When no translations registered, t filter should return missing translation marker + engine := NewSecureLiquidEngine() + + result, err := engine.Render(`{{ "some.key" | t }}`, map[string]interface{}{}) + require.NoError(t, err) + assert.Equal(t, "[Missing translation: some.key]", result) +}