From cba4bc8ecd0b86036c00c8910409e13557b13e88 Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 09:42:36 +0100 Subject: [PATCH 01/20] Add template i18n design document Design for issue #268: template-level internationalization using a Liquid `t` filter with translation key catalogs. Covers data model, API surface, rendering pipeline, migration plan, and frontend UX. --- docs/plans/2026-02-24-template-i18n-design.md | 303 ++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 docs/plans/2026-02-24-template-i18n-design.md 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..280d38ec --- /dev/null +++ b/docs/plans/2026-02-24-template-i18n-design.md @@ -0,0 +1,303 @@ +# 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: `Workspace` struct + +```go +type Workspace struct { + // ... existing fields ... + DefaultLanguage string `json:"default_language"` // e.g., "en" + SupportedLanguages []string `json:"supported_languages"` // 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 + +```sql +ALTER TABLE workspaces + ADD COLUMN IF NOT EXISTS default_language VARCHAR(10) NOT NULL DEFAULT 'en'; +ALTER TABLE workspaces + ADD COLUMN IF NOT EXISTS supported_languages JSONB NOT NULL DEFAULT '["en"]'::jsonb; +``` + +### 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. From a547160a759dd01abe70937e56ce0f608fd7baf9 Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 09:49:44 +0100 Subject: [PATCH 02/20] Add template i18n implementation plan 13-task TDD plan covering: translation utilities, Liquid t filter, domain model changes, V28 migration, repository/service/handler layers, rendering pipeline integration, and frontend translations panel. --- ...2026-02-24-template-i18n-implementation.md | 1949 +++++++++++++++++ 1 file changed, 1949 insertions(+) create mode 100644 docs/plans/2026-02-24-template-i18n-implementation.md 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. From af9ab03fb56513c72509a0a9446730a95efe8c4d Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 09:56:00 +0100 Subject: [PATCH 03/20] Add documentation update plan for template i18n 7-task plan covering: OpenAPI schema/paths updates for template translations and workspace translations API, CHANGELOG v28.0 entry, CLAUDE.md Liquid filter docs, and design doc migration correction. --- docs/plans/2026-02-24-template-i18n-docs.md | 513 ++++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 docs/plans/2026-02-24-template-i18n-docs.md 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. From e004a94525dd69a9b635771b046b6a62c0ada853 Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 09:59:05 +0100 Subject: [PATCH 04/20] docs: add v28.0 changelog entry for template i18n --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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) From 5d965b6f06fe09d68902b43b7cd2366bd60415ef Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 09:59:06 +0100 Subject: [PATCH 05/20] docs: add translations fields to OpenAPI template schema --- openapi/components/schemas/template.yaml | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) 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 From 88507977119fbf9a86d4398b366f6f8e5ac91309 Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 09:59:20 +0100 Subject: [PATCH 06/20] docs: update CLAUDE.md with template i18n documentation --- CLAUDE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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) From 363da7bc57527c5666ed6d6b3828e7b2812be35c Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 09:59:25 +0100 Subject: [PATCH 07/20] =?UTF-8?q?docs:=20fix=20design=20doc=20migration=20?= =?UTF-8?q?section=20=E2=80=94=20language=20settings=20go=20in=20Workspace?= =?UTF-8?q?Settings=20JSONB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-02-24-template-i18n-design.md | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/docs/plans/2026-02-24-template-i18n-design.md b/docs/plans/2026-02-24-template-i18n-design.md index 280d38ec..91f473bc 100644 --- a/docs/plans/2026-02-24-template-i18n-design.md +++ b/docs/plans/2026-02-24-template-i18n-design.md @@ -114,13 +114,13 @@ type Template struct { } ``` -### Modified: `Workspace` struct +### Modified: `WorkspaceSettings` struct (inside Workspace.Settings JSONB) ```go -type Workspace struct { +type WorkspaceSettings struct { // ... existing fields ... - DefaultLanguage string `json:"default_language"` // e.g., "en" - SupportedLanguages []string `json:"supported_languages"` // e.g., ["en", "fr", "de"] + DefaultLanguage string `json:"default_language,omitempty"` // e.g., "en" + SupportedLanguages []string `json:"supported_languages,omitempty"` // e.g., ["en", "fr", "de"] } ``` @@ -141,12 +141,7 @@ Non-breaking, additive migration. Existing templates get empty `{}` translations ### System database -```sql -ALTER TABLE workspaces - ADD COLUMN IF NOT EXISTS default_language VARCHAR(10) NOT NULL DEFAULT 'en'; -ALTER TABLE workspaces - ADD COLUMN IF NOT EXISTS supported_languages JSONB NOT NULL DEFAULT '["en"]'::jsonb; -``` +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 From 49aa61cdf0bab4842fd815ae27028afbad70b599 Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 10:00:51 +0100 Subject: [PATCH 08/20] docs: add OpenAPI schema for workspace translations --- .../schemas/workspace-translation.yaml | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 openapi/components/schemas/workspace-translation.yaml 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 From 88431c693fa89d1865adce422d0129f31db5bf2a Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 10:01:05 +0100 Subject: [PATCH 09/20] feat(i18n): add translation utility functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locale resolution, nested key lookup, placeholder interpolation, and translation merging — pure functions with full test coverage. --- internal/domain/translation.go | 121 ++++++++++++++ internal/domain/translation_test.go | 250 ++++++++++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 internal/domain/translation.go create mode 100644 internal/domain/translation_test.go 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) + }) + } +} From 5ba74a14c9e2ddfa1a99335d0ff651a74c7598e1 Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 10:01:39 +0100 Subject: [PATCH 10/20] docs: add OpenAPI paths for workspace translations API --- openapi/paths/workspace-translations.yaml | 117 ++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 openapi/paths/workspace-translations.yaml 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' From d5384b9ed518fc424dae7e94ceefd7faf8417c1c Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 10:02:25 +0100 Subject: [PATCH 11/20] docs: register workspace translations in OpenAPI root --- openapi/openapi.yaml | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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: From ab17189856963d76d73226c162612a889423086d Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 10:08:50 +0100 Subject: [PATCH 12/20] 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 }} Includes a patch to liquidgo (via go.mod replace) to evaluate and pass keyword arguments to filter invocations. The t filter is registered by default with empty translations on engine creation, so it always exists. --- go.mod | 3 + pkg/notifuse_mjml/liquid_secure.go | 16 ++++ pkg/notifuse_mjml/translation_filter.go | 95 ++++++++++++++++++++ pkg/notifuse_mjml/translation_filter_test.go | 92 +++++++++++++++++++ 4 files changed, 206 insertions(+) create mode 100644 pkg/notifuse_mjml/translation_filter.go create mode 100644 pkg/notifuse_mjml/translation_filter_test.go 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/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) +} From 3f1692690335fe3c99d236c4d971976ace1c34ec Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 10:13:02 +0100 Subject: [PATCH 13/20] docs: add external docs update design for template i18n --- ...2-24-template-i18n-external-docs-design.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/plans/2026-02-24-template-i18n-external-docs-design.md 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 | From 17cc4e586ffb62dffb6107d261bf72827fdc2391 Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 10:13:44 +0100 Subject: [PATCH 14/20] feat(i18n): add translation fields to domain models Template: translations (JSONB) + default_language. WorkspaceSettings: default_language + supported_languages. New WorkspaceTranslation entity with repository interface. --- internal/domain/template.go | 24 +++++++ internal/domain/template_test.go | 55 ++++++++++++++ internal/domain/workspace.go | 16 +++++ internal/domain/workspace_translation.go | 65 +++++++++++++++++ internal/domain/workspace_translation_test.go | 72 +++++++++++++++++++ 5 files changed, 232 insertions(+) create mode 100644 internal/domain/workspace_translation.go create mode 100644 internal/domain/workspace_translation_test.go 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/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) + } + }) + } +} From 0cdf958459a83189f5aeef2efef70e30a200200d Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 10:15:51 +0100 Subject: [PATCH 15/20] docs: add external docs implementation plan for template i18n --- .../2026-02-24-template-i18n-external-docs.md | 839 ++++++++++++++++++ 1 file changed, 839 insertions(+) create mode 100644 docs/plans/2026-02-24-template-i18n-external-docs.md 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). From a84f76ca1971e3257ed606cba7d528c896afd663 Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 10:18:17 +0100 Subject: [PATCH 16/20] 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. --- config/config.go | 2 +- internal/database/init.go | 8 +++ internal/migrations/v28.go | 60 ++++++++++++++++++++++ internal/migrations/v28_test.go | 91 +++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 internal/migrations/v28.go create mode 100644 internal/migrations/v28_test.go 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/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/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") + }) +} From 507faa8f8046116eb7c7f979bb87fe458cf2f2d5 Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 10:32:15 +0100 Subject: [PATCH 17/20] feat(i18n): add workspace translation service CRUD operations for workspace-level translations with auth + validation. --- .../service/workspace_translation_service.go | 104 +++++ .../workspace_translation_service_test.go | 402 ++++++++++++++++++ 2 files changed, 506 insertions(+) create mode 100644 internal/service/workspace_translation_service.go create mode 100644 internal/service/workspace_translation_service_test.go 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) + }) +} From 6ad9b0b66fee42bc716801f7fa1483f345f74dd7 Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 11:04:16 +0100 Subject: [PATCH 18/20] fix: update migration manager test for V28 The 'database up to date' test expected version 27 but V28 is now registered, so it needs to return 28 to be considered up to date. --- internal/migrations/manager_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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) From bd417b0f897f0ed0b0a6aaeb75890b768c2837c0 Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 11:06:07 +0100 Subject: [PATCH 19/20] feat(i18n): add frontend API types and service for translations Add translations and default_language fields to Template interface. Create workspace-translations API service with list, upsert, and delete operations matching the RPC-style backend endpoints. --- console/src/services/api/template.ts | 2 + .../services/api/workspace-translations.ts | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 console/src/services/api/workspace-translations.ts diff --git a/console/src/services/api/template.ts b/console/src/services/api/template.ts index 4a28cb10..b43d3ac2 100644 --- a/console/src/services/api/template.ts +++ b/console/src/services/api/template.ts @@ -19,6 +19,8 @@ export interface Template { utm_campaign?: string test_data?: Record 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) + } +} From 0a7e66969825ab841bee9bab03247e50896eaaab Mon Sep 17 00:00:00 2001 From: Peep van Puijenbroek Date: Tue, 24 Feb 2026 11:16:21 +0100 Subject: [PATCH 20/20] feat(i18n): add workspace language settings UI Default language and supported languages configuration in workspace settings. --- .../components/settings/LanguageSettings.tsx | 182 ++++++++++++++++++ .../components/settings/SettingsSidebar.tsx | 9 +- console/src/pages/WorkspaceSettingsPage.tsx | 10 + 3 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 console/src/components/settings/LanguageSettings.tsx 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 (