diff --git a/README.md b/README.md index 3338567..34061c2 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ El sistema está construido como un **monolito modular** con límites de dominio Discovery → Lead → Opportunity → Quote → Order → Inventory → Portal → Invoice → Payment ``` -**974 tests** (contrato + integración + e2e) en 50 archivos. Dashboard con datos reales (facturación, pipeline CRM, operaciones). +**972 tests** (contrato + integración + e2e) en 50 archivos. Dashboard con datos reales (facturación, pipeline CRM, operaciones). --- @@ -106,7 +106,7 @@ cbos-platform/ │ │ │ ├── accounting/ Facturación, pagos, overdue scanner │ │ │ └── analytics/ Agregación cross-módulo │ │ └── main.py Lifespan, routers, CORS, background tasks -│ ├── tests/ 974 tests (pytest-asyncio) +│ ├── tests/ 972 tests (pytest-asyncio) │ ├── alembic/ Migraciones de base de datos │ └── requirements.txt ├── composable-os/ React 18 + Vite frontend @@ -232,7 +232,7 @@ docker compose exec backend pytest --tb=short -q cd backend && pytest --tb=short -q ``` -**974 tests** en 50 archivos. Las categorías no se solapan: cada archivo cuenta +**972 tests** en 50 archivos. Las categorías no se solapan: cada archivo cuenta una sola vez y las filas suman 41. | Categoría | Archivos | Cobertura | diff --git a/backend/app/modules/notifications/router.py b/backend/app/modules/notifications/router.py index 94eec14..553dc76 100644 --- a/backend/app/modules/notifications/router.py +++ b/backend/app/modules/notifications/router.py @@ -39,22 +39,9 @@ "InvoiceOverdue", } -NOTIFY_LABELS = { - "WorkflowTriggered": "Workflow ejecutado", - "WorkflowCompleted": "Workflow completado", - "WorkflowFailed": "Workflow falló", - "InventoryLowThresholdDetected": "Stock bajo", - "QuoteAccepted": "Cotización aceptada", - "QuoteRejected": "Cotización rechazada", - "SalesOrderCreated": "Nueva orden de venta", - "CustomerActionPerformed": "Acción del cliente", - "OpportunityWon": "Deal ganado 🎉", - "OpportunityLost": "Deal perdido", - "PortalSessionCreated": "Portal compartido con cliente", - "InvoiceCreated": "Factura generada", - "InvoicePaid": "Factura pagada 💰", - "InvoiceOverdue": "Factura vencida ⚠️", -} +# El servidor manda el tipo de evento y la interfaz pone el texto en el idioma de +# quien lo lee (`layout:notifications.events.`): ADR 0014. Un evento sin +# entrada alla se muestra con su nombre. # ── Email Notification Preferences ───────────────────────────────────── @@ -164,7 +151,6 @@ async def forward_events(): await websocket.send_json({ "type": "notification", "event_type": event_type, - "title": NOTIFY_LABELS.get(event_type, event_type), "payload": event.get("payload", {}), "entity_id": event.get("entity_id"), "timestamp": event.get("timestamp"), diff --git a/backend/app/modules/portal/service.py b/backend/app/modules/portal/service.py index 7204cb0..346f4dc 100644 --- a/backend/app/modules/portal/service.py +++ b/backend/app/modules/portal/service.py @@ -336,7 +336,7 @@ async def portal_accept( return PortalActionResult( success=False, action=session.action, - message=f"Ya realizaste una acción ({session.action}) en esta cotización.", + message="This session already has a recorded action.", ) quote = await _load_quote_with_lines(db, session.quote_id) @@ -486,7 +486,7 @@ async def portal_accept( return PortalActionResult( success=True, action="accepted", - message="Cotización aceptada. Tu orden de compra ha sido creada.", + message="Quote accepted; the sales order was created.", order_number=order_number, ) @@ -500,7 +500,7 @@ async def portal_reject( return PortalActionResult( success=False, action=session.action, - message=f"Ya realizaste una acción ({session.action}) en esta cotización.", + message="This session already has a recorded action.", ) quote = await _load_quote_with_lines(db, session.quote_id) @@ -570,7 +570,7 @@ async def portal_reject( return PortalActionResult( success=True, action="rejected", - message="Cotización rechazada. Hemos notificado al equipo.", + message="Quote rejected; the team was notified.", ) diff --git a/backend/tests/test_e2e_notifications_pipeline.py b/backend/tests/test_e2e_notifications_pipeline.py index 378a496..d4b5bf4 100644 --- a/backend/tests/test_e2e_notifications_pipeline.py +++ b/backend/tests/test_e2e_notifications_pipeline.py @@ -9,7 +9,7 @@ from datetime import datetime, timezone from app.events.types import Event -from app.modules.notifications.router import NOTIFY_EVENTS, NOTIFY_LABELS +from app.modules.notifications.router import NOTIFY_EVENTS # ── Override DB fixture (no DB needed for this file) ───────────────────────── @@ -113,14 +113,13 @@ async def test_notify_event_forwarded_with_correct_shape(): message = { "type": "notification", "event_type": event_type, - "title": NOTIFY_LABELS.get(event_type, event_type), "payload": event.get("payload", {}), "entity_id": event.get("entity_id"), "timestamp": event.get("timestamp"), } assert message["type"] == "notification" - assert message["title"] == NOTIFY_LABELS["QuoteAccepted"] + assert "title" not in message # el texto lo pone la interfaz (ADR 0014) assert message["payload"]["total"] == 1500.0 assert message["entity_id"] == "quote-abc" @@ -172,7 +171,7 @@ async def fake_listen(): sent = ws.send_json.call_args[0][0] assert sent["type"] == "notification" assert sent["event_type"] == "OpportunityWon" - assert sent["title"] == NOTIFY_LABELS["OpportunityWon"] + assert "title" not in sent # el texto lo pone la interfaz (ADR 0014) assert sent["payload"]["deal_value"] == 50000.0 assert sent["entity_id"] == "opp-99" @@ -291,13 +290,12 @@ async def mock_publish(channel, data): ws_message = { "type": "notification", "event_type": event_type, - "title": NOTIFY_LABELS.get(event_type, event_type), "payload": parsed.get("payload", {}), "entity_id": parsed.get("entity_id"), "timestamp": parsed.get("timestamp"), } - assert ws_message["title"] == "Cotización aceptada" + assert "title" not in ws_message assert ws_message["payload"]["total"] == 2500.0 assert ws_message["entity_id"] == "quote-xyz" assert ws_message["type"] == "notification" @@ -334,12 +332,11 @@ async def test_full_pipeline_inventory_low_threshold_reaches_ws(): ws_message = { "type": "notification", "event_type": parsed["event_type"], - "title": NOTIFY_LABELS.get(parsed["event_type"], parsed["event_type"]), "payload": parsed.get("payload", {}), "entity_id": parsed.get("entity_id"), "timestamp": parsed.get("timestamp"), } - assert ws_message["title"] == "Stock bajo" + assert "title" not in ws_message assert ws_message["payload"]["current_stock"] == 3 diff --git a/backend/tests/test_e2e_portal_ws_notification.py b/backend/tests/test_e2e_portal_ws_notification.py index 3c9c70d..b4de39a 100644 --- a/backend/tests/test_e2e_portal_ws_notification.py +++ b/backend/tests/test_e2e_portal_ws_notification.py @@ -100,28 +100,15 @@ async def test_portal_session_created_is_notification_eligible(): ) -async def test_portal_session_created_has_ws_label(): - """PortalSessionCreated must have a human-readable label in NOTIFY_LABELS.""" - from app.modules.notifications.router import NOTIFY_LABELS - - label = NOTIFY_LABELS.get("PortalSessionCreated") - assert label is not None, "No label for PortalSessionCreated in NOTIFY_LABELS" - assert len(label) > 0 - - # ── Layer 3: WS payload transform ─────────────────────────────────────────── async def test_portal_session_created_transforms_to_ws_payload(): """The router's transform logic produces the expected WS payload shape.""" - from app.modules.notifications.router import NOTIFY_LABELS - event = _portal_session_event() - label = NOTIFY_LABELS.get(event.event_type, event.event_type) ws_payload = { "type": "notification", "event_type": event.event_type, - "title": label, "entity_id": event.entity_id, "payload": event.payload, "timestamp": event.timestamp.isoformat(), @@ -129,7 +116,6 @@ async def test_portal_session_created_transforms_to_ws_payload(): assert ws_payload["type"] == "notification" assert ws_payload["event_type"] == "PortalSessionCreated" - assert ws_payload["title"] == label assert ws_payload["entity_id"] == "session-abc-123" assert ws_payload["payload"]["client_name"] == "Test Client" @@ -142,10 +128,10 @@ async def test_full_pipeline_portal_session_created_reaches_ws_client(): publish(PortalSessionCreated) → Redis pub/sub → ConnectionManager.send_to_workspace(). Verifies that a WS client subscribed to workspace ws-portal-001 would receive - a notification payload with title from NOTIFY_LABELS["PortalSessionCreated"]. + a notification payload for PortalSessionCreated. """ from app.events.bus import publish - from app.modules.notifications.router import NOTIFY_EVENTS, NOTIFY_LABELS + from app.modules.notifications.router import NOTIFY_EVENTS workspace_id = "ws-portal-001" event = _portal_session_event(workspace_id) @@ -167,11 +153,9 @@ async def test_full_pipeline_portal_session_created_reaches_ws_client(): event_type = parsed.get("event_type", "") assert event_type in NOTIFY_EVENTS, f"{event_type} filtered out — not in NOTIFY_EVENTS" - label = NOTIFY_LABELS.get(event_type, event_type) ws_payload = json.dumps({ "type": "notification", "event_type": event_type, - "title": label, "entity_id": parsed.get("entity_id"), "payload": parsed.get("payload", {}), "timestamp": parsed.get("timestamp"), @@ -184,7 +168,6 @@ async def test_full_pipeline_portal_session_created_reaches_ws_client(): delivered = json.loads(mock_manager.send_to_workspace.call_args[0][1]) assert delivered["type"] == "notification" assert delivered["event_type"] == "PortalSessionCreated" - assert delivered["title"] == NOTIFY_LABELS["PortalSessionCreated"] assert delivered["payload"]["quote_id"] == "quote-xyz-456" @@ -198,7 +181,7 @@ async def test_portal_session_creation_triggers_notification_pipeline( PortalSessionCreated is published to Redis (bus.publish mocked), and that the pub/sub data is notification-eligible and correctly shaped. """ - from app.modules.notifications.router import NOTIFY_EVENTS, NOTIFY_LABELS + from app.modules.notifications.router import NOTIFY_EVENTS # 1. Create a quote (Sales) resp = await client.post("/api/v1/sales/quotes", headers=auth_headers, json={ @@ -261,7 +244,3 @@ async def capturing_publish(event: Event): # 4. Verify it would pass the WS filter assert emitted.event_type in NOTIFY_EVENTS - # 5. Verify it has a label for WS delivery - assert emitted.event_type in NOTIFY_LABELS - label = NOTIFY_LABELS[emitted.event_type] - assert len(label) > 0 diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py index e412f44..fd363a1 100644 --- a/backend/tests/test_notifications.py +++ b/backend/tests/test_notifications.py @@ -1,13 +1,16 @@ """ Notifications module tests. Strategy: unit tests with mocks — no real WebSocket or Redis connection required. -Tests cover: event filtering, label mapping, token validation, message shape, +Tests cover: event filtering, token validation, message shape (language-neutral), and ConnectionManager behaviour. """ +import json +from pathlib import Path + import pytest from unittest.mock import AsyncMock, MagicMock, patch -from app.modules.notifications.router import NOTIFY_EVENTS, NOTIFY_LABELS +from app.modules.notifications.router import NOTIFY_EVENTS # ── Override the per-test DB autouse fixture so this unit-test file ───────── @@ -60,25 +63,29 @@ def test_notify_events_does_not_include_internal_events(): assert "LeadCaptured" not in NOTIFY_EVENTS -# ── NOTIFY_LABELS coverage ──────────────────────────────────────────────────── +# ── Labels live in the interface catalogue ─────────────────────────────────── +# The server sends the event type; the text is `layout:notifications.events.` +# in each frontend locale (ADR 0014). This is the check that used to be on NOTIFY_LABELS: +# a notifiable event without a label would show its raw name to the user. -def test_every_notify_event_has_a_label(): - """Every event in NOTIFY_EVENTS must have a human-readable label.""" - for event_type in NOTIFY_EVENTS: - assert event_type in NOTIFY_LABELS, f"Missing label for {event_type}" +LOCALES_DIR = Path(__file__).resolve().parents[2] / "composable-os" / "src" / "locales" -def test_labels_are_non_empty_strings(): - for event_type, label in NOTIFY_LABELS.items(): - assert isinstance(label, str), f"Label for {event_type} is not a string" - assert len(label) > 0, f"Empty label for {event_type}" +@pytest.mark.skipif(not LOCALES_DIR.is_dir(), reason="the frontend catalogues are not in this checkout") +@pytest.mark.parametrize("language", ["es", "en"]) +def test_every_notify_event_has_a_label_in_each_language(language): + layout = json.loads((LOCALES_DIR / language / "layout.json").read_text(encoding="utf-8")) + labels = layout["notifications"]["events"] + missing = NOTIFY_EVENTS - set(labels) + assert missing == set(), f"Events without a '{language}' label: {sorted(missing)}" + assert all(isinstance(v, str) and v for v in labels.values()) -def test_labels_dict_covers_all_notify_events(): - """NOTIFY_LABELS must be a superset of NOTIFY_EVENTS (no orphan labels required, - but every notifiable event needs a label).""" - missing = NOTIFY_EVENTS - set(NOTIFY_LABELS.keys()) - assert missing == set(), f"Events without labels: {missing}" + +@pytest.mark.skipif(not LOCALES_DIR.is_dir(), reason="the frontend catalogues are not in this checkout") +def test_the_catalogues_do_not_label_events_the_server_never_sends(): + layout = json.loads((LOCALES_DIR / "es" / "layout.json").read_text(encoding="utf-8")) + assert set(layout["notifications"]["events"]) - NOTIFY_EVENTS == set() # ── Message filtering logic ─────────────────────────────────────────────────── @@ -111,59 +118,64 @@ def test_all_notify_events_pass_filter(): # ── Message transformation ──────────────────────────────────────────────────── -def test_notification_message_shape(): - """Verify the shape of a forwarded notification matches the contract.""" - event_type = "QuoteAccepted" - raw_event = { - "event_type": event_type, +async def _forwarded(events: list[dict]) -> list[dict]: + """Run the real `notifications_ws` over `events` and return what the client got.""" + import asyncio + import json + + ws = AsyncMock() + + async def disconnect_later(): + await asyncio.sleep(0.2) # time for forward_events to drain the pub/sub + raise Exception("disconnect") + + ws.receive_text = AsyncMock(side_effect=disconnect_later) + + messages = [{"type": "message", "data": json.dumps(e)} for e in events] + mock_pubsub = AsyncMock() + mock_pubsub.listen = MagicMock(return_value=_async_iter(messages)) + mock_redis = AsyncMock() + mock_redis.pubsub = MagicMock(return_value=mock_pubsub) + + with patch( + "app.modules.notifications.router.verify_token", + return_value={"sub": "user-1", "workspace_id": "ws-42"}, + ), patch("app.modules.notifications.router.manager") as mock_manager, patch( + "app.modules.notifications.router.get_redis", return_value=mock_redis + ): + mock_manager.connect = AsyncMock() + mock_manager.disconnect = MagicMock() + from app.modules.notifications.router import notifications_ws + await notifications_ws(ws, token="good-token") + + return [call.args[0] for call in ws.send_json.call_args_list] + + +async def test_a_notification_carries_the_event_type_and_no_prose(): + """The interface words the notification in the reader's language (ADR 0014).""" + sent = await _forwarded([{ + "event_type": "QuoteAccepted", "payload": {"quote_id": "q-123", "amount": 1500.0}, "entity_id": "q-123", "timestamp": "2026-04-05T12:00:00Z", - } + }]) - # Simulate the transformation from router.py forward_events() - message = { - "type": "notification", - "event_type": raw_event["event_type"], - "title": NOTIFY_LABELS.get(raw_event["event_type"], raw_event["event_type"]), - "payload": raw_event.get("payload", {}), - "entity_id": raw_event.get("entity_id"), - "timestamp": raw_event.get("timestamp"), - } - - assert message["type"] == "notification" - assert message["event_type"] == "QuoteAccepted" - assert message["title"] == NOTIFY_LABELS["QuoteAccepted"] - assert message["payload"]["quote_id"] == "q-123" - assert message["entity_id"] == "q-123" - assert message["timestamp"] == "2026-04-05T12:00:00Z" - - -def test_notification_message_has_all_required_keys(): - """The forwarded message must always contain all six required fields.""" - raw_event = { - "event_type": "WorkflowTriggered", - "payload": {"workflow_id": "wf-1"}, - "entity_id": "wf-1", - "timestamp": "2026-04-05T08:00:00Z", - } - message = { + assert sent == [{ "type": "notification", - "event_type": raw_event["event_type"], - "title": NOTIFY_LABELS.get(raw_event["event_type"], raw_event["event_type"]), - "payload": raw_event.get("payload", {}), - "entity_id": raw_event.get("entity_id"), - "timestamp": raw_event.get("timestamp"), - } - for key in ("type", "event_type", "title", "payload", "entity_id", "timestamp"): - assert key in message - - -def test_notification_title_falls_back_to_event_type(): - """If event_type has no label, event_type itself is used as title.""" - event_type = "UnknownEvent" - title = NOTIFY_LABELS.get(event_type, event_type) - assert title == event_type + "event_type": "QuoteAccepted", + "payload": {"quote_id": "q-123", "amount": 1500.0}, + "entity_id": "q-123", + "timestamp": "2026-04-05T12:00:00Z", + }] + + +async def test_events_outside_the_whitelist_are_not_forwarded(): + sent = await _forwarded([ + {"event_type": "SomethingInternal", "payload": {}}, + {"event_type": "WorkflowTriggered", "payload": {}, "entity_id": "wf-1"}, + ]) + + assert [m["event_type"] for m in sent] == ["WorkflowTriggered"] def test_missing_payload_defaults_to_empty_dict(): diff --git a/composable-os/src/components/layout/AppLayout.test.tsx b/composable-os/src/components/layout/AppLayout.test.tsx index 4aeeadd..953fd80 100644 --- a/composable-os/src/components/layout/AppLayout.test.tsx +++ b/composable-os/src/components/layout/AppLayout.test.tsx @@ -22,10 +22,10 @@ vi.mock("@/lib/useNotifications", () => ({ useNotifications: () => notif })); const NOT_TEXT = [/^AT$/, /^CBOS$/]; /** Lo que llega del servidor o del usuario: no es texto de la interfaz. */ -const DATA = ["Ana Torres", "ana@sol.co", "Cotización aceptada", "quote.accepted"]; +const DATA = ["Ana Torres", "ana@sol.co", "QuoteAccepted"]; const note = (over: Record = {}) => ({ - id: "n1", event_type: "quote.accepted", title: "Cotización aceptada", payload: {}, entity_id: null, + id: "n1", event_type: "QuoteAccepted", payload: {}, entity_id: null, timestamp: new Date(Date.now() - 5 * 60_000).toISOString(), read: false, ...over, }); @@ -102,7 +102,7 @@ describe("AppLayout / AppSidebar en español", () => { openBell("Notificaciones"); expect(screen.getByText("Cotización aceptada")).toBeInTheDocument(); - expect(screen.getByText(/quote\.accepted · hace 5 min/)).toBeInTheDocument(); + expect(screen.getByText(/QuoteAccepted · hace 5 min/)).toBeInTheDocument(); expect(notif.markAllRead).toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Marcar todas" })); @@ -112,6 +112,20 @@ describe("AppLayout / AppSidebar en español", () => { expect(notif.dismiss).toHaveBeenCalledWith("n1"); }); + it("el texto de la notificacion sale del catalogo segun el tipo de evento", () => { + notif.notifications = [ + note({ id: "n1", event_type: "InvoiceOverdue" }), + note({ id: "n2", event_type: "EventoNuevo" }), + ]; + renderLayout(); + + openBell("Notificaciones"); + + expect(screen.getByText("Factura vencida ⚠️")).toBeInTheDocument(); + // Un evento sin entrada en el catalogo se muestra con su nombre, no con un hueco. + expect(screen.getByText("EventoNuevo")).toBeInTheDocument(); + }); + it("sin notificaciones lo dice", () => { notif.notifications = []; notif.unreadCount = 0; diff --git a/composable-os/src/components/layout/AppLayout.tsx b/composable-os/src/components/layout/AppLayout.tsx index 0c7904c..575ba72 100644 --- a/composable-os/src/components/layout/AppLayout.tsx +++ b/composable-os/src/components/layout/AppLayout.tsx @@ -12,9 +12,11 @@ import { useAuth } from "@/lib/auth"; import { useNotifications } from "@/lib/useNotifications"; import { useFormat } from "@/i18n/useFormat"; import { useT } from "@/i18n/useT"; +import { useEnumLabel } from "@/i18n/enumLabel"; export function AppLayout() { const t = useT(); + const label = useEnumLabel(); const { formatRelativeTime } = useFormat(); const { user, logout } = useAuth(); const { notifications, unreadCount, markAllRead, dismiss } = useNotifications(); @@ -77,7 +79,7 @@ export function AppLayout() {
-

{n.title}

+

{label("layout:notifications.events", n.event_type)}

{n.event_type} · {formatRelativeTime(n.timestamp)}

diff --git a/composable-os/src/i18n/english-layout.test.tsx b/composable-os/src/i18n/english-layout.test.tsx index f50da13..5fa34cf 100644 --- a/composable-os/src/i18n/english-layout.test.tsx +++ b/composable-os/src/i18n/english-layout.test.tsx @@ -88,13 +88,15 @@ describe("AppLayout in English", () => { unmount(); notif.notifications = [{ - id: "n1", event_type: "quote.accepted", title: "Quote accepted", payload: {}, entity_id: null, + id: "n1", event_type: "QuoteAccepted", payload: {}, entity_id: null, timestamp: new Date(Date.now() - 5 * 60_000).toISOString(), read: false, }]; notif.unreadCount = 1; renderWithI18n(, instance); fireEvent.click(screen.getByRole("button", { name: "Notifications" })); + expect(screen.getByText("Quote accepted")).toBeInTheDocument(); + expect(screen.queryByText("Cotización aceptada")).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Mark all as read" })); expect(notif.markAllRead).toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Dismiss" })); diff --git a/composable-os/src/lib/useNotifications.ts b/composable-os/src/lib/useNotifications.ts index ef14abb..ab1f7c4 100644 --- a/composable-os/src/lib/useNotifications.ts +++ b/composable-os/src/lib/useNotifications.ts @@ -4,7 +4,6 @@ import { getToken } from "@/lib/api"; export interface AppNotification { id: string; event_type: string; - title: string; payload: Record; entity_id: string | null; timestamp: string; @@ -48,7 +47,6 @@ export function useNotifications() { { id: String(++idCounter), event_type: msg.event_type, - title: msg.title, payload: msg.payload ?? {}, entity_id: msg.entity_id ?? null, timestamp: msg.timestamp ?? new Date().toISOString(), diff --git a/composable-os/src/locales/en/layout.json b/composable-os/src/locales/en/layout.json index 38afeb3..f2fd445 100644 --- a/composable-os/src/locales/en/layout.json +++ b/composable-os/src/locales/en/layout.json @@ -39,6 +39,22 @@ "title": "Notifications", "markAll": "Mark all as read", "empty": "No notifications", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "events": { + "WorkflowTriggered": "Workflow executed", + "WorkflowCompleted": "Workflow completed", + "WorkflowFailed": "Workflow failed", + "InventoryLowThresholdDetected": "Low stock", + "QuoteAccepted": "Quote accepted", + "QuoteRejected": "Quote rejected", + "SalesOrderCreated": "New sales order", + "CustomerActionPerformed": "Customer action", + "OpportunityWon": "Deal won 🎉", + "OpportunityLost": "Deal lost", + "PortalSessionCreated": "Portal shared with customer", + "InvoiceCreated": "Invoice created", + "InvoicePaid": "Invoice paid 💰", + "InvoiceOverdue": "Invoice overdue ⚠️" + } } } diff --git a/composable-os/src/locales/es/layout.json b/composable-os/src/locales/es/layout.json index 83edb47..646a746 100644 --- a/composable-os/src/locales/es/layout.json +++ b/composable-os/src/locales/es/layout.json @@ -39,6 +39,22 @@ "title": "Notificaciones", "markAll": "Marcar todas", "empty": "Sin notificaciones", - "dismiss": "Descartar" + "dismiss": "Descartar", + "events": { + "WorkflowTriggered": "Workflow ejecutado", + "WorkflowCompleted": "Workflow completado", + "WorkflowFailed": "Workflow falló", + "InventoryLowThresholdDetected": "Stock bajo", + "QuoteAccepted": "Cotización aceptada", + "QuoteRejected": "Cotización rechazada", + "SalesOrderCreated": "Nueva orden de venta", + "CustomerActionPerformed": "Acción del cliente", + "OpportunityWon": "Deal ganado 🎉", + "OpportunityLost": "Deal perdido", + "PortalSessionCreated": "Portal compartido con cliente", + "InvoiceCreated": "Factura generada", + "InvoicePaid": "Factura pagada 💰", + "InvoiceOverdue": "Factura vencida ⚠️" + } } } diff --git a/docs/CAPABILITY_MATURITY_SCORECARD.md b/docs/CAPABILITY_MATURITY_SCORECARD.md index 92c5ee2..a2841b5 100644 --- a/docs/CAPABILITY_MATURITY_SCORECARD.md +++ b/docs/CAPABILITY_MATURITY_SCORECARD.md @@ -147,4 +147,4 @@ A module may advance from Tier 3 → Tier 2 or Tier 2 → Tier 1 when it achieve **12 modules Tier 1 — 12 total modules.** Full wedge funnel: Discovery → Identity → CRM → Sales → Inventory → Portal → Workflows → Accounting → Notifications → Contracts → Projects → HR. -**974 tests across 50 test files.** Q3 backlog 100% closed. Q4 G2 (overdue automation) complete. Q4 E1 (analytics real data) complete. Q4 Q1 (frontend CI type-check) complete. Q4 Q2 (invoice PDF download) complete. M1 (Contracts) complete. M2 (Projects) complete. M3 (HR/Team) complete. Fase 1 (integration tests + specs for M1/M2/M3) complete. +**972 tests across 50 test files.** Q3 backlog 100% closed. Q4 G2 (overdue automation) complete. Q4 E1 (analytics real data) complete. Q4 Q1 (frontend CI type-check) complete. Q4 Q2 (invoice PDF download) complete. M1 (Contracts) complete. M2 (Projects) complete. M3 (HR/Team) complete. Fase 1 (integration tests + specs for M1/M2/M3) complete. diff --git a/docs/IMPLEMENTATION_ALIGNMENT.md b/docs/IMPLEMENTATION_ALIGNMENT.md index 7e17758..f781311 100644 --- a/docs/IMPLEMENTATION_ALIGNMENT.md +++ b/docs/IMPLEMENTATION_ALIGNMENT.md @@ -58,7 +58,7 @@ Legacy documents are historical reference only unless explicitly promoted by ADR ## Current Platform Snapshot - Active modules: identity, crm, sales, inventory, portal, discovery, workflows, notifications, accounting, analytics, contracts, projects, hr -- Test suite: 974 automated tests across 50 test files +- Test suite: 972 automated tests across 50 test files - Frontend active surface: API-backed pages aligned to the implemented backend wedge plus current Tier 1 modules - Public runtime surfaces: - `GET /health` @@ -78,12 +78,12 @@ Legacy documents are historical reference only unless explicitly promoted by ADR | Frontend error surfacing | Closed and enforced by the toolchain rather than by review. All 47 `onError` handlers type the error as `Error` and pass it to `translateApiError`, which takes `unknown` and renders Spanish from the backend `code` per ADR 0014 — which also settles the old English-message inconsistency. The original bug was two handlers reading `e.response.data.detail`, an axios shape this codebase does not use, so the expression evaluated to `undefined` and the user saw a generic fallback; it survived because those handlers typed the error as `any`. Two things now stop it recurring: `@typescript-eslint/no-explicit-any` is an error and CI runs `npm run lint`, and `useUnknownInCatchVariables` closes the hole the linter cannot see — with `strict: false` a bare `catch (e)` was implicitly `any`, and reaching for a property on it now fails to compile. The only remaining `any` is scoped to `PortalBuilder.tsx` behind a justified file-level disable | Every mutation surfaces the backend's own message when it has one | Low | Keep it closed: the compiler and the linter enforce it. Lifting the `PortalBuilder.tsx` disable needs an explicit contract for block props first, and turning on full `strict` is a separate, much larger change — `strictNullChecks` is still off | | Runtime health signal | `GET /health` now checks Redis alongside the API and Postgres. It did not before, and that blind spot hid a real production outage: Redis was down on the deployed instance while `/health` reported `healthy`, because no ordinary HTTP route touches it — what fails silently is notifications, the workflow engine and the consumers. Verified both ways locally: stopping Redis turns the endpoint `unhealthy`, restarting it returns `healthy` | Health reflects every dependency whose loss degrades the product | Medium | When a new runtime dependency is added, add its probe in the same change set. A dependency only reached by background work is exactly the one an external probe cannot see | | Frontend alignment | Active routes are much cleaner than before, but docs still lag occasionally | UI backed by owned capabilities and current backend maturity | Medium | Refresh capability specs and route docs alongside feature delivery | -| Test coverage narrative | Codebase has 974 tests across 50 test files. The four score-bearing docs (this one, `README.md`, `CAPABILITY_MATURITY_SCORECARD.md`, and the README breakdown table) now agree on that number | One accurate confidence narrative | Medium | Re-derive the count from `pytest -q` in the same change set as the feature, and update all four places at once — they drifted apart because only one was touched | +| Test coverage narrative | Codebase has 972 tests across 50 test files. The four score-bearing docs (this one, `README.md`, `CAPABILITY_MATURITY_SCORECARD.md`, and the README breakdown table) now agree on that number | One accurate confidence narrative | Medium | Re-derive the count from `pytest -q` in the same change set as the feature, and update all four places at once — they drifted apart because only one was touched | | Contracts/Projects/HR production confirmation | Deploy readiness is locally verified, but no production-domain smoke evidence has been recorded in governance | Production dimension marked green only after deployed route smoke passes against the production domain and matching SHA | High | Run production smoke for `/contracts`, `/projects`, `/departments`, and `/employees` after deploy | | 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 and quote 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 Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults. Dates and amounts follow the language in both PDFs and the emails (`app/core/i18n/format.py`, an own table with the frontend's region defaults: `es` is `es-MX`, `en` is `en-US`; email dates stay ISO). English is offered: the backend catalogues (invoice PDF, quote PDF, 9 emails) are complete and `en` is in `SUPPORTED_LOCALES`, and the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales`, `settings` and `workflows` translated (19 of 19 namespaces); it offers a language only once every namespace exists. The quote share dialog lets the seller choose the customer's language (`portal_sessions.locale`), which sets the language of the email sent to the customer; the portal page itself follows the customer's browser. 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 and quote 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 Spanish prose still returned by the workflow `send_email` action's defaults (the portal's API messages are English and the notification texts come from the interface catalogue, `layout:notifications.events`). Dates and amounts follow the language in both PDFs and the emails (`app/core/i18n/format.py`, an own table with the frontend's region defaults: `es` is `es-MX`, `en` is `en-US`; email dates stay ISO). English is offered: the backend catalogues (invoice PDF, quote PDF, 9 emails) are complete and `en` is in `SUPPORTED_LOCALES`, and the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects`, `sales`, `settings` and `workflows` translated (19 of 19 namespaces); it offers a language only once every namespace exists. The quote share dialog lets the seller choose the customer's language (`portal_sessions.locale`), which sets the language of the email sent to the customer; the portal page itself follows the customer's browser. 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 63a4f6d..15f863e 100644 --- a/docs/superpowers/plans/2026-08-07-i18n-full.md +++ b/docs/superpowers/plans/2026-08-07-i18n-full.md @@ -478,10 +478,15 @@ completa migrada no hay patron que seguir. - [x] PDF de cotizacion (`sales/pdf.py`) con catalogo `quote_pdf` es/en, en el idioma del usuario que lo descarga. El estado ya no sale como el codigo en ingles (`SENT`) sino traducido (`ENVIADA`). + - [x] Textos en espanol que el servidor devolvia al usuario: el `message` de las + respuestas del portal pasa a ingles de desarrollador (como el resto de la API; ninguna + pantalla lo mostraba) y las notificaciones en vivo dejan de llevar `title`: el + servidor manda el tipo de evento y la interfaz pone el texto + (`layout:notifications.events.*`, es y en). - [ ] Recorrer la aplicacion en ingles buscando lo que el segundo idioma revela: cadenas concatenadas, orden de palabras, plurales. El barrido estatico del frontend - salio limpio; quedan los textos del portal, las etiquetas de notificaciones y - Discovery, y el recorrido real en el navegador. + salio limpio; quedan Discovery (asistente, blueprint, capacidades) y el recorrido + real en el navegador. ---