{
  "meta": {
    "title": "Neuro Frontend — Feature Inventory",
    "application": "neuro-frontend (Ember Octane back office for shopping-centre operators)",
    "version": "4.0.0-dev",
    "generated": "2026-10-12",
    "gitCommit": "276788ca6",
    "sources": [
      "app/router.js — 9 top-level product areas, ~140 routes",
      "app/routes/**, app/controllers/** — behaviour, actions and error handling",
      "app/models/** — 130 models; `get validations()` blocks are the authoritative source for failure paths",
      "app/abilities/** — 55 ember-can classes; the authoritative source for role and module gating",
      "app/enums/** — statuses, types and filter palettes",
      "translations.json — 3,214 keys; feature names come from `menu.navbar.*` / `title.header.*` and failure messages from `error.*` and the per-feature namespaces"
    ],
    "schema": {
      "id": "stable slug",
      "name": "feature name as an operator would say it",
      "area": "product area",
      "label": "the translation key(s) the UI labels this feature with",
      "routes": "Ember route names",
      "code": "primary implementation files",
      "models": "Ember Data models touched",
      "modules": "centre modules that must be enabled",
      "roles": "staff levels allowed",
      "description": "what the feature does",
      "comments": "how it actually works, including quirks and weak spots found in the code",
      "useCases": "why an operator reaches for it",
      "happyPaths": "named end-to-end success flows, as ordered steps",
      "failurePaths": "named failure modes with cause and observed handling",
      "i18nKeys": "translation keys that evidence the above",
      "i18nMissing": "translation keys this feature uses that do not exist in translations.json (rendered as raw keys)"
    },
    "notes": [
      "Permissions in this product are three-dimensional: staff level x centre module x record state. A feature can be invisible for any of the three reasons, and the UI rarely says which.",
      "Failure paths are taken from the code, not inferred: model `validations` blocks, `.catch()` handlers and `throw` guards. Where a failure is swallowed or leaves a stuck spinner, that is recorded as the handling, because it is what an operator will actually experience.",
      "Several translation namespaces have no remaining code references and look like dead or not-yet-built surface: `bonusRequest` (22 keys), `dmr-weekly`, `marketing-analytics` (16 keys), `commerce-stay-alert` (4 keys, though an ability and a menu key still exist), plus partial remnants of `leasing`, `lineitem` and `supplier`. They are deliberately not listed as features.",
      "Two CSV-import implementations coexist (`nf-import` and the older `import-csv`) with different robustness; `import-csv.errors.load.required-selections` is still in Portuguese while its siblings are English."
    ],
    "counts": {
      "areas": 10,
      "features": 92,
      "useCases": 331,
      "happyPaths": 136,
      "failurePaths": 436
    },
    "review": {
      "method": [
        "Every route in app/router.js is referenced by at least one feature.",
        "100 of 101 controllers and all feature-bearing route files are referenced; the remaining route files are pageHeader-only parents.",
        "The side menu (utils/get-side-nav-menu.js) was walked entry by entry, including commented-out and module-disabled entries.",
        "Unreferenced models, serializers and components were traced to their templates to find hidden features (mobile-app config, login methods, integrations, social wifi, Froged, analytics, the dormant action log).",
        "All 2,598 translation keys used literally in code were checked against translations.json."
      ],
      "addedInReview": [
        "platform-runtime-config",
        "platform-ui-language",
        "platform-support-widget",
        "xc-product-analytics",
        "config-mobile-app",
        "config-customer-login",
        "config-integrations",
        "config-social-wifi-location",
        "crm-customer-action-log"
      ],
      "i18nMissingKeys": {
        "access-to-account-types.client": [
          "app/enums/access-to-account-types.js"
        ],
        "access-to-account-types.employee-shop": [
          "app/enums/access-to-account-types.js"
        ],
        "access-to-account-types.employee-outsource": [
          "app/enums/access-to-account-types.js"
        ],
        "error.server-incident.required": [
          "app/models/server-incident.js"
        ],
        "error.communication.store.required": [
          "app/models/intranet-communication.js"
        ],
        "reference-campaign": [
          "app/models/campaign.js"
        ],
        "reference-date": [
          "app/models/campaign.js"
        ],
        "error.month.required": [
          "app/models/anonymazed-saft.js"
        ],
        "cms.multicentre-proposal.general-data": [
          "app/controllers/cms/multicentre-proposals/new.js"
        ],
        "server-incident": [
          "app/templates/configuration/server-incidents/index.hbs"
        ],
        "crm.commercy-types": [
          "app/templates/crm/commerce-types/index.hbs"
        ],
        "email-campaign.insert-variable.placeholder": [
          "app/templates/components/email-variable-selector.hbs"
        ],
        "intranet.communications.terms-and-conditions": [
          "app/templates/components/eula-communications-modal.hbs"
        ],
        "actions.edit": [
          "app/templates/components/form-user/row-actions.hbs"
        ],
        "multicentreProposal": [
          "app/templates/cms/multicentre-proposals/index.hbs"
        ],
        "cms.proposals.new.image-generation.gif": [
          "app/templates/cms/proposals/new.hbs"
        ],
        "want_reject_publish": [
          "app/templates/cms/proposals/index.hbs"
        ],
        "activityCampaign": [
          "app/templates/marketing/activity-campaigns/index.hbs"
        ],
        "sovosFile": [
          "app/templates/intranet/safte/import/index.hbs"
        ]
      },
      "deadSurface": {
        "commerce-stay-alert": "model, ability and menu translation exist; no route, template or menu item",
        "bonusRequest": "22 translation keys and an env comment; no code",
        "marketing-analytics": "16 keys; only used as a disabledByModule flag",
        "dmr-weekly": "keys only",
        "customer action log": "component and model built, not mounted in any template",
        "supplier / lineitem / service models": "only referenced by enums and the campaign report"
      }
    }
  },
  "productAreas": [
    {
      "id": "Platform & Access",
      "description": "Authentication, session lifecycle, centre scoping and the landing desktop — the shell every other feature runs inside."
    },
    {
      "id": "CRM",
      "description": "Customer, segment, store and receipt management: the customer database and everything that reads or writes it."
    },
    {
      "id": "Marketing",
      "description": "Activities (campaigns with loyalty mechanics), email campaigns and the embedded loyalty/gift/booking platforms."
    },
    {
      "id": "CMS",
      "description": "Content published to the mobile app and web: proposals, push notifications, categories and the app button layout."
    },
    {
      "id": "Intranet",
      "description": "Centre-to-tenant operations: sales declaration and closing, annual statements, communications, documents, footfall, SAFT-E and utilities."
    },
    {
      "id": "Analytics",
      "description": "Dashboards and the prebuilt CRM / data-quality panels."
    },
    {
      "id": "Play Center",
      "description": "Supervised play-area stays: child check-in and check-out against capacity."
    },
    {
      "id": "Configuration",
      "description": "Tenant and platform administration: centres and their modules, staff accounts, reference data, dashboards and operational tooling."
    },
    {
      "id": "Smart Assistant",
      "description": "The embedded conversational assistant (CandelarIA)."
    },
    {
      "id": "Cross-cutting",
      "description": "Shared engines and layers reused by many features: imports, exports, filters, files, i18n, realtime, permissions and error handling."
    }
  ],
  "features": [
    {
      "id": "platform-login",
      "name": "Staff login",
      "area": "Platform & Access",
      "label": "menu.navbar.logout / form-login",
      "routes": [
        "user.login"
      ],
      "code": [
        "app/routes/user/login.js",
        "app/components/form/form-login.js",
        "app/authenticators/",
        "app/services/auth.js"
      ],
      "models": [
        "staff-member"
      ],
      "modules": [],
      "roles": [
        "any staff level"
      ],
      "description": "Email + password authentication for back-office staff. On success a bearer token is stored in the ember-simple-auth session and the current user, their centers and their dashboards are loaded.",
      "comments": "Auth state lives in the `auth` service: the token is aliased from `session.data.authenticated.token`, while the active centre is persisted as a `cid` cookie (max-age 86400) rather than in the session. `auth.load()` hydrates user + centers + dashboards and is re-run in `routes/index.js#beforeModel` because right after login those relationships are not yet in the store. The user is also pushed to Sentry (`sentrySetUser`).",
      "useCases": [
        "A centre manager signs in at the start of a shift to work on sales, proposals or customers.",
        "An admin with several centres signs in and then picks which centre to operate on.",
        "An external/API-level account authenticates to obtain a token."
      ],
      "happyPaths": [
        {
          "name": "Successful sign-in",
          "steps": [
            "Staff member opens /user/login and enters email + password.",
            "Credentials are posted to the API authenticator; a bearer token comes back.",
            "`auth.load()` resolves the user, the list of accessible centres and the dashboards.",
            "The `cid` cookie is set to the default centre and the app transitions to the desktop (`index`).",
            "The side menu is rebuilt from the user's role and the centre's enabled modules."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Wrong credentials",
          "cause": "API rejects the email/password pair.",
          "handling": "The login form stays on screen and surfaces the authenticator's error; no token is stored."
        },
        {
          "name": "Centre cookie unavailable",
          "cause": "Cookies blocked or `cid` missing — `auth.getCookie` throws `NoCookieError`.",
          "handling": "`centerId`/`centerGid` getters swallow the error and return an empty string, so centre-scoped queries silently run without a centre filter. Worth treating as a known weak spot: the UI does not warn the user."
        },
        {
          "name": "Role not recognised",
          "cause": "`auth.user.role` is not one of the values in `enums/staff-levels`.",
          "handling": "`nf-navbar#menu` returns `[]`, so the user lands on an app with an empty navigation menu instead of an explicit error."
        },
        {
          "name": "Session invalidated mid-use",
          "cause": "Token expired or rejected by the API.",
          "handling": "`routes/application.js` catches the failure and calls `session.invalidate()`, returning the user to login."
        }
      ],
      "i18nKeys": [
        "menu.navbar.logout",
        "pass-required",
        "match-pass"
      ],
      "i18nMissing": []
    },
    {
      "id": "platform-reset-password",
      "name": "Password reset request",
      "area": "Platform & Access",
      "label": "change-password.titles",
      "routes": [
        "user.reset-password"
      ],
      "code": [
        "app/routes/user/reset-password.js",
        "app/components/form/reset-password.js"
      ],
      "models": [
        "staff-member"
      ],
      "modules": [],
      "roles": [
        "unauthenticated"
      ],
      "description": "Public form where a staff member asks for a password-reset email by entering their address.",
      "comments": "Deliberately unauthenticated and paired with `update_password/:token`. The route itself is a thin shell (9 lines); the work is in the `reset-password` component.",
      "useCases": [
        "Staff member forgot their password and needs a reset link.",
        "An admin tells a user to self-service a reset instead of re-issuing credentials."
      ],
      "happyPaths": [
        {
          "name": "Reset email requested",
          "steps": [
            "User opens /user/reset-password and submits their email.",
            "API queues a reset email containing a tokenised link.",
            "A confirmation message is shown; the user leaves the app and opens the email."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Unknown email",
          "cause": "Address does not belong to a staff member.",
          "handling": "Handled by the API response; the form reports the error. Note the flow does not distinguish 'unknown address' from 'sent' in the UI copy, which is the usual anti-enumeration behaviour."
        },
        {
          "name": "Mail delivery fails",
          "cause": "Email provider error downstream.",
          "handling": "No frontend feedback loop exists — the user simply never receives the link and must retry."
        }
      ],
      "i18nKeys": [
        "change-password.titles"
      ],
      "i18nMissing": []
    },
    {
      "id": "platform-update-password",
      "name": "Password update from reset link",
      "area": "Platform & Access",
      "label": "change-password.success-message",
      "routes": [
        "update_password/:token"
      ],
      "code": [
        "app/routes/update-password.js",
        "app/controllers/update-password.js"
      ],
      "models": [
        "staff-member"
      ],
      "modules": [],
      "roles": [
        "unauthenticated (token-bearing)"
      ],
      "description": "Token-gated screen where a staff member sets a new password and confirmation.",
      "comments": "The route pre-flights the token (`isValidToken: 200 === checkToken.status`) before rendering, so an expired link is detected on entry rather than on submit. The controller runs its own `validate()` that requires a password and checks the confirmation match before calling the API.",
      "useCases": [
        "Completing a self-service password reset.",
        "A newly created staff member setting their first password after the 'send password email' action."
      ],
      "happyPaths": [
        {
          "name": "Password changed",
          "steps": [
            "User opens the tokenised URL; the route validates the token against the API (HTTP 200).",
            "User types the new password twice.",
            "Local validation passes (non-empty, both fields match, format rules from the staff-member model).",
            "API accepts the change and `change-password.success-message` is shown."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Expired or tampered token",
          "cause": "Token check returns a non-200 status.",
          "handling": "`isValidToken` is false and the form is not usable; the user must request a new reset email."
        },
        {
          "name": "Empty password",
          "cause": "Field left blank.",
          "handling": "Local validation sets `errors.password` to `pass-required`; no request is sent."
        },
        {
          "name": "Confirmation mismatch",
          "cause": "The two fields differ.",
          "handling": "`match-pass` is shown inline (also enforced by the staff-member model's `passConfirmation.match` rule)."
        },
        {
          "name": "Weak password",
          "cause": "Fails `/^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).{10,}$/` — needs lower + upper + digit and 10+ characters.",
          "handling": "`pass-format` message from the staff-member validations."
        }
      ],
      "i18nKeys": [
        "change-password.success-message",
        "pass-required",
        "match-pass",
        "pass-format"
      ],
      "i18nMissing": []
    },
    {
      "id": "platform-change-own-password",
      "name": "Change own password",
      "area": "Platform & Access",
      "label": "menu.navbar.configuration.password",
      "routes": [
        "configuration.password",
        "configuration.password.index"
      ],
      "code": [
        "app/routes/configuration/password.js",
        "app/components/form-password.js"
      ],
      "models": [
        "staff-member"
      ],
      "modules": [],
      "roles": [
        "any authenticated staff level"
      ],
      "description": "Authenticated self-service password change requiring the current password plus a new one and its confirmation.",
      "comments": "Extends the generic `routes/form.js` save pipeline, so model validation runs before `save()` and the route redirects to `returnRoute` on success. It overrides the super call with a `.catch()` so an API rejection keeps the user on the form instead of transitioning away. The menu entry excludes `content_creator`, `calibrator` and `external`, so those levels have no self-service password change from the menu.",
      "useCases": [
        "Routine credential rotation.",
        "A user who suspects their password was shared changing it immediately."
      ],
      "happyPaths": [
        {
          "name": "Password rotated",
          "steps": [
            "User opens Configuration → Change password.",
            "Enters current password, new password and confirmation.",
            "Model validations pass (`passOld` present, `pass` matches the format rule, `passConfirmation` matches `pass`).",
            "`informative-message.change-password.success.message` is shown and the user is returned to the previous route."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Current password missing or wrong",
          "cause": "`passOld` blank (local) or rejected by the API.",
          "handling": "`current-pass-required` locally; API errors are caught by the overridden `_super(...).catch()` so the form is kept open."
        },
        {
          "name": "New password too weak",
          "cause": "Fails the 10-character mixed-case + digit format rule.",
          "handling": "`pass-format` inline error, no request sent."
        },
        {
          "name": "Confirmation mismatch",
          "cause": "`passConfirmation` ≠ `pass`.",
          "handling": "`match-pass` inline error."
        }
      ],
      "i18nKeys": [
        "menu.navbar.configuration.password",
        "informative-message.change-password.success.message",
        "current-pass-required",
        "pass-format",
        "match-pass"
      ],
      "i18nMissing": []
    },
    {
      "id": "platform-auto-logout",
      "name": "Idle auto-logout",
      "area": "Platform & Access",
      "label": "modal.logout.description",
      "routes": [
        "(application-wide)"
      ],
      "code": [
        "app/services/idle-monitor.js",
        "app/controllers/application.js",
        "app/models/module-auto-logout.js"
      ],
      "models": [
        "module-auto-logout"
      ],
      "modules": [
        "auto_logout"
      ],
      "roles": [
        "all"
      ],
      "description": "Per-centre inactivity timer that warns the user and then ends the session. The timeout in minutes is configured on the centre's `auto_logout` module.",
      "comments": "The monitor listens to `mousemove`, `mousedown`, `keypress` and `touchmove` on `window` and restarts an Ember `later()` timer on each event. It is a no-op when the module is disabled or `minutes` is unset. The application controller exposes `keepSessionActive` and `logoutNow`, plus `minutesBeforeLogout` for the warning copy. Note `willDestroy` removes listeners using fresh `.bind(this)` references, so those `removeEventListener` calls do not actually detach the original handlers — a real leak if the service is ever torn down.",
      "useCases": [
        "A shopping-centre back office on a shared desktop where unattended sessions must not stay open.",
        "Centres with stricter data-protection requirements enabling a short window while others leave it off."
      ],
      "happyPaths": [
        {
          "name": "Session kept alive",
          "steps": [
            "Centre has `auto_logout.enabled` with e.g. 15 minutes.",
            "User is idle; the timer fires and the warning modal appears with the remaining minutes.",
            "User clicks 'keep session active'; the timer resets and work continues."
          ]
        },
        {
          "name": "Idle session closed",
          "steps": [
            "User walks away; no input events arrive before the timeout.",
            "`logout` is triggered, the session is invalidated and the app returns to the login screen."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Module enabled but minutes unset",
          "cause": "`auto_logout.minutes` is undefined.",
          "handling": "`logout()` returns early and `msBeforeLogout` is 0 — the feature silently does nothing, so a half-configured centre believes it is protected when it is not."
        },
        {
          "name": "Centre not loaded yet",
          "cause": "`auth.center` is still null during boot.",
          "handling": "`resetTimer`/`msBeforeLogout` guard on the missing centre and skip scheduling until it is available."
        },
        {
          "name": "Unsaved work lost",
          "cause": "Logout fires while a long multi-step form (campaign, proposal, centre) is half-filled.",
          "handling": "No draft persistence — the in-memory records are discarded with the session. A known UX risk on long wizards."
        }
      ],
      "i18nKeys": [
        "modal.logout.description",
        "modules-auto-logout"
      ],
      "i18nMissing": []
    },
    {
      "id": "platform-center-switching",
      "name": "Centre switching & multi-centre scoping",
      "area": "Platform & Access",
      "label": "menu.navbar.configuration.my-center",
      "routes": [
        "(application-wide)"
      ],
      "code": [
        "app/services/auth.js",
        "app/components/nf-navbar.js",
        "app/utils/get-side-nav-menu.js"
      ],
      "models": [
        "center",
        "modules"
      ],
      "modules": [],
      "roles": [
        "all; `admin` sees every centre, others only `user.centers`"
      ],
      "description": "The navbar centre picker sets the active centre, which scopes virtually every list query (`filter.center_id`), drives which modules are enabled and therefore which menu entries and features exist at all.",
      "comments": "This is the single most load-bearing cross-cutting concern in the app. `nf-navbar#centers` returns all centres for `admin` and only `auth.centersAvailable` otherwise. `nf-navbar#menu` filters each section by role (`section.levels`), by module (`has<Module>`), by `disabledByModule`, or by an ember-can ability when `section.ability` is set. Dashboards are injected into the menu dynamically from `auth.dashboards`. Centre selection is a cookie, not session state, so it survives reloads but is also shared across tabs.",
      "useCases": [
        "A group-level admin comparing or administering several shopping centres from one login.",
        "Enabling a feature for one centre only — e.g. Yardi, Fideliza or the lottery module — without a code change.",
        "A store-level user restricted to a single centre and its stores."
      ],
      "happyPaths": [
        {
          "name": "Switch active centre",
          "steps": [
            "Admin opens the centre dropdown in the navbar.",
            "Picks another centre; `auth.center` setter writes the new `cid` cookie.",
            "Menu recomputes from the new centre's modules and the user's role.",
            "Subsequent list queries carry the new `center_id` as a fixed filter."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Module disabled",
          "cause": "Centre does not have the module that guards a section.",
          "handling": "The menu entry is filtered out and the corresponding route's ability check (`canAccess`) blocks direct URL entry."
        },
        {
          "name": "Role lacks the level",
          "cause": "`section.levels` does not include the user's role.",
          "handling": "Entry hidden; route-level `can` checks reject manual navigation."
        },
        {
          "name": "Stale cookie pointing at a lost centre",
          "cause": "`cid` references a centre the user no longer has access to.",
          "handling": "`auth.center` resolves to `undefined` via `findBy`, and downstream `get(center, 'has…')` checks fall back to false, producing an app with almost no menu rather than a clear 'no access' message."
        }
      ],
      "i18nKeys": [
        "menu.navbar.configuration.my-center",
        "menu.navbar.configuration.centers"
      ],
      "i18nMissing": []
    },
    {
      "id": "platform-incident-alert",
      "name": "Server-incident banner",
      "area": "Platform & Access",
      "label": "menu.navbar.server-incidents",
      "routes": [
        "(application-wide)"
      ],
      "code": [
        "app/controllers/application.js",
        "app/components/nf-incident-alert.js"
      ],
      "models": [
        "server-incident"
      ],
      "modules": [],
      "roles": [
        "all"
      ],
      "description": "In-app banner that surfaces an open server incident (title, description, last update) to every user, dismissible per session.",
      "comments": "Consumer side of the Server incidents admin feature. The application controller exposes `incidentTitle`, `incidentDescription`, `incidentUpdatedAt` and `incidentAlertIsClosed` plus a `closeIncident` action. Dismissal is per user per session, so a re-login re-shows the alert.",
      "useCases": [
        "Telling all back-office users that receipt validation is degraded before they open support tickets.",
        "Announcing a maintenance window from inside the product."
      ],
      "happyPaths": [
        {
          "name": "Incident announced and acknowledged",
          "steps": [
            "An admin creates a server incident under Configuration.",
            "Every signed-in user sees the banner with title, description and update time.",
            "A user clicks close; `incidentAlertIsClosed` hides it for the rest of the session."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "No open incident",
          "cause": "Nothing unresolved to display.",
          "handling": "Banner is simply not rendered."
        },
        {
          "name": "Incident resolved but banner cached",
          "cause": "The alert is resolved after the page was loaded.",
          "handling": "No live push — the banner clears on the next load/refresh."
        }
      ],
      "i18nKeys": [
        "menu.navbar.server-incidents",
        "title.header.configuration.server-incidents"
      ],
      "i18nMissing": []
    },
    {
      "id": "platform-desktop",
      "name": "Desktop (home dashboard)",
      "area": "Platform & Access",
      "label": "title.header.desktop",
      "routes": [
        "index"
      ],
      "code": [
        "app/routes/index.js",
        "app/controllers/index.js"
      ],
      "models": [
        "dashboard",
        "receipt"
      ],
      "modules": [],
      "roles": [
        "all; pending-receipts nudge only for admin, manager, crmmanager"
      ],
      "description": "The landing screen after login. It renders whichever dashboard is configured with `category: 'home'` — a custom panel, the data-quality panel or an embedded external URL — and nudges moderators when receipts are waiting.",
      "comments": "`beforeModel` re-runs `auth.load()` because dashboards and the centre are often not in the store yet right after login. The panel value is validated against `enums/dashboard-panels` into `isValidDashboard`, and the controller branches on `isCustomDashboard` / `isExternalUrlDashboard`. For external-URL dashboards the page header is hidden (`pageHeader.isHidden`) and restored in `deactivate`. The pending-receipts modal is gated on a `canShowReceiptsModal` sessionStorage flag and shown once, 3 seconds after load, only if the query returns at least one pending receipt.",
      "useCases": [
        "A CRM manager landing on registration-quality KPIs each morning.",
        "A centre embedding an external BI dashboard as the default home screen.",
        "Reminding moderators that receipts are queued for verification."
      ],
      "happyPaths": [
        {
          "name": "Custom home dashboard",
          "steps": [
            "User logs in and lands on `index`.",
            "A dashboard with `category: 'home'` and `panel: 'custom'` is found.",
            "Its configured filters are pushed into the controller models and `ctrl.settle()` runs.",
            "Charts and KPI tiles render with the current month's date range as the default."
          ]
        },
        {
          "name": "Receipts nudge",
          "steps": [
            "An admin/manager/crmmanager logs in with `canShowReceiptsModal` still true.",
            "A query finds pending receipts for the centre.",
            "After 3 seconds the modal opens and the flag is set to false so it does not reappear this session."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "No home dashboard configured",
          "cause": "No dashboard has `category: 'home'`.",
          "handling": "`hasDashboard` is false and the desktop renders empty — the page header falls back to `title.header.dashboards.show`."
        },
        {
          "name": "Unknown panel type",
          "cause": "`dashboard.panel` is not one of data-quality / custom / url.",
          "handling": "`isValidDashboard` is false, so the controller refuses to render the panel."
        },
        {
          "name": "External dashboard URL unreachable",
          "cause": "Embedded URL is wrong or the provider is down.",
          "handling": "`dashboard.error.not-found-external-dashboard`; in older browsers the iframe fallback copy `error_browser_iframe` applies."
        },
        {
          "name": "Receipt query fails",
          "cause": "API error while counting pending receipts.",
          "handling": "`showModalReceipts` is fired without awaiting and has no catch, so the rejection is unhandled — the modal just never appears."
        }
      ],
      "i18nKeys": [
        "title.header.desktop",
        "title.header.dashboards.show",
        "dashboard.error.not-found-external-dashboard",
        "error_browser_iframe"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-customer-list",
      "name": "Customer list & filtering",
      "area": "CRM",
      "label": "title.header.crm.users / menu.navbar.customer.show",
      "routes": [
        "crm.users",
        "crm.users.index"
      ],
      "code": [
        "app/controllers/crm/users/index.js",
        "app/routes/crm/users/index.js",
        "app/enums/user-filters.js",
        "app/enums/user-group-filters.js",
        "app/mixins/list-mixin.js",
        "app/mixins/segment-filter-mixin.js"
      ],
      "models": [
        "user",
        "segment",
        "building",
        "channel"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "crmoperator",
        "weboperator",
        "content_creator"
      ],
      "description": "Paginated, sortable, filterable list of the centre's registered customers showing code, name, surname, email, VAT number and phone, with row actions for children, edit and delete.",
      "comments": "Built on `ListMixin` + `SegmentFilterMixin`. The centre is pinned as a fixed filter (`fixedFilters.center_id`) in `init`, default sort is `-updated-at`, and the query forwards a `meta.page.token` when the API returns one — a cursor-style pagination for large datasets. The available filters come from `enums/user-filters` run through `beautifyFilters(center)` and `groupFilterByModule`, so the filter palette itself changes with the centre's enabled modules. A side panel can list the segments a filtered set belongs to (`fetchSegments`, independently paginated at 16 per page). `resetFilters` clears the pagination token as well as the filters.",
      "useCases": [
        "Finding one customer by email or VAT number to resolve a support query.",
        "Building an ad-hoc audience (e.g. women 25-34 in a postal code with marketing consent) before saving it as a segment.",
        "Checking which segments a given filtered population already belongs to.",
        "Jumping from a customer to their registered children for the play centre."
      ],
      "happyPaths": [
        {
          "name": "Filter and page through customers",
          "steps": [
            "User opens CRM → Customer list; the first page loads scoped to the active centre, newest first.",
            "User adds one or more filters from the grouped filter palette and applies them.",
            "The query is rebuilt with `filter`, `page` and `sort`; results refresh.",
            "User pages forward; the API's `page.token` is forwarded so deep pages stay consistent."
          ]
        },
        {
          "name": "Inspect segments of a filtered set",
          "steps": [
            "With filters applied, the user opens the segments panel.",
            "`fetchSegments` queries segments with the same fixed filters and fills a separate paginated table."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Filter left with an empty value",
          "cause": "A filter row is added but no value is chosen.",
          "handling": "`segment-filters#applyFilters` detects the void value, sets `showFilterWarning` and refuses to apply — no request is sent."
        },
        {
          "name": "Required filter missing",
          "cause": "The filter palette marks some options `required` and none of them is present.",
          "handling": "`hasValidFilters` is false, blocking apply/save."
        },
        {
          "name": "Row action invoked without a record",
          "cause": "`showChildren`/`delete` called with a missing model or id.",
          "handling": "Throws `TypeError('\\`model\\` is not a valid user')` — a programming guard rather than user-facing copy."
        },
        {
          "name": "Deep pagination drift",
          "cause": "Records change between page requests while a token is in use.",
          "handling": "The token is passed through unchanged; stale tokens surface as an API error with no dedicated message."
        }
      ],
      "i18nKeys": [
        "title.header.crm.users",
        "menu.navbar.customer.show",
        "crm.users.code",
        "crm.users.name",
        "crm.users.last_name",
        "crm.users.email",
        "crm.users.vat-number",
        "crm.users.phone",
        "show-children"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-customer-form",
      "name": "Customer create & edit",
      "area": "CRM",
      "label": "crm.new-user / users.general_data",
      "routes": [
        "crm.users.new",
        "crm.users.edit"
      ],
      "code": [
        "app/controllers/crm/users/new.js",
        "app/routes/crm/users/new.js",
        "app/routes/crm/users/edit.js",
        "app/components/form-user.js",
        "app/models/user.js"
      ],
      "models": [
        "user",
        "campaign",
        "channel",
        "user-type",
        "extra"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "crmoperator",
        "weboperator"
      ],
      "description": "Tabbed customer record: general data (identity, contact, address, consents, tags, manual campaigns) plus receipts and loyalty tabs once the customer exists.",
      "comments": "Tabs are computed in `form-user.js`: 'general' always, then 'receipts' and 'loyalty' only when `model.id` exists AND the centre has the tickets or loyalty-cards module. Required fields are partly centre-driven — `auth.center.userRequired` injects extra presence validations at runtime, so the same form has different mandatory fields per centre. `name` and `cp` are only required on create (`if: _this.isNew`); for existing records the controller instead shows a soft warning listing the missing recommended fields. Saving is two-stage: `saveUser` validates, then `_hasAcceptedAllNotifications()` opens a consent modal when any of mail / partner-mail / app notification consents is unchecked, and only `patchUser` actually persists. Email is also validated live on change via `emailRegexValidator`. A Pusher channel keyed on the user id streams in a captured signature URL, and `willDestroyElement` disconnects it. Existing customers show an identity QR code encoding `user_id:<id>` (download button hidden) next to the captured signature, so a desk device can scan a customer straight into their record.",
      "useCases": [
        "Registering a walk-in customer at the information desk, including GDPR consent.",
        "Updating contact details or consents on request.",
        "Attaching user tags or manual (non-segmented) campaigns to a specific customer.",
        "Recording single-parent / large-family status for benefit schemes.",
        "Capturing a handwritten signature from a tablet and seeing it appear live on the record."
      ],
      "happyPaths": [
        {
          "name": "New customer saved with full consent",
          "steps": [
            "Operator opens CRM → new customer and fills registration date, email, name, postal code and the centre's extra required fields.",
            "GDPR (`eula`) and the notification consents are ticked.",
            "`saveUser` validates the model successfully and `_hasAcceptedAllNotifications()` returns true.",
            "`patchUser` saves, tracks `CRM_USER_SAVED`, shows `crm.users.user-saved` and scrolls to the top."
          ]
        },
        {
          "name": "Existing customer edited with partial consent",
          "steps": [
            "Operator edits a record and leaves third-party notifications unchecked.",
            "The consent modal lists the unchecked statements.",
            "Operator confirms; `patchUser` runs and the record is saved as-is."
          ]
        },
        {
          "name": "Signature captured remotely",
          "steps": [
            "Operator opens an existing customer; `pusher.connectToChannel(user.id)` subscribes.",
            "The customer signs on a paired device.",
            "The pushed URL is written to `model.signatureUrl` and the signature renders without a reload."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Missing mandatory fields",
          "cause": "Email, registration date or channels blank; name/postal code blank on create.",
          "handling": "`user.validate()` fails, `parseErrors` maps the messages to fields (`error.user.email.required`, `error.user.registration-date.required`, `error.user.name.required`, `error.user.cp.required`, `error.channels.required`) and the page scrolls to the top."
        },
        {
          "name": "Invalid email format",
          "cause": "Address fails the email validator.",
          "handling": "`form-user.not-valid.email` is set on change and again by the model's `email` rule on submit."
        },
        {
          "name": "GDPR not accepted",
          "cause": "`eula` false.",
          "handling": "Custom validation fails with `error.user.gdpr`."
        },
        {
          "name": "Centre-specific required field left blank",
          "cause": "A field listed in `center.userRequired` (e.g. `commerce`) is empty.",
          "handling": "A presence rule is generated at runtime with the generic `required` message; note only `commerce` has a translated attribute label, so other dynamic fields show an untranslated attribute name."
        },
        {
          "name": "Consent modal declined",
          "cause": "User closes the notifications modal instead of confirming.",
          "handling": "`onCloseNotificationsModal` only closes it — the record stays unsaved, which can read as a silent no-op."
        },
        {
          "name": "API rejection on save",
          "cause": "Server-side conflict (e.g. duplicate email) or validation.",
          "handling": "The catch block populates `infoErrors` from `parsedErrors.data` or `errors.errors[].detail`, sets `crm.users.user-save-errors` as the banner and scrolls up."
        },
        {
          "name": "External sync failure",
          "cause": "Downstream integration (Juguettos Fusion) rejects the create/update.",
          "handling": "`user.fusion-error` / `user.fusion-error-update` are surfaced; the local record may already be saved, so the two systems can diverge."
        },
        {
          "name": "Registration email resend fails",
          "cause": "API error on resending the registration email.",
          "handling": "`user.registration-email-error`."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `access-to-account-types.client`, `access-to-account-types.employee-outsource`, `access-to-account-types.employee-shop` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "crm.new-user",
        "users.general_data",
        "crm.users.user-saved",
        "crm.users.user-save-errors",
        "error.user.email.required",
        "error.user.name.required",
        "error.user.cp.required",
        "error.user.gdpr",
        "error.user.registration-date.required",
        "error.channels.required",
        "form-user.not-valid.email",
        "user.fusion-error",
        "user.fusion-error-update",
        "user.registration-email-error",
        "error.signature.not_exist"
      ],
      "i18nMissing": [
        "access-to-account-types.client",
        "access-to-account-types.employee-outsource",
        "access-to-account-types.employee-shop"
      ]
    },
    {
      "id": "crm-customer-delete",
      "name": "Customer deletion with unsubscribe notice",
      "area": "CRM",
      "label": "delete",
      "routes": [
        "crm.users.index"
      ],
      "code": [
        "app/controllers/crm/users/index.js",
        "app/routes/crm/users/index.js"
      ],
      "models": [
        "user"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin",
        "manager (per `user` ability `canDelete`)"
      ],
      "description": "Deletes a customer record and, unless the operator opts out, posts to `users_unsubscribe_send` so the person receives an unsubscribe/erasure notification.",
      "comments": "`sendRemoveEmail` defaults to true and is reset to true in `finally`, so the opt-out is per-deletion rather than sticky. The notification is a raw `fetch` to `/api/v4/users_unsubscribe_send` with the bearer token — fire-and-forget, with no `.then`/`.catch`, so its outcome is invisible to the operator. Deletion itself goes through `destroyRecord()`.",
      "useCases": [
        "Honouring a GDPR erasure request and confirming it to the customer by email.",
        "Removing a duplicate or test registration without emailing anyone (opt-out ticked)."
      ],
      "happyPaths": [
        {
          "name": "Delete and notify",
          "steps": [
            "Operator clicks delete on a row and confirms in the deletion modal.",
            "`destroyRecord()` removes the customer.",
            "Because `sendRemoveEmail` is true, an unsubscribe notification is posted for that user id.",
            "The flag resets to true for the next deletion."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Invalid model",
          "cause": "`delete` called without a model or id.",
          "handling": "Throws `TypeError('model' is not a valid user)`."
        },
        {
          "name": "Delete rejected by the API",
          "cause": "Referential constraints or permissions.",
          "handling": "`destroyRecord()` rejects; the `.then` that sends the email is skipped, but the `finally` still resets the flag. There is no explicit user-facing error for this branch."
        },
        {
          "name": "Unsubscribe email never sent",
          "cause": "The fire-and-forget fetch fails (network, 4xx/5xx).",
          "handling": "Unhandled and unreported — the customer is deleted but never notified. A compliance-relevant blind spot."
        }
      ],
      "i18nKeys": [
        "delete"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-customer-export",
      "name": "Customer CSV export with column picker",
      "area": "CRM",
      "label": "informative-message.export-crm-csv.success.message",
      "routes": [
        "crm.users.index"
      ],
      "code": [
        "app/controllers/crm/users/index.js",
        "app/services/load.js",
        "app/mixins/export-csv.js"
      ],
      "models": [
        "user"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin",
        "manager (`user` ability `canExport`)"
      ],
      "description": "Exports the currently filtered customer set as CSV, letting the operator choose from ~30 columns. The file is produced server-side and delivered by email.",
      "comments": "The default column set is computed in `userExportColumns` and grows with the centre's modules: `account_type` when `hasEmployeesSegments`, `calculated_income` + `calculated_postal_code` when `hasForegroundLocation`. `toggleUserExportColumns` flips individual columns. `load.export()` POSTs to `/api/v4/exports` with the model name, the active filters (minus `type`, which is sent separately as `filter-type`), the chosen `csv-fields` and the centre id (used as the email 'from'), and tracks a `DATA_EXPORTED` analytics event.",
      "useCases": [
        "Handing a filtered audience to an external mailing tool.",
        "Pulling a data-quality extract to see how many records lack a phone or postal code.",
        "Exporting employee-segment populations where account type matters."
      ],
      "happyPaths": [
        {
          "name": "Filtered export emailed",
          "steps": [
            "Operator filters the list, then opens the export modal (`openUserExportModal`).",
            "Columns are pre-ticked from the module-aware default set; operator adjusts them.",
            "`exportUserCsv` closes the modal, sets the loading flag and calls `load.export('user', filters, columns)`.",
            "On resolve the success banner `informative-message.export-crm-csv.success.message` appears and the file arrives by email."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Export request fails",
          "cause": "API error on `/api/v4/exports`.",
          "handling": "`exportUserCsv` has no `.catch()`, so `isExportLoading` stays true and the spinner never clears — the operator is left waiting with no error message."
        },
        {
          "name": "Email never arrives",
          "cause": "Export job fails asynchronously or the mail is filtered.",
          "handling": "The UI already reported success; there is no job-status surface in the frontend."
        },
        {
          "name": "No columns selected",
          "cause": "Operator unticks every column.",
          "handling": "Not validated client-side — an empty `csv-fields` array is sent and the outcome depends entirely on the API."
        }
      ],
      "i18nKeys": [
        "informative-message.export-crm-csv.success.message"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-customer-import",
      "name": "Customer CSV import",
      "area": "CRM",
      "label": "import-csv.column-database",
      "routes": [
        "crm.users.import"
      ],
      "code": [
        "app/controllers/crm/users/import.js",
        "app/routes/crm/users/import.js",
        "app/components/nf-import.js",
        "app/components/import-csv.js",
        "app/models/load.js",
        "app/enums/csv-examples.js"
      ],
      "models": [
        "load",
        "user",
        "channel",
        "campaign",
        "user-account-type"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin (`user` ability `canImport`)"
      ],
      "description": "Bulk customer create/update from a CSV: the operator uploads the file, maps each CSV column to one of ~40 user attributes, picks the match key (id / email / VAT number), separator, date format, consent defaults and channels, then runs the import.",
      "comments": "The mapping table auto-matches CSV headers whose text equals an attribute value, so a template-shaped file needs no manual mapping. `modelExample` serves a different downloadable template when the centre has employee segments (`baseContentCreatorCSV(userTypeCodes)` instead of `baseCSV`). Validation lives on the `load` model: no duplicate column mappings, a date format when `register_date`/`bdate` are mapped, and at least one channel when importing users. `nf-import` adds a pre-flight (`_validateSelectionsAndShowErrors`) that collects the same issues into a summary banner before the model validation runs, and `update: true` controls insert-only vs update-and-insert.",
      "useCases": [
        "Onboarding a centre by loading its historical customer database.",
        "Periodic sync from a POS or legacy CRM keyed on email or VAT number.",
        "Bulk-assigning user tags or account types to an existing population."
      ],
      "happyPaths": [
        {
          "name": "Template-shaped import",
          "steps": [
            "Operator downloads the example CSV, fills it and uploads it.",
            "The file is parsed (`csv.parseCSV`, `;` delimiter) and simultaneously uploaded to storage (`apiFiles.uploadFile`).",
            "Columns auto-map; operator picks the match key, date format and channels.",
            "`load.validate()` passes, `load.save()` queues the job and the success state is reported."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Same attribute mapped twice",
          "cause": "Two CSV columns point at one database field.",
          "handling": "`csvFields` custom validation fails with `error.load.repeated-fields`; the modal scrolls to the top to show it."
        },
        {
          "name": "Date columns without a format",
          "cause": "`register_date` or `bdate` mapped but no date format chosen.",
          "handling": "`error.load.format-date.required` / `import-csv.errors.load.format-date.required`."
        },
        {
          "name": "No channel selected",
          "cause": "Importing users without `commons.channels`.",
          "handling": "`error.load.channels.required` / `import-csv.errors.load.channels.required`."
        },
        {
          "name": "Required selections incomplete",
          "cause": "Pre-flight finds any of the above.",
          "handling": "`import-csv.errors.load.required-selections` is shown as the banner — note this key's value is in Portuguese while the rest of the file is English, a localisation inconsistency."
        },
        {
          "name": "Server-side row errors",
          "cause": "Rows rejected during processing.",
          "handling": "`informative-message.import-csv.error.title` + `informative-message.import-csv.error` list the errors returned by the API."
        },
        {
          "name": "Malformed CSV / wrong separator",
          "cause": "File does not parse into a header row.",
          "handling": "`parseCSV` throws inside an `async` action; `nf-import` wraps its import in try/catch and reads `error.errors[0].status`, but a parse failure in `import-csv.js` is unguarded and leaves `isLoading` true."
        },
        {
          "name": "Upload rejected",
          "cause": "File type or size refused by storage.",
          "handling": "`error.file.type.not_allowed`, `error.file.oversize`, `error.file.not_allowed`."
        }
      ],
      "i18nKeys": [
        "import-csv.column-csv",
        "import-csv.column-database",
        "error.load.repeated-fields",
        "error.load.format-date.required",
        "error.load.channels.required",
        "import-csv.errors.load.required-selections",
        "informative-message.import-csv.error",
        "import-csv.update-options.only-insert",
        "import-csv.update-options.update-insert"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-children-import",
      "name": "Children CSV import",
      "area": "CRM",
      "label": "user.relationship",
      "routes": [
        "crm.users.import-children"
      ],
      "code": [
        "app/controllers/crm/users/import-children.js",
        "app/routes/crm/users/import-children.js",
        "app/components/nf-import.js"
      ],
      "models": [
        "load",
        "child"
      ],
      "modules": [
        "crm",
        "playcenter"
      ],
      "roles": [
        "root",
        "admin"
      ],
      "description": "Bulk import of customers' children (dependants) keyed on the parent's id or email, carrying name, surname, birth date, gender, relationship and notes.",
      "comments": "A narrower variant of the customer import: `modelName` is `child`, the match options are only id and email (no VAT number), and the template is `baseChildrenCSV`. Reached from the customer list via the `navigateToImportChildren` action, and gated by `canImportChildren` in `nf-import`.",
      "useCases": [
        "Loading existing play-centre registrations when a centre adopts the module.",
        "Keeping dependants in sync with an external kids-club system."
      ],
      "happyPaths": [
        {
          "name": "Children loaded against existing parents",
          "steps": [
            "Operator opens the children import from the customer list.",
            "Uploads a CSV whose rows carry the parent's email plus the child's details.",
            "Columns map, match key set to email, date format chosen for `bdate`.",
            "Import runs and the children appear under each customer."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Parent not found",
          "cause": "The id/email in a row matches no customer.",
          "handling": "Rejected server-side and reported in the import error list; the frontend does not pre-check."
        },
        {
          "name": "Missing birth date format",
          "cause": "`bdate` mapped without a date format.",
          "handling": "`error.load.format-date.required`."
        },
        {
          "name": "Duplicate column mapping",
          "cause": "Two columns mapped to the same attribute.",
          "handling": "`error.load.repeated-fields`."
        }
      ],
      "i18nKeys": [
        "user.relationship",
        "user.match-options.id",
        "user.match-options.email"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-ai-filter",
      "name": "AI natural-language customer filter",
      "area": "CRM",
      "label": "ai.error",
      "routes": [
        "crm.users.index",
        "crm.segments.new"
      ],
      "code": [
        "app/components/nf-ai-filter.js",
        "app/models/module-ai.js",
        "app/utils/filter.js"
      ],
      "models": [
        "user"
      ],
      "modules": [
        "ai"
      ],
      "roles": [
        "as per customer list / segment builder"
      ],
      "description": "A prompt box that turns a plain-language audience description into structured customer filters, which are then prepended to whatever filters are already applied.",
      "comments": "Only available when `auth.center.hasAIUserFilter`. The prompt is sent as a query parameter to `/api/v4/client_filter`, and the JSON returned is reshaped into `Filter` objects, each resolved to a group filter through `userFilters` → `groupFilterCode` → `groupFilters`. If any filter cannot be matched to a group, the code throws and reports to Sentry. Privacy-conscious by design: the analytics events (`AI_FILTER_REQUESTED`, `AI_FILTER_SUCCEEDED`, `AI_FILTER_FAILED`) deliberately send only the prompt *length*, filter count and filter types — never the prompt text, because it may contain customer data. Note the prompt is interpolated into the URL without `encodeURIComponent`, so prompts containing `&` or `#` will be truncated.",
      "useCases": [
        "A marketer describing an audience in words instead of assembling six filter rows by hand.",
        "Quickly exploring a population before saving the result as a segment."
      ],
      "happyPaths": [
        {
          "name": "Prompt converted to filters",
          "steps": [
            "User types e.g. 'women between 25 and 34 with a valid email in Madrid'.",
            "`_fetchClientFilter` calls the API and parses the returned filter JSON.",
            "Each entry is mapped to a `Filter` with its group, operation and values.",
            "`onApplyFilter` prepends them to the existing filters and `AI_FILTER_SUCCEEDED` is tracked."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Empty prompt",
          "cause": "Nothing typed.",
          "handling": "`isDisabled` keeps the apply button inactive."
        },
        {
          "name": "No usable filter returned",
          "cause": "API responds non-OK.",
          "handling": "`error` is set and `{}` returned; the UI shows `ai.error` ('No valid filter found')."
        },
        {
          "name": "Unmappable filter key",
          "cause": "A returned filter key has no matching entry in `enums/user-filters`, so its group filter is undefined.",
          "handling": "`getFilters` throws, reports `GPT filter error` to Sentry with the prompt and filter payload, sets `error` and returns `[]`. Note this Sentry capture *does* include the prompt text, which is at odds with the deliberate no-prompt policy in the analytics calls."
        },
        {
          "name": "Network failure",
          "cause": "Fetch rejects.",
          "handling": "`AI_FILTER_FAILED` is tracked with `reason: 'network'` and the error is re-thrown, so `isLoading` is left true and the spinner sticks."
        },
        {
          "name": "Prompt containing URL-special characters",
          "cause": "Prompt is concatenated into the query string unescaped.",
          "handling": "Not handled — the prompt is silently truncated at `&`/`#`, producing a confusingly wrong filter."
        }
      ],
      "i18nKeys": [
        "ai.error",
        "modules-ai"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-save-filters-as-segment",
      "name": "Save current filters as a segment",
      "area": "CRM",
      "label": "crm.new-segment",
      "routes": [
        "crm.users.index",
        "crm.segments.new"
      ],
      "code": [
        "app/controllers/crm/users/index.js",
        "app/mixins/segment-filter-mixin.js",
        "app/components/segment-filters.js"
      ],
      "models": [
        "segment"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin",
        "manager, crmmanager (`segment` ability `canCreate`)"
      ],
      "description": "Turns the ad-hoc filter set from the customer list into a reusable, named segment by handing the serialised filters to the segment builder.",
      "comments": "`saveSegment(filters)` transitions to `crm.segments.new` with `filtersJSON` and `filterType` as query params — so the segment builder opens pre-populated. `filterType` carries the and/or combinator from `enums/segment-filters-types`.",
      "useCases": [
        "Promoting an exploratory audience into a segment used by email campaigns, push notifications and proposals.",
        "Standardising a recurring audience definition so colleagues reuse it instead of re-filtering."
      ],
      "happyPaths": [
        {
          "name": "Filters promoted to a segment",
          "steps": [
            "User applies filters on the customer list and clicks save as segment.",
            "Filters are JSON-serialised into the query params and the segment builder opens.",
            "User names the segment and submits; the segment is created with the same filter definition."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Void filter values",
          "cause": "A filter row with no value.",
          "handling": "`applyFilters`/`saveChanges` set `showFilterWarning` first; the transition is only offered from an applied state."
        },
        {
          "name": "Segment name missing",
          "cause": "`segmentName` blank on submit.",
          "handling": "The segment model's presence rule fails with the generic `required` message and `_scrollTop()` brings the error into view."
        }
      ],
      "i18nKeys": [
        "crm.new-segment",
        "all",
        "any-of"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-segment-list",
      "name": "Segment list, refresh & export",
      "area": "CRM",
      "label": "title.header.crm.segments / menu.navbar.segment.show",
      "routes": [
        "crm.segments",
        "crm.segments.index"
      ],
      "code": [
        "app/controllers/crm/segments/index.js",
        "app/routes/crm/segments/index.js"
      ],
      "models": [
        "segment"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "crmoperator",
        "weboperator",
        "content_creator"
      ],
      "description": "Lists segments with their category, account types, consent flags, automatic tags and member count, and offers per-row recalculation and CSV export of the members.",
      "comments": "`refreshSegment` forces a server-side recount — the UI shows `segment.calculating-user-info` while it runs. `exportSegment` reuses the shared `load.export` pipeline with the segment's own column set. `hasEmployeeSegments` toggles the account-types column. Some segments are flagged non-editable (`crm.segment.current-segment-is-not-editable`) and segments bound to a user tag warn with `crm.segment.tag-associated` before deletion.",
      "useCases": [
        "Checking how many customers a segment currently reaches before launching a campaign.",
        "Recomputing a segment after a large import so campaign audiences are accurate.",
        "Exporting a segment's members for an external channel."
      ],
      "happyPaths": [
        {
          "name": "Recalculate a segment",
          "steps": [
            "User clicks refresh on a segment row.",
            "The recalculation request is sent and the row shows the calculating state.",
            "On completion the member count updates in place."
          ]
        },
        {
          "name": "Export segment members",
          "steps": [
            "User clicks export on a row.",
            "`load.export` is called with the segment's filters and columns and the CSV is emailed."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Refresh fails",
          "cause": "API error during recalculation.",
          "handling": "A bare `.catch(() => {})` swallows it — the row leaves the calculating state with the old count and no error message."
        },
        {
          "name": "Segment is locked",
          "cause": "The segment is one of the non-editable system segments.",
          "handling": "`crm.segment.current-segment-is-not-editable` is shown and edit is blocked."
        },
        {
          "name": "Deleting a tag-linked segment",
          "cause": "The segment is associated with a user tag.",
          "handling": "`crm.segment.tag-associated` warns the operator, since deletion would affect the tag's automatic assignment."
        },
        {
          "name": "Export error",
          "cause": "Export request rejects.",
          "handling": "Caught in a `try/catch` with no user-facing message."
        }
      ],
      "i18nKeys": [
        "title.header.crm.segments",
        "segment.refresh-segment",
        "segment.export-segment",
        "segment.users-in-segment",
        "segment.calculating-user-info",
        "crm.segment.current-segment-is-not-editable",
        "crm.segment.tag-associated"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-segment-builder",
      "name": "Segment builder with automatic tag actions",
      "area": "CRM",
      "label": "crm.new-segment",
      "routes": [
        "crm.segments.new",
        "crm.segments.edit"
      ],
      "code": [
        "app/controllers/crm/segments/new.js",
        "app/routes/crm/segments/new.js",
        "app/models/segment.js",
        "app/models/usertype-linked-segment.js",
        "app/components/segment-filters.js",
        "app/enums/automatic-action-types.js"
      ],
      "models": [
        "segment",
        "user",
        "user-type",
        "usertype-linked-segment",
        "building",
        "user-account-type"
      ],
      "modules": [
        "crm",
        "employees_segments (optional)",
        "ai (optional)"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager (`segment` ability `canCreate`/`canEdit`)"
      ],
      "description": "Two-tab editor for a segment: general data (name, category, account types) plus a live filter builder that previews the matching customers, with optional rules that automatically add or remove user tags as people enter or leave the segment.",
      "comments": "The preview table reuses the customer columns, and the same AI filter and export affordances as the customer list are available here. `addNewAutomaticActionType` / `removeAutomaticAction` manage `usertypeLinkedSegments` fragments, each needing both a `usertype` and a `dynamic` flag — the model validates this with a single combined message. `isLifeCycleSegment` flags segments driven by lifecycle rules. When the centre has employee segments, at least one account type is mandatory (`userAccountTypes.length.minimum 1`). New user tags can be created inline (`addNewUserType`), which validates the tag model separately into `userTypeErrors`.",
      "useCases": [
        "Defining 'families with children under 12 who shopped in the last 90 days' once and reusing it everywhere.",
        "Auto-tagging customers as VIP when they enter a segment and untagging them when they leave.",
        "Restricting a segment to employee account types in centres with employee segmentation."
      ],
      "happyPaths": [
        {
          "name": "Segment created with an automatic tag rule",
          "steps": [
            "User names the segment, picks a category and (if required) account types.",
            "Filters are assembled in the builder; the preview refreshes with matching customers.",
            "An automatic action is added: tag X, dynamic on.",
            "`submit` validates the segment and the linked-segment fragments and saves it."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Name missing",
          "cause": "`segmentName` blank.",
          "handling": "Presence rule fails; `_scrollTop()` scrolls the error into view."
        },
        {
          "name": "Account types missing",
          "cause": "Employee-segments centre with no account type selected.",
          "handling": "Minimum-length rule fails with `required` + `user.account-type`."
        },
        {
          "name": "Incomplete automatic action",
          "cause": "A `usertypeLinkedSegments` row lacks `usertype` or `dynamic`.",
          "handling": "`error.user.automatic-action-type` ('Remember that you have to fill in all the fields') — one message for the whole collection, so the operator is not told which row is wrong."
        },
        {
          "name": "Inline user-tag creation invalid",
          "cause": "The new tag fails its own validations.",
          "handling": "`userTypeErrors` is populated via `parseErrors` and shown on the inline form."
        },
        {
          "name": "Filters not applicable",
          "cause": "`isDisableApplyFilters` is true (incomplete filter rows).",
          "handling": "Apply is disabled; the preview is not refreshed."
        },
        {
          "name": "Save rejected",
          "cause": "API error.",
          "handling": "A bare `.catch(() => {})` in the submit chain hides the reason."
        }
      ],
      "i18nKeys": [
        "crm.new-segment",
        "general_data",
        "segment.segment-category",
        "segment.account-types",
        "segment.automatic-tags",
        "error.user.automatic-action-type",
        "segment.app-notifications",
        "segment.email-notifications"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-segment-categories",
      "name": "Segment categories",
      "area": "CRM",
      "label": "title.header.crm.segment-categories / menu.navbar.segment-categories.show",
      "routes": [
        "crm.segment-categories",
        "crm.segment-categories.index",
        "crm.segment-categories.new",
        "crm.segment-categories.edit"
      ],
      "code": [
        "app/controllers/crm/segment-categories/index.js",
        "app/routes/crm/segment-categories/*.js",
        "app/models/segment-category.js"
      ],
      "models": [
        "segment-category"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager"
      ],
      "description": "Simple CRUD for the taxonomy used to group segments, so long segment lists stay navigable.",
      "comments": "A minimal list (name + edit/delete) built on the standard `nf-crud` + `routes/form.js` pattern; deletion goes through the shared confirm-deletion service.",
      "useCases": [
        "Separating lifecycle segments from campaign segments and employee segments.",
        "Keeping a centre's segment list organised as it grows."
      ],
      "happyPaths": [
        {
          "name": "Category created and assigned",
          "steps": [
            "User creates a category with a name.",
            "`routes/form.js#saveModel` validates and saves, then returns to the list.",
            "The category becomes selectable in the segment builder."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Name missing",
          "cause": "Blank name on save.",
          "handling": "Model validation rejects and `saveModel` surfaces the field error."
        },
        {
          "name": "Deleting a category still in use",
          "cause": "Segments still reference it.",
          "handling": "Rejected by the API; `routes/form.js` sets `errorMessage` from `response.errors[0].detail`."
        }
      ],
      "i18nKeys": [
        "title.header.crm.segment-categories",
        "segment-categories.new"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-user-types",
      "name": "User tags (user types)",
      "area": "CRM",
      "label": "menu.navbar.tag.show / crm.tags",
      "routes": [
        "crm.user-types.index",
        "crm.user-types.new",
        "crm.user-types.edit"
      ],
      "code": [
        "app/controllers/crm/user-types/index.js",
        "app/routes/crm/user-types/*.js",
        "app/components/form-user-type.js",
        "app/models/user-type.js",
        "app/abilities/user-type.js"
      ],
      "models": [
        "user-type"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "weboperator"
      ],
      "description": "Manages the labels that can be attached to customers, with flags controlling whether a tag is an interest, whether it is visible in the back office and whether it is visible in the mobile app.",
      "comments": "The list surfaces code, name and three visibility flags (`is-interest`, `is-visible`, `is-visible-on-app`, plus an `is-not-visible` state). Tags are assignable manually on the customer form and automatically through segment rules (`usertype-linked-segment`), so deleting one has reach beyond the tag list. Deletion uses the `delete` / `cancelRemove` / `onShowDeleteModal` trio common to the simpler CRUD controllers.",
      "useCases": [
        "Marking interests (e.g. 'cinema', 'kids') that the app exposes as preference chips.",
        "Flagging internal-only attributes that should never show in the app.",
        "Driving automatic tagging from segment membership."
      ],
      "happyPaths": [
        {
          "name": "App-visible interest tag created",
          "steps": [
            "User creates a tag with code and name and ticks interest + visible on app.",
            "The tag saves and appears in the customer form and in the app's interest picker.",
            "A segment rule can now add it automatically."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Invalid model on delete",
          "cause": "`delete` invoked without a valid record.",
          "handling": "Throws `TypeError('model' is not a valid user-type)`."
        },
        {
          "name": "Deleting a tag bound to a segment",
          "cause": "The tag is referenced by an automatic segment action.",
          "handling": "The segment list warns with `crm.segment.tag-associated`; the API is the final arbiter."
        },
        {
          "name": "Duplicate code",
          "cause": "Code already used in the centre.",
          "handling": "Server-side rejection surfaced through the generic form error path."
        }
      ],
      "i18nKeys": [
        "menu.navbar.tag.show",
        "crm.tags",
        "crm.tag.new",
        "crm.tags.code",
        "crm.tags.is-interest",
        "crm.tags.is-visible",
        "crm.tags.is-visible-on-app",
        "crm.tags.is-not-visible"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-customer-receipts-tab",
      "name": "Customer receipts tab & ticket/stamp generation",
      "area": "CRM",
      "label": "users.receipts",
      "routes": [
        "crm.users.edit"
      ],
      "code": [
        "app/components/form-user/receipts.js",
        "app/models/receipt.js",
        "app/models/ticket.js"
      ],
      "models": [
        "receipt",
        "ticket",
        "campaign",
        "commerce"
      ],
      "modules": [
        "tickets",
        "loyalty-cards"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "crmoperator",
        "weboperator"
      ],
      "description": "Inside an existing customer record: register purchase receipts (date, code, amount, store, campaign) and generate the raffle tickets or loyalty stamps they earn, with a preview of what will be created before confirming.",
      "comments": "Only present when the centre has the tickets or loyalty-cards module and the customer already exists. `activeCampaigns` restricts which campaigns a receipt can be attributed to. The preview tables (`ticketsPreviewColumns`, `stampsPreviewColumns`) show the computed entitlement — including accumulated and pending balance — before `generateTickets` commits. Receipt validation errors are bubbled up to the parent form through `onReceiptErrors`, so the receipts tab shows an error badge. Submission wraps `receipt.validate()` in an explicit promise that rejects with the model errors.",
      "useCases": [
        "Desk staff entering a paper receipt a customer brings in so they get their raffle entries.",
        "Awarding loyalty-card stamps for a qualifying purchase.",
        "Correcting or cancelling a wrongly entered receipt."
      ],
      "happyPaths": [
        {
          "name": "Receipt registered and tickets generated",
          "steps": [
            "Operator opens the receipts tab on a customer and adds a receipt with date, code, amount and store.",
            "A campaign is selected from the active list; the accumulated/pending balance updates.",
            "`submitReceipt` validates and saves the receipt.",
            "Operator opens the generate-tickets modal, reviews the preview and confirms.",
            "`ticket.generate-success-message` is shown and the tickets appear under the loyalty tab."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Receipt fields missing",
          "cause": "Date, amount, store or code blank.",
          "handling": "`error.tickets.date.required`, `error.tickets.amount.required`, `error.tickets.commerce.required`, `error.tickets.code.required`; the promise rejects with the model errors and the tab badge turns red."
        },
        {
          "name": "Ticket generation fails",
          "cause": "API error while creating tickets.",
          "handling": "`ticket.generate-error-message` — explicitly tells the operator nothing was created and to contact support."
        },
        {
          "name": "Invalid record on cancel",
          "cause": "`cancelReceipt` called with a bad model.",
          "handling": "Throws `TypeError('model' is not a valid receipt)`."
        },
        {
          "name": "No active campaign",
          "cause": "No campaign is currently open for attribution.",
          "handling": "The campaign picker is empty, so no entitlement can be generated — the receipt can still be stored."
        }
      ],
      "i18nKeys": [
        "users.receipts",
        "receipt.save-message",
        "receipt.campaign.accumulated-balance",
        "receipt.campaign.accumulative-amount",
        "receipt.pending-balance",
        "receipt.stamps-number",
        "ticket.generate-success-message",
        "ticket.generate-error-message",
        "error.tickets.date.required",
        "error.tickets.amount.required",
        "error.tickets.commerce.required",
        "error.tickets.code.required"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-customer-loyalty-raffles",
      "name": "Customer loyalty — raffle tickets",
      "area": "CRM",
      "label": "ticket.raffle",
      "routes": [
        "crm.users.edit"
      ],
      "code": [
        "app/components/form-user/loyalty.js",
        "app/components/form-user/loyalty/raffles.js"
      ],
      "models": [
        "ticket",
        "campaign"
      ],
      "modules": [
        "tickets"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "crmoperator",
        "weboperator"
      ],
      "description": "Read-oriented view of a customer's raffle entries per campaign: ticket code, originating receipt, store, rule, creation date, plus a per-rule resume with pending balance.",
      "comments": "One of four steps inside the loyalty tab (`loyalty.js` computes `raffleStepId`, `loyaltyCardStepId`, `rewardStepId`, `couponStepId` and shows only the ones the centre's modules allow). Campaign selection (`changeCampaign`) re-queries the tickets. The resume columns expose `ticket.pending-balance`, i.e. purchase value not yet converted into an entry.",
      "useCases": [
        "Answering 'how many entries do I have?' at the information desk.",
        "Auditing which receipt and rule produced a given ticket when a customer disputes a draw."
      ],
      "happyPaths": [
        {
          "name": "Review a customer's entries",
          "steps": [
            "Operator opens the loyalty tab and the raffle step.",
            "Picks a campaign; the ticket list and the per-rule resume load.",
            "Pending balance explains any purchase amount not yet converted."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Module disabled",
          "cause": "Centre has no tickets module.",
          "handling": "The raffle step is not offered at all."
        },
        {
          "name": "Ticket list fails to load",
          "cause": "API error on the campaign-scoped query.",
          "handling": "No dedicated message in this component — the table stays empty."
        }
      ],
      "i18nKeys": [
        "ticket.raffle",
        "ticket.code",
        "ticket.receipt-code",
        "ticket.rule",
        "ticket.tickets-number",
        "ticket.pending-balance",
        "ticket.commerce-name"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-customer-loyalty-cards",
      "name": "Customer loyalty — cards & stamps",
      "area": "CRM",
      "label": "ticket.loyalty-card",
      "routes": [
        "crm.users.edit"
      ],
      "code": [
        "app/components/form-user/loyalty/loyalty-cards.js"
      ],
      "models": [
        "campaign",
        "ticket",
        "loyalty-card"
      ],
      "modules": [
        "loyalty-cards"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "crmoperator",
        "weboperator"
      ],
      "description": "Shows the customer's loyalty cards per campaign with their stamp counts, and lets staff manually add stamps or exchange a completed card.",
      "comments": "`createStamps` is guarded by `validateStampNumber(amount, campaign)`, which refuses an amount larger than the campaign's stamp quota. `createStampsModalCampaigns` narrows the picker to campaigns that can still take stamps. `exchangeStamps` consumes a completed card. Unredeemed stamps are surfaced separately (`loyalty-cards.unredeemed-stamps`).",
      "useCases": [
        "Compensating a customer whose stamp was not registered at the till.",
        "Redeeming a completed card for its reward at the desk.",
        "Explaining to a customer how many stamps they still need."
      ],
      "happyPaths": [
        {
          "name": "Stamps granted manually",
          "steps": [
            "Operator opens the loyalty-cards step and selects a campaign.",
            "Opens the create-stamps modal and enters an amount within the campaign's quota.",
            "`validateStampNumber` passes and the stamps are created.",
            "`loyalty-cards.generate-success-message` confirms and the card's count updates."
          ]
        },
        {
          "name": "Completed card exchanged",
          "steps": [
            "Operator opens the exchange-stamps modal for a full card.",
            "Confirms; the stamps are consumed and the card moves to its exchanged state."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Too many stamps requested",
          "cause": "Amount exceeds the campaign's stamp quantity.",
          "handling": "`loyalty-cards.error-stamp-number` ('Number of stamps is greater than the quantity of stamps in the campaign'); nothing is created."
        },
        {
          "name": "Card generation fails",
          "cause": "API error while creating cards/stamps.",
          "handling": "`loyalty-cards.generate-error-message` states explicitly that nothing was created and to contact support."
        },
        {
          "name": "Exchange fails",
          "cause": "API error or already-exchanged card.",
          "handling": "Caught in `try/catch`; the modal closes without a specific message."
        }
      ],
      "i18nKeys": [
        "ticket.loyalty-card",
        "loyalty-cards",
        "loyalty-card.campaign",
        "loyalty-card.stamps.value",
        "loyalty-cards.unredeemed-stamps",
        "loyalty-cards.generate-success-message",
        "loyalty-cards.generate-error-message",
        "loyalty-cards.error-stamp-number"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-customer-loyalty-rewards",
      "name": "Customer loyalty — rewards",
      "area": "CRM",
      "label": "ticket.rewards",
      "routes": [
        "crm.users.edit"
      ],
      "code": [
        "app/components/form-user/loyalty/rewards.js",
        "app/models/reward.js",
        "app/abilities/reward.js"
      ],
      "models": [
        "reward",
        "campaign"
      ],
      "modules": [
        "rewards"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "crmoperator",
        "weboperator"
      ],
      "description": "Issue a reward to a customer against a campaign and then redeem, un-redeem or resend it, with code, validity window and exchange date on show.",
      "comments": "Four distinct mutations, each with its own success and error message: `redeem`, `unredeem`, `resend` and `cancelReward`. `submitReward` wraps `reward.validate()` in an explicit promise so a missing campaign or expiry date rejects before the request. `activeCampaigns` limits issuance to currently running campaigns. The presence of an `unredeem` action means redemption is reversible — important for till mistakes.",
      "useCases": [
        "Handing a customer their prize at the desk and marking it redeemed.",
        "Reversing a redemption that was registered by mistake.",
        "Resending a reward code the customer never received.",
        "Issuing a goodwill reward outside the normal earning rules."
      ],
      "happyPaths": [
        {
          "name": "Reward issued and redeemed",
          "steps": [
            "Operator picks an active campaign and an expiry (`validUntil`) and submits the reward.",
            "Validation passes and the reward is created with a code.",
            "Later the operator opens the redeem modal and confirms.",
            "`reward.redeem-success` is shown and the exchange date is recorded."
          ]
        },
        {
          "name": "Redemption reversed",
          "steps": [
            "Operator opens the un-redeem modal on a redeemed reward and confirms.",
            "`reward.unredeem-success` is shown and the reward becomes redeemable again."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Campaign or expiry missing",
          "cause": "`campaign` or `validUntil` blank.",
          "handling": "`error.reward.campaign.required` / `error.reward.validUntil.required`; the promise rejects with the model errors."
        },
        {
          "name": "Redeem fails",
          "cause": "API error or reward already redeemed/expired.",
          "handling": "`reward.redeem-error` ('please retry later')."
        },
        {
          "name": "Un-redeem fails",
          "cause": "API refuses the reversal.",
          "handling": "`reward.unredeem-error`."
        },
        {
          "name": "Resend fails",
          "cause": "Mail dispatch error.",
          "handling": "`reward.resend-error`."
        },
        {
          "name": "Cancel fails",
          "cause": "API error on delete.",
          "handling": "Bare `.catch(() => {})` — silent."
        }
      ],
      "i18nKeys": [
        "ticket.rewards",
        "reward.campaign",
        "reward.code",
        "reward.valid-from",
        "reward.valid-until",
        "reward.exchanged-at",
        "reward.redeem-success",
        "reward.redeem-error",
        "reward.unredeem-success",
        "reward.unredeem-error",
        "reward.resend-success",
        "reward.resend-error",
        "error.reward.campaign.required",
        "error.reward.validUntil.required"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-customer-coupon-exchanges",
      "name": "Customer loyalty — coupon exchanges",
      "area": "CRM",
      "label": "ticket.coupon-exchanges",
      "routes": [
        "crm.users.edit"
      ],
      "code": [
        "app/components/form-user/loyalty/coupon-exchanges.js",
        "app/models/coupon-exchange.js",
        "app/abilities/coupon.js"
      ],
      "models": [
        "coupon-exchange",
        "coupon",
        "commerce"
      ],
      "modules": [
        "cms (coupons on proposals)",
        "crm"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager, crmoperator, weboperator (`coupon` ability `canRedeem`)"
      ],
      "description": "Lists the proposal coupons a customer has taken and lets staff redeem one by recording the store and the exchange date.",
      "comments": "Redemption has its own client-side form validation (`validateRedeemForm` → `_validateCommerce` + `_validateDate`), including a rule that the date cannot be in the future. The API error detail is preferred over the generic copy when present (`json?.errors?.[0]?.detail ? … : intl.t('coupon-exchanges.redeem.error')`), which is better error plumbing than most of the surrounding code.",
      "useCases": [
        "A store assistant redeeming a promotional coupon the customer shows on their phone.",
        "Back-office correction of a coupon redeemed against the wrong store."
      ],
      "happyPaths": [
        {
          "name": "Coupon redeemed",
          "steps": [
            "Operator opens the coupon-exchanges step and the redeem modal on a coupon.",
            "Selects the store and a date that is not in the future.",
            "`validateRedeemForm` passes and the redemption is posted.",
            "`coupon-exchanges.redeem.success` is shown and the coupon's status changes."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Store not selected",
          "cause": "`couponSelectedCommerce` empty.",
          "handling": "`coupon-exchanges.redeem.validation.commerce`."
        },
        {
          "name": "Date missing",
          "cause": "No exchange date chosen.",
          "handling": "`coupon-exchanges.redeem.validation.date`."
        },
        {
          "name": "Future date",
          "cause": "Exchange date after today.",
          "handling": "`coupon-exchanges.redeem.validation.future.date`."
        },
        {
          "name": "API rejects the redemption",
          "cause": "Usage limit reached, coupon expired, or exchange limit per user exceeded.",
          "handling": "The API's `errors[0].detail` is shown if provided, otherwise `coupon-exchanges.redeem.error` under the `coupon-exchanges.error.title` heading."
        }
      ],
      "i18nKeys": [
        "ticket.coupon-exchanges",
        "coupon-exchanges.coupon",
        "coupon-exchanges.shop",
        "coupon-exchanges.date-coupon-has-exchanged",
        "coupon-exchanges.redeem",
        "coupon-exchanges.redeem.success",
        "coupon-exchanges.redeem.error",
        "coupon-exchanges.error.title",
        "coupon-exchanges.redeem.validation.commerce",
        "coupon-exchanges.redeem.validation.date",
        "coupon-exchanges.redeem.validation.future.date"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-children",
      "name": "Customer children (dependants)",
      "area": "CRM",
      "label": "children / crm.children.add",
      "routes": [
        "crm.children",
        "crm.children.index",
        "crm.children.new",
        "crm.children.edit"
      ],
      "code": [
        "app/controllers/crm/children/index.js",
        "app/routes/crm/children/*.js",
        "app/models/child.js",
        "app/abilities/child.js"
      ],
      "models": [
        "child",
        "user",
        "child-stay"
      ],
      "modules": [
        "playcenter"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "crmoperator",
        "weboperator"
      ],
      "description": "Per-customer list of registered children with name, birth date and relationship, plus play-centre check-in and check-out directly from the row.",
      "comments": "Nested under a customer (`users/:user_id/children`). Check-in/check-out are the same operations as the play-centre screen but reached from the parent's record; both map API errors through `mapErrors(e, intl.t)` into `errorDetails`, which is the better error-surfacing pattern in this codebase. The `child` ability makes the gating explicit: `canCheckIn` needs `hasVatNumber && !isInPlayCenter`, `canCheckOut` needs `hasVatNumber && isInPlayCenter`.",
      "useCases": [
        "Registering a customer's children once so play-centre entry is a single click later.",
        "Checking a child in when the parent arrives at the play area.",
        "Checking a child out when collected."
      ],
      "happyPaths": [
        {
          "name": "Child registered and checked in",
          "steps": [
            "Operator opens a customer's children list and adds a child with name, birth date, gender and relationship.",
            "Later, with the parent's VAT number on file, the check-in modal is opened.",
            "Entry time and expected exit time are entered and confirmed.",
            "`child.checkin-success-message` is shown and the child appears as in the play centre."
          ]
        },
        {
          "name": "Child checked out",
          "steps": [
            "Operator opens the check-out modal for a child currently inside.",
            "Confirms; `child.checkout-success-message` is shown."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Parent has no VAT number",
          "cause": "`hasVatNumber` false.",
          "handling": "`canCheckIn`/`canCheckOut` are false and the UI explains with `child.not-dni-error-message`, telling the operator to set the VAT number on the customer profile first."
        },
        {
          "name": "Child data incomplete",
          "cause": "Name, relationship, gender or birth date missing.",
          "handling": "`error.child.name.required`, `error.child.relationship.required`, `error.child.sex.required`, `error.child.bdate.required`."
        },
        {
          "name": "Check-in times missing",
          "cause": "Entry or expected exit time blank.",
          "handling": "`error.crm.children.entryTime.required` / `error.crm.children.expectedExitTime.required`."
        },
        {
          "name": "Check-in/out rejected by the API",
          "cause": "Capacity reached, already inside, or server error.",
          "handling": "`response.errors` are mapped through `mapErrors` into `errorDetails` and listed to the operator."
        },
        {
          "name": "Invalid record",
          "cause": "Action called without a valid child.",
          "handling": "Throws `TypeError('model' is not a valid child)`."
        }
      ],
      "i18nKeys": [
        "children",
        "crm.children.add",
        "crm.children.name",
        "crm.children.bdate",
        "child.checkin-success-message",
        "child.checkout-success-message",
        "child.not-dni-error-message",
        "error.child.name.required",
        "error.child.relationship.required",
        "error.child.sex.required",
        "error.child.bdate.required",
        "error.crm.children.entryTime.required",
        "error.crm.children.expectedExitTime.required"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-receipt-moderation",
      "name": "Receipt verification queue",
      "area": "CRM",
      "label": "title.header.crm.receipts / menu.navbar.receipt.show",
      "routes": [
        "crm.receipts",
        "crm.receipts.index"
      ],
      "code": [
        "app/controllers/crm/receipts/index.js",
        "app/routes/crm/receipts/index.js",
        "app/enums/receipt-status.js",
        "app/enums/reject-reasons-receipts.js",
        "app/enums/pending-reasons-receipts.js",
        "app/abilities/receipt.js"
      ],
      "models": [
        "receipt",
        "campaign",
        "commerce"
      ],
      "modules": [
        "tickets"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "crmoperator",
        "weboperator"
      ],
      "description": "Moderation workspace for customer-uploaded purchase receipts: review the image and OCR'd data, validate (which also redeems the entitlement), reject with a reason, un-reject, and navigate the queue with previous/next.",
      "comments": "The richest state machine in the app. Seven receipt states (`draft`, `pending`, `not-redeemable`, `redeemable`, `not-yet-redeemable`, `redeemed`, `rejected`) each with a colour and a tooltip; eight reject reasons (duplicated, illegible, modified, other centre, out of period, out of store, over max amount, other) and seven automatic pending reasons explaining why the system could not decide (no code, no store, over 250 amount, not this centre, out of date, no amount, invalid date). `isValidating`/`isSavingReject`/`isUnrejecting` guard concurrent mutations, and `previousReceipt`/`nextReceipt` with `isFirstSelectedReceipt`/`isLastSelectedReceipt` make high-volume moderation keyboard-fast. `canSeeModerator` reveals who moderated a receipt. `reprocessPendingReceipts` re-runs automatic processing over the pending backlog.",
      "useCases": [
        "A moderator working through the overnight backlog of uploaded receipts.",
        "Rejecting a duplicate receipt with an auditable reason the customer can be told.",
        "Reversing a wrong rejection (`unreject` re-runs validation).",
        "Re-processing the pending queue after fixing a store's configuration or campaign dates.",
        "Finding out which moderator handled a disputed receipt."
      ],
      "happyPaths": [
        {
          "name": "Receipt validated",
          "steps": [
            "Moderator opens the queue filtered to pending and selects a receipt.",
            "Reviews the image against the parsed amount, date, code and store.",
            "Clicks validate; `isValidating` blocks double submission.",
            "The state moves to a validated state, `validatedAt` is stamped and the entitlement is redeemed."
          ]
        },
        {
          "name": "Receipt rejected with a reason",
          "steps": [
            "Moderator picks a reject reason (required) and saves.",
            "State becomes `rejected`, `validatedAt` is stamped and the reason is stored for the customer-facing explanation."
          ]
        },
        {
          "name": "Rejection reversed",
          "steps": [
            "Moderator opens a rejected receipt and clicks un-reject.",
            "`unreject()` delegates straight to `validate()`, so the receipt is re-validated in one step."
          ]
        },
        {
          "name": "Pending backlog reprocessed",
          "steps": [
            "Moderator triggers reprocess on the pending set.",
            "`crm.receipts.reprocess-receipts-success` confirms the job was accepted."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Validated but not redeemable",
          "cause": "The receipt validates but the entitlement cannot be redeemed (campaign closed, limits reached).",
          "handling": "`receipt.validate.error-message-unable-to-redeem` — explicitly states the receipt was validated but not redeemed, so the operator knows the two outcomes diverged."
        },
        {
          "name": "Validation request fails",
          "cause": "API error.",
          "handling": "`catch` resets `selectedReceipt.validatedAt` to null and clears `isValidating`, rolling the optimistic update back."
        },
        {
          "name": "Reject without a reason",
          "cause": "`rejectReason` not set.",
          "handling": "`reject()` only proceeds when a reason exists; the field is marked `required`."
        },
        {
          "name": "Unknown reject reason",
          "cause": "A stored `rejectReason` is not in the enum.",
          "handling": "`rejectReasons` getter does `.find(...).label` without a guard, so this throws a TypeError and breaks the panel."
        },
        {
          "name": "Reject save fails",
          "cause": "API error.",
          "handling": "`catch` resets `validatedAt` and leaves the previous state."
        },
        {
          "name": "Embedded API errors",
          "cause": "A 200 response that still carries `data.errors`.",
          "handling": "`throw data.errors.firstObject` surfaces it into the catch chain."
        },
        {
          "name": "Reprocess not permitted",
          "cause": "`canReprocessReceipts` false for the role.",
          "handling": "The action is hidden."
        }
      ],
      "i18nKeys": [
        "title.header.crm.receipts",
        "menu.navbar.receipt.show",
        "receipt.status.draft",
        "receipt.status.pending",
        "receipt.status.redeemable",
        "receipt.status.not-redeemable",
        "receipt.status.not-yet-redeemable",
        "receipt.status.redeemed",
        "receipt.status.rejected",
        "receipt.validate.error-message-unable-to-redeem",
        "crm.receipts.reprocess-receipts-success",
        "receipts.reject-reason",
        "receipts.index.moderator-name",
        "receipt.state.duplicated",
        "receipt.state.illegible",
        "receipt.state.modified",
        "receipt.state.other-center",
        "receipt.state.out-of-period",
        "receipt.state.out-of-commerce",
        "receipt.state.over-max-amount",
        "receipt.pending.not_code",
        "receipt.pending.not_store",
        "receipt.pending.over_250_amount",
        "receipt.pending.not_center",
        "receipt.pending.out_of_date",
        "receipt.pending.not_amount",
        "receipt.pending.invalid_date"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-commerce-list",
      "name": "Store list, filters & exports",
      "area": "CRM",
      "label": "title.header.crm.commerces / menu.navbar.shop.show",
      "routes": [
        "crm.commerces",
        "crm.commerces.index"
      ],
      "code": [
        "app/controllers/crm/commerces/index.js",
        "app/routes/crm/commerces/index.js",
        "app/enums/commerces-filters.js",
        "app/abilities/commerce.js"
      ],
      "models": [
        "commerce",
        "user"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "scmanager",
        "storeManager",
        "weboperator"
      ],
      "description": "The centre's store directory with module-aware filters, a CSV export of the full store dataset, and a bulk QR export for store-level loyalty QR codes.",
      "comments": "`modelFilters` filters each filter option by the modules it declares, so e.g. Yardi- or Fideliza-specific filters only appear where those modules are on. `exportQr` downloads QR assets and `exportCsv` uses the shared export pipeline; `canExport` is limited to root/admin/manager and `canImport` to root/admin only — tighter than `canEdit`. `checkUsersRelationship` is a safety check before deletion: it looks for customers attached to the store.",
      "useCases": [
        "Maintaining the store directory that feeds the app's store list and map.",
        "Exporting all store QR codes to print in-store loyalty signage.",
        "Finding stores missing a Yardi tenant code before a sales export.",
        "Sending a store its back-office access information."
      ],
      "happyPaths": [
        {
          "name": "Store QR codes exported",
          "steps": [
            "User filters the store list as needed and triggers the QR export.",
            "`exportQr` fetches the QR assets and the download starts."
          ]
        },
        {
          "name": "Access information emailed to a store",
          "steps": [
            "User triggers the access-information action for a store.",
            "`crm.commerce-access-information.sent-success` confirms the email was queued."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Deleting a store with customers attached",
          "cause": "`checkUsersRelationship` finds customers linked to the store.",
          "handling": "`crm.commerce-access-information.users-related-error` ('you are trying to remove a store with customers associated') and the delete is not confirmed."
        },
        {
          "name": "QR export fails",
          "cause": "API/download error.",
          "handling": "`crm.commerces.qr-error` asks the user to retry later."
        },
        {
          "name": "Access-information email fails",
          "cause": "API error.",
          "handling": "`.catch(() => setUpResponse('error'))` → `crm.commerce-access-information.sent-error` (a generic 'An error has occurred')."
        }
      ],
      "i18nKeys": [
        "title.header.crm.commerces",
        "menu.navbar.shop.show",
        "commerces.new",
        "crm.commerces.qr-error",
        "crm.commerce-access-information.sent-success",
        "crm.commerce-access-information.sent-error",
        "crm.commerce-access-information.users-related-error"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-commerce-form",
      "name": "Store create & edit",
      "area": "CRM",
      "label": "crm.commerce.general-data",
      "routes": [
        "crm.commerces.new",
        "crm.commerces.edit"
      ],
      "code": [
        "app/controllers/crm/commerces/new.js",
        "app/routes/crm/commerces/new.js",
        "app/models/commerce.js",
        "app/models/commerce-extra.js"
      ],
      "models": [
        "commerce",
        "extra",
        "commerce-extra",
        "zone",
        "language",
        "campaign",
        "commerce-type"
      ],
      "modules": [
        "crm",
        "yardi (optional)",
        "fideliza (optional)",
        "intranet-sovos (optional)"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "scmanager",
        "weboperator"
      ],
      "description": "Multi-step store editor covering general data, physical-store attributes, the store QR code and centre-defined extra attributes, including social links, opening data, categories, zone, gift-card administrators and integration codes.",
      "comments": "Steps and tabs are computed, and several are module-conditional: `hasYardi` adds the tenant-code field (with a centre-specific label via `yardiCodeLabel`), `hasFideliza` brings in gift-card administrators, and `canSeeIntelISales` exposes IntelliSales. Extra attributes are handled separately from the model: `requiredExtras` is assembled per label and `isValidCommerceExtra` sets a bespoke `extraError` of the form `` `${label} is required` `` — note this message is built in code, not translated. `saveCommerce` validates the commerce, then saves extras, and special-cases HTTP 422 from the adapter. A loyalty-QR flow (`downloadQr`, `searchCampaign`, `loadSelectedCampaign`) ties a store QR to a campaign. Category selection supports select-all.",
      "useCases": [
        "Onboarding a new tenant: address, categories, contact, social links and images.",
        "Setting the Yardi tenant code so the store's sales can be exported.",
        "Nominating gift-card administrators for a Fideliza-enabled centre.",
        "Printing a store's loyalty QR code tied to a specific campaign."
      ],
      "happyPaths": [
        {
          "name": "Store created with extras",
          "steps": [
            "User fills general data: name, categories, zone, address, locality, province, image.",
            "Yardi centres also require the tenant code.",
            "Extra attributes required by the centre are completed.",
            "`saveCommerce` validates the model, saves it, then persists the commerce-extras.",
            "The store appears in the directory and in the app."
          ]
        },
        {
          "name": "Loyalty QR downloaded",
          "steps": [
            "User searches and selects a campaign for the store QR.",
            "`downloadQr` renders and downloads the QR image with the store name as alt text."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Mandatory store fields missing",
          "cause": "Name, categories, zone, image, address, direction, locality or province blank.",
          "handling": "`error.commerce.name.required`, `error.commerce.categories.required`, `error.commerce.zone.required`, `error.commerce.imageUrl.required`, `error.commerce.address.required`, `error.commerce.direction.required`, `error.commerce.locality.required`, `error.commerce.province.required`."
        },
        {
          "name": "Yardi code missing",
          "cause": "Centre has Yardi but `tenantCode` is blank.",
          "handling": "`error.commerce.tenantCode.required` ('Yardi code is required')."
        },
        {
          "name": "Malformed URL",
          "cause": "`webUrl` or `bookTableUrl` does not match the http(s) regex.",
          "handling": "`error.commerce.web.format` ('The text must be a url')."
        },
        {
          "name": "WhatsApp number without country code",
          "cause": "Phone not in `+346xxxxxxxx` shape.",
          "handling": "`error.commerce.whatsapp-phone.format` spells out the expected format; a WhatsApp message is also required (`error.commerce.whatsapp-message.required`)."
        },
        {
          "name": "Invalid gift-card administrator emails",
          "cause": "One or more addresses malformed, or the field left empty when required.",
          "handling": "`error.commerce.gift-card-admins.invalid-email` / `error.commerce.gift-card-admins.required`."
        },
        {
          "name": "Required extra attribute empty",
          "cause": "A centre-defined required extra has no value.",
          "handling": "`extraError` is set to an untranslated `\"<label> is required\"` string — inconsistent with the rest of the form's localised errors."
        },
        {
          "name": "Unprocessable entity from the API",
          "cause": "Adapter returns 422 (e.g. duplicate name or code).",
          "handling": "Explicitly branched on `'422' === adapterError.errors[0].status`; other statuses fall through to the generic catch."
        },
        {
          "name": "Extras image/HTML with embedded images",
          "cause": "HTML extra contains an `<img>`.",
          "handling": "`error.configuration.center.extra.image-not-suported` (and the proposal/campaign equivalents) reject embedded images in rich-text fields."
        }
      ],
      "i18nKeys": [
        "crm.commerce.general-data",
        "crm.commerce.physical-store",
        "crm.commerce.qr-code",
        "crm.commerce.extra-attributes",
        "yardi-code",
        "error.commerce.name.required",
        "error.commerce.categories.required",
        "error.commerce.zone.required",
        "error.commerce.imageUrl.required",
        "error.commerce.tenantCode.required",
        "error.commerce.web.format",
        "error.commerce.whatsapp-phone.format",
        "error.commerce.whatsapp-message.required",
        "error.commerce.gift-card-admins.invalid-email",
        "error.commerce.gift-card-admins.required",
        "error.commerce.address.required",
        "error.commerce.direction.required",
        "error.commerce.locality.required",
        "error.commerce.province.required"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-commerce-import",
      "name": "Store CSV import",
      "area": "CRM",
      "label": "import-csv.column-database",
      "routes": [
        "crm.commerces.import"
      ],
      "code": [
        "app/controllers/crm/commerces/import.js",
        "app/routes/crm/commerces/import.js",
        "app/components/nf-import.js"
      ],
      "models": [
        "load",
        "commerce"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin (`commerce` ability `canImport`)"
      ],
      "description": "Bulk store create/update from CSV with ~30 mappable attributes including address, categories, social handles, IBAN, fiscal name, tenant code, Fideliza code, surfaces and gift-card settings.",
      "comments": "Same engine as the customer import but with the store attribute list and no channel requirement. Notably mappable: `company-reference-number`, `fideliza-code`, `tenant-code`, `pin`, `sale-price`, `surface`, `showcase-surface`, `commerce-admins`, `commerce-tags`, `gift-card-active`, `gift-card-fiscal-name`. The tightest permission of any import — root/admin only.",
      "useCases": [
        "Onboarding a centre's full tenant list in one pass.",
        "Bulk-updating tenant codes before enabling the Yardi export.",
        "Mass-assigning store labels or administrators."
      ],
      "happyPaths": [
        {
          "name": "Stores bulk-updated",
          "steps": [
            "Admin downloads the store template, fills it and uploads.",
            "Columns auto-map; the match key is set to id (default for non-user models).",
            "`load.validate()` passes and the import job is queued.",
            "Store directory reflects the changes."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Duplicate column mapping",
          "cause": "Two CSV columns mapped to one attribute.",
          "handling": "`error.load.repeated-fields`."
        },
        {
          "name": "Rows rejected",
          "cause": "Invalid category, unknown zone, malformed URL per row.",
          "handling": "Reported in `informative-message.import-csv.error`; the frontend does no per-row pre-validation."
        },
        {
          "name": "Insufficient permission",
          "cause": "Role is not root/admin.",
          "handling": "The import route's `can` check rejects entry."
        }
      ],
      "i18nKeys": [
        "import-csv.column-csv",
        "import-csv.column-database",
        "error.load.repeated-fields",
        "informative-message.import-csv.error"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-commerce-types",
      "name": "Store labels (store types)",
      "area": "CRM",
      "label": "menu.navbar.commerce-type / crm.commerce-types",
      "routes": [
        "crm.commerce-types.index",
        "crm.commerce-types.new",
        "crm.commerce-types.edit"
      ],
      "code": [
        "app/controllers/crm/commerce-types/index.js",
        "app/routes/crm/commerce-types/*.js",
        "app/models/commerce-type.js",
        "app/abilities/commerce-type.js"
      ],
      "models": [
        "commerce-type"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "weboperator"
      ],
      "description": "CRUD for the code+name labels used to group stores, which campaigns and proposals can then target as a set instead of listing individual stores.",
      "comments": "Small but structurally important: `onSelectedCommerceTypesChange` in both the campaign and proposal editors expands a selected label into its stores, so a label is effectively a reusable store audience. `canEdit`/`canDelete` both collapse to `canRead` for the allowed roles.",
      "useCases": [
        "Grouping stores as 'Fashion', 'Food court' or 'Services' for campaign targeting.",
        "Selecting all food-court tenants for a proposal in one click."
      ],
      "happyPaths": [
        {
          "name": "Label created and used for targeting",
          "steps": [
            "User creates a label with a code and name.",
            "Stores are assigned the label on the store form or via import.",
            "In a campaign or proposal the label is selected and expands to its stores."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Invalid record on delete",
          "cause": "`delete` called without a valid model.",
          "handling": "Throws `TypeError('model' is not a valid commerce-type)`."
        },
        {
          "name": "Deleting a label in use",
          "cause": "Stores or campaigns still reference it.",
          "handling": "API rejection surfaced through the generic form error path."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `crm.commercy-types` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "menu.navbar.commerce-type",
        "crm.commerce-types",
        "crm.commerce-types.new",
        "crm.tags.code",
        "crm.tags.name"
      ],
      "i18nMissing": [
        "crm.commercy-types"
      ]
    },
    {
      "id": "marketing-campaign-list",
      "name": "Activity list & duplication",
      "area": "Marketing",
      "label": "title.header.marketing.campaigns / menu.navbar.marketing.campaign.index",
      "routes": [
        "marketing.campaigns",
        "marketing.campaigns.index"
      ],
      "code": [
        "app/controllers/marketing/campaigns/index.js",
        "app/routes/marketing/campaigns/index.js",
        "app/enums/campaign-status.js",
        "app/enums/campaign-types.js",
        "app/abilities/campaign.js"
      ],
      "models": [
        "campaign",
        "campaignrule"
      ],
      "modules": [
        "marketing"
      ],
      "roles": [
        "root",
        "admin",
        "owner",
        "manager",
        "crmmanager",
        "weboperator",
        "content_creator"
      ],
      "description": "Lists the centre's marketing activities with type, segment, date range and status (planned / active / finished / closed), and offers edit, duplicate, delete and — for finished activities — the report view.",
      "comments": "Status drives almost every permission: `canEdit` lets root/admin edit anything but restricts manager/scmanager/weboperator/content_creator to activities that are not closed or finished; `canDelete` similarly protects closed and finished activities from everyone but root/admin; `canShow` (the report) is only available once the status is `finished`. `canEditRaffle` additionally blocks structural edits once any raffle rule has issued tickets. Duplication reuses the proposal-duplication copy keys (`cms.proposals.proposal-duplicated`). The Activities menu entry declares `disabledByModule: 'MarketingAnalytics'`, so it disappears for centres with the marketing-analytics module even though the routes still work.",
      "useCases": [
        "Finding last year's Christmas activity and duplicating it as the starting point for this year.",
        "Reviewing which activities are currently running across the centre.",
        "Opening the report of a finished activity to report results to the centre's owners.",
        "Preventing a colleague from editing a closed activity whose results are already reported."
      ],
      "happyPaths": [
        {
          "name": "Activity duplicated",
          "steps": [
            "User finds a past activity and clicks duplicate.",
            "A copy is created and `cms.proposals.proposal-duplicated` confirms it.",
            "The user opens the copy, adjusts dates and content and saves."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Duplication fails",
          "cause": "API error while copying the activity and its rules.",
          "handling": "`cms.proposals.proposal-duplicated-error` ('Error on duplicating proposal') — reusing the proposal wording for an activity is a copy bug that will confuse users."
        },
        {
          "name": "Edit blocked by status",
          "cause": "Activity is closed or finished and the role is not root/admin.",
          "handling": "`canEdit` is false, so the action is hidden."
        },
        {
          "name": "Delete blocked by status",
          "cause": "Same as above for `canDelete`.",
          "handling": "Action hidden. Note the `canDelete` condition checks `CONTENT_CREATOR` against `model.status`, comparing a role constant to a status value — a latent logic bug that makes the guard weaker than intended."
        },
        {
          "name": "Report unavailable",
          "cause": "Activity is not yet `finished`.",
          "handling": "`canShow` false; the report link is not rendered."
        }
      ],
      "i18nKeys": [
        "title.header.marketing.campaigns",
        "menu.navbar.marketing.campaign.index",
        "marketing.new-campaign",
        "campaigns.copy",
        "campaigntype",
        "campaign.status.planned",
        "campaign.status.active",
        "campaign.status.finished",
        "campaign.status.closed",
        "cms.proposals.proposal-duplicated",
        "cms.proposals.proposal-duplicated-error"
      ],
      "i18nMissing": []
    },
    {
      "id": "marketing-campaign-editor",
      "name": "Activity editor (loyalty mechanics, rules & targeting)",
      "area": "Marketing",
      "label": "campaign.new / campaign.edit",
      "routes": [
        "marketing.campaigns.new",
        "marketing.campaigns.edit"
      ],
      "code": [
        "app/controllers/marketing/campaigns/new.js",
        "app/routes/marketing/campaigns/new.js",
        "app/models/campaign.js",
        "app/models/campaignrule.js",
        "app/models/segmented-campaign-rule.js",
        "app/models/gift-voucher.js",
        "app/enums/loyalty-types.js",
        "app/enums/raffle-types.js",
        "app/enums/loyalty-card-types.js",
        "app/enums/campaign-rule-types.js"
      ],
      "models": [
        "campaign",
        "campaignrule",
        "segmented-campaign-rule",
        "gift-voucher",
        "commerce",
        "segment",
        "supplier",
        "ticket",
        "language"
      ],
      "modules": [
        "marketing",
        "tickets",
        "loyalty-cards",
        "rewards",
        "fideliza",
        "lottery"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "weboperator (create); plus scmanager, storeManager, crmmanager for edits"
      ],
      "description": "The largest form in the product (1,100+ lines of controller). Defines a marketing activity: type, multi-language content, legal bases, date range, reference period, target stores and segment, and — for loyalty activities — the full earning mechanic: raffle tickets, loyalty cards and stamps, rewards or Fideliza gift vouchers, each with their own earning rules, QR options and limits.",
      "comments": "Structure: `tabs` for sections with per-tab validation attribute lists, and `steps` generated one-per-language (the default language marked with `*`), so translations are a wizard dimension rather than a separate screen. The loyalty mechanic is a tree: `loyalty` ∈ none | raffle | loyalty-card | reward | gift-voucher, each gated by its own module (`enums/loyalty-types` carries the module per option). Raffle and loyalty-card then branch again on how entitlement is earned — by uploaded tickets, by amount, or by store QR. Rules come in three kinds (`base`, `receipts`, `stores`) submitted separately via `submitGeneralRule` / `submitReceiptsRule`, and `segmented-campaign-rule` records let a segment earn extra stamps with unique or multiple allocation. Several toggles have cascading side effects: `checkGenerateQrTickets` clears `withoutRemains`, `isGenericReward`, `balanceNotVisibleInApp`, the general rule amount and `receiptsRedemptionDays`; `onOneTicketPerCommerceChange(false)` forces `maxLoyaltyCardsPerUser` to 1. Changing the poll max-votes opens a confirmation modal that reverts the value on cancel (`__initialPollMaxVotes`). `afterSaved` opens the QR download modal when a loyalty-card campaign is not auto-validating. Error handling is centralised in `_setAdapterError`, which maps API errors through `mapErrors` and picks `user-error` for 4xx vs `api-error` for everything else — the clearest error-classification in the codebase. `downloadCommercesPDF` posts the selected store ids to `/api/v4/qr_download` and downloads a printable QR sheet.",
      "useCases": [
        "Running a receipt-based raffle: customers upload receipts, earn entries per spend threshold, prizes drawn at the end.",
        "Running a stamp card: ten stamps earned by store QR scan, redeemable for a reward.",
        "Issuing Fideliza gift vouchers to a segment with an amount, a budget and a validity window.",
        "Letting a specific segment earn additional stamps (segmented rules) as a VIP perk.",
        "Restricting earning to a subset of stores, or to one ticket per store.",
        "Attaching legal bases either as rich text or as a URL, as required by raffle regulation.",
        "Printing the QR sheet for all participating stores before an activity launches."
      ],
      "happyPaths": [
        {
          "name": "Receipt-based raffle created",
          "steps": [
            "User sets name, type `promotion`, start and end dates and the reference period.",
            "Loyalty is set to `raffle` and the raffle type to 'by amount'.",
            "A base rule defines the spend amount per entry; `maxTicketsPerReceipt` caps abuse.",
            "Participating stores and the target segment (or 'all users') are chosen.",
            "Legal bases are supplied as text or URL; content is translated for each language step.",
            "`saveCampaign` validates with the active rule set, `patchCampaign` saves, and the activity appears as planned or active."
          ]
        },
        {
          "name": "Loyalty-card activity with store QR",
          "steps": [
            "Loyalty is set to `loyalty-card`, card type 'by store QR', stamps quota entered.",
            "`checkGenerateQrTickets` clears the conflicting flags and the rule amount.",
            "Stores are selected; one-ticket-per-store is enabled.",
            "On save, because the card does not auto-validate, the QR download modal opens so the user can print the store QRs."
          ]
        },
        {
          "name": "Gift-voucher activity",
          "steps": [
            "Loyalty is set to `gift-voucher` (requires the Fideliza module).",
            "Vouchers are added with name, amount, segment, budget and either a day count or a `validUntil` date.",
            "`checkGiftVoucherForm` and `handleValidityGiftVoucher` validate the validity choice.",
            "The total amount is recalculated (`calculateTotalAmount`) against the budget and the activity is saved."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Core fields missing",
          "cause": "Name, type, start date or end date blank.",
          "handling": "Presence rules fire with the generic `required` message plus the attribute label."
        },
        {
          "name": "End date not after start date",
          "cause": "`endDate <= startDate`.",
          "handling": "`campaign.end-date-after-start-date`."
        },
        {
          "name": "Loyalty type left as 'none' on a loyalty activity",
          "cause": "`campaignType === 'loyalty'` but `loyalty === 'none'`.",
          "handling": "Custom validation fails with `required` + the loyalty label."
        },
        {
          "name": "Reward validity not specified",
          "cause": "Reward loyalty with neither `validDays` nor `validUntil`.",
          "handling": "Mutually exclusive presence rules: `campaign.form.valid-days.required` / `campaign.form.valid-until.required` — whichever is missing when the other is also absent."
        },
        {
          "name": "Earning amount not set",
          "cause": "Loyalty-card 'by amount' or raffle 'by amount' with a base rule amount of 0.",
          "handling": "Custom validation inspects the last `base` rule and fails with `required` + amount. Note it reads `generalRule.lastObject.amount` without a guard, so a campaign with no base rule throws instead of showing the error."
        },
        {
          "name": "No stores selected",
          "cause": "Loyalty activity without `commerces`.",
          "handling": "Presence rule fails with `required` + commerces."
        },
        {
          "name": "No audience",
          "cause": "Loyalty activity (not gift-voucher) with neither `allUsers` nor a segment.",
          "handling": "Custom validation fails with `required` + segment."
        },
        {
          "name": "Stamps quota missing",
          "cause": "`isLoyaltyCard` without `stamps`.",
          "handling": "Presence rule fails with `required` + stamps."
        },
        {
          "name": "Negative tickets-per-receipt cap",
          "cause": "`maxTicketsPerReceipt` below 0.",
          "handling": "`marketing.campaign.raffle.max-tickets-per-receipt.error`."
        },
        {
          "name": "Images pasted into rich text",
          "cause": "`description` or `legalText` contains an `<img>`.",
          "handling": "`error.proposal.description.image-not-suported` / `error.campaign.legal-text.image-not-suported`."
        },
        {
          "name": "Reference period incomplete",
          "cause": "`referenceType` is `date` without both reference dates, or `campaign` without a reference campaign.",
          "handling": "`required` + reference-date / reference-campaign."
        },
        {
          "name": "Rating missing on closure",
          "cause": "Activity marked `closed` without a rating.",
          "handling": "`required` + rating — closure demands an assessment."
        },
        {
          "name": "Rule invalid",
          "cause": "A general or receipts rule fails `validate()` (missing label, zero amount, or `stores` type without stores).",
          "handling": "`submitGeneralRule`/`submitReceiptsRule` reject with the rule's own errors (`required` + label / amount / commerces)."
        },
        {
          "name": "Segmented rule invalid",
          "cause": "Missing campaign, segment, or stamps not greater than 0.",
          "handling": "The segmented-campaign-rule model's presence and numericality rules fire."
        },
        {
          "name": "Gift voucher incomplete",
          "cause": "Missing segment, name or amount; neither days nor valid-until; vouchers limit ≤ 0.",
          "handling": "`gift-voucher.days-or-valid-until-required`, `gift-voucher.vouchers-limit-must-be-greater-than-zero` and the relevant `required` messages."
        },
        {
          "name": "Raffle structure locked",
          "cause": "`canEditRaffle` is false because a rule already has issued tickets.",
          "handling": "Structural raffle edits are blocked to protect entries already awarded."
        },
        {
          "name": "API rejection",
          "cause": "Server-side validation or failure.",
          "handling": "`_setAdapterError` maps each error through `mapErrors` into `errorDetails` and shows `user-error` for 4xx or `api-error` for 5xx. Note it reads `errors[0].status.charAt()`, so an error payload without a `status` string throws inside the error handler."
        },
        {
          "name": "Store QR sheet download fails",
          "cause": "`/api/v4/qr_download` errors.",
          "handling": "No `.catch()` on the fetch chain — the download silently never starts."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `reference-campaign`, `reference-date` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "campaign.new",
        "campaign.edit",
        "users.general_data",
        "campaign.date-range",
        "campaign-rule.label",
        "marketing.campaigns.loyalty",
        "raffle",
        "loyalty-card",
        "gift-voucher",
        "generate-ballots.by-uploaded-tickets",
        "generate-ballots.by-amount",
        "generate-stamps.by-uploaded-tickets",
        "generate-stamps.by-amount",
        "generate-stamps.by-qr-tickets",
        "segmented-campaign-rule.aditional-stamps",
        "segmented-campaign-rule.unique-allocation",
        "segmented-campaign-rule.multiple-allocation",
        "gift-voucher.amount",
        "gift-voucher.budget",
        "gift-voucher.days",
        "gift-voucher.valid-until",
        "campaign.end-date-after-start-date",
        "campaign.form.valid-days.required",
        "campaign.form.valid-until.required",
        "marketing.campaign.raffle.max-tickets-per-receipt.error",
        "error.campaign.legal-text.image-not-suported",
        "user-error",
        "api-error"
      ],
      "i18nMissing": [
        "reference-campaign",
        "reference-date"
      ]
    },
    {
      "id": "marketing-campaign-report",
      "name": "Activity report & PDF",
      "area": "Marketing",
      "label": "campaign-report.estimated-cost / marketing.campaign.rating",
      "routes": [
        "marketing.campaigns.show"
      ],
      "code": [
        "app/controllers/marketing/campaigns/show.js",
        "app/components/campaign-report.js",
        "app/services/campaign-stats.js",
        "app/enums/campaign-kpis.js",
        "app/models/campaign-goal.js",
        "app/models/campaign-action.js"
      ],
      "models": [
        "campaign",
        "campaign-goal",
        "campaign-action",
        "supplier",
        "supplier-rating"
      ],
      "modules": [
        "marketing"
      ],
      "roles": [
        "root",
        "admin",
        "owner",
        "manager, crmmanager, weboperator, content_creator (`canReport` = module access)"
      ],
      "description": "Read-only closure report for a finished activity: KPI achievement against goals, estimated vs final cost with an over/under-budget verdict, participating stores, activity actions, supplier costs and ratings — exportable as a PDF.",
      "comments": "Only reachable once the activity's status is `finished` (`campaign.canShow`). `campaign-stats` supplies the measured KPI values; `campaignGoalsColumns` pairs each KPI with its goal and current value to produce an achievement figure. The cost verdict is a single derived label: `campaign-report.final-cost-in-estimated` ('Within budget') or `campaign-report.final-cost-over-estimated` ('Over budget'). Store lists collapse to `campaign-report.all-shops` or `campaign-report.more-shops` ('+ {shops} stores'). `createPDF` renders the report for distribution. `hiddenKPIs` lets a centre suppress KPIs that do not apply, and `referredComparison` / `lastYearPeriod` support like-for-like comparison against the reference period. Activity actions are drawn on a Gantt timeline (`nf-gantt`) spanning the activity's start and end dates, and the section is hidden when there are no actions.",
      "useCases": [
        "Reporting a completed activity's results and ROI to the centre's owners.",
        "Comparing performance against the same period last year.",
        "Reconciling supplier invoices against the activity's estimated budget.",
        "Rating suppliers after an event so future activities can pick better partners."
      ],
      "happyPaths": [
        {
          "name": "Report produced and exported",
          "steps": [
            "User opens a finished activity's report.",
            "`campaign-stats` resolves the measured KPIs and each goal shows its achievement.",
            "Costs are totalled and compared to the estimate, yielding the within/over-budget verdict.",
            "`createPDF` generates the PDF and the user shares it."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Activity not finished",
          "cause": "Status is planned, active or closed.",
          "handling": "`canShow` is false and the report route is not offered."
        },
        {
          "name": "No goals defined",
          "cause": "The activity was run without KPI goals.",
          "handling": "The achievement table renders without comparison values — no error, but the report loses its point."
        },
        {
          "name": "Stats unavailable",
          "cause": "`campaign-stats` fails to resolve.",
          "handling": "No dedicated error surface in this component; KPI tiles render empty."
        },
        {
          "name": "Rating missing",
          "cause": "The activity was closed without a rating.",
          "handling": "Blocked earlier by the campaign model's `rating` presence rule on closure."
        }
      ],
      "i18nKeys": [
        "campaign-report.estimated-cost",
        "campaign-report.final-cost",
        "campaign-report.final-cost-in-estimated",
        "campaign-report.final-cost-over-estimated",
        "campaign-report.all-shops",
        "campaign-report.more-shops",
        "kpi",
        "achievement",
        "current-value",
        "goal-value",
        "marketing.campaign.rating",
        "marketing.campaigns.actions"
      ],
      "i18nMissing": []
    },
    {
      "id": "marketing-activity-campaigns",
      "name": "Activity campaigns (programme grouping)",
      "area": "Marketing",
      "label": "title.header.marketing.activity-campaigns / menu.navbar.crm.activity-campaigns.index",
      "routes": [
        "marketing.activity-campaigns",
        "marketing.activity-campaigns.index",
        "marketing.activity-campaigns.new",
        "marketing.activity-campaigns.edit"
      ],
      "code": [
        "app/controllers/marketing/activity-campaigns/index.js",
        "app/controllers/marketing/activity-campaigns/new.js",
        "app/routes/marketing/activity-campaigns/*.js",
        "app/models/activity-campaign.js",
        "app/models/activity-campaign-language.js",
        "app/abilities/activity-campaign.js",
        "app/enums/activity-campaign-status.js"
      ],
      "models": [
        "activity-campaign",
        "activity-campaign-language",
        "campaign",
        "proposal",
        "language"
      ],
      "modules": [
        "marketing",
        "marketingActivityCampaigns (centre flag)"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "weboperator (create); scmanager, storeManager, crmmanager also edit"
      ],
      "description": "A lighter, multi-language campaign object used to group activities and proposals into a named programme with its own date range and status.",
      "comments": "Gated by a dedicated centre flag: `activity-campaign#canAccess` requires both an allowed role and `center.hasMarketingActivityCampaigns`, and the same flag is checked in the campaign editor, proposal editor and push-notification editor (`hasMarketingActivityCampaigns`) to decide whether the activity-campaign picker appears. Statuses mirror the activity statuses (planned/active/finished/closed) and gate `canEdit`/`canDelete`/`canClose` the same way. Start/end date changes run through `onChangeStartDate`/`onChangeEndDate` handlers.",
      "useCases": [
        "Grouping a season's activities and proposals under one umbrella programme.",
        "Reporting on a programme rather than on individual activities.",
        "Linking a push notification or proposal to a programme for attribution."
      ],
      "happyPaths": [
        {
          "name": "Programme created and linked",
          "steps": [
            "User creates an activity campaign with a name and date range, translated per language.",
            "It is saved as `planned` and later becomes `active`.",
            "Activities, proposals and push notifications reference it from their own editors."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Centre flag off",
          "cause": "`hasMarketingActivityCampaigns` false.",
          "handling": "`canAccess` is false — the menu entry and the pickers in other editors all disappear."
        },
        {
          "name": "Closed or finished programme",
          "cause": "Status is closed/finished and the role is content_creator or root (per `canEdit`'s branch).",
          "handling": "Editing is blocked. Note `canDelete` additionally requires `canCreate`, so store managers and CRM managers can edit but not delete."
        },
        {
          "name": "Missing translations",
          "cause": "A translatable field left blank for a non-default language.",
          "handling": "The shared multi-language guard `error.all-translation-fields.required` applies in the editors that enforce it."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `activityCampaign` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "title.header.marketing.activity-campaigns",
        "menu.navbar.crm.activity-campaigns.index",
        "marketing.activity-campaign.new",
        "campaign.status.planned",
        "campaign.status.active",
        "campaign.status.finished",
        "campaign.status.closed"
      ],
      "i18nMissing": [
        "activityCampaign"
      ]
    },
    {
      "id": "marketing-email-campaign-list",
      "name": "Email campaign list, duplication & stop",
      "area": "Marketing",
      "label": "title.header.emailing.campaigns / menu.navbar.marketing.emailing.index",
      "routes": [
        "marketing.emailing",
        "marketing.emailing.index",
        "marketing.emailing.show"
      ],
      "code": [
        "app/controllers/marketing/emailing/index.js",
        "app/routes/marketing/emailing/index.js",
        "app/enums/emailing-campaign-status.js",
        "app/abilities/email-campaign.js"
      ],
      "models": [
        "email-campaign",
        "email-campaign-template",
        "segment",
        "test-ab"
      ],
      "modules": [
        "marketingMailing"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "weboperator",
        "content_creator"
      ],
      "description": "Lists email campaigns with their segment, subject, send date, A/B template and delivery statistics (delivered, opened, clicked, bounced, blocked, spam), and offers duplicate, stop, edit, delete and a read-only detail view.",
      "comments": "Four statuses: draft, scheduled, active, archived. Statistics are columns on the list itself, so performance is visible without opening each campaign. `stop` halts a running or scheduled campaign; `duplicate` clones it as a new draft (`marketing.emailing.copy-of` prefix). Both are confirmation-modal driven (`onCloseDuplicateModal`, `onCloseStopModal`). Errors are routed through `_setSaveError` reading `adapterError?.errors`.",
      "useCases": [
        "Monitoring open and click rates across recent sends.",
        "Stopping a campaign immediately when an error in the content is spotted mid-send.",
        "Cloning a recurring newsletter rather than rebuilding it.",
        "Spotting a bounce or spam spike that suggests a list-quality problem."
      ],
      "happyPaths": [
        {
          "name": "Campaign duplicated",
          "steps": [
            "User clicks duplicate and confirms.",
            "A copy is created named with the `marketing.emailing.copy-of` prefix, in draft.",
            "The user edits the copy and schedules it."
          ]
        },
        {
          "name": "Running campaign stopped",
          "steps": [
            "User clicks stop on an active campaign and confirms.",
            "The send is halted and the status changes."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Duplication fails",
          "cause": "API error while cloning.",
          "handling": "`marketing.emailing.duplicate-error` ('Error on duplicating campaign')."
        },
        {
          "name": "Stop fails",
          "cause": "API error, or the campaign already finished sending.",
          "handling": "`marketing.emailing.stop-error` ('Error on stopping campaign'). Because a send in progress cannot be un-sent, a failed stop has real consequences."
        },
        {
          "name": "Edit/stop not permitted",
          "cause": "`email-campaign` ability `canEdit`/`canStop` false for the status or role.",
          "handling": "Actions are hidden."
        }
      ],
      "i18nKeys": [
        "title.header.emailing.campaigns",
        "menu.navbar.marketing.emailing.index",
        "marketing.new-emailing",
        "email-campaign.name",
        "email-campaign.subject",
        "email-campaign.sentDatetime",
        "email-campaign.statistic.delivered",
        "email-campaign.statistic.opened",
        "email-campaign.statistic.clicked",
        "email-campaign.statistic.bounced",
        "email-campaign.statistic.blocked",
        "email-campaign.statistic.spam",
        "email-campaign.ab-test.template",
        "email-campaign.active",
        "email-campaign.scheduled",
        "email-campaign.draft",
        "email-campaign.archived",
        "marketing.emailing.duplicate-error",
        "marketing.emailing.stop-error",
        "marketing.emailing.copy-of"
      ],
      "i18nMissing": []
    },
    {
      "id": "marketing-email-campaign-builder",
      "name": "Email campaign builder (wizard, A/B test, scheduling)",
      "area": "Marketing",
      "label": "email-campaign.new / email-campaign.edit",
      "routes": [
        "marketing.emailing.new",
        "marketing.emailing.edit"
      ],
      "code": [
        "app/controllers/marketing/emailing/new.js",
        "app/routes/marketing/emailing/new.js",
        "app/models/email-campaign.js",
        "app/models/test-ab.js",
        "app/models/email-bag.js",
        "app/services/api-email-campaigns.js",
        "app/enums/email-campaign-frequency-types.js",
        "app/enums/emailing-campaign-html-params.js",
        "app/components/email-variable-selector.js",
        "app/components/nf-code-param-list.js"
      ],
      "models": [
        "email-campaign",
        "email-campaign-template",
        "test-ab",
        "email-bag",
        "segment"
      ],
      "modules": [
        "marketingMailing"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "weboperator",
        "content_creator"
      ],
      "description": "Four-step wizard — general info, template design, (optional) B-variant design, summary — that composes an email campaign: subject, audience segment, HTML body with personalisation variables, send window, frequency, optional A/B split, and a test send before publishing.",
      "comments": "Step validation is declarative: `stepsInfo` gives each step a `validateAttrs` list and the design steps instead validate the HTML path (`designHtmlPaths[stepId]`), so `isValidModel()` only checks what the current step owns. Personalisation uses `codeParams` (centre name, user code, user email, user name) injectable into the subject and body. HTML bodies are scanned for invalid parameters and for the presence of an unsubscribe link, producing non-blocking warnings (`email-campaign.warning-invalid-html-params`, `email-campaign.warning-unsub-link`) plus a hard publish-time error (`email-campaign.error-invalid-html-params`, which names the offending template). Template import reads an uploaded HTML file (`readImportedHtml`) for A and B separately. `userReachedInfo` estimates reach and changes its wording when email validation is on (`segment.users_reached.info_with_email_validation`). Send quota is surfaced as `consumedEmailbagWarningMessage` from the centre's email bag. `sendTestEmail` on the route validates a comma-separated address list itself and throws if it is not an array of e-mails. Frequency supports single sending plus recurring 15min/30min/hourly/daily/weekly.",
      "useCases": [
        "Sending a monthly newsletter to a segment with personalised greeting.",
        "A/B testing two subject lines on a percentage split before the main send.",
        "Scheduling a recurring automated email (e.g. a weekly digest).",
        "Sending a test to colleagues for sign-off before scheduling.",
        "Re-engaging only those who opened or clicked a previous send (A/B test shipping method)."
      ],
      "happyPaths": [
        {
          "name": "Single-send campaign scheduled",
          "steps": [
            "Step 1: name, subject, segment and the start/end datetime window are set; frequency is single sending.",
            "Step 2: an HTML template is imported or composed; personalisation variables are inserted and the unsubscribe link check passes.",
            "Step 4 summary shows the estimated reach and the remaining email-bag quota.",
            "A test email is sent and approved.",
            "`sendCampaign` publishes it and the campaign moves to scheduled."
          ]
        },
        {
          "name": "A/B test campaign",
          "steps": [
            "`toggleABTest` enables the variant; subject B, a B template and a quote (percentage split) are entered.",
            "At least one shipping method (opened or clicked) is selected.",
            "The quote validates as an integer between 0.01 and 99.99.",
            "Step 3 designs template B; the campaign is published with the split."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Name or subject blank",
          "cause": "Required step-1 fields empty.",
          "handling": "`error.email-campaign.name.required` / `error.email-campaign.subject.required`."
        },
        {
          "name": "No segment",
          "cause": "`segment` belongsTo empty.",
          "handling": "`error.email-campaign.segment.required`."
        },
        {
          "name": "Empty template",
          "cause": "`htmlBody` blank.",
          "handling": "`error.email-campaign.htmlBody.required`; the design step's own validation blocks advancing."
        },
        {
          "name": "Start date in the past",
          "cause": "`startDatetime` before now.",
          "handling": "`error.email-campaign.date.invalid_past`."
        },
        {
          "name": "End before start",
          "cause": "`endDatetime` earlier than `startDatetime`.",
          "handling": "`error.email-campaign.endDatetime.post_startDatetime`."
        },
        {
          "name": "Dates missing",
          "cause": "Either datetime blank.",
          "handling": "`error.email-campaign.date.required`."
        },
        {
          "name": "A/B split invalid",
          "cause": "Quote blank, not an integer, or outside 0.01–99.99.",
          "handling": "`error.email-campaign.quote.required`, `error.email-campaign.quote.integer`, `error.email-campaign.quote.invalid`."
        },
        {
          "name": "A/B shipping method missing",
          "cause": "Neither `testOpened` nor `testClicked` selected on an A/B campaign.",
          "handling": "`error.email-campaign.testOpenedOrClicked.required`."
        },
        {
          "name": "B subject missing",
          "cause": "A/B enabled without `testAb.subject`.",
          "handling": "Reuses `error.email-campaign.subject.required`."
        },
        {
          "name": "Invalid HTML parameters at publish",
          "cause": "The template contains personalisation tokens the renderer cannot resolve.",
          "handling": "`email-campaign.error-invalid-html-params` names the template and blocks publishing; warnings appear earlier in the wizard."
        },
        {
          "name": "Missing unsubscribe link",
          "cause": "Template has no unsubscribe token.",
          "handling": "`email-campaign.warning-unsub-link` — a warning, not a block, so a non-compliant email can still be sent."
        },
        {
          "name": "Email bag exhausted",
          "cause": "The centre's send quota is consumed.",
          "handling": "`email-campaign.new.consumed-emailbag-warning-message` warns on the summary step."
        },
        {
          "name": "Test send with a bad address list",
          "cause": "`sendTestEmail` receives something that is not an array of e-mails.",
          "handling": "Throws `TypeError('The e-mail list is not a array with e-mails.')` after `validateEmailList` fails — a developer-facing error rather than inline field feedback."
        },
        {
          "name": "Duplicate campaign posting twice",
          "cause": "Double submission of a duplicated campaign.",
          "handling": "Addressed by a dedicated fix (`duplicate-email-campaign-should-not-post`, SC-13815) — the duplicate action no longer re-posts."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `email-campaign.insert-variable.placeholder` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "email-campaign.new",
        "email-campaign.edit",
        "email-campaign.form.general_info",
        "email-campaign.form.desing_template",
        "email-campaign.form.design_template_b",
        "email-campaign.form.summary",
        "email-campaign.single-sending",
        "email-campaign.every-15-minutes",
        "email-campaign.every-30-minutes",
        "email-campaign.hourly",
        "email-campaign.daily",
        "email-campaign.weekly",
        "email-campaign.params.subject.center_name",
        "email-campaign.params.subject.user_code",
        "email-campaign.params.subject.user_email",
        "email-campaign.params.subject.user_name",
        "email-campaign.warning-invalid-html-params",
        "email-campaign.warning-unsub-link",
        "email-campaign.error-invalid-html-params",
        "email-campaign.new.consumed-emailbag-warning-message",
        "segment.users_reached.info",
        "segment.users_reached.info_with_email_validation",
        "error.email-campaign.name.required",
        "error.email-campaign.subject.required",
        "error.email-campaign.htmlBody.required",
        "error.email-campaign.segment.required",
        "error.email-campaign.date.required",
        "error.email-campaign.date.invalid_past",
        "error.email-campaign.endDatetime.post_startDatetime",
        "error.email-campaign.quote.required",
        "error.email-campaign.quote.integer",
        "error.email-campaign.quote.invalid",
        "error.email-campaign.testOpenedOrClicked.required"
      ],
      "i18nMissing": [
        "email-campaign.insert-variable.placeholder"
      ]
    },
    {
      "id": "marketing-lottery",
      "name": "Digital games (lottery)",
      "area": "Marketing",
      "label": "title.header.marketing.lottery / menu.navbar.marketing.lottery",
      "routes": [
        "marketing.lottery"
      ],
      "code": [
        "app/controllers/marketing/lottery.js",
        "app/routes/marketing/lottery.js",
        "app/models/lottery.js",
        "app/models/module-lottery.js",
        "app/abilities/module-lottery.js"
      ],
      "models": [
        "lottery",
        "module-lottery"
      ],
      "modules": [
        "lottery"
      ],
      "roles": [
        "root",
        "admin",
        "owner",
        "manager",
        "crmmanager",
        "weboperator"
      ],
      "description": "Embeds the external digital-games (lottery) management tool inside the back office using the URL configured on the centre's lottery module.",
      "comments": "A thin integration: the controller computes `externalURL` from the centre configuration and the template renders it in an iframe. Also referenced from the app-button manager as the `lotteries` MOF type and from the centre form, which exposes a lottery QR URL template (`copyUrlLotteryQr`). Because it is an embed, the back office has no visibility into failures inside the external tool.",
      "useCases": [
        "Running a scratch-card or spin-the-wheel game as part of a centre activity.",
        "Giving marketing staff one place to manage games without a second login.",
        "Linking an app button straight to a specific game."
      ],
      "happyPaths": [
        {
          "name": "Games tool opened in-app",
          "steps": [
            "Centre has the lottery module enabled with a configured URL.",
            "User opens Marketing → Digital games.",
            "The external tool loads in the iframe, authenticated by the embedded URL."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Module disabled or URL unset",
          "cause": "`lottery` module off or no URL configured.",
          "handling": "`module-lottery#canAccess` hides the menu entry; with a blank URL the frame renders empty with no explanatory message."
        },
        {
          "name": "External tool unavailable",
          "cause": "Provider down or URL wrong.",
          "handling": "No in-app error handling for iframe load failures; the fallback copy `error_browser_iframe` only covers browsers without iframe support."
        }
      ],
      "i18nKeys": [
        "title.header.marketing.lottery",
        "menu.navbar.marketing.lottery",
        "mof-type.digital-games",
        "modules-lottery",
        "error_browser_iframe"
      ],
      "i18nMissing": []
    },
    {
      "id": "marketing-fidcard",
      "name": "Fidcard",
      "area": "Marketing",
      "label": "menu.navbar.marketing.fidcard",
      "routes": [
        "marketing.fidcard",
        "marketing.fidcard.index"
      ],
      "code": [
        "app/controllers/marketing/fidcard/index.js",
        "app/routes/marketing/fidcard/index.js",
        "app/models/module-fidcard.js",
        "app/abilities/module-marketing-fidcard.js"
      ],
      "models": [
        "module-fidcard"
      ],
      "modules": [
        "fidcard"
      ],
      "roles": [
        "root",
        "admin"
      ],
      "description": "Embeds the external Fidcard loyalty-card platform, using the URL from the centre's fidcard module.",
      "comments": "The most tightly restricted marketing feature — root and admin only. Related MOF button types exist for the app side: `fidcard`, `fidcard_manager` and `fidcard_client`, and the app-button manager enforces that only one fidcard-type button can exist (`cms.mofs.only-one-fidcard-type`). The centre form validates the Fidcard URL (`configuration.center.fidcard-url.error`).",
      "useCases": [
        "Administering physical/virtual loyalty cards issued through Fidcard.",
        "Exposing the customer-facing Fidcard view as an app button."
      ],
      "happyPaths": [
        {
          "name": "Fidcard console opened",
          "steps": [
            "Centre has the fidcard module with a valid URL.",
            "An admin opens Marketing → Fidcard.",
            "`fidcardURL` resolves and the console loads in the frame."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Module off or role insufficient",
          "cause": "`fidcard` disabled, or the user is not root/admin.",
          "handling": "`module-marketing-fidcard#canAccess` hides the entry and blocks the route."
        },
        {
          "name": "Invalid configured URL",
          "cause": "Centre's Fidcard URL fails the URL check.",
          "handling": "`configuration.center.fidcard-url.error` blocks saving the centre, preventing a broken embed."
        },
        {
          "name": "Duplicate fidcard app button",
          "cause": "A second fidcard-type MOF button is created.",
          "handling": "`cms.mofs.only-one-fidcard-type` rejects it."
        }
      ],
      "i18nKeys": [
        "menu.navbar.marketing.fidcard",
        "modules-fidcard",
        "configuration.center.fidcard-url.error",
        "cms.mofs.only-one-fidcard-type"
      ],
      "i18nMissing": []
    },
    {
      "id": "marketing-present-card",
      "name": "Present cards (Fideliza gift cards)",
      "area": "Marketing",
      "label": "menu.navbar.present-card",
      "routes": [
        "marketing.present-card",
        "marketing.present-card.index"
      ],
      "code": [
        "app/controllers/marketing/present-card/index.js",
        "app/routes/marketing/present-card/index.js",
        "app/models/module-fideliza.js",
        "app/models/module-fideliza-gift-card.js",
        "app/models/module-fideliza-gift-card-admin.js",
        "app/abilities/present-card.js"
      ],
      "models": [
        "module-fideliza",
        "gift-voucher"
      ],
      "modules": [
        "fideliza"
      ],
      "roles": [
        "per `present-card` ability"
      ],
      "description": "Embeds the Fideliza gift-card / present-card platform so staff can issue and manage gift cards without leaving the back office.",
      "comments": "The Fideliza module carries three URLs — the main platform, the gift-card client view and the gift-card admin view — each validated separately on the centre form. The controller resolves `fidelizaURL` and distinguishes a configuration problem (`fideliza_url_invalid`) from a runtime failure (`fideliza_url_error`), which is better than the other embeds. Related MOF button types: `gift-card` and `gift-card-admin`. Stores can nominate gift-card administrators by email on the store form.",
      "useCases": [
        "Selling or issuing a centre gift card at the information desk.",
        "Letting a store's nominated administrator redeem gift cards.",
        "Exposing the gift-card purchase flow as an app button."
      ],
      "happyPaths": [
        {
          "name": "Gift-card console opened",
          "steps": [
            "Centre has the Fideliza module with valid URLs.",
            "User opens Marketing → Present cards.",
            "`fidelizaURL` resolves and the platform loads."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "URL not configured or malformed",
          "cause": "Fideliza URL missing/invalid.",
          "handling": "`fideliza_url_invalid` in the view; the centre form blocks saving with `configuration.center.fideliza-url.error`."
        },
        {
          "name": "Platform error",
          "cause": "Fideliza unreachable.",
          "handling": "`fideliza_url_error` ('An error occurred. Please try again later.')."
        },
        {
          "name": "Store missing gift-card administrators",
          "cause": "Gift card enabled for a store without administrator emails.",
          "handling": "`error.commerce.gift-card-admins.required` on the store form."
        }
      ],
      "i18nKeys": [
        "menu.navbar.present-card",
        "fideliza_url_error",
        "fideliza_url_invalid",
        "configuration.center.fideliza-url.error",
        "mof-type.gift-card-admin",
        "modules-fideliza"
      ],
      "i18nMissing": []
    },
    {
      "id": "marketing-book-appointment",
      "name": "Book appointment",
      "area": "Marketing",
      "label": "title.header.book-appointment / menu.navbar.marketing.book-appointment",
      "routes": [
        "marketing.book-appointment",
        "marketing.book-appointment.index"
      ],
      "code": [
        "app/controllers/marketing/book-appointment/index.js",
        "app/routes/marketing/book-appointment/index.js",
        "app/abilities/module-marketing-book-appointment.js"
      ],
      "models": [],
      "modules": [
        "marketingBookAppointment"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "weboperator",
        "content_creator"
      ],
      "description": "Embeds the external appointment-booking tool, with the redirect URL assembled from the centre's configuration.",
      "comments": "The controller builds the embed address from `redirectBaseUrl` + `fetchExternalURL` into `bookingRedirectUrl`, i.e. the URL is fetched rather than statically configured — which is why this embed has a real error message where the others do not. A matching `booking_appointment` MOF type exposes it as an app button, and that button type requires a linked activity (`error.mof.campaign.required`).",
      "useCases": [
        "Letting customers book a slot for a service (e.g. a gift-wrapping desk or a kids' workshop).",
        "Staff checking or managing today's bookings.",
        "Surfacing booking from the app via a dedicated button."
      ],
      "happyPaths": [
        {
          "name": "Booking tool opened",
          "steps": [
            "Centre has the book-appointment module enabled.",
            "User opens Marketing → Book appointment.",
            "The redirect URL is fetched and resolved and the tool loads in the frame."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "URL fetch fails",
          "cause": "The request that resolves the external URL errors.",
          "handling": "`marketing.book-appointment.error-message` ('There was an error loading Book Appointment page.')."
        },
        {
          "name": "Module disabled",
          "cause": "`marketingBookAppointment` off for the centre.",
          "handling": "`canAccess` false; entry hidden and route blocked."
        },
        {
          "name": "App button without an activity",
          "cause": "A `booking_appointment` MOF button saved without a campaign.",
          "handling": "`error.mof.campaign.required`."
        }
      ],
      "i18nKeys": [
        "title.header.book-appointment",
        "menu.navbar.marketing.book-appointment",
        "marketing.book-appointment.error-message",
        "mof-type.booking-appointment",
        "error.mof.campaign.required"
      ],
      "i18nMissing": []
    },
    {
      "id": "cms-proposal-list",
      "name": "Proposal list & publication workflow",
      "area": "CMS",
      "label": "title.header.proposals / menu.navbar.proposal.show",
      "routes": [
        "cms.proposals",
        "cms.proposals.index"
      ],
      "code": [
        "app/controllers/cms/proposals/index.js",
        "app/routes/cms/proposals/index.js",
        "app/enums/proposal-status.js",
        "app/enums/proposal-filters.js",
        "app/abilities/proposal.js"
      ],
      "models": [
        "proposal",
        "language",
        "coupon"
      ],
      "modules": [
        "cms",
        "proposal_inbox (optional)",
        "proposal_ranking (optional)"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "storeManager",
        "commerce",
        "crmmanager",
        "weboperator",
        "content_creator"
      ],
      "description": "Lists the centre's content proposals (news, events, promotions) with type, category, segment, store, date range and status, and drives the full editorial workflow: request publication, publish, reject publication, unpublish, expire, duplicate, preview, delete and drag-to-reorder.",
      "comments": "The editorial state machine spans five statuses (draft, pending, scheduled, active, expired) and six transitions, each with its own ability (`canPublish`, `canRequestPublish`, `canRejectPublication`, `canUnpublish`, `canExpire`, `canDuplicate`) so a content creator can request publication while only a manager can approve it. All transitions funnel through one `saveProposal(proposal, type)` helper driven by a `messageProposal` map of success/error copy, with per-action in-flight flags (`isPublishing`, `isRequesting`, `isRejecting`, `isExpiring`, `isUnpublishing`) guarding double submission, and `loadCollection()` re-running afterwards. `expire` is implemented by setting `endDate` to today rather than by a status field. Note the `finally` block derives the flag name by running the action verb through the `nlp` library to build a gerund — a clever but fragile way to reset `is<Verb>ing`, which is why every flag is also reset explicitly. Drag-to-reorder (`onDroppedRow`) only accepts active and scheduled proposals.",
      "useCases": [
        "A content creator drafting a promotion and requesting publication.",
        "A manager reviewing the pending queue and approving or rejecting it.",
        "Taking a live proposal down immediately (unpublish) or ending it today (expire).",
        "Reordering the proposals shown on the app home screen.",
        "Duplicating a recurring event rather than re-entering it.",
        "Previewing exactly how a proposal will look in the app before publishing."
      ],
      "happyPaths": [
        {
          "name": "Request → approve → live",
          "steps": [
            "Content creator finishes a draft and clicks request; `cms.proposals.request-success` confirms and the status becomes pending.",
            "A manager opens the pending proposal, previews it and clicks publish.",
            "`published` is set true, saved, and `cms.proposals.publish-success` is shown.",
            "The proposal becomes scheduled or active depending on its start date and appears in the app."
          ]
        },
        {
          "name": "Taken down",
          "steps": [
            "Manager clicks unpublish on a live proposal and confirms.",
            "`cms.proposals.unpublish-success` is shown and the proposal leaves the app."
          ]
        },
        {
          "name": "Reordered on the home screen",
          "steps": [
            "User drags an active proposal to a new position.",
            "`onDroppedRow` persists the new order."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Publish fails",
          "cause": "API error on save.",
          "handling": "`cms.proposals.publish-error`; state set to error, and the collection reloads so the row reflects reality."
        },
        {
          "name": "Request / reject / unpublish / expire fails",
          "cause": "API error on the respective transition.",
          "handling": "`cms.proposals.request-error`, `cms.proposals.reject-publication-error`, `cms.proposals.unpublish-error`, `cms.proposals.expire-error`."
        },
        {
          "name": "Duplication fails",
          "cause": "API error while copying.",
          "handling": "`cms.proposals.proposal-duplicated-error`."
        },
        {
          "name": "Dragging a non-movable proposal",
          "cause": "Row is draft, pending or expired.",
          "handling": "`cms.proposals.error-dragging` ('Only active and scheduled proposals can be moved')."
        },
        {
          "name": "Transition not permitted",
          "cause": "The role's ability for that transition is false.",
          "handling": "The icon is not rendered for that row."
        },
        {
          "name": "Double submission",
          "cause": "User clicks publish twice quickly.",
          "handling": "The `isPublishing` guard returns a resolved promise instead of firing a second save."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `want_reject_publish` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "title.header.proposals",
        "menu.navbar.proposal.show",
        "cms.new-proposal",
        "cms.proposal.draft",
        "cms.proposal.pending",
        "cms.proposal.scheduled",
        "cms.proposal.active",
        "cms.proposal.expired",
        "publish",
        "unpublish",
        "expire",
        "request",
        "reject-publishing",
        "preview",
        "cms.proposals.publish-success",
        "cms.proposals.publish-error",
        "cms.proposals.request-success",
        "cms.proposals.request-error",
        "cms.proposals.reject-publication-success",
        "cms.proposals.reject-publication-error",
        "cms.proposals.unpublish-success",
        "cms.proposals.unpublish-error",
        "cms.proposals.expire-success",
        "cms.proposals.expire-error",
        "cms.proposals.error-dragging",
        "cms.proposals.proposal-duplicated",
        "cms.proposals.proposal-duplicated-error"
      ],
      "i18nMissing": [
        "want_reject_publish"
      ]
    },
    {
      "id": "cms-proposal-editor",
      "name": "Proposal editor (content, coupon, push, AI images)",
      "area": "CMS",
      "label": "cms.proposal.general-data",
      "routes": [
        "cms.proposals.new",
        "cms.proposals.edit"
      ],
      "code": [
        "app/controllers/cms/proposals/new.js",
        "app/routes/cms/proposals/new.js",
        "app/models/proposal.js",
        "app/models/proposal-extra.js",
        "app/models/coupon.js",
        "app/enums/proposal-types.js",
        "app/enums/image-ai-options.js",
        "app/services/pusher.js"
      ],
      "models": [
        "proposal",
        "proposal-language",
        "proposal-extra",
        "coupon",
        "push-notification",
        "category",
        "segment",
        "commerce",
        "campaign",
        "extra",
        "language"
      ],
      "modules": [
        "cms",
        "ai (image generation)",
        "totem_channel (optional)",
        "proposal_ranking (optional)",
        "marketingActivityCampaigns (optional)"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "storeManager",
        "commerce",
        "crmmanager",
        "weboperator",
        "content_creator"
      ],
      "description": "The main content-authoring form (800+ lines of controller): multi-language title/description, app/web/totem images with optional AI generation, audio attachment, audience (gender, age range, segments), channels, store or centre scope, date and event windows, category, coupon, an optional push notification sent with the proposal, polls, extra attributes and a shareable public URL.",
      "comments": "Several notable sub-capabilities. (1) AI image generation: `openImageGenerationModal` → `generateImage` with options from `enums/image-ai-options`, results polled over Pusher, then `downloadImage`. (2) Audio: `uploadAudioFile`/`deleteAudioFile`/`mountAudio` with a 10 MB limit enforced by the error copy. (3) An embedded push notification validated by `_isValidPushNotification()` only when `wantToSendPushNotification` — and cross-validated so the push cannot start before the event starts. (4) Coupons: a `coupon` sub-record with its own validations collected into `couponErrors`. (5) Extra attributes: `requiredProposalExtras` are reset and re-checked on each submit, with the same untranslated `\"<label> is required\"` message as the store form. (6) `copyUrlTemplate` copies a public proposal URL built from the centre's template. Submission is a chain — `submit` → validate proposal + coupon + push → `patchProposal` → `saveExtras` → `afterSaved` — and errors go through the same `mapErrors` + `user-error`/`api-error` classification as the campaign editor.",
      "useCases": [
        "Publishing a centre event with images, an event window and a push notification announcing it.",
        "A store manager creating a store-scoped promotion with a redeemable coupon.",
        "Generating a hero image with AI rather than briefing a designer.",
        "Attaching an audio description for accessibility.",
        "Running a poll attached to a proposal, with a max-votes cap.",
        "Targeting a proposal to a segment and an age/gender range.",
        "Sharing a proposal outside the app via its public URL."
      ],
      "happyPaths": [
        {
          "name": "Centre event with push notification",
          "steps": [
            "User fills name, description, category, type `center`, channels and the app image.",
            "Marks it an event and sets event start/end dates within the proposal's date range.",
            "Audience: gender, min/max age and optionally segments.",
            "Enables the push notification, whose start is not before the event start.",
            "`submit` validates the proposal, the coupon (if enabled) and the push, saves the proposal, then the extras.",
            "The proposal is created in draft and can be requested/published from the list."
          ]
        },
        {
          "name": "AI image generated",
          "steps": [
            "User opens the image-generation modal and picks an AI option.",
            "`generateImage` requests it; the result arrives over the Pusher channel.",
            "User reviews it and downloads/attaches it as the proposal image."
          ]
        },
        {
          "name": "Store coupon promotion",
          "steps": [
            "Type is set to `commerce` and the store is selected.",
            "Coupon is enabled with text, a usage limit and (for centre-scoped proposals) its stores.",
            "Multi-exchange is enabled with a per-user exchange limit.",
            "Save succeeds and customers can take the coupon in the app."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Mandatory content missing",
          "cause": "Name, description, category, type, start date, end date, min/max age, gender or channels blank.",
          "handling": "`error.proposal.name.required`, `error.proposal.description.required`, `error.proposal.category.required`, `error.proposal.proposalType.required`, `error.proposal.startDate.required`, `error.proposal.end-date.required`, `error.proposal.minimumAge.required`, `error.proposal.maximumAge.required`, `error.proposal.gender.required`, `error.proposal.channel.required`."
        },
        {
          "name": "Images missing",
          "cause": "`appImage` (always) or `totemImage` (totem channel) not set.",
          "handling": "`required` + `proposal.app-image` / `proposal.totem-image`."
        },
        {
          "name": "Store missing on a store proposal",
          "cause": "`proposalType === 'commerce'` without a commerce.",
          "handling": "`required` + commerce."
        },
        {
          "name": "End date before start date",
          "cause": "Date ordering violated.",
          "handling": "`error.proposal.end-date.after`."
        },
        {
          "name": "Event dates invalid",
          "cause": "Event marked but start/end missing, or event end before event start.",
          "handling": "`error.proposal.eventStartDate.required`, `error.proposal.event-end-date.required`, `error.proposal.event-end-date.after`."
        },
        {
          "name": "Push notification starts before the event",
          "cause": "Embedded push start earlier than the event start.",
          "handling": "`error.proposal.event-start-date.after-push-notification-startdate`."
        },
        {
          "name": "Segment required but missing",
          "cause": "`isSegmentRequired` true with no segments.",
          "handling": "`error.proposal.segment.required`."
        },
        {
          "name": "Poll without an activity",
          "cause": "`poll` enabled without a campaign.",
          "handling": "`error.proposal.campaign.required` ('Required Activity')."
        },
        {
          "name": "Images embedded in the description",
          "cause": "Rich-text description contains an `<img>`.",
          "handling": "`error.proposal.description.image-not-suported`."
        },
        {
          "name": "Coupon incomplete",
          "cause": "Coupon enabled without text, usage limit, stores (centre-scoped) or per-user exchange limit (multi-exchange).",
          "handling": "`error.coupon.text.required`, `error.coupon.usageLimit.required`, `error.coupon.commerces.required`, `error.coupon.exchange-limit-by-user.required`, collected into `couponErrors`."
        },
        {
          "name": "Audio upload fails or is too large",
          "cause": "Upload error or file over 10 MB.",
          "handling": "`cms.proposal.audio-upload-error` names the 10 MB limit explicitly."
        },
        {
          "name": "Required extra attribute empty",
          "cause": "A centre-defined required proposal extra has no value.",
          "handling": "`extraError` set to an untranslated `\"<label> is required\"` string."
        },
        {
          "name": "API rejection",
          "cause": "Server-side validation or failure.",
          "handling": "`errorDetails` from `mapErrors`, then `user-error` (4xx) or `api-error` (5xx). Same `errors[0].status.charAt()` fragility as the campaign editor."
        },
        {
          "name": "AI image generation fails",
          "cause": "Generation service error.",
          "handling": "Caught by `.catch((err) => …)` in the generation chain; no dedicated translated message exists for this path."
        },
        {
          "name": "URL copy fails",
          "cause": "Clipboard API unavailable or blocked.",
          "handling": "Only the success path has copy (`cms.proposal.copy-url-success`); a blocked clipboard is silent."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `cms.proposals.new.image-generation.gif` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "cms.proposal.general-data",
        "cms.proposal.extra-attributes",
        "cms.proposal.copy-url-success",
        "cms.proposal.code-params.user-name.description",
        "cms.proposal.audio-upload-error",
        "error.proposal.name.required",
        "error.proposal.description.required",
        "error.proposal.category.required",
        "error.proposal.channel.required",
        "error.proposal.proposalType.required",
        "error.proposal.startDate.required",
        "error.proposal.end-date.required",
        "error.proposal.end-date.after",
        "error.proposal.eventStartDate.required",
        "error.proposal.event-end-date.required",
        "error.proposal.event-end-date.after",
        "error.proposal.gender.required",
        "error.proposal.minimumAge.required",
        "error.proposal.maximumAge.required",
        "error.proposal.segment.required",
        "error.proposal.campaign.required",
        "error.proposal.description.image-not-suported",
        "error.proposal.event-start-date.after-push-notification-startdate",
        "error.coupon.text.required",
        "error.coupon.usageLimit.required",
        "error.coupon.commerces.required",
        "error.coupon.exchange-limit-by-user.required",
        "user-error",
        "api-error"
      ],
      "i18nMissing": [
        "cms.proposals.new.image-generation.gif"
      ]
    },
    {
      "id": "cms-push-notification-list",
      "name": "Push notification list & publishing",
      "area": "CMS",
      "label": "title.header.push-notifications / menu.navbar.push-notification.index",
      "routes": [
        "cms.push-notifications",
        "cms.push-notifications.index",
        "cms.push-notifications.show"
      ],
      "code": [
        "app/controllers/cms/push-notifications/index.js",
        "app/routes/cms/push-notifications/index.js",
        "app/enums/push-notification-status.js",
        "app/abilities/push-notification.js"
      ],
      "models": [
        "push-notification",
        "push-firebase-config"
      ],
      "modules": [
        "cms"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "weboperator",
        "content_creator"
      ],
      "description": "Lists push notifications with subject, segment, frequency, date window, status and delivery stats (active app users, sent, opened), with publish, duplicate, detail, edit and delete actions.",
      "comments": "Four statuses (draft, scheduled, active, archived). Duplication has a dedicated temporal guard: a push scheduled in the past cannot be duplicated (`cms.push-notification-duplicated-startdate-after-now-error`), because the copy would otherwise be born invalid. Duplicates are named with the `cms.push-notification.copy-of` prefix. The list also surfaces the filters a push targeted, so it is auditable after the fact.",
      "useCases": [
        "Checking how many app users a push reached and how many opened it.",
        "Re-sending a successful notification by duplicating and rescheduling it.",
        "Publishing a prepared draft at the right moment.",
        "Auditing which audience a past notification went to."
      ],
      "happyPaths": [
        {
          "name": "Draft published",
          "steps": [
            "User opens the list and clicks publish on a draft.",
            "The notification moves to scheduled/active and begins delivering at its start datetime.",
            "Sent and opened counts populate on the list."
          ]
        },
        {
          "name": "Notification duplicated and rescheduled",
          "steps": [
            "User duplicates a notification whose start date is still in the future.",
            "A copy is created with the `copy-of` prefix.",
            "The user adjusts the date and publishes it."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Duplicating a past notification",
          "cause": "Source push has a start datetime in the past.",
          "handling": "`cms.push-notification-duplicated-startdate-after-now-error` ('Push notifications scheduled in the past cannot be duplicated')."
        },
        {
          "name": "Duplication fails",
          "cause": "API error.",
          "handling": "`cms.push-notification-duplicated-error`."
        },
        {
          "name": "Invalid record",
          "cause": "An action is invoked without a valid model.",
          "handling": "Throws `TypeError('model' is not a valid push notification)`."
        },
        {
          "name": "Action not permitted",
          "cause": "`push-notification` ability denies publish/edit/delete for the role.",
          "handling": "Icons hidden."
        }
      ],
      "i18nKeys": [
        "title.header.push-notifications",
        "menu.navbar.push-notification.index",
        "push-notification.new",
        "push-notification.subject",
        "push-notification.segment",
        "push-notification.frequency",
        "push-notification.active-app-users",
        "push-notification.sent",
        "push-notification.opened",
        "push-notification.status",
        "push-notification.state",
        "push-notification.filters",
        "cms.push-notification.copy-of",
        "cms.push-notification-duplicated-error",
        "cms.push-notification-duplicated-startdate-after-now-error"
      ],
      "i18nMissing": []
    },
    {
      "id": "cms-push-notification-editor",
      "name": "Push notification editor (scheduling, geofencing, deep links)",
      "area": "CMS",
      "label": "cms.push-notification.general-data",
      "routes": [
        "cms.push-notifications.new",
        "cms.push-notifications.edit"
      ],
      "code": [
        "app/controllers/cms/push-notifications/new.js",
        "app/routes/cms/push-notifications/new.js",
        "app/models/push-notification.js",
        "app/enums/push-notification-frequencies.js",
        "app/enums/push-notification-action-types.js",
        "app/enums/push-engagement-options.js"
      ],
      "models": [
        "push-notification",
        "segment",
        "proposal",
        "campaign"
      ],
      "modules": [
        "cms",
        "marketingActivityCampaigns (optional)",
        "foreground_location (for on-location)"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "weboperator",
        "content_creator"
      ],
      "description": "Composes a push notification: subject and message with personalisation variables, target segment, send frequency (single or recurring), an optional geofence (latitude/longitude for on-location delivery), an image, and an action that either opens the app or deep-links to a specific proposal.",
      "comments": "Validation is heavily conditional on `frequency`: `endDatetime` is only required for non-single sends, and latitude/longitude only for `on-location`, each with explicit range checks (±90 / ±180). `startDatetime` must be in the future — a rule that also makes editing a scheduled push fail once its time has passed. `actionType: 'proposal'` requires `actionData.proposal_id`, validated both by presence and by a custom rule, with `resetActionData` clearing it when the type changes. `checkImageSize` guards the image, and the activity-campaign picker only appears when the centre flag is on, behind a confirmation modal (`onAcceptActivityCampaignModal`).",
      "useCases": [
        "Announcing an event to a segment the evening before.",
        "Triggering a geofenced message when customers are near the centre.",
        "Deep-linking a notification straight to the relevant proposal.",
        "Setting up a recurring reminder over a date range.",
        "Personalising the message with the recipient's name."
      ],
      "happyPaths": [
        {
          "name": "Single scheduled push with a deep link",
          "steps": [
            "User sets subject, message and a future start datetime.",
            "Frequency is single sending, so no end datetime is required.",
            "Action type is `proposal` and a proposal is searched and selected, filling `actionData.proposal_id`.",
            "A segment is chosen and the image passes the size check.",
            "`submit` validates and saves; the push appears as scheduled."
          ]
        },
        {
          "name": "Geofenced recurring push",
          "steps": [
            "Frequency is set to `on-location`, requiring latitude and longitude.",
            "Coordinates are entered within ±90 / ±180.",
            "An end datetime after the start is supplied.",
            "The notification saves and fires when matching users are nearby."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Core fields missing",
          "cause": "Subject, message, segment, frequency, action type or start datetime blank.",
          "handling": "`required` + the relevant label, `push-notification.start-datetime`, `push-notification.end-datetime`."
        },
        {
          "name": "Start datetime not in the future",
          "cause": "`startDatetime <= now`.",
          "handling": "`push-notification.startdate-after-now-error` ('The date and time must be later than the current date and time'). This also blocks re-saving a push whose scheduled time has already arrived."
        },
        {
          "name": "End before start",
          "cause": "Non-single frequency with `endDatetime` earlier than `startDatetime`.",
          "handling": "`push-notification.startdate-before-enddate-error` and `error.push-notification.event-end-date.after-startdate` — two overlapping rules for the same condition."
        },
        {
          "name": "Coordinates out of range",
          "cause": "On-location frequency with latitude outside ±90 or longitude outside ±180.",
          "handling": "`push-notification.latitude-range-error` / `push-notification.longitude-range-error`."
        },
        {
          "name": "Proposal action without a proposal",
          "cause": "`actionType === 'proposal'` and `actionData.proposal_id` empty.",
          "handling": "Both a presence and a custom rule fail with `required` + `push-notification.action-data.proposal`."
        },
        {
          "name": "Image too large",
          "cause": "`checkImageSize` fails.",
          "handling": "Guarded in a bare `try/catch {}` in the controller, so the failure is swallowed — a silent path worth fixing."
        },
        {
          "name": "Submit while invalid",
          "cause": "`model.validate()` false.",
          "handling": "`submit` returns false early; the field errors are rendered on the tabs."
        }
      ],
      "i18nKeys": [
        "cms.push-notification.general-data",
        "cms.notification.code-params.user-name.description",
        "push-notification.start-datetime",
        "push-notification.end-datetime",
        "push-notification.startdate-after-now-error",
        "push-notification.startdate-before-enddate-error",
        "push-notification.latitude-range-error",
        "push-notification.longitude-range-error",
        "push-notification.action-type.default",
        "push-notification.action-type.proposal",
        "error.push-notification.event-end-date.after-startdate"
      ],
      "i18nMissing": []
    },
    {
      "id": "cms-categories",
      "name": "Content categories",
      "area": "CMS",
      "label": "title.header.cms.categories / menu.navbar.category.show",
      "routes": [
        "cms.categories",
        "cms.categories.index",
        "cms.categories.new",
        "cms.categories.edit"
      ],
      "code": [
        "app/controllers/cms/categories/index.js",
        "app/controllers/cms/categories/new.js",
        "app/routes/cms/categories/*.js",
        "app/models/category.js",
        "app/models/category-language.js",
        "app/abilities/category.js"
      ],
      "models": [
        "category",
        "category-language"
      ],
      "modules": [
        "cms"
      ],
      "roles": [
        "root",
        "admin"
      ],
      "description": "Hierarchical, multi-language category tree used to classify proposals and stores, with parent selection, per-language names and a CSV export of the whole taxonomy.",
      "comments": "Root/admin only — the tightest CMS permission, because categories are structural. `searchParentCategories` + `selectParentCategory` build the hierarchy, and `updateTranslationName` keeps per-language names in step. `downloadCSV` exports the tree. Categories are also referenced by the app-button manager: MOF types `stores` and `proposals` require a category (`error.mof.category.required`), so deleting a category can break app navigation.",
      "useCases": [
        "Defining the content taxonomy a centre's app navigation is built on.",
        "Adding a sub-category under an existing parent.",
        "Translating category names for a multilingual centre.",
        "Exporting the taxonomy for review or migration."
      ],
      "happyPaths": [
        {
          "name": "Sub-category created and translated",
          "steps": [
            "Admin opens CMS → Categories → new and enters the default-language name.",
            "Searches and selects a parent category.",
            "Fills the name for each additional language.",
            "Saves; the category becomes selectable on proposals, stores and app buttons."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Name missing",
          "cause": "Blank name.",
          "handling": "Model validation rejects via the shared form save pipeline."
        },
        {
          "name": "Missing translation",
          "cause": "A translatable name left blank for a non-default language.",
          "handling": "`error.all-translation-fields.required` where the multi-language guard is enforced."
        },
        {
          "name": "Deleting a category in use",
          "cause": "Proposals, stores or app buttons still reference it.",
          "handling": "Rejected by the API; the generic form error path shows the detail. Note the app-button validation only checks presence, so an orphaned reference surfaces as a broken button rather than a clear error."
        },
        {
          "name": "Export fails",
          "cause": "API error on the CSV download.",
          "handling": "No dedicated error message on `downloadCSV`."
        }
      ],
      "i18nKeys": [
        "title.header.cms.categories",
        "menu.navbar.category.show",
        "cms.new-category",
        "cms.categories.general-data",
        "error.all-translation-fields.required",
        "error.mof.category.required"
      ],
      "i18nMissing": []
    },
    {
      "id": "cms-mofs",
      "name": "App button manager (MOFs)",
      "area": "CMS",
      "label": "title.header.cms.mofs / menu.navbar.cms.mof",
      "routes": [
        "cms.mofs",
        "cms.mofs.index"
      ],
      "code": [
        "app/controllers/cms/mofs/index.js",
        "app/routes/cms/mofs/index.js",
        "app/models/mof.js",
        "app/models/mof-language.js",
        "app/enums/mof-types.js",
        "app/enums/button-mof-types.js",
        "app/enums/button-sub-types.js",
        "app/enums/mof-requires.js",
        "app/enums/mofs-status.js",
        "app/abilities/mof.js"
      ],
      "models": [
        "mof",
        "mof-language",
        "campaign",
        "category",
        "language"
      ],
      "modules": [
        "cms",
        "plus the module behind each button type (lottery, fideliza, cashback, fidcard, forms, tally, commerces_map, proposal_ranking, …)"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "weboperator"
      ],
      "description": "Configures the mobile app's navigation buttons: their type, label (per language), image, position, date window, visibility, access requirements and audience targeting — covering both the fixed standard menu and custom buttons, including groups of buttons.",
      "comments": "The most configuration-dense screen in the app (750-line controller) because each of ~21 button types changes which fields are relevant. Two top-level kinds: `custom` buttons and `menu_static` (the app's standard menu entries — home, search, loyalty, events, news, stores, profile, info, plan your visit, services, commerces map), and for `menu_static` the model deliberately drops the start/end date requirements (`attrsToValidate.pop()` twice). Type-conditional required fields: URL types need a `url`; `stores`/`proposals` need a `categoryId`; `lotteries`/`booking_appointment`/`loyalty_qr_commerce` need a `campaignId`; `group` needs `mofsCount`. A long list of `canShow…`/`canEdit…` getters decides field visibility per type and role. Uniqueness rules are enforced in the controller, not the model: at most one cashback button (`cms.mofs.only-one-cashback-type`) and one fidcard button (`cms.mofs.only-one-fidcard-type`), and a button already inside a group cannot be grouped again (`cms.mofs.mof-already-grouped`). Buttons can require `dni` or `email_validated` before opening, and can target or exclude a user type (`selectedUserTypeId` / `selectedUserTypeNegativeId`). Positions are managed against `persistedPositions`/`availablePositions`. Relationship loads are defensive (`relationship.load().catch(() => null)`), so a broken reference degrades rather than crashes.",
      "useCases": [
        "Adding a 'Digital games' button to the app home that opens a specific game.",
        "Pointing a button at a store or proposal category so it opens a filtered list.",
        "Gating a loyalty button so only users with a VAT number or a validated email can open it.",
        "Grouping several buttons under one entry to keep the home screen clean.",
        "Scheduling a seasonal button to appear and disappear on set dates.",
        "Reordering the standard menu for a particular centre.",
        "Showing a button only to a given user type, or hiding it from one."
      ],
      "happyPaths": [
        {
          "name": "Custom URL button published",
          "steps": [
            "User creates a custom button, picks a URL type and enters the URL.",
            "Uploads the button image and sets the label for each language.",
            "Sets start and end dates and a free position.",
            "Optionally adds an access requirement and a user-type target.",
            "`save` passes `validates()` and the button appears in the app."
          ]
        },
        {
          "name": "Button group created",
          "steps": [
            "User creates a `group` button and selects the buttons to nest (`groupableMofs`).",
            "`mofsCount` is non-zero so validation passes.",
            "The group is saved and the app shows one entry that expands."
          ]
        },
        {
          "name": "Standard menu adjusted",
          "steps": [
            "User switches the view type to `menu_static` and picks a sub-type.",
            "Because the standard menu is permanent, the date requirements are dropped.",
            "Position and label are set and the standard entry is updated."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Core fields missing",
          "cause": "Image, type, name, start date or end date blank on a custom button.",
          "handling": "`error.mof.image.required`, `error.mof.type.required`, `error.mof.name.required`, `error.mof.start-date.required`, `error.mof.end-date.required`."
        },
        {
          "name": "Start date after end date",
          "cause": "`validateDates(startDate, endDate)` fails.",
          "handling": "`error.mof.start-date.date` ('The date must not be greater than the end date')."
        },
        {
          "name": "URL type without a URL",
          "cause": "`url`, `custom_url`, `external_url`, `forms` or `tally` type with no URL.",
          "handling": "`error.mof.url.required`."
        },
        {
          "name": "Category type without a category",
          "cause": "`stores` or `proposals` type with no `categoryId`.",
          "handling": "`error.mof.category.required`."
        },
        {
          "name": "Activity type without an activity",
          "cause": "`lotteries`, `booking_appointment` or `loyalty_qr_commerce` without `campaignId`.",
          "handling": "`error.mof.campaign.required`; the lottery-specific variant is `error.mof.lottery.required`."
        },
        {
          "name": "Group without buttons",
          "cause": "`group` type with `mofsCount` 0.",
          "handling": "`error.mof.mofs.required` ('Grouping of buttons required')."
        },
        {
          "name": "Duplicate singleton type",
          "cause": "A second cashback or fidcard button.",
          "handling": "`cms.mofs.only-one-cashback-type` / `cms.mofs.only-one-fidcard-type`."
        },
        {
          "name": "Button already in a group",
          "cause": "Trying to add a button that another group already contains.",
          "handling": "`cms.mofs.mof-already-grouped`, backed by `hasMofInAnotherGroup`."
        },
        {
          "name": "Save fails",
          "cause": "API error.",
          "handling": "`cms.mofs.save-error` ('An error occurred while trying to save the button, please contact the administrator')."
        },
        {
          "name": "Delete fails",
          "cause": "API error.",
          "handling": "`cms.mofs.delete-error`."
        },
        {
          "name": "Broken relationship",
          "cause": "A referenced campaign or category no longer exists.",
          "handling": "`relationship.load().catch(() => null)` and `.catch(() => [])` degrade silently — the button renders with an empty reference rather than reporting the broken link."
        }
      ],
      "i18nKeys": [
        "title.header.cms.mofs",
        "menu.navbar.cms.mof",
        "mof-type.custom",
        "mof-type.standard",
        "mof-type.url",
        "mof-type.custom-url",
        "mof-type.external-url",
        "mof-type.proposals",
        "mof-type.stores",
        "mof-type.delivery",
        "mof-type.tao-wifi",
        "mof-type.digital-games",
        "mof-type.booking-appointment",
        "mof-type.gift-card-admin",
        "mof-type.proposal-ranking",
        "mof-type.tally",
        "mof-type.forms",
        "static-button-type.home",
        "static-button-type.search",
        "static-button-type.loyalty",
        "static-button-type.events",
        "static-button-type.news",
        "static-button-type.stores",
        "static-button-type.profile",
        "static-button-type.information",
        "static-button-type.plan_your_visit",
        "static-button-type.services",
        "static-button-type.commerces-map",
        "mof-requires.dni",
        "mof-requires.email-validated",
        "error.mof.image.required",
        "error.mof.type.required",
        "error.mof.name.required",
        "error.mof.start-date.required",
        "error.mof.start-date.date",
        "error.mof.end-date.required",
        "error.mof.url.required",
        "error.mof.category.required",
        "error.mof.campaign.required",
        "error.mof.lottery.required",
        "error.mof.mofs.required",
        "cms.mofs.save-error",
        "cms.mofs.delete-error",
        "cms.mofs.mof-already-grouped",
        "cms.mofs.only-one-cashback-type",
        "cms.mofs.only-one-fidcard-type"
      ],
      "i18nMissing": []
    },
    {
      "id": "cms-multicentre-proposals",
      "name": "Multicentre proposals",
      "area": "CMS",
      "label": "title.header.multicentre-proposals / menu.navbar.multicentre-proposals.show",
      "routes": [
        "cms.multicentre-proposals",
        "cms.multicentre-proposals.index",
        "cms.multicentre-proposals.new",
        "cms.multicentre-proposals.detail"
      ],
      "code": [
        "app/controllers/cms/multicentre-proposals/index.js",
        "app/controllers/cms/multicentre-proposals/new.js",
        "app/routes/cms/multicentre-proposals/*.js",
        "app/models/multicentre-proposal.js",
        "app/abilities/multicentre-proposals.js"
      ],
      "models": [
        "multicentre-proposal",
        "category",
        "language",
        "commerce"
      ],
      "modules": [
        "multicenter_proposals"
      ],
      "roles": [
        "root",
        "admin",
        "manager"
      ],
      "description": "Authors one piece of content that is published across several centres at once, with per-language fields, app and web images, channels and a store selection that can span centres.",
      "comments": "Distinguished from ordinary proposals by two things: the store selection can cover multiple centres (with a select-all helper), and translation completeness is enforced up front — `_validateProposalTranslationFields(model.languages)` checks every language before `submit` proceeds and blocks with `error.all-translation-fields.required`. Note the helper's return value is inverted relative to its name (`isValidated` is true when a language *failed*), which works but reads backwards. `commerce` is only required when no `commerces` are selected, so either a single store or a multi-store selection satisfies it. Detail is read-only.",
      "useCases": [
        "A retail group publishing the same campaign across all of its shopping centres.",
        "Keeping brand messaging consistent without re-entering it per centre.",
        "Reviewing a multicentre proposal's reach from the detail view."
      ],
      "happyPaths": [
        {
          "name": "Group-wide proposal published",
          "steps": [
            "User creates a multicentre proposal with name, description, images and channels.",
            "Every language's translatable fields are completed.",
            "Stores across the participating centres are selected (or select-all is used).",
            "`submit` passes the translation check and the model validations and the proposal is created for each centre."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Incomplete translations",
          "cause": "Any language missing a translatable field.",
          "handling": "`error.all-translation-fields.required` ('All translatable fields must be translate') and submit is aborted before validation."
        },
        {
          "name": "Core content missing",
          "cause": "Name, description, app image, web image or channels blank.",
          "handling": "`error.proposal.name.required`, `error.proposal.description.required`, `required` + app/web image, `error.proposal.channel.required`."
        },
        {
          "name": "No stores at all",
          "cause": "Neither a single commerce nor any `commerces`.",
          "handling": "The conditional presence rule on `commerce` fires."
        },
        {
          "name": "Images in the description",
          "cause": "Rich text contains an `<img>`.",
          "handling": "`error.proposal.description.image-not-suported`."
        },
        {
          "name": "Module disabled",
          "cause": "`multicenter_proposals` off.",
          "handling": "Menu entry hidden and the route's ability check blocks entry."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `cms.multicentre-proposal.general-data`, `multicentreProposal` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "title.header.multicentre-proposals",
        "menu.navbar.multicentre-proposals.show",
        "cms.new-multicentre-proposal",
        "cms.multicentre-proposal.general-data",
        "error.all-translation-fields.required",
        "modules-multicenter-proposals"
      ],
      "i18nMissing": [
        "cms.multicentre-proposal.general-data",
        "multicentreProposal"
      ]
    },
    {
      "id": "intranet-sales",
      "name": "Store sales declaration & month closing",
      "area": "Intranet",
      "label": "title.header.sales / menu.navbar.intranet.sale.index",
      "routes": [
        "intranet.sales",
        "intranet.sales.index"
      ],
      "code": [
        "app/controllers/intranet/sales/index.js",
        "app/controllers/intranet/sales.js",
        "app/routes/intranet/sales/index.js",
        "app/models/intranet-sale.js",
        "app/models/closed-month.js",
        "app/models/estimated-sale.js",
        "app/abilities/intranet-sale.js",
        "app/abilities/closed-month.js",
        "app/enums/shop-month-states.js",
        "app/components/intranet-sales-close-month.js",
        "app/components/close-month-modal.js",
        "app/components/reject-close-month-modal.js",
        "app/services/xlsx.js"
      ],
      "models": [
        "intranet-sale",
        "closed-month",
        "estimated-sale",
        "commerce",
        "sale-concept",
        "intranet-sales-year"
      ],
      "modules": [
        "intranet",
        "intranet-sovos (optional)",
        "intranet-yardi (optional)"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "scmanager",
        "sales",
        "storeManager",
        "commerce",
        "owner"
      ],
      "description": "The monthly sales cycle for every store: a calendar of declared daily sales, manual entry, bulk XLSX import, estimated-sales declaration, month closing and validation, reopen requests, rejection with an automatic communication to the store, and an audit log of who changed what.",
      "comments": "The most state-heavy part of the intranet. A store-month moves through four states (`without_sales`, `unclosed`, `closed`, `validated`) each with its own colour and icon. Role logic is deliberately split: `canReopenMonth` is true for admin/manager on a validated month, while `canRequestToReopenMonth` is the *inverse* role test for everyone else — so store users request and management grants. `canAddNewSale` combines the `closed-month` ability with 'the month is not validated', and the ability itself requires the month to be in the past, not already closed, and to have at least one sale. New-sale dates are clamped to the selected month (`minAllowedNewSaleDate`/`maxAllowedNewSaleDate`). Type guards are unusually strict: `closeMonth`, `confirmCloseMonth` and `rejectCloseMonth` all throw `TypeError`/`IllegalStateError` when given the wrong shape. Rejecting a close creates an `intranet-communication` addressed to the store, so the rejection is auditable and the store is notified in one step. `parseXLSX` reads the file through the `xlsx` service into draft sales for review before `saveSales` commits them, and `downloadTemplate` emits the expected shape. IntelliSales/Sovos centres hide the manual delete column and show Sovos totals instead (`canShowSovosSales` additionally requires a non-zero value).",
      "useCases": [
        "A store manager entering daily turnover for the month.",
        "Importing a month of sales from the till system as an XLSX instead of typing them.",
        "Declaring estimated sales when actuals are not yet available.",
        "A store closing its month so the centre can consolidate.",
        "Centre management validating or rejecting a closed month, with the store notified automatically.",
        "A store asking management to reopen a validated month to fix an error.",
        "Auditing who entered or changed a figure via the log table."
      ],
      "happyPaths": [
        {
          "name": "Month declared, closed and validated",
          "steps": [
            "Store user selects the store and month; the calendar shows the current state.",
            "Daily sales are added with date, concept, amount and ticket count — dates constrained to the month.",
            "When complete, the user closes the month; the confirmation modal names the store and month (`intranet.close_month-message`).",
            "`confirmCloseMonth` validates the types and marks the month closed; `informative-message.close-month.success.message` confirms.",
            "Management later validates it, moving the state to `validated`."
          ]
        },
        {
          "name": "Bulk import from XLSX",
          "steps": [
            "User downloads the template, fills it and opens the import modal.",
            "`parseXLSX` reads the file into draft sales shown for review.",
            "User confirms; `saveSales` persists them and `sales.upload-xlsx-success` is shown."
          ]
        },
        {
          "name": "Close rejected and store notified",
          "steps": [
            "Management opens the reject-close modal on a closed month and writes a reason.",
            "`rejectCloseMonth` validates the communication model and saves both the rejection and an `intranet-communication` to the store.",
            "`informative-message.reject-close-month.success.message` confirms and the store sees the communication."
          ]
        },
        {
          "name": "Estimated sales declared",
          "steps": [
            "User with `declareEstimatesSales` opens the estimated-sales form.",
            "The month's start/end dates and the store are attached automatically.",
            "Save succeeds with `intranet.sales.estimated-sales-has-been-succesfully-saved`."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Sale in a future date",
          "cause": "Date later than today.",
          "handling": "`error.sale.date.invalid_future` (\"You can't add sales of future dates\")."
        },
        {
          "name": "Sale in a closed month",
          "cause": "Target month already closed.",
          "handling": "`error.sale.date.closed-month-error` (\"You can't add sales in closed months\")."
        },
        {
          "name": "Amount missing or zero",
          "cause": "`amount` blank or exactly 0.",
          "handling": "Both a presence and a custom rule fail with `required` + amount — zero is explicitly rejected, not treated as 'no sales'."
        },
        {
          "name": "Date missing",
          "cause": "`date` blank.",
          "handling": "`required` + date."
        },
        {
          "name": "Month cannot be closed",
          "cause": "`closed-month#canCreate` false — month is current/future, already closed, or has no sales.",
          "handling": "The close action is unavailable; no message explains which of the three reasons applies."
        },
        {
          "name": "Close fails",
          "cause": "API error during closing.",
          "handling": "`informative-message.close-month.error.message`; the optimistic `validated: true` is rolled back in the catch."
        },
        {
          "name": "Reject-close fails",
          "cause": "API error saving the rejection or its communication.",
          "handling": "`informative-message.reject-close-month.error.message`."
        },
        {
          "name": "Wrong argument types",
          "cause": "`closeMonth`/`confirmCloseMonth`/`rejectCloseMonth` called with a non-Commerce, non-Month or non-IntranetCommunication.",
          "handling": "Throws `TypeError`/`IllegalStateError` — developer-facing, surfaced as a crash rather than a message."
        },
        {
          "name": "Invalid XLSX",
          "cause": "Wrong format or unreadable file.",
          "handling": "`sales.upload-xlsx-error` ('The file you have uploaded is not a valid format'); `errorParsingMessage` holds the parse detail."
        },
        {
          "name": "Estimated sales save fails",
          "cause": "API error.",
          "handling": "`intranet.sales.estimated-sales-has-failed` via `_estimatedSalesHasBeenFailedSaving`."
        },
        {
          "name": "API error with a detail key",
          "cause": "The API returns `errors[0].meta['detail-key']`.",
          "handling": "That key is translated directly into `errorMessage` — a neat server-driven message channel, but it throws if `meta` is absent."
        },
        {
          "name": "Month already validated",
          "cause": "Trying to add or delete sales in a validated month.",
          "handling": "`canAddNewSale` is false and the delete column is not rendered."
        }
      ],
      "i18nKeys": [
        "title.header.sales",
        "menu.navbar.intranet.sale.index",
        "title.header.intranet-sale",
        "shopMonth.state.without_sales",
        "shopMonth.state.unclosed",
        "shopMonth.state.closed",
        "shopMonth.state.validated",
        "intranet.close_month-message",
        "informative-message.close-month.success.message",
        "informative-message.close-month.error.message",
        "informative-message.reject-close-month.success.message",
        "informative-message.reject-close-month.error.message",
        "sales.upload-xlsx-success",
        "sales.upload-xlsx-error",
        "sales.sale-concepts",
        "sale.date",
        "sale.amount",
        "sale.concept",
        "sale.tickets",
        "error.sale.date.invalid_future",
        "error.sale.date.closed-month-error",
        "intranet.sales.estimated-sales-has-been-succesfully-saved",
        "intranet.sales.estimated-sales-has-failed",
        "communication.reject-close-month.subject"
      ],
      "i18nMissing": []
    },
    {
      "id": "intranet-sales-state",
      "name": "Sales status overview & Yardi export",
      "area": "Intranet",
      "label": "title.header.sales (state tab)",
      "routes": [
        "intranet.sales.state"
      ],
      "code": [
        "app/controllers/intranet/sales/state.js",
        "app/routes/intranet/sales/state.js",
        "app/services/yardi.js",
        "app/models/yardi-upload.js",
        "app/models/shop-property.js",
        "app/components/yardi-errors-modal.js"
      ],
      "models": [
        "commerce",
        "yardi-upload",
        "shop-property",
        "closed-month"
      ],
      "modules": [
        "intranet",
        "intranet-yardi"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "scmanager (`canReadState`, `canAccessYardi`)"
      ],
      "description": "Cross-store view of a month's declaration status with totals, plus the Yardi integration: validate the export, generate it, upload it to Yardi and download it for inspection.",
      "comments": "Yardi is a four-step flow — `checkErrors` → `createYardi` → `uploadYardi` → `downloadYardi` — with a dedicated errors modal (`yardi-errors-modal`) listing the stores that block the export and an `exportYardi` action to proceed anyway. The two blocking per-store conditions are explicit: a store must have a linked property (`yardi.shop_property_error`) and a specific tenant code (`yardi.tenant_code_error`). An important business rule is stated in the copy: only *validated* sales are uploaded, closed-but-unvalidated sales are not (`yardi.warn_errors_validated_sales_upload`). `setDownloadDate` scopes the download and `filteredShops` narrows the overview.",
      "useCases": [
        "Seeing at a glance which stores have not yet closed the month.",
        "Pushing the month's validated sales into Yardi for rent and service-charge calculation.",
        "Fixing the stores that block an export before running it.",
        "Downloading the generated file to reconcile against Yardi."
      ],
      "happyPaths": [
        {
          "name": "Month exported to Yardi",
          "steps": [
            "Manager opens the state tab for the month and reviews the per-store status and totals.",
            "`checkErrors` runs and reports no blocking stores.",
            "`createYardi` generates the export from the validated sales.",
            "`uploadYardi` pushes it and `informative-message.upload-yardi.success` confirms.",
            "`downloadYardi` retrieves the file for the records."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Stores block the export",
          "cause": "One or more stores lack a linked property or a tenant code.",
          "handling": "`yardi.warn_errors_exporting` ('There are errors in the export. Please, review them before continue.') and the errors modal lists each store with `yardi.shop_property_error` or `yardi.tenant_code_error`."
        },
        {
          "name": "Upload fails",
          "cause": "Yardi rejects the file or the request errors.",
          "handling": "`informative-message.upload-yardi.error` (\"The Yardi file hasn't been uploaded correctly. Please, try again or contact with an admin.\")."
        },
        {
          "name": "Unvalidated sales silently excluded",
          "cause": "Months are closed but not validated.",
          "handling": "Warned about rather than blocked: `yardi.warn_errors_validated_sales_upload`. The export completes with less data than the user may expect."
        },
        {
          "name": "Module or role missing",
          "cause": "Yardi module off or role lacks `canAccessYardi`.",
          "handling": "The Yardi actions are not rendered."
        }
      ],
      "i18nKeys": [
        "yardi.title",
        "yardi.warn_errors_exporting",
        "yardi.shop_property_error",
        "yardi.tenant_code_error",
        "yardi.warn_errors_validated_sales_upload",
        "informative-message.upload-yardi.success",
        "informative-message.upload-yardi.error",
        "informative-message.export-crm-csv.success.message"
      ],
      "i18nMissing": []
    },
    {
      "id": "intranet-sales-export",
      "name": "Sales & audit-log CSV download",
      "area": "Intranet",
      "label": "intranet.sales.download.date-error",
      "routes": [
        "intranet.sales",
        "intranet.sales.index"
      ],
      "code": [
        "app/controllers/intranet/sales.js",
        "app/services/csv.js"
      ],
      "models": [
        "intranet-sale",
        "intranet-sales-detail"
      ],
      "modules": [
        "intranet"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "scmanager, sales (per `intranet-sale` abilities)"
      ],
      "description": "Downloads sales data for a date range as CSV — both the sales themselves (date, concept, amount, tickets, store, Yardi code) and the change log (who changed what and when).",
      "comments": "Date-range validation is done locally before any request (`_validateDownloadCsvRange` → `validDownloadCsvDates`) with three distinct messages, including one that caps the end date at today. The log columns (`log.creator-email`, `log.created-at`, `log.concept`, `log.amount`) make this the audit trail for sales figures. `exportIntranetSalesDetailsCsv` covers the detail rows.",
      "useCases": [
        "Pulling a quarter of sales for the centre's own reporting.",
        "Producing an audit trail of who modified a disputed figure.",
        "Reconciling declared sales against the Yardi export."
      ],
      "happyPaths": [
        {
          "name": "Range exported",
          "steps": [
            "User picks a start and end date within the allowed window.",
            "`_validateDownloadCsvRange` passes.",
            "`downloadCSV` builds the file with the sales or log columns and the browser downloads it."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Start after end",
          "cause": "Dates reversed.",
          "handling": "`intranet.sales.download.start-date-error` / `intranet.sales.download.date-error` ('Date from must be less than date to')."
        },
        {
          "name": "End date in the future",
          "cause": "End date after today.",
          "handling": "`intranet.sales.download.end-date-error` ('The end date cannot be later than today')."
        },
        {
          "name": "Dates missing",
          "cause": "Either bound blank.",
          "handling": "`intranet.sales.download.start-date-missing` / `intranet.sales.download.end-date-missing`; `validDownloadCsvDates` returns false and no request is made."
        }
      ],
      "i18nKeys": [
        "intranet.sales.download.date-error",
        "intranet.sales.download.start-date-error",
        "intranet.sales.download.end-date-error",
        "intranet.sales.download.start-date-missing",
        "intranet.sales.download.end-date-missing",
        "log.creator-email",
        "log.created-at",
        "log.concept",
        "log.amount",
        "commerce.yardi-code"
      ],
      "i18nMissing": []
    },
    {
      "id": "intranet-sales-years",
      "name": "Annual sales statement",
      "area": "Intranet",
      "label": "title.header.intranet.sales-years / menu.navbar.sales-years",
      "routes": [
        "intranet.sales-years",
        "intranet.sales-years.index",
        "intranet.sales-years.edit",
        "intranet.sales-years.show"
      ],
      "code": [
        "app/controllers/intranet/sales-years/index.js",
        "app/controllers/intranet/sales-years/edit.js",
        "app/controllers/intranet/sales-years/show.js",
        "app/routes/intranet/sales-years/*.js",
        "app/models/intranet-sales-year.js",
        "app/abilities/intranet-sales-year.js",
        "app/enums/intranet-sales-years-states.js"
      ],
      "models": [
        "intranet-sales-year",
        "commerce",
        "intranet-communication",
        "yardi-upload"
      ],
      "modules": [
        "intranet",
        "intranet-yardi (optional)"
      ],
      "roles": [
        "root",
        "admin",
        "manager"
      ],
      "description": "The yearly declaration each store must submit: a 12-month breakdown with totals, moving through unrequested → pending → validated/rejected, with deviation analysis against declared monthly sales, a DMI adjustment, PDF generation and a Yardi upload.",
      "comments": "Management-only (root/admin/manager). The index can enable or disable the statement campaign for all stores (`enableSalesYears`/`disableSalesYears`) and flags `unvalidatedCommerces` — computed by treating only `without_sales` and `validated` months as settled, so any store with an unclosed or merely closed month shows up. `isEveryMonthValidated` gates the year-level validation. The show view adds the deviation level and the DMI value update (the code and translations never expand the acronym), and `rejectSaleYear` does something notable: it sets the state to `rejected` *and* creates an `intranet-communication` to the store using pre-written subject and message translations, so rejection always comes with an explanation. `createPDF` produces the signed statement. Navigation note: the side-menu entry for this feature is commented out in `utils/get-side-nav-menu.js`, so the statement is only reachable through the link to `intranet.sales-years.edit` on the sales screen or by typing the URL.",
      "useCases": [
        "Collecting each tenant's annual turnover declaration for rent review.",
        "Comparing the annual declaration against the sum of monthly declarations to spot deviation.",
        "Rejecting an inconsistent statement with an automatic notification to the store.",
        "Updating the DMI value after a negotiation.",
        "Producing the PDF statement for the lease file.",
        "Pushing the validated annual figures to Yardi."
      ],
      "happyPaths": [
        {
          "name": "Statement validated",
          "steps": [
            "Manager enables the statement; stores see it as pending.",
            "A store completes the 12-month breakdown; totals are computed.",
            "Manager opens the show view, checks the deviation level against monthly sales.",
            "Every month is validated, so `validateSaleYear` sets the state to `validated`.",
            "`createPDF` is used to file the statement."
          ]
        },
        {
          "name": "DMI updated",
          "steps": [
            "Manager opens the update-DMI modal and enters a new value.",
            "`updateDMI` saves it and `intranet.sales-years.update-dmi-success-message` confirms."
          ]
        },
        {
          "name": "Statement rejected with notice",
          "steps": [
            "Manager clicks reject; the state is set to `rejected`.",
            "An `intranet-communication` is created with the standard reject subject and message and pushed to the store.",
            "The store receives the communication and resubmits."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "DMI update fails",
          "cause": "API error.",
          "handling": "`intranet.sales-years.update-dmi-error-message` — explicitly says the value was not updated and to contact support."
        },
        {
          "name": "Yardi upload fails",
          "cause": "API/Yardi error.",
          "handling": "`intranet.sales-years.yardi-upload-error-message`."
        },
        {
          "name": "Rejection communication fails",
          "cause": "The rejection saves but the communication does not.",
          "handling": "A bare `.catch(() => {})` — the statement can end up rejected with the store never told, which is the most consequential silent failure in this area."
        },
        {
          "name": "Year cannot be validated",
          "cause": "Not every month is validated.",
          "handling": "`isEveryMonthValidated` is false and `unvalidatedCommerces` lists the offenders; the validate action is withheld."
        },
        {
          "name": "Enable/disable fails",
          "cause": "API error toggling the campaign.",
          "handling": "`.catch(() => {})` on the index — silent."
        },
        {
          "name": "Role insufficient",
          "cause": "Not root/admin/manager.",
          "handling": "The ability blocks the route and hides the menu entry."
        },
        {
          "name": "Not discoverable from the menu",
          "cause": "The `intranet-sales-years` menu item is commented out.",
          "handling": "Management users who don't know the URL or the link on the sales screen cannot find the feature at all."
        }
      ],
      "i18nKeys": [
        "title.header.intranet.sales-years",
        "menu.navbar.sales-years",
        "intranet.sales-years.status.unrequested",
        "intranet.sales-years.status.pending",
        "intranet.sales-years.status.rejected",
        "intranet.sales-years.status.validated",
        "intranet.sales-years.declared",
        "intranet.sales-years.deviation-level",
        "intranet.sales-years.dmi",
        "intranet.sales-years.update-dmi-value",
        "intranet.sales-years.update-dmi-success-message",
        "intranet.sales-years.update-dmi-error-message",
        "intranet.sales-years.yardi-upload-error-message",
        "intranet.sales-years.reject-communication-subject",
        "intranet.sales-years.reject-communication-message",
        "intranet.sales-year.button-show"
      ],
      "i18nMissing": []
    },
    {
      "id": "intranet-communications",
      "name": "Intranet communications (threads, confirmations, authorisations)",
      "area": "Intranet",
      "label": "title.header.communication / menu.navbar.intranet.communication.index",
      "routes": [
        "intranet.communications",
        "intranet.communications.index",
        "intranet.communications.new",
        "intranet.communications.show"
      ],
      "code": [
        "app/controllers/intranet/communications/index.js",
        "app/controllers/intranet/communications/show.js",
        "app/routes/intranet/communications/show.js",
        "app/models/intranet-communication.js",
        "app/abilities/intranet-communication.js",
        "app/enums/communication-types.js",
        "app/components/form-intranet-communication.js",
        "app/components/intranet-communication-thread.js",
        "app/components/communication-state.js",
        "app/components/eula-communications-modal.js"
      ],
      "models": [
        "intranet-communication",
        "intranet-staff",
        "commerce",
        "staff-member",
        "intranet-subtype"
      ],
      "modules": [
        "intranet"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "commerce",
        "storeManager",
        "scmanager",
        "services"
      ],
      "description": "Internal messaging between centre management and stores: typed communications (issues, access authorisations, general notices) with threads and responses, per-recipient read/confirm tracking, state changes, urgency flags, a signed confirmation PDF and a CSV export of recipients.",
      "comments": "Permission logic here is the most explicit in the codebase and uses dedicated error classes rather than booleans: `changeState` throws `NotAllowedError` when the user is not management, when their staff level equals the communication's `from_role`, or when the type is not `issue`/`access`; `confirm` throws `IllegalStateError` if already confirmed and `NotAllowedError` if the user's level may not confirm; `respondTo` throws when the responder's role does not match. A business rule is encoded in a validation message: an access authorisation must be dated at least 48 hours after creation unless the Urgent flag is set (`error.communication.date.invalid`). Confirmation is tracked per recipient (`total_confirmed`, `confirmed`, `viewed`, `received`) and a per-recipient PDF is downloadable from `/v3/intranet_communications/:id/confirm_receipt`, saved as `<email>_confirmation.pdf`. The index can filter by source and type (`changeSource`, `changeType`, `changeFiltersWithSource`) and uses a v3 meta shape (`metaV3`).",
      "useCases": [
        "Management broadcasting an operational notice to all stores and seeing who has read and confirmed it.",
        "A store raising an issue (e.g. a maintenance fault) and following the thread to resolution.",
        "Requesting access authorisation for contractors, with companion details and a 48-hour notice rule.",
        "Downloading a signed confirmation PDF as proof a store acknowledged a notice.",
        "Exporting the recipient list with read/confirm status for compliance.",
        "Duplicating a recurring notice."
      ],
      "happyPaths": [
        {
          "name": "Notice sent and confirmed",
          "steps": [
            "Management creates a communication, picks a subtype and selects the recipient stores.",
            "Stores receive it; each recipient's `received`/`viewed` state updates as they open it.",
            "A store user clicks confirm; `confirm` checks it is not already confirmed and that their level may confirm, then sets `confirmed`.",
            "`total_confirmed` increases on the index and management downloads the confirmation PDF."
          ]
        },
        {
          "name": "Access authorisation with companions",
          "steps": [
            "A store creates an access communication at least 48 hours ahead, listing companions.",
            "Validation passes and the authorisation is sent.",
            "Management changes its state as it is processed."
          ]
        },
        {
          "name": "Recipients exported",
          "steps": [
            "Management opens the show view and triggers the CSV export of recipients.",
            "`informative-message.export-crm-csv.success.message` confirms the emailed file."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Access date too soon",
          "cause": "Authorisation dated less than 48 hours after creation without the Urgent flag.",
          "handling": "`error.communication.date.invalid` spells out both the rule and the escape hatch."
        },
        {
          "name": "No recipient store",
          "cause": "No store selected.",
          "handling": "`error.communication.commerce.required` ('Select at least one shop')."
        },
        {
          "name": "Companion data incomplete",
          "cause": "A companion row left partly blank.",
          "handling": "`error.communication.companion.required` ('Some companions have blank data')."
        },
        {
          "name": "State change not allowed",
          "cause": "Non-management user, same level as `from_role`, or a type other than issue/access.",
          "handling": "Throws `NotAllowedError` with a specific message per case — precise, but surfaced as an exception rather than a UI message."
        },
        {
          "name": "Double confirmation",
          "cause": "Confirming an already-confirmed communication.",
          "handling": "Throws `IllegalStateError('only can be confirmed communications that haven\\'t been confirmed before')`."
        },
        {
          "name": "Response from the wrong role",
          "cause": "`respondTo` called by a role the communication does not accept.",
          "handling": "Throws `NotAllowedError` naming the `from_role`."
        },
        {
          "name": "Staff update fails",
          "cause": "API error updating a recipient's state.",
          "handling": "`intranet.communications.error` ('Error updating staff member')."
        },
        {
          "name": "Confirmation PDF unavailable",
          "cause": "The recipient has no signature on file.",
          "handling": "`error.signature.not_exist` ('The user does not have signature')."
        },
        {
          "name": "Detail not permitted",
          "cause": "`canShowDetail`/`canDetail` false for the role.",
          "handling": "The detail action is hidden; `canShowAll` additionally limits whose communications are listed."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `error.communication.store.required`, `intranet.communications.terms-and-conditions` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "title.header.communication",
        "menu.navbar.intranet.communication.index",
        "intranet.new-communication",
        "communications.subject",
        "communications.type",
        "communications.state",
        "communications.urgency",
        "communications.creator",
        "communications.datetime",
        "communication.confirmed",
        "communication.viewed",
        "communication.received",
        "communication.role",
        "communication.commerce-name",
        "communications.show.download-confirmation-pdf",
        "error.communication.date.invalid",
        "error.communication.commerce.required",
        "error.communication.companion.required",
        "intranet.communications.error",
        "error.signature.not_exist"
      ],
      "i18nMissing": [
        "error.communication.store.required",
        "intranet.communications.terms-and-conditions"
      ]
    },
    {
      "id": "intranet-subtypes",
      "name": "Communication subtypes",
      "area": "Intranet",
      "label": "title.header.subtypes / menu.navbar.intranet.subtype.index",
      "routes": [
        "intranet.subtypes",
        "intranet.subtypes.index",
        "intranet.subtypes.new",
        "intranet.subtypes.edit"
      ],
      "code": [
        "app/routes/intranet/subtypes/index.js",
        "app/controllers/intranet/subtypes/edit.js",
        "app/models/intranet-subtype.js",
        "app/abilities/intranet-subtype.js",
        "app/components/form-intranet-subtype.js",
        "app/components/intranet-subtypes-list.js"
      ],
      "models": [
        "intranet-subtype"
      ],
      "modules": [
        "intranet"
      ],
      "roles": [
        "root",
        "admin",
        "manager (`intranet-subtype` ability `canCreate`)"
      ],
      "description": "CRUD for the categories that classify intranet communications, so stores and management can filter and route messages consistently.",
      "comments": "Deletion is handled on the route (`deleteModel`/`showDeleteModal`) via the shared `show-confirm-deletion` service and throws `Error(\"deleteModel(): parameter 'model' is not a valid DS.Model\")` on a bad argument. The model carries no validations of its own, so correctness depends entirely on the API.",
      "useCases": [
        "Separating maintenance issues from commercial notices from access requests.",
        "Giving the communications list meaningful filter options.",
        "Retiring a subtype that is no longer used."
      ],
      "happyPaths": [
        {
          "name": "Subtype created and used",
          "steps": [
            "Manager creates a subtype with a name.",
            "It becomes selectable when composing a communication and as a filter on the list."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Invalid model on delete",
          "cause": "`deleteModel` called without a valid record.",
          "handling": "Throws a developer-facing `Error`."
        },
        {
          "name": "Deleting a subtype in use",
          "cause": "Communications still reference it.",
          "handling": "API rejection; there are no client-side validations on this model at all, so every rule lives server-side."
        }
      ],
      "i18nKeys": [
        "title.header.subtypes",
        "menu.navbar.intranet.subtype.index",
        "title.communication.subtypes",
        "intranet.new-subtype"
      ],
      "i18nMissing": []
    },
    {
      "id": "intranet-files",
      "name": "Files management",
      "area": "Intranet",
      "label": "title.header.files / menu.navbar.intranet.file.index",
      "routes": [
        "intranet.files",
        "intranet.files.index",
        "intranet.files.new",
        "intranet.files.edit",
        "intranet.files.show"
      ],
      "code": [
        "app/controllers/intranet/files/index.js",
        "app/controllers/intranet/files/new.js",
        "app/routes/intranet/files/*.js",
        "app/models/intranet-file.js",
        "app/abilities/intranet-file.js",
        "app/enums/intranet-file-roles.js",
        "app/services/explore-files.js",
        "app/services/api-files.js",
        "app/components/add-folder-modal.js",
        "app/components/nf-file-manager.js",
        "app/components/file-gallery.js"
      ],
      "models": [
        "intranet-file",
        "commerce"
      ],
      "modules": [
        "intranet"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "commerce",
        "storeManager",
        "scmanager"
      ],
      "description": "A shared document library with folders, where each file or folder is published to a role audience (all / commerce / management) or to specific stores, and can be downloaded by the audiences allowed to see it.",
      "comments": "Visibility is the core concept: `enums/intranet-file-roles` defines three audiences and, for each, which staff levels may *assign* it — only admin/manager/scmanager can publish to `all` or `management`, while `commerce` is additionally assignable by commerce-level users. `canSelectRole` reflects this. The model validates conditionally through helper predicates: `fileUrl` is required only when the entry is not a directory (`isNotDirectory`), and `commerces` only when the file targets specific stores (`isToCommerces`); `roles` has both a presence rule and an inclusion rule over `['management','commerce','owner','all']` — note `owner` is accepted here but is not one of the three enum options offered in the UI. Navigation state lives in the `explore-files` service (`changeFolder`), and folder creation reports `folder.success-created`.",
      "useCases": [
        "Publishing the centre's operating manual to all stores.",
        "Sharing a confidential document with management only.",
        "Sending a specific contract to one store.",
        "Organising documents into folders by year or topic.",
        "A store downloading the forms it needs."
      ],
      "happyPaths": [
        {
          "name": "Document published to all stores",
          "steps": [
            "Manager creates a folder (`folder.success-created`) and opens it.",
            "Uploads a file, names it and selects the `all` role.",
            "Validation passes (name and file URL present, role valid).",
            "Stores see the file in their library and download it."
          ]
        },
        {
          "name": "Store-specific document",
          "steps": [
            "Manager sets the audience to specific stores and selects them.",
            "`commerces` presence rule is satisfied and the file saves.",
            "Only the selected stores see it."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Name missing",
          "cause": "Blank name.",
          "handling": "`error.file.name.required`."
        },
        {
          "name": "No file attached",
          "cause": "Non-directory entry without a file URL.",
          "handling": "`error.file.url.required` ('File is required')."
        },
        {
          "name": "No role selected",
          "cause": "`roles` blank or outside the allowed list.",
          "handling": "`error.file.roles.required` ('Select a role')."
        },
        {
          "name": "Store-targeted file with no stores",
          "cause": "`isToCommerces` with an empty `commerces`.",
          "handling": "`error.file.commerces.required` ('Select at least one shop')."
        },
        {
          "name": "Upload rejected",
          "cause": "Disallowed type or oversized file.",
          "handling": "`error.file.type.not_allowed`, `error.file.oversize`, `error.file.not_allowed`."
        },
        {
          "name": "Role not assignable by this user",
          "cause": "A commerce-level user tries to publish to `all` or `management`.",
          "handling": "`canSelectRole` restricts the options rather than erroring."
        },
        {
          "name": "Download not permitted",
          "cause": "`canDownload` false for the user's role on that file.",
          "handling": "The download action is hidden."
        }
      ],
      "i18nKeys": [
        "title.header.files",
        "menu.navbar.intranet.file.index",
        "file.new",
        "file.name",
        "file.size",
        "file.type",
        "file.visibility",
        "file.creator_id",
        "file.updated_at",
        "file.roles.all",
        "file.roles.commerce",
        "file.roles.management",
        "folder.success-created",
        "new_folder",
        "error.file.name.required",
        "error.file.url.required",
        "error.file.roles.required",
        "error.file.commerces.required",
        "error.file.type.not_allowed",
        "error.file.oversize",
        "error.file.not_allowed"
      ],
      "i18nMissing": []
    },
    {
      "id": "intranet-footfall",
      "name": "Footfall (flows) & Yardi upload",
      "area": "Intranet",
      "label": "title.header.footfall / menu.navbar.footfall",
      "routes": [
        "intranet.flows",
        "intranet.flows.index",
        "intranet.flows.import"
      ],
      "code": [
        "app/controllers/intranet/flows/index.js",
        "app/controllers/intranet/flows/import.js",
        "app/routes/intranet/flows/*.js",
        "app/models/intranet-flow.js",
        "app/abilities/intranet-flow.js",
        "app/services/yardi.js"
      ],
      "models": [
        "intranet-flow",
        "load",
        "yardi-upload"
      ],
      "modules": [
        "intranet",
        "flows",
        "intranet-yardi (optional)"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "scmanager",
        "owner"
      ],
      "description": "Daily visitor-flow figures for the centre — footfall, cars, cinema admissions, plus day flags (open, bank holiday, comparable) — entered day by day or bulk-imported from a spreadsheet, with an optional Yardi upload.",
      "comments": "`selectNextDay` makes sequential daily entry fast, and `isValidate`/`total` give immediate feedback. The import warns about a real data-quality trap in its own copy: every field must carry an amount, zero included, because blank cells are not treated as zero (`intranet.flow.import_warning_errors`). Date-range filtering validates ordering locally (`intranet.flows.index.date-error`). The Yardi upload is behind `yardiIsEnabled` with its own modal (`openModalYardi`/`uploadYardi`).",
      "useCases": [
        "Recording yesterday's footfall, car-park entries and cinema admissions.",
        "Loading a month of footfall exported from the counting system.",
        "Marking bank holidays and comparable days so year-on-year analysis is fair.",
        "Pushing footfall to Yardi alongside sales."
      ],
      "happyPaths": [
        {
          "name": "Daily figures entered",
          "steps": [
            "User opens Footfall and selects the date.",
            "Enters footfall, cars and cinema figures and ticks the day flags.",
            "`submit` saves and `info.footfall.success.message` confirms.",
            "`selectNextDay` moves to the following day for the next entry."
          ]
        },
        {
          "name": "Month imported",
          "steps": [
            "User downloads the example, fills every numeric cell (zeros included) and imports.",
            "Columns map to date, footfall, cars, cinemas and the day flags.",
            "The import runs and the figures appear in the list."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Blank cells in the import",
          "cause": "Fields left empty instead of 0.",
          "handling": "`intranet.flow.import_warning_errors` warns before importing: 'All fields must have an amount, even if it is 0'. Rows with blanks are rejected server-side."
        },
        {
          "name": "Date range reversed",
          "cause": "Date-from after date-to on the list filter.",
          "handling": "`intranet.flows.index.date-error` ('Date from must be less than date to')."
        },
        {
          "name": "Save fails",
          "cause": "API error.",
          "handling": "`res.errors?.length` is inspected in the submit path, but there is no dedicated translated error message for a failed save."
        },
        {
          "name": "Yardi upload fails",
          "cause": "Yardi rejects the footfall file.",
          "handling": "`informative-message.upload-yardi.error`."
        },
        {
          "name": "Module off",
          "cause": "`flows` module disabled.",
          "handling": "`intranet-flow#canRead` false; menu entry hidden."
        }
      ],
      "i18nKeys": [
        "title.header.footfall",
        "menu.navbar.footfall",
        "footfall",
        "footfall.cars",
        "footfall.cinemas",
        "footfall.day-opens",
        "footfall.day-bank-holiday",
        "footfall.day-comparable",
        "footfall.day-opens.import",
        "footfall.day-bank-holiday.import",
        "footfall.day-comparable.import",
        "info.footfall.success.message",
        "intranet.flows.index.date-error",
        "intranet.flow.import_warning_errors",
        "flows.date"
      ],
      "i18nMissing": []
    },
    {
      "id": "intranet-safte",
      "name": "SAFT-E / IntelliSales import",
      "area": "Intranet",
      "label": "title.header.safte / menu.navbar.intranet.saft",
      "routes": [
        "intranet.safte",
        "intranet.safte.import",
        "intranet.safte.import.index"
      ],
      "code": [
        "app/controllers/intranet/safte/import.js",
        "app/controllers/intranet/safte/import/index.js",
        "app/routes/intranet/safte/*.js",
        "app/models/anonymazed-saft.js",
        "app/models/sovos-file.js",
        "app/models/module-intranet-sovos.js",
        "app/abilities/intranet-safte.js",
        "app/enums/sovos-file-status.js",
        "app/enums/intranet-saft-index-filters.js"
      ],
      "models": [
        "anonymazed-saft",
        "sovos-file",
        "commerce",
        "closed-month"
      ],
      "modules": [
        "intranet",
        "intranet-sovos"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "storeManager"
      ],
      "description": "Lets a store upload its SAFT-E fiscal file for a month so sales are derived from the official accounting export instead of being typed in, with anonymisation and a history of uploaded files and their processing status.",
      "comments": "A wizard with three steps and no per-step attribute validation (`validateAttrs: null` throughout); the real check is `anonymazedSaft.validate()` requiring a file, a commerce and a month. The month picker is filtered by `notValidatedMonths`, computed as the store's closed months minus those already validated — so a store cannot upload against a settled month. The upload posts directly and converts a non-OK response into a rejection (`response.json().then((error) => Promise.reject(error))`), which is a cleaner pattern than most fetches here. The index tab lists history with amount, commerce, month, name and status, and the sales screen branches on `hasIntelliSalesEnabled` to hide manual editing when this is the source of truth.",
      "useCases": [
        "A Portuguese/Spanish tenant submitting its SAFT-E file so the centre derives turnover from certified accounting data.",
        "Avoiding manual sales entry entirely for IntelliSales-enabled stores.",
        "Checking whether last month's file was processed successfully."
      ],
      "happyPaths": [
        {
          "name": "SAFT file uploaded and processed",
          "steps": [
            "Store user opens IntelliSales → import and selects the store.",
            "Picks a month from the not-yet-validated list.",
            "Attaches the SAFT-E file and chooses the anonymisation option.",
            "`anonymazedSaft.validate()` passes and the file is uploaded.",
            "The history tab shows it with its processing status and the derived amount."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "File missing",
          "cause": "No SAFT file attached.",
          "handling": "`required` + `error.file.required` ('SAFT file')."
        },
        {
          "name": "Store missing",
          "cause": "No commerce selected.",
          "handling": "`required` + `error.commerce.required` ('Field required')."
        },
        {
          "name": "Month missing",
          "cause": "No month selected.",
          "handling": "`required` + `error.month.required`."
        },
        {
          "name": "Month already validated",
          "cause": "The chosen month has been settled.",
          "handling": "It is excluded from `notValidatedMonths`, so it cannot be chosen at all."
        },
        {
          "name": "Upload rejected by the API",
          "cause": "Malformed or non-conforming SAFT file.",
          "handling": "The non-OK response is parsed and rejected, then handled in `.catch((error) => …)`; the detail comes from the API."
        },
        {
          "name": "Sovos list unavailable",
          "cause": "The Sovos backend errors while loading the history.",
          "handling": "`intranet.sovos.error-title` + `intranet.sovos.error` ('An error has occurred and we were unable to load your information at this time. You can try again or reload the page.') — one of the better-written error states."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `error.month.required`, `sovosFile` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "title.header.safte",
        "menu.navbar.intranet.saft",
        "intranet.safte.upload-safte-data",
        "intranet.safte.anonymize-safte-data",
        "intranet.safte.sended-file",
        "intranet.safte.uploaded-files-history",
        "error.file.required",
        "error.commerce.required",
        "error.month.required",
        "intranet.sovos.error",
        "intranet.sovos.error-title"
      ],
      "i18nMissing": [
        "error.month.required",
        "sovosFile"
      ]
    },
    {
      "id": "intranet-consumptions",
      "name": "Utility consumptions",
      "area": "Intranet",
      "label": "title.header.consumption / menu.navbar.intranet.consumption",
      "routes": [
        "intranet.consumptions"
      ],
      "code": [
        "app/controllers/intranet/consumptions.js",
        "app/routes/intranet/consumptions.js",
        "app/models/consumption.js",
        "app/abilities/intranet-consumption.js",
        "app/enums/consumption-types.js"
      ],
      "models": [
        "consumption",
        "commerce"
      ],
      "modules": [
        "intranet",
        "consumptions"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "storeManager",
        "commerce"
      ],
      "description": "Records each store's electricity, gas and water consumption for a period, for service-charge apportionment and sustainability reporting.",
      "comments": "Small and self-contained: pick the store (`changeCommerce`), then add rows with type, value and a start/end period. Validation covers all three realistic mistakes — missing start date, missing end date, end before start, and a non-positive value (`0 < consumption`). Notably the end-before-start message is borrowed from the email-campaign namespace (`error.email-campaign.endDatetime.post_startDatetime`), which works but is a copy smell.",
      "useCases": [
        "Entering quarterly meter readings per tenant for service-charge recharges.",
        "Tracking centre-wide utility consumption for ESG reporting.",
        "Correcting a mis-entered reading."
      ],
      "happyPaths": [
        {
          "name": "Reading recorded",
          "steps": [
            "User selects the store.",
            "Adds a row: type electricity, a positive value and the period start and end.",
            "`update` validates and saves; the row appears in the table."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Start date missing",
          "cause": "Blank start date.",
          "handling": "`error.consumption.start-date.required`."
        },
        {
          "name": "End date missing",
          "cause": "Blank end date.",
          "handling": "`error.consumption.end-date.required`."
        },
        {
          "name": "End before start",
          "cause": "Period reversed.",
          "handling": "`error.email-campaign.endDatetime.post_startDatetime` — correct behaviour, borrowed copy."
        },
        {
          "name": "Non-positive value",
          "cause": "Consumption 0 or negative.",
          "handling": "`error.consumption.required` ('Consumption is required')."
        },
        {
          "name": "Module off",
          "cause": "`consumptions` module disabled.",
          "handling": "`intranet-consumption#canAccess` false; entry hidden."
        }
      ],
      "i18nKeys": [
        "title.header.consumption",
        "menu.navbar.intranet.consumption",
        "consumption.type",
        "consumption.value",
        "consumptions.electricity",
        "consumptions.gas",
        "consumptions.water",
        "error.consumption.start-date.required",
        "error.consumption.end-date.required",
        "error.consumption.required"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-centers-list",
      "name": "Centre list & export",
      "area": "Configuration",
      "label": "title.header.configuration.centers / menu.navbar.configuration.centers",
      "routes": [
        "configuration.centers",
        "configuration.centers.index"
      ],
      "code": [
        "app/controllers/configuration/centers/index.js",
        "app/routes/configuration/centers/index.js",
        "app/abilities/center.js"
      ],
      "models": [
        "center",
        "country"
      ],
      "modules": [],
      "roles": [
        "root",
        "admin, manager, weboperator read; root/admin create"
      ],
      "description": "Lists every centre the user can administer with its id, name, whether it has a mobile app and which modules are enabled, plus a CSV download.",
      "comments": "The `center` ability separates four concerns: `canRead` for root/admin/manager/weboperator, `canCreate` for root/admin only, `canUpdate` for root/admin/manager/scmanager, and `canShowProposal` for root/admin/manager. The 'Modules' column is the fastest way to see a centre's feature footprint, which matters because modules decide almost all feature visibility elsewhere.",
      "useCases": [
        "A group admin auditing which modules each centre has switched on.",
        "Creating a new centre during onboarding.",
        "Exporting the centre list for a licensing or billing review."
      ],
      "happyPaths": [
        {
          "name": "Centre list reviewed and exported",
          "steps": [
            "Admin opens Configuration → Centres.",
            "The list shows each centre with its mobile-app flag and module set.",
            "`downloadCSV` produces the export."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Create not permitted",
          "cause": "Role is manager/weboperator rather than root/admin.",
          "handling": "`canCreate` false; the new-centre action is hidden while the list stays readable."
        },
        {
          "name": "Export fails",
          "cause": "API error.",
          "handling": "No dedicated error message on `downloadCSV`."
        }
      ],
      "i18nKeys": [
        "title.header.configuration.centers",
        "menu.navbar.configuration.centers",
        "configuration.new-center",
        "configuration.center.center-has-mobile-app",
        "Modules"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-center-editor",
      "name": "Centre editor (modules, app config, integrations)",
      "area": "Configuration",
      "label": "configuration.center.general-data / menu.navbar.configuration.my-center",
      "routes": [
        "configuration.centers.new",
        "configuration.centers.edit"
      ],
      "code": [
        "app/controllers/configuration/centers/new.js",
        "app/routes/configuration/centers/new.js",
        "app/models/center.js",
        "app/models/app-config.js",
        "app/models/modules.js",
        "app/models/shop-property.js",
        "app/enums/modules.js",
        "app/enums/timezones.js",
        "app/enums/currencies.js"
      ],
      "models": [
        "center",
        "center-language",
        "app-config",
        "modules",
        "shop-property",
        "channel",
        "category",
        "commerce",
        "language",
        "country",
        "province"
      ],
      "modules": [
        "all — this screen is where they are switched on"
      ],
      "roles": [
        "root",
        "admin (create); root, admin, manager, scmanager (update)"
      ],
      "description": "The control panel for a whole tenant: identity and locale (name, languages, default language, timezone, latitude/longitude, email from/to), every feature module and its parameters, mobile-app configuration, themes, integration URLs (Fideliza, Fidcard, cashback, Tally, custom forms, chatbase, form-commerces), shop properties, extra attributes and translation uploads.",
      "comments": "The widest-reaching form in the product — nearly 500 lines of validations on the `center` model alone — and the reason the inventory treats modules as a first-class dimension. Validation style is unusual: most integration checks are written as `presence` rules whose `if` condition is 'the module is enabled AND the URL fails `urlRegexValidator`', so the error reads as 'required' even when the real problem is a malformed URL. Each integration has its own message (`configuration.center.fideliza-url.error`, `.fidcard-url.error`, `.cashback-url.error`, `.tally-url.error`, `.custom-forms.url.error`, `.ai.chatbase-url.error`, `.form-commerces-url.error`). Custom forms additionally validate a start-before-end date and a profile-button name capped at 20 characters. `_isValidForm()` validates the centre *and* every shop property and only saves when all pass, collecting messages from both into `infoErrors`. Other actions: `uploadTranslations` for bulk locale files, `manageAiChecks` and `handleLocationCheckboxChange` (which requires at least one location option — `configuration.center.location.enabled-error-message`), `onChangeEnablePacv`/`onChangeEnableAppPacv`, `clearPlaycenterCapacity`, `toggleMailing`, `copyUrlTemplate` and `copyUrlLotteryQr` for shareable URL templates, and `sovosOrganizationIdIsDisabled` gating the Sovos org id.",
      "useCases": [
        "Onboarding a new centre: locale, branding, modules and integration endpoints.",
        "Switching on a module (e.g. Fideliza or the lottery) for one centre only.",
        "Configuring the mobile app: home ordering (date vs recommended), channels, themes.",
        "Wiring an external tool by URL and having the form reject a malformed endpoint before it breaks an embed.",
        "Uploading a translation file rather than editing strings one by one.",
        "Defining shop properties used by the Yardi export.",
        "Setting the play-centre capacity, or clearing it when the module is turned off."
      ],
      "happyPaths": [
        {
          "name": "New centre configured",
          "steps": [
            "Admin fills name, app URL, languages and default language, timezone, coordinates and the from/to email addresses.",
            "Modules are switched on; each enabled integration's URL passes `urlRegexValidator`.",
            "App configuration, themes and channels are set.",
            "Shop properties are added; `_isValidForm()` validates the centre and each property.",
            "`saveCenter` persists everything and `configuration.center.center-saved` is shown."
          ]
        },
        {
          "name": "Integration enabled safely",
          "steps": [
            "Admin ticks the Fideliza module and pastes its URL plus the gift-card and gift-card-admin URLs.",
            "Each URL passes the validator, so the save is allowed and the embeds will work."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Identity or locale fields missing",
          "cause": "Name, app URL, default language, languages, timezone, latitude, longitude, from-email or to-email blank.",
          "handling": "`required` + the specific label for each; `appUrl` additionally has a `URL: true` rule."
        },
        {
          "name": "Integration enabled with a bad URL",
          "cause": "Module on and its URL fails the regex.",
          "handling": "The matching message fires — `configuration.center.fideliza-url.error`, `.fidcard-url.error`, `.cashback-url.error`, `.tally-url.error`, `.custom-forms-url.error`, `.ai.chatbase-url.error`, `.form-commerces-url.error`. Note these are modelled as presence rules, so the copy ('It must be a valid URL') is clearer than the rule type suggests."
        },
        {
          "name": "Custom-forms dates reversed",
          "cause": "Start date on or after the end date.",
          "handling": "`configuration.center.custom-forms-start-date-before-end-date`."
        },
        {
          "name": "Profile button misconfigured",
          "cause": "Profile button enabled without a name, or a name longer than 20 characters.",
          "handling": "`configuration.center.custom-forms-profile-button-error` / `configuration.center.custom-forms-profile-button-length-error`."
        },
        {
          "name": "No location option selected",
          "cause": "Location enabled but no sub-option ticked.",
          "handling": "`configuration.center.location.enabled-error-message` ('You should select at least one option')."
        },
        {
          "name": "Images in an EXTRA rich-text field",
          "cause": "An `<img>` pasted into a centre extra.",
          "handling": "`error.configuration.center.extra.image-not-suported`."
        },
        {
          "name": "Invalid shop property",
          "cause": "Any shop property fails its own validation.",
          "handling": "`_isValidForm()` returns false and the whole save is blocked; messages from all properties are merged into `infoErrors` — so the user may see several unrelated errors at once with no indication of which property each belongs to."
        },
        {
          "name": "Save rejected by the API",
          "cause": "Server-side validation.",
          "handling": "`infoErrors` is filled from `errors.map((elem) => elem.detail)` and `user-error` is shown as the banner."
        },
        {
          "name": "Translation upload fails",
          "cause": "Malformed locale file.",
          "handling": "Wrapped in `try/catch (err)`; there is no dedicated translated message for this path."
        }
      ],
      "i18nKeys": [
        "configuration.center.general-data",
        "configuration.center.modules",
        "configuration.center.app-configuration",
        "configuration.center.extra-attributes",
        "configuration.center.center-saved",
        "menu.navbar.configuration.my-center",
        "configuration.center.app-config.proposals-order.date",
        "configuration.center.app-config.proposals-order.recommended",
        "configuration.center.theaters.channel-app",
        "configuration.center.theaters.channel-web",
        "configuration.center.fideliza-url.error",
        "configuration.center.fidcard-url.error",
        "configuration.center.cashback-url.error",
        "configuration.center.tally-url.error",
        "configuration.center.custom-forms-url.error",
        "configuration.center.custom-forms.url.error",
        "configuration.center.custom-forms-start-date-before-end-date",
        "configuration.center.custom-forms-profile-button-error",
        "configuration.center.custom-forms-profile-button-length-error",
        "configuration.center.ai.chatbase-url.error",
        "configuration.center.form-commerces-url.error",
        "configuration.center.location.enabled-error-message",
        "error.configuration.center.extra.image-not-suported",
        "cms.proposal.copy-url-success",
        "user-error"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-staff-list",
      "name": "Staff member list, deletion & restore",
      "area": "Configuration",
      "label": "title.header.configuration.staff-members / menu.navbar.staff.show",
      "routes": [
        "configuration.staff-members",
        "configuration.staff-members.index"
      ],
      "code": [
        "app/controllers/configuration/staff-members/index.js",
        "app/routes/configuration/staff-members/index.js",
        "app/abilities/staff-member.js",
        "app/enums/staff-status.js",
        "app/enums/staff-members-filters.js"
      ],
      "models": [
        "staff-member",
        "center"
      ],
      "modules": [],
      "roles": [
        "per `staff-member` ability; creation is further limited by each level's `creators` list"
      ],
      "description": "Lists back-office users with name, email, level and status, and supports both deleting and restoring an account — so deletion is reversible rather than destructive.",
      "comments": "The `restore` action alongside `delete` is the notable part: deactivated accounts can be brought back, which matters for seasonal staff. Who may create whom is encoded per level in `enums/staff-levels` via a `creators` array — e.g. `root` can only be created by `root`, `storeManager` has an empty `creators` list so it cannot be created from the UI at all, and most other levels are creatable by admin and manager. Both actions throw `TypeError` when handed an invalid record.",
      "useCases": [
        "Onboarding and offboarding centre staff.",
        "Reactivating a seasonal employee's account for the next campaign.",
        "Auditing who has back-office access and at what level."
      ],
      "happyPaths": [
        {
          "name": "Account deactivated and restored",
          "steps": [
            "Admin deletes a leaver's account; the row moves to the inactive status.",
            "Months later the person returns and the admin clicks restore.",
            "The account is reactivated with its previous level and scope."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Invalid record",
          "cause": "`delete`/`restore` called without a valid model.",
          "handling": "Throws `TypeError('model' is not a valid staff-member)`."
        },
        {
          "name": "Level not creatable",
          "cause": "Target level's `creators` list excludes the current role (or is empty, as with `storeManager`).",
          "handling": "The level is not offered in `allowedLevels` on the form."
        },
        {
          "name": "Delete/restore rejected",
          "cause": "API error or insufficient permission.",
          "handling": "No dedicated message in this controller — the action simply does not take effect."
        }
      ],
      "i18nKeys": [
        "title.header.configuration.staff-members",
        "menu.navbar.staff.show",
        "staff-members.new",
        "restore",
        "level",
        "status"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-staff-editor",
      "name": "Staff member editor (levels, scope, API token)",
      "area": "Configuration",
      "label": "general-data",
      "routes": [
        "configuration.staff-members.new",
        "configuration.staff-members.edit"
      ],
      "code": [
        "app/controllers/configuration/staff-members/new.js",
        "app/routes/configuration/staff-members/new.js",
        "app/models/staff-member.js",
        "app/enums/staff-levels.js",
        "app/enums/access-to-account-types.js",
        "app/enums/permissions.js"
      ],
      "models": [
        "staff-member",
        "center",
        "building",
        "channel",
        "country",
        "dashboard",
        "commerce",
        "user-account-type"
      ],
      "modules": [
        "employees_segments (optional)"
      ],
      "roles": [
        "root",
        "admin, manager (subject to each level's `creators` list)"
      ],
      "description": "Creates and edits a back-office account: identity, level, which centres and stores it can see, which dashboards it gets, channel and country, contactability, GDPR acceptance, employee-account-type access, an API token to copy, and an action to email the user a password link.",
      "comments": "Scope is the heart of this form. `allowedLevels` is derived from the current user's role through the `creators` arrays, so the UI cannot grant a level the actor is not entitled to grant. `allowedCenters` limits which centres can be assigned, and `selectedCommercesFiltered` / `onChangeSelectedCommerces` restrict a `commerce`-level account to its stores — enforced by a conditional validation (`commerces` required when `level === 'commerce'`). `calibrator` level requires a default building. `dashboardsAssociatedToRole` pre-selects the dashboards that match the chosen level. `copyToken` exposes an API token for `external`-level integrations (`staff-member.copy-token-success`). `openModalSendPasswordEmail` → `confirmSendPasswordEmail` is the invitation flow, with `sendPasswordEmailIsDisabled` guarding it, and `disableSubmit` blocking premature saves. When the centre has employee segments, `accessToAccountTypes` must be non-empty.",
      "useCases": [
        "Creating a store-level account scoped to a single tenant.",
        "Giving a regional manager access to several centres at once.",
        "Issuing an `external` account with an API token for a system integration.",
        "Inviting a new user by emailing them a password link instead of setting a password manually.",
        "Restricting a CRM operator to specific employee account types."
      ],
      "happyPaths": [
        {
          "name": "Store-level account created and invited",
          "steps": [
            "Admin enters name, surname, email, channel and country and accepts GDPR on the user's behalf.",
            "Level is set to `commerce`, so the stores field appears and is filled.",
            "Centres are assigned from `allowedCenters`; dashboards default from the level.",
            "The account saves, then `openModalSendPasswordEmail` → `confirmSendPasswordEmail` emails the password link.",
            "The user completes `update_password/:token` to set their own password."
          ]
        },
        {
          "name": "API token issued",
          "steps": [
            "Admin creates an `external`-level account.",
            "`copyToken` copies the bearer token and `staff-member.copy-token-success` confirms.",
            "The integration authenticates with that token."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Identity fields missing",
          "cause": "Name, surname, email, level, channel, country or centres blank.",
          "handling": "`name-required`, `last-name-required`, `email-required`, `level-required`, `channel-required`, `country-required`, `centers-required`."
        },
        {
          "name": "Commerce level without stores",
          "cause": "`level === 'commerce'` and `commerces` empty.",
          "handling": "`error.file.commerces.required` ('Select at least one shop') — reused from the intranet-files namespace, so the message is correct but oddly namespaced."
        },
        {
          "name": "Calibrator without a default building",
          "cause": "`level === 'calibrator'` and `defaultBuilding` blank.",
          "handling": "`default-building-required`."
        },
        {
          "name": "Password invalid",
          "cause": "Blank, or failing the 10-char mixed-case + digit format; confirmation mismatch.",
          "handling": "`required` + pass, `pass-format`, `match-pass`."
        },
        {
          "name": "GDPR not accepted",
          "cause": "`eula` false.",
          "handling": "`error.user.gdpr`."
        },
        {
          "name": "Contactable without a phone",
          "cause": "`contactable` ticked with no phone number.",
          "handling": "Custom rule fails with `phone-required`."
        },
        {
          "name": "Employee account types missing",
          "cause": "Centre has employee segments and `accessToAccountTypes` is empty.",
          "handling": "`required` + `access-to-account-types`."
        },
        {
          "name": "Reference data unavailable",
          "cause": "Buildings or channels fail to load.",
          "handling": "`store.findAll(...).catch(() => [])` degrades to empty pickers — the form still opens but the user cannot complete the required fields and is given no reason."
        },
        {
          "name": "Level not grantable",
          "cause": "The actor's role is not in that level's `creators`.",
          "handling": "The level is absent from `allowedLevels`."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `access-to-account-types.client`, `access-to-account-types.employee-outsource`, `access-to-account-types.employee-shop` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "general-data",
        "staff-member.copy-token-success",
        "name-required",
        "last-name-required",
        "email-required",
        "level-required",
        "channel-required",
        "country-required",
        "centers-required",
        "default-building-required",
        "phone-required",
        "pass-format",
        "match-pass",
        "error.user.gdpr",
        "access-to-account-types",
        "root",
        "admin",
        "content-creator",
        "calibrator",
        "commerce",
        "crm-manager",
        "crm-operator",
        "external-api",
        "manager",
        "owner",
        "scmanager",
        "store-manager",
        "web-operator",
        "staff-level-sales",
        "staff-level-services"
      ],
      "i18nMissing": [
        "access-to-account-types.client",
        "access-to-account-types.employee-outsource",
        "access-to-account-types.employee-shop"
      ]
    },
    {
      "id": "config-channels",
      "name": "Channels",
      "area": "Configuration",
      "label": "title.header.configuration.channels / menu.navbar.channel.show",
      "routes": [
        "configuration.channels",
        "configuration.channels.index",
        "configuration.channels.new"
      ],
      "code": [
        "app/controllers/configuration/channels/index.js",
        "app/routes/configuration/channels/*.js",
        "app/models/channel.js"
      ],
      "models": [
        "channel"
      ],
      "modules": [],
      "roles": [
        "authenticated staff with configuration access"
      ],
      "description": "Manages the registration/publication channels (app, web, play centre, social wifi, totem, other) that customers, proposals and extra attributes are attributed to.",
      "comments": "Small list (id + name) but structurally load-bearing: channels are a required field on the customer model (`error.channels.required`), on proposals (`error.proposal.channel.required`), on extra attributes (`error.extra-attributes.channel.required`), on staff members (`channel-required`) and on the CSV import (`error.load.channels.required`). The dashboard also breaks registrations down by channel. There is no edit route — only index and new — so renaming a channel is not possible from the UI.",
      "useCases": [
        "Attributing registrations to the source that produced them for dashboard analysis.",
        "Publishing a proposal to the app but not the web.",
        "Adding a new acquisition channel such as a totem."
      ],
      "happyPaths": [
        {
          "name": "Channel added and used",
          "steps": [
            "User creates a channel with a name.",
            "It becomes selectable on customers, proposals, extras and staff members.",
            "Registrations attributed to it appear in the dashboard's channel breakdown."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Name missing",
          "cause": "Blank name.",
          "handling": "Rejected through the shared form save pipeline."
        },
        {
          "name": "No way to edit",
          "cause": "The router defines only `index` and `new`.",
          "handling": "A mistyped channel name cannot be corrected in the UI — a genuine gap rather than a guarded failure."
        }
      ],
      "i18nKeys": [
        "title.header.configuration.channels",
        "menu.navbar.channel.show",
        "configuration.new-channel",
        "channels.app",
        "channels.web",
        "channels.play-center",
        "channels.social-wifi",
        "channels.other",
        "error.channels.required"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-extra-attributes",
      "name": "Extra attributes",
      "area": "Configuration",
      "label": "title.header.configuration.extra-attributes / menu.navbar.configuration.extra",
      "routes": [
        "configuration.extra-attributes",
        "configuration.extra-attributes.index",
        "configuration.extra-attributes.new",
        "configuration.extra-attributes.edit"
      ],
      "code": [
        "app/controllers/configuration/extra-attributes/index.js",
        "app/controllers/configuration/extra-attributes/new.js",
        "app/routes/configuration/extra-attributes/*.js",
        "app/models/extra.js",
        "app/models/extra-language.js",
        "app/enums/extra-attribute-extra-types.js",
        "app/enums/extra-attribute-linked-models.js",
        "app/abilities/extra.js"
      ],
      "models": [
        "extra",
        "extra-language",
        "commerce-extra",
        "proposal-extra",
        "language"
      ],
      "modules": [
        "extra"
      ],
      "roles": [
        "root",
        "admin",
        "manager"
      ],
      "description": "Defines the custom fields a centre can add to stores and proposals: a label, a code, a data type (text, HTML, image, URL, address, audio, activity, form, custom form, gift card, gift-card admin) and the channels it applies to, with a CSV export.",
      "comments": "This is the extensibility mechanism behind the store and proposal editors: `linkedModel` ∈ all / commerce / proposal decides where the field appears, and `extraType` decides the input widget. Channel selection is validated conditionally — required only when neither `channelWeb` nor `channelApp` is set — and `channelWebIsDisabled` can lock the web option. Per-language labels come from `extra-language`. Because the consuming forms build their own `\"<label> is required\"` message for required extras, the label chosen here leaks into user-facing error text untranslated.",
      "useCases": [
        "Adding an 'Opening hours' HTML field to every store.",
        "Attaching a centre-specific audio description field to proposals.",
        "Adding a gift-card field only for Fideliza-enabled centres.",
        "Exporting the extra-attribute catalogue for documentation."
      ],
      "happyPaths": [
        {
          "name": "Store extra created",
          "steps": [
            "Admin creates an extra with a label, a code, type `html` and linked model `commerce`.",
            "Channels are selected (or the app/web flags set).",
            "Per-language labels are filled.",
            "The field now appears on the store editor's extra-attributes step."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Label or code missing",
          "cause": "Either blank.",
          "handling": "`error.extra-attributes.label.required` / `error.extra-attributes.code.required`."
        },
        {
          "name": "Type missing",
          "cause": "`extraType` blank.",
          "handling": "`error.extra-attributes.extra-type.required`."
        },
        {
          "name": "Linked model missing",
          "cause": "`linkedModel` blank.",
          "handling": "`error.extra-attributes.linked-model.required`."
        },
        {
          "name": "No channel",
          "cause": "Neither channels nor the web/app flags set.",
          "handling": "`error.extra-attributes.channel.required` ('At least one channel must be selected')."
        },
        {
          "name": "Deleting an extra with data",
          "cause": "Stores or proposals hold values for it.",
          "handling": "API rejection through the generic form error path."
        }
      ],
      "i18nKeys": [
        "title.header.configuration.extra-attributes",
        "menu.navbar.configuration.extra",
        "configuration.extra-attributes.general-data",
        "configuration.extra-attributes.list.text",
        "configuration.extra-attributes.list.html",
        "configuration.extra-attributes.list.image",
        "configuration.extra-attributes.list.url",
        "configuration.extra-attributes.list.address",
        "configuration.extra-attributes.list.audio",
        "configuration.extra-attributes.list.activity",
        "configuration.extra-attributes.list.form",
        "configuration.extra-attributes.list.custom-form",
        "configuration.extra-attributes.list.gift-card-client",
        "configuration.extra-attributes.list.gift-card-admin",
        "configuration.extra-attributes.list.all",
        "configuration.extra-attributes.list.commerce",
        "configuration.extra-attributes.list.proposal",
        "error.extra-attributes.label.required",
        "error.extra-attributes.code.required",
        "error.extra-attributes.extra-type.required",
        "error.extra-attributes.linked-model.required",
        "error.extra-attributes.channel.required"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-email-templates",
      "name": "Email template preview & test send",
      "area": "Configuration",
      "label": "title.header.configuration.email-templates / menu.navbar.email-template.show",
      "routes": [
        "configuration.email-templates",
        "configuration.email-templates.index"
      ],
      "code": [
        "app/routes/configuration/email-templates/index.js",
        "app/models/email-template.js",
        "app/components/show-email-template.js",
        "app/components/email-template-preview.js",
        "app/components/send-email-template-modal.js"
      ],
      "models": [
        "email-template"
      ],
      "modules": [],
      "roles": [
        "authenticated staff with configuration access"
      ],
      "description": "Browse the centre's transactional email templates, preview the rendered HTML and send a test copy to an address to check how it looks in a real inbox.",
      "comments": "Read-and-test only — templates are not editable here. The route's `sendTestEmail` guards its input (`throw new TypeError('\\`email\\` is not a string')`) and converts a non-OK response into a thrown error, while `send-email-template-modal` validates the recipient list client-side (`isValidEmail`/`validateEmails`) before calling it. Templates are sorted by label for a usable dropdown.",
      "useCases": [
        "Checking the registration or password-reset email after a branding change.",
        "Sending a test to a colleague for sign-off.",
        "Diagnosing a template that renders badly in a particular mail client."
      ],
      "happyPaths": [
        {
          "name": "Template previewed and tested",
          "steps": [
            "User opens Configuration → Email templates and picks one from the dropdown.",
            "The rendered HTML preview appears.",
            "The user enters a recipient in the send-test modal; `validateEmails` passes.",
            "The test is posted to `/email_templates/:id/send_test` and arrives in the inbox."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Invalid recipient address",
          "cause": "A malformed address in the list.",
          "handling": "`validateEmails` returns false and the send is not attempted; the modal shows the error. `error.email.invalid` covers the generic case."
        },
        {
          "name": "Non-string recipient",
          "cause": "`sendTestEmail` receives a non-string first element.",
          "handling": "Throws `TypeError('\\`email\\` is not a string')`."
        },
        {
          "name": "Send fails",
          "cause": "API returns non-OK.",
          "handling": "`throw new Error(response)` propagates to the modal's `.catch((errors) => …)`."
        },
        {
          "name": "No templates",
          "cause": "Centre has none configured.",
          "handling": "`email-template.not_found` is shown as the dropdown's empty message."
        }
      ],
      "i18nKeys": [
        "title.header.configuration.email-templates",
        "menu.navbar.email-template.show",
        "email-template.select_one",
        "email-template.not_found",
        "email-template.send.test",
        "error.email.invalid"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-dashboards",
      "name": "Dashboard configuration",
      "area": "Configuration",
      "label": "title.header.configuration.dashboard / menu.navbar.configuration.dashboards",
      "routes": [
        "configuration.dashboards",
        "configuration.dashboards.index",
        "configuration.dashboards.new",
        "configuration.dashboards.edit"
      ],
      "code": [
        "app/controllers/configuration/dashboards/index.js",
        "app/controllers/configuration/dashboards/new.js",
        "app/routes/configuration/dashboards/*.js",
        "app/models/dashboard.js",
        "app/models/dashboard-config.js",
        "app/models/dashboard-component.js",
        "app/models/dashboard-query.js",
        "app/enums/dashboard-panels.js",
        "app/enums/dashboard-categories.js",
        "app/enums/dashboard-filters.js",
        "app/abilities/dashboard.js"
      ],
      "models": [
        "dashboard",
        "dashboard-config",
        "dashboard-component",
        "dashboard-query"
      ],
      "modules": [
        "analytics"
      ],
      "roles": [
        "per `dashboard` ability `canCreate`/`canDelete`/`canRead`"
      ],
      "description": "Defines the dashboards available in the app: a name, a category (general / home / traffic), a panel type (data-quality, custom, external URL) and — for custom panels — a JSON component and filter configuration, plus which staff roles may see it.",
      "comments": "The `category: 'home'` dashboard is what the desktop renders, so this screen indirectly configures the landing page. Custom panels are authored as raw JSON: `components` is a `JSON.stringify`/`JSON.parse` round-trip in a getter/setter pair and `filters` is a comma-separated string split on `,` — powerful but completely unvalidated, so a syntax error in the components JSON throws inside the setter. `onPanelChange` seeds an empty `config` (styles, filters, components) when switching to custom, and `switchDashboardType` clears `panel` or `bimeId` depending on the type. Dashboards are attached to staff roles and injected into the navbar from `auth.dashboards`.",
      "useCases": [
        "Making the data-quality panel the centre's home dashboard.",
        "Embedding an external BI dashboard by URL for the owners.",
        "Building a custom KPI panel from configured components.",
        "Giving a particular role its own dashboard in the menu."
      ],
      "happyPaths": [
        {
          "name": "Home dashboard configured",
          "steps": [
            "User creates a dashboard named e.g. 'Registration quality' with category `home` and panel `data-quality`.",
            "Roles that should see it are selected.",
            "It saves and appears in the navbar; the desktop now renders it after login."
          ]
        },
        {
          "name": "External URL dashboard",
          "steps": [
            "Panel is set to `url` and the URL is entered in `parameters`.",
            "Validation requires the URL for this panel type, so a blank is caught.",
            "The dashboard renders full-bleed with the page header hidden."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Name, category or panel missing",
          "cause": "Any of the three blank.",
          "handling": "`error.dashboard.name.required`, `error.dashboard.category.required`, `error.dashboard.panel.required`."
        },
        {
          "name": "URL panel without a URL",
          "cause": "`panel === 'url'` and `parameters` is an empty string.",
          "handling": "`error.dashboard.url.required`."
        },
        {
          "name": "Malformed component JSON",
          "cause": "Invalid JSON typed into the components field.",
          "handling": "`JSON.parse` throws inside the setter with no guard — the form breaks rather than showing a validation message. The most fragile input in Configuration."
        },
        {
          "name": "Unknown panel at render time",
          "cause": "Panel value not in `enums/dashboard-panels`.",
          "handling": "`isValidDashboard` is false on the viewer and nothing renders."
        },
        {
          "name": "External dashboard missing",
          "cause": "The configured URL is unreachable.",
          "handling": "`dashboard.error.not-found-external-dashboard`."
        }
      ],
      "i18nKeys": [
        "title.header.configuration.dashboard",
        "menu.navbar.configuration.dashboards",
        "dashboards.new",
        "dashboard.name",
        "dashboard.category",
        "dashboard.panel",
        "dashboard.type",
        "dashboard.internal",
        "dashboard.external-url",
        "dashboard.data-quality",
        "dashboard.custom",
        "dashboard.category.general",
        "dashboard.category.home",
        "dashboard.category.traffic",
        "error.dashboard.name.required",
        "error.dashboard.category.required",
        "error.dashboard.panel.required",
        "error.dashboard.url.required",
        "dashboard.error.not-found-external-dashboard"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-influence-zones",
      "name": "Influence zones",
      "area": "Configuration",
      "label": "title.influence-zones / menu.navbar.configuration.influence-zones",
      "routes": [
        "configuration.influence-zones",
        "configuration.influence-zones.index",
        "configuration.influence-zones.import"
      ],
      "code": [
        "app/controllers/configuration/influence-zones/index.js",
        "app/controllers/configuration/influence-zones/import.js",
        "app/routes/configuration/influence-zones/*.js",
        "app/models/influence-zone.js",
        "app/abilities/influence-zone.js",
        "app/enums/influence-zones.js"
      ],
      "models": [
        "influence-zone",
        "load"
      ],
      "modules": [
        "influence-zone"
      ],
      "roles": [
        "root",
        "admin"
      ],
      "description": "Maps postal codes to named catchment zones (primary, secondary, tertiary and so on), so customer registrations can be analysed by catchment. Supports CSV import and export.",
      "comments": "Three fields only — id, postal code, zone — but it feeds the demographic dashboard's influence-zone breakdown. `fetchData` is defensive: a failed query falls back to an empty table and a synthetic meta object (`{ page: { number: 1, size: 15, total: 0 } }`) rather than leaving the list in a loading state, though it also means a backend outage looks identical to 'no data'. Export goes through `load.export('influence', …)` — note the model name sent to the API is `influence`, not `influence-zone`.",
      "useCases": [
        "Loading a region's postal-code-to-catchment mapping during onboarding.",
        "Analysing what share of registrations comes from the primary catchment.",
        "Correcting a mis-assigned postal code."
      ],
      "happyPaths": [
        {
          "name": "Mapping imported",
          "steps": [
            "Admin opens Influence zones → import and downloads the three-column example.",
            "Uploads the filled CSV; columns auto-map to id, postal_code and influence_zone.",
            "The import runs and the list shows the mapping.",
            "The demographic dashboard's influence-zone chart populates."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Query fails",
          "cause": "API error loading the list.",
          "handling": "Caught by a bare `catch {}`; the table shows zero rows with a fabricated meta — indistinguishable from an empty dataset."
        },
        {
          "name": "Duplicate column mapping on import",
          "cause": "Two CSV columns mapped to one field.",
          "handling": "`error.load.repeated-fields`."
        },
        {
          "name": "Role insufficient",
          "cause": "Not root/admin.",
          "handling": "`influence-zone#canAccess`/`canImport`/`canExport` block it."
        }
      ],
      "i18nKeys": [
        "title.influence-zones",
        "menu.navbar.configuration.influence-zones",
        "configuration.influence-zone.id",
        "configuration.influence-zone.postal-code",
        "configuration.influence-zone.zone",
        "influence-zone.postal_code",
        "influence-zone.zone"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-server-incidents",
      "name": "Server incidents",
      "area": "Configuration",
      "label": "title.header.configuration.server-incidents / menu.navbar.server-incidents",
      "routes": [
        "configuration.server-incidents",
        "configuration.server-incidents.index",
        "configuration.server-incidents.new",
        "configuration.server-incidents.edit",
        "configuration.server-incidents.show"
      ],
      "code": [
        "app/controllers/configuration/server-incidents/index.js",
        "app/controllers/configuration/server-incidents/new.js",
        "app/controllers/configuration/server-incidents/edit.js",
        "app/controllers/configuration/server-incidents/show.js",
        "app/routes/configuration/server-incidents/*.js",
        "app/models/server-incident.js",
        "app/abilities/server-incident.js"
      ],
      "models": [
        "server-incident"
      ],
      "modules": [],
      "roles": [
        "root",
        "admin"
      ],
      "description": "Lets platform administrators publish an incident notice (title, description) that every signed-in user sees as an in-app banner, and mark it resolved when the issue is over.",
      "comments": "The authoring side of the application-wide incident banner. Root/admin only. The editor has an explicit `resolve` action alongside `submit`/`cancel`, and the list shows created-at and resolved-at so the incident history is auditable. Both fields share one validation message (`error.server-incident.required`), so the user is not told which field is missing.",
      "useCases": [
        "Warning all users that receipt processing is delayed before support is flooded.",
        "Announcing a planned maintenance window from inside the product.",
        "Closing an incident once service is restored."
      ],
      "happyPaths": [
        {
          "name": "Incident opened and resolved",
          "steps": [
            "Admin creates an incident with a title and description.",
            "Every user immediately sees the banner with the last-updated time.",
            "When service is restored the admin opens the incident and clicks resolve.",
            "`resolvedAt` is stamped and the banner stops appearing on new page loads."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Title or description missing",
          "cause": "Either field blank.",
          "handling": "`error.server-incident.required` — the same message for both fields."
        },
        {
          "name": "Role insufficient",
          "cause": "Not root/admin.",
          "handling": "`server-incident` ability blocks the route."
        },
        {
          "name": "Banner persists after resolution",
          "cause": "Users already have the page open.",
          "handling": "No live invalidation — the banner clears on the next load."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `error.server-incident.required`, `server-incident` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "title.header.configuration.server-incidents",
        "menu.navbar.server-incidents",
        "server-incidents.new",
        "server-incidents.index.title",
        "server-incidents.index.created-at",
        "server-incidents.index.resolved-at",
        "configuration.server-incidents.new",
        "configuration.server-incidents.edit",
        "configuration.server-incidents.show",
        "error.server-incident.required"
      ],
      "i18nMissing": [
        "error.server-incident.required",
        "server-incident"
      ]
    },
    {
      "id": "config-cli",
      "name": "Server command runner (CLI)",
      "area": "Configuration",
      "label": "title.header.configuration.cli / menu.navbar.configuration.cli",
      "routes": [
        "configuration.cli"
      ],
      "code": [
        "app/controllers/configuration/cli.js",
        "app/routes/configuration/cli.js",
        "app/models/cli.js",
        "app/abilities/cli.js"
      ],
      "models": [
        "cli"
      ],
      "modules": [],
      "roles": [
        "root only"
      ],
      "description": "A root-only console that posts a named Laravel Artisan command plus a JSON parameter object to the backend and reports whether it succeeded.",
      "comments": "The single most privileged feature in the product — `cli` ability allows `root` and nobody else. Client-side validation requires a command name and, if parameters are supplied, that they parse as JSON (`isJsonString`), though that rule carries no message so a malformed payload fails silently on the field. Privacy is handled deliberately: the analytics event records only the command name and the success flag, with an explicit comment that parameters may contain sensitive data. The fetch has a `.then` but no `.catch`, so a network failure leaves the form with no feedback at all.",
      "useCases": [
        "Re-running a scheduled job that failed overnight without shell access.",
        "Clearing a cache or re-indexing after a configuration change.",
        "Support running a maintenance command for a specific centre."
      ],
      "happyPaths": [
        {
          "name": "Command executed",
          "steps": [
            "A root user opens Configuration → CLI.",
            "Enters the command name and, optionally, a valid JSON parameter object.",
            "`executeCommand` posts to `/api/v4/server_commands/laravel_artisan`.",
            "The response is OK, so `configuration.cli.command.success` is shown and only the command name is tracked."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Command missing",
          "cause": "Blank command.",
          "handling": "`error.cli.command.required`."
        },
        {
          "name": "Parameters not valid JSON",
          "cause": "`isJsonString` fails.",
          "handling": "The custom validation rejects but defines no `message`, so the field fails without telling the user why."
        },
        {
          "name": "Command rejected",
          "cause": "Non-OK response.",
          "handling": "`configuration.cli.command.failed` is set as the error message."
        },
        {
          "name": "Network failure",
          "cause": "The fetch rejects.",
          "handling": "No `.catch()` — the promise rejection is unhandled and the UI gives no feedback whatsoever."
        },
        {
          "name": "Non-root user",
          "cause": "Any role other than root.",
          "handling": "`cli#canRead` false; the route and menu entry are unavailable."
        }
      ],
      "i18nKeys": [
        "title.header.configuration.cli",
        "menu.navbar.configuration.cli",
        "error.cli.command.required",
        "configuration.cli.command.success",
        "configuration.cli.command.failed"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-accessibility",
      "name": "Accessibility statement",
      "area": "Configuration",
      "label": "title.header.configuration.accessibility / menu.navbar.accessibility",
      "routes": [
        "configuration.accesibility",
        "configuration.accesibility.index"
      ],
      "code": [
        "app/routes/configuration/accesibility.js",
        "app/templates/configuration/accesibility.hbs"
      ],
      "models": [],
      "modules": [],
      "roles": [
        "all authenticated"
      ],
      "description": "Displays the product's accessibility declaration as an embedded PDF (`assets/images/declaracion_accesibilidad.pdf`).",
      "comments": "A static compliance page: a single `<embed>` at 80vh with the PDF toolbar hidden. Note the route and URL are spelled `accesibility` (one 's') while the translation keys use `accessibility`, so the two spellings must be kept in mind when searching. Because it is a raw embed there is no fallback for browsers or viewers that cannot render PDFs inline. The menu entry is only offered to root, admin, manager, crmoperator, crmmanager, weboperator and content_creator — store-level, owner, sales and services users cannot reach the accessibility statement from the menu.",
      "useCases": [
        "Meeting the legal obligation to publish an accessibility statement.",
        "Pointing an auditor at the declaration from inside the product."
      ],
      "happyPaths": [
        {
          "name": "Statement displayed",
          "steps": [
            "User opens Configuration → Accessibility.",
            "The bundled PDF renders inline at 80% of the viewport height."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "No inline PDF support",
          "cause": "Browser or platform cannot render PDFs in an `<embed>`.",
          "handling": "No fallback content or download link is provided — the area simply appears blank, which is itself an accessibility problem on an accessibility page."
        },
        {
          "name": "Asset missing",
          "cause": "The PDF is absent from the build.",
          "handling": "Unhandled; the embed renders empty."
        }
      ],
      "i18nKeys": [
        "title.header.configuration.accessibility",
        "menu.navbar.accessibility"
      ],
      "i18nMissing": []
    },
    {
      "id": "analytics-dashboard-viewer",
      "name": "Dashboard viewer",
      "area": "Analytics",
      "label": "title.header.dashboards.show / menu.navbar.analytics",
      "routes": [
        "analytics",
        "analytics.dashboards",
        "analytics.dashboards.show"
      ],
      "code": [
        "app/routes/analytics/dashboards/show.js",
        "app/controllers/analytics/dashboards/show.js",
        "app/components/nf-dashboard.js",
        "app/components/nf-dashboard/header.js",
        "app/components/nf-dashboard/config.js",
        "app/mixins/dashboard-mixin.js",
        "app/abilities/module-analytics.js"
      ],
      "models": [
        "dashboard",
        "dashboard-config",
        "dashboard-component"
      ],
      "modules": [
        "analytics"
      ],
      "roles": [
        "per `module-analytics` ability; each dashboard is additionally scoped to staff roles"
      ],
      "description": "Renders a configured dashboard — a custom component panel, the built-in data-quality panel, or an embedded external URL — with a date-range selector, refresh, print and export actions.",
      "comments": "The same panel-resolution logic as the desktop: `isValidDashboard` checks the panel against `enums/dashboard-panels` and the controller branches on `isCustomDashboard` / `isExternalUrlDashboard`. Dashboards are injected into the navbar from `auth.dashboards`, so each user sees only the dashboards attached to their role. `nf-dashboard/header` provides `print`, `refresh` and `exportDashboard`. For external-URL dashboards the page header is hidden so the embed fills the viewport.",
      "useCases": [
        "A CRM manager reviewing registration quality for the last quarter.",
        "An owner opening an embedded BI dashboard without a second login.",
        "Printing or exporting a dashboard for a management meeting.",
        "Refreshing figures after a large import."
      ],
      "happyPaths": [
        {
          "name": "Custom dashboard reviewed",
          "steps": [
            "User picks a dashboard from the Analytics menu.",
            "The panel resolves as `custom`, its filters are seeded and `settle()` runs.",
            "Charts and KPI tiles render for the default date range.",
            "The user adjusts the range, refreshes and then prints or exports."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Unknown panel type",
          "cause": "`dashboard.panel` not in the enum.",
          "handling": "`isValidDashboard` false; nothing renders and no message explains why."
        },
        {
          "name": "External dashboard not found",
          "cause": "Configured URL wrong or provider down.",
          "handling": "`dashboard.error.not-found-external-dashboard`."
        },
        {
          "name": "No dashboards for the role",
          "cause": "None attached to the user's role.",
          "handling": "The Analytics menu section is empty."
        },
        {
          "name": "Module disabled",
          "cause": "Analytics module off for the centre.",
          "handling": "`module-analytics#canAccess` blocks the route."
        }
      ],
      "i18nKeys": [
        "title.header.dashboards.show",
        "menu.navbar.analytics",
        "dashboard.error.not-found-external-dashboard",
        "error_browser_iframe"
      ],
      "i18nMissing": []
    },
    {
      "id": "analytics-crm-panels",
      "name": "CRM dashboard panels (overview, channels, demographic)",
      "area": "Analytics",
      "label": "overview / channels / demographic",
      "routes": [
        "index",
        "analytics.dashboards.show"
      ],
      "code": [
        "app/components/dashboard/dashboard-crm/overview.js",
        "app/components/dashboard/dashboard-crm/channels.js",
        "app/components/dashboard/dashboard-crm/demographic.js",
        "app/components/nf-kpi.js",
        "app/components/nf-chart-area.js",
        "app/components/nf-chart-bar.js",
        "app/components/nf-chart-pie.js",
        "app/components/nf-chart-stacked.js",
        "app/components/nf-chart-timeseries.js",
        "app/services/stats.js"
      ],
      "models": [
        "stats"
      ],
      "modules": [
        "crm",
        "analytics"
      ],
      "roles": [
        "as per the dashboard's role scope"
      ],
      "description": "Three ready-made CRM panels: an overview of registered and active users by channel; a channels panel with registration trends and consent percentages compared to the previous month and year; and a demographic panel with age ranges, gender, marital status, influence zone and data-completeness measures.",
      "comments": "The demographic panel doubles as a data-quality view — `qualityData` reports what proportion of records have a valid email, phone, VAT number and postal code. The channels panel is explicitly comparative: it computes `prevMonthHasEmailPercentage`, `prevYearHasEmailPercentage` and the equivalents for mail-notification consent, so trends are visible without a separate report. Channel breakdown covers app, web, play centre, social wifi and other; age buckets are <18, 18-24, 25-34, 35-44 and >45. All figures come from the `stats` service.",
      "useCases": [
        "Tracking how registration volume and quality move month over month.",
        "Seeing which acquisition channel produces the most registrations.",
        "Checking marketing-consent rates before planning an email campaign.",
        "Understanding the age and gender profile of the customer base.",
        "Spotting that a channel is producing records without phone numbers."
      ],
      "happyPaths": [
        {
          "name": "Consent trend reviewed",
          "steps": [
            "User opens the channels panel for the current month.",
            "Mail-notification and third-party consent percentages render alongside the previous month and previous year.",
            "The registrations chart shows the trend by channel."
          ]
        },
        {
          "name": "Data quality assessed",
          "steps": [
            "User opens the demographic panel.",
            "`qualityData` shows the share of records with a valid email, phone, VAT number and postal code.",
            "The user exports the gaps from the customer list for follow-up."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Stats unavailable",
          "cause": "The `stats` service request fails.",
          "handling": "No dedicated error state in these components — tiles and charts render empty or at zero, which can be mistaken for genuinely zero data."
        },
        {
          "name": "Period with no registrations",
          "cause": "Empty dataset for the selected range.",
          "handling": "Percentages derived from a zero denominator; the components do not distinguish 'no data' from '0%'."
        }
      ],
      "i18nKeys": [
        "overview",
        "channels",
        "demographic",
        "channels.app",
        "channels.web",
        "channels.play-center",
        "channels.social-wifi",
        "channels.other",
        "dashboards.registered-users",
        "dashboards.active-users",
        "dashboards.age-range",
        "dashboards.gender",
        "dashboards.men",
        "dashboards.women",
        "dashboards.other",
        "dashboards.email",
        "dashboards.phone",
        "dashboards.vat-number",
        "dashboards.zip-code",
        "dashboards.visit-types",
        "dashboards.mail-notif-consent",
        "dashboards.mail-partner-consent",
        "dashboards.0-17",
        "dashboards.18-24",
        "dashboards.25-34",
        "dashboards.35-44",
        "dashboards.45-99",
        "dashboards.alone",
        "dashboards.couple",
        "dashboards.family"
      ],
      "i18nMissing": []
    },
    {
      "id": "analytics-data-quality",
      "name": "Data-quality dashboard",
      "area": "Analytics",
      "label": "dashboard.data-quality",
      "routes": [
        "index",
        "analytics.dashboards.show"
      ],
      "code": [
        "app/components/dashboard/data-quality.js",
        "app/enums/dashboard-filters.js",
        "app/enums/dashboard-ranges.js"
      ],
      "models": [
        "user",
        "stats"
      ],
      "modules": [
        "crm",
        "analytics"
      ],
      "roles": [
        "as per the dashboard's role scope"
      ],
      "description": "A dedicated panel measuring how complete new registrations are: the share of new registrants with and without an email, phone, name, age, postal code and ID, plus notification opt-in rates and the registration history by channel.",
      "comments": "Deliberately symmetric — for each attribute it reports both the 'with' and the 'without' figure (e.g. `new-registers-with-phone` and `from-new-registers-without-phone`), which makes a gap impossible to overlook. It also combines attribute and consent (`new-registers-with-email-and-notifications`, `new-registers-with-app-and-notifications`, `new-registers-with-phone-and-notifications`), i.e. it measures *contactable* registrations rather than merely complete ones. `changeFilters` re-scopes the whole panel and the filter set comes from `enums/dashboard-filters`.",
      "useCases": [
        "Setting and tracking a data-quality target for front-desk staff.",
        "Proving to marketing how many of the new registrations are actually contactable.",
        "Comparing registration quality across channels to find the weak one.",
        "Deciding whether to make a field mandatory via `center.userRequired`."
      ],
      "happyPaths": [
        {
          "name": "Monthly quality review",
          "steps": [
            "User opens the data-quality dashboard and sets the period.",
            "Each attribute shows its with/without pair and the contactable combinations.",
            "The registration history by channel identifies where quality drops.",
            "The user tightens `center.userRequired` for the offending field."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Stats request fails",
          "cause": "Backend error.",
          "handling": "No error surface; figures render as zero."
        },
        {
          "name": "No new registrations in the period",
          "cause": "Empty dataset.",
          "handling": "All percentages collapse to zero, indistinguishable from 100% incomplete data."
        }
      ],
      "i18nKeys": [
        "dashboard.data-quality",
        "dashboard.data-quality.history-of-registred-users",
        "dashboard.data-quality.new-registers-by-channel",
        "dashboard.data-quality.new-registers-with-email",
        "dashboard.data-quality.from-new-registers-with-email",
        "dashboard.data-quality.new-registers-with-phone",
        "dashboard.data-quality.from-new-registers-without-phone",
        "dashboard.data-quality.new-registers-with-name",
        "dashboard.data-quality.from-new-registers-without-name",
        "dashboard.data-quality.new-registers-with-age",
        "dashboard.data-quality.from-new-registers-without-age",
        "dashboard.data-quality.new-registers-with-postal-code",
        "dashboard.data-quality.from-new-registers-without-postal-code",
        "dashboard.data-quality.new-registers-with-id",
        "dashboard.data-quality.from-new-registers-without-id",
        "dashboard.data-quality.new-registers-with-email-and-notifications",
        "dashboard.data-quality.new-registers-with-app-and-notifications",
        "dashboard.data-quality.new-registers-with-phone-and-notifications",
        "dashboard.data-quality.from-new-registers-without-app-notifications"
      ],
      "i18nMissing": []
    },
    {
      "id": "playcenter-stays",
      "name": "Play centre stays (check-in / check-out)",
      "area": "Play Center",
      "label": "play-center-stays / menu.navbar.playcenter.show",
      "routes": [
        "play-center",
        "play-center.children",
        "play-center.children.index"
      ],
      "code": [
        "app/controllers/play-center/children/index.js",
        "app/routes/play-center/children/index.js",
        "app/models/child.js",
        "app/models/child-stay.js",
        "app/abilities/module-playcenter.js",
        "app/abilities/child.js"
      ],
      "models": [
        "child",
        "child-stay",
        "user"
      ],
      "modules": [
        "playcenter"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "crmmanager",
        "crmoperator",
        "weboperator"
      ],
      "description": "The supervised-play-area desk screen: who is currently inside, with the child's name, birth date, guardian, guardian contact details and time in the centre, plus check-in and check-out against the centre's capacity.",
      "comments": "`playcenterFull` compares current occupancy with the capacity configured on the centre — the only hard capacity control in the product. Check-in requires the guardian to have a VAT number on file (`child#canCheckIn` needs `hasVatNumber && !isInPlayCenter`), which is a legal-identification requirement expressed as an ability, and the message tells the operator exactly how to fix it. `is-in-play-center` and `play-center.time` make the current state and dwell time visible at a glance; the same operations are also reachable from a customer's children list.",
      "useCases": [
        "Checking a child in when a parent drops them off, recording the expected pick-up time.",
        "Seeing who is inside and how long they have been there during an evacuation or shift handover.",
        "Refusing entry when the area is at capacity.",
        "Refusing entry when the guardian has no VAT number on file.",
        "Checking a child out when collected."
      ],
      "happyPaths": [
        {
          "name": "Child checked in",
          "steps": [
            "Operator finds the guardian and their registered child.",
            "The guardian has a VAT number and the area is below capacity.",
            "Entry time and expected exit time are entered in the check-in modal.",
            "`child.checkin-success-message` confirms and the child appears in the current-stays list with a running time."
          ]
        },
        {
          "name": "Child checked out",
          "steps": [
            "Operator opens the check-out modal for a child inside.",
            "Confirms; `child.checkout-success-message` is shown and occupancy drops."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Play area full",
          "cause": "`playcenterFull` true against the configured capacity.",
          "handling": "Check-in is blocked — the capacity limit is enforced in the UI rather than only server-side."
        },
        {
          "name": "Guardian without a VAT number",
          "cause": "`hasVatNumber` false.",
          "handling": "`child.not-dni-error-message` (\"It isn't allowed to use the play center without entering a VAT number. Please, go to user profile to set it\") — a rare example of an error that tells the user exactly where to go."
        },
        {
          "name": "Child already inside",
          "cause": "`isInPlayCenter` true on check-in.",
          "handling": "`canCheckIn` false, so only check-out is offered."
        },
        {
          "name": "Check-in/out rejected",
          "cause": "API error.",
          "handling": "`response.errors` are mapped through `mapErrors` into `errorDetails` and listed."
        },
        {
          "name": "Times missing",
          "cause": "Entry or expected exit time blank.",
          "handling": "`error.crm.children.entryTime.required` / `error.crm.children.expectedExitTime.required`."
        },
        {
          "name": "Module disabled",
          "cause": "`playcenter` module off.",
          "handling": "`module-playcenter#canAccess` hides the section."
        }
      ],
      "i18nKeys": [
        "play-center-stays",
        "menu.navbar.playcenter.show",
        "check-in",
        "child.check-out",
        "is-in-play-center",
        "play-center.time",
        "play-center.tutor",
        "crm.children.name",
        "crm.children.bdate",
        "child.checkin-success-message",
        "child.checkout-success-message",
        "child.not-dni-error-message",
        "error.crm.children.entryTime.required",
        "error.crm.children.expectedExitTime.required"
      ],
      "i18nMissing": []
    },
    {
      "id": "smart-assistant",
      "name": "CandelarIA smart assistant",
      "area": "Smart Assistant",
      "label": "menu.navbar.smart-assistant",
      "routes": [
        "smart-assistant",
        "smart-assistant.index"
      ],
      "code": [
        "app/controllers/smart-assistant/index.js",
        "app/routes/smart-assistant/index.js",
        "app/models/module-ai.js",
        "app/abilities/smart-assistant.js"
      ],
      "models": [
        "module-ai"
      ],
      "modules": [
        "ai"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "weboperator"
      ],
      "description": "Embeds a Chatbase-hosted conversational assistant ('CandelarIA') inside the back office, using the chatbase URL configured on the centre's AI module.",
      "comments": "A three-line controller (`chatbaseUrl` from `auth.center`) behind a four-role ability. The AI module also powers the natural-language customer filter, and the centre form validates the chatbase URL with its own message (`configuration.center.ai.chatbase-url.error`), so a broken endpoint is caught at configuration time rather than at use time. As with the other embeds there is no in-app handling for a failure inside the iframe.",
      "useCases": [
        "A manager asking the assistant how to perform a task instead of consulting documentation.",
        "Getting a quick answer about the centre's own data or processes.",
        "Onboarding a new staff member with in-product help."
      ],
      "happyPaths": [
        {
          "name": "Assistant opened",
          "steps": [
            "Centre has the AI module enabled with a valid chatbase URL.",
            "An allowed role opens CandelarIA from the menu.",
            "`chatbaseUrl` resolves and the assistant loads in the frame.",
            "The user converses with it."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Module off or role not allowed",
          "cause": "AI module disabled, or the role is not root/admin/manager/weboperator.",
          "handling": "`smart-assistant#canAccess` hides the entry and blocks the route."
        },
        {
          "name": "Invalid chatbase URL",
          "cause": "URL fails validation on the centre form.",
          "handling": "`configuration.center.ai.chatbase-url.error` prevents saving a broken configuration."
        },
        {
          "name": "Chatbase unavailable",
          "cause": "Provider down.",
          "handling": "No in-app error handling; the frame is blank."
        }
      ],
      "i18nKeys": [
        "menu.navbar.smart-assistant",
        "modules-ai",
        "configuration.center.ai.chatbase-url.error",
        "ai.error"
      ],
      "i18nMissing": []
    },
    {
      "id": "xc-csv-import-engine",
      "name": "CSV import engine",
      "area": "Cross-cutting",
      "label": "import-csv.column-database",
      "routes": [
        "crm.users.import",
        "crm.users.import-children",
        "crm.commerces.import",
        "configuration.influence-zones.import",
        "intranet.flows.import"
      ],
      "code": [
        "app/components/nf-import.js",
        "app/components/import-csv.js",
        "app/models/load.js",
        "app/models/load-common.js",
        "app/services/csv.js",
        "app/services/api-files.js",
        "app/enums/csv-examples.js"
      ],
      "models": [
        "load",
        "load-common"
      ],
      "modules": [],
      "roles": [
        "varies per entity — tightest is root/admin for stores and influence zones"
      ],
      "description": "The shared engine behind every bulk import in the product: upload a CSV, auto-map its headers to model attributes, choose the separator, the match key, the date format, consent defaults and channels, preview and import — with the file stored server-side and the job queued as a `load` record.",
      "comments": "Two implementations coexist: the older `import-csv` component (modal-based, fixed `;` delimiter, 120 lines) and the newer `nf-import` (606 lines, full page, configurable separator and primary key, preview table, template download, `canImportChildren`/`canRemoveUserTypes` affordances and a pre-flight error summary). Both drive the same `load` model, whose three validations are the real contract: no duplicate column mappings, a date format whenever a date column is mapped, and at least one channel for user imports. `parseCSV` parses and uploads in parallel (`Promise.all`), so the mapping table and the stored file are produced in one step. `update: true` switches between insert-only and update-and-insert.",
      "useCases": [
        "Migrating a legacy customer or store database during onboarding.",
        "Periodic bulk updates from a POS, HR or counting system.",
        "Loading reference data such as postal-code-to-zone mappings.",
        "Giving a non-technical user a safe, previewable alternative to an API integration."
      ],
      "happyPaths": [
        {
          "name": "Template import with preview",
          "steps": [
            "User downloads the entity's example CSV (which varies by centre configuration) and fills it.",
            "Uploads the file; it is parsed and stored in parallel.",
            "Headers matching attribute names are mapped automatically; the rest are mapped by hand.",
            "Separator, match key, date format, consents and channels are set.",
            "The preview table shows how rows will be interpreted.",
            "`load.validate()` passes, `load.save()` queues the job and the success state is reported."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Duplicate column mapping",
          "cause": "Two CSV columns mapped to the same attribute.",
          "handling": "`error.load.repeated-fields` ('Please select a single database field for each CSV column'); `import-csv` scrolls the modal to the top to reveal it."
        },
        {
          "name": "Date column without a format",
          "cause": "`register_date` or `bdate` mapped with no `formatDate`.",
          "handling": "`error.load.format-date.required` / `import-csv.errors.load.format-date.required`."
        },
        {
          "name": "User import without channels",
          "cause": "`model === 'user'` and `commons.channels` empty.",
          "handling": "`error.load.channels.required` / `import-csv.errors.load.channels.required`."
        },
        {
          "name": "Pre-flight summary",
          "cause": "`nf-import._validateSelectionsAndShowErrors` finds any required selection missing.",
          "handling": "`import-csv.errors.load.required-selections` as a banner — note this string is in Portuguese while its siblings are English."
        },
        {
          "name": "Upload rejected",
          "cause": "Disallowed file type or oversized file.",
          "handling": "`error.file.type.not_allowed`, `error.file.oversize`, `error.file.not_allowed`."
        },
        {
          "name": "Server-side row errors",
          "cause": "Rows rejected during processing.",
          "handling": "`informative-message.import-csv.error.title` + `informative-message.import-csv.error` list them. `nf-import` also reads `error?.errors?.[0]?.status` to classify the failure."
        },
        {
          "name": "Parse failure leaves a stuck spinner",
          "cause": "Unparseable file in the older `import-csv` component.",
          "handling": "`parseCSV` is `async` with no try/catch there, so `isLoading` stays true. `nf-import` handles this better with explicit try/catch blocks."
        },
        {
          "name": "Wrong separator chosen",
          "cause": "File uses `,` but `;` is selected (or vice versa).",
          "handling": "Configurable in `nf-import` (`onChangeSeparator`); hard-coded to `;` in `import-csv`, where a comma-separated file parses as a single column."
        }
      ],
      "i18nKeys": [
        "import-csv.column-csv",
        "import-csv.column-database",
        "import-csv.separation-options.comma",
        "import-csv.separation-options.semicon",
        "import-csv.formats-date.dd-mm-yyyy",
        "import-csv.formats-date.mm-dd-yyyy",
        "import-csv.formats-date.yyy-mm-dd",
        "import-csv.update-options.only-insert",
        "import-csv.update-options.update-insert",
        "error.load.repeated-fields",
        "error.load.format-date.required",
        "error.load.channels.required",
        "import-csv.errors.load.required-selections",
        "import-csv.errors.load.channels.required",
        "import-csv.errors.load.format-date.required",
        "informative-message.import-csv.error.title",
        "informative-message.import-csv.error",
        "error.file.type.not_allowed",
        "error.file.oversize",
        "error.file.not_allowed"
      ],
      "i18nMissing": []
    },
    {
      "id": "xc-export-engine",
      "name": "Data export engine (email delivery)",
      "area": "Cross-cutting",
      "label": "informative-message.export-crm-csv.success.message",
      "routes": [
        "crm.users.index",
        "crm.segments.index",
        "crm.commerces.index",
        "configuration.influence-zones.index",
        "cms.categories.index",
        "configuration.extra-attributes.index",
        "configuration.centers.index",
        "intranet.communications.show"
      ],
      "code": [
        "app/services/load.js",
        "app/mixins/export-csv.js",
        "app/services/csv.js",
        "app/services/xlsx.js"
      ],
      "models": [],
      "modules": [],
      "roles": [
        "varies per entity (e.g. `commerce#canExport` is root/admin/manager)"
      ],
      "description": "The shared export path: a POST to `/api/v4/exports` naming the model, the active filters, the filter combinator and the chosen columns, with the resulting file emailed to the requester rather than downloaded in the browser.",
      "comments": "Deliberately asynchronous — large exports do not block the UI and are not limited by the browser. The `type` key is pulled out of the filters and sent separately as `filter-type` (defaulting to `and`), and `center-id` is included so the API knows which centre the email is 'from'. Every export tracks a `DATA_EXPORTED` analytics event with the entity, the filter count and the column count but not the filter values. Some screens bypass this and build a file in the browser instead (`csv`/`xlsx` services, e.g. intranet sales and the dashboard export), so there are two export idioms in the codebase with different failure characteristics.",
      "useCases": [
        "Exporting a large filtered customer set without timing out the browser.",
        "Handing a segment to an external mailing or analytics tool.",
        "Producing a reference-data extract for documentation or migration.",
        "Getting a recipient list with read/confirm status out of a communication."
      ],
      "happyPaths": [
        {
          "name": "Filtered export emailed",
          "steps": [
            "User applies filters and selects the columns to include.",
            "`load.export(model, filters, columns)` posts the job and tracks the analytics event.",
            "The promise resolves and the success banner is shown.",
            "The file arrives by email, sent on behalf of the active centre."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Export request fails",
          "cause": "API error on `/api/v4/exports`.",
          "handling": "`load.export` returns the parsed response but never rejects on a non-OK status, and most callers have no `.catch()` — so the loading flag can stay set and no error is shown. The weakest part of this otherwise sensible design."
        },
        {
          "name": "Email never arrives",
          "cause": "The job fails asynchronously, or the mail is blocked or filtered.",
          "handling": "No job-status surface exists in the frontend; the user has already been told it succeeded."
        },
        {
          "name": "No columns selected",
          "cause": "User unticks everything in a column picker.",
          "handling": "Not validated client-side — an empty `csv-fields` array is sent."
        },
        {
          "name": "Browser-side export fails",
          "cause": "The `csv`/`xlsx` in-browser path errors on a large dataset.",
          "handling": "Depends on the caller; the intranet sales download at least validates its date range first."
        }
      ],
      "i18nKeys": [
        "informative-message.export-crm-csv.success.message",
        "segment.export-segment"
      ],
      "i18nMissing": []
    },
    {
      "id": "xc-segment-filter-builder",
      "name": "Filter & segment builder component",
      "area": "Cross-cutting",
      "label": "all / any-of",
      "routes": [
        "crm.users.index",
        "crm.segments.new",
        "crm.segments.edit",
        "crm.commerces.index",
        "cms.proposals.index",
        "crm.receipts.index",
        "marketing.campaigns.index"
      ],
      "code": [
        "app/components/segment-filters.js",
        "app/components/nf-box-filters.js",
        "app/components/quick-filter.js",
        "app/components/quick-search.js",
        "app/components/nf-range-filter.js",
        "app/mixins/filter-mixin.js",
        "app/mixins/segment-filter-mixin.js",
        "app/mixins/list-mixin.js",
        "app/utils/parse-filters.js",
        "app/utils/beautify-filters.js",
        "app/utils/group-filter.js",
        "app/enums/segment-filters-types.js"
      ],
      "models": [
        "segment"
      ],
      "modules": [],
      "roles": [
        "as per the host screen"
      ],
      "description": "The reusable filter builder used across list screens and the segment editor: grouped filter options, per-filter operations and values, an AND/OR combinator, apply/clear, and — in the segment context — saving the filter set as a persisted definition.",
      "comments": "The filter palette is data-driven and module-aware: `beautifyFilters(enumFilters, center)` and `group-filter.js` strip options whose declared modules the centre does not have, so the same component offers different filters per tenant. Two correctness guards matter: `_filtersHasVoidValue()` refuses to apply a filter with no value and raises `showFilterWarning`, and `requiredFilters`/`hasValidFilters` enforce that any option marked `required` in the palette is present. `parseFilters` serialises the rows into the API's filter shape, and the combinator (`and`/`or`, labelled 'All' / 'Any of') is sent as `type`. Applying and saving both emit analytics (`LIST_FILTERED`, `SEGMENT_SAVED`).",
      "useCases": [
        "Narrowing any list screen without needing a bespoke filter UI per entity.",
        "Switching between 'match all' and 'match any' semantics on the same filter set.",
        "Promoting an ad-hoc filter set into a reusable segment.",
        "Offering tenant-specific filters without code changes."
      ],
      "happyPaths": [
        {
          "name": "Filters applied",
          "steps": [
            "User adds filter rows from the module-aware grouped palette.",
            "Each row gets an operation and a value.",
            "The combinator is set to All or Any of.",
            "`applyFilters` serialises them via `parseFilters` and refreshes the list, tracking `LIST_FILTERED`."
          ]
        },
        {
          "name": "Filter set saved to a segment",
          "steps": [
            "In the segment editor the user clicks save changes.",
            "`saveChanges` serialises the filters and the combinator, persists them, tracks `SEGMENT_SAVED` and re-applies them to refresh the preview."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Filter row with no value",
          "cause": "A row added but left empty.",
          "handling": "`_filtersHasVoidValue()` blocks apply and sets `showFilterWarning` — nothing is requested."
        },
        {
          "name": "Required filter absent",
          "cause": "The palette marks an option `required` and it is not present.",
          "handling": "`hasValidFilters` is false, disabling apply and save."
        },
        {
          "name": "Save rejected",
          "cause": "API error while persisting the segment definition.",
          "handling": "`saveChanges` only has a `.then()`, so `isSavingChanges` stays true on rejection and the spinner never clears."
        },
        {
          "name": "Filter unavailable for the centre",
          "cause": "The option's module is not enabled.",
          "handling": "It is removed from the palette by `beautifyFilters`/`group-filter`, so it cannot be selected at all."
        }
      ],
      "i18nKeys": [
        "all",
        "any-of",
        "filter",
        "crm.segments",
        "segment.users-in-segment"
      ],
      "i18nMissing": []
    },
    {
      "id": "xc-file-manager",
      "name": "File upload, gallery & image modal",
      "area": "Cross-cutting",
      "label": "error.file.type.not_allowed",
      "routes": [
        "(used by most editors)"
      ],
      "code": [
        "app/components/nf-file-manager.js",
        "app/components/nf-file-manager-single.js",
        "app/components/nf-file-loader.js",
        "app/components/nf-input-file.js",
        "app/components/file-gallery.js",
        "app/components/file-image.js",
        "app/components/file-size.js",
        "app/components/files-loader.js",
        "app/components/nf-img-modal.js",
        "app/services/api-files.js",
        "app/services/explore-files.js",
        "app/services/img-modal.js",
        "app/enums/file-extensions.js",
        "app/enums/file-type-assets.js",
        "app/enums/aspectRatio.js"
      ],
      "models": [
        "intranet-file"
      ],
      "modules": [],
      "roles": [
        "as per the host screen"
      ],
      "description": "The shared upload and media-browsing layer behind store images, proposal images and audio, centre branding, app-button images, intranet files and CSV uploads — with type and size restrictions, aspect-ratio guidance, a gallery picker and a full-size image modal.",
      "comments": "`api-files.uploadFile(file, …)` is the single upload entry point and returns a `file_url` that models store as a string rather than as a binary relationship, which is why the same component works for images, audio, PDFs and CSVs. Three shared error strings cover every caller (`error.file.type.not_allowed`, `error.file.oversize`, `error.file.not_allowed`), so a failed upload reads the same everywhere even though the limits differ by context — the proposal audio limit of 10 MB, for example, is only stated in its own message. `enums/aspectRatio` and `enums/orientations` drive the cropping guidance, and `img-modal` provides the zoom view.",
      "useCases": [
        "Uploading a store logo at the right aspect ratio.",
        "Attaching a hero image to a proposal, or picking one already uploaded from the gallery.",
        "Uploading a PDF to the intranet document library.",
        "Attaching an audio description to a proposal for accessibility.",
        "Uploading a CSV as part of an import."
      ],
      "happyPaths": [
        {
          "name": "Image uploaded and attached",
          "steps": [
            "User opens the file field in an editor and selects an image within the allowed types and size.",
            "`api-files.uploadFile` stores it and returns the URL.",
            "The URL is written to the model attribute and the thumbnail renders.",
            "The gallery can be used later to reuse the same asset."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Disallowed file type",
          "cause": "Extension outside `enums/file-extensions` for that field.",
          "handling": "`error.file.type.not_allowed`."
        },
        {
          "name": "File too large",
          "cause": "Exceeds the context's size limit.",
          "handling": "`error.file.oversize`; the proposal audio path has its own more specific message naming the 10 MB cap."
        },
        {
          "name": "Upload rejected",
          "cause": "Storage or API failure.",
          "handling": "`error.file.not_allowed` — a generic catch-all that does not distinguish a rejected file from a failed upload."
        },
        {
          "name": "Required image left empty",
          "cause": "A mandatory image field not filled.",
          "handling": "Enforced by the host model (e.g. `error.commerce.imageUrl.required`, `required` + `proposal.app-image`, `error.mof.image.required`)."
        },
        {
          "name": "Orphaned uploads",
          "cause": "A file is uploaded but the form is then abandoned.",
          "handling": "Not handled — the asset remains stored with no record referencing it."
        }
      ],
      "i18nKeys": [
        "error.file.type.not_allowed",
        "error.file.oversize",
        "error.file.not_allowed",
        "Image",
        "new_folder",
        "folder.success-created"
      ],
      "i18nMissing": []
    },
    {
      "id": "xc-multilanguage",
      "name": "Multi-language content authoring",
      "area": "Cross-cutting",
      "label": "error.all-translation-fields.required",
      "routes": [
        "cms.proposals.new",
        "cms.categories.new",
        "cms.mofs.index",
        "marketing.campaigns.new",
        "marketing.activity-campaigns.new",
        "cms.multicentre-proposals.new",
        "configuration.centers.new",
        "crm.commerces.new",
        "configuration.extra-attributes.new"
      ],
      "code": [
        "app/models/proposal-language.js",
        "app/models/category-language.js",
        "app/models/mof-language.js",
        "app/models/campaign-language.js",
        "app/models/activity-campaign-language.js",
        "app/models/center-language.js",
        "app/models/commerce-language.js",
        "app/models/extra-language.js",
        "app/models/language.js",
        "app/components/nf-wizard.js",
        "app/components/nf-wizard-tab.js"
      ],
      "models": [
        "language",
        "*-language fragments"
      ],
      "modules": [],
      "roles": [
        "as per the host screen"
      ],
      "description": "Every customer-facing entity carries a per-language record for its translatable fields, and the editors expose languages as wizard steps — the default language marked with an asterisk — so content is authored once per language in the same form.",
      "comments": "Nine `*-language` models exist (proposal, category, mof, campaign, activity-campaign, center, commerce, extra and multicentre), all keyed on the centre's configured `languages` with one marked default. The campaign editor's `steps` getter is the clearest example: it sorts languages with the default first and appends `*` to its label. Enforcement is inconsistent — the multicentre-proposal editor checks every language before submitting and blocks with `error.all-translation-fields.required`, while most other editors only validate the default language, so a proposal can be published with an untranslated secondary language. The centre form also supports bulk `uploadTranslations`.",
      "useCases": [
        "A bilingual centre publishing a proposal in both languages from one form.",
        "Adding a third language to an existing centre and filling in the gaps.",
        "Bulk-uploading a translation file instead of editing strings one by one.",
        "Making sure a group-wide multicentre proposal is complete in every language before it goes out."
      ],
      "happyPaths": [
        {
          "name": "Proposal authored in two languages",
          "steps": [
            "Centre has two languages configured, one default.",
            "User fills the default-language step (marked `*`) and then the second step.",
            "The `*-language` records are saved alongside the entity.",
            "The app serves each user the content for their language."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Incomplete translations (enforced)",
          "cause": "A language missing a translatable field on a multicentre proposal.",
          "handling": "`_validateProposalTranslationFields` blocks submit with `error.all-translation-fields.required`."
        },
        {
          "name": "Incomplete translations (unenforced)",
          "cause": "A secondary language left blank on an ordinary proposal, campaign or category.",
          "handling": "Not validated — the record saves and the app falls back or shows an empty field. The most consequential inconsistency in this area."
        },
        {
          "name": "Translation upload fails",
          "cause": "Malformed locale file on the centre form.",
          "handling": "`try/catch (err)` with no translated message."
        },
        {
          "name": "Default language not set",
          "cause": "Centre saved without `defaultLanguage`.",
          "handling": "Blocked by the centre model's presence rule with `required` + `configuration.center.default-language`."
        }
      ],
      "i18nKeys": [
        "error.all-translation-fields.required",
        "configuration.center.language",
        "configuration.center.default-language"
      ],
      "i18nMissing": []
    },
    {
      "id": "xc-realtime",
      "name": "Realtime updates (Pusher)",
      "area": "Cross-cutting",
      "label": "error.signature.not_exist",
      "routes": [
        "crm.users.edit",
        "cms.proposals.new"
      ],
      "code": [
        "app/services/pusher.js",
        "app/components/form-user.js",
        "app/controllers/cms/proposals/new.js",
        "app/routes/application.js"
      ],
      "models": [
        "user",
        "proposal"
      ],
      "modules": [],
      "roles": [
        "as per the host screen"
      ],
      "description": "A websocket channel used for two things: streaming a customer's captured signature into the open customer form, and delivering AI-generated proposal images when they are ready.",
      "comments": "`pusher.connectToChannel(id, callback)` subscribes per record and `disconnectToChannel(id)` is called in `willDestroyElement`, so subscriptions are scoped to the open form. In the customer form the callback simply writes `model.signatureUrl`, which is what makes a signature captured on a paired tablet appear without a reload. In the proposal editor the same mechanism carries the result of an AI image generation, which is inherently slow. There is no reconnection or fallback polling: if the socket drops, the update silently never arrives and the user must reload.",
      "useCases": [
        "A customer signing a GDPR consent on a tablet while the operator watches it appear on the record.",
        "Waiting for an AI-generated proposal image without polling or reloading.",
        "Keeping a long-lived form in step with an out-of-band action."
      ],
      "happyPaths": [
        {
          "name": "Signature appears live",
          "steps": [
            "Operator opens an existing customer; the form subscribes to that user's channel.",
            "The customer signs on a paired device and the signature is stored.",
            "The pushed URL is written to `model.signatureUrl` and renders immediately.",
            "Leaving the form unsubscribes."
          ]
        },
        {
          "name": "AI image delivered",
          "steps": [
            "User requests an AI image on a proposal.",
            "Generation runs asynchronously; the result arrives over the channel.",
            "The image is offered for review and attachment."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Socket connection lost",
          "cause": "Network interruption or provider issue.",
          "handling": "No reconnect or polling fallback — the update never arrives and nothing tells the user. The form looks simply as if nothing happened."
        },
        {
          "name": "Signature not captured",
          "cause": "The customer never signs, or the device fails.",
          "handling": "Downstream features report `error.signature.not_exist` ('The user does not have signature') — for example when generating a communication confirmation PDF."
        },
        {
          "name": "Subscription leak",
          "cause": "The component is destroyed without `willDestroyElement` running.",
          "handling": "Guarded by the explicit disconnect, but only when `this.model` is present."
        }
      ],
      "i18nKeys": [
        "error.signature.not_exist"
      ],
      "i18nMissing": []
    },
    {
      "id": "xc-permissions",
      "name": "Role & module permission layer",
      "area": "Cross-cutting",
      "label": "(abilities)",
      "routes": [
        "(every route)"
      ],
      "code": [
        "app/abilities/*.js",
        "app/services/can.js",
        "app/enums/staff-levels.js",
        "app/enums/modules.js",
        "app/enums/permissions.js",
        "app/utils/get-side-nav-menu.js",
        "app/components/nf-navbar.js",
        "app/routes/authenticated.js"
      ],
      "models": [
        "modules",
        "module-*",
        "staff-member"
      ],
      "modules": [
        "all"
      ],
      "roles": [
        "15 staff levels: root, admin, content_creator, calibrator, commerce, crmmanager, crmoperator, external, manager, owner, scmanager, storeManager, weboperator, sales, services"
      ],
      "description": "Access control is the product of two independent dimensions — the user's staff level and the centre's enabled modules — expressed as 55 ember-can ability classes that gate menu entries, route entry and individual actions.",
      "comments": "The pattern is consistent: a `module-*` ability holds a `moduleName` plus a `roles` array and computes `canAccess` as 'role is allowed AND centre has the module'; entity abilities extend the relevant module ability and add `canCreate`/`canEdit`/`canDelete`/etc. on top, often also conditioned on record state (campaign status, proposal status, receipt state, month state). This is why permissions in this product are genuinely three-dimensional: role × module × record state. The navbar applies the same logic to sections (`section.levels`, `section.module`, `section.disabledByModule`, or `section.ability`). Worth knowing: a role that is not in `enums/staff-levels` yields an empty menu rather than an error; some abilities contain latent logic bugs (the `campaign#canDelete` check compares the `CONTENT_CREATOR` role constant against `model.status`); and `intranet-file` accepts an `owner` role value in its inclusion rule that the UI never offers.",
      "useCases": [
        "Giving a store-level user access only to their own sales and documents.",
        "Letting a content creator draft proposals but requiring a manager to publish them.",
        "Selling modules per centre so one tenant has loyalty and another does not.",
        "Protecting closed activities and validated months from further edits.",
        "Restricting the CLI to root and imports to root/admin."
      ],
      "happyPaths": [
        {
          "name": "Menu and actions reflect role and modules",
          "steps": [
            "User signs in; `auth.load()` resolves their role and the centre's modules.",
            "`nf-navbar#menu` filters sections by level, module and ability.",
            "Each list screen renders only the row actions whose abilities pass.",
            "Direct URL entry to a blocked route is rejected by the route's own `can` check."
          ]
        },
        {
          "name": "Record state tightens permission",
          "steps": [
            "An activity is marked finished.",
            "`campaign#canEdit`/`canDelete` now return false for non-root/admin roles.",
            "`canShow` becomes true, so the report opens instead."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Module disabled",
          "cause": "Centre lacks the module behind the feature.",
          "handling": "`canAccess` false — menu entry hidden and route entry rejected."
        },
        {
          "name": "Role not allowed",
          "cause": "User's level is not in the ability's `roles`.",
          "handling": "Same as above; no explanatory message is shown."
        },
        {
          "name": "Unrecognised role",
          "cause": "`auth.user.role` is not a known staff level.",
          "handling": "`nf-navbar#menu` returns `[]` — the user gets a functioning app with no navigation, which is confusing rather than secure-by-message."
        },
        {
          "name": "Centre not loaded",
          "cause": "`auth.center` still null, or `cid` cookie points at an inaccessible centre.",
          "handling": "`get(center, 'has…')` resolves falsy, so every module-gated feature disappears at once."
        },
        {
          "name": "Latent ability logic bug",
          "cause": "`campaign#canDelete` tests a role constant against `model.status`.",
          "handling": "Evaluates as a non-match, making the status guard weaker than intended — worth a fix."
        }
      ],
      "i18nKeys": [
        "root",
        "admin",
        "content-creator",
        "calibrator",
        "commerce",
        "crm-manager",
        "crm-operator",
        "external-api",
        "manager",
        "owner",
        "scmanager",
        "store-manager",
        "web-operator",
        "staff-level-sales",
        "staff-level-services",
        "modules-crm",
        "modules-cms",
        "modules-marketing",
        "modules-intranet",
        "modules-ai",
        "modules-fideliza",
        "modules-lottery",
        "modules-fidcard",
        "modules-cashback",
        "modules-tally",
        "modules-forms",
        "modules-connectif",
        "modules-blog",
        "modules-theaters",
        "modules-email-validation",
        "modules-app-operator",
        "modules-auto-logout",
        "modules-totem-channel",
        "modules-proposal-inbox",
        "modules-multicenter-proposals",
        "modules-form-commerces",
        "modules-employees-segments",
        "modules-commerces-map",
        "modules-foreground-location"
      ],
      "i18nMissing": []
    },
    {
      "id": "xc-error-handling",
      "name": "Error presentation & API error mapping",
      "area": "Cross-cutting",
      "label": "user-error / api-error",
      "routes": [
        "(every form)"
      ],
      "code": [
        "app/utils/parse-errors-array-to-errors-object.js",
        "app/utils/map-errors.js",
        "app/components/nf-error.js",
        "app/components/nf-info.js",
        "app/routes/form.js",
        "app/errors/",
        "app/services/show-confirm-deletion.js"
      ],
      "models": [],
      "modules": [],
      "roles": [
        "all"
      ],
      "description": "The shared plumbing that turns model validation failures and API error payloads into field-level messages and page banners, plus the custom error classes used to signal illegal states and disallowed operations.",
      "comments": "Three layers. (1) `parseErrors` converts an ember-model-validator errors array into a field-keyed object the form components render inline. (2) `mapErrors(e, intl.t)` translates an API error object, and the better controllers (campaign, proposal, children, play centre) feed it into an `errorDetails` list. (3) Classification: `'4' === adapterError.errors[0].status.charAt()` chooses `user-error` ('Something is wrong. Please check the following errors:') for client errors versus `api-error` (\"Something is wrong and it's not your fault. Please, contact support…\") for server errors — a genuinely good distinction, though it throws if the payload has no string `status`. The generic fallback is `info.default.error.message`/`info.default.error.title`. Custom error classes (`NoCookieError`, `IllegalStateError`, `NotAllowedError`, `IllegalArgumentError`) are thrown for programming and permission violations; these surface as exceptions, not UI messages. A recurring weakness across the codebase is the bare `.catch(() => {})`, which hides the reason for a failure — notably on segment refresh, sales-year rejection communications and sales-year enable/disable.",
      "useCases": [
        "Showing a user exactly which fields are wrong without a page reload.",
        "Distinguishing 'you made a mistake' from 'our server failed' so support tickets land in the right place.",
        "Letting the API drive a translated message via `errors[0].meta['detail-key']`.",
        "Guarding developer mistakes with explicit exceptions rather than silent misbehaviour."
      ],
      "happyPaths": [
        {
          "name": "Validation errors shown inline",
          "steps": [
            "User submits an incomplete form.",
            "`model.validate()` fails and `parseErrors` keys the messages by field.",
            "`nf-error` renders each message beside its input and the page scrolls to the top."
          ]
        },
        {
          "name": "API error classified",
          "steps": [
            "Save is rejected with a 422.",
            "`mapErrors` translates each error into `errorDetails`.",
            "Because the status starts with 4, the `user-error` banner is shown with the detail list."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Error payload without a status",
          "cause": "API returns errors lacking a string `status`.",
          "handling": "`status.charAt()` throws inside the error handler, so the original failure is replaced by a crash."
        },
        {
          "name": "Swallowed failure",
          "cause": "A bare `.catch(() => {})` in the calling code.",
          "handling": "Nothing is shown; the operation appears to have done nothing. Present in segment refresh, sales-year rejection, sales-year enable/disable and several others."
        },
        {
          "name": "Stuck loading flag",
          "cause": "A promise chain with a `.then()` that sets a flag but no `.catch()` to clear it.",
          "handling": "The spinner never clears — the pattern behind the export, segment-save and AI-filter failure modes."
        },
        {
          "name": "Untranslated message",
          "cause": "A message built in code rather than from the locale file, e.g. the extras' `\"<label> is required\"`.",
          "handling": "Shown as-is in English regardless of the user's language."
        },
        {
          "name": "Developer-facing exception",
          "cause": "A `TypeError`/`NotAllowedError`/`IllegalStateError` guard fires.",
          "handling": "Surfaces as an unhandled exception rather than a message — correct for genuine programming errors, less so for the permission cases in intranet communications."
        }
      ],
      "i18nKeys": [
        "user-error",
        "api-error",
        "info.default.error.message",
        "info.default.error.title",
        "error.server",
        "required",
        "input.default.max-char.error.message"
      ],
      "i18nMissing": []
    },
    {
      "id": "platform-runtime-config",
      "name": "Runtime environment & white-label boot",
      "area": "Platform & Access",
      "label": "(config.json)",
      "routes": [
        "application"
      ],
      "code": [
        "app/routes/application.js",
        "public/config.json (deployed per environment)",
        "app/styles/themes/cbre.scss",
        "app/services/head-data.js"
      ],
      "models": [],
      "modules": [],
      "roles": [
        "all (runs before login)"
      ],
      "description": "Before anything renders, the app fetches a per-deployment `config.json` that sets the API host, environment and deploy names, the visual theme, the browser-tab title, Pusher credentials and the Fidcard / gift-card URLs — so one build serves several white-labelled clients.",
      "comments": "`routes/application.js#beforeModel` awaits `$.getJSON('config.json')` and writes `api.host` (trailing slash stripped), `api.namespace = 'v3'`, `auth.env`, `auth.deployEnv`, `auth.fidecardUrl`, `auth.giftCardClientUrl`, `auth.giftCardAdminUrl`, `headData.theme` (falling back to the module prefix), `headData.title` (the env name, also sent to Froged as the company) and the Pusher app key. Only one alternative theme ships in the repo (`styles/themes/cbre.scss`). A code comment says the env flag exists 'to show bonus request only in cbre environment', but no bonus-request code remains — the `bonusRequest` translation namespace is orphaned. After config, `_setLanguage()` and `_loadCurrentUser()` run, and every route change is tracked through both `metrics.trackPage` and `analytics.page()`.",
      "useCases": [
        "Deploying the same frontend build for a second client with its own branding and API.",
        "Pointing a staging deployment at a staging API without rebuilding.",
        "Giving each environment a distinguishable browser-tab title."
      ],
      "happyPaths": [
        {
          "name": "Boot with a valid config",
          "steps": [
            "Browser loads the app; `config.json` is fetched from the deployment root.",
            "API host, theme, title and Pusher keys are applied and Pusher is initialised.",
            "Translations load, then the current user is resolved or the user is sent to login."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "config.json missing or malformed",
          "cause": "Deployment without the file, or invalid JSON.",
          "handling": "`beforeModel` rejects before any UI exists — the user sees a blank page with no message. The single hardest failure in the product."
        },
        {
          "name": "Pusher block absent",
          "cause": "`data.pusher` not set for the environment.",
          "handling": "App key and env fall back to empty strings; realtime features (signatures, AI images) silently never update."
        },
        {
          "name": "Theme not shipped",
          "cause": "`data.theme` names a theme with no stylesheet.",
          "handling": "No guard — the default styling is used without warning."
        }
      ],
      "i18nKeys": [],
      "i18nMissing": []
    },
    {
      "id": "platform-ui-language",
      "name": "Back-office interface language",
      "area": "Platform & Access",
      "label": "(intl)",
      "routes": [
        "application"
      ],
      "code": [
        "app/routes/application.js",
        "app/instance-initializers/intl.js",
        "translations.json"
      ],
      "models": [],
      "modules": [],
      "roles": [
        "all"
      ],
      "description": "The staff interface is shown in Spanish or Portuguese when the browser is set to one of them, and in English otherwise, with English filling any missing string.",
      "comments": "`_setLanguage()` always loads English first, then — if `navigator.language` starts with `es` or `pt` — loads that locale, strips keys whose value is an empty string (`_removeKeysWithVoidValues`) and calls `intl.setLocale([lang, 'en'])`, so untranslated strings fall back to English rather than rendering blank. Loading is retried up to three times before giving up with a console error. There is no in-app language switcher: the only control is the browser's language setting. This is separate from the *content* languages configured per centre (see Multi-language content authoring).",
      "useCases": [
        "Spanish and Portuguese centre staff using the back office in their own language.",
        "Shipping a new string in English first while translations catch up, without blank labels."
      ],
      "happyPaths": [
        {
          "name": "Portuguese browser",
          "steps": [
            "Browser language is `pt-PT`; English and Portuguese bundles load.",
            "Empty Portuguese values are removed and the locale chain is set to `pt`, then `en`.",
            "The UI renders in Portuguese with English for any gaps."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Unsupported browser language",
          "cause": "e.g. French or Italian.",
          "handling": "English is used; there is no way to choose Spanish or Portuguese without changing the browser."
        },
        {
          "name": "Translation bundle fails to load",
          "cause": "Network error three times in a row.",
          "handling": "Logged to the console only; the app continues with whatever locale state exists, which can mean raw keys on screen."
        },
        {
          "name": "Wrong-language string in a bundle",
          "cause": "A value in the wrong language inside the English file.",
          "handling": "Rendered as-is — e.g. `import-csv.errors.load.required-selections` appears in Portuguese to English users."
        }
      ],
      "i18nKeys": [],
      "i18nMissing": []
    },
    {
      "id": "platform-support-widget",
      "name": "In-app help & support chat (Froged)",
      "area": "Platform & Access",
      "label": "(Froged widget)",
      "routes": [
        "(application-wide)"
      ],
      "code": [
        "app/index.html",
        "app/components/nf-navbar.js"
      ],
      "models": [
        "staff-member",
        "center"
      ],
      "modules": [],
      "roles": [
        "all authenticated"
      ],
      "description": "A Froged support bubble with a knowledge base and chat, loaded on every page and identified with the signed-in staff member, their company (environment) and their current centre.",
      "comments": "`index.html` bootstraps the Froged SDK with `kbTags: [env_name, 'all']`, so knowledge-base articles can be targeted per deployment. `nf-navbar#_setFrogedConfig` sends `userId` (staff gid), `email`, `name`, `Empresa` (env title) and `Centro` (active centre name), and `_setupFrogedRole` patches the injected iframe's title for accessibility. On logout `Froged('logout')` is called. Bug: the guard flag is set to `false` inside the `if (!this._isFrogedConfigSetted)` block instead of `true`, so the identify call is re-sent every time the menu recomputes (every centre switch or module change).",
      "useCases": [
        "A store manager opening a help article without leaving the back office.",
        "Support seeing which centre and user is writing in, without asking.",
        "Publishing knowledge-base articles only to one client's deployment."
      ],
      "happyPaths": [
        {
          "name": "User asks for help",
          "steps": [
            "User signs in; the menu builds and Froged is identified with user and centre.",
            "User opens the bubble and searches the knowledge base or starts a chat.",
            "Support receives the conversation tagged with company and centre."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "SDK blocked",
          "cause": "Ad-blocker or network policy blocks `sdk.froged.com`.",
          "handling": "`window.Froged` is the queue stub only; identify calls queue harmlessly and no bubble appears."
        },
        {
          "name": "Repeated identify calls",
          "cause": "The `_isFrogedConfigSetted` flag never becomes true.",
          "handling": "Identity is re-sent on every menu recompute — harmless functionally, noisy for the vendor."
        },
        {
          "name": "Stale centre in support context",
          "cause": "User switches centre after the first identify.",
          "handling": "Because of the bug above, the new centre is actually re-sent — the bug masks what would otherwise be stale context."
        }
      ],
      "i18nKeys": [],
      "i18nMissing": []
    },
    {
      "id": "xc-product-analytics",
      "name": "Product analytics & error monitoring",
      "area": "Cross-cutting",
      "label": "(analytics)",
      "routes": [
        "(every route)"
      ],
      "code": [
        "app/services/analytics.js",
        "app/utils/analytics/providers",
        "app/enums/analytics-events.js",
        "app/routes/application.js",
        "app/services/auth.js"
      ],
      "models": [],
      "modules": [],
      "roles": [
        "all"
      ],
      "description": "A vendor-agnostic analytics facade that records page views and ~88 named product events (exports, imports, AI filter use, segment saves, status changes, logins), plus Sentry for error monitoring and `ember-metrics` page tracking.",
      "comments": "The service picks a provider from `config.analytics` (`provider`, `apiKey`, `enabled`, `debug`) and falls back to a no-op provider, wrapping every call so tracking can never break the UI. Every event is enriched with `center_id` and the current route. The codebase is notably privacy-aware: the AI filter sends only prompt length, the CLI only the command name, exports only filter and column counts. Sentry receives the user on login (`sentrySetUser`) and explicit `captureException` calls (e.g. the AI filter's mapping failures — which, in contrast to the analytics policy, include the prompt).",
      "useCases": [
        "Measuring adoption of features such as the AI filter or segment builder.",
        "Seeing which list screens are filtered and exported most.",
        "Diagnosing front-end errors per user in Sentry."
      ],
      "happyPaths": [
        {
          "name": "Event tracked",
          "steps": [
            "A user saves a customer; the controller calls `analytics.track(CRM_USER_SAVED, {...})`.",
            "The facade adds `center_id` and route and forwards to the configured provider."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Analytics not configured",
          "cause": "`enabled` false or no API key.",
          "handling": "The no-op provider is used; nothing is recorded and nothing breaks."
        },
        {
          "name": "Provider throws",
          "cause": "Vendor SDK error.",
          "handling": "Guarded inside `_call`; the UI is unaffected."
        },
        {
          "name": "PII in error reports",
          "cause": "`captureException` with free text.",
          "handling": "Not filtered — the AI filter sends its prompt to Sentry."
        }
      ],
      "i18nKeys": [],
      "i18nMissing": []
    },
    {
      "id": "config-mobile-app",
      "name": "Mobile app configuration",
      "area": "Configuration",
      "label": "configuration.center.app-configuration",
      "routes": [
        "configuration.centers.new",
        "configuration.centers.edit"
      ],
      "code": [
        "app/templates/configuration/centers/new.hbs",
        "app/models/app-config.js",
        "app/models/app-modules.js",
        "app/models/app-module.js",
        "app/models/app-module-welcome.js",
        "app/models/app-module-direction.js",
        "app/models/app-module-service.js",
        "app/models/app-module-registration-data.js",
        "app/models/app-registration-data-subtype.js",
        "app/models/app-login.js"
      ],
      "models": [
        "app-config",
        "app-modules",
        "app-module",
        "app-module-welcome",
        "app-module-direction",
        "app-module-service",
        "app-module-registration-data",
        "app-login"
      ],
      "modules": [],
      "roles": [
        "root",
        "admin",
        "manager",
        "scmanager"
      ],
      "description": "Per-centre settings for the customer mobile app: which app sections are on (welcome, featured, events, cinema, directions, play centre, services, visit list, WhatsApp support, call alert, VIP discount), the login screen's image or video, privacy text, how proposals are ordered (by date or recommended), short-link and URL templates for sharing proposals and lottery QR codes, and required registration data.",
      "comments": "Lives inside the centre editor's app-configuration tab and is persisted as fragments under `center.appConfig.modules`. `canShowProposalUrlTemplate` / `copyUrlTemplate` expose a public proposal URL pattern, and `copyUrlLotteryQr` the lottery QR pattern, both copied to the clipboard with `cms.proposal.copy-url-success`. Proposal ordering offers `date` or `recommended`. The visit-list switch also changes the proposal editor (`hasVisitListEnabled`). These settings change the *app*, not the back office, so a mistake is only visible on a phone.",
      "useCases": [
        "Turning the cinema or play-centre section of the app on for a centre that has them.",
        "Replacing the login-screen video for a seasonal campaign.",
        "Switching the app home to 'recommended' proposals ordering.",
        "Copying the proposal URL template to share content outside the app."
      ],
      "happyPaths": [
        {
          "name": "App section enabled",
          "steps": [
            "Admin opens My centre → App configuration.",
            "Ticks e.g. 'Directions active' and enters the directions URL.",
            "Saves the centre; the app shows the new section on next launch."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Section enabled without its data",
          "cause": "e.g. directions on with no URL.",
          "handling": "Only some sections have presence rules; others save and render an empty section in the app."
        },
        {
          "name": "Rich text with images",
          "cause": "An `<img>` inside the centre's info/privacy translations.",
          "handling": "The centre model's `info` custom rule rejects it (`error.configuration.center.extra.image-not-suported`)."
        },
        {
          "name": "Clipboard blocked",
          "cause": "Browser denies clipboard access.",
          "handling": "No error copy — only the success toast exists."
        }
      ],
      "i18nKeys": [
        "configuration.center.app-configuration",
        "configuration.center.app-modules",
        "configuration.center.app-modules.welcome",
        "configuration.center.app-modules.featured",
        "configuration.center.app-modules.events",
        "configuration.center.app-modules.cinema",
        "configuration.center.app-modules.directions",
        "configuration.center.app-modules.play-center",
        "configuration.center.app-modules.services",
        "configuration.center.app-modules.visit-list",
        "configuration.center.app-modules.support-whatsapp",
        "configuration.center.app-modules.call-alert",
        "configuration.center.app-modules.discount-vip",
        "configuration.center.app-login",
        "center.app-login.img-url",
        "center.app-login.video-enabled",
        "center.app-login.video-url",
        "configuration.center.app-config.privacity",
        "configuration.center.app-config.proposals",
        "configuration.center.app-config.proposals-order.date",
        "configuration.center.app-config.proposals-order.recommended",
        "configuration.center.app-config.bitly-url",
        "configuration.center.app-config.proposal-url-template",
        "configuration.center.app-config.qr-url-lottery",
        "cms.proposal.copy-url-success"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-customer-login",
      "name": "Customer login methods (Google, Facebook)",
      "area": "Configuration",
      "label": "configuration.center.login-configuration",
      "routes": [
        "configuration.centers.new",
        "configuration.centers.edit"
      ],
      "code": [
        "app/routes/configuration/centers/new.js",
        "app/models/google-login-config.js",
        "app/models/center.js"
      ],
      "models": [
        "google-login-config",
        "center"
      ],
      "modules": [],
      "roles": [
        "root",
        "admin",
        "manager",
        "scmanager"
      ],
      "description": "Chooses how customers can sign in to the centre's app and web: a Google OAuth project selected from the platform's registered Google login configurations, and/or Facebook with an app id and secret.",
      "comments": "The route loads every `googleLoginConfig` (client id + project name) sorted by project name, so Google projects are registered centrally and only *selected* per centre. Facebook is configured inline; the centre model requires `facebookAppSecret` whenever `facebookAppId` is filled. These are credentials, so they are visible to anyone who can edit the centre.",
      "useCases": [
        "Letting customers register with Google to reduce sign-up friction.",
        "Moving a centre to a new Facebook app.",
        "Reusing one Google OAuth project across a group's centres."
      ],
      "happyPaths": [
        {
          "name": "Social login enabled",
          "steps": [
            "Admin opens the login configuration section of the centre.",
            "Selects the Google project and enters the Facebook app id and secret.",
            "Saves; the app offers both sign-in buttons."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Facebook id without secret",
          "cause": "`facebookAppId` set, secret empty.",
          "handling": "`required` + `configuration.center.facebook-app-secret`."
        },
        {
          "name": "No Google projects registered",
          "cause": "Empty `googleLoginConfig` list.",
          "handling": "The picker is empty; Google login cannot be enabled from the UI."
        },
        {
          "name": "Wrong credentials",
          "cause": "Valid-looking but incorrect id/secret.",
          "handling": "Not verifiable here; failures appear only when customers try to sign in."
        }
      ],
      "i18nKeys": [
        "configuration.center.login-configuration",
        "configuration.center.login-config",
        "configuration.center.select-login",
        "configuration.center.facebook-login",
        "configuration.center.facebook-app-id",
        "configuration.center.facebook-app-secret"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-integrations",
      "name": "Third-party integrations & connectors",
      "area": "Configuration",
      "label": "configuration.center.modules",
      "routes": [
        "configuration.centers.new",
        "configuration.centers.edit"
      ],
      "code": [
        "app/templates/configuration/centers/new.hbs",
        "app/models/center.js",
        "app/models/module-connectif.js",
        "app/models/module-movies.js",
        "app/models/module-blog.js",
        "app/models/module-cashback.js",
        "app/models/module-footfall.js",
        "app/models/app-module-tally.js",
        "app/models/app-module-custom-forms.js",
        "app/models/app-module-pacv.js",
        "app/models/app-module-email-validation.js",
        "app/models/module-app-operator.js",
        "app/models/module-app-operator-playcenter.js",
        "app/serializers/module-neodoc-ocr.js"
      ],
      "models": [
        "module-connectif",
        "module-movies",
        "module-blog",
        "module-cashback",
        "module-footfall",
        "app-module-tally",
        "app-module-custom-forms",
        "app-module-pacv",
        "app-module-email-validation",
        "module-app-operator"
      ],
      "modules": [
        "connectif",
        "movies",
        "blog",
        "cashback",
        "tally",
        "forms",
        "email_validation",
        "app_operator",
        "form_commerces"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "scmanager"
      ],
      "description": "The per-centre connector settings: Connectif (marketing automation API URL and key), cinema listings (theatre API and id, app/web channels), blog URL, cashback (admin, client and store-transaction URLs), Tally and custom forms, PAC Virtual receipt capture with Neodoc or Neuro OCR, email validation, footfall, and the operator app for the play centre.",
      "comments": "Each connector is a module toggle plus parameters, validated with the pattern 'enabled AND value missing/invalid'. PAC Virtual ('PACV') is the receipt-upload channel: it has an application key, an automatic-validation switch, a ticket-notification option, a read-name option and an OCR provider; enabling Neodoc OCR makes its token mandatory (`configuration.center.neodoc-ocr.token`). `onChangeEnablePacv` / `onChangeEnableAppPacv` keep the CRM and app sides consistent. The operator app has its own sub-modules (`enums/modules-app-operator`), currently the play centre. Search modules (`store-search`, `staff-search`, `category-search`) are also toggled here.",
      "useCases": [
        "Sending customer events to Connectif for marketing automation.",
        "Showing cinema listings in the app from the theatre's API.",
        "Letting customers upload receipts via PAC Virtual with OCR so moderators only check exceptions.",
        "Enabling a cashback programme with separate admin and client portals."
      ],
      "happyPaths": [
        {
          "name": "PAC Virtual with OCR",
          "steps": [
            "Admin enables PACV on the CRM side, which reveals the app PACV section.",
            "Sets the application key, enables Neodoc OCR and pastes its token.",
            "Optionally enables automatic validation.",
            "Saves; uploaded receipts are read by OCR and land in the receipt queue already parsed."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "OCR enabled without token",
          "cause": "Neodoc OCR on, token empty.",
          "handling": "`required` + `configuration.center.neodoc-ocr.token`."
        },
        {
          "name": "Integration URL malformed",
          "cause": "Cashback, Tally, custom forms or chatbase URL fails the regex.",
          "handling": "Per-integration message (`configuration.center.cashback-url.error`, `.tally-url.error`, …)."
        },
        {
          "name": "Wrong API key",
          "cause": "Connectif or theatre API credentials incorrect.",
          "handling": "Not tested at save time; failures surface only in the downstream system."
        }
      ],
      "i18nKeys": [
        "configuration.center.modules",
        "configuration.center.connectif",
        "configuration.center.connectif.api-url",
        "configuration.center.connectif.api-key",
        "configuration.center.theaters",
        "configuration.center.theaters.api-url",
        "configuration.center.theaters.theater-id",
        "configuration.center.blog",
        "configuration.center.blog.url",
        "configuration.center.cashback",
        "configuration.center.cashback.admin",
        "configuration.center.cashback.client",
        "configuration.center.cashback.store-transactions",
        "configuration.center.crm.pacv",
        "configuration.center.crm.pacv.applicationKey",
        "configuration.center.crm.pacv.hasAutomaticValidation",
        "configuration.center.neodoc-ocr",
        "configuration.center.neodoc-ocr.token",
        "configuration.center.neuro-ocr",
        "configuration.center.ticket-notification",
        "configuration.center.tally",
        "configuration.center.crm.custom-forms",
        "configuration.center.email-validation",
        "configuration.center.footfall",
        "configuration.center.operator-app",
        "configuration.modules.store-search",
        "configuration.modules.staff-search",
        "configuration.modules.category-search"
      ],
      "i18nMissing": []
    },
    {
      "id": "config-social-wifi-location",
      "name": "Social wifi & location services",
      "area": "Configuration",
      "label": "configuration.center.flame / configuration.center.location",
      "routes": [
        "configuration.centers.new",
        "configuration.centers.edit"
      ],
      "code": [
        "app/models/app-module-flame.js",
        "app/models/app-module-location.js",
        "app/models/module-social-wifi.js",
        "app/enums/wifi-security-types.js",
        "app/controllers/configuration/centers/new.js"
      ],
      "models": [
        "app-module-flame",
        "app-module-location",
        "module-social-wifi"
      ],
      "modules": [
        "foreground_location"
      ],
      "roles": [
        "root",
        "admin",
        "manager",
        "scmanager"
      ],
      "description": "Configures the centre's social wifi (Flame: network SSID and security type — open, WEP, WPA or PEAP) so the app can connect customers, and the location features: a geofence radius and which location-triggered features are on (coupon exchange, push notifications, store polls), plus foreground location.",
      "comments": "Validation follows the module pattern: Flame enabled requires an SSID; location enabled requires a positive radius; and `handleLocationCheckboxChange` requires at least one of the three location features (`configuration.center.location.enabled-error-message`). Location settings are what make 'on-location' push notifications and location-based coupon exchange work, and foreground location adds `calculated_income` / `calculated_postal_code` to customer exports.",
      "useCases": [
        "Auto-joining app users to the centre's wifi.",
        "Sending a push when customers arrive inside the geofence.",
        "Only allowing coupon redemption when the customer is physically at the centre."
      ],
      "happyPaths": [
        {
          "name": "Geofenced features enabled",
          "steps": [
            "Admin enables location, sets a radius in metres and ticks push notifications and coupon exchange.",
            "Saves; on-location pushes and location-gated coupons now work in the app."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Wifi without SSID",
          "cause": "Flame enabled, SSID blank.",
          "handling": "`required` + `configuration.center.flame.ssid`."
        },
        {
          "name": "Location without radius",
          "cause": "Radius blank or not greater than zero.",
          "handling": "`required` + `configuration.center.location.location-radius`."
        },
        {
          "name": "Location with no feature ticked",
          "cause": "All three sub-options off.",
          "handling": "`configuration.center.location.enabled-error-message`."
        }
      ],
      "i18nKeys": [
        "configuration.center.flame",
        "configuration.center.flame.ssid",
        "configuration.center.flame.securityType",
        "configuration.center.location",
        "configuration.center.location.coupon-exchange",
        "configuration.center.location.push-notifications",
        "configuration.center.location.shop-poll",
        "configuration.center.location.enabled-error-message",
        "configuration.center.foreground-location",
        "wifi-security-type.open"
      ],
      "i18nMissing": []
    },
    {
      "id": "crm-customer-action-log",
      "name": "Customer action log (dormant)",
      "area": "CRM",
      "label": "actions.new / actions.edit",
      "routes": [
        "(not mounted)"
      ],
      "code": [
        "app/components/form-user/row-actions.js",
        "app/templates/components/form-user/row-actions.hbs",
        "app/components/form-action.js",
        "app/models/action.js",
        "app/enums/action-types.js",
        "app/services/user-actions.js"
      ],
      "models": [
        "action",
        "user",
        "staff-member"
      ],
      "modules": [
        "crm"
      ],
      "roles": [
        "n/a"
      ],
      "description": "A per-customer log of interactions — calls, visits, emails, forms (manual) and automatic entries such as marketing info sent, reservations and rejections — each with a scheduled and a done date, a title and notes, attributed to a staff member. The code is complete but no template renders it.",
      "comments": "`row-actions` supports create (pre-filled with now, the current staff member and the customer), edit, save (tracking `USER_ACTION_LOGGED`) and delete; the `action` model distinguishes scheduled from done (`isDone`). However, `form-user/row-actions` is not referenced from any template, so the feature is unreachable. Listed so it is not mistaken for missing — it is built and switched off.",
      "useCases": [
        "Recording that a customer was called about a lost item.",
        "Scheduling a follow-up visit and marking it done later.",
        "Seeing the automatic history of marketing sent to a customer."
      ],
      "happyPaths": [
        {
          "name": "If re-mounted: action logged",
          "steps": [
            "Operator opens a customer and creates an action of type call.",
            "Fills title and notes and saves; the action appears in the customer's list."
          ]
        }
      ],
      "failurePaths": [
        {
          "name": "Unreachable",
          "cause": "Component not used by any template.",
          "handling": "No user can reach it today."
        },
        {
          "name": "If re-mounted: save fails",
          "cause": "API error.",
          "handling": "`saveAction` has no `.catch()`; the modal stays open with no message."
        },
        {
          "name": "Raw translation key on screen",
          "cause": "The code references `actions.edit` but the key does not exist in translations.json.",
          "handling": "ember-intl renders the missing key (or its missing-message placeholder) instead of text — visible to every user of this screen until the key is added."
        }
      ],
      "i18nKeys": [
        "actions.new",
        "actions.edit",
        "actions.call",
        "actions.visit",
        "actions.email",
        "actions.form",
        "actions.sent_marketing_info",
        "actions.reservation",
        "actions.rejection",
        "actions.datetime"
      ],
      "i18nMissing": [
        "actions.edit"
      ]
    }
  ],
  "findings": [
    {
      "id": "R01",
      "title": "Unsubscribe notice is fire-and-forget on customer deletion",
      "severity": "high",
      "category": "compliance",
      "features": [
        "crm-customer-delete"
      ],
      "detail": "The POST to `users_unsubscribe_send` has no `.then`/`.catch`; if it fails the customer is deleted but never notified.",
      "location": "app/controllers/crm/users/index.js",
      "suggestion": "Await the request and surface a warning when it fails, or move the notification server-side into the delete."
    },
    {
      "id": "R02",
      "title": "Rejected annual statement can leave the store uninformed",
      "severity": "high",
      "category": "silent-failure",
      "features": [
        "intranet-sales-years"
      ],
      "detail": "`rejectSaleYear` saves the rejection, then the communication; a bare `.catch(() => {})` hides a failed communication.",
      "location": "app/controllers/intranet/sales-years/show.js",
      "suggestion": "Report the failure and offer to resend, or create both atomically on the API."
    },
    {
      "id": "R03",
      "title": "Missing unsubscribe link only warns",
      "severity": "medium",
      "category": "compliance",
      "features": [
        "marketing-email-campaign-builder"
      ],
      "detail": "`email-campaign.warning-unsub-link` is a warning; a template without an unsubscribe link can still be sent.",
      "location": "app/controllers/marketing/emailing/new.js",
      "suggestion": "Make the unsubscribe link a publish-blocking validation."
    },
    {
      "id": "R04",
      "title": "Export spinner never clears on failure",
      "severity": "medium",
      "category": "silent-failure",
      "features": [
        "crm-customer-export",
        "xc-export-engine"
      ],
      "detail": "`load.export` never rejects on a non-OK response and callers have no `.catch()`, so `isExportLoading` stays true.",
      "location": "app/services/load.js",
      "suggestion": "Reject on `!response.ok` in the service and handle it in each caller."
    },
    {
      "id": "R05",
      "title": "AI filter prompt is not URL-encoded",
      "severity": "medium",
      "category": "bug",
      "features": [
        "crm-ai-filter"
      ],
      "detail": "The prompt is concatenated into `?prompt=` unescaped, so `&` or `#` truncate it.",
      "location": "app/components/nf-ai-filter.js",
      "suggestion": "Wrap the prompt in `encodeURIComponent`."
    },
    {
      "id": "R06",
      "title": "AI filter sends prompt text to Sentry",
      "severity": "medium",
      "category": "privacy",
      "features": [
        "crm-ai-filter",
        "xc-product-analytics"
      ],
      "detail": "Analytics deliberately sends only prompt length, but `captureException` includes the full prompt.",
      "location": "app/components/nf-ai-filter.js",
      "suggestion": "Send prompt length and filter keys only."
    },
    {
      "id": "R07",
      "title": "`campaign#canDelete` compares a role to a status",
      "severity": "medium",
      "category": "bug",
      "features": [
        "marketing-campaign-list",
        "xc-permissions"
      ],
      "detail": "`!['closed','finished',CONTENT_CREATOR].includes(status)` mixes a role constant into a status list.",
      "location": "app/abilities/campaign.js",
      "suggestion": "Remove `CONTENT_CREATOR` from the status list and express the role rule separately."
    },
    {
      "id": "R08",
      "title": "Error handler crashes on payloads without `status`",
      "severity": "medium",
      "category": "bug",
      "features": [
        "marketing-campaign-editor",
        "cms-proposal-editor",
        "xc-error-handling"
      ],
      "detail": "`adapterError.errors[0].status.charAt()` throws when `status` is missing, replacing the original error.",
      "location": "app/controllers/marketing/campaigns/new.js",
      "suggestion": "Guard with optional chaining and default to `api-error`."
    },
    {
      "id": "R09",
      "title": "Unknown receipt reject reason breaks the moderation panel",
      "severity": "medium",
      "category": "bug",
      "features": [
        "crm-receipt-moderation"
      ],
      "detail": "`rejectReasons` does `.find(...).label` without a guard.",
      "location": "app/controllers/crm/receipts/index.js",
      "suggestion": "Fall back to the raw value when no enum entry matches."
    },
    {
      "id": "R10",
      "title": "Activity amount validation throws without a base rule",
      "severity": "medium",
      "category": "bug",
      "features": [
        "marketing-campaign-editor"
      ],
      "detail": "The `amount` rule reads `generalRule.lastObject.amount` unguarded.",
      "location": "app/models/campaign.js",
      "suggestion": "Treat a missing base rule as invalid instead of throwing."
    },
    {
      "id": "R11",
      "title": "Dashboard component JSON is parsed unguarded",
      "severity": "medium",
      "category": "bug",
      "features": [
        "config-dashboards"
      ],
      "detail": "The `components` setter calls `JSON.parse` on free text; invalid JSON throws inside the form.",
      "location": "app/controllers/configuration/dashboards/new.js",
      "suggestion": "Parse in try/catch and show a field error."
    },
    {
      "id": "R12",
      "title": "Auto-logout silently disabled when minutes are unset",
      "severity": "medium",
      "category": "configuration",
      "features": [
        "platform-auto-logout"
      ],
      "detail": "Module enabled without `minutes` makes `logout()` return early.",
      "location": "app/services/idle-monitor.js",
      "suggestion": "Validate `minutes` on the centre form when the module is enabled."
    },
    {
      "id": "R13",
      "title": "Missing centre cookie produces unscoped queries",
      "severity": "medium",
      "category": "bug",
      "features": [
        "platform-login",
        "platform-center-switching"
      ],
      "detail": "`centerId` swallows `NoCookieError` and returns `''`.",
      "location": "app/services/auth.js",
      "suggestion": "Redirect to centre selection when no centre is available."
    },
    {
      "id": "R14",
      "title": "Secondary-language content is not enforced",
      "severity": "medium",
      "category": "i18n",
      "features": [
        "xc-multilanguage",
        "cms-proposal-editor",
        "marketing-campaign-editor"
      ],
      "detail": "Only multicentre proposals check all languages before saving.",
      "location": "app/controllers/cms/multicentre-proposals/new.js",
      "suggestion": "Reuse `_validateProposalTranslationFields` in the other editors."
    },
    {
      "id": "R15",
      "title": "19 translation keys used by the code do not exist",
      "severity": "medium",
      "category": "i18n",
      "features": [
        "cms-multicentre-proposals",
        "config-server-incidents",
        "intranet-safte",
        "intranet-communications",
        "marketing-campaign-editor",
        "cms-proposal-list",
        "config-staff-editor",
        "crm-commerce-types",
        "marketing-activity-campaigns",
        "marketing-email-campaign-builder",
        "cms-proposal-editor",
        "crm-customer-action-log"
      ],
      "detail": "Keys such as `error.month.required`, `error.server-incident.required` and `crm.commercy-types` render as raw keys.",
      "location": "translations.json",
      "suggestion": "Add the keys (and fix the `commercy` typo at the call site)."
    },
    {
      "id": "R16",
      "title": "Segment refresh and save hide failures",
      "severity": "low",
      "category": "silent-failure",
      "features": [
        "crm-segment-list",
        "crm-segment-builder",
        "xc-segment-filter-builder"
      ],
      "detail": "Bare `.catch(() => {})` on refresh/submit; `saveChanges` has no catch so `isSavingChanges` sticks.",
      "location": "app/controllers/crm/segments/index.js",
      "suggestion": "Surface an error state."
    },
    {
      "id": "R17",
      "title": "CLI gives no feedback on network failure",
      "severity": "low",
      "category": "silent-failure",
      "features": [
        "config-cli"
      ],
      "detail": "The fetch has no `.catch()`, and the JSON parameter rule has no message.",
      "location": "app/controllers/configuration/cli.js",
      "suggestion": "Add a catch and a message for invalid JSON."
    },
    {
      "id": "R18",
      "title": "Idle monitor cannot remove its listeners",
      "severity": "low",
      "category": "bug",
      "features": [
        "platform-auto-logout"
      ],
      "detail": "`removeEventListener` is called with new `.bind(this)` references.",
      "location": "app/services/idle-monitor.js",
      "suggestion": "Store bound handlers once and reuse them."
    },
    {
      "id": "R19",
      "title": "Froged identify flag never set",
      "severity": "low",
      "category": "bug",
      "features": [
        "platform-support-widget"
      ],
      "detail": "`_isFrogedConfigSetted` is set to `false` inside its own guard.",
      "location": "app/components/nf-navbar.js",
      "suggestion": "Set it to `true` (and reset on centre change if re-identification is wanted)."
    },
    {
      "id": "R20",
      "title": "Portuguese string in the English bundle",
      "severity": "low",
      "category": "i18n",
      "features": [
        "xc-csv-import-engine",
        "platform-ui-language"
      ],
      "detail": "`import-csv.errors.load.required-selections` is Portuguese in translations.json.",
      "location": "translations.json",
      "suggestion": "Translate it."
    },
    {
      "id": "R21",
      "title": "Required-extra errors are untranslated",
      "severity": "low",
      "category": "i18n",
      "features": [
        "crm-commerce-form",
        "cms-proposal-editor",
        "config-extra-attributes"
      ],
      "detail": "`` `${label} is required` `` is built in code.",
      "location": "app/controllers/crm/commerces/new.js",
      "suggestion": "Use `intl.t('required', { attr: label })`."
    },
    {
      "id": "R22",
      "title": "Push image size failure is swallowed",
      "severity": "low",
      "category": "silent-failure",
      "features": [
        "cms-push-notification-editor"
      ],
      "detail": "`checkImageSize` failures land in an empty `catch {}`.",
      "location": "app/controllers/cms/push-notifications/new.js",
      "suggestion": "Show `error.file.oversize`."
    },
    {
      "id": "R23",
      "title": "Channels cannot be edited",
      "severity": "low",
      "category": "gap",
      "features": [
        "config-channels"
      ],
      "detail": "The router defines only `index` and `new` for channels.",
      "location": "app/router.js",
      "suggestion": "Add an edit route or document channels as immutable."
    },
    {
      "id": "R24",
      "title": "Influence-zone load errors look like an empty list",
      "severity": "low",
      "category": "silent-failure",
      "features": [
        "config-influence-zones"
      ],
      "detail": "`fetchData` catches and renders zero rows with a fabricated meta.",
      "location": "app/controllers/configuration/influence-zones/index.js",
      "suggestion": "Show an error state."
    },
    {
      "id": "R25",
      "title": "Accessibility statement has no PDF fallback",
      "severity": "low",
      "category": "accessibility",
      "features": [
        "config-accessibility"
      ],
      "detail": "A bare `<embed>` with no download link or text alternative.",
      "location": "app/templates/configuration/accesibility.hbs",
      "suggestion": "Add a download link and an HTML summary."
    },
    {
      "id": "R26",
      "title": "Realtime updates have no reconnect",
      "severity": "low",
      "category": "resilience",
      "features": [
        "xc-realtime"
      ],
      "detail": "No reconnect or polling fallback for Pusher.",
      "location": "app/services/pusher.js",
      "suggestion": "Add reconnect handling or a refresh prompt."
    },
    {
      "id": "R27",
      "title": "Desktop receipts query has no catch",
      "severity": "low",
      "category": "silent-failure",
      "features": [
        "platform-desktop"
      ],
      "detail": "`showModalReceipts` is fired without await or catch.",
      "location": "app/routes/index.js",
      "suggestion": "Catch and ignore explicitly."
    },
    {
      "id": "R28",
      "title": "Annual sales statement missing from the menu",
      "severity": "info",
      "category": "discoverability",
      "features": [
        "intranet-sales-years"
      ],
      "detail": "The menu item is commented out.",
      "location": "app/utils/get-side-nav-menu.js",
      "suggestion": "Restore the entry or retire the feature."
    },
    {
      "id": "R29",
      "title": "Activity duplicate uses proposal wording",
      "severity": "info",
      "category": "copy",
      "features": [
        "marketing-campaign-list"
      ],
      "detail": "Duplicating an activity shows `cms.proposals.proposal-duplicated(-error)`.",
      "location": "app/controllers/marketing/campaigns/index.js",
      "suggestion": "Add activity-specific keys."
    },
    {
      "id": "R30",
      "title": "Dormant and dead surface",
      "severity": "info",
      "category": "dead-code",
      "features": [
        "crm-customer-action-log"
      ],
      "detail": "Customer action log not mounted; `commerce-stay-alert`, `bonusRequest`, `dmr-weekly`, `marketing-analytics` exist only as models/keys.",
      "location": "app/components/form-user/row-actions.js",
      "suggestion": "Decide: ship, or delete with their translations."
    },
    {
      "id": "R31",
      "title": "Intranet files accept an `owner` role the UI never offers",
      "severity": "info",
      "category": "consistency",
      "features": [
        "intranet-files"
      ],
      "detail": "The model's inclusion rule allows `owner`; `enums/intranet-file-roles` does not.",
      "location": "app/models/intranet-file.js",
      "suggestion": "Align the enum and the rule."
    },
    {
      "id": "R32",
      "title": "Runtime config failure is a blank page",
      "severity": "medium",
      "category": "resilience",
      "features": [
        "platform-runtime-config"
      ],
      "detail": "`beforeModel` awaits `config.json` with no error UI.",
      "location": "app/routes/application.js",
      "suggestion": "Render a static error screen when config cannot load."
    }
  ]
}