diff --git a/composable-os/src/i18n/catalogues.test.ts b/composable-os/src/i18n/catalogues.test.ts index 1290374..6f14e50 100644 --- a/composable-os/src/i18n/catalogues.test.ts +++ b/composable-os/src/i18n/catalogues.test.ts @@ -31,7 +31,7 @@ const BASE = "es"; * idioma se ofrece solo (`releasedLocales`): no hay un interruptor aparte. */ const TRANSLATED: Record = { - en: ["common", "errors", "auth", "layout", "dashboard", "analytics", "companyProfile", "customerPortal", "contracts", "crm", "discovery", "hr", "inventory", "invoicing", "portalBuilder", "projects", "sales"], + en: ["common", "errors", "auth", "layout", "dashboard", "analytics", "companyProfile", "customerPortal", "contracts", "crm", "discovery", "hr", "inventory", "invoicing", "portalBuilder", "projects", "sales", "settings"], }; /** @@ -58,6 +58,11 @@ const IDENTICAL_TO_BASE = [ "portalBuilder:sessions.email", "sales:quotes.total", "sales:quotes.pdf", "sales:orders.total", "sales:detail.pdf", "sales:detail.lines.sku", "sales:detail.totals.subtotal", "sales:detail.totals.total", + "settings:tabs.general", "settings:architecture.nodes.gateway.label", "settings:architecture.nodes.gateway.sublabel", + "settings:architecture.nodes.crm.label", "settings:architecture.nodes.eventbus.label", + "settings:architecture.nodes.postgres.label", "settings:architecture.cards.gateway.title", + "settings:architecture.cards.eventbus.title", "settings:health.version", "settings:health.services.api", + "settings:health.services.postgres", "analytics:tabs.pipeline","companyProfile:identity.logoAlt", "companyProfile:contact.email", "customerPortal:quote.subtotal", "customerPortal:quote.total", ]; diff --git a/composable-os/src/i18n/english-settings.test.tsx b/composable-os/src/i18n/english-settings.test.tsx new file mode 100644 index 0000000..0b20831 --- /dev/null +++ b/composable-os/src/i18n/english-settings.test.tsx @@ -0,0 +1,181 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import i18next, { type i18n as I18n } from "i18next"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildI18nOptions, resources } from "@/i18n"; +import Settings from "@/pages/Settings"; +import { renderPageWithI18n } from "@/test/i18n"; + +/** + * `settings` en el ingles *real* (tarea 12 del plan de i18n): las cinco pestanas de + * Ajustes con el catalogo `en` que se envia, no el pseudo-localizado de + * `Settings.test.tsx`. + */ + +const svc = vi.hoisted(() => ({ getHealth: vi.fn() })); +const http = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() })); + +vi.mock("@/services/health", () => ({ healthService: svc })); +vi.mock("@/lib/api", async (importOriginal) => ({ + ...(await importOriginal()), + api: http, +})); + +async function english(): Promise { + const instance = i18next.createInstance(); + await instance.init(buildI18nOptions(resources, "en")); + return instance; +} + +const health = (over: Record = {}) => ({ + status: "healthy", + version: "1.2.3", + checks: [ + { name: "api", status: "healthy", latency_ms: 12 }, + { name: "postgres", status: "degraded", latency_ms: 340 }, + ], + ...over, +}); + +const prefs = (over: Record = {}) => ({ + email_enabled: true, + email_events: { QuoteAccepted: true, SalesOrderCreated: true, WorkflowFailed: true, InventoryLowThresholdDetected: false, InvoiceOverdue: false }, + ...over, +}); + +beforeEach(() => { + svc.getHealth.mockReset().mockResolvedValue(health()); + http.get.mockReset().mockResolvedValue(prefs()); + http.put.mockReset().mockImplementation(async (_url: string, patch: { email_enabled?: boolean; email_events?: Record }) => { + const current = prefs(); + return { ...current, ...patch, email_events: { ...current.email_events, ...(patch.email_events ?? {}) } }; + }); +}); + +/** Radix activa una pestana con `mousedown`, no con `click`. */ +const openTab = (name: string | RegExp) => fireEvent.mouseDown(screen.getByRole("tab", { name }), { button: 0 }); + +describe("Settings in English", () => { + it("renders the header and the five tabs", async () => { + renderPageWithI18n(, await english()); + + expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument(); + expect(screen.getByText("Platform configuration, architecture and system status.")).toBeInTheDocument(); + for (const tab of ["Architecture", "System health", "Notifications", "General", "Team"]) { + expect(screen.getByRole("tab", { name: tab })).toBeInTheDocument(); + } + }); + + it("shows the diagram and the three layer cards in English", async () => { + renderPageWithI18n(, await english()); + + expect(screen.getByText("Platform architecture")).toBeInTheDocument(); + expect(screen.getByText(/Hover over the components/)).toBeInTheDocument(); + for (const text of ["Client apps", "Graph database", "Vector database", "AI agents", "API LAYER", "MICROSERVICES", "DATA LAYER", "HTTPS"]) { + expect(screen.getByText(text)).toBeInTheDocument(); + } + expect(screen.getAllByText("Microservice")).toHaveLength(6); + expect(screen.getByText("Data layer")).toBeInTheDocument(); + expect(screen.getByText(/Asynchronous pub\/sub messaging/)).toBeInTheDocument(); + expect(screen.getByText(/Handles authentication, rate limiting/)).toBeInTheDocument(); + expect(screen.queryByText(/Microservicio|CAPA DE/)).not.toBeInTheDocument(); + }); + + describe("system health", () => { + it("shows each service with an English status and the latency", async () => { + renderPageWithI18n(, await english()); + openTab("System health"); + + expect(await screen.findByText("healthy", { selector: "div" })).toBeInTheDocument(); + expect(screen.getByText("degraded")).toBeInTheDocument(); + expect(screen.getByText("12ms")).toBeInTheDocument(); + expect(screen.getByText("340ms")).toBeInTheDocument(); + expect(screen.getAllByText("Latency")).toHaveLength(2); + }); + + it("shows the overall status, the version and how fresh the data is", async () => { + renderPageWithI18n(, await english()); + openTab("System health"); + + expect(await screen.findByText(/Overall status:/)).toBeInTheDocument(); + expect(screen.getByText(/v1\.2\.3/)).toBeInTheDocument(); + expect(await screen.findByText(/^Updated (now|\d+ ?s(ec\.)? ago)$/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument(); + }); + + it("explains when the backend cannot be reached", async () => { + svc.getHealth.mockRejectedValue(new Error("down")); + renderPageWithI18n(, await english()); + openTab("System health"); + + expect(await screen.findByText("Could not connect to the system. Check that the backend is running.", undefined, { timeout: 5000 })).toBeInTheDocument(); + }); + }); + + describe("notifications", () => { + it("lists the event types in English with named switches and a summary", async () => { + renderPageWithI18n(, await english()); + openTab("Notifications"); + + expect(await screen.findByText("Quotes accepted")).toBeInTheDocument(); + expect(screen.getByText("Event types")).toBeInTheDocument(); + expect(screen.getByText("When a customer accepts a quote")).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Quotes accepted" })).toBeChecked(); + expect(screen.getByRole("switch", { name: "Low stock" })).not.toBeChecked(); + expect(screen.getByText("3 of 5 event types active")).toBeInTheDocument(); + }); + + it("saves a per-event toggle and updates the summary", async () => { + renderPageWithI18n(, await english()); + openTab("Notifications"); + + fireEvent.click(await screen.findByRole("switch", { name: "Overdue invoices" })); + + await waitFor(() => expect(http.put).toHaveBeenCalledWith("/notifications/preferences", { email_events: { InvoiceOverdue: true } })); + expect(await screen.findByText("4 of 5 event types active")).toBeInTheDocument(); + }); + + it("turns everything off from the master switch", async () => { + renderPageWithI18n(, await english()); + openTab("Notifications"); + + fireEvent.click(await screen.findByRole("switch", { name: "Email notifications" })); + + await waitFor(() => expect(http.put).toHaveBeenCalledWith("/notifications/preferences", { email_enabled: false })); + expect(await screen.findByText("All email notifications are turned off")).toBeInTheDocument(); + expect(screen.getByRole("switch", { name: "Quotes accepted" })).toBeDisabled(); + }); + + it("explains the in-app notifications", async () => { + renderPageWithI18n(, await english()); + openTab("Notifications"); + + expect(await screen.findByText("Real-time notifications")).toBeInTheDocument(); + expect(screen.getByText(/delivered over WebSocket/)).toBeInTheDocument(); + }); + }); + + describe("general and team", () => { + it("shows the workspace form and the preferences", async () => { + renderPageWithI18n(, await english()); + openTab("General"); + + expect(await screen.findByText("Workspace settings")).toBeInTheDocument(); + expect(screen.getByLabelText("Workspace name")).toHaveValue("Composable OS"); + expect(screen.getByLabelText("Workspace URL")).toBeDisabled(); + expect(screen.getByRole("switch", { name: "Enable AI recommendations" })).toBeChecked(); + expect(screen.getByRole("switch", { name: "Automatic knowledge graph linking" })).toBeChecked(); + expect(screen.getByText("Preferences")).toBeInTheDocument(); + }); + + it("lists the team with English roles", async () => { + renderPageWithI18n(, await english()); + openTab("Team"); + + expect(await screen.findByText("Team members")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Invite/ })).toBeInTheDocument(); + expect(screen.getAllByText("Admin")).toHaveLength(2); + expect(screen.getAllByText("Member")).toHaveLength(4); + }); + }); +}); diff --git a/composable-os/src/locales/en/settings.json b/composable-os/src/locales/en/settings.json new file mode 100644 index 0000000..5a8bde9 --- /dev/null +++ b/composable-os/src/locales/en/settings.json @@ -0,0 +1,131 @@ +{ + "title": "Settings", + "subtitle": "Platform configuration, architecture and system status.", + "tabs": { + "architecture": "Architecture", + "health": "System health", + "notifications": "Notifications", + "general": "General", + "team": "Team" + }, + "architecture": { + "title": "Platform architecture", + "hint": "Hover over the components to follow the data flow paths.", + "layers": { + "presentation": "PRESENTATION", + "api": "API LAYER", + "microservices": "MICROSERVICES", + "messaging": "MESSAGING", + "data": "DATA LAYER" + }, + "nodes": { + "client": { "label": "Client apps", "sublabel": "React SPA / Mobile" }, + "gateway": { "label": "API Gateway", "sublabel": "Auth · Rate Limit · Routing" }, + "projects": { "label": "Projects", "sublabel": "Microservice" }, + "crm": { "label": "CRM", "sublabel": "Microservice" }, + "docs": { "label": "Documents", "sublabel": "Microservice" }, + "knowledge": { "label": "Knowledge", "sublabel": "Microservice" }, + "analytics": { "label": "Analytics", "sublabel": "Microservice" }, + "ai": { "label": "AI agents", "sublabel": "Microservice" }, + "eventbus": { "label": "Event Bus", "sublabel": "Async messaging · Pub/Sub" }, + "postgres": { "label": "PostgreSQL", "sublabel": "Primary data store" }, + "graph": { "label": "Graph database", "sublabel": "Neo4j · Relationships" }, + "vector": { "label": "Vector database", "sublabel": "Embeddings · Semantic search" } + }, + "cards": { + "gateway": { + "title": "API Gateway", + "description": "Handles authentication, rate limiting, validation and smart routing to the microservices. Supports REST and GraphQL." + }, + "eventbus": { + "title": "Event Bus", + "description": "Asynchronous pub/sub messaging keeps the modules loosely coupled. When a deal is closed in the CRM, events create the project automatically." + }, + "data": { + "title": "Data layer", + "description": "PostgreSQL for structured data, Neo4j for the knowledge graph relationships and a vector database for semantic search and AI embeddings." + } + } + }, + "health": { + "loading": "Loading system status...", + "updated": "Updated {{when}}", + "refresh": "Refresh", + "error": "Could not connect to the system. Check that the backend is running.", + "latency": "Latency", + "overall": "Overall status:", + "version": "v{{version}}", + "services": { + "api": "API Gateway", + "postgres": "PostgreSQL" + }, + "status": { + "healthy": "healthy", + "degraded": "degraded", + "unhealthy": "unhealthy" + } + }, + "notifications": { + "title": "Email notifications", + "saving": "Saving...", + "intro": "Control which business events send email alerts. Changes are saved automatically.", + "loading": "Loading preferences...", + "master": "Email notifications", + "masterHint": "Master switch — turn it off to stop all email alerts", + "eventTypes": "Event types", + "summaryOn": "{{active}} of {{total}} event types active", + "summaryOff": "All email notifications are turned off", + "realtimeTitle": "Real-time notifications", + "realtimeBody": "In-app notifications are delivered over WebSocket and are always on while you are connected. They cover 13 event types, including workflow status, sales activity, inventory alerts, portal interactions and accounting updates. Use the bell icon in the header to see them.", + "events": { + "QuoteAccepted": { + "label": "Quotes accepted", + "desc": "When a customer accepts a quote" + }, + "SalesOrderCreated": { + "label": "Sales orders created", + "desc": "When a new sales order is generated" + }, + "WorkflowFailed": { + "label": "Failed workflows", + "desc": "When an automated workflow fails" + }, + "InventoryLowThresholdDetected": { + "label": "Low stock", + "desc": "When a product reaches its minimum inventory threshold" + }, + "InvoiceOverdue": { + "label": "Overdue invoices", + "desc": "When an invoice passes its due date without payment" + } + } + }, + "general": { + "title": "Workspace settings", + "name": "Workspace name", + "url": "Workspace URL", + "preferences": "Preferences", + "prefs": { + "ai": { + "label": "Enable AI recommendations", + "desc": "Show AI suggestions across all modules" + }, + "projects": { + "label": "Create projects automatically from deals", + "desc": "Automatically generate a project when a CRM deal is closed" + }, + "graph": { + "label": "Automatic knowledge graph linking", + "desc": "Automatically detect and link entities in documents" + } + } + }, + "team": { + "title": "Team members", + "invite": "Invite", + "role": { + "admin": "Admin", + "member": "Member" + } + } +} diff --git a/docs/IMPLEMENTATION_ALIGNMENT.md b/docs/IMPLEMENTATION_ALIGNMENT.md index 46f899f..d516dec 100644 --- a/docs/IMPLEMENTATION_ALIGNMENT.md +++ b/docs/IMPLEMENTATION_ALIGNMENT.md @@ -83,7 +83,7 @@ Legacy documents are historical reference only unless explicitly promoted by ADR | External brand-site integration | Strategic direction documented; public lead intake v1 now has code, contract tests, decision logging, event metadata, and rate-limit response behavior, but is not yet operationally proven under production-like traffic | One validated public integration slice before SDK/builder expansion | High | Run deployment-like validation for CRM public intake before expanding public surfaces | | Public API boundary | Portal proves public interaction can work; CRM public intake now defines the initial site-key/origin/idempotency/rate-limit model | Explicit public endpoint policy with origin, key, idempotency, abuse controls, and logging rules | High | Promote the CRM public intake contract into broader public API conventions only after production validation | | Future stack pressure | Strategic docs mention SDK, builder, semantic layers, and UKIP-compatible ideas | Current stack remains frozen until justified | Medium | Treat those areas as target-state only unless promoted by ADR | -| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, locale-aware date and amount formatting in the server-rendered artifacts, and a second language offered to anyone. English is under way: the backend catalogues (invoice PDF, 9 emails) are complete but held back in `UNRELEASED_LOCALES`, the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects` and `sales` translated (17 of 19 namespaces) and offers a language only once every namespace exists. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | +| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, locale-aware date and amount formatting in the server-rendered artifacts, and a second language offered to anyone. English is under way: the backend catalogues (invoice PDF, 9 emails) are complete but held back in `UNRELEASED_LOCALES`, the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales` and `settings` translated (18 of 19 namespaces) and offers a language only once every namespace exists. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | | Schema drift between models and database | Closed. `alembic revision --autogenerate` on an unchanged model set emits an empty `upgrade()`, so a generated migration now contains only the intended change. Resolved in three steps: foreign-key delete rules declared in the models, `users.notification_preferences` tightened to `NOT NULL` in the database, and two leftovers removed — a redundant unique constraint on `public_sites.api_key` that duplicated the unique index, and an index on `quote_events.user_id` that no query used | Autogenerate stays silent on an unchanged model set | Medium | Keep it closed: run autogenerate before and after a model change and treat any unrelated output as drift to fix in its own change set, not to hand-edit away | --- diff --git a/docs/superpowers/plans/2026-08-07-i18n-full.md b/docs/superpowers/plans/2026-08-07-i18n-full.md index 392b0ba..b0aa03c 100644 --- a/docs/superpowers/plans/2026-08-07-i18n-full.md +++ b/docs/superpowers/plans/2026-08-07-i18n-full.md @@ -458,9 +458,10 @@ completa migrada no hay patron que seguir. departamentos y dialogos de alta), `inventory` (centro de operaciones, existencias y paneles), `invoicing` (facturas, pagos y alta), `portalBuilder` (sesiones, constructor de paginas y bloques de tienda), `projects` - (lista, detalle con tareas y dialogos de alta) y `sales` (panel, cotizaciones, ordenes - y el detalle de una cotizacion con su dialogo de compartir). - Quedan 2: `settings`, `workflows`. + (lista, detalle con tareas y dialogos de alta), `sales` (panel, cotizaciones, ordenes + y el detalle de una cotizacion con su dialogo de compartir) y `settings` (las cinco + pestanas de Ajustes). + Queda 1: `workflows`, `workflows`. - [ ] Ofrecer `en`: mover `en` de `UNRELEASED_LOCALES` a `SUPPORTED_LOCALES` en el backend y el selector de idioma en el dialogo de compartir (pendiente desde la tarea 2). - [ ] Recorrer la aplicacion en ingles buscando lo que el segundo idioma revela: