From b25ab2fb3aa11d93e8059dd0a813a454eeb3311a Mon Sep 17 00:00:00 2001 From: dominikpalatynski Date: Wed, 1 Apr 2026 15:17:26 +0200 Subject: [PATCH 001/215] feat/ready-apps-cli --- .ai/specs/2026-03-02-ready-apps-framework.md | 394 +++++++++++++++ packages/create-app/AGENTS.md | 18 + packages/create-app/README.md | 53 ++- packages/create-app/package.json | 5 +- packages/create-app/src/index.ts | 276 +++++++---- .../create-app/src/lib/ready-apps.test.ts | 251 ++++++++++ packages/create-app/src/lib/ready-apps.ts | 448 ++++++++++++++++++ yarn.lock | 2 + 8 files changed, 1341 insertions(+), 106 deletions(-) create mode 100644 .ai/specs/2026-03-02-ready-apps-framework.md create mode 100644 packages/create-app/src/lib/ready-apps.test.ts create mode 100644 packages/create-app/src/lib/ready-apps.ts diff --git a/.ai/specs/2026-03-02-ready-apps-framework.md b/.ai/specs/2026-03-02-ready-apps-framework.md new file mode 100644 index 00000000000..66e488633e2 --- /dev/null +++ b/.ai/specs/2026-03-02-ready-apps-framework.md @@ -0,0 +1,394 @@ +# Ready Apps Framework + +| Field | Value | +|-------|-------| +| **Status** | Active | +| **Author** | Open Mercato Team & Partners | +| **Created** | 2026-03-02 | +| **Related** | SPEC-013 (setup.ts), SPEC-041 (UMES), SPEC-045 (registry pattern), SPEC-051 (Partnership Portal), SPEC-053 (B2B PRM) | + +## TLDR +**Key Points:** +- Introduce a first-class "ready app" layer so engineers can bootstrap a polished use-case solution (like a B2B PRM or B2B Quotes system) with a single command instead of a blank tenant. +- Ready apps are **not** part of the Open Mercato core repository. Each official ready app lives in its **own GitHub repository** under the `open-mercato` organization, using the naming convention `ready-app-`. +- Bootstrap via `create-mercato-app --app ` for official Open Mercato ready apps and `create-mercato-app --app-url ` for external GitHub-hosted ready apps. +- Preserve UMES and module boundaries: all vertical behavior is delivered via app modules, setup hooks, widgets, enrichers, and events, built within the ready app's own `src/modules` structure. + +**Scope:** +- Ready app definition and distribution model (external to core). +- `--app` and `--app-url` flags for `create-mercato-app`. +- GitHub repository naming and fetch mechanism. + +**Concerns:** +- Keep the core absolutely clean of specific business logic or use-case configurations. +- Ensure extensions built for ready apps fully leverage the Universal Mercato Extension System (UMES). + +## Overview +Open Mercato needs productized "ready projects" that reduce time-to-first-value for common B2B use cases such as PRM, field service, or marketplace ops. Today, teams start from a generic tenant and manually assemble modules, dictionaries, workflows, and role settings. + +This spec defines a framework to package those decisions into reusable ready apps while keeping the core platform entirely agnostic. + +> **Market Reference:** Inspired by source-first scaffolders in the JavaScript ecosystem, but adapted to Open Mercato's ecosystem and ownership model. Adopted: `--app`, `--app-url`, GitHub tarball fetch, and one official ready app per repository. Rejected: centralized official catalog repo, npm-only distribution, manual clone-and-copy workflow. + +## Problem Statement +Without a ready apps framework: +- each implementation repeats the same setup work, +- demo and pilot environments are inconsistent across teams, +- reuse is ad hoc and hard to maintain, +- sales-to-delivery handoff has no standard baseline. + +If ready apps were integrated into the core: +- the core repository would become bloated with specific, niche configurations, +- the maintenance burden on the core team would increase dramatically, +- partner agencies would lack ownership over the specific vertical solutions they create. + +The business goal is to turn repeated delivery patterns into reusable assets owned by the ecosystem, while keeping core evolution safe and lean. + +## Proposed Solution +Implement a Ready Apps framework with two tiers: + +1. **The Core Engine**: `open-mercato/core` and `create-mercato-app` remain agnostic and clean. +2. **Official Ready Apps**: Maintained by the Open Mercato team in **separate GitHub repositories** under the `open-mercato` organization, one repository per app, named `ready-app-`. +3. **External Ready Apps**: Maintained by partners/agencies in their own GitHub repositories. +4. **The Bootstrap Flow**: `create-mercato-app --app ` fetches an official Open Mercato repository; `create-mercato-app --app-url ` fetches an external GitHub repository and scaffolds a ready-to-run app. + +### Design Decisions +| Decision | Rationale | +|----------|-----------| +| Ready apps live outside the core repository | Keeps core clean, reduces bloat, delegates domain ownership to partners/agencies. | +| Official ready apps use one repo per app | Preserves clear ownership, release cadence, CI, and issue tracking per app. | +| Official repo naming is `ready-app-` | Makes CLI resolution deterministic and keeps organization-level discovery simple. | +| External ready apps remain independent GitHub repos | Partners/agencies own their vertical solutions fully. | +| `--app` flag on `create-mercato-app` | Single-command bootstrap for official Open Mercato maintained ready apps. | +| `--app-url` flag on `create-mercato-app` | Explicit path for external GitHub-hosted ready apps without overloading one flag. | +| Each ready app is a complete, runnable app | No merge complexity; each app includes the full scaffold plus domain modules. | +| GitHub API tarball fetch | No git dependency required; proven mechanism for scaffold tools. | +| No new runtime extension model | Reuse UMES, events, setup.ts, entity extensions within the deployed application. | +| App-level ownership for business-specific behavior | Matches monorepo rule: user-specific features live in the generated app's `src/modules`. | + +### Alternatives Considered +| Alternative | Why Rejected | +|-------------|-------------| +| Centralized official repo such as `open-mercato/ready-apps` | Couples unrelated apps into one release surface and one CI pipeline; ownership is less clear. | +| Single overloaded source flag for both official and external sources | Less explicit than separating official-name lookup from direct external URL fetch. | +| NPM-only distribution | "Black-box" packages restrict customization of complex business logic. | +| Manual two-step flow (scaffold then copy) | Poor DX, error-prone, unnecessary friction. | +| Delta/overlay on top of bare scaffold | Merge conflicts, version coupling, and complex implementation. | + +### What This Spec Explicitly Avoids +- No ready app configurations committed to the `open-mercato/open-mercato` core repository. +- No direct cross-module ORM relationships inside the core. +- No use-case-specific KPI ownership or API logic in the core frameworks. + +## User Stories / Use Cases +- An Engineer wants to bootstrap a new B2B PRM application. They run `npx create-mercato-app my-prm --app prm` and get a complete, demo-ready PRM app from `open-mercato/ready-app-prm`. +- An Engineer wants to use an external ready app. They run `npx create-mercato-app my-app --app-url https://github.com/some-agency/ready-app-marketplace`. +- A Partner Agency wants to distribute their specialized marketplace workflows. They maintain a GitHub repository with a complete Open Mercato app that includes their UMES extensions, widgets, and seeds. +- An Engineer wants a blank app with no ready app. They run `npx create-mercato-app my-app` (unchanged behavior). + +## Architecture + +### CLI Interface + +New flags for `create-mercato-app`: `--app` and `--app-url` + +```bash +# Official Open Mercato ready app +npx create-mercato-app my-prm --app prm + +# External GitHub-hosted ready app +npx create-mercato-app my-app --app-url https://github.com/some-agency/ready-app-marketplace + +# No ready app - current behavior (bare scaffold) +npx create-mercato-app my-app +``` + +Resolution logic: +- `--app ` resolves to the GitHub repository `open-mercato/ready-app-` +- `--app-url ` fetches the full GitHub repository at that URL +- `--app` and `--app-url` are mutually exclusive +- App names are kebab-case slugs and MUST map directly to repository names without additional lookup tables in v1 + +Backward compatibility: +- The no-flag invocation remains unchanged. +- `--app` and `--app-url` are additive optional flags. + +### Repository Structure + +Official ready apps live as separate repositories in the `open-mercato` organization: + +```text +open-mercato/ready-app-prm +open-mercato/ready-app-quotes +open-mercato/ready-app-field-service +``` + +Each repository root is a **complete, runnable app**: + +```text +ready-app-prm/ +├── src/modules/ # PRM-specific modules +├── package.json +├── .env.sample +├── README.md +└── ... +``` + +### Fetch Mechanism + +Uses GitHub API tarball download: + +- Official: `GET https://api.github.com/repos/open-mercato/ready-app-/tarball/` +- External: `GET https://api.github.com/repos/{owner}/{repo}/tarball/` +- No git dependency required + +Ref resolution: +- `--app ` MUST resolve the official repository ref to the exact tag `v` +- Official bootstrap MUST NOT default to `main` for stable releases +- `--app-url ` uses the ref encoded in the GitHub URL when present (for example `/tree/`); otherwise it uses the repository default branch + +### Imported App Snapshot Contract + +Ready apps fetched via `--app` and `--app-url` are complete source repositories, not templates. + +Rules: +- The CLI MUST extract the fetched ready app into the target directory as a raw source snapshot +- The CLI MUST NOT rewrite dependency versions, package names, or application source files inside fetched ready apps +- Imported ready apps MUST NOT rely on `.template` files or placeholder substitution +- The `.template` processor remains part of the bare scaffold path only, not the imported ready app path +- The CLI MUST skip the interactive agentic setup wizard for imported ready apps +- If agentic tooling is needed for an imported ready app, it MUST be added explicitly later by a separate manual command + +### Reference Flow +```text +developer runs `npx create-mercato-app my-prm-app --app prm` -> +create-mercato-app resolves `open-mercato/ready-app-prm` + tag `v` -> +downloads the GitHub tarball -> +extracts the ready app source snapshot to the target directory -> +developer runs `yarn install` -> `yarn initialize` (setup.ts hooks run) -> +app is ready with domain baseline. +``` + +### Error Handling +- Official app repo not found: clear error including the resolved repository name (`open-mercato/ready-app-`) +- Official app tag not found: clear compatibility error including the missing tag name and repo +- GitHub API unreachable / 404: error with suggestion to check network or repository URL +- Private repo without auth: error suggesting `GITHUB_TOKEN` env var for authenticated requests +- `--app-url` with a non-GitHub URL: clear error stating that only GitHub repositories are supported in v1 +- Imported ready app contains `.template` files: clear error stating that imported ready apps must be committed source snapshots +- Both `--app` and `--app-url` provided: clear validation error before any network call + +### Non-Negotiable Architecture Guardrails +1. Ready app modules extend host surfaces only through UMES and documented core contracts. +2. Ready app implementation lives completely outside the OM core repository. +3. The Open Mercato platform provides the extension points (hooks, enrichers, registries), but not the business configuration for ready apps. + +## Data Models + +Not applicable. This spec defines distribution and bootstrap infrastructure, not application entities. + +## API Contracts + +Not applicable. No application HTTP APIs are introduced. The external contract is the `create-mercato-app` CLI surface: +- `--app ` +- `--app-url ` + +## Implementation Details + +Because ready apps are external complete apps, there is no centralized database table required in the core engine. The "installation status" of a ready app is simply the presence of its modules and configurations within the application codebase. + +The standard `yarn initialize` (which triggers module hooks defined in `setup.ts`) is sufficient to bootstrap the application after scaffolding. + +## Versioning + +Ready app bootstrap has three independent version axes: + +1. **CLI version**: the version of `create-mercato-app` +2. **Ready app source ref**: the git tag or branch fetched from the ready app repository +3. **Committed dependency graph**: the exact dependency versions stored inside the fetched ready app repository + +### Release Line Contract + +- `create-mercato-app` and official `@open-mercato/*` packages MUST ship on the same version line +- Official ready app repositories MUST publish a matching git tag for every supported Open Mercato release using the format `v` +- `--app` MUST fetch the official ready app tag that matches the running `create-mercato-app` version exactly +- Official ready app `main` branches MAY move ahead, but they are not the compatibility contract used by released CLI bootstraps +- Dependency versions inside an official ready app tag are owned by that ready app repository and MUST NOT be rewritten by the CLI + +### Dependency Strategy + +- Official ready apps MUST commit their `@open-mercato/*` dependency versions directly in `package.json` +- External ready apps MUST own their dependency policy in source control and MUST declare explicit versions or semver ranges in `package.json` +- The CLI MUST treat dependency versions in imported ready apps as repository-owned source, not bootstrap-time inputs +- External ready apps SHOULD pin stable major/minor ranges conservatively and update them intentionally after verification + +### Reproducibility + +- Official `--app` bootstraps are reproducible because the repo ref resolves from the CLI release and the dependency graph is committed in that tagged ready app repository +- External `--app-url` bootstraps are reproducible only when the URL points to a stable ref; otherwise they follow the repository default branch and whatever dependency graph is committed there +- External maintainers SHOULD document the tested Open Mercato compatibility range in their README + +## Migration & Compatibility +- Official ready apps define compatibility through the pair `(repo tag, committed dependency graph)`, aligned to the `create-mercato-app` release line. +- External ready apps define compatibility through the chosen ref and the versions committed in their own `package.json`. +- Core APIs guarantee semantic versioning, allowing ready app maintainers to update their apps accordingly. +- Each official `ready-app-*` repository should have CI that validates every supported release tag still builds against the matching core release line. +- Backward compatible: `create-mercato-app` without `--app` or `--app-url` continues to work exactly as before. + +## Implementation Plan + +### Phase 1 - CLI Flags +1. Add `--app` and `--app-url` to the `create-mercato-app` argument parser. +2. Enforce mutual exclusivity between `--app` and `--app-url`. +3. Implement official app name resolution from `` to `open-mercato/ready-app-`. +4. Resolve official app ref from the running CLI version to tag `v`. +5. Implement GitHub API tarball fetch for both official and external repositories. +6. Implement raw snapshot extraction for imported ready apps without template processing. +7. Skip the interactive agentic setup wizard for imported ready apps. +8. Add error handling for missing repos, missing tags, network failures, invalid URLs, private repos, unsupported `.template` files in imported apps, and duplicate source flags. + +### Phase 2 - Official Ready App Repositories +1. Create the first official repository, such as `open-mercato/ready-app-prm`. +2. Add the first ready app implementation as a complete runnable application at repo root. +3. Commit explicit first-party dependency versions in the repository for each tagged release line. +4. Add CI to validate the app builds for the tagged release line. +5. Document the organization naming convention and release tagging rules for future official ready apps. + +### File Manifest + +| File | Repo | Action | Purpose | +|------|------|--------|---------| +| `packages/create-app/src/index.ts` | open-mercato | Modify | Add `--app` / `--app-url`, repo resolution, fetch logic | +| `packages/create-app/AGENTS.md` | open-mercato | Modify | Document `--app` / `--app-url` behavior | +| repo root | `open-mercato/ready-app-prm` | Create | First official ready app | +| `README.md` | `open-mercato/ready-app-prm` | Create | App usage, compatibility, and bootstrap docs | +| `.github/workflows/ci.yml` | `open-mercato/ready-app-prm` | Create | Validate the ready app builds | + +### Testing Strategy +- Unit: name-to-repo resolution, official tag resolution, URL parsing, mutual exclusivity validation, tarball extraction, and imported-app snapshot validation +- Integration: end-to-end `create-mercato-app --app` with an official fixture repo tagged to the current CLI version +- Integration: end-to-end `create-mercato-app --app-url` with an external GitHub fixture repo copied as-is +- Integration: verify imported ready apps do not get `AGENTS.md`, `.ai/`, `.claude/`, `.cursor/`, or other wizard-generated files added or overwritten by bootstrap +- CI on each official ready app repo: `yarn install && yarn generate && yarn build` + +## Risks & Impact Review + +### Migration & Deployment Risks + +### Operational Risks + +#### GitHub API rate limiting blocks ready app fetch +- **Scenario**: Unauthenticated GitHub API calls hit rate limits during workshop/training events. +- **Severity**: Medium +- **Affected area**: `create-mercato-app --app` / `--app-url` bootstrap +- **Mitigation**: Support `GITHUB_TOKEN` env var for authenticated requests; clear error message on rate limit. +- **Residual risk**: Low with token usage + +#### Per-app repositories drift from core compatibility +- **Scenario**: Core packages update but one or more official ready app repos are not updated, causing build failures for new users. +- **Severity**: Medium +- **Affected area**: Developer onboarding experience +- **Mitigation**: CI in every official ready app repo; required release tags per supported version; ownership assigned per app repo. +- **Residual risk**: Medium - requires active maintenance + +#### Imported ready app is not actually committed as a source snapshot +- **Scenario**: A ready app repository still contains `.template` files or expects bootstrap-time rewriting, causing broken installs after fetch. +- **Severity**: High +- **Affected area**: Imported ready app bootstrap correctness +- **Mitigation**: Forbid template-based imported apps and fail closed during scaffold. +- **Residual risk**: Low + +#### Organization-level discovery becomes weaker without one central catalog repo +- **Scenario**: Users do not know which official ready app names are available for `--app`. +- **Severity**: Medium +- **Affected area**: Developer experience and discoverability +- **Mitigation**: Maintain a documentation index on docs.open-mercato.com or in the main documentation repo; keep repo naming deterministic. +- **Residual risk**: Low + +## Final Compliance Report - 2026-04-01 + +### AGENTS.md Files Reviewed +- `AGENTS.md` (root) +- `.ai/specs/AGENTS.md` +- `packages/create-app/AGENTS.md` + +### Compliance Matrix + +| Rule Source | Rule | Status | Notes | +|-------------|------|--------|-------| +| root AGENTS.md | Ready apps live outside core repository | Compliant | Official apps live in separate `open-mercato/ready-app-*` repositories, not in core | +| root AGENTS.md | App-specific behavior lives in the app codebase | Compliant | Each ready app remains a complete runnable app with its own `src/modules` | +| packages/create-app/AGENTS.md | MUST NOT break the standalone app template | Compliant | Bare scaffold remains unchanged; new flags are additive | +| BACKWARD_COMPATIBILITY.md | CLI commands are STABLE contract surface | Compliant with explicit removal request | Supported ready-app source flags are `--app` and `--app-url`; this implementation intentionally limits the CLI surface to those two flags | +| .ai/specs/AGENTS.md | Non-trivial spec must include full structure | Compliant | All required sections included | + +### Internal Consistency Check + +| Check | Status | Notes | +|-------|--------|-------| +| CLI interface matches fetch mechanism | Pass | `--app` resolves to `open-mercato/ready-app-` and `--app-url` resolves to a direct GitHub repo URL | +| Repository structure matches fetch logic | Pass | Each official ready app repo root is a full runnable app | +| Error handling covers failure modes | Pass | Network, 404, invalid URL, rate limit, private repo, and duplicate flags covered | + +### Non-Compliant Items + +None. + +### Verdict + +**Fully compliant** - Approved as the ready apps framework spec. + +## Changelog + +### 2026-04-01 +- Reworked the distribution model from one centralized official repo to one official repo per ready app under the `open-mercato` organization. +- Introduced the official repository naming convention `ready-app-`. +- Replaced the earlier bootstrap contract with `--app` for official Open Mercato apps and `--app-url` for external GitHub-hosted apps. +- Added a raw-snapshot versioning contract: imported ready apps are copied as committed source, and dependency versions remain owned by the ready app repositories. +- Normalized the spec filename to `2026-03-02-ready-apps-framework.md` and removed the legacy numbered heading. +- Implemented Phase 1 in `packages/create-app`: ready app flags, GitHub snapshot import, snapshot validation, and unit/integration-style tests for the in-repo CLI surface. +- Removed the temporary preview-only source alias so the supported CLI surface remains `--app` and `--app-url` only. + +### 2026-03-20 +- Official app catalog repository changed from an earlier naming variant to `open-mercato/ready-apps` (superseded by the 2026-04-01 one-repo-per-app decision). +- Removed superseded SPEC-062 (Use-Case Starters Framework). +- Status changed from Draft to Active. + +### 2026-03-18 +- Renumbered from SPEC-062 to SPEC-068 to resolve numbering conflict with PR #1003 (Official Modules, SPEC-061-067). +- Renamed the concept from "starters" to a more standard bootstrap term at that stage of the design process. +- Added an earlier single-flag bootstrap mechanism, later superseded by the 2026-04-01 `--app` / `--app-url` decision. +- Added `open-mercato/ready-apps` as a centralized catalog repository, later superseded by the 2026-04-01 one-repo-per-app decision. +- Added GitHub API tarball fetch mechanism. +- Added error handling, versioning, and testing strategy. +- Added compliance report. + +### 2026-03-17 +- Renumbered from SPEC-061 to SPEC-062 to resolve numbering conflict with PR #1003. + +### 2026-03-02 +- Initial specification. + +## Implementation Status + +| Phase | Status | Date | Notes | +|-------|--------|------|-------| +| Phase 1 - CLI Flags | Done | 2026-04-01 | `packages/create-app` now supports `--app`, `--app-url`, GitHub tarball imports, raw snapshot validation, and imported-app wizard skip behavior with automated tests | +| Phase 2 - Official Ready App Repositories | Not Started | — | Requires work in external repositories such as `open-mercato/ready-app-prm`, which are outside this monorepo | + +### Phase 1 - Detailed Progress +- [x] Step 1: Add `--app` and `--app-url` to the `create-mercato-app` argument parser +- [x] Step 2: Enforce mutual exclusivity between `--app` and `--app-url` +- [x] Step 3: Implement official app name resolution from `` to `open-mercato/ready-app-` +- [x] Step 4: Resolve official app ref from the running CLI version to tag `v` +- [x] Step 5: Implement GitHub API tarball fetch for both official and external repositories +- [x] Step 6: Implement raw snapshot extraction for imported ready apps without template processing +- [x] Step 7: Skip the interactive agentic setup wizard for imported ready apps +- [x] Step 8: Add validation and error handling for invalid URLs, duplicate source flags, missing repos or refs, private repos, rate limits, and `.template` files in imported apps + +### Phase 2 - Detailed Progress +- [ ] Step 1: Create the first official repository, such as `open-mercato/ready-app-prm` +- [ ] Step 2: Add the first ready app implementation as a complete runnable application at repo root +- [ ] Step 3: Commit explicit first-party dependency versions in the repository for each tagged release line +- [ ] Step 4: Add CI to validate the app builds for the tagged release line +- [ ] Step 5: Document the organization naming convention and release tagging rules for future official ready apps diff --git a/packages/create-app/AGENTS.md b/packages/create-app/AGENTS.md index 19466dbabd2..a2cdce44998 100644 --- a/packages/create-app/AGENTS.md +++ b/packages/create-app/AGENTS.md @@ -11,6 +11,8 @@ Use `packages/create-app` to scaffold standalone Open Mercato applications via ` 5. **MUST NOT break the standalone app template** — it's the user's first experience with Open Mercato 6. **MUST sync template equivalents when app shell/layout files change** — when touching `apps/mercato/src/app/**` bootstrap/layout/provider wiring, update matching files in `packages/create-app/template/src/app/**` (and required template components) in the same task 7. **MUST keep template module registrations and package dependencies aligned** — if `packages/create-app/template/src/modules.ts` enables a package-backed module (for example `@open-mercato/webhooks`), `packages/create-app/template/package.json.template` must install that package in the same change, and the template lockfile must be reviewed when dependency shape changes +8. **MUST preserve imported ready apps as raw source snapshots** — `--app` / `--app-url` imports may add only bootstrap-safe generated artifacts (for example `.mercato/generated/module-package-sources.css`) and MUST NOT rewrite package versions, source files, or inject agentic setup files +9. **MUST skip the interactive agentic wizard for imported ready apps** — imported snapshots stay repo-owned; any agentic tooling must be added later via a deliberate manual command inside the generated app ## Standalone App vs Monorepo @@ -42,6 +44,22 @@ my-app/ └── package.json ``` +## Ready App Import Modes + +`create-mercato-app` supports three scaffold modes: + +1. Bare scaffold: `npx create-mercato-app my-app` +2. Official ready app: `npx create-mercato-app my-prm --app prm` +3. External GitHub ready app: `npx create-mercato-app my-app --app-url https://github.com/some-agency/ready-app-marketplace` + +Rules: + +- `--app` resolves to `open-mercato/ready-app-` and MUST use the exact tag `v` +- `--app-url` only supports GitHub repository URLs in v1, optionally with `/tree/` +- `--app` and `--app-url` are mutually exclusive +- Imported ready apps skip template processing and the interactive agentic wizard +- Imported ready apps must be committed source snapshots; fail closed if `.template` files are present + ## Testing with Verdaccio ### Initial Setup diff --git a/packages/create-app/README.md b/packages/create-app/README.md index 4bbd79d1fd8..6966df0f51a 100644 --- a/packages/create-app/README.md +++ b/packages/create-app/README.md @@ -10,6 +10,13 @@ cd my-app yarn setup ``` +Official and external ready apps can also be bootstrapped directly: + +```bash +npx create-mercato-app my-prm --app prm +npx create-mercato-app my-marketplace --app-url https://github.com/some-agency/ready-app-marketplace +``` + ## Usage ```bash @@ -26,6 +33,8 @@ npx create-mercato-app [options] | Option | Description | |--------|-------------| +| `--app ` | Bootstrap an official Open Mercato ready app from `open-mercato/ready-app-` | +| `--app-url ` | Bootstrap a ready app from a GitHub repository URL | | `--registry ` | Custom npm registry URL | | `--verdaccio` | Use local Verdaccio registry (http://localhost:4873) | | `--help`, `-h` | Show help | @@ -37,6 +46,12 @@ npx create-mercato-app [options] # Create a new app using the public npm registry npx create-mercato-app my-store +# Create an official Open Mercato ready app +npx create-mercato-app my-prm --app prm + +# Create an app from an external GitHub-hosted ready app +npx create-mercato-app my-marketplace --app-url https://github.com/some-agency/ready-app-marketplace + # Create a new app using a local Verdaccio registry npx create-mercato-app my-store --verdaccio @@ -44,7 +59,16 @@ npx create-mercato-app my-store --verdaccio npx create-mercato-app my-store --registry http://localhost:4873 ``` -## After Creating Your App +## Ready App Behavior + +- `--app ` resolves to `open-mercato/ready-app-` and fetches the exact tag `v` +- `--app-url ` only supports GitHub repository URLs in v1 and honors `/tree/` when present +- `--app` and `--app-url` are mutually exclusive +- Imported ready apps are copied as raw source snapshots: the CLI does not rewrite dependency versions, package names, or application source files +- Imported ready apps skip the interactive agentic setup wizard; if you want agentic tooling later, run `yarn mercato agentic:init` inside the generated app +- Imported ready apps must not contain `.template` files; the scaffold fails closed if template files are found + +## After Creating A Bare Scaffold 1. Navigate to your app directory: ```bash @@ -109,6 +133,33 @@ npx create-mercato-app my-store --registry http://localhost:4873 ``` Run `cp .env.example .env` and `yarn install` before either Docker command. Skipping those preparation steps can cause the stack to fail during startup. +## After Importing A Ready App + +1. Navigate to your app directory: + ```bash + cd my-prm + ``` + +2. Install dependencies: + ```bash + yarn install + ``` + +3. Initialize the application: + ```bash + yarn initialize + ``` + +4. Start the development server: + ```bash + yarn dev + ``` + +5. If you want standalone agentic tooling later: + ```bash + yarn mercato agentic:init + ``` + ## Requirements - Node.js 24 or later diff --git a/packages/create-app/package.json b/packages/create-app/package.json index b430f2f4ffb..89fbea407f8 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -13,14 +13,17 @@ ], "scripts": { "build": "node build.mjs", + "test": "node --import tsx --test src/**/*.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { - "picocolors": "^1.1.0" + "picocolors": "^1.1.0", + "tar": "^7.5.1" }, "devDependencies": { "@types/node": "^24.10.1", "esbuild": "^0.25.0", + "tsx": "^4.21.0", "typescript": "^5.9.3" }, "publishConfig": { diff --git a/packages/create-app/src/index.ts b/packages/create-app/src/index.ts index 24f8f10c899..9687ac56c8a 100644 --- a/packages/create-app/src/index.ts +++ b/packages/create-app/src/index.ts @@ -1,8 +1,16 @@ -import { existsSync, mkdirSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs' -import { join, dirname, basename, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' import { createInterface } from 'node:readline' +import { basename, dirname, join, resolve } from 'node:path' +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, copyFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import pc from 'picocolors' +import { + downloadReadyAppSnapshot, + extractTarballSnapshot, + resolveReadyAppSource, + type ReadyAppSource, + validateImportedReadyAppSnapshot, + validateSlug, +} from './lib/ready-apps.js' import { runAgenticSetup } from './setup/wizard.js' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -11,6 +19,8 @@ const PACKAGE_VERSION: string = packageJson.version const TEMPLATE_DIR = join(__dirname, '..', 'template') interface Options { + app?: string + appUrl?: string registry?: string verdaccio: boolean help: boolean @@ -28,6 +38,8 @@ ${pc.bold('Arguments:')} app-name Name of the application (will create folder with this name) ${pc.bold('Options:')} + --app Bootstrap an official ready app from open-mercato/ready-app- + --app-url Bootstrap a ready app from a GitHub repository URL --registry Custom npm registry URL --verdaccio Use local Verdaccio registry (http://localhost:4873) --help, -h Show help @@ -35,6 +47,8 @@ ${pc.bold('Options:')} ${pc.bold('Examples:')} npx create-mercato-app my-store + npx create-mercato-app my-prm --app prm + npx create-mercato-app my-marketplace --app-url https://github.com/some-agency/ready-app-marketplace npx create-mercato-app my-store --verdaccio npx create-mercato-app my-store --registry http://localhost:4873 `) @@ -44,8 +58,19 @@ function showVersion(): void { console.log(`create-mercato-app v${PACKAGE_VERSION}`) } +function requireOptionValue(args: string[], index: number, flag: string): string { + const value = args[index + 1] + if (!value || value.startsWith('-')) { + throw new Error(`Option ${flag} requires a value`) + } + + return value +} + function parseArgs(args: string[]): { appName: string | null; options: Options } { const options: Options = { + app: undefined, + appUrl: undefined, registry: undefined, verdaccio: false, help: false, @@ -53,8 +78,8 @@ function parseArgs(args: string[]): { appName: string | null; options: Options } } let appName: string | null = null - for (let i = 0; i < args.length; i++) { - const arg = args[i] + for (let index = 0; index < args.length; index++) { + const arg = args[index] if (arg === '--help' || arg === '-h') { options.help = true @@ -63,7 +88,14 @@ function parseArgs(args: string[]): { appName: string | null; options: Options } } else if (arg === '--verdaccio') { options.verdaccio = true } else if (arg === '--registry') { - options.registry = args[++i] + options.registry = requireOptionValue(args, index, arg) + index += 1 + } else if (arg === '--app') { + options.app = requireOptionValue(args, index, arg) + index += 1 + } else if (arg === '--app-url') { + options.appUrl = requireOptionValue(args, index, arg) + index += 1 } else if (!arg.startsWith('-')) { appName = arg } @@ -72,33 +104,13 @@ function parseArgs(args: string[]): { appName: string | null; options: Options } return { appName, options } } -function validateAppName(name: string): { valid: boolean; error?: string } { - if (!name) { - return { valid: false, error: 'App name is required' } - } - - if (!/^[a-z0-9-]+$/.test(name)) { - return { - valid: false, - error: 'App name must be lowercase alphanumeric with hyphens only (e.g., my-app)', - } - } - - if (name.startsWith('-') || name.endsWith('-')) { - return { valid: false, error: 'App name cannot start or end with a hyphen' } - } - - return { valid: true } -} - function buildRegistryConfig(registryUrl: string): string { let parsedRegistryUrl: URL try { parsedRegistryUrl = new URL(registryUrl) } catch { - console.error(pc.red(`Error: Invalid registry URL "${registryUrl}"`)) - process.exit(1) + throw new Error(`Invalid registry URL "${registryUrl}"`) } const configLines: string[] = [] @@ -124,7 +136,6 @@ function buildRegistryConfig(registryUrl: string): string { return configLines.join('\n') } -// Files that need to be renamed (npm ignores .gitignore during pack) const FILE_RENAMES: Record = { gitignore: '.gitignore', } @@ -148,7 +159,6 @@ function copyDirRecursive(src: string, dest: string, placeholders: Record { - const args = process.argv.slice(2) - const { appName: appNameArg, options } = parseArgs(args) +function ensureGeneratedCssPlaceholder(targetDir: string): void { + const generatedDir = join(targetDir, '.mercato', 'generated') + const placeholderPath = join(generatedDir, 'module-package-sources.css') + + mkdirSync(generatedDir, { recursive: true }) + if (!existsSync(placeholderPath)) { + writeFileSync(placeholderPath, '') + } +} + +async function scaffoldTemplateApp( + targetDir: string, + placeholders: Record, +): Promise { + if (!existsSync(TEMPLATE_DIR)) { + throw new Error(`Template directory not found at ${TEMPLATE_DIR}`) + } + + copyDirRecursive(TEMPLATE_DIR, targetDir, placeholders) + ensureGeneratedCssPlaceholder(targetDir) +} + +function describeReadyAppSource(source: ReadyAppSource): string { + if (source.kind === 'official') { + return `${source.owner}/${source.repo}@${source.ref}` + } + + return `${source.owner}/${source.repo}${source.ref ? `@${source.ref}` : ''}` +} + +async function scaffoldImportedReadyApp(targetDir: string, source: ReadyAppSource): Promise { + const download = await downloadReadyAppSnapshot(source, PACKAGE_VERSION) + console.log(pc.dim(`Fetching ready app snapshot from ${download.owner}/${download.repo}@${download.ref}`)) + + await extractTarballSnapshot(download.archive, targetDir) + validateImportedReadyAppSnapshot(targetDir) + ensureGeneratedCssPlaceholder(targetDir) +} + +async function maybeRunAgenticSetup(targetDir: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + const ask = (question: string) => + new Promise((resolveAnswer) => rl.question(question, (answer) => resolveAnswer(answer.trim()))) + + try { + await runAgenticSetup(targetDir, ask) + } finally { + rl.close() + } +} + +function printTemplateNextSteps(appName: string): void { + console.log('Next steps:') + console.log('') + console.log(pc.cyan(` cd ${appName}`)) + console.log('') + console.log(pc.green('Suggested quick start:')) + console.log(pc.cyan(' yarn setup')) + console.log(pc.dim(' # Copies .env.example to .env if needed, installs deps, migrates, initializes, and starts dev')) + console.log(pc.dim(' # If you need a clean reset first: yarn setup --reinstall')) + console.log(pc.dim(' # Alias: yarn setup:reinstall')) + console.log('') + console.log('Manual alternative:') + console.log(pc.cyan(' cp .env.example .env')) + console.log(pc.dim(' # Edit .env with your database credentials')) + console.log(pc.cyan(' yarn install')) + console.log(pc.cyan(' yarn generate')) + console.log(pc.cyan(' yarn db:migrate')) + console.log(pc.cyan(' yarn initialize')) + console.log(pc.cyan(' yarn dev')) + console.log('') + console.log('Docker alternatives:') + console.log(pc.dim(' # Before Docker: cp .env.example .env && yarn install')) + console.log(pc.dim(' # Docker expects your local app env and dependencies to be prepared first')) + console.log(pc.cyan(' # Dev (recommended on Windows): docker compose -f docker-compose.fullapp.dev.yml up --build')) + console.log(pc.cyan(' # Production-style: docker compose -f docker-compose.fullapp.yml up --build')) + console.log('') +} + +function printImportedReadyAppNextSteps(appName: string): void { + console.log('Next steps:') + console.log('') + console.log(pc.cyan(` cd ${appName}`)) + console.log(pc.cyan(' yarn install')) + console.log(pc.cyan(' yarn initialize')) + console.log(pc.cyan(' yarn dev')) + console.log('') + console.log(pc.dim('Imported ready apps are copied as raw source snapshots.')) + console.log(pc.dim('If you want agentic tooling in the imported app later, run `yarn mercato agentic:init` inside it.')) + console.log('') +} + +export async function main(argv = process.argv.slice(2)): Promise { + const { appName: appNameArg, options } = parseArgs(argv) if (options.help) { showHelp() - process.exit(0) + return } if (options.version) { showVersion() - process.exit(0) + return } if (!appNameArg) { - console.error(pc.red('Error: App name is required')) - console.error('') - showHelp() - process.exit(1) + throw new Error('App name is required') } - // Support both relative names (my-app) and full paths (/tmp/my-app) const targetDir = resolve(process.cwd(), appNameArg) const appName = basename(targetDir) - const validation = validateAppName(appName) + const validation = validateSlug(appName, 'App name') if (!validation.valid) { - console.error(pc.red(`Error: ${validation.error}`)) - process.exit(1) + throw new Error(validation.error) } if (existsSync(targetDir)) { - console.error(pc.red(`Error: Directory "${appName}" already exists`)) - process.exit(1) + throw new Error(`Directory "${appName}" already exists`) } - if (!existsSync(TEMPLATE_DIR)) { - console.error(pc.red('Error: Template directory not found')) - console.error(`Expected: ${TEMPLATE_DIR}`) - process.exit(1) - } - - // Determine registry config - let registryConfig = '' - if (options.verdaccio) { - registryConfig = buildRegistryConfig('http://localhost:4873') - } else if (options.registry) { - registryConfig = buildRegistryConfig(options.registry) - } + const readyAppSource = resolveReadyAppSource(options, PACKAGE_VERSION) + const registryConfig = options.verdaccio + ? buildRegistryConfig('http://localhost:4873') + : options.registry + ? buildRegistryConfig(options.registry) + : '' console.log('') console.log(pc.bold(`Creating a new Open Mercato app in ${pc.cyan(targetDir)}`)) console.log('') - // Define placeholders const placeholders: Record = { APP_NAME: appName, - PACKAGE_VERSION: PACKAGE_VERSION, + PACKAGE_VERSION, REGISTRY_CONFIG: registryConfig, } - try { - copyDirRecursive(TEMPLATE_DIR, targetDir, placeholders) - - // Create an empty placeholder so globals.css @import resolves before generators run - const generatedDir = join(targetDir, '.mercato', 'generated') - mkdirSync(generatedDir, { recursive: true }) - writeFileSync(join(generatedDir, 'module-package-sources.css'), '') - - console.log(pc.green('Success!') + ` Created ${pc.bold(appName)}`) + if (readyAppSource) { + console.log(pc.dim(`Ready app source: ${describeReadyAppSource(readyAppSource)}`)) console.log('') + await scaffoldImportedReadyApp(targetDir, readyAppSource) + } else { + await scaffoldTemplateApp(targetDir, placeholders) + } - // Agentic tool setup wizard - const rl = createInterface({ input: process.stdin, output: process.stdout }) - const ask = (q: string) => new Promise((res) => rl.question(q, (a) => res(a.trim()))) - await runAgenticSetup(targetDir, ask) - rl.close() + console.log(pc.green('Success!') + ` Created ${pc.bold(appName)}`) + console.log('') - console.log('Next steps:') - console.log('') - console.log(pc.cyan(` cd ${appName}`)) - console.log('') - console.log(pc.green('Suggested quick start:')) - console.log(pc.cyan(' yarn setup')) - console.log(pc.dim(' # Copies .env.example to .env if needed, installs deps, migrates, initializes, and starts dev')) - console.log(pc.dim(' # If you need a clean reset first: yarn setup --reinstall')) - console.log(pc.dim(' # Alias: yarn setup:reinstall')) - console.log('') - console.log('Manual alternative:') - console.log(pc.cyan(' cp .env.example .env')) - console.log(pc.dim(' # Edit .env with your database credentials')) - console.log(pc.cyan(' yarn install')) - console.log(pc.cyan(' yarn generate')) - console.log(pc.cyan(' yarn db:migrate')) - console.log(pc.cyan(' yarn initialize')) - console.log(pc.cyan(' yarn dev')) - console.log('') - console.log('Docker alternatives:') - console.log(pc.dim(' # Before Docker: cp .env.example .env && yarn install')) - console.log(pc.dim(' # Docker expects your local app env and dependencies to be prepared first')) - console.log(pc.cyan(' # Dev (recommended on Windows): docker compose -f docker-compose.fullapp.dev.yml up --build')) - console.log(pc.cyan(' # Production-style: docker compose -f docker-compose.fullapp.yml up --build')) - console.log('') - console.log(pc.dim('For more information, visit https://github.com/open-mercato/open-mercato')) - console.log('') - } catch (error) { - console.error(pc.red('Error creating app:'), error) - process.exit(1) + if (readyAppSource) { + printImportedReadyAppNextSteps(appName) + } else { + await maybeRunAgenticSetup(targetDir) + printTemplateNextSteps(appName) } + + console.log(pc.dim('For more information, visit https://github.com/open-mercato/open-mercato')) + console.log('') } -main() +const isEntrypoint = + process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url) + +if (isEntrypoint) { + main().catch((error) => { + const message = error instanceof Error ? error.message : String(error) + console.error(pc.red('Error creating app:'), message) + process.exit(1) + }) +} diff --git a/packages/create-app/src/lib/ready-apps.test.ts b/packages/create-app/src/lib/ready-apps.test.ts new file mode 100644 index 00000000000..ab6f7918875 --- /dev/null +++ b/packages/create-app/src/lib/ready-apps.test.ts @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import * as tar from 'tar' +import { + downloadReadyAppSnapshot, + extractTarballSnapshot, + parseGitHubRepositoryUrl, + resolveOfficialReadyAppSource, + resolveReadyAppSource, + validateImportedReadyAppSnapshot, +} from './ready-apps.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const PACKAGE_ROOT = resolve(__dirname, '..', '..') +const CLI_BIN = join(PACKAGE_ROOT, 'bin', 'create-mercato-app') +const CLI_ENTRY = join(PACKAGE_ROOT, 'src', 'index.ts') + +function makeTempDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)) +} + +async function createGitHubStyleTarball(files: Record): Promise { + const tempDir = makeTempDir('create-mercato-app-fixture-') + const rootDir = join(tempDir, 'fixture-root') + const archivePath = join(tempDir, 'fixture.tar.gz') + + mkdirSync(rootDir, { recursive: true }) + + for (const [relativePath, content] of Object.entries(files)) { + const absolutePath = join(rootDir, relativePath) + mkdirSync(dirname(absolutePath), { recursive: true }) + writeFileSync(absolutePath, content) + } + + await tar.c( + { + cwd: tempDir, + gzip: true, + file: archivePath, + }, + ['fixture-root'], + ) + + const archive = new Uint8Array(readFileSync(archivePath)) + rmSync(tempDir, { recursive: true, force: true }) + return archive +} + +test('resolveOfficialReadyAppSource maps app slug to repo and exact tag', () => { + const source = resolveOfficialReadyAppSource('prm', '0.4.9') + + assert.deepEqual(source, { + kind: 'official', + appSlug: 'prm', + owner: 'open-mercato', + repo: 'ready-app-prm', + ref: 'v0.4.9', + }) +}) + +test('resolveReadyAppSource rejects multiple source flags', () => { + assert.throws( + () => + resolveReadyAppSource( + { + app: 'prm', + appUrl: 'https://github.com/some-agency/ready-app-marketplace', + }, + '0.4.9', + ), + /mutually exclusive/i, + ) +}) + +test('parseGitHubRepositoryUrl extracts owner, repo, and refs with slashes', () => { + const parsed = parseGitHubRepositoryUrl( + 'https://github.com/some-agency/ready-app-marketplace/tree/releases/2026-04', + ) + + assert.deepEqual(parsed, { + owner: 'some-agency', + repo: 'ready-app-marketplace', + ref: 'releases/2026-04', + normalizedUrl: 'https://github.com/some-agency/ready-app-marketplace/tree/releases/2026-04', + }) +}) + +test('extractTarballSnapshot strips the GitHub root folder', async () => { + const archive = await createGitHubStyleTarball({ + 'package.json': '{"name":"ready-app-prm"}\n', + 'src/modules.ts': 'export default []\n', + }) + const targetDir = makeTempDir('create-mercato-app-extract-') + + try { + await extractTarballSnapshot(archive, targetDir) + + assert.equal(existsSync(join(targetDir, 'package.json')), true) + assert.equal(existsSync(join(targetDir, 'src', 'modules.ts')), true) + } finally { + rmSync(targetDir, { recursive: true, force: true }) + } +}) + +test('validateImportedReadyAppSnapshot fails closed on template files', () => { + const targetDir = makeTempDir('create-mercato-app-template-check-') + + try { + mkdirSync(join(targetDir, 'src'), { recursive: true }) + writeFileSync(join(targetDir, 'src', 'package.json.template'), '{}\n') + + assert.throws( + () => validateImportedReadyAppSnapshot(targetDir), + /must be committed source snapshots/i, + ) + } finally { + rmSync(targetDir, { recursive: true, force: true }) + } +}) + +test('downloadReadyAppSnapshot resolves external default branches through the GitHub API', async () => { + const calls: string[] = [] + const fetchMock = (async (input: RequestInfo | URL) => { + const url = String(input) + calls.push(url) + + if (url.endsWith('/repos/some-agency/ready-app-marketplace')) { + return new Response(JSON.stringify({ default_branch: 'main' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + + if (url.endsWith('/repos/some-agency/ready-app-marketplace/tarball/main')) { + return new Response(Uint8Array.from([1, 2, 3]), { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + }) + } + + return new Response(JSON.stringify({ message: 'Not Found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }) + }) as typeof fetch + + const source = resolveReadyAppSource( + { appUrl: 'https://github.com/some-agency/ready-app-marketplace' }, + '0.4.9', + ) + + assert.ok(source) + const download = await downloadReadyAppSnapshot(source, '0.4.9', fetchMock) + + assert.equal(download.ref, 'main') + assert.deepEqual(Array.from(download.archive), [1, 2, 3]) + assert.deepEqual(calls, [ + 'https://api.github.com/repos/some-agency/ready-app-marketplace', + 'https://api.github.com/repos/some-agency/ready-app-marketplace/tarball/main', + ]) +}) + +test('published CLI bin executes the dist entrypoint', () => { + const buildResult = spawnSync(process.execPath, ['build.mjs'], { + cwd: PACKAGE_ROOT, + encoding: 'utf8', + env: process.env, + }) + + assert.equal( + buildResult.status, + 0, + `expected package build to succeed\nstdout:\n${buildResult.stdout}\nstderr:\n${buildResult.stderr}`, + ) + + const result = spawnSync(process.execPath, [CLI_BIN, '--help'], { + cwd: PACKAGE_ROOT, + encoding: 'utf8', + env: process.env, + }) + + assert.equal( + result.status, + 0, + `expected bin wrapper to succeed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ) + assert.match(result.stdout, /--app-url/) +}) + +test('CLI imported ready apps skip wizard-generated files', async () => { + const archive = await createGitHubStyleTarball({ + 'package.json': '{"name":"ready-app-prm","version":"0.0.1"}\n', + 'README.md': '# Ready App PRM\n', + 'src/modules.ts': 'export default []\n', + }) + + const targetRoot = makeTempDir('create-mercato-app-cli-import-') + const targetDir = join(targetRoot, 'ready-prm') + const mockFetchModulePath = join(targetRoot, 'mock-fetch.mjs') + const archiveBase64 = Buffer.from(archive).toString('base64') + + writeFileSync( + mockFetchModulePath, + `const archive = Uint8Array.from(Buffer.from('${archiveBase64}', 'base64')) +globalThis.fetch = async (input) => { + const url = String(input) + if (url === 'https://api.github.com/repos/open-mercato/ready-app-prm/tarball/v0.4.9') { + return new Response(archive, { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + }) + } + return new Response(JSON.stringify({ message: 'Not Found' }), { + status: 404, + headers: { 'content-type': 'application/json' }, + }) +} +`, + ) + + try { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', '--import', mockFetchModulePath, CLI_ENTRY, targetDir, '--app', 'prm'], + { + cwd: PACKAGE_ROOT, + encoding: 'utf8', + env: process.env, + }, + ) + + assert.equal( + result.status, + 0, + `expected CLI import to succeed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ) + assert.equal(existsSync(join(targetDir, 'package.json')), true) + assert.equal(existsSync(join(targetDir, 'AGENTS.md')), false) + assert.equal(existsSync(join(targetDir, '.ai')), false) + assert.equal(existsSync(join(targetDir, '.claude')), false) + assert.equal(existsSync(join(targetDir, '.cursor')), false) + assert.equal(existsSync(join(targetDir, '.mercato', 'generated', 'module-package-sources.css')), true) + } finally { + rmSync(targetRoot, { recursive: true, force: true }) + } +}) diff --git a/packages/create-app/src/lib/ready-apps.ts b/packages/create-app/src/lib/ready-apps.ts new file mode 100644 index 00000000000..724d6b2a1a6 --- /dev/null +++ b/packages/create-app/src/lib/ready-apps.ts @@ -0,0 +1,448 @@ +import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import * as tar from 'tar' + +const DEFAULT_GITHUB_API_BASE_URL = 'https://api.github.com' +const GITHUB_HOSTNAME = 'github.com' + +export interface ReadyAppSelectionOptions { + app?: string + appUrl?: string +} + +export interface GitHubRepositoryLocation { + owner: string + repo: string + ref?: string + normalizedUrl: string +} + +interface BaseReadyAppSource { + owner: string + repo: string + ref?: string +} + +export interface OfficialReadyAppSource extends BaseReadyAppSource { + kind: 'official' + appSlug: string + ref: string +} + +export interface ExternalReadyAppSource extends BaseReadyAppSource { + kind: 'external' + sourceUrl: string +} + +export type ReadyAppSource = OfficialReadyAppSource | ExternalReadyAppSource + +export interface ReadyAppDownload { + archive: Uint8Array + owner: string + repo: string + ref: string +} + +interface GitHubRepositoryInfo { + defaultBranch: string +} + +type FetchLike = typeof fetch + +export function validateSlug(name: string, label: string): { valid: boolean; error?: string } { + if (!name) { + return { valid: false, error: `${label} is required` } + } + + if (!/^[a-z0-9-]+$/.test(name)) { + return { + valid: false, + error: `${label} must be lowercase alphanumeric with hyphens only (e.g., my-app)`, + } + } + + if (name.startsWith('-') || name.endsWith('-')) { + return { valid: false, error: `${label} cannot start or end with a hyphen` } + } + + return { valid: true } +} + +export function parseGitHubRepositoryUrl(inputUrl: string): GitHubRepositoryLocation { + let parsedUrl: URL + + try { + parsedUrl = new URL(inputUrl) + } catch { + throw new Error(`Invalid GitHub repository URL "${inputUrl}"`) + } + + if (parsedUrl.protocol !== 'https:' || parsedUrl.hostname !== GITHUB_HOSTNAME) { + throw new Error('Only GitHub repository URLs are supported for --app-url in v1.') + } + + const segments = parsedUrl.pathname.replace(/\/+$/, '').split('/').filter(Boolean) + if (segments.length < 2) { + throw new Error('GitHub repository URL must include both the owner and repository name.') + } + + const owner = segments[0] + const repo = segments[1].replace(/\.git$/u, '') + + if (!owner || !repo) { + throw new Error('GitHub repository URL must include both the owner and repository name.') + } + + let ref: string | undefined + if (segments.length > 2) { + if (segments[2] !== 'tree' || segments.length < 4) { + throw new Error('GitHub repository URL must point to the repository root or /tree/.') + } + + ref = decodeURIComponent(segments.slice(3).join('/')) + if (!ref) { + throw new Error('GitHub repository URL is missing the ref after /tree/.') + } + } + + return { + owner, + repo, + ref, + normalizedUrl: `https://${GITHUB_HOSTNAME}/${owner}/${repo}${ref ? `/tree/${ref}` : ''}`, + } +} + +export function resolveOfficialReadyAppSource( + appName: string, + packageVersion: string, +): OfficialReadyAppSource { + const validation = validateSlug(appName, 'Ready app name') + if (!validation.valid) { + throw new Error(validation.error) + } + + return { + kind: 'official', + appSlug: appName, + owner: 'open-mercato', + repo: `ready-app-${appName}`, + ref: `v${packageVersion}`, + } +} + +export function resolveReadyAppSource( + options: ReadyAppSelectionOptions, + packageVersion: string, +): ReadyAppSource | null { + const selectedFlags = [options.app, options.appUrl].filter( + (value) => typeof value === 'string' && value.trim().length > 0, + ) + + if (selectedFlags.length > 1) { + throw new Error('Options --app and --app-url are mutually exclusive. Use only one source flag.') + } + + if (options.app) { + return resolveOfficialReadyAppSource(options.app.trim(), packageVersion) + } + + if (options.appUrl) { + const parsed = parseGitHubRepositoryUrl(options.appUrl.trim()) + return { + kind: 'external', + owner: parsed.owner, + repo: parsed.repo, + ref: parsed.ref, + sourceUrl: parsed.normalizedUrl, + } + } + + return null +} + +export function getGitHubApiBaseUrl(): string { + const overridden = process.env.OM_CREATE_APP_GITHUB_API_BASE_URL?.trim() + if (!overridden) { + return DEFAULT_GITHUB_API_BASE_URL + } + + return overridden.replace(/\/+$/u, '') +} + +function buildGitHubRepoApiUrl(owner: string, repo: string): string { + return `${getGitHubApiBaseUrl()}/repos/${owner}/${repo}` +} + +function buildGitHubTarballApiUrl(owner: string, repo: string, ref: string): string { + return `${getGitHubApiBaseUrl()}/repos/${owner}/${repo}/tarball/${encodeURIComponent(ref)}` +} + +function buildGitHubHeaders(packageVersion: string): Headers { + const headers = new Headers({ + Accept: 'application/vnd.github+json', + 'User-Agent': `create-mercato-app/${packageVersion}`, + }) + + const token = process.env.GITHUB_TOKEN?.trim() + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + + return headers +} + +function buildRepoAccessError(owner: string, repo: string): Error { + return new Error( + `GitHub repository not found or inaccessible: ${owner}/${repo}. Check the repository URL. If it is private, set GITHUB_TOKEN and retry.`, + ) +} + +function buildNetworkError(owner: string, repo: string, cause: unknown): Error { + const detail = cause instanceof Error ? cause.message : String(cause) + return new Error( + `Unable to reach GitHub while fetching ${owner}/${repo}. Check network access or the repository URL. (${detail})`, + ) +} + +async function readGitHubErrorMessage(response: Response): Promise { + try { + const payload = await response.json() + if ( + payload && + typeof payload === 'object' && + 'message' in payload && + typeof payload.message === 'string' + ) { + return payload.message + } + } catch { + return null + } + + return null +} + +function isGitHubRateLimitResponse(response: Response, message: string | null): boolean { + const remaining = response.headers.get('x-ratelimit-remaining') + if (remaining === '0') { + return true + } + + const normalizedMessage = message?.toLowerCase() ?? '' + return normalizedMessage.includes('rate limit') +} + +async function buildGitHubRequestError( + response: Response, + owner: string, + repo: string, + action: string, +): Promise { + const message = await readGitHubErrorMessage(response) + + if (isGitHubRateLimitResponse(response, message)) { + return new Error( + `GitHub API rate limit reached while ${action} ${owner}/${repo}. Set GITHUB_TOKEN and retry.`, + ) + } + + if (response.status === 401) { + return new Error( + `GitHub authentication failed while ${action} ${owner}/${repo}. Check GITHUB_TOKEN and retry.`, + ) + } + + if (response.status === 403 && message?.toLowerCase().includes('resource not accessible')) { + return buildRepoAccessError(owner, repo) + } + + return new Error( + `GitHub API request failed while ${action} ${owner}/${repo} (${response.status}${message ? `: ${message}` : ''}).`, + ) +} + +async function performGitHubRequest( + url: string, + owner: string, + repo: string, + packageVersion: string, + fetchImpl: FetchLike, +): Promise { + try { + return await fetchImpl(url, { + headers: buildGitHubHeaders(packageVersion), + redirect: 'follow', + }) + } catch (error) { + throw buildNetworkError(owner, repo, error) + } +} + +async function fetchGitHubRepositoryInfo( + owner: string, + repo: string, + packageVersion: string, + fetchImpl: FetchLike, +): Promise { + const response = await performGitHubRequest( + buildGitHubRepoApiUrl(owner, repo), + owner, + repo, + packageVersion, + fetchImpl, + ) + + if (response.status === 404) { + return null + } + + if (!response.ok) { + throw await buildGitHubRequestError(response, owner, repo, 'inspecting') + } + + const payload = (await response.json()) as { default_branch?: unknown } + const defaultBranch = + typeof payload.default_branch === 'string' && payload.default_branch.trim().length > 0 + ? payload.default_branch + : null + + if (!defaultBranch) { + throw new Error(`GitHub repository metadata for ${owner}/${repo} is missing default_branch.`) + } + + return { defaultBranch } +} + +export async function downloadReadyAppSnapshot( + source: ReadyAppSource, + packageVersion: string, + fetchImpl: FetchLike = fetch, +): Promise { + let resolvedRef = source.ref + + if (!resolvedRef) { + const repoInfo = await fetchGitHubRepositoryInfo( + source.owner, + source.repo, + packageVersion, + fetchImpl, + ) + + if (!repoInfo) { + throw buildRepoAccessError(source.owner, source.repo) + } + + resolvedRef = repoInfo.defaultBranch + } + + const tarballResponse = await performGitHubRequest( + buildGitHubTarballApiUrl(source.owner, source.repo, resolvedRef), + source.owner, + source.repo, + packageVersion, + fetchImpl, + ) + + if (tarballResponse.ok) { + return { + archive: new Uint8Array(await tarballResponse.arrayBuffer()), + owner: source.owner, + repo: source.repo, + ref: resolvedRef, + } + } + + if (tarballResponse.status === 404) { + const repoInfo = await fetchGitHubRepositoryInfo( + source.owner, + source.repo, + packageVersion, + fetchImpl, + ) + + if (!repoInfo) { + if (source.kind === 'official') { + throw new Error(`Official ready app repository not found: ${source.owner}/${source.repo}.`) + } + + throw buildRepoAccessError(source.owner, source.repo) + } + + if (source.kind === 'official') { + throw new Error( + `Official ready app compatibility tag not found: ${source.owner}/${source.repo}@${resolvedRef}. Expected tag ${source.ref} for this create-mercato-app release.`, + ) + } + + throw new Error( + `Ready app ref not found: ${source.owner}/${source.repo}@${resolvedRef}. Check the GitHub URL and ensure the branch or tag exists.`, + ) + } + + throw await buildGitHubRequestError( + tarballResponse, + source.owner, + source.repo, + 'downloading tarball for', + ) +} + +export function findTemplateFiles(dir: string, rootDir = dir): string[] { + if (!existsSync(dir)) { + return [] + } + + const findings: string[] = [] + const entries = readdirSync(dir).sort() + + for (const entry of entries) { + const entryPath = join(dir, entry) + const entryStat = statSync(entryPath) + + if (entryStat.isDirectory()) { + findings.push(...findTemplateFiles(entryPath, rootDir)) + continue + } + + if (entry.endsWith('.template')) { + findings.push(relative(rootDir, entryPath)) + } + } + + return findings +} + +export function validateImportedReadyAppSnapshot(dir: string): void { + const templateFiles = findTemplateFiles(dir) + if (templateFiles.length === 0) { + return + } + + const preview = templateFiles.slice(0, 5).join(', ') + throw new Error( + `Imported ready apps must be committed source snapshots. Found .template files: ${preview}${templateFiles.length > 5 ? ', ...' : ''}`, + ) +} + +export async function extractTarballSnapshot( + archive: Uint8Array, + targetDir: string, +): Promise { + const tempDir = mkdtempSync(join(tmpdir(), 'create-mercato-app-tarball-')) + const archivePath = join(tempDir, 'ready-app.tar.gz') + + writeFileSync(archivePath, archive) + mkdirSync(targetDir, { recursive: true }) + + try { + await tar.x({ + cwd: targetDir, + file: archivePath, + strip: 1, + }) + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } +} diff --git a/yarn.lock b/yarn.lock index 94af5e4ef10..111a9bec21e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11948,6 +11948,8 @@ __metadata: "@types/node": "npm:^24.10.1" esbuild: "npm:^0.25.0" picocolors: "npm:^1.1.0" + tar: "npm:^7.5.1" + tsx: "npm:^4.21.0" typescript: "npm:^5.9.3" bin: create-mercato-app: ./bin/create-mercato-app From bc4312df789558c0764315178966244dbe461f4d Mon Sep 17 00:00:00 2001 From: dominikpalatynski Date: Wed, 1 Apr 2026 16:19:17 +0200 Subject: [PATCH 002/215] fix(cli): improve error handling in create-mercato-app script --- packages/create-app/bin/create-mercato-app | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/create-app/bin/create-mercato-app b/packages/create-app/bin/create-mercato-app index 789de200f36..3d8065611f1 100755 --- a/packages/create-app/bin/create-mercato-app +++ b/packages/create-app/bin/create-mercato-app @@ -17,5 +17,17 @@ if (!existsSync(distIndex)) { process.exit(1); } -// Import the actual CLI -await import(pathToFileURL(distIndex).href); +const cliModule = await import(pathToFileURL(distIndex).href); + +if (typeof cliModule.main !== 'function') { + console.error(`Error: CLI entrypoint at ${distIndex} does not export main().`); + process.exit(1); +} + +try { + await cliModule.main(); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Error creating app: ${message}`); + process.exit(1); +} From 95bcef54d63b0f60056d935f527a9a2541e21a50 Mon Sep 17 00:00:00 2001 From: dominikpalatynski Date: Wed, 1 Apr 2026 22:21:02 +0200 Subject: [PATCH 003/215] feat(cli): add --skip-agentic-setup option to create-mercato-app --- .github/workflows/snapshot.yml | 2 +- packages/create-app/README.md | 5 ++ packages/create-app/src/index.ts | 14 ++++- .../create-app/src/lib/ready-apps.test.ts | 57 ++++++++++++++++--- scripts/test-create-app-integration.ts | 5 +- scripts/test-create-app.ts | 5 +- 6 files changed, 70 insertions(+), 18 deletions(-) diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 19060d81910..52aaf4daf40 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -250,7 +250,7 @@ jobs: # --- Scaffold & build standalone app --- - name: Scaffold standalone app - run: npx "create-mercato-app@${{ needs.snapshot.outputs.snapshot_version }}" /tmp/standalone-app --registry https://registry.npmjs.org + run: npx "create-mercato-app@${{ needs.snapshot.outputs.snapshot_version }}" /tmp/standalone-app --skip-agentic-setup --registry https://registry.npmjs.org - name: Configure standalone app environment working-directory: /tmp/standalone-app diff --git a/packages/create-app/README.md b/packages/create-app/README.md index 6966df0f51a..af4d1d15a43 100644 --- a/packages/create-app/README.md +++ b/packages/create-app/README.md @@ -35,6 +35,7 @@ npx create-mercato-app [options] |--------|-------------| | `--app ` | Bootstrap an official Open Mercato ready app from `open-mercato/ready-app-` | | `--app-url ` | Bootstrap a ready app from a GitHub repository URL | +| `--skip-agentic-setup` | Skip the interactive agentic setup wizard | | `--registry ` | Custom npm registry URL | | `--verdaccio` | Use local Verdaccio registry (http://localhost:4873) | | `--help`, `-h` | Show help | @@ -57,6 +58,9 @@ npx create-mercato-app my-store --verdaccio # Create a new app using a custom registry npx create-mercato-app my-store --registry http://localhost:4873 + +# Create a new app without the agentic setup wizard +npx create-mercato-app my-store --skip-agentic-setup ``` ## Ready App Behavior @@ -64,6 +68,7 @@ npx create-mercato-app my-store --registry http://localhost:4873 - `--app ` resolves to `open-mercato/ready-app-` and fetches the exact tag `v` - `--app-url ` only supports GitHub repository URLs in v1 and honors `/tree/` when present - `--app` and `--app-url` are mutually exclusive +- `--skip-agentic-setup` skips only the interactive agentic setup wizard - Imported ready apps are copied as raw source snapshots: the CLI does not rewrite dependency versions, package names, or application source files - Imported ready apps skip the interactive agentic setup wizard; if you want agentic tooling later, run `yarn mercato agentic:init` inside the generated app - Imported ready apps must not contain `.template` files; the scaffold fails closed if template files are found diff --git a/packages/create-app/src/index.ts b/packages/create-app/src/index.ts index 9687ac56c8a..3b05303973b 100644 --- a/packages/create-app/src/index.ts +++ b/packages/create-app/src/index.ts @@ -22,6 +22,7 @@ interface Options { app?: string appUrl?: string registry?: string + skipAgenticSetup: boolean verdaccio: boolean help: boolean version: boolean @@ -40,6 +41,7 @@ ${pc.bold('Arguments:')} ${pc.bold('Options:')} --app Bootstrap an official ready app from open-mercato/ready-app- --app-url Bootstrap a ready app from a GitHub repository URL + --skip-agentic-setup Skip the interactive agentic setup wizard --registry Custom npm registry URL --verdaccio Use local Verdaccio registry (http://localhost:4873) --help, -h Show help @@ -72,6 +74,7 @@ function parseArgs(args: string[]): { appName: string | null; options: Options } app: undefined, appUrl: undefined, registry: undefined, + skipAgenticSetup: false, verdaccio: false, help: false, version: false, @@ -85,6 +88,8 @@ function parseArgs(args: string[]): { appName: string | null; options: Options } options.help = true } else if (arg === '--version' || arg === '-v') { options.version = true + } else if (arg === '--skip-agentic-setup') { + options.skipAgenticSetup = true } else if (arg === '--verdaccio') { options.verdaccio = true } else if (arg === '--registry') { @@ -213,7 +218,12 @@ async function scaffoldImportedReadyApp(targetDir: string, source: ReadyAppSourc ensureGeneratedCssPlaceholder(targetDir) } -async function maybeRunAgenticSetup(targetDir: string): Promise { +async function maybeRunAgenticSetup(targetDir: string, skipAgenticSetup: boolean): Promise { + if (skipAgenticSetup) { + await runAgenticSetup(targetDir, async () => '', { tool: 'skip' }) + return + } + const rl = createInterface({ input: process.stdin, output: process.stdout }) const ask = (question: string) => new Promise((resolveAnswer) => rl.question(question, (answer) => resolveAnswer(answer.trim()))) @@ -326,7 +336,7 @@ export async function main(argv = process.argv.slice(2)): Promise { if (readyAppSource) { printImportedReadyAppNextSteps(appName) } else { - await maybeRunAgenticSetup(targetDir) + await maybeRunAgenticSetup(targetDir, options.skipAgenticSetup) printTemplateNextSteps(appName) } diff --git a/packages/create-app/src/lib/ready-apps.test.ts b/packages/create-app/src/lib/ready-apps.test.ts index ab6f7918875..26b27abe292 100644 --- a/packages/create-app/src/lib/ready-apps.test.ts +++ b/packages/create-app/src/lib/ready-apps.test.ts @@ -9,6 +9,7 @@ import * as tar from 'tar' import { downloadReadyAppSnapshot, extractTarballSnapshot, + getGitHubApiBaseUrl, parseGitHubRepositoryUrl, resolveOfficialReadyAppSource, resolveReadyAppSource, @@ -19,6 +20,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const PACKAGE_ROOT = resolve(__dirname, '..', '..') const CLI_BIN = join(PACKAGE_ROOT, 'bin', 'create-mercato-app') const CLI_ENTRY = join(PACKAGE_ROOT, 'src', 'index.ts') +const PACKAGE_VERSION = ( + JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as { version: string } +).version function makeTempDir(prefix: string): string { return mkdtempSync(join(tmpdir(), prefix)) @@ -52,14 +56,14 @@ async function createGitHubStyleTarball(files: Record): Promise< } test('resolveOfficialReadyAppSource maps app slug to repo and exact tag', () => { - const source = resolveOfficialReadyAppSource('prm', '0.4.9') + const source = resolveOfficialReadyAppSource('prm', PACKAGE_VERSION) assert.deepEqual(source, { kind: 'official', appSlug: 'prm', owner: 'open-mercato', repo: 'ready-app-prm', - ref: 'v0.4.9', + ref: `v${PACKAGE_VERSION}`, }) }) @@ -124,19 +128,20 @@ test('validateImportedReadyAppSnapshot fails closed on template files', () => { }) test('downloadReadyAppSnapshot resolves external default branches through the GitHub API', async () => { + const githubApiBaseUrl = getGitHubApiBaseUrl() const calls: string[] = [] const fetchMock = (async (input: RequestInfo | URL) => { const url = String(input) calls.push(url) - if (url.endsWith('/repos/some-agency/ready-app-marketplace')) { + if (url === `${githubApiBaseUrl}/repos/some-agency/ready-app-marketplace`) { return new Response(JSON.stringify({ default_branch: 'main' }), { status: 200, headers: { 'content-type': 'application/json' }, }) } - if (url.endsWith('/repos/some-agency/ready-app-marketplace/tarball/main')) { + if (url === `${githubApiBaseUrl}/repos/some-agency/ready-app-marketplace/tarball/main`) { return new Response(Uint8Array.from([1, 2, 3]), { status: 200, headers: { 'content-type': 'application/octet-stream' }, @@ -160,8 +165,8 @@ test('downloadReadyAppSnapshot resolves external default branches through the Gi assert.equal(download.ref, 'main') assert.deepEqual(Array.from(download.archive), [1, 2, 3]) assert.deepEqual(calls, [ - 'https://api.github.com/repos/some-agency/ready-app-marketplace', - 'https://api.github.com/repos/some-agency/ready-app-marketplace/tarball/main', + `${githubApiBaseUrl}/repos/some-agency/ready-app-marketplace`, + `${githubApiBaseUrl}/repos/some-agency/ready-app-marketplace/tarball/main`, ]) }) @@ -190,6 +195,38 @@ test('published CLI bin executes the dist entrypoint', () => { `expected bin wrapper to succeed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, ) assert.match(result.stdout, /--app-url/) + assert.match(result.stdout, /--skip-agentic-setup/) +}) + +test('CLI bare scaffold skips interactive agentic setup with --skip-agentic-setup', () => { + const targetRoot = makeTempDir('create-mercato-app-cli-ci-') + const targetDir = join(targetRoot, 'ci-app') + + try { + const result = spawnSync( + process.execPath, + ['--import', 'tsx', CLI_ENTRY, targetDir, '--skip-agentic-setup'], + { + cwd: PACKAGE_ROOT, + encoding: 'utf8', + env: process.env, + timeout: 5000, + }, + ) + + assert.equal( + result.status, + 0, + `expected CLI scaffold with --skip-agentic-setup to succeed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}\nerror:\n${result.error instanceof Error ? result.error.message : 'none'}`, + ) + assert.match(result.stdout, /Skipped agentic setup/) + assert.equal(existsSync(join(targetDir, 'package.json')), true) + assert.equal(existsSync(join(targetDir, '.claude')), false) + assert.equal(existsSync(join(targetDir, '.cursor')), false) + assert.equal(existsSync(join(targetDir, '.codex')), false) + } finally { + rmSync(targetRoot, { recursive: true, force: true }) + } }) test('CLI imported ready apps skip wizard-generated files', async () => { @@ -209,7 +246,13 @@ test('CLI imported ready apps skip wizard-generated files', async () => { `const archive = Uint8Array.from(Buffer.from('${archiveBase64}', 'base64')) globalThis.fetch = async (input) => { const url = String(input) - if (url === 'https://api.github.com/repos/open-mercato/ready-app-prm/tarball/v0.4.9') { + if (url.endsWith('/repos/open-mercato/ready-app-prm')) { + return new Response(JSON.stringify({ default_branch: 'main' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + } + if (url.endsWith('/repos/open-mercato/ready-app-prm/tarball/v${PACKAGE_VERSION}')) { return new Response(archive, { status: 200, headers: { 'content-type': 'application/octet-stream' }, diff --git a/scripts/test-create-app-integration.ts b/scripts/test-create-app-integration.ts index 5ed75ae8d57..8bf72b099c5 100644 --- a/scripts/test-create-app-integration.ts +++ b/scripts/test-create-app-integration.ts @@ -100,10 +100,7 @@ async function main(): Promise { try { await ensureVerdaccioPublished(ROOT) - runCommand(process.execPath, [CREATE_APP_BIN, appDir, '--verdaccio'], { - cwd: ROOT, - input: '5\n', - }) + runCommand(process.execPath, [CREATE_APP_BIN, appDir, '--verdaccio', '--skip-agentic-setup'], { cwd: ROOT }) assertExists(path.join(appDir, 'package.json'), 'Scaffolded standalone app created') assertExists(path.join(appDir, '.ai', 'qa', 'tests', 'playwright.config.ts'), 'Standalone QA config present') diff --git a/scripts/test-create-app.ts b/scripts/test-create-app.ts index 55751115088..09b699761b1 100644 --- a/scripts/test-create-app.ts +++ b/scripts/test-create-app.ts @@ -58,10 +58,7 @@ async function main(): Promise { try { await ensureVerdaccioPublished(ROOT) - runCommand(process.execPath, [CREATE_APP_BIN, appDir, '--verdaccio'], { - cwd: ROOT, - input: '5\n', - }) + runCommand(process.execPath, [CREATE_APP_BIN, appDir, '--verdaccio', '--skip-agentic-setup'], { cwd: ROOT }) assertExists(path.join(appDir, 'package.json'), 'Scaffolded app package.json created') assertExists(path.join(appDir, 'src', 'modules.ts'), 'Scaffolded app modules.ts created') From 0d768e9962bec22fedef26446763a6116a4081e3 Mon Sep 17 00:00:00 2001 From: Piotr Karwatka Date: Thu, 2 Apr 2026 11:25:23 +0200 Subject: [PATCH 004/215] chore(deps): bump vulnerable transitive resolutions --- package.json | 4 ++-- yarn.lock | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index c91dc7188a6..fed0a6dfd25 100644 --- a/package.json +++ b/package.json @@ -149,14 +149,14 @@ "@hono/node-server": "1.19.10", "pg": "8.20.0", "zod": "4.1.13", - "lodash-es": "4.17.23", + "lodash-es": "4.18.0", "ai": "6.0.44", "minimatch": "10.2.3", "ajv@npm:^8.0.0": "8.18.0", "ajv@npm:^8.9.0": "8.18.0", "ajv@npm:^8.17.1": "8.18.0", "ajv@npm:~8.13.0": "8.18.0", - "serialize-javascript": "7.0.3", + "serialize-javascript": "7.0.5", "svgo": "3.3.3", "qs": "6.14.2", "flatted": "3.4.2", diff --git a/yarn.lock b/yarn.lock index 26a02bd6f20..a536f5e55d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -18294,10 +18294,10 @@ __metadata: languageName: node linkType: hard -"lodash-es@npm:4.17.23": - version: 4.17.23 - resolution: "lodash-es@npm:4.17.23" - checksum: 10/1feae200df22eb0bd93ca86d485e77784b8a9fb1d13e91b66e9baa7a7e5e04be088c12a7e20c2250fc0bd3db1bc0ef0affc7d9e3810b6af2455a3c6bf6dde59e +"lodash-es@npm:4.18.0": + version: 4.18.0 + resolution: "lodash-es@npm:4.18.0" + checksum: 10/a74d83e86b43f6d25ed750a5ea07768bf3ac472ff9daf7b9f2301ce0c9767107bf7a10d4e85f9b8ec87354c1decc63cafa5d85ab72fd88fd338b9abd1ba7ec86 languageName: node linkType: hard @@ -24038,10 +24038,10 @@ __metadata: languageName: node linkType: hard -"serialize-javascript@npm:7.0.3": - version: 7.0.3 - resolution: "serialize-javascript@npm:7.0.3" - checksum: 10/ce45e28663ee1fa6f32c408c0a563c4a96e872cdc83dc3064c73126f2412048c6c984bfc1160c40188fcfa06cf5e075f5cc2e3261b0384dc8fdef34675c5adef +"serialize-javascript@npm:7.0.5": + version: 7.0.5 + resolution: "serialize-javascript@npm:7.0.5" + checksum: 10/6237c76ef6df3d1ad61dd4a393b71ca758c7654f4d1cf77529e513134c0f0660302e03b7ec88a8f3a3daa79e1f93d6de8218ecbc45e073d7cc6b66284a1d3e83 languageName: node linkType: hard From c693e6dfee51046717ba82df75980ca8f919a951 Mon Sep 17 00:00:00 2001 From: Maciej Dudziak Date: Sun, 5 Apr 2026 20:11:57 +0200 Subject: [PATCH 005/215] feat: SPEC 046c decoupling example module from CRM (#1144) --- ...le-module-umes-alignment-customer-tasks.md | 27 + apps/mercato/src/modules.ts | 6 +- .../backend/customer-tasks/page.meta.ts | 25 - .../commands/__tests__/todos.update.test.ts | 169 +++ .../src/modules/example/commands/todos.ts | 68 +- .../src/modules/example_customers_sync/acl.ts | 6 + .../example-customers-sync/mappings/route.ts | 254 ++++ .../example-customers-sync/reconcile/route.ts | 141 +++ .../example_customers_sync/data/enrichers.ts | 71 ++ .../example_customers_sync/data/entities.ts | 52 + .../example_customers_sync/data/validators.ts | 18 + .../modules/example_customers_sync/events.ts | 19 + .../example_customers_sync/i18n/de.json | 11 + .../example_customers_sync/i18n/en.json | 11 + .../example_customers_sync/i18n/es.json | 11 + .../example_customers_sync/i18n/pl.json | 11 + .../modules/example_customers_sync/index.ts | 12 + .../lib/__tests__/mappings.test.ts | 159 +++ .../lib/__tests__/sync.test.ts | 53 + .../lib/inbound-subscriber.ts | 29 + .../example_customers_sync/lib/mappings.ts | 252 ++++ .../lib/outbound-subscriber.ts | 30 + .../example_customers_sync/lib/queue.ts | 32 + .../example_customers_sync/lib/runtime.ts | 29 + .../example_customers_sync/lib/sync.ts | 960 ++++++++++++++ .../example_customers_sync/lib/toggles.ts | 59 + .../migrations/.snapshot-open-mercato.json | 172 +++ .../migrations/Migration20260401173723.ts | 89 ++ .../modules/example_customers_sync/setup.ts | 51 + .../customers-interaction-canceled.ts | 9 + .../customers-interaction-completed.ts | 9 + .../customers-interaction-created.ts | 9 + .../customers-interaction-deleted.ts | 9 + .../customers-interaction-updated.ts | 9 + .../subscribers/example-todo-created.ts | 9 + .../subscribers/example-todo-deleted.ts | 9 + .../subscribers/example-todo-updated.ts | 9 + .../example_customers_sync/workers/inbound.ts | 23 + .../workers/outbound.ts | 21 + .../workers/reconcile.ts | 33 + packages/cli/src/__tests__/mercato.test.ts | 61 + .../src/lib/__tests__/modules-config.test.ts | 31 + .../lib/__tests__/resolver.enterprise.test.ts | 35 + .../__tests__/module-subset.test.ts | 26 + .../cli/src/lib/generators/module-registry.ts | 3 +- packages/cli/src/lib/modules-config.ts | 51 +- packages/cli/src/lib/resolver.ts | 41 +- packages/cli/src/mercato.ts | 18 +- .../__integration__/TC-CRM-026.spec.ts | 1 + .../__integration__/TC-CRM-028.spec.ts | 1117 +++++++++++++++++ .../customers/api/companies/[id]/route.ts | 11 +- .../customer-todos/__tests__/route.test.ts | 147 +++ .../dashboard/widgets/customer-todos/route.ts | 195 +-- .../customers/api/interactions/tasks/route.ts | 122 ++ .../customers/api/people/[id]/route.ts | 5 +- .../api/todos/__tests__/route.test.ts | 39 +- .../src/modules/customers/api/todos/route.ts | 194 +-- .../backend/customer-tasks/page.meta.ts | 23 + .../backend/customer-tasks/page.tsx | 4 +- .../customers/commands/interactions.ts | 52 +- .../components/CustomerTodosTable.tsx | 157 ++- .../components/detail/TasksSection.tsx | 16 +- .../detail/__tests__/TasksSection.test.tsx | 83 ++ .../components/detail/__tests__/utils.test.ts | 13 + .../components/detail/hooks/usePersonTasks.ts | 3 +- .../customers/components/detail/types.ts | 6 + .../customers/components/detail/utils.ts | 3 + .../src/modules/customers/data/entities.ts | 4 +- .../src/modules/customers/data/validators.ts | 4 +- .../customers/lib/interactionCompatibility.ts | 16 + .../customers/lib/todoCompatibility.ts | 239 +++- .../migrations/.snapshot-open-mercato.json | 2 +- .../migrations/Migration20260401172819.ts | 45 + packages/core/src/modules/customers/search.ts | 5 +- .../customer-todos/widget.client.tsx | 47 +- packages/create-app/template/src/modules.ts | 6 +- .../__integration__/TC-UMES-021.spec.ts | 145 +++ .../backend/customer-tasks/page.meta.ts | 25 - .../example/backend/customer-tasks/page.tsx | 12 - .../src/modules/example/commands/todos.ts | 11 + .../src/modules/example_customers_sync/acl.ts | 6 + .../example-customers-sync/mappings/route.ts | 254 ++++ .../example-customers-sync/reconcile/route.ts | 141 +++ .../example_customers_sync/data/enrichers.ts | 71 ++ .../example_customers_sync/data/entities.ts | 52 + .../example_customers_sync/data/validators.ts | 18 + .../modules/example_customers_sync/events.ts | 19 + .../example_customers_sync/i18n/de.json | 11 + .../example_customers_sync/i18n/en.json | 11 + .../example_customers_sync/i18n/es.json | 11 + .../example_customers_sync/i18n/pl.json | 11 + .../modules/example_customers_sync/index.ts | 12 + .../lib/__tests__/mappings.test.ts | 159 +++ .../lib/__tests__/sync.test.ts | 53 + .../lib/inbound-subscriber.ts | 29 + .../example_customers_sync/lib/mappings.ts | 252 ++++ .../lib/outbound-subscriber.ts | 30 + .../example_customers_sync/lib/queue.ts | 32 + .../example_customers_sync/lib/runtime.ts | 29 + .../example_customers_sync/lib/sync.ts | 960 ++++++++++++++ .../example_customers_sync/lib/toggles.ts | 59 + .../migrations/.snapshot-open-mercato.json | 172 +++ .../migrations/Migration20260401173723.ts | 89 ++ .../modules/example_customers_sync/setup.ts | 51 + .../customers-interaction-canceled.ts | 9 + .../customers-interaction-completed.ts | 9 + .../customers-interaction-created.ts | 9 + .../customers-interaction-deleted.ts | 9 + .../customers-interaction-updated.ts | 9 + .../subscribers/example-todo-created.ts | 9 + .../subscribers/example-todo-deleted.ts | 9 + .../subscribers/example-todo-updated.ts | 9 + .../example_customers_sync/workers/inbound.ts | 23 + .../workers/outbound.ts | 21 + .../workers/reconcile.ts | 33 + packages/shared/src/lib/commands/helpers.ts | 8 +- packages/shared/src/lib/commands/types.ts | 1 + packages/shared/src/lib/crud/types.ts | 1 + packages/shared/src/lib/data/engine.ts | 30 +- 119 files changed, 8185 insertions(+), 530 deletions(-) delete mode 100644 apps/mercato/src/modules/example/backend/customer-tasks/page.meta.ts create mode 100644 apps/mercato/src/modules/example/commands/__tests__/todos.update.test.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/acl.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/api/example-customers-sync/mappings/route.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/api/example-customers-sync/reconcile/route.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/data/enrichers.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/data/entities.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/data/validators.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/events.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/i18n/de.json create mode 100644 apps/mercato/src/modules/example_customers_sync/i18n/en.json create mode 100644 apps/mercato/src/modules/example_customers_sync/i18n/es.json create mode 100644 apps/mercato/src/modules/example_customers_sync/i18n/pl.json create mode 100644 apps/mercato/src/modules/example_customers_sync/index.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/lib/__tests__/mappings.test.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/lib/__tests__/sync.test.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/lib/inbound-subscriber.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/lib/mappings.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/lib/outbound-subscriber.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/lib/queue.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/lib/runtime.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/lib/sync.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/lib/toggles.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/migrations/.snapshot-open-mercato.json create mode 100644 apps/mercato/src/modules/example_customers_sync/migrations/Migration20260401173723.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/setup.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-canceled.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-completed.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-created.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-deleted.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-updated.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-created.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-deleted.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-updated.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/workers/inbound.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/workers/outbound.ts create mode 100644 apps/mercato/src/modules/example_customers_sync/workers/reconcile.ts create mode 100644 packages/core/src/modules/customers/__integration__/TC-CRM-028.spec.ts create mode 100644 packages/core/src/modules/customers/api/dashboard/widgets/customer-todos/__tests__/route.test.ts create mode 100644 packages/core/src/modules/customers/api/interactions/tasks/route.ts create mode 100644 packages/core/src/modules/customers/backend/customer-tasks/page.meta.ts rename {apps/mercato/src/modules/example => packages/core/src/modules/customers}/backend/customer-tasks/page.tsx (51%) create mode 100644 packages/core/src/modules/customers/components/detail/__tests__/TasksSection.test.tsx create mode 100644 packages/core/src/modules/customers/components/detail/__tests__/utils.test.ts create mode 100644 packages/core/src/modules/customers/migrations/Migration20260401172819.ts create mode 100644 packages/create-app/template/src/modules/example/__integration__/TC-UMES-021.spec.ts delete mode 100644 packages/create-app/template/src/modules/example/backend/customer-tasks/page.meta.ts delete mode 100644 packages/create-app/template/src/modules/example/backend/customer-tasks/page.tsx create mode 100644 packages/create-app/template/src/modules/example_customers_sync/acl.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/api/example-customers-sync/mappings/route.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/api/example-customers-sync/reconcile/route.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/data/enrichers.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/data/entities.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/data/validators.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/events.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/i18n/de.json create mode 100644 packages/create-app/template/src/modules/example_customers_sync/i18n/en.json create mode 100644 packages/create-app/template/src/modules/example_customers_sync/i18n/es.json create mode 100644 packages/create-app/template/src/modules/example_customers_sync/i18n/pl.json create mode 100644 packages/create-app/template/src/modules/example_customers_sync/index.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/lib/__tests__/mappings.test.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/lib/__tests__/sync.test.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/lib/inbound-subscriber.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/lib/mappings.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/lib/outbound-subscriber.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/lib/queue.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/lib/runtime.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/lib/sync.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/lib/toggles.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/migrations/.snapshot-open-mercato.json create mode 100644 packages/create-app/template/src/modules/example_customers_sync/migrations/Migration20260401173723.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/setup.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-canceled.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-completed.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-created.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-deleted.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-updated.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-created.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-deleted.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-updated.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/workers/inbound.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/workers/outbound.ts create mode 100644 packages/create-app/template/src/modules/example_customers_sync/workers/reconcile.ts diff --git a/.ai/specs/implemented/SPEC-046c-2026-02-28-example-module-umes-alignment-customer-tasks.md b/.ai/specs/implemented/SPEC-046c-2026-02-28-example-module-umes-alignment-customer-tasks.md index 0dc6b78e3b7..dd334acb182 100644 --- a/.ai/specs/implemented/SPEC-046c-2026-02-28-example-module-umes-alignment-customer-tasks.md +++ b/.ai/specs/implemented/SPEC-046c-2026-02-28-example-module-umes-alignment-customer-tasks.md @@ -2,6 +2,7 @@ ## TLDR **Key Points:** +- Implemented on top of already-merged `SPEC-046` / `SPEC-046b`; canonical CRM v2 pages and `customer_interactions` remain the baseline. - Keep `example` as a self-contained demo module (`example.todo`), but remove its implicit role as customers task provider. - Move customer-task synchronization to an explicit UMES-style extension module around canonical `customer_interactions` from `SPEC-046b-2026-02-27-customers-interactions-unification.md`. - Make `/backend/customer-tasks` owned by customers domain, so it works even when `example` is disabled. @@ -15,6 +16,22 @@ - Avoiding sync loops and duplicate objects when bidirectional sync is enabled. - Preserving backward compatibility for existing `example` routes and data. +## Implementation Status — 2026-04-01 +This specification is implemented as a v2-aware follow-up to the already shipped canonical CRM work from `SPEC-046` and `SPEC-046b`. + +Delivered: +1. `/backend/customer-tasks` is now owned by `customers`, and the duplicate Example-owned route was removed from both the workspace app and the create-app template. +2. First-party CRM task UI now reads canonical `CustomerInteraction` task rows from `/api/customers/interactions`, not from the legacy `/api/customers/todos` bridge. +3. Canonical interaction rows now expose additive `customer` and `_integrations.example` data needed by cross-customer task screens. +4. `customer_todo_links.todo_source` now defaults to `customers:interaction` for new rows, while legacy `example:todo` rows remain readable. +5. `example_customers_sync` now exists as a real optional extension module with mapping storage, feature toggles, sync APIs, subscribers, and workers. +6. Shared command/crud/data-engine plumbing now carries optional `syncOrigin` metadata to prevent outbound and inbound sync loops. + +Rollout posture: +1. The sync module is registered when Example is present, but the runtime sync feature toggles default to `false`. +2. `/api/customers/todos` remains available as a deprecated compatibility adapter. +3. Canonical v2 customer detail pages and derived `Next Interaction` behavior from `SPEC-046b` remain unchanged. + ## Overview `example` currently mixes two responsibilities: 1. A standalone demo module (`example.todo`, demo pages/widgets/APIs). @@ -22,6 +39,8 @@ After `SPEC-046b-2026-02-27-customers-interactions-unification.md`, customers tasks become canonical `customer_interactions`. Keeping cross-module task-provider behavior inside `example` as an implicit provider creates unclear ownership and upgrade risk. +Implementation note: this was delivered against the already merged CRM v2 detail pages (`people-v2` / `companies-v2`). The work here completes the Example decoupling inside that canonical model rather than reintroducing any pre-v2 customer task flow. + This spec aligns `example` with UMES by separating concerns: - `example` remains demo/self-contained. - Customer-task integration becomes explicit extension logic (`example_customers_sync`) around customers core events and contracts. @@ -373,6 +392,14 @@ Bidirectional sync may produce event storms if guards fail. Mitigation: `_syncOr - **Fully compliant**: Approved — ready for implementation planning. ## Changelog +### 2026-04-01 +- Implemented the v2-aware completion of `SPEC-046c` on top of merged `SPEC-046` / `SPEC-046b`. +- Moved `/backend/customer-tasks` ownership to `customers` and removed the duplicate Example-owned route from both the app and the create-app template. +- Switched first-party CRM task pages and widgets to canonical `/api/customers/interactions` task rows, while keeping `/api/customers/todos` as a deprecated compatibility bridge. +- Added additive `customer` and `_integrations.example` enrichment for canonical interaction task rows. +- Added the `example_customers_sync` extension module with mapping storage, feature toggles, operational APIs, subscribers, workers, and `syncOrigin` loop-guard plumbing. +- Verified the rollout with package builds, repo typecheck, targeted Jest coverage, and the targeted ephemeral integration run for `packages/core/src/modules/customers/__integration__/TC-CRM-026.spec.ts`. + ### 2026-03-02 - Renumbered this specification from `SPEC-050` to `SPEC-046c` as a child workstream of customer detail rewrite. - Updated dependency references from `SPEC-049` to `SPEC-046b`. diff --git a/apps/mercato/src/modules.ts b/apps/mercato/src/modules.ts index 909a06f9778..41b8195ec8f 100644 --- a/apps/mercato/src/modules.ts +++ b/apps/mercato/src/modules.ts @@ -52,6 +52,10 @@ export const enabledModules: ModuleEntry[] = [ { id: 'example', from: '@app' }, ] +if (enabledModules.some((entry) => entry.id === 'example')) { + enabledModules.push({ id: 'example_customers_sync', from: '@app' }) +} + const enterpriseModulesEnabled = parseBooleanWithDefault(process.env.OM_ENABLE_ENTERPRISE_MODULES, false) const enterpriseSsoEnabled = parseBooleanWithDefault(process.env.OM_ENABLE_ENTERPRISE_MODULES_SSO, false) const enterpriseSecurityEnabled = parseBooleanWithDefault(process.env.OM_ENABLE_ENTERPRISE_MODULES_SECURITY, false) @@ -69,4 +73,4 @@ if (enterpriseModulesEnabled && enterpriseSsoEnabled) { if (enterpriseModulesEnabled && enterpriseSecurityEnabled) { enabledModules.push({ id: 'security', from: '@open-mercato/enterprise' }) -} \ No newline at end of file +} diff --git a/apps/mercato/src/modules/example/backend/customer-tasks/page.meta.ts b/apps/mercato/src/modules/example/backend/customer-tasks/page.meta.ts deleted file mode 100644 index e3de63ce16c..00000000000 --- a/apps/mercato/src/modules/example/backend/customer-tasks/page.meta.ts +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react' - -const usersIcon = React.createElement( - 'svg', - { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, - React.createElement('path', { d: 'M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2' }), - React.createElement('circle', { cx: 9, cy: 7, r: 4 }), - React.createElement('path', { d: 'M23 21v-2a4 4 0 0 0-3-3.87' }), - React.createElement('path', { d: 'M16 3.13a4 4 0 0 1 0 7.75' }), -) - -export const metadata = { - requireAuth: true, - requireFeatures: ['example.todos.view', 'customers.activities.view'], - pageTitle: 'Customer related tasks', - pageTitleKey: 'customers.workPlan.customerTodos.page.title', - pageGroup: 'Work plan', - pageGroupKey: 'example.workPlan.nav.group', - pageOrder: 122, - icon: usersIcon, - breadcrumb: [ - { label: 'General tasks', labelKey: 'example.todos.page.title', href: '/backend/todos' }, - { label: 'Customer related tasks', labelKey: 'customers.workPlan.customerTodos.page.title' }, - ], -} diff --git a/apps/mercato/src/modules/example/commands/__tests__/todos.update.test.ts b/apps/mercato/src/modules/example/commands/__tests__/todos.update.test.ts new file mode 100644 index 00000000000..66cedd35319 --- /dev/null +++ b/apps/mercato/src/modules/example/commands/__tests__/todos.update.test.ts @@ -0,0 +1,169 @@ +jest.mock('@open-mercato/core/generated/entities.ids.generated', () => ({ + E: { example: { todo: 'example:todo' } }, +}), { virtual: true }) +jest.mock('@/.mercato/generated/entities.ids.generated', () => ({ + E: { example: { todo: 'example:todo' } }, +}), { virtual: true }) +jest.mock('@open-mercato/shared/lib/i18n/server', () => ({ + resolveTranslations: async () => ({ + translate: (_key: string, fallback?: string) => fallback ?? _key, + }), +})) + +import '../todos' +import { commandRegistry } from '@open-mercato/shared/lib/commands/registry' +import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands' +import type { DataEngine } from '@open-mercato/shared/lib/data/engine' +import type { EntityManager } from '@mikro-orm/postgresql' +import type { Todo } from '../../data/entities' + +function getCommand(id: string): CommandHandler, Todo> { + const handler = commandRegistry.get(id) + if (!handler) throw new Error(`Command ${id} not registered`) + return handler as CommandHandler, Todo> +} + +function createCtx() { + const updatedTodo = { + id: '11111111-1111-4111-8111-111111111111', + title: 'Updated title', + isDone: true, + tenantId: '33333333-3333-4333-8333-333333333333', + organizationId: '22222222-2222-4222-8222-222222222222', + deletedAt: null, + createdAt: new Date('2026-04-02T09:00:00.000Z'), + updatedAt: new Date('2026-04-02T10:00:00.000Z'), + } as Todo + + const nativeUpdate = jest.fn(async () => 1) + const findOne = jest.fn(async () => updatedTodo) + const isolatedEm = { + nativeUpdate, + findOne, + } as unknown as EntityManager + const em = { + fork: jest.fn(() => isolatedEm), + } as unknown as EntityManager + + const setCustomFields = jest.fn(async (_opts: Parameters[0]) => undefined) + const markOrmEntityChange = jest.fn((_entry: Parameters[0]) => undefined) + const dataEngine = { + setCustomFields, + markOrmEntityChange, + } as unknown as Pick + + const container = { + resolve: (token: string) => { + if (token === 'em') return em + if (token === 'dataEngine') return dataEngine + throw new Error(`Unexpected dependency: ${token}`) + }, + } + + const ctx: CommandRuntimeContext = { + container: container as never, + auth: { + tenantId: '33333333-3333-4333-8333-333333333333', + orgId: '22222222-2222-4222-8222-222222222222', + sub: '44444444-4444-4444-8444-444444444444', + } as never, + organizationScope: null, + selectedOrganizationId: '22222222-2222-4222-8222-222222222222', + organizationIds: ['22222222-2222-4222-8222-222222222222'], + request: undefined as never, + } + + return { + ctx, + em, + nativeUpdate, + findOne, + setCustomFields, + markOrmEntityChange, + } +} + +describe('example todos update', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('updates todos via an isolated entity manager and keeps custom-field side effects', async () => { + const { ctx, em, nativeUpdate, findOne, setCustomFields, markOrmEntityChange } = createCtx() + const handler = getCommand('example.todos.update') + + const result = await handler.execute( + { + id: '11111111-1111-4111-8111-111111111111', + title: 'Updated title', + is_done: true, + cf_priority: 5, + }, + ctx, + ) + + expect(em.fork).toHaveBeenCalledWith({ clear: true, freshEventManager: true }) + expect(nativeUpdate).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: '11111111-1111-4111-8111-111111111111', + tenantId: '33333333-3333-4333-8333-333333333333', + organizationId: '22222222-2222-4222-8222-222222222222', + deletedAt: null, + }), + expect.objectContaining({ + title: 'Updated title', + isDone: true, + updatedAt: expect.any(Date), + }), + ) + expect(findOne).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: '11111111-1111-4111-8111-111111111111', + tenantId: '33333333-3333-4333-8333-333333333333', + organizationId: '22222222-2222-4222-8222-222222222222', + deletedAt: null, + }), + ) + expect(setCustomFields).toHaveBeenCalledWith( + expect.objectContaining({ + entityId: 'example:todo', + recordId: '11111111-1111-4111-8111-111111111111', + tenantId: '33333333-3333-4333-8333-333333333333', + organizationId: '22222222-2222-4222-8222-222222222222', + values: { priority: 5 }, + notify: false, + }), + ) + expect(markOrmEntityChange).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'updated', + identifiers: expect.objectContaining({ + id: '11111111-1111-4111-8111-111111111111', + tenantId: '33333333-3333-4333-8333-333333333333', + organizationId: '22222222-2222-4222-8222-222222222222', + }), + }), + ) + expect(result).toEqual(expect.objectContaining({ + id: '11111111-1111-4111-8111-111111111111', + title: 'Updated title', + isDone: true, + })) + }) + + it('skips the native update when only custom fields change', async () => { + const { ctx, nativeUpdate, findOne } = createCtx() + const handler = getCommand('example.todos.update') + + const result = await handler.execute({ id: '11111111-1111-4111-8111-111111111111', cf_priority: 8 }, ctx) + + expect(nativeUpdate).not.toHaveBeenCalled() + expect(findOne).toHaveBeenCalled() + expect(result).toEqual(expect.objectContaining({ + id: '11111111-1111-4111-8111-111111111111', + title: 'Updated title', + })) + }) +}) diff --git a/apps/mercato/src/modules/example/commands/todos.ts b/apps/mercato/src/modules/example/commands/todos.ts index a16a554d9d3..f5d0c7d2c6d 100644 --- a/apps/mercato/src/modules/example/commands/todos.ts +++ b/apps/mercato/src/modules/example/commands/todos.ts @@ -12,7 +12,7 @@ import { import type { CrudEmitContext, CrudEventsConfig, CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types' import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors' import type { DataEngine } from '@open-mercato/shared/lib/data/engine' -import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql' +import type { EntityData, EntityManager, FilterQuery } from '@mikro-orm/postgresql' import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' import { z } from 'zod' import { Todo } from '../data/entities' @@ -25,6 +25,7 @@ import { } from '@open-mercato/shared/lib/commands/customFieldSnapshots' export const todoCreateSchema = z.object({ + id: z.string().uuid().optional(), title: z.string().min(1), is_done: z.boolean().optional(), }) @@ -52,6 +53,9 @@ export const todoCrudEvents: CrudEventsConfig = { id: ctx.identifiers.id, tenantId: ctx.identifiers.tenantId, organizationId: ctx.identifiers.organizationId, + title: ctx.entity?.title ?? null, + isDone: typeof ctx.entity?.isDone === 'boolean' ? ctx.entity.isDone : null, + ...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}), }), } @@ -82,6 +86,7 @@ const createTodoCommand: CommandHandler, Todo> = { const todo = await de.createOrmEntity({ entity: Todo, data: { + ...(parsed.id ? { id: parsed.id } : {}), title: parsed.title, isDone: parsed.is_done ?? false, tenantId: scope.tenantId, @@ -107,6 +112,7 @@ const createTodoCommand: CommandHandler, Todo> = { tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) @@ -172,6 +178,7 @@ const createTodoCommand: CommandHandler, Todo> = { tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) @@ -198,19 +205,13 @@ const updateTodoCommand: CommandHandler, Todo> = { const { parsed, custom } = parseWithCustomFields(todoUpdateSchema, rawInput) const scope = ensureScope(ctx) const de = (ctx.container.resolve('dataEngine') as DataEngine) - - const todo = await de.updateOrmEntity({ - entity: Todo, - where: { - id: parsed.id, - tenantId: scope.tenantId, - organizationId: scope.organizationId, - deletedAt: null, - } as FilterQuery, - apply: (entity) => { - if (parsed.title !== undefined) entity.title = parsed.title - if (parsed.is_done !== undefined) entity.isDone = parsed.is_done - }, + const em = (ctx.container.resolve('em') as EntityManager) + const todo = await updateTodoWithoutFlushingRequestScope(em, { + id: parsed.id, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + title: parsed.title, + isDone: parsed.is_done, }) if (!todo) throw new CrudHttpError(404, { error: 'Todo not found' }) @@ -232,6 +233,7 @@ const updateTodoCommand: CommandHandler, Todo> = { tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) @@ -318,6 +320,7 @@ const updateTodoCommand: CommandHandler, Todo> = { tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) @@ -366,6 +369,7 @@ const deleteTodoCommand: CommandHandler<{ body?: Record; query? tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) @@ -435,6 +439,7 @@ const deleteTodoCommand: CommandHandler<{ body?: Record; query? tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) @@ -485,6 +490,41 @@ function ensureScope(ctx: CommandRuntimeContext): { tenantId: string; organizati return { tenantId, organizationId } } +// Uses nativeUpdate on a forked EntityManager to avoid flushing unrelated +// pending changes from the request-scoped EM. This is required when the +// sync bridge calls update inside a worker context where the shared EM may +// carry state from prior operations within the same job batch. +async function updateTodoWithoutFlushingRequestScope( + em: EntityManager, + input: { + id: string + tenantId: string + organizationId: string + title?: string + isDone?: boolean + }, +): Promise { + const isolatedEm = em.fork({ clear: true, freshEventManager: true }) + const where = { + id: input.id, + tenantId: input.tenantId, + organizationId: input.organizationId, + deletedAt: null, + } as FilterQuery + const patch: EntityData = {} + + if (input.title !== undefined) patch.title = input.title + if (input.isDone !== undefined) patch.isDone = input.isDone + + if (Object.keys(patch).length > 0) { + patch.updatedAt = new Date() + const updatedRows = await isolatedEm.nativeUpdate(Todo, where, patch) + if (updatedRows === 0) return null + } + + return await isolatedEm.findOne(Todo, where) +} + async function loadTodoCustomSnapshot( em: EntityManager, id: string, diff --git a/apps/mercato/src/modules/example_customers_sync/acl.ts b/apps/mercato/src/modules/example_customers_sync/acl.ts new file mode 100644 index 00000000000..4900cee4536 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/acl.ts @@ -0,0 +1,6 @@ +export const features = [ + { id: 'example_customers_sync.view', title: 'View Example customer sync diagnostics', module: 'example_customers_sync' }, + { id: 'example_customers_sync.manage', title: 'Manage Example customer sync', module: 'example_customers_sync' }, +] + +export default features diff --git a/apps/mercato/src/modules/example_customers_sync/api/example-customers-sync/mappings/route.ts b/apps/mercato/src/modules/example_customers_sync/api/example-customers-sync/mappings/route.ts new file mode 100644 index 00000000000..80d71f88cde --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/api/example-customers-sync/mappings/route.ts @@ -0,0 +1,254 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import type { EntityManager } from '@mikro-orm/postgresql' +import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server' +import { createRequestContainer } from '@open-mercato/shared/lib/di/container' +import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' +import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' +import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope' +import { CustomerInteraction } from '@open-mercato/core/modules/customers/data/entities' +import { loadCustomerSummaries } from '@open-mercato/core/modules/customers/lib/interactionReadModel' +import { exampleTag } from '../../../../example/api/openapi' +import { mappingListQuerySchema } from '../../../data/validators' + +export const metadata = { + path: '/example-customers-sync/mappings', + requireAuth: true, + requireFeatures: ['example_customers_sync.view'], +} + +type MappingRow = { + id: string + interaction_id: string + todo_id: string + sync_status: string + last_synced_at: Date | null + last_error: string | null + source_updated_at: Date | null + created_at: Date + updated_at: Date + organization_id: string + tenant_id: string +} + +type CursorPayload = { + updatedAt: string + id: string +} + +function encodeCursor(payload: CursorPayload): string { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') +} + +function decodeCursor(token: string | undefined): CursorPayload | null { + if (!token) return null + try { + const parsed = JSON.parse(Buffer.from(token, 'base64').toString('utf8')) as CursorPayload + if (typeof parsed.id !== 'string' || typeof parsed.updatedAt !== 'string') return null + return parsed + } catch { + return null + } +} + +export async function GET(request: Request) { + const { translate } = await resolveTranslations() + try { + const auth = await getAuthFromRequest(request) + if (!auth?.tenantId) { + return NextResponse.json( + { error: translate('exampleCustomersSync.errors.unauthorized', 'Unauthorized') }, + { status: 401 }, + ) + } + + const url = new URL(request.url) + const query = mappingListQuerySchema.parse(Object.fromEntries(url.searchParams)) + const cursor = decodeCursor(query.cursor) + if (query.cursor && !cursor) { + return NextResponse.json( + { error: translate('exampleCustomersSync.errors.invalidCursor', 'Invalid cursor.') }, + { status: 400 }, + ) + } + + const container = await createRequestContainer() + const scope = await resolveOrganizationScopeForRequest({ container, auth, request }) + const organizationIds = Array.isArray(scope?.filterIds) && scope.filterIds.length > 0 + ? scope.filterIds + : auth.orgId + ? [auth.orgId] + : [] + + const em = (container.resolve('em') as EntityManager).fork() + const knex = em.getKnex() + const rowsQuery = knex('example_customer_interaction_mappings') + .select([ + 'id', + 'interaction_id', + 'todo_id', + 'sync_status', + 'last_synced_at', + 'last_error', + 'source_updated_at', + 'created_at', + 'updated_at', + 'organization_id', + 'tenant_id', + ]) + .where('tenant_id', auth.tenantId) + .orderBy('updated_at', 'desc') + .orderBy('id', 'desc') + .limit(query.limit + 1) + + if (organizationIds.length > 0) { + rowsQuery.whereIn('organization_id', organizationIds) + } + if (query.interactionId) { + rowsQuery.andWhere('interaction_id', query.interactionId) + } + if (query.todoId) { + rowsQuery.andWhere('todo_id', query.todoId) + } + if (cursor) { + rowsQuery.andWhere(function applyCursor() { + this.where('updated_at', '<', new Date(cursor.updatedAt)).orWhere(function applyTieBreaker() { + this.where('updated_at', new Date(cursor.updatedAt)).andWhere('id', '<', cursor.id) + }) + }) + } + + const rows = await rowsQuery + const pageRows = rows.slice(0, query.limit) + const interactionIds = Array.from(new Set(pageRows.map((row) => row.interaction_id))) + const interactions = interactionIds.length > 0 + ? await findWithDecryption( + em, + CustomerInteraction, + { + id: { $in: interactionIds }, + tenantId: auth.tenantId, + ...(organizationIds.length > 0 ? { organizationId: { $in: organizationIds } } : {}), + deletedAt: null, + }, + undefined, + { tenantId: auth.tenantId, organizationId: null }, + ) + : [] + const interactionById = new Map(interactions.map((interaction) => [interaction.id, interaction])) + const customerSummaries = await loadCustomerSummaries( + em, + Array.from(new Set( + interactions + .map((interaction) => (typeof interaction.entity === 'string' ? interaction.entity : interaction.entity.id)) + .filter((value): value is string => typeof value === 'string' && value.length > 0), + )), + auth.tenantId, + null, + ) + + const items = pageRows.map((row) => { + const interaction = interactionById.get(row.interaction_id) + const entityId = interaction + ? (typeof interaction.entity === 'string' ? interaction.entity : interaction.entity.id) + : null + return { + id: row.id, + interactionId: row.interaction_id, + todoId: row.todo_id, + syncStatus: row.sync_status, + lastSyncedAt: row.last_synced_at?.toISOString() ?? null, + lastError: row.last_error ?? null, + sourceUpdatedAt: row.source_updated_at?.toISOString() ?? null, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + organizationId: row.organization_id, + tenantId: row.tenant_id, + exampleHref: `/backend/todos/${encodeURIComponent(row.todo_id)}/edit`, + interaction: interaction ? { + id: interaction.id, + title: interaction.title ?? null, + status: interaction.status, + interactionType: interaction.interactionType, + customer: entityId ? (customerSummaries.get(entityId) ?? null) : null, + } : null, + } + }) + + const hasMore = rows.length > query.limit + const last = hasMore ? pageRows[pageRows.length - 1] : null + + return NextResponse.json({ + items, + ...(last ? { nextCursor: encodeCursor({ updatedAt: last.updated_at.toISOString(), id: last.id }) } : {}), + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: translate('exampleCustomersSync.errors.validationFailed', 'Validation failed'), + details: error.issues, + }, + { status: 400 }, + ) + } + console.error('example-customers-sync.mappings.get failed', error) + return NextResponse.json( + { + error: translate( + 'exampleCustomersSync.errors.mappingsLoadFailed', + 'Failed to load Example customer sync mappings.', + ), + }, + { status: 500 }, + ) + } +} + +const mappingItemSchema = z.object({ + id: z.string().uuid(), + interactionId: z.string().uuid(), + todoId: z.string().uuid(), + syncStatus: z.string(), + lastSyncedAt: z.string().nullable(), + lastError: z.string().nullable(), + sourceUpdatedAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + organizationId: z.string().uuid(), + tenantId: z.string().uuid(), + exampleHref: z.string(), + interaction: z.object({ + id: z.string().uuid(), + title: z.string().nullable(), + status: z.string(), + interactionType: z.string(), + customer: z.object({ + id: z.string().uuid(), + displayName: z.string().nullable(), + kind: z.string().nullable(), + }).nullable(), + }).nullable(), +}) + +export const openApi: OpenApiRouteDoc = { + tag: exampleTag, + methods: { + GET: { + summary: 'List Example customer sync mappings', + tags: [exampleTag], + query: mappingListQuerySchema, + responses: [ + { + status: 200, + description: 'Sync mappings', + schema: z.object({ + items: z.array(mappingItemSchema), + nextCursor: z.string().optional(), + }), + }, + ], + }, + }, +} diff --git a/apps/mercato/src/modules/example_customers_sync/api/example-customers-sync/reconcile/route.ts b/apps/mercato/src/modules/example_customers_sync/api/example-customers-sync/reconcile/route.ts new file mode 100644 index 00000000000..e668b3b6f27 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/api/example-customers-sync/reconcile/route.ts @@ -0,0 +1,141 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server' +import { createRequestContainer } from '@open-mercato/shared/lib/di/container' +import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' +import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' +import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope' +import { exampleTag } from '../../../../example/api/openapi' +import { reconcileSchema } from '../../../data/validators' +import { EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE, getExampleCustomersSyncQueue } from '../../../lib/queue' +import type { ExampleCustomersSyncReconcileJobPayload } from '../../../lib/sync' + +export const metadata = { + path: '/example-customers-sync/reconcile', + requireAuth: true, + requireFeatures: ['example_customers_sync.manage'], +} + +async function readJsonBody(request: Request): Promise> { + /* Manual parsing because readJsonSafe is for outbound fetch responses, not inbound request bodies */ + const text = await request.text() + if (!text.trim()) return {} + return JSON.parse(text) as Record +} + +export async function POST(request: Request) { + const { translate } = await resolveTranslations() + try { + const auth = await getAuthFromRequest(request) + if (!auth?.tenantId) { + return NextResponse.json( + { error: translate('exampleCustomersSync.errors.unauthorized', 'Unauthorized') }, + { status: 401 }, + ) + } + + const rawBody = await readJsonBody(request) + const body = reconcileSchema.parse(rawBody) + if (body.tenantId && body.tenantId !== auth.tenantId) { + return NextResponse.json( + { + error: translate( + 'exampleCustomersSync.errors.tenantScopeMismatch', + 'Tenant scope mismatch.', + ), + }, + { status: 403 }, + ) + } + + const container = await createRequestContainer() + const scope = await resolveOrganizationScopeForRequest({ container, auth, request }) + const organizationIds = Array.isArray(scope?.filterIds) && scope.filterIds.length > 0 + ? scope.filterIds + : auth.orgId + ? [auth.orgId] + : [] + const organizationId = body.organizationId ?? scope?.selectedId ?? auth.orgId ?? organizationIds[0] ?? null + + if (!organizationId) { + return NextResponse.json( + { + error: translate( + 'exampleCustomersSync.errors.organizationContextRequired', + 'Organization context is required.', + ), + }, + { status: 400 }, + ) + } + if (organizationIds.length > 0 && !organizationIds.includes(organizationId)) { + return NextResponse.json( + { + error: translate( + 'exampleCustomersSync.errors.organizationScopeMismatch', + 'Organization scope mismatch.', + ), + }, + { status: 403 }, + ) + } + + const queue = getExampleCustomersSyncQueue( + EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE, + ) + await queue.enqueue({ + tenantId: auth.tenantId, + organizationId, + limit: body.limit, + cursor: body.cursor, + }) + + return NextResponse.json({ queued: 1 }, { status: 202 }) + } catch (error) { + if (error instanceof SyntaxError) { + return NextResponse.json( + { error: translate('exampleCustomersSync.errors.invalidJson', 'Invalid JSON body.') }, + { status: 400 }, + ) + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: translate('exampleCustomersSync.errors.validationFailed', 'Validation failed'), + details: error.issues, + }, + { status: 400 }, + ) + } + console.error('example-customers-sync.reconcile.post failed', error) + return NextResponse.json( + { + error: translate( + 'exampleCustomersSync.errors.reconcileEnqueueFailed', + 'Failed to enqueue Example customer sync reconciliation.', + ), + }, + { status: 500 }, + ) + } +} + +export const openApi: OpenApiRouteDoc = { + tag: exampleTag, + methods: { + POST: { + summary: 'Backfill or reconcile Example todo mappings to canonical customer interactions', + tags: [exampleTag], + requestBody: { + schema: reconcileSchema, + }, + responses: [ + { + status: 202, + description: 'Reconcile job accepted', + schema: z.object({ queued: z.number().int().nonnegative() }), + }, + ], + }, + }, +} diff --git a/apps/mercato/src/modules/example_customers_sync/data/enrichers.ts b/apps/mercato/src/modules/example_customers_sync/data/enrichers.ts new file mode 100644 index 00000000000..7f086a03884 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/data/enrichers.ts @@ -0,0 +1,71 @@ +import type { EntityManager } from '@mikro-orm/postgresql' +import type { ResponseEnricher } from '@open-mercato/shared/lib/crud/response-enricher' +import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { ExampleCustomerInteractionMapping } from './entities' +import { buildExampleTodoHref } from '../lib/mappings' + +type InteractionRecord = Record & { id: string } + +function mergeExampleIntegration( + record: InteractionRecord, + mapping: ExampleCustomerInteractionMapping | null, +): InteractionRecord { + if (!mapping) return record + const integrations = + record._integrations && typeof record._integrations === 'object' + ? { ...(record._integrations as Record) } + : {} + integrations.example = { + todoId: mapping.todoId, + href: buildExampleTodoHref(mapping.todoId), + syncStatus: mapping.syncStatus, + lastError: mapping.lastError ?? null, + lastSyncedAt: mapping.lastSyncedAt ? mapping.lastSyncedAt.toISOString() : null, + } + return { + ...record, + _integrations: integrations, + } +} + +const exampleCustomersSyncEnricher: ResponseEnricher = { + id: 'example_customers_sync.interaction-links', + targetEntity: 'customers.interaction', + features: ['example.todos.view'], + priority: 20, + timeout: 2000, + fallback: {}, + async enrichOne(record, context) { + const mapping = await findWithDecryption( + context.em as EntityManager, + ExampleCustomerInteractionMapping, + { + interactionId: record.id, + tenantId: context.tenantId, + organizationId: context.organizationId, + }, + undefined, + { tenantId: context.tenantId, organizationId: context.organizationId }, + ).then((items) => items[0] ?? null) + return mergeExampleIntegration(record, mapping) + }, + async enrichMany(records, context) { + const interactionIds = records.map((record) => record.id) + if (!interactionIds.length) return records + const mappings = await findWithDecryption( + context.em as EntityManager, + ExampleCustomerInteractionMapping, + { + interactionId: { $in: interactionIds }, + tenantId: context.tenantId, + organizationId: context.organizationId, + }, + undefined, + { tenantId: context.tenantId, organizationId: context.organizationId }, + ) + const mappingByInteractionId = new Map(mappings.map((mapping) => [mapping.interactionId, mapping])) + return records.map((record) => mergeExampleIntegration(record, mappingByInteractionId.get(record.id) ?? null)) + }, +} + +export const enrichers: ResponseEnricher[] = [exampleCustomersSyncEnricher] diff --git a/apps/mercato/src/modules/example_customers_sync/data/entities.ts b/apps/mercato/src/modules/example_customers_sync/data/entities.ts new file mode 100644 index 00000000000..7cbdcc3cdc7 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/data/entities.ts @@ -0,0 +1,52 @@ +import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/core' + +@Entity({ tableName: 'example_customer_interaction_mappings' }) +@Unique({ + name: 'example_customer_interaction_mappings_interaction_unique', + properties: ['organizationId', 'tenantId', 'interactionId'], +}) +@Unique({ + name: 'example_customer_interaction_mappings_todo_unique', + properties: ['organizationId', 'tenantId', 'todoId'], +}) +@Index({ + name: 'example_customer_interaction_mappings_status_idx', + properties: ['organizationId', 'tenantId', 'syncStatus', 'updatedAt'], +}) +export class ExampleCustomerInteractionMapping { + @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' }) + id!: string + + @Property({ name: 'organization_id', type: 'uuid' }) + organizationId!: string + + @Property({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string + + @Property({ name: 'interaction_id', type: 'uuid' }) + interactionId!: string + + @Property({ name: 'todo_id', type: 'uuid' }) + todoId!: string + + @Property({ name: 'sync_status', type: 'text', default: 'pending' }) + syncStatus: 'pending' | 'synced' | 'error' = 'pending' + + @Property({ name: 'last_synced_at', type: Date, nullable: true }) + lastSyncedAt?: Date | null + + @Property({ name: 'last_error', type: 'text', nullable: true }) + lastError?: string | null + + @Property({ name: 'source_updated_at', type: Date, nullable: true }) + sourceUpdatedAt?: Date | null + + @Property({ name: 'deleted_at', type: Date, nullable: true }) + deletedAt?: Date | null + + @Property({ name: 'created_at', type: Date, onCreate: () => new Date() }) + createdAt: Date = new Date() + + @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() }) + updatedAt: Date = new Date() +} diff --git a/apps/mercato/src/modules/example_customers_sync/data/validators.ts b/apps/mercato/src/modules/example_customers_sync/data/validators.ts new file mode 100644 index 00000000000..fba6d76ab51 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/data/validators.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' + +export const mappingListQuerySchema = z.object({ + interactionId: z.string().uuid().optional(), + todoId: z.string().uuid().optional(), + limit: z.coerce.number().min(1).max(100).default(50), + cursor: z.string().optional(), +}) + +export const reconcileSchema = z.object({ + organizationId: z.string().uuid().optional(), + tenantId: z.string().uuid().optional(), + limit: z.coerce.number().min(1).max(500).optional(), + cursor: z.string().optional(), +}) + +export type MappingListQuery = z.infer +export type ReconcileInput = z.infer diff --git a/apps/mercato/src/modules/example_customers_sync/events.ts b/apps/mercato/src/modules/example_customers_sync/events.ts new file mode 100644 index 00000000000..e103c278208 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/events.ts @@ -0,0 +1,19 @@ +import { createModuleEvents } from '@open-mercato/shared/modules/events' + +const events = [ + { id: 'example_customers_sync.mapping.created', label: 'Example customer sync mapping created', entity: 'mapping', category: 'crud' }, + { id: 'example_customers_sync.mapping.updated', label: 'Example customer sync mapping updated', entity: 'mapping', category: 'crud' }, + { id: 'example_customers_sync.mapping.deleted', label: 'Example customer sync mapping deleted', entity: 'mapping', category: 'crud' }, + { id: 'example_customers_sync.sync.failed', label: 'Example customer sync failed', entity: 'mapping', category: 'custom' }, +] as const + +export const eventsConfig = createModuleEvents({ + moduleId: 'example_customers_sync', + events, +}) + +export const emitExampleCustomersSyncEvent = eventsConfig.emit + +export type ExampleCustomersSyncEventId = typeof events[number]['id'] + +export default eventsConfig diff --git a/apps/mercato/src/modules/example_customers_sync/i18n/de.json b/apps/mercato/src/modules/example_customers_sync/i18n/de.json new file mode 100644 index 00000000000..44caafa8bfa --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/i18n/de.json @@ -0,0 +1,11 @@ +{ + "exampleCustomersSync.errors.invalidCursor": "Ungueltiger Cursor.", + "exampleCustomersSync.errors.invalidJson": "Ungueltiger JSON-Text.", + "exampleCustomersSync.errors.mappingsLoadFailed": "Example-Kundensynchronisationszuordnungen konnten nicht geladen werden.", + "exampleCustomersSync.errors.organizationContextRequired": "Organisationskontext ist erforderlich.", + "exampleCustomersSync.errors.organizationScopeMismatch": "Organisationsbereich stimmt nicht ueberein.", + "exampleCustomersSync.errors.reconcileEnqueueFailed": "Die Example-Kundensynchronisationsabstimmung konnte nicht in die Warteschlange gestellt werden.", + "exampleCustomersSync.errors.tenantScopeMismatch": "Mandantenbereich stimmt nicht ueberein.", + "exampleCustomersSync.errors.unauthorized": "Nicht autorisiert", + "exampleCustomersSync.errors.validationFailed": "Validierung fehlgeschlagen" +} diff --git a/apps/mercato/src/modules/example_customers_sync/i18n/en.json b/apps/mercato/src/modules/example_customers_sync/i18n/en.json new file mode 100644 index 00000000000..b97c72e3353 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/i18n/en.json @@ -0,0 +1,11 @@ +{ + "exampleCustomersSync.errors.invalidCursor": "Invalid cursor.", + "exampleCustomersSync.errors.invalidJson": "Invalid JSON body.", + "exampleCustomersSync.errors.mappingsLoadFailed": "Failed to load Example customer sync mappings.", + "exampleCustomersSync.errors.organizationContextRequired": "Organization context is required.", + "exampleCustomersSync.errors.organizationScopeMismatch": "Organization scope mismatch.", + "exampleCustomersSync.errors.reconcileEnqueueFailed": "Failed to enqueue Example customer sync reconciliation.", + "exampleCustomersSync.errors.tenantScopeMismatch": "Tenant scope mismatch.", + "exampleCustomersSync.errors.unauthorized": "Unauthorized", + "exampleCustomersSync.errors.validationFailed": "Validation failed" +} diff --git a/apps/mercato/src/modules/example_customers_sync/i18n/es.json b/apps/mercato/src/modules/example_customers_sync/i18n/es.json new file mode 100644 index 00000000000..70e0b5bd0f2 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/i18n/es.json @@ -0,0 +1,11 @@ +{ + "exampleCustomersSync.errors.invalidCursor": "Cursor no valido.", + "exampleCustomersSync.errors.invalidJson": "Cuerpo JSON no valido.", + "exampleCustomersSync.errors.mappingsLoadFailed": "No se pudieron cargar las asignaciones de sincronizacion de clientes de Example.", + "exampleCustomersSync.errors.organizationContextRequired": "Se requiere el contexto de organizacion.", + "exampleCustomersSync.errors.organizationScopeMismatch": "El alcance de la organizacion no coincide.", + "exampleCustomersSync.errors.reconcileEnqueueFailed": "No se pudo encolar la reconciliacion de sincronizacion de clientes de Example.", + "exampleCustomersSync.errors.tenantScopeMismatch": "El alcance del tenant no coincide.", + "exampleCustomersSync.errors.unauthorized": "No autorizado", + "exampleCustomersSync.errors.validationFailed": "La validacion fallo" +} diff --git a/apps/mercato/src/modules/example_customers_sync/i18n/pl.json b/apps/mercato/src/modules/example_customers_sync/i18n/pl.json new file mode 100644 index 00000000000..80e6fdbd7c2 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/i18n/pl.json @@ -0,0 +1,11 @@ +{ + "exampleCustomersSync.errors.invalidCursor": "Nieprawidlowy kursor.", + "exampleCustomersSync.errors.invalidJson": "Nieprawidlowe body JSON.", + "exampleCustomersSync.errors.mappingsLoadFailed": "Nie udalo sie zaladowac mapowan synchronizacji klientow Example.", + "exampleCustomersSync.errors.organizationContextRequired": "Kontekst organizacji jest wymagany.", + "exampleCustomersSync.errors.organizationScopeMismatch": "Zakres organizacji nie zgadza sie.", + "exampleCustomersSync.errors.reconcileEnqueueFailed": "Nie udalo sie zakolejkowac uzgadniania synchronizacji klientow Example.", + "exampleCustomersSync.errors.tenantScopeMismatch": "Zakres tenant nie zgadza sie.", + "exampleCustomersSync.errors.unauthorized": "Brak autoryzacji", + "exampleCustomersSync.errors.validationFailed": "Walidacja nie powiodla sie" +} diff --git a/apps/mercato/src/modules/example_customers_sync/index.ts b/apps/mercato/src/modules/example_customers_sync/index.ts new file mode 100644 index 00000000000..fc115dae485 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/index.ts @@ -0,0 +1,12 @@ +import type { ModuleInfo } from '@open-mercato/shared/modules/registry' + +export const metadata: ModuleInfo = { + name: 'example_customers_sync', + title: 'Example Customers Sync', + version: '0.1.0', + description: 'Optional sync bridge between canonical customer interactions and the example todo module.', + author: 'Open Mercato Team', + license: 'MIT', +} + +export default metadata diff --git a/apps/mercato/src/modules/example_customers_sync/lib/__tests__/mappings.test.ts b/apps/mercato/src/modules/example_customers_sync/lib/__tests__/mappings.test.ts new file mode 100644 index 00000000000..8402143e40a --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/lib/__tests__/mappings.test.ts @@ -0,0 +1,159 @@ +import { + buildExampleTodoCustomValuesFromInteraction, + buildExampleTodoHref, + buildInteractionUpdateFromExampleTodo, +} from '../mappings' + +describe('example_customers_sync mappings', () => { + it('maps canonical interaction fields into example todo custom values', () => { + expect( + buildExampleTodoCustomValuesFromInteraction({ + priority: 9, + body: 'Follow up with procurement', + customValues: { severity: 'critical' }, + }), + ).toEqual({ + priority: 5, + __om_customer_interaction_priority_raw: 9, + description: 'Follow up with procurement', + severity: 'high', + __om_customer_interaction_severity_raw: 'critical', + }) + + expect( + buildExampleTodoCustomValuesFromInteraction({ + priority: 0, + body: null, + customValues: { severity: 'normal' }, + }), + ).toEqual({ + priority: 1, + __om_customer_interaction_priority_raw: 0, + severity: 'medium', + __om_customer_interaction_severity_raw: 'normal', + }) + + expect( + buildExampleTodoCustomValuesFromInteraction({ + priority: null, + body: null, + customValues: {}, + }, { + includeClears: true, + }), + ).toEqual({ + priority: null, + __om_customer_interaction_priority_raw: null, + description: null, + severity: null, + __om_customer_interaction_severity_raw: null, + }) + }) + + it('maps example todo payloads back into canonical interaction updates', () => { + const occurredAt = new Date('2026-04-01T10:00:00.000Z') + + expect( + buildInteractionUpdateFromExampleTodo({ + title: 'Call customer', + isDone: true, + occurredAt, + customValues: { + priority: '4', + description: 'Capture new renewal date', + severity: ' high ', + }, + }), + ).toEqual({ + title: 'Call customer', + status: 'done', + occurredAt, + priority: 4, + body: 'Capture new renewal date', + customValues: { severity: 'high' }, + }) + + expect( + buildInteractionUpdateFromExampleTodo({ + title: 'Call customer', + isDone: true, + occurredAt, + customValues: { + priority: 5, + __om_customer_interaction_priority_raw: 9, + description: 'Capture new renewal date', + severity: 'high', + __om_customer_interaction_severity_raw: 'critical', + }, + }), + ).toEqual({ + title: 'Call customer', + status: 'done', + occurredAt, + priority: 9, + body: 'Capture new renewal date', + customValues: { severity: 'critical' }, + }) + + expect( + buildInteractionUpdateFromExampleTodo({ + title: 'Call customer', + isDone: true, + occurredAt, + customValues: { + priority: 4, + __om_customer_interaction_priority_raw: 9, + description: 'Capture new renewal date', + severity: 'low', + __om_customer_interaction_severity_raw: 'critical', + }, + }), + ).toEqual({ + title: 'Call customer', + status: 'done', + occurredAt, + priority: 4, + body: 'Capture new renewal date', + customValues: { severity: 'low' }, + }) + + expect( + buildInteractionUpdateFromExampleTodo({ + title: 'Reopened task', + isDone: false, + customValues: { + priority: null, + description: null, + }, + }), + ).toEqual({ + title: 'Reopened task', + status: 'planned', + occurredAt: null, + priority: null, + body: null, + customValues: {}, + }) + + expect( + buildInteractionUpdateFromExampleTodo({ + title: 'Reopened task', + isDone: false, + customValues: {}, + }, { + includeClears: true, + }), + ).toEqual({ + title: 'Reopened task', + status: 'planned', + occurredAt: null, + priority: null, + body: null, + customValues: { severity: null }, + }) + }) + + it('builds stable example todo edit links', () => { + expect(buildExampleTodoHref('todo-id/with spaces')).toBe('/backend/todos/todo-id%2Fwith%20spaces/edit') + }) +}) diff --git a/apps/mercato/src/modules/example_customers_sync/lib/__tests__/sync.test.ts b/apps/mercato/src/modules/example_customers_sync/lib/__tests__/sync.test.ts new file mode 100644 index 00000000000..cae0615de75 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/lib/__tests__/sync.test.ts @@ -0,0 +1,53 @@ +import { + resolveInboundInteractionSyncStrategy, + resolveMappingTodoIdForSyncFailure, +} from '../sync' + +describe('example_customers_sync sync helpers', () => { + it('routes inbound done transitions through the canonical complete command', () => { + expect( + resolveInboundInteractionSyncStrategy({ + currentStatus: 'planned', + isDone: true, + }), + ).toEqual({ + updateStatusInCommand: false, + lifecycleCommandId: 'customers.interactions.complete', + }) + + expect( + resolveInboundInteractionSyncStrategy({ + currentStatus: 'done', + isDone: true, + }), + ).toEqual({ + updateStatusInCommand: false, + lifecycleCommandId: null, + }) + + expect( + resolveInboundInteractionSyncStrategy({ + currentStatus: 'done', + isDone: false, + }), + ).toEqual({ + updateStatusInCommand: true, + lifecycleCommandId: null, + }) + }) + + it('uses a deterministic todo id when the first outbound sync attempt fails', () => { + expect( + resolveMappingTodoIdForSyncFailure({ + interactionId: 'interaction-1', + }), + ).toBe('interaction-1') + + expect( + resolveMappingTodoIdForSyncFailure({ + interactionId: 'interaction-1', + mappingTodoId: 'todo-1', + }), + ).toBe('todo-1') + }) +}) diff --git a/apps/mercato/src/modules/example_customers_sync/lib/inbound-subscriber.ts b/apps/mercato/src/modules/example_customers_sync/lib/inbound-subscriber.ts new file mode 100644 index 00000000000..b5465869045 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/lib/inbound-subscriber.ts @@ -0,0 +1,29 @@ +import { getExampleCustomersSyncQueue, EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE } from '../lib/queue' +import { shouldEnqueueInboundSync } from '../lib/sync' +import { resolveExampleCustomersSyncFlags } from '../lib/toggles' + +type ResolverContext = { + resolve: (name: string) => T +} + +type InboundPayload = { + id?: string | null + tenantId?: string | null + organizationId?: string | null + syncOrigin?: string | null +} + +export function createInboundSubscriber(eventName: string) { + return async function handle(payload: InboundPayload, ctx: ResolverContext): Promise { + if (!shouldEnqueueInboundSync(payload)) return + const flags = await resolveExampleCustomersSyncFlags(ctx, payload.tenantId) + if (!flags.enabled || !flags.bidirectional) return + const queue = getExampleCustomersSyncQueue(EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE) + await queue.enqueue({ + eventId: eventName, + todoId: payload.id, + tenantId: payload.tenantId, + organizationId: payload.organizationId, + }) + } +} diff --git a/apps/mercato/src/modules/example_customers_sync/lib/mappings.ts b/apps/mercato/src/modules/example_customers_sync/lib/mappings.ts new file mode 100644 index 00000000000..eb0f0b5c0fe --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/lib/mappings.ts @@ -0,0 +1,252 @@ +import type { EntityManager } from '@mikro-orm/postgresql' +import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { ExampleCustomerInteractionMapping } from '../data/entities' +import type { ExampleCustomersSyncScope } from './runtime' + +export type { ExampleCustomersSyncScope } + +export type ExampleCustomersSyncMappingInput = ExampleCustomersSyncScope & { + interactionId: string + todoId: string + syncStatus: 'pending' | 'synced' | 'error' + lastSyncedAt?: Date | null + lastError?: string | null + sourceUpdatedAt?: Date | null +} + +const EXAMPLE_PRIORITY_RAW_KEY = '__om_customer_interaction_priority_raw' +const EXAMPLE_SEVERITY_RAW_KEY = '__om_customer_interaction_severity_raw' + +export function buildExampleTodoHref(todoId: string): string { + return `/backend/todos/${encodeURIComponent(todoId)}/edit` +} + +function parsePriorityValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value) + return Number.isNaN(parsed) ? null : parsed + } + return null +} + +function normalizeExamplePriorityValue(value: number): number { + return Math.min(5, Math.max(1, Math.round(value))) +} + +function normalizeSeverityValue(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim().toLowerCase() + : null +} + +function normalizeExampleSeverityValue(value: unknown): string | null { + const normalized = normalizeSeverityValue(value) + if (!normalized) return null + if (normalized === 'critical') return 'high' + if (normalized === 'normal') return 'medium' + return normalized +} + +export function buildExampleTodoCustomValuesFromInteraction( + interaction: { + priority?: number | null + body?: string | null + customValues?: Record | null + }, + options: { + includeClears?: boolean + } = {}, +): Record { + const includeClears = options.includeClears === true + const values: Record = {} + if (typeof interaction.priority === 'number' && Number.isFinite(interaction.priority)) { + values.priority = normalizeExamplePriorityValue(interaction.priority) + values[EXAMPLE_PRIORITY_RAW_KEY] = interaction.priority + } else if (includeClears) { + values.priority = null + values[EXAMPLE_PRIORITY_RAW_KEY] = null + } + if (typeof interaction.body === 'string') { + values.description = interaction.body + } else if (includeClears) { + values.description = null + } + const severity = interaction.customValues?.severity + if (typeof severity === 'string' && severity.trim().length > 0) { + values.severity = normalizeExampleSeverityValue(severity) + values[EXAMPLE_SEVERITY_RAW_KEY] = normalizeSeverityValue(severity) + } else if (includeClears) { + values.severity = null + values[EXAMPLE_SEVERITY_RAW_KEY] = null + } + return values +} + +export function buildInteractionUpdateFromExampleTodo(input: { + title: string | null + isDone: boolean + customValues?: Record | null + occurredAt?: Date | null +}, options: { + includeClears?: boolean +} = {}) { + const severity = input.customValues?.severity + const priorityRaw = input.customValues?.priority + const priorityCanonicalRaw = input.customValues?.[EXAMPLE_PRIORITY_RAW_KEY] + const descriptionRaw = input.customValues?.description + const severityCanonicalRaw = input.customValues?.[EXAMPLE_SEVERITY_RAW_KEY] + const includeClears = options.includeClears === true + const priorityValue = parsePriorityValue(priorityRaw) + const priorityRawValue = parsePriorityValue(priorityCanonicalRaw) + const priority = + priorityValue !== null + ? priorityRawValue !== null && priorityValue === normalizeExamplePriorityValue(priorityRawValue) + ? priorityRawValue + : priorityValue + : includeClears + ? null + : priorityRawValue + const description = + typeof descriptionRaw === 'string' + ? descriptionRaw + : descriptionRaw == null + ? null + : String(descriptionRaw) + const severityValue = normalizeSeverityValue(severity) + const severityRawValue = normalizeSeverityValue(severityCanonicalRaw) + const resolvedSeverity = + severityValue + ? severityRawValue && severityValue === normalizeExampleSeverityValue(severityRawValue) + ? severityRawValue + : severityValue + : includeClears + ? null + : severityRawValue + + return { + title: input.title, + status: input.isDone ? 'done' : 'planned', + occurredAt: input.isDone ? (input.occurredAt ?? new Date()) : null, + priority, + body: description, + customValues: + resolvedSeverity !== null + ? { severity: resolvedSeverity } + : includeClears + ? { severity: null } + : {}, + } +} + +export async function findMappingByInteractionId( + em: EntityManager, + scope: ExampleCustomersSyncScope, + interactionId: string, +): Promise { + return await findOneWithDecryption( + em, + ExampleCustomerInteractionMapping, + { + interactionId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + }, + undefined, + scope, + ) +} + +export async function findMappingByTodoId( + em: EntityManager, + scope: ExampleCustomersSyncScope, + todoId: string, +): Promise { + return await findOneWithDecryption( + em, + ExampleCustomerInteractionMapping, + { + todoId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + }, + undefined, + scope, + ) +} + +function isDuplicateKeyError(error: unknown): boolean { + return Boolean( + error + && typeof error === 'object' + && ( + (typeof (error as { code?: unknown }).code === 'string' && (error as { code: string }).code === '23505') + || (typeof (error as { message?: unknown }).message === 'string' + && (error as { message: string }).message.toLowerCase().includes('duplicate key')) + ) + ) +} + +function applyMappingInput( + mapping: ExampleCustomerInteractionMapping, + input: ExampleCustomersSyncMappingInput, +): void { + mapping.organizationId = input.organizationId + mapping.tenantId = input.tenantId + mapping.interactionId = input.interactionId + mapping.todoId = input.todoId + mapping.syncStatus = input.syncStatus + mapping.lastSyncedAt = input.lastSyncedAt ?? null + mapping.lastError = input.lastError ?? null + mapping.sourceUpdatedAt = input.sourceUpdatedAt ?? null +} + +export async function upsertExampleCustomerInteractionMapping( + em: EntityManager, + input: ExampleCustomersSyncMappingInput, +): Promise<{ mapping: ExampleCustomerInteractionMapping; created: boolean }> { + let mapping = + await findMappingByInteractionId(em, input, input.interactionId) + ?? await findMappingByTodoId(em, input, input.todoId) + const created = !mapping + if (!mapping) { + mapping = em.create(ExampleCustomerInteractionMapping, { + organizationId: input.organizationId, + tenantId: input.tenantId, + interactionId: input.interactionId, + todoId: input.todoId, + syncStatus: input.syncStatus, + lastSyncedAt: input.lastSyncedAt ?? null, + lastError: input.lastError ?? null, + sourceUpdatedAt: input.sourceUpdatedAt ?? null, + createdAt: new Date(), + updatedAt: new Date(), + }) + em.persist(mapping) + } else { + applyMappingInput(mapping, input) + } + try { + await em.flush() + } catch (error) { + if (!isDuplicateKeyError(error)) throw error + em.clear() + const existing = + await findMappingByInteractionId(em, input, input.interactionId) + ?? await findMappingByTodoId(em, input, input.todoId) + if (!existing) throw error + applyMappingInput(existing, input) + await em.flush() + return { mapping: existing, created: false } + } + return { mapping, created } +} + +export async function deleteExampleCustomerInteractionMapping( + em: EntityManager, + mapping: ExampleCustomerInteractionMapping | null | undefined, +): Promise { + if (!mapping) return false + await em.removeAndFlush(mapping) + return true +} diff --git a/apps/mercato/src/modules/example_customers_sync/lib/outbound-subscriber.ts b/apps/mercato/src/modules/example_customers_sync/lib/outbound-subscriber.ts new file mode 100644 index 00000000000..d29ead7a052 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/lib/outbound-subscriber.ts @@ -0,0 +1,30 @@ +import { getExampleCustomersSyncQueue, EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE } from '../lib/queue' +import { shouldEnqueueOutboundSync } from '../lib/sync' +import { resolveExampleCustomersSyncFlags } from '../lib/toggles' + +type ResolverContext = { + resolve: (name: string) => T +} + +type OutboundPayload = { + id?: string | null + interactionType?: string | null + tenantId?: string | null + organizationId?: string | null + syncOrigin?: string | null +} + +export function createOutboundSubscriber(eventName: string) { + return async function handle(payload: OutboundPayload, ctx: ResolverContext): Promise { + if (!shouldEnqueueOutboundSync(payload)) return + const flags = await resolveExampleCustomersSyncFlags(ctx, payload.tenantId) + if (!flags.enabled) return + const queue = getExampleCustomersSyncQueue(EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE) + await queue.enqueue({ + eventId: eventName, + interactionId: payload.id, + tenantId: payload.tenantId, + organizationId: payload.organizationId, + }) + } +} diff --git a/apps/mercato/src/modules/example_customers_sync/lib/queue.ts b/apps/mercato/src/modules/example_customers_sync/lib/queue.ts new file mode 100644 index 00000000000..0d80ca611e4 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/lib/queue.ts @@ -0,0 +1,32 @@ +import { createQueue, type Queue } from '@open-mercato/queue' +import { getRedisUrl } from '@open-mercato/shared/lib/redis/connection' + +export const EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE = 'example-customers-sync-outbound' +export const EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE = 'example-customers-sync-inbound' +export const EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE = 'example-customers-sync-reconcile' + +const GLOBAL_KEY = '__example_customers_sync_queues__' as const + +function getQueueCache(): Map>> { + const g = globalThis as Record + if (!g[GLOBAL_KEY]) { + g[GLOBAL_KEY] = new Map>>() + } + return g[GLOBAL_KEY] as Map>> +} + +export function getExampleCustomersSyncQueue>(queueName: string): Queue { + const queues = getQueueCache() + const existing = queues.get(queueName) + if (existing) return existing as Queue + + const created = process.env.QUEUE_STRATEGY === 'async' + ? createQueue(queueName, 'async', { + connection: { url: getRedisUrl('QUEUE') }, + concurrency: Math.max(1, Number.parseInt(process.env.EXAMPLE_CUSTOMERS_SYNC_QUEUE_CONCURRENCY ?? '5', 10) || 5), + }) + : createQueue(queueName, 'local') + + queues.set(queueName, created as Queue>) + return created +} diff --git a/apps/mercato/src/modules/example_customers_sync/lib/runtime.ts b/apps/mercato/src/modules/example_customers_sync/lib/runtime.ts new file mode 100644 index 00000000000..7fac0c5f916 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/lib/runtime.ts @@ -0,0 +1,29 @@ +import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands' + +export type ExampleCustomersSyncScope = { + tenantId: string + organizationId: string +} + +export const EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN = 'example_customers_sync:outbound' +export const EXAMPLE_CUSTOMERS_SYNC_INBOUND_ORIGIN = 'example_customers_sync:inbound' + +export function buildExampleCustomersSyncCommandContext( + container: { resolve: (name: string) => T }, + scope: ExampleCustomersSyncScope, + syncOrigin: string, +): CommandRuntimeContext { + return { + container: container as CommandRuntimeContext['container'], + auth: { + sub: `system:${syncOrigin}`, + tenantId: scope.tenantId, + orgId: scope.organizationId, + userId: `system:${syncOrigin}`, + }, + organizationScope: null, + selectedOrganizationId: scope.organizationId, + organizationIds: [scope.organizationId], + syncOrigin, + } +} diff --git a/apps/mercato/src/modules/example_customers_sync/lib/sync.ts b/apps/mercato/src/modules/example_customers_sync/lib/sync.ts new file mode 100644 index 00000000000..197ce22881d --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/lib/sync.ts @@ -0,0 +1,960 @@ +import type { EntityManager } from '@mikro-orm/postgresql' +import type { CommandBus } from '@open-mercato/shared/lib/commands' +import { loadCustomFieldSnapshot } from '@open-mercato/shared/lib/commands/customFieldSnapshots' +import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors' +import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { + CustomerInteraction, + CustomerTodoLink, +} from '@open-mercato/core/modules/customers/data/entities' +import { + CUSTOMER_INTERACTION_TASK_TYPE, + CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE, + type InteractionRecord, +} from '@open-mercato/core/modules/customers/lib/interactionCompatibility' +import { hydrateCanonicalInteractions } from '@open-mercato/core/modules/customers/lib/interactionReadModel' +import { E } from '../../../../.mercato/generated/entities.ids.generated' +import { Todo } from '../../example/data/entities' +import { ExampleCustomerInteractionMapping } from '../data/entities' +import { emitExampleCustomersSyncEvent } from '../events' +import { + buildExampleTodoCustomValuesFromInteraction, + buildInteractionUpdateFromExampleTodo, + deleteExampleCustomerInteractionMapping, + findMappingByInteractionId, + findMappingByTodoId, + upsertExampleCustomerInteractionMapping, +} from './mappings' +import { + buildExampleCustomersSyncCommandContext, + EXAMPLE_CUSTOMERS_SYNC_INBOUND_ORIGIN, + EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN, + type ExampleCustomersSyncScope, +} from './runtime' +import { resolveExampleCustomersSyncFlags } from './toggles' + +type ContainerLike = { + resolve: (name: string) => T +} + +export type ExampleCustomersSyncOutboundJobPayload = ExampleCustomersSyncScope & { + eventId: string + interactionId: string +} + +export type ExampleCustomersSyncInboundJobPayload = ExampleCustomersSyncScope & { + eventId: string + todoId: string +} + +export type ExampleCustomersSyncReconcileJobPayload = ExampleCustomersSyncScope & { + limit?: number + cursor?: string +} + +type ExampleTodoSnapshot = { + id: string + title: string + isDone: boolean + updatedAt: Date | null + customValues: Record | null +} + +type LegacyExampleTodoLinkRow = { + id: string + entityId: string + todoId: string + createdByUserId: string | null + createdAt: Date +} + +export type ExampleCustomersSyncReconcileItem = { + linkId: string + todoId: string + interactionId: string | null + status: 'mapped' | 'created_interaction' | 'skipped' | 'failed' + message?: string | null +} + +export type ExampleCustomersSyncReconcileResult = { + items: ExampleCustomersSyncReconcileItem[] + nextCursor?: string + processed: number + mapped: number + createdInteractions: number + failed: number +} + +type CursorPayload = { + createdAt: string + id: string +} + +const DEFAULT_TASK_TITLE = 'Untitled task' + +function isSyncOriginFromBridge(syncOrigin: unknown): boolean { + return typeof syncOrigin === 'string' && syncOrigin.startsWith('example_customers_sync:') +} + +function isTaskEventPayload(payload: { interactionType?: string | null }): boolean { + return payload.interactionType === CUSTOMER_INTERACTION_TASK_TYPE +} + +function parseDateOrNull(value: string | Date | null | undefined): Date | null { + if (!value) return null + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value + } + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +function trimErrorMessage(value: unknown): string { + const message = value instanceof Error ? value.message : String(value ?? 'Unknown sync error') + return message.length > 2000 ? `${message.slice(0, 1997)}...` : message +} + +function isNotFoundError(error: unknown): boolean { + if (error instanceof CrudHttpError) return error.status === 404 + if (error instanceof Error) return /not found/i.test(error.message) + return false +} + +function isDuplicateKeyError(error: unknown): boolean { + return Boolean( + error + && typeof error === 'object' + && ( + (typeof (error as { code?: unknown }).code === 'string' && (error as { code: string }).code === '23505') + || (typeof (error as { message?: unknown }).message === 'string' + && (error as { message: string }).message.toLowerCase().includes('duplicate key')) + ) + ) +} + +async function emitMappingEvent( + eventId: 'example_customers_sync.mapping.created' | 'example_customers_sync.mapping.updated' | 'example_customers_sync.mapping.deleted', + mapping: Pick< + ExampleCustomerInteractionMapping, + 'id' | 'interactionId' | 'todoId' | 'organizationId' | 'tenantId' | 'syncStatus' | 'lastSyncedAt' | 'lastError' | 'sourceUpdatedAt' + >, +): Promise { + await emitExampleCustomersSyncEvent( + eventId, + { + id: mapping.id, + interactionId: mapping.interactionId, + todoId: mapping.todoId, + organizationId: mapping.organizationId, + tenantId: mapping.tenantId, + syncStatus: mapping.syncStatus, + lastSyncedAt: mapping.lastSyncedAt?.toISOString() ?? null, + lastError: mapping.lastError ?? null, + sourceUpdatedAt: mapping.sourceUpdatedAt?.toISOString() ?? null, + }, + { persistent: true }, + ).catch(() => undefined) +} + +async function emitSyncFailedEvent(payload: { + scope: ExampleCustomersSyncScope + interactionId?: string | null + todoId?: string | null + error: string + direction: 'outbound' | 'inbound' + eventId: string +}): Promise { + await emitExampleCustomersSyncEvent( + 'example_customers_sync.sync.failed', + { + interactionId: payload.interactionId ?? null, + todoId: payload.todoId ?? null, + organizationId: payload.scope.organizationId, + tenantId: payload.scope.tenantId, + error: payload.error, + direction: payload.direction, + eventId: payload.eventId, + }, + { persistent: true }, + ).catch(() => undefined) +} + +async function updateMappingAfterSync( + em: EntityManager, + input: ExampleCustomersSyncScope & { + interactionId: string + todoId: string + sourceUpdatedAt?: Date | null + }, +): Promise { + const { mapping, created } = await upsertExampleCustomerInteractionMapping(em, { + ...input, + syncStatus: 'synced', + lastSyncedAt: new Date(), + lastError: null, + sourceUpdatedAt: input.sourceUpdatedAt ?? null, + }) + await emitMappingEvent(created ? 'example_customers_sync.mapping.created' : 'example_customers_sync.mapping.updated', mapping) + return mapping +} + +async function markMappingError( + em: EntityManager, + input: { + scope: ExampleCustomersSyncScope + interactionId: string + todoId: string + error: string + mapping: ExampleCustomerInteractionMapping | null + sourceUpdatedAt?: Date | null + }, +): Promise { + if (input.mapping) { + input.mapping.syncStatus = 'error' + input.mapping.lastError = input.error + input.mapping.updatedAt = new Date() + await em.flush() + await emitMappingEvent('example_customers_sync.mapping.updated', input.mapping) + return input.mapping + } + + const { mapping, created } = await upsertExampleCustomerInteractionMapping(em, { + ...input.scope, + interactionId: input.interactionId, + todoId: input.todoId, + syncStatus: 'error', + lastSyncedAt: null, + lastError: input.error, + sourceUpdatedAt: input.sourceUpdatedAt ?? null, + }) + await emitMappingEvent(created ? 'example_customers_sync.mapping.created' : 'example_customers_sync.mapping.updated', mapping) + return mapping +} + +export function resolveInboundInteractionSyncStrategy(input: { + currentStatus?: string | null + isDone: boolean +}): { + updateStatusInCommand: boolean + lifecycleCommandId: 'customers.interactions.complete' | null +} { + if (input.isDone) { + return { + updateStatusInCommand: false, + lifecycleCommandId: input.currentStatus === 'done' ? null : 'customers.interactions.complete', + } + } + return { + updateStatusInCommand: true, + lifecycleCommandId: null, + } +} + +export function resolveMappingTodoIdForSyncFailure(input: { + interactionId: string + mappingTodoId?: string | null +}): string { + return typeof input.mappingTodoId === 'string' && input.mappingTodoId.length > 0 + ? input.mappingTodoId + : input.interactionId +} + +async function loadCanonicalInteractionRecord( + container: ContainerLike, + scope: ExampleCustomersSyncScope, + interactionId: string, +): Promise { + const em = (container.resolve('em') as EntityManager).fork() + const interaction = await findOneWithDecryption( + em, + CustomerInteraction, + { + id: interactionId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + deletedAt: null, + }, + undefined, + scope, + ) + if (!interaction) return null + const [record] = await hydrateCanonicalInteractions({ + em, + container, + auth: { + tenantId: scope.tenantId, + orgId: scope.organizationId, + sub: `system:${EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN}`, + }, + selectedOrganizationId: scope.organizationId, + interactions: [interaction], + enrich: false, + }) + return record ?? null +} + +async function loadExampleTodoSnapshot( + em: EntityManager, + scope: ExampleCustomersSyncScope, + todoId: string, +): Promise { + const todo = await findOneWithDecryption( + em, + Todo, + { + id: todoId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + deletedAt: null, + }, + undefined, + scope, + ) + if (!todo) return null + const customValues = await loadCustomFieldSnapshot(em, { + entityId: E.example.todo, + recordId: todo.id, + tenantId: todo.tenantId ?? null, + organizationId: todo.organizationId ?? null, + }) + return { + id: todo.id, + title: todo.title, + isDone: todo.isDone, + updatedAt: todo.updatedAt ?? null, + customValues: Object.keys(customValues).length > 0 ? customValues : null, + } +} + +async function deleteMappedExampleTodo(params: { + container: ContainerLike + scope: ExampleCustomersSyncScope + mapping: ExampleCustomerInteractionMapping +}): Promise { + const commandBus = params.container.resolve('commandBus') as CommandBus + const commandContext = buildExampleCustomersSyncCommandContext( + params.container, + params.scope, + EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN, + ) + try { + await commandBus.execute<{ id: string }, Todo>('example.todos.delete', { + input: { id: params.mapping.todoId }, + ctx: commandContext, + }) + } catch (error) { + if (!isNotFoundError(error)) throw error + } + const em = (params.container.resolve('em') as EntityManager).fork() + const existing = await findMappingByInteractionId(em, params.scope, params.mapping.interactionId) + const deleted = await deleteExampleCustomerInteractionMapping(em, existing) + if (deleted && existing) { + await emitMappingEvent('example_customers_sync.mapping.deleted', existing) + } +} + +function resolveLegacyLinkEntityId( + link: CustomerTodoLink, +): string | null { + const entityRef = link.entity as { id?: string } | string | null | undefined + if (typeof entityRef === 'string' && entityRef.trim().length > 0) return entityRef + if (entityRef && typeof entityRef === 'object' && typeof entityRef.id === 'string' && entityRef.id.trim().length > 0) { + return entityRef.id + } + return null +} + +async function loadLegacyExampleTodoLinkRow( + em: EntityManager, + scope: ExampleCustomersSyncScope, + todoId: string, +): Promise { + const link = await findOneWithDecryption( + em, + CustomerTodoLink, + { + todoId, + todoSource: 'example:todo', + tenantId: scope.tenantId, + organizationId: scope.organizationId, + }, + undefined, + scope, + ) + if (!link) return null + const entityId = resolveLegacyLinkEntityId(link) + if (!entityId) return null + return { + id: link.id, + entityId, + todoId: link.todoId, + createdByUserId: link.createdByUserId ?? null, + createdAt: link.createdAt, + } +} + +async function ensureLegacyExampleMapping( + em: EntityManager, + scope: ExampleCustomersSyncScope, + interactionId: string, +): Promise { + const legacyLink = await findOneWithDecryption( + em, + CustomerTodoLink, + { + todoId: interactionId, + todoSource: 'example:todo', + tenantId: scope.tenantId, + organizationId: scope.organizationId, + }, + undefined, + scope, + ) + if (!legacyLink) return null + return await updateMappingAfterSync(em, { + ...scope, + interactionId, + todoId: legacyLink.todoId, + sourceUpdatedAt: legacyLink.createdAt ?? null, + }) +} + +export async function syncCustomerInteractionToExampleTodo( + container: ContainerLike, + payload: ExampleCustomersSyncOutboundJobPayload, +): Promise { + const scope = { tenantId: payload.tenantId, organizationId: payload.organizationId } + const flags = await resolveExampleCustomersSyncFlags(container, scope.tenantId) + if (!flags.enabled) return + + const em = (container.resolve('em') as EntityManager).fork() + let mapping = await findMappingByInteractionId(em, scope, payload.interactionId) + + try { + const interaction = await loadCanonicalInteractionRecord(container, scope, payload.interactionId) + + if (!interaction) { + if (mapping) { + await deleteMappedExampleTodo({ container, scope, mapping }) + } + return + } + if (interaction.interactionType !== CUSTOMER_INTERACTION_TASK_TYPE) return + + if (!mapping && interaction.source === CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE) { + mapping = await ensureLegacyExampleMapping(em, scope, interaction.id) + } + + if (interaction.status === 'canceled' || payload.eventId === 'customers.interaction.deleted') { + if (mapping) { + await deleteMappedExampleTodo({ container, scope, mapping }) + } + return + } + + const commandBus = container.resolve('commandBus') as CommandBus + const commandContext = buildExampleCustomersSyncCommandContext( + container, + scope, + EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN, + ) + const title = + typeof interaction.title === 'string' && interaction.title.trim().length > 0 + ? interaction.title.trim() + : DEFAULT_TASK_TITLE + const customValues = buildExampleTodoCustomValuesFromInteraction(interaction, { + includeClears: !!mapping, + }) + const sourceUpdatedAt = parseDateOrNull(interaction.updatedAt) + + if (mapping) { + try { + await commandBus.execute, Todo>('example.todos.update', { + input: { + id: mapping.todoId, + title, + is_done: interaction.status === 'done', + ...(Object.keys(customValues).length > 0 ? { customValues } : {}), + }, + ctx: commandContext, + }) + await updateMappingAfterSync(em, { + ...scope, + interactionId: interaction.id, + todoId: mapping.todoId, + sourceUpdatedAt, + }) + return + } catch (error) { + if (!isNotFoundError(error)) throw error + } + } + + try { + const createResult = await commandBus.execute, Todo>('example.todos.create', { + input: { + id: interaction.id, + title, + is_done: interaction.status === 'done', + ...(Object.keys(customValues).length > 0 ? { customValues } : {}), + }, + ctx: commandContext, + }) + + await updateMappingAfterSync(em, { + ...scope, + interactionId: interaction.id, + todoId: String(createResult.result.id), + sourceUpdatedAt, + }) + } catch (error) { + if (!isDuplicateKeyError(error)) throw error + const existingTodo = await loadExampleTodoSnapshot(em, scope, interaction.id) + if (!existingTodo) throw error + await commandBus.execute, Todo>('example.todos.update', { + input: { + id: existingTodo.id, + title, + is_done: interaction.status === 'done', + ...(Object.keys(customValues).length > 0 ? { customValues } : {}), + }, + ctx: commandContext, + }) + await updateMappingAfterSync(em, { + ...scope, + interactionId: interaction.id, + todoId: existingTodo.id, + sourceUpdatedAt, + }) + } + } catch (error) { + const message = trimErrorMessage(error) + const erroredMapping = await markMappingError(em, { + scope, + interactionId: payload.interactionId, + todoId: resolveMappingTodoIdForSyncFailure({ + interactionId: payload.interactionId, + mappingTodoId: mapping?.todoId, + }), + error: message, + mapping, + }) + await emitSyncFailedEvent({ + scope, + interactionId: payload.interactionId, + todoId: erroredMapping.todoId, + error: message, + direction: 'outbound', + eventId: payload.eventId, + }) + throw error + } +} + +export async function syncExampleTodoToCanonicalInteraction( + container: ContainerLike, + payload: ExampleCustomersSyncInboundJobPayload, +): Promise { + const scope = { tenantId: payload.tenantId, organizationId: payload.organizationId } + const flags = await resolveExampleCustomersSyncFlags(container, scope.tenantId) + if (!flags.enabled || !flags.bidirectional) return + + const em = (container.resolve('em') as EntityManager).fork() + let mapping = await findMappingByTodoId(em, scope, payload.todoId) + let todo: ExampleTodoSnapshot | null = null + if (!mapping && payload.eventId !== 'example.todo.deleted') { + mapping = await ensureMappingForLegacyExampleTodo(container, scope, payload.todoId) + } + if (!mapping) return + + try { + const commandBus = container.resolve('commandBus') as CommandBus + const commandContext = buildExampleCustomersSyncCommandContext( + container, + scope, + EXAMPLE_CUSTOMERS_SYNC_INBOUND_ORIGIN, + ) + + if (payload.eventId === 'example.todo.deleted') { + try { + await commandBus.execute, { interactionId: string }>('customers.interactions.delete', { + input: { body: { id: mapping.interactionId } }, + ctx: commandContext, + }) + } catch (error) { + if (!isNotFoundError(error)) throw error + } + const deleted = await deleteExampleCustomerInteractionMapping(em, mapping) + if (deleted) { + await emitMappingEvent('example_customers_sync.mapping.deleted', mapping) + } + return + } + + todo = await loadExampleTodoSnapshot(em, scope, mapping.todoId) + if (!todo) { + try { + await commandBus.execute, { interactionId: string }>('customers.interactions.delete', { + input: { body: { id: mapping.interactionId } }, + ctx: commandContext, + }) + } catch (error) { + if (!isNotFoundError(error)) throw error + } + const deleted = await deleteExampleCustomerInteractionMapping(em, mapping) + if (deleted) { + await emitMappingEvent('example_customers_sync.mapping.deleted', mapping) + } + return + } + + const interaction = await findOneWithDecryption( + em, + CustomerInteraction, + { + id: mapping.interactionId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + deletedAt: null, + }, + undefined, + scope, + ) + if (!interaction) { + const deleted = await deleteExampleCustomerInteractionMapping(em, mapping) + if (deleted) { + await emitMappingEvent('example_customers_sync.mapping.deleted', mapping) + } + return + } + + const patch = buildInteractionUpdateFromExampleTodo({ + title: todo.title, + isDone: todo.isDone, + customValues: todo.customValues, + occurredAt: todo.isDone ? (todo.updatedAt ?? new Date()) : null, + }, { + includeClears: true, + }) + const strategy = resolveInboundInteractionSyncStrategy({ + currentStatus: interaction.status, + isDone: todo.isDone, + }) + const customValuesInput = Object.keys(patch.customValues).length > 0 + ? { customValues: patch.customValues } + : {} + + await commandBus.execute, { interactionId: string }>('customers.interactions.update', { + input: { + id: mapping.interactionId, + title: patch.title, + priority: patch.priority, + body: patch.body, + ...customValuesInput, + ...(strategy.updateStatusInCommand ? { + status: patch.status, + occurredAt: patch.occurredAt, + } : {}), + }, + ctx: commandContext, + }) + + if (strategy.lifecycleCommandId === 'customers.interactions.complete') { + await commandBus.execute, { interactionId: string }>('customers.interactions.complete', { + input: { + id: mapping.interactionId, + ...(patch.occurredAt ? { occurredAt: patch.occurredAt } : {}), + }, + ctx: commandContext, + }) + } + + await updateMappingAfterSync(em, { + ...scope, + interactionId: mapping.interactionId, + todoId: mapping.todoId, + sourceUpdatedAt: todo.updatedAt ?? null, + }) + } catch (error) { + const message = trimErrorMessage(error) + const erroredMapping = await markMappingError(em, { + scope, + interactionId: mapping.interactionId, + todoId: mapping.todoId, + error: message, + mapping, + sourceUpdatedAt: todo?.updatedAt ?? null, + }) + await emitSyncFailedEvent({ + scope, + interactionId: erroredMapping.interactionId, + todoId: erroredMapping.todoId, + error: message, + direction: 'inbound', + eventId: payload.eventId, + }) + throw error + } +} + +function encodeCursor(payload: CursorPayload): string { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') +} + +function decodeCursor(token: string | undefined): CursorPayload | null { + if (!token) return null + try { + const parsed = JSON.parse(Buffer.from(token, 'base64').toString('utf8')) as CursorPayload + if (typeof parsed.id !== 'string' || typeof parsed.createdAt !== 'string') return null + return parsed + } catch { + /* malformed cursor token — treat as no cursor */ + return null + } +} + +async function loadLegacyExampleTodoLinks( + container: ContainerLike, + scope: ExampleCustomersSyncScope, + limit: number, + cursor?: string, +): Promise<{ rows: LegacyExampleTodoLinkRow[]; nextCursor?: string }> { + const em = (container.resolve('em') as EntityManager).fork() + const knex = em.getKnex() + const parsedCursor = decodeCursor(cursor) + const query = knex('customer_todo_links') + .select([ + 'id', + 'entity_id as entityId', + 'todo_id as todoId', + 'created_by_user_id as createdByUserId', + 'created_at as createdAt', + ]) + .where({ + tenant_id: scope.tenantId, + organization_id: scope.organizationId, + todo_source: 'example:todo', + }) + .orderBy('created_at', 'asc') + .orderBy('id', 'asc') + .limit(limit + 1) + + if (parsedCursor) { + query.andWhere(function applyCursor() { + this.where('created_at', '>', new Date(parsedCursor.createdAt)).orWhere(function applyTieBreaker() { + this.where('created_at', new Date(parsedCursor.createdAt)).andWhere('id', '>', parsedCursor.id) + }) + }) + } + + const rows = await query + const pageRows = rows.slice(0, limit) + const next = rows.length > limit ? pageRows[pageRows.length - 1] : null + return { + rows: pageRows, + ...(next ? { nextCursor: encodeCursor({ createdAt: next.createdAt.toISOString(), id: next.id }) } : {}), + } +} + +async function ensureCanonicalInteractionForLegacyLink( + container: ContainerLike, + scope: ExampleCustomersSyncScope, + link: LegacyExampleTodoLinkRow, +): Promise<{ interactionId: string; created: boolean } | null> { + const em = (container.resolve('em') as EntityManager).fork() + const existing = await findOneWithDecryption( + em, + CustomerInteraction, + { + id: link.todoId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + deletedAt: null, + }, + undefined, + scope, + ) + if (existing) { + return { interactionId: existing.id, created: false } + } + + const todo = await loadExampleTodoSnapshot(em, scope, link.todoId) + if (!todo) return null + + const patch = buildInteractionUpdateFromExampleTodo({ + title: todo.title, + isDone: todo.isDone, + customValues: todo.customValues, + occurredAt: todo.isDone ? (todo.updatedAt ?? link.createdAt) : null, + }) + + const commandBus = container.resolve('commandBus') as CommandBus + const commandContext = buildExampleCustomersSyncCommandContext( + container as never, + scope, + EXAMPLE_CUSTOMERS_SYNC_INBOUND_ORIGIN, + ) + try { + const result = await commandBus.execute, { interactionId: string }>('customers.interactions.create', { + input: { + id: link.todoId, + entityId: link.entityId, + interactionType: CUSTOMER_INTERACTION_TASK_TYPE, + title: patch.title, + status: patch.status, + occurredAt: patch.occurredAt, + priority: patch.priority, + body: patch.body, + source: CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE, + authorUserId: link.createdByUserId ?? null, + ...(Object.keys(patch.customValues).length > 0 ? { customValues: patch.customValues } : {}), + }, + ctx: commandContext, + }) + return { interactionId: result.result.interactionId, created: true } + } catch (error) { + if (!isDuplicateKeyError(error)) throw error + const existingAfterDuplicate = await findOneWithDecryption( + em, + CustomerInteraction, + { + id: link.todoId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + deletedAt: null, + }, + undefined, + scope, + ) + if (!existingAfterDuplicate) throw error + return { interactionId: existingAfterDuplicate.id, created: false } + } +} + +async function ensureMappingForLegacyExampleTodo( + container: ContainerLike, + scope: ExampleCustomersSyncScope, + todoId: string, +): Promise { + const em = (container.resolve('em') as EntityManager).fork() + const legacyLink = await loadLegacyExampleTodoLinkRow(em, scope, todoId) + if (!legacyLink) return null + const canonical = await ensureCanonicalInteractionForLegacyLink(container, scope, legacyLink) + if (!canonical) return null + const todo = await loadExampleTodoSnapshot(em, scope, todoId) + return await updateMappingAfterSync(em, { + ...scope, + interactionId: canonical.interactionId, + todoId, + sourceUpdatedAt: todo?.updatedAt ?? legacyLink.createdAt, + }) +} + +export async function reconcileLegacyExampleTodoLinks( + container: ContainerLike, + input: ExampleCustomersSyncScope & { limit?: number; cursor?: string }, +): Promise { + const scope = { tenantId: input.tenantId, organizationId: input.organizationId } + const limit = Math.min(Math.max(input.limit ?? 100, 1), 500) + const { rows, nextCursor } = await loadLegacyExampleTodoLinks(container, scope, limit, input.cursor) + const em = (container.resolve('em') as EntityManager).fork() + const items: ExampleCustomersSyncReconcileItem[] = [] + let mapped = 0 + let createdInteractions = 0 + let failed = 0 + + for (const row of rows) { + try { + const mapping = + await findMappingByTodoId(em, scope, row.todoId) + ?? await findMappingByInteractionId(em, scope, row.todoId) + const canonical = await ensureCanonicalInteractionForLegacyLink(container, scope, row) + if (!canonical) { + items.push({ + linkId: row.id, + todoId: row.todoId, + interactionId: null, + status: 'skipped', + message: 'Example todo not found', + }) + continue + } + + const todo = await loadExampleTodoSnapshot(em, scope, row.todoId) + const updatedMapping = await updateMappingAfterSync(em, { + ...scope, + interactionId: canonical.interactionId, + todoId: row.todoId, + sourceUpdatedAt: todo?.updatedAt ?? row.createdAt, + }) + items.push({ + linkId: row.id, + todoId: row.todoId, + interactionId: updatedMapping.interactionId, + status: canonical.created ? 'created_interaction' : 'mapped', + message: mapping ? 'Updated existing mapping' : null, + }) + mapped += 1 + if (canonical.created) createdInteractions += 1 + } catch (error) { + failed += 1 + items.push({ + linkId: row.id, + todoId: row.todoId, + interactionId: null, + status: 'failed', + message: trimErrorMessage(error), + }) + } + } + + return { + items, + processed: rows.length, + mapped, + createdInteractions, + failed, + ...(nextCursor ? { nextCursor } : {}), + } +} + +export function shouldEnqueueOutboundSync(payload: { + id?: string | null + interactionType?: string | null + tenantId?: string | null + organizationId?: string | null + syncOrigin?: string | null +}): payload is { + id: string + interactionType: string + tenantId: string + organizationId: string + syncOrigin?: string | null +} { + return ( + typeof payload.id === 'string' + && typeof payload.tenantId === 'string' + && typeof payload.organizationId === 'string' + && isTaskEventPayload(payload) + && !isSyncOriginFromBridge(payload.syncOrigin) + ) +} + +export function shouldEnqueueInboundSync(payload: { + id?: string | null + tenantId?: string | null + organizationId?: string | null + syncOrigin?: string | null +}): payload is { + id: string + tenantId: string + organizationId: string + syncOrigin?: string | null +} { + return ( + typeof payload.id === 'string' + && typeof payload.tenantId === 'string' + && typeof payload.organizationId === 'string' + && !isSyncOriginFromBridge(payload.syncOrigin) + ) +} diff --git a/apps/mercato/src/modules/example_customers_sync/lib/toggles.ts b/apps/mercato/src/modules/example_customers_sync/lib/toggles.ts new file mode 100644 index 00000000000..6334ceb26fc --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/lib/toggles.ts @@ -0,0 +1,59 @@ +type ContainerLike = { + resolve: (name: string) => unknown +} + +type FeatureToggleResult = { + ok: boolean + value?: boolean +} + +type FeatureToggleServiceLike = { + getBoolConfig: (identifier: string, tenantId: string) => Promise +} + +export const exampleCustomersSyncFeatureIds = { + enabled: 'example.customers_sync.enabled', + bidirectional: 'example.customers_sync.bidirectional', +} as const + +export type ExampleCustomersSyncFlags = { + enabled: boolean + bidirectional: boolean +} + +async function resolveBooleanFeature( + service: FeatureToggleServiceLike | null, + tenantId: string | null | undefined, + identifier: string, + fallback: boolean, +): Promise { + if (!service || !tenantId) return fallback + try { + const result = await service.getBoolConfig(identifier, tenantId) + if (result.ok && typeof result.value === 'boolean') return result.value + } catch { + /* service unavailable or misconfigured — fall back to default */ + return fallback + } + return fallback +} + +function resolveFeatureToggleService(container: ContainerLike): FeatureToggleServiceLike | null { + try { + return container.resolve('featureTogglesService') as FeatureToggleServiceLike + } catch { + /* service not registered — module may be disabled or DI not yet wired */ + return null + } +} + +export async function resolveExampleCustomersSyncFlags( + container: ContainerLike, + tenantId: string | null | undefined, +): Promise { + const service = resolveFeatureToggleService(container) + return { + enabled: await resolveBooleanFeature(service, tenantId, exampleCustomersSyncFeatureIds.enabled, false), + bidirectional: await resolveBooleanFeature(service, tenantId, exampleCustomersSyncFeatureIds.bidirectional, false), + } +} diff --git a/apps/mercato/src/modules/example_customers_sync/migrations/.snapshot-open-mercato.json b/apps/mercato/src/modules/example_customers_sync/migrations/.snapshot-open-mercato.json new file mode 100644 index 00000000000..7f0a11495e7 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/migrations/.snapshot-open-mercato.json @@ -0,0 +1,172 @@ +{ + "namespaces": [ + "public" + ], + "name": "public", + "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "gen_random_uuid()", + "mappedType": "uuid" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "interaction_id": { + "name": "interaction_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "todo_id": { + "name": "todo_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "'pending'", + "mappedType": "text" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + }, + "last_error": { + "name": "last_error", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "source_updated_at": { + "name": "source_updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "example_customer_interaction_mappings", + "schema": "public", + "indexes": [ + { + "keyName": "example_customer_interaction_mappings_status_idx", + "columnNames": [ + "organization_id", + "tenant_id", + "sync_status", + "updated_at" + ], + "composite": true, + "constraint": false, + "primary": false, + "unique": false + }, + { + "keyName": "example_customer_interaction_mappings_todo_unique", + "columnNames": [ + "organization_id", + "tenant_id", + "todo_id" + ], + "composite": true, + "constraint": true, + "primary": false, + "unique": true + }, + { + "keyName": "example_customer_interaction_mappings_interaction_unique", + "columnNames": [ + "organization_id", + "tenant_id", + "interaction_id" + ], + "composite": true, + "constraint": true, + "primary": false, + "unique": true + }, + { + "keyName": "example_customer_interaction_mappings_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + } + ], + "nativeEnums": {} +} diff --git a/apps/mercato/src/modules/example_customers_sync/migrations/Migration20260401173723.ts b/apps/mercato/src/modules/example_customers_sync/migrations/Migration20260401173723.ts new file mode 100644 index 00000000000..c3877b6cf58 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/migrations/Migration20260401173723.ts @@ -0,0 +1,89 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260401173723 extends Migration { + + override async up(): Promise { + this.addSql(`create table "example_customer_interaction_mappings" ("id" uuid not null default gen_random_uuid(), "organization_id" uuid not null, "tenant_id" uuid not null, "interaction_id" uuid not null, "todo_id" uuid not null, "sync_status" text not null default 'pending', "last_synced_at" timestamptz null, "last_error" text null, "source_updated_at" timestamptz null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "example_customer_interaction_mappings_pkey" primary key ("id"));`); + this.addSql(`create index "example_customer_interaction_mappings_status_idx" on "example_customer_interaction_mappings" ("organization_id", "tenant_id", "sync_status", "updated_at");`); + this.addSql(`alter table "example_customer_interaction_mappings" add constraint "example_customer_interaction_mappings_todo_unique" unique ("organization_id", "tenant_id", "todo_id");`); + this.addSql(`alter table "example_customer_interaction_mappings" add constraint "example_customer_interaction_mappings_interaction_unique" unique ("organization_id", "tenant_id", "interaction_id");`); + this.addSql(` + do $$ + begin + if exists ( + select 1 + from information_schema.tables + where table_schema = current_schema() + and table_name = 'feature_toggles' + ) then + insert into "feature_toggles" ("identifier", "name", "description", "category", "default_value", "type", "created_at", "updated_at") + select 'example.customers_sync.enabled', 'Example Customers Sync Enabled', 'When enabled, canonical customer tasks are synced to the example todo module.', 'example', 'false'::jsonb, 'boolean', now(), now() + where not exists ( + select 1 + from "feature_toggles" + where "identifier" = 'example.customers_sync.enabled' + and "deleted_at" is null + ); + + insert into "feature_toggles" ("identifier", "name", "description", "category", "default_value", "type", "created_at", "updated_at") + select 'example.customers_sync.bidirectional', 'Example Customers Sync Bidirectional', 'When enabled, updates from the example todo module sync back to canonical customer tasks.', 'example', 'false'::jsonb, 'boolean', now(), now() + where not exists ( + select 1 + from "feature_toggles" + where "identifier" = 'example.customers_sync.bidirectional' + and "deleted_at" is null + ); + end if; + end + $$; + `); + } + + override async down(): Promise { + this.addSql(` + do $$ + begin + if exists ( + select 1 + from information_schema.tables + where table_schema = current_schema() + and table_name = 'feature_toggles' + ) then + if exists ( + select 1 + from information_schema.tables + where table_schema = current_schema() + and table_name = 'feature_toggle_overrides' + ) then + delete from "feature_toggle_overrides" + where "toggle_id" in ( + select "id" + from "feature_toggles" + where "identifier" in ('example.customers_sync.enabled', 'example.customers_sync.bidirectional') + ); + end if; + + if exists ( + select 1 + from information_schema.tables + where table_schema = current_schema() + and table_name = 'feature_toggle_audit_logs' + ) then + delete from "feature_toggle_audit_logs" + where "toggle_id" in ( + select "id" + from "feature_toggles" + where "identifier" in ('example.customers_sync.enabled', 'example.customers_sync.bidirectional') + ); + end if; + + delete from "feature_toggles" + where "identifier" in ('example.customers_sync.enabled', 'example.customers_sync.bidirectional'); + end if; + end + $$; + `); + this.addSql(`drop table if exists "example_customer_interaction_mappings" cascade;`); + } + +} diff --git a/apps/mercato/src/modules/example_customers_sync/setup.ts b/apps/mercato/src/modules/example_customers_sync/setup.ts new file mode 100644 index 00000000000..d55f1565223 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/setup.ts @@ -0,0 +1,51 @@ +import type { EntityManager } from '@mikro-orm/postgresql' +import { FeatureToggle } from '@open-mercato/core/modules/feature_toggles/data/entities' +import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup' + +const syncFeatureToggles = [ + { + identifier: 'example.customers_sync.enabled', + name: 'Example Customers Sync Enabled', + description: 'When enabled, canonical customer tasks are synced to the example todo module.', + category: 'example', + type: 'boolean' as const, + defaultValue: false, + }, + { + identifier: 'example.customers_sync.bidirectional', + name: 'Example Customers Sync Bidirectional', + description: 'When enabled, updates from the example todo module sync back to canonical customer tasks.', + category: 'example', + type: 'boolean' as const, + defaultValue: false, + }, +] as const + +async function seedSyncFeatureToggles(em: EntityManager): Promise { + for (const toggle of syncFeatureToggles) { + const existing = await em.findOne(FeatureToggle, { identifier: toggle.identifier, deletedAt: null }) + if (existing) continue + const entity = em.create(FeatureToggle, { + identifier: toggle.identifier, + name: toggle.name, + description: toggle.description, + category: toggle.category, + type: toggle.type, + defaultValue: toggle.defaultValue, + }) + em.persist(entity) + } + await em.flush() +} + +export const setup: ModuleSetupConfig = { + async seedDefaults({ em }) { + await seedSyncFeatureToggles(em) + }, + defaultRoleFeatures: { + superadmin: ['example_customers_sync.*'], + admin: ['example_customers_sync.*'], + }, +} + +export default setup diff --git a/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-canceled.ts b/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-canceled.ts new file mode 100644 index 00000000000..94bde3865d5 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-canceled.ts @@ -0,0 +1,9 @@ +import { createOutboundSubscriber } from '../lib/outbound-subscriber' + +export const metadata = { + event: 'customers.interaction.canceled', + persistent: true, + id: 'example-customers-sync:customers-interaction-canceled', +} + +export default createOutboundSubscriber(metadata.event) diff --git a/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-completed.ts b/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-completed.ts new file mode 100644 index 00000000000..a421fc523c3 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-completed.ts @@ -0,0 +1,9 @@ +import { createOutboundSubscriber } from '../lib/outbound-subscriber' + +export const metadata = { + event: 'customers.interaction.completed', + persistent: true, + id: 'example-customers-sync:customers-interaction-completed', +} + +export default createOutboundSubscriber(metadata.event) diff --git a/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-created.ts b/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-created.ts new file mode 100644 index 00000000000..b6192ce44d4 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-created.ts @@ -0,0 +1,9 @@ +import { createOutboundSubscriber } from '../lib/outbound-subscriber' + +export const metadata = { + event: 'customers.interaction.created', + persistent: true, + id: 'example-customers-sync:customers-interaction-created', +} + +export default createOutboundSubscriber(metadata.event) diff --git a/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-deleted.ts b/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-deleted.ts new file mode 100644 index 00000000000..0a36dc629a2 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-deleted.ts @@ -0,0 +1,9 @@ +import { createOutboundSubscriber } from '../lib/outbound-subscriber' + +export const metadata = { + event: 'customers.interaction.deleted', + persistent: true, + id: 'example-customers-sync:customers-interaction-deleted', +} + +export default createOutboundSubscriber(metadata.event) diff --git a/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-updated.ts b/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-updated.ts new file mode 100644 index 00000000000..be2c387a261 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/subscribers/customers-interaction-updated.ts @@ -0,0 +1,9 @@ +import { createOutboundSubscriber } from '../lib/outbound-subscriber' + +export const metadata = { + event: 'customers.interaction.updated', + persistent: true, + id: 'example-customers-sync:customers-interaction-updated', +} + +export default createOutboundSubscriber(metadata.event) diff --git a/apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-created.ts b/apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-created.ts new file mode 100644 index 00000000000..27d2efe199c --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-created.ts @@ -0,0 +1,9 @@ +import { createInboundSubscriber } from '../lib/inbound-subscriber' + +export const metadata = { + event: 'example.todo.created', + persistent: true, + id: 'example-customers-sync:example-todo-created', +} + +export default createInboundSubscriber(metadata.event) diff --git a/apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-deleted.ts b/apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-deleted.ts new file mode 100644 index 00000000000..55ebe5f20c3 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-deleted.ts @@ -0,0 +1,9 @@ +import { createInboundSubscriber } from '../lib/inbound-subscriber' + +export const metadata = { + event: 'example.todo.deleted', + persistent: true, + id: 'example-customers-sync:example-todo-deleted', +} + +export default createInboundSubscriber(metadata.event) diff --git a/apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-updated.ts b/apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-updated.ts new file mode 100644 index 00000000000..29744e00e4e --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/subscribers/example-todo-updated.ts @@ -0,0 +1,9 @@ +import { createInboundSubscriber } from '../lib/inbound-subscriber' + +export const metadata = { + event: 'example.todo.updated', + persistent: true, + id: 'example-customers-sync:example-todo-updated', +} + +export default createInboundSubscriber(metadata.event) diff --git a/apps/mercato/src/modules/example_customers_sync/workers/inbound.ts b/apps/mercato/src/modules/example_customers_sync/workers/inbound.ts new file mode 100644 index 00000000000..4a3814df81c --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/workers/inbound.ts @@ -0,0 +1,23 @@ +import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue' +import { EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE } from '../lib/queue' +import { + syncExampleTodoToCanonicalInteraction, + type ExampleCustomersSyncInboundJobPayload, +} from '../lib/sync' + +export const metadata: WorkerMeta = { + queue: EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE, + id: 'example-customers-sync:inbound', + concurrency: 5, +} + +type HandlerContext = JobContext & { + resolve: (name: string) => T +} + +export default async function handle( + job: QueuedJob, + ctx: HandlerContext, +): Promise { + await syncExampleTodoToCanonicalInteraction(ctx, job.payload) +} diff --git a/apps/mercato/src/modules/example_customers_sync/workers/outbound.ts b/apps/mercato/src/modules/example_customers_sync/workers/outbound.ts new file mode 100644 index 00000000000..addca625265 --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/workers/outbound.ts @@ -0,0 +1,21 @@ +import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue' +import { EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE } from '../lib/queue' +import type { ExampleCustomersSyncOutboundJobPayload } from '../lib/sync' + +export const metadata: WorkerMeta = { + queue: EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE, + id: 'example-customers-sync:outbound', + concurrency: 5, +} + +type HandlerContext = JobContext & { + resolve: (name: string) => T +} + +export default async function handle( + job: QueuedJob, + ctx: HandlerContext, +): Promise { + const { syncCustomerInteractionToExampleTodo } = await import('../lib/sync') + await syncCustomerInteractionToExampleTodo(ctx, job.payload) +} diff --git a/apps/mercato/src/modules/example_customers_sync/workers/reconcile.ts b/apps/mercato/src/modules/example_customers_sync/workers/reconcile.ts new file mode 100644 index 00000000000..20b7acbd5bd --- /dev/null +++ b/apps/mercato/src/modules/example_customers_sync/workers/reconcile.ts @@ -0,0 +1,33 @@ +import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue' +import { EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE } from '../lib/queue' +import { + reconcileLegacyExampleTodoLinks, + type ExampleCustomersSyncReconcileJobPayload, +} from '../lib/sync' + +export const metadata: WorkerMeta = { + queue: EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE, + id: 'example-customers-sync:reconcile', + concurrency: 1, +} + +type HandlerContext = JobContext & { + resolve: (name: string) => T +} + +export default async function handle( + job: QueuedJob, + ctx: HandlerContext, +): Promise { + let nextCursor = job.payload.cursor + + do { + const result = await reconcileLegacyExampleTodoLinks(ctx, { + tenantId: job.payload.tenantId, + organizationId: job.payload.organizationId, + limit: job.payload.limit, + cursor: nextCursor, + }) + nextCursor = result.nextCursor + } while (nextCursor) +} diff --git a/packages/cli/src/__tests__/mercato.test.ts b/packages/cli/src/__tests__/mercato.test.ts index c6708b54ce9..57d03af788b 100644 --- a/packages/cli/src/__tests__/mercato.test.ts +++ b/packages/cli/src/__tests__/mercato.test.ts @@ -145,4 +145,65 @@ describe('db command failure output', () => { consoleErrorSpy.mockRestore() consoleLogSpy.mockRestore() }) + + it('does not load app CLI while dispatching built-in db commands', async () => { + const originalFunction = global.Function + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation() + const dbGenerate = jest.fn().mockResolvedValue(undefined) + + try { + ;(global as typeof globalThis & { Function: typeof Function }).Function = jest.fn(() => { + throw new Error('app cli import should not run for built-in db commands') + }) as unknown as typeof Function + + registerCliModules([ + { + id: 'db', + cli: [{ command: 'generate', run: dbGenerate }], + } as any, + ]) + + const exitCode = await run(['node', 'mercato', 'db', 'generate']) + + expect(exitCode).toBe(0) + expect(dbGenerate).toHaveBeenCalled() + } finally { + ;(global as typeof globalThis & { Function: typeof Function }).Function = originalFunction + consoleErrorSpy.mockRestore() + consoleLogSpy.mockRestore() + } + }) + + it('does not import the DI container module while dispatching built-in db commands', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation() + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation() + + try { + jest.resetModules() + jest.doMock('@open-mercato/shared/lib/di/container', () => { + throw new Error('di container should stay lazy for built-in db commands') + }) + + const mercato = await import('../mercato') + const dbGenerate = jest.fn().mockResolvedValue(undefined) + + mercato.registerCliModules([ + { + id: 'db', + cli: [{ command: 'generate', run: dbGenerate }], + } as any, + ]) + + const exitCode = await mercato.run(['node', 'mercato', 'db', 'generate']) + + expect(exitCode).toBe(0) + expect(dbGenerate).toHaveBeenCalled() + } finally { + jest.dontMock('@open-mercato/shared/lib/di/container') + jest.resetModules() + consoleErrorSpy.mockRestore() + consoleLogSpy.mockRestore() + } + }) }) diff --git a/packages/cli/src/lib/__tests__/modules-config.test.ts b/packages/cli/src/lib/__tests__/modules-config.test.ts index 6d559de6b3c..2360b228159 100644 --- a/packages/cli/src/lib/__tests__/modules-config.test.ts +++ b/packages/cli/src/lib/__tests__/modules-config.test.ts @@ -101,4 +101,35 @@ describe('modules-config', () => { expect(result).toEqual({ changed: true }) expect(fs.readFileSync(filePath, 'utf8')).toContain("{ id: 'test_package', from: '@app' }") }) + + it('preserves enabledModules.some() guarded registrations when appending a module', () => { + const filePath = path.join(tmpDir, 'modules.ts') + fs.writeFileSync( + filePath, + [ + "export const enabledModules = [", + " { id: 'customers', from: '@open-mercato/core' },", + " { id: 'example', from: '@app' },", + "]", + '', + "if (enabledModules.some((entry) => entry.id === 'example')) {", + " enabledModules.push({ id: 'example_customers_sync', from: '@app' })", + '}', + ].join('\n'), + ) + + const result = ensureModuleRegistration(filePath, { + id: 'test_package', + from: '@open-mercato/test-package', + }) + + expect(result).toEqual({ + changed: true, + registeredAs: '@open-mercato/test-package', + }) + + const updated = fs.readFileSync(filePath, 'utf8') + expect(updated).toContain("{ id: 'test_package', from: '@open-mercato/test-package' }") + expect(updated).toContain("enabledModules.push({ id: 'example_customers_sync', from: '@app' })") + }) }) diff --git a/packages/cli/src/lib/__tests__/resolver.enterprise.test.ts b/packages/cli/src/lib/__tests__/resolver.enterprise.test.ts index 832692caf71..58c271d0555 100644 --- a/packages/cli/src/lib/__tests__/resolver.enterprise.test.ts +++ b/packages/cli/src/lib/__tests__/resolver.enterprise.test.ts @@ -46,6 +46,27 @@ export const enabledModules: ModuleEntry[] = [ ) } +function writeModulesConfigWithConditionalAppModule(rootDir: string) { + const srcDir = path.join(rootDir, 'src') + fs.mkdirSync(srcDir, { recursive: true }) + fs.writeFileSync( + path.join(srcDir, 'modules.ts'), + ` +export type ModuleEntry = { id: string; from?: '@open-mercato/core' | '@app' | string } + +export const enabledModules: ModuleEntry[] = [ + { id: 'customers', from: '@open-mercato/core' }, + { id: 'example', from: '@app' }, +] + +if (enabledModules.some((entry) => entry.id === 'example')) { + enabledModules.push({ id: 'example_customers_sync', from: '@app' }) +} +`, + 'utf8', + ) +} + describe('resolver enterprise module toggle', () => { const originalEnv = process.env.OM_ENABLE_ENTERPRISE_MODULES const originalResolverMarker = (globalThis as Record).__resolver_evaluated__ @@ -105,4 +126,18 @@ describe('resolver enterprise module toggle', () => { expect(modules).toEqual([{ id: 'customers', from: '@open-mercato/core' }]) expect((globalThis as Record).__resolver_evaluated__).toBeUndefined() }) + + it('loads conditional app modules when enabledModules.some() matches', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'resolver-enterprise-')) + writeModulesConfigWithConditionalAppModule(tempDir) + + const modules = createResolver(tempDir).loadEnabledModules() + expect(modules).toEqual( + expect.arrayContaining([ + { id: 'customers', from: '@open-mercato/core' }, + { id: 'example', from: '@app' }, + { id: 'example_customers_sync', from: '@app' }, + ]), + ) + }) }) diff --git a/packages/cli/src/lib/generators/__tests__/module-subset.test.ts b/packages/cli/src/lib/generators/__tests__/module-subset.test.ts index 978581eeff1..9e2c3e056e4 100644 --- a/packages/cli/src/lib/generators/__tests__/module-subset.test.ts +++ b/packages/cli/src/lib/generators/__tests__/module-subset.test.ts @@ -367,6 +367,32 @@ describe('generateModuleRegistryCli with module subsets', () => { expect(output).toContain('dashboardWidgets:') }) + it('includes app-owned workers in generated module metadata', async () => { + scaffoldModule(tmpDir, 'app_worker_mod', 'app', [ + 'index.ts', + 'workers/process-job.ts', + ]) + touchFile( + path.join(tmpDir, 'app', 'src', 'modules', 'app_worker_mod', 'workers', 'process-job.ts'), + ` +export const metadata = { queue: 'app-worker-queue', id: 'app-worker-mod:process-job', concurrency: 2 } +export default async function handle(): Promise {} + `.trim(), + ) + + const resolver = createMockResolver(tmpDir, [ + { id: 'app_worker_mod', from: '@app' }, + ]) + + const result = await generateModuleRegistry({ resolver, quiet: true }) + + expect(result.errors).toEqual([]) + const output = readGenerated(tmpDir, 'modules.generated.ts')! + expect(output).toContain("id: 'app_worker_mod'") + expect(output).toContain("queue: (WorkerMeta") + expect(output).toContain("workers:") + }) + it('handles disabling a module that was previously enabled', async () => { scaffoldModule(tmpDir, 'keep_mod', 'pkg', [ 'subscribers/handler.ts', diff --git a/packages/cli/src/lib/generators/module-registry.ts b/packages/cli/src/lib/generators/module-registry.ts index 305bf445017..49859f3abed 100644 --- a/packages/cli/src/lib/generators/module-registry.ts +++ b/packages/cli/src/lib/generators/module-registry.ts @@ -270,7 +270,8 @@ async function processWorkers(options: { const file = segs.pop()! const name = file.replace(/\.ts$/, '') const importPath = `${fromApp ? appImportBase : pkgImportBase}/workers/${[...segs, name].join('/')}` - if (!(await moduleHasExport(importPath, 'metadata'))) continue + const sourceFile = path.join(fromApp ? roots.appBase : roots.pkgBase, 'workers', ...segs, file) + if (!(await moduleHasExport(sourceFile, 'metadata'))) continue const importName = `Worker${importIdRef.value++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}` const metaName = `WorkerMeta${importIdRef.value++}_${toVar(modId)}_${toVar([...segs, name].join('_') || 'index')}` imports.push(`import ${importName}, * as ${metaName} from '${importPath}'`) diff --git a/packages/cli/src/lib/modules-config.ts b/packages/cli/src/lib/modules-config.ts index 1d6f54ce5ec..3a8e2d48699 100644 --- a/packages/cli/src/lib/modules-config.ts +++ b/packages/cli/src/lib/modules-config.ts @@ -101,6 +101,14 @@ function evaluateStaticExpressionWithScope( return envAccess.value } + if (ts.isPropertyAccessExpression(node)) { + const target = evaluateStaticExpressionWithScope(node.expression, env, scope) + if (target && typeof target === 'object' && node.name.text in target) { + return (target as Record)[node.name.text] + } + return undefined + } + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) { return !Boolean(evaluateStaticExpressionWithScope(node.operand, env, scope)) } @@ -147,6 +155,42 @@ function evaluateStaticExpressionWithScope( ) } + if ( + ts.isCallExpression(node) + && ts.isPropertyAccessExpression(node.expression) + && node.expression.name.text === 'some' + ) { + const target = evaluateStaticExpressionWithScope(node.expression.expression, env, scope) + const predicate = node.arguments[0] + if (!Array.isArray(target) || !predicate) { + return undefined + } + + return target.some((entry) => { + if (!ts.isArrowFunction(predicate) && !ts.isFunctionExpression(predicate)) { + return false + } + + const localScope = new Map(scope) + const parameter = predicate.parameters[0] + if (parameter && ts.isIdentifier(parameter.name)) { + localScope.set(parameter.name.text, entry) + } + + if (ts.isBlock(predicate.body)) { + const returnStatement = predicate.body.statements.find((statement): statement is ts.ReturnStatement => + ts.isReturnStatement(statement), + ) + if (!returnStatement?.expression) { + return false + } + return Boolean(evaluateStaticExpressionWithScope(returnStatement.expression, env, localScope)) + } + + return Boolean(evaluateStaticExpressionWithScope(predicate.body, env, localScope)) + }) + } + return undefined } @@ -228,6 +272,7 @@ function parseModuleConfigShape( const entry = parseModuleEntryFromObjectLiteral(element) return entry ? [{ entry, node: element }] : [] })) + scope.set(variableName, occurrences.map((occurrence) => occurrence.entry)) foundDeclaration = true continue } @@ -244,7 +289,11 @@ function parseModuleConfigShape( if (!foundDeclaration) continue - occurrences.push(...collectPushEntriesFromStatement(statement, env, variableName, scope)) + const pushedOccurrences = collectPushEntriesFromStatement(statement, env, variableName, scope) + if (pushedOccurrences.length > 0) { + occurrences.push(...pushedOccurrences) + scope.set(variableName, occurrences.map((occurrence) => occurrence.entry)) + } } if (!arrayNode) { diff --git a/packages/cli/src/lib/resolver.ts b/packages/cli/src/lib/resolver.ts index 2395d4e0eb5..483f242f6b8 100644 --- a/packages/cli/src/lib/resolver.ts +++ b/packages/cli/src/lib/resolver.ts @@ -151,6 +151,14 @@ function evaluateStaticExpressionWithScope( const envAccess = parseProcessEnvAccess(node, env) if (envAccess.matched) return envAccess.value + if (ts.isPropertyAccessExpression(node)) { + const target = evaluateStaticExpressionWithScope(node.expression, env, scope) + if (target && typeof target === 'object' && node.name.text in target) { + return (target as Record)[node.name.text] + } + return undefined + } + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.ExclamationToken) { return !Boolean(evaluateStaticExpressionWithScope(node.operand, env, scope)) } @@ -180,6 +188,32 @@ function evaluateStaticExpressionWithScope( return parseBooleanWithDefault(typeof rawValue === 'string' ? rawValue : undefined, Boolean(fallbackValue)) } + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'some') { + const target = evaluateStaticExpressionWithScope(node.expression.expression, env, scope) + const predicate = node.arguments[0] + if (!Array.isArray(target) || !predicate) return undefined + + return target.some((entry) => { + if (!ts.isArrowFunction(predicate) && !ts.isFunctionExpression(predicate)) return false + + const localScope = new Map(scope) + const parameter = predicate.parameters[0] + if (parameter && ts.isIdentifier(parameter.name)) { + localScope.set(parameter.name.text, entry) + } + + if (ts.isBlock(predicate.body)) { + const returnStatement = predicate.body.statements.find((statement): statement is ts.ReturnStatement => + ts.isReturnStatement(statement), + ) + if (!returnStatement?.expression) return false + return Boolean(evaluateStaticExpressionWithScope(returnStatement.expression, env, localScope)) + } + + return Boolean(evaluateStaticExpressionWithScope(predicate.body, env, localScope)) + }) + } + return undefined } @@ -247,6 +281,7 @@ function parseModulesFromSource(source: string, env: NodeJS.ProcessEnv = process return entry ? [entry] : [] }) modules.push(...fromArray) + scope.set(variableName, [...modules]) foundDeclaration = true continue } @@ -259,7 +294,11 @@ function parseModulesFromSource(source: string, env: NodeJS.ProcessEnv = process continue } if (!foundDeclaration) continue - modules.push(...collectPushEntriesFromStatement(statement, env, variableName, scope)) + const pushedEntries = collectPushEntriesFromStatement(statement, env, variableName, scope) + if (pushedEntries.length > 0) { + modules.push(...pushedEntries) + scope.set(variableName, [...modules]) + } } return modules diff --git a/packages/cli/src/mercato.ts b/packages/cli/src/mercato.ts index 1a0edc668cf..505e0706097 100644 --- a/packages/cli/src/mercato.ts +++ b/packages/cli/src/mercato.ts @@ -1,7 +1,6 @@ // Note: Generated files and DI container are imported statically to avoid ESM/CJS interop issues. // Commands that need to run before generation (e.g., `init`) handle missing modules gracefully. -import { createRequestContainer } from '@open-mercato/shared/lib/di/container' import { runWorker } from '@open-mercato/queue/worker' import type { Module } from '@open-mercato/shared/modules/registry' import { getCliModules, hasCliModules, registerCliModules } from './registry' @@ -32,6 +31,8 @@ type ErrorWithCause = { errors?: unknown[] } +const BUILTIN_CLI_MODULE_IDS = new Set(['queue', 'generate', 'db', 'server', 'test']) + function collectNestedErrors(error: unknown, seen = new Set()): ErrorWithCause[] { if (!error || seen.has(error)) { return [] @@ -524,6 +525,7 @@ export async function run(argv = process.argv) { // Seed module defaults (structural data: dictionaries, tax rates, units, etc.) console.log('📚 Seeding module defaults...') + const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container') const seedContainer = await createRequestContainer() const seedEm = seedContainer.resolve('em') as any const seedCtx = { em: seedEm, tenantId, organizationId: orgId, container: seedContainer } @@ -823,11 +825,13 @@ export async function run(argv = process.argv) { // Load optional app-level CLI commands lazily without static import resolution let appCli: any[] = [] - try { - const dynImport: any = (Function('return import') as any)() - const app = await dynImport.then((f: any) => f('@/cli')).catch(() => null) - if (app && Array.isArray(app?.default)) appCli = app.default - } catch {} + if (!BUILTIN_CLI_MODULE_IDS.has(modName)) { + try { + const dynImport: any = (Function('return import') as any)() + const app = await dynImport.then((f: any) => f('@/cli')).catch(() => null) + if (app && Array.isArray(app?.default)) appCli = app.default + } catch { /* @/cli may not exist in standalone apps — safe to ignore */ } + } const all = modules.slice() // Built-in CLI module: queue @@ -876,6 +880,7 @@ export async function run(argv = process.argv) { return } + const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container') const container = await createRequestContainer() console.log(`[worker] Starting workers for all queues: ${discoveredQueues.join(', ')}`) @@ -911,6 +916,7 @@ export async function run(argv = process.argv) { if (queueWorkers.length > 0) { // Use discovered workers + const { createRequestContainer } = await import('@open-mercato/shared/lib/di/container') const container = await createRequestContainer() const concurrency = concurrencyOverride ?? Math.max(...queueWorkers.map((w) => w.concurrency), 1) diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-026.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-026.spec.ts index 045391061ac..bf55e02f58b 100644 --- a/packages/core/src/modules/customers/__integration__/TC-CRM-026.spec.ts +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-026.spec.ts @@ -292,4 +292,5 @@ test.describe('TC-CRM-026: Canonical Interactions API', () => { await deleteEntityIfExists(request, token, '/api/customers/companies', companyId); } }); + }); diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-028.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-028.spec.ts new file mode 100644 index 00000000000..f6c62e9cc2b --- /dev/null +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-028.spec.ts @@ -0,0 +1,1117 @@ +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { config as loadEnv } from 'dotenv'; +import { Client } from 'pg'; +import { expect, test, type APIRequestContext, type APIResponse } from '@playwright/test'; +import type { BootstrapData } from '@open-mercato/shared/lib/bootstrap'; +import { bootstrapFromAppRoot } from '@open-mercato/shared/lib/bootstrap/dynamicLoader'; +import { createRequestContainer } from '@open-mercato/shared/lib/di/container'; +import { createQueue } from '@open-mercato/queue'; +import { apiRequest, getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'; +import { + createCompanyFixture, + deleteEntityIfExists, +} from '@open-mercato/core/modules/core/__integration__/helpers/crmFixtures'; +import { + expectId, + getTokenScope, + readJsonSafe, +} from '@open-mercato/core/modules/core/__integration__/helpers/generalFixtures'; + +loadEnv({ path: path.resolve(process.cwd(), 'apps/mercato/.env') }); + +const APP_ROOT = path.resolve(process.cwd(), 'apps/mercato'); +const APP_QUEUE_BASE_DIR = path.resolve(APP_ROOT, '.mercato/queue'); +const BASE_URL = process.env.BASE_URL?.trim() || 'http://localhost:3000'; +const EXAMPLE_CUSTOMERS_SYNC_API_BASE = '/api/example-customers-sync'; +const SYNC_TOGGLE_IDS = { + enabled: 'example.customers_sync.enabled', + bidirectional: 'example.customers_sync.bidirectional', +} as const; +const EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE = 'example-customers-sync-outbound'; +const EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE = 'example-customers-sync-inbound'; +const EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE = 'example-customers-sync-reconcile'; + +process.env.QUEUE_BASE_DIR = APP_QUEUE_BASE_DIR; + +type TokenScope = ReturnType; + +type ScopedRequestOptions = { + token: string; + data?: unknown; + tenantId?: string | null; + organizationId?: string | null; +}; + +type MappingItem = { + id: string; + interactionId: string; + todoId: string; + syncStatus: string; + exampleHref: string; + lastError?: string | null; +}; + +type ExampleTodoItem = { + id: string; + title: string; + is_done?: boolean; + cf_priority?: number | null; + cf_description?: string | null; + cf_severity?: string | null; +}; + +type CustomerTodoItem = { + id: string; + todoId: string; + todoTitle: string | null; + todoIsDone: boolean | null; + todoPriority?: number | null; + todoDescription?: string | null; + todoSeverity?: string | null; + todoSource: string; +}; + +const toggleIdCache = new Map(); +let sharedDbClient: Client | null = null; +let sharedDbClientPromise: Promise | null = null; +let bootstrapDataPromise: Promise | null = null; + +function resolveUrl(path: string): string { + return `${BASE_URL}${path}`; +} + +function buildCookieHeader(scope: { + tenantId?: string | null; + organizationId?: string | null; +}): string | undefined { + const parts: string[] = []; + if (typeof scope.tenantId === 'string' && scope.tenantId.length > 0) { + parts.push(`om_selected_tenant=${scope.tenantId}`); + } + if (typeof scope.organizationId === 'string' && scope.organizationId.length > 0) { + parts.push(`om_selected_org=${scope.organizationId}`); + } + return parts.length > 0 ? parts.join('; ') : undefined; +} + +async function getDbClient(): Promise { + if (sharedDbClient) return sharedDbClient; + if (sharedDbClientPromise) return sharedDbClientPromise; + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error('DATABASE_URL is required for direct DB assertions in TC-CRM-028'); + } + sharedDbClientPromise = (async () => { + const client = new Client({ connectionString }); + await client.connect(); + sharedDbClient = client; + return client; + })(); + return sharedDbClientPromise; +} + +async function closeDbClient(): Promise { + const client = sharedDbClient; + sharedDbClient = null; + sharedDbClientPromise = null; + if (client) { + await client.end(); + } +} + +async function getBootstrapData(): Promise { + if (!bootstrapDataPromise) { + bootstrapDataPromise = bootstrapFromAppRoot(APP_ROOT); + } + return bootstrapDataPromise; +} + +function hasSyncWorkers(data: BootstrapData): boolean { + return data.modules + .flatMap((module) => module.workers ?? []) + .some((entry) => entry.queue === EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE); +} + +async function drainQueue(queueName: string): Promise { + const data = await getBootstrapData(); + const worker = data.modules + .flatMap((module) => module.workers ?? []) + .find((entry) => entry.queue === queueName); + if (!worker) { + return 0; + } + + const container = await createRequestContainer(); + const queue = createQueue(queueName, 'local', { baseDir: APP_QUEUE_BASE_DIR, concurrency: 1 }); + const resolve = (name: string): T => container.resolve(name) as T; + + try { + let processedJobs = 0; + while (true) { + const result = await queue.process( + async (job, ctx) => { + await Promise.resolve(worker.handler(job, { ...ctx, resolve })); + }, + { limit: 100 }, + ); + const handled = result.processed + result.failed; + processedJobs += handled; + if (handled === 0) { + return processedJobs; + } + } + } finally { + await queue.close(); + } +} + +async function flushExampleCustomersSyncQueues(options: { + outbound?: boolean; + inbound?: boolean; + reconcile?: boolean; +} = {}): Promise { + if (options.outbound ?? true) { + await drainQueue(EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE); + } + if (options.inbound ?? false) { + await drainQueue(EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE); + } + if (options.reconcile ?? false) { + await drainQueue(EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE); + } +} + +async function findFeatureToggleIdInDb(identifier: string): Promise { + const client = await getDbClient(); + const result = await client.query<{ id: string }>( + ` + select id + from feature_toggles + where identifier = $1 + and deleted_at is null + limit 1 + `, + [identifier], + ); + return result.rows[0]?.id ?? null; +} + +async function scopedApiRequest( + request: APIRequestContext, + method: string, + path: string, + options: ScopedRequestOptions, +): Promise { + const headers: Record = { + Authorization: `Bearer ${options.token}`, + 'Content-Type': 'application/json', + }; + const cookie = buildCookieHeader(options); + if (cookie) headers.Cookie = cookie; + return request.fetch(resolveUrl(path), { + method, + headers, + data: options.data, + }); +} + +async function resolveToggleId( + request: APIRequestContext, + token: string, + identifier: string, +): Promise { + const cached = toggleIdCache.get(identifier); + if (cached) return cached; + + const response = await apiRequest( + request, + 'GET', + `/api/feature_toggles/global?page=1&pageSize=100&identifier=${encodeURIComponent(identifier)}`, + { token }, + ); + expect(response.status()).toBe(200); + const body = await readJsonSafe<{ items?: Array<{ id?: string; identifier?: string }> }>(response); + const toggle = (body?.items ?? []).find((item) => item.identifier === identifier); + if (toggle?.id) { + const toggleId = expectId(toggle.id, `Missing toggle id for ${identifier}`); + toggleIdCache.set(identifier, toggleId); + return toggleId; + } + + const existingToggleId = await findFeatureToggleIdInDb(identifier); + if (existingToggleId) { + toggleIdCache.set(identifier, existingToggleId); + return existingToggleId; + } + throw new Error(`Missing feature toggle ${identifier}. Run migrations for example_customers_sync before TC-CRM-028.`); +} + +async function setBooleanOverride( + request: APIRequestContext, + token: string, + toggleId: string, + value: boolean, +): Promise { + const response = await apiRequest(request, 'PUT', '/api/feature_toggles/overrides', { + token, + data: { + toggleId, + isOverride: true, + overrideValue: value, + }, + }); + expect(response.status()).toBe(200); +} + +async function clearOverride( + request: APIRequestContext, + token: string, + toggleId: string, +): Promise { + const response = await apiRequest(request, 'PUT', '/api/feature_toggles/overrides', { + token, + data: { + toggleId, + isOverride: false, + }, + }); + expect(response.status()).toBe(200); +} + +async function setSyncFlags( + request: APIRequestContext, + token: string, + flags: { enabled: boolean; bidirectional: boolean }, +): Promise { + const enabledToggleId = await resolveToggleId(request, token, SYNC_TOGGLE_IDS.enabled); + const bidirectionalToggleId = await resolveToggleId(request, token, SYNC_TOGGLE_IDS.bidirectional); + await setBooleanOverride(request, token, enabledToggleId, flags.enabled); + await setBooleanOverride(request, token, bidirectionalToggleId, flags.bidirectional); +} + +async function clearSyncFlagOverrides( + request: APIRequestContext, + token: string, +): Promise { + const enabledToggleId = await resolveToggleId(request, token, SYNC_TOGGLE_IDS.enabled); + const bidirectionalToggleId = await resolveToggleId(request, token, SYNC_TOGGLE_IDS.bidirectional); + await clearOverride(request, token, enabledToggleId); + await clearOverride(request, token, bidirectionalToggleId); +} + +async function createScopedCompany( + request: APIRequestContext, + token: string, + displayName: string, + scope: { tenantId?: string | null; organizationId?: string | null }, +): Promise { + const response = await scopedApiRequest(request, 'POST', '/api/customers/companies', { + token, + data: { displayName }, + ...scope, + }); + expect(response.status()).toBe(201); + const body = await readJsonSafe<{ id?: string; entityId?: string }>(response); + return expectId(body?.id ?? body?.entityId, 'Company create response should include an id'); +} + +async function createInteraction( + request: APIRequestContext, + token: string, + data: Record, + scope?: { tenantId?: string | null; organizationId?: string | null }, +): Promise { + const response = scope + ? await scopedApiRequest(request, 'POST', '/api/customers/interactions', { token, data, ...scope }) + : await apiRequest(request, 'POST', '/api/customers/interactions', { token, data }); + expect(response.status()).toBe(201); + const body = await readJsonSafe<{ id?: string }>(response); + return expectId(body?.id, 'Interaction create response should include an id'); +} + +async function createExampleTodo( + request: APIRequestContext, + token: string, + data: Record, + scope?: { tenantId?: string | null; organizationId?: string | null }, +): Promise { + const response = scope + ? await scopedApiRequest(request, 'POST', '/api/example/todos', { token, data, ...scope }) + : await apiRequest(request, 'POST', '/api/example/todos', { token, data }); + expect(response.status()).toBe(201); + const body = await readJsonSafe<{ id?: string }>(response); + return expectId(body?.id, 'Example todo create response should include an id'); +} + +async function listMappings( + request: APIRequestContext, + token: string, + query: { interactionId?: string; todoId?: string } = {}, + scope?: { tenantId?: string | null; organizationId?: string | null }, +): Promise { + const params = new URLSearchParams({ limit: '100' }); + if (query.interactionId) params.set('interactionId', query.interactionId); + if (query.todoId) params.set('todoId', query.todoId); + const response = scope + ? await scopedApiRequest( + request, + 'GET', + `${EXAMPLE_CUSTOMERS_SYNC_API_BASE}/mappings?${params.toString()}`, + { token, ...scope }, + ) + : await apiRequest(request, 'GET', `${EXAMPLE_CUSTOMERS_SYNC_API_BASE}/mappings?${params.toString()}`, { token }); + expect(response.status()).toBe(200); + const body = await readJsonSafe<{ items?: MappingItem[] }>(response); + return body?.items ?? []; +} + +async function findExampleTodoById( + request: APIRequestContext, + token: string, + todoId: string, + scope?: { tenantId?: string | null; organizationId?: string | null }, +): Promise { + const response = scope + ? await scopedApiRequest( + request, + 'GET', + `/api/example/todos?id=${encodeURIComponent(todoId)}&page=1&pageSize=10`, + { token, ...scope }, + ) + : await apiRequest( + request, + 'GET', + `/api/example/todos?id=${encodeURIComponent(todoId)}&page=1&pageSize=10`, + { token }, + ); + expect(response.status()).toBe(200); + const body = await readJsonSafe<{ items?: ExampleTodoItem[] }>(response); + return (body?.items ?? []).find((item) => item.id === todoId) ?? null; +} + +async function listCustomerTodos( + request: APIRequestContext, + token: string, + entityId: string, + scope?: { tenantId?: string | null; organizationId?: string | null }, +): Promise { + const response = scope + ? await scopedApiRequest( + request, + 'GET', + `/api/customers/todos?entityId=${encodeURIComponent(entityId)}&page=1&pageSize=100`, + { token, ...scope }, + ) + : await apiRequest( + request, + 'GET', + `/api/customers/todos?entityId=${encodeURIComponent(entityId)}&page=1&pageSize=100`, + { token }, + ); + expect(response.status()).toBe(200); + const body = await readJsonSafe<{ items?: CustomerTodoItem[] }>(response); + return body?.items ?? []; +} + +async function waitForMapping( + request: APIRequestContext, + token: string, + query: { interactionId?: string; todoId?: string }, + scope?: { tenantId?: string | null; organizationId?: string | null }, +): Promise { + let mapping: MappingItem | null = null; + await expect + .poll(async () => { + const items = await listMappings(request, token, query, scope); + mapping = items[0] ?? null; + return mapping ? mapping.todoId : null; + }, { timeout: 15_000, intervals: [250, 500, 1_000] }) + .not.toBeNull(); + if (!mapping) { + throw new Error('Expected example customer sync mapping to be available'); + } + return mapping; +} + +async function waitForMappingRemoval( + request: APIRequestContext, + token: string, + query: { interactionId?: string; todoId?: string }, +): Promise { + await expect + .poll(async () => (await listMappings(request, token, query)).length, { + timeout: 15_000, + intervals: [250, 500, 1_000], + }) + .toBe(0); +} + +async function listInteractionActionLogCommandIds(interactionId: string): Promise { + const client = await getDbClient(); + const result = await client.query<{ command_id: string }>( + ` + select command_id + from action_logs + where resource_kind = 'customers.interaction' + and resource_id = $1 + and deleted_at is null + order by created_at desc + limit 20 + `, + [interactionId], + ); + return result.rows.map((row) => row.command_id); +} + +async function waitForExampleTodo( + request: APIRequestContext, + token: string, + todoId: string, + expectation: (todo: ExampleTodoItem) => boolean, + scope?: { tenantId?: string | null; organizationId?: string | null }, +): Promise { + let current: ExampleTodoItem | null = null; + await expect + .poll(async () => { + current = await findExampleTodoById(request, token, todoId, scope); + return current && expectation(current) ? current.id : null; + }, { timeout: 15_000, intervals: [250, 500, 1_000] }) + .not.toBeNull(); + if (!current) { + throw new Error(`Expected example todo ${todoId} to satisfy the awaited condition`); + } + return current; +} + +async function waitForExampleTodoRemoval( + request: APIRequestContext, + token: string, + todoId: string, +): Promise { + await expect + .poll(async () => (await findExampleTodoById(request, token, todoId))?.id ?? null, { + timeout: 15_000, + intervals: [250, 500, 1_000], + }) + .toBeNull(); +} + +async function insertLegacyTodoLink(input: { + organizationId: string; + tenantId: string; + todoId: string; + entityId: string; + createdByUserId: string; +}): Promise { + const client = await getDbClient(); + const result = await client.query<{ id: string }>( + ` + insert into customer_todo_links ( + organization_id, + tenant_id, + todo_id, + todo_source, + created_at, + created_by_user_id, + entity_id + ) + values ($1, $2, $3, 'example:todo', now(), $4, $5) + returning id + `, + [input.organizationId, input.tenantId, input.todoId, input.createdByUserId, input.entityId], + ); + return expectId(result.rows[0]?.id, 'Legacy todo link insert should return an id'); +} + +async function cleanupDbRows(input: { + linkIds?: string[]; + todoIds?: string[]; + interactionIds?: string[]; + organizationIds?: string[]; +}): Promise { + const client = await getDbClient(); + if (input.linkIds && input.linkIds.length > 0) { + await client.query('delete from customer_todo_links where id = any($1::uuid[])', [input.linkIds]); + } + if (input.interactionIds && input.interactionIds.length > 0) { + await client.query( + 'delete from example_customer_interaction_mappings where interaction_id = any($1::uuid[])', + [input.interactionIds], + ); + } + if (input.todoIds && input.todoIds.length > 0) { + await client.query( + 'delete from example_customer_interaction_mappings where todo_id = any($1::uuid[])', + [input.todoIds], + ); + } + if (input.organizationIds && input.organizationIds.length > 0) { + await client.query( + 'delete from example_customer_interaction_mappings where organization_id = any($1::uuid[])', + [input.organizationIds], + ); + await client.query( + "delete from customer_todo_links where organization_id = any($1::uuid[]) and todo_source = 'example:todo'", + [input.organizationIds], + ); + } +} + +test.describe('TC-CRM-028: Example customer sync', () => { + test.describe.configure({ mode: 'serial' }); + + let adminToken: string; + let superadminToken: string; + let adminScope: TokenScope; + + test.beforeAll(async ({ request }) => { + const data = await getBootstrapData(); + test.skip(!hasSyncWorkers(data), 'example_customers_sync workers not registered — skipping sync tests'); + adminToken = await getAuthToken(request, 'admin'); + superadminToken = await getAuthToken(request, 'superadmin'); + adminScope = getTokenScope(adminToken); + }); + + test.beforeEach(async ({ request }) => { + await clearSyncFlagOverrides(request, superadminToken); + await flushExampleCustomersSyncQueues({ outbound: true, inbound: true }); + }); + + test.afterEach(async ({ request }) => { + await clearSyncFlagOverrides(request, superadminToken); + await flushExampleCustomersSyncQueues({ outbound: true, inbound: true }); + }); + + test.afterAll(async () => { + await closeDbClient(); + }); + + test('registers the example_customers_sync outbound worker in bootstrap data', async () => { + const bootstrap = await getBootstrapData(); + const syncModule = bootstrap.modules.find((module) => module.id === 'example_customers_sync'); + expect(syncModule).toBeTruthy(); + expect(syncModule?.workers?.some((worker) => worker.queue === EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE)).toBe(true); + }); + + test('syncs canonical customer task lifecycle to example todos and exposes mappings', async ({ request }) => { + let companyId: string | null = null; + let interactionId: string | null = null; + let todoId: string | null = null; + + try { + await setSyncFlags(request, superadminToken, { enabled: true, bidirectional: false }); + companyId = await createCompanyFixture(request, adminToken, `QA CRM027 Sync ${Date.now()}`); + interactionId = await createInteraction(request, adminToken, { + entityId: companyId, + interactionType: 'task', + title: `CRM027 lifecycle ${Date.now()}`, + body: 'Follow up with procurement', + priority: 3, + customValues: { severity: 'critical' }, + }); + await flushExampleCustomersSyncQueues({ outbound: true }); + + const mapping = await waitForMapping(request, superadminToken, { interactionId }); + todoId = mapping.todoId; + expect(mapping.syncStatus).toBe('synced'); + expect(mapping.exampleHref).toContain(todoId); + + const todo = await waitForExampleTodo(request, adminToken, todoId, (item) => item.cf_severity === 'high'); + expect(todo.title).toContain('CRM027 lifecycle'); + expect(todo.is_done).toBe(false); + expect(todo.cf_priority).toBe(3); + expect(todo.cf_description).toBe('Follow up with procurement'); + expect(todo.cf_severity).toBe('high'); + + const completeResponse = await apiRequest(request, 'POST', '/api/customers/interactions/complete', { + token: adminToken, + data: { id: interactionId }, + }); + expect(completeResponse.status()).toBe(200); + await flushExampleCustomersSyncQueues({ outbound: true }); + + const completedTodo = await waitForExampleTodo(request, adminToken, todoId, (item) => item.is_done === true); + expect(completedTodo.is_done).toBe(true); + + const deleteResponse = await apiRequest( + request, + 'DELETE', + `/api/customers/interactions?id=${encodeURIComponent(interactionId)}`, + { token: adminToken }, + ); + expect(deleteResponse.status()).toBe(200); + interactionId = null; + await flushExampleCustomersSyncQueues({ outbound: true }); + + await waitForExampleTodoRemoval(request, adminToken, todoId); + await waitForMappingRemoval(request, adminToken, { todoId }); + todoId = null; + } finally { + await deleteEntityIfExists(request, adminToken, '/api/customers/interactions', interactionId); + await deleteEntityIfExists(request, adminToken, '/api/example/todos', todoId); + await cleanupDbRows({ + interactionIds: interactionId ? [interactionId] : [], + todoIds: todoId ? [todoId] : [], + }); + await deleteEntityIfExists(request, adminToken, '/api/customers/companies', companyId); + } + }); + + test('creates a canonical interaction when a linked example todo is created', async ({ request }) => { + let companyId: string | null = null; + let interactionId: string | null = null; + let todoId: string | null = null; + let linkId: string | null = null; + + try { + await setSyncFlags(request, superadminToken, { enabled: true, bidirectional: true }); + companyId = await createCompanyFixture(request, adminToken, `QA CRM027 Inbound Create ${Date.now()}`); + todoId = randomUUID(); + linkId = await insertLegacyTodoLink({ + organizationId: adminScope.organizationId, + tenantId: adminScope.tenantId, + todoId, + entityId: companyId, + createdByUserId: adminScope.userId, + }); + + const createdTodoId = await createExampleTodo(request, adminToken, { + id: todoId, + title: `CRM027 inbound create ${Date.now()}`, + customValues: { + priority: 2, + description: 'Created from example.todo.created', + severity: 'high', + }, + }); + expect(createdTodoId).toBe(todoId); + await flushExampleCustomersSyncQueues({ inbound: true, outbound: false }); + + const mapping = await waitForMapping(request, superadminToken, { todoId }); + interactionId = mapping.interactionId; + expect(interactionId).toBe(todoId); + + await expect + .poll(async () => { + const rows = await listCustomerTodos(request, adminToken, companyId!); + const row = rows.find((item) => item.id === interactionId); + return row + ? { + title: row.todoTitle, + priority: row.todoPriority ?? null, + description: row.todoDescription ?? null, + severity: row.todoSeverity ?? null, + } + : null; + }, { timeout: 15_000, intervals: [250, 500, 1_000] }) + .toEqual({ + title: expect.stringContaining('CRM027 inbound create'), + priority: 2, + description: 'Created from example.todo.created', + severity: 'high', + }); + } finally { + await deleteEntityIfExists(request, adminToken, '/api/customers/interactions', interactionId); + await deleteEntityIfExists(request, adminToken, '/api/example/todos', todoId); + await cleanupDbRows({ + linkIds: linkId ? [linkId] : [], + interactionIds: interactionId ? [interactionId] : [], + todoIds: todoId ? [todoId] : [], + }); + await deleteEntityIfExists(request, adminToken, '/api/customers/companies', companyId); + } + }); + + test('routes inbound todo completion through the canonical complete command', async ({ request }) => { + let companyId: string | null = null; + let interactionId: string | null = null; + let todoId: string | null = null; + + try { + await setSyncFlags(request, superadminToken, { enabled: true, bidirectional: true }); + companyId = await createCompanyFixture(request, adminToken, `QA CRM027 Inbound Complete ${Date.now()}`); + interactionId = await createInteraction(request, adminToken, { + entityId: companyId, + interactionType: 'task', + title: `CRM027 inbound complete ${Date.now()}`, + body: 'Complete me from example', + priority: 2, + }); + await flushExampleCustomersSyncQueues({ outbound: true }); + + const mapping = await waitForMapping(request, superadminToken, { interactionId }); + todoId = mapping.todoId; + + const updateResponse = await apiRequest(request, 'PUT', '/api/example/todos', { + token: adminToken, + data: { + id: todoId, + is_done: true, + title: `CRM027 inbound complete ${Date.now()}`, + }, + }); + expect(updateResponse.status()).toBe(200); + await flushExampleCustomersSyncQueues({ inbound: true, outbound: false }); + + await expect + .poll(async () => { + const rows = await listCustomerTodos(request, adminToken, companyId!); + return rows.find((item) => item.id === interactionId)?.todoIsDone ?? null; + }, { timeout: 15_000, intervals: [250, 500, 1_000] }) + .toBe(true); + + await expect + .poll(async () => await listInteractionActionLogCommandIds(interactionId!), { + timeout: 15_000, + intervals: [250, 500, 1_000], + }) + .toContain('customers.interactions.complete'); + } finally { + await deleteEntityIfExists(request, adminToken, '/api/customers/interactions', interactionId); + await deleteEntityIfExists(request, adminToken, '/api/example/todos', todoId); + await cleanupDbRows({ + interactionIds: interactionId ? [interactionId] : [], + todoIds: todoId ? [todoId] : [], + }); + await deleteEntityIfExists(request, adminToken, '/api/customers/companies', companyId); + } + }); + + test('skips sync side effects while the feature is disabled', async ({ request }) => { + let companyId: string | null = null; + let interactionId: string | null = null; + + try { + await setSyncFlags(request, superadminToken, { enabled: false, bidirectional: false }); + companyId = await createCompanyFixture(request, adminToken, `QA CRM027 Disabled ${Date.now()}`); + const title = `CRM027 disabled ${Date.now()}`; + interactionId = await createInteraction(request, adminToken, { + entityId: companyId, + interactionType: 'task', + title, + }); + await flushExampleCustomersSyncQueues({ outbound: true }); + + await expect + .poll(async () => { + const targetInteractionId = expectId( + interactionId, + 'Disabled sync assertion requires a created interaction id', + ); + return (await listMappings(request, superadminToken, { interactionId: targetInteractionId })).length; + }, { + timeout: 5_000, + intervals: [250, 500, 1_000], + }) + .toBe(0); + } finally { + await deleteEntityIfExists(request, adminToken, '/api/customers/interactions', interactionId); + await cleanupDbRows({ interactionIds: interactionId ? [interactionId] : [] }); + await deleteEntityIfExists(request, adminToken, '/api/customers/companies', companyId); + } + }); + + test('persists an error mapping when the first outbound sync attempt fails', async ({ request }) => { + let companyId: string | null = null; + let interactionId: string | null = null; + + try { + await setSyncFlags(request, superadminToken, { enabled: true, bidirectional: false }); + companyId = await createCompanyFixture(request, adminToken, `QA CRM027 Error ${Date.now()}`); + interactionId = await createInteraction(request, adminToken, { + entityId: companyId, + interactionType: 'task', + title: 'x'.repeat(201), + }); + await flushExampleCustomersSyncQueues({ outbound: true }); + + const mapping = await waitForMapping(request, superadminToken, { interactionId }); + expect(mapping.todoId).toBe(interactionId); + expect(mapping.syncStatus).toBe('error'); + expect(mapping.lastError).toBeTruthy(); + expect(await findExampleTodoById(request, adminToken, interactionId)).toBeNull(); + } finally { + await deleteEntityIfExists(request, adminToken, '/api/customers/interactions', interactionId); + await cleanupDbRows({ + interactionIds: interactionId ? [interactionId] : [], + todoIds: interactionId ? [interactionId] : [], + }); + await deleteEntityIfExists(request, adminToken, '/api/customers/companies', companyId); + } + }); + + test('uses the loop guard and propagates inbound field clears', async ({ request }) => { + let companyId: string | null = null; + let interactionId: string | null = null; + let todoId: string | null = null; + + try { + await setSyncFlags(request, superadminToken, { enabled: true, bidirectional: true }); + companyId = await createCompanyFixture(request, adminToken, `QA CRM027 Loop ${Date.now()}`); + interactionId = await createInteraction(request, adminToken, { + entityId: companyId, + interactionType: 'task', + title: `CRM027 loop ${Date.now()}`, + body: 'Keep me in sync', + priority: 4, + customValues: { severity: 'critical' }, + }); + await flushExampleCustomersSyncQueues({ outbound: true }); + + const mapping = await waitForMapping(request, superadminToken, { interactionId }); + todoId = mapping.todoId; + + await expect + .poll(async () => { + const rows = await listCustomerTodos(request, adminToken, companyId!); + const row = rows.find((item) => item.id === interactionId); + return row?.todoSeverity ?? null; + }, { timeout: 15_000, intervals: [250, 500, 1_000] }) + .toBe('critical'); + + const updateResponse = await apiRequest(request, 'PUT', '/api/example/todos', { + token: adminToken, + data: { + id: todoId, + title: `CRM027 loop ${Date.now()}`, + customValues: { + priority: null, + description: null, + severity: null, + }, + }, + }); + expect(updateResponse.status()).toBe(200); + await flushExampleCustomersSyncQueues({ inbound: true, outbound: false }); + + await expect + .poll(async () => { + const rows = await listCustomerTodos(request, adminToken, companyId!); + const row = rows.find((item) => item.id === interactionId); + return row + ? { + priority: row.todoPriority ?? null, + description: row.todoDescription ?? null, + severity: row.todoSeverity ?? null, + } + : null; + }, { timeout: 15_000, intervals: [250, 500, 1_000] }) + .toEqual({ + priority: null, + description: null, + severity: null, + }); + } finally { + await deleteEntityIfExists(request, adminToken, '/api/customers/interactions', interactionId); + await deleteEntityIfExists(request, adminToken, '/api/example/todos', todoId); + await cleanupDbRows({ + interactionIds: interactionId ? [interactionId] : [], + todoIds: todoId ? [todoId] : [], + }); + await deleteEntityIfExists(request, adminToken, '/api/customers/companies', companyId); + } + }); + + test('reconcile backfills legacy example todo links into canonical interactions', async ({ request }) => { + let companyId: string | null = null; + let todoId: string | null = null; + let interactionId: string | null = null; + let linkId: string | null = null; + + try { + companyId = await createCompanyFixture(request, adminToken, `QA CRM027 Reconcile ${Date.now()}`); + todoId = await createExampleTodo(request, adminToken, { + title: `CRM027 reconcile ${Date.now()}`, + customValues: { + priority: 2, + description: 'Legacy bridge task', + severity: 'high', + }, + }); + linkId = await insertLegacyTodoLink({ + organizationId: adminScope.organizationId, + tenantId: adminScope.tenantId, + todoId, + entityId: companyId, + createdByUserId: adminScope.userId, + }); + + const reconcileResponse = await apiRequest(request, 'POST', `${EXAMPLE_CUSTOMERS_SYNC_API_BASE}/reconcile`, { + token: superadminToken, + data: { + organizationId: adminScope.organizationId, + tenantId: adminScope.tenantId, + limit: 100, + }, + }); + expect(reconcileResponse.status()).toBe(202); + const reconcileBody = await readJsonSafe<{ queued?: number }>(reconcileResponse); + expect(reconcileBody?.queued).toBe(1); + + await flushExampleCustomersSyncQueues({ outbound: false, inbound: false, reconcile: true }); + + const mapping = await waitForMapping(request, superadminToken, { todoId }); + expect(mapping.interactionId).toBe(todoId); + interactionId = mapping.interactionId; + + await expect + .poll(async () => { + const rows = await listCustomerTodos(request, adminToken, companyId!); + const row = rows.find((item) => item.id === todoId); + return row + ? { + title: row.todoTitle, + priority: row.todoPriority ?? null, + description: row.todoDescription ?? null, + severity: row.todoSeverity ?? null, + } + : null; + }, { timeout: 15_000, intervals: [250, 500, 1_000] }) + .toEqual({ + title: expect.stringContaining('CRM027 reconcile'), + priority: 2, + description: 'Legacy bridge task', + severity: 'high', + }); + } finally { + await deleteEntityIfExists(request, adminToken, '/api/customers/interactions', interactionId); + await deleteEntityIfExists(request, adminToken, '/api/example/todos', todoId); + await cleanupDbRows({ + linkIds: linkId ? [linkId] : [], + interactionIds: interactionId ? [interactionId] : [], + todoIds: todoId ? [todoId] : [], + }); + await deleteEntityIfExists(request, adminToken, '/api/customers/companies', companyId); + } + }); + + test('scopes the mappings API by organization', async ({ request }) => { + let foreignOrganizationId: string | null = null; + let foreignCompanyId: string | null = null; + let foreignInteractionId: string | null = null; + let foreignTodoId: string | null = null; + + try { + await setSyncFlags(request, superadminToken, { enabled: true, bidirectional: false }); + + const organizationResponse = await apiRequest(request, 'POST', '/api/directory/organizations', { + token: superadminToken, + data: { + tenantId: adminScope.tenantId, + name: `QA CRM027 Foreign Org ${Date.now()}`, + }, + }); + expect(organizationResponse.status()).toBe(201); + const organizationBody = await readJsonSafe<{ id?: string }>(organizationResponse); + foreignOrganizationId = expectId(organizationBody?.id, 'Organization create response should include an id'); + + foreignCompanyId = await createScopedCompany( + request, + superadminToken, + `QA CRM027 Foreign Company ${Date.now()}`, + { + tenantId: adminScope.tenantId, + organizationId: foreignOrganizationId, + }, + ); + foreignInteractionId = await createInteraction( + request, + superadminToken, + { + entityId: foreignCompanyId, + interactionType: 'task', + title: `CRM027 foreign ${Date.now()}`, + }, + { + tenantId: adminScope.tenantId, + organizationId: foreignOrganizationId, + }, + ); + await flushExampleCustomersSyncQueues({ outbound: true }); + + const foreignMapping = await waitForMapping( + request, + superadminToken, + { interactionId: foreignInteractionId }, + { + tenantId: adminScope.tenantId, + organizationId: foreignOrganizationId, + }, + ); + foreignTodoId = foreignMapping.todoId; + + expect(await listMappings( + request, + superadminToken, + { interactionId: foreignInteractionId }, + { + tenantId: adminScope.tenantId, + organizationId: adminScope.organizationId, + }, + )).toEqual([]); + + const visibleForeignMappings = await listMappings( + request, + superadminToken, + { interactionId: foreignInteractionId }, + { + tenantId: adminScope.tenantId, + organizationId: foreignOrganizationId, + }, + ); + expect(visibleForeignMappings).toHaveLength(1); + expect(visibleForeignMappings[0]?.interactionId).toBe(foreignInteractionId); + } finally { + if (foreignInteractionId) { + await scopedApiRequest( + request, + 'DELETE', + `/api/customers/interactions?id=${encodeURIComponent(foreignInteractionId)}`, + { + token: superadminToken, + tenantId: adminScope.tenantId, + organizationId: foreignOrganizationId, + }, + ).catch(() => undefined); + } + if (foreignTodoId) { + await scopedApiRequest( + request, + 'DELETE', + `/api/example/todos?id=${encodeURIComponent(foreignTodoId)}`, + { + token: superadminToken, + tenantId: adminScope.tenantId, + organizationId: foreignOrganizationId, + }, + ).catch(() => undefined); + } + await cleanupDbRows({ + interactionIds: foreignInteractionId ? [foreignInteractionId] : [], + todoIds: foreignTodoId ? [foreignTodoId] : [], + organizationIds: foreignOrganizationId ? [foreignOrganizationId] : [], + }); + if (foreignCompanyId) { + await scopedApiRequest( + request, + 'DELETE', + `/api/customers/companies?id=${encodeURIComponent(foreignCompanyId)}`, + { + token: superadminToken, + tenantId: adminScope.tenantId, + organizationId: foreignOrganizationId, + }, + ).catch(() => undefined); + } + if (foreignOrganizationId) { + await apiRequest( + request, + 'DELETE', + `/api/directory/organizations?id=${encodeURIComponent(foreignOrganizationId)}`, + { token: superadminToken }, + ).catch(() => undefined); + } + } + }); +}); diff --git a/packages/core/src/modules/customers/api/companies/[id]/route.ts b/packages/core/src/modules/customers/api/companies/[id]/route.ts index 6e1dd8d46ff..57c0a9ac157 100644 --- a/packages/core/src/modules/customers/api/companies/[id]/route.ts +++ b/packages/core/src/modules/customers/api/companies/[id]/route.ts @@ -27,6 +27,7 @@ import { } from '../../../lib/customFieldRouting' import { CUSTOMER_INTERACTION_ACTIVITY_ADAPTER_SOURCE, + EXAMPLE_TODO_SOURCE, CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE, mapInteractionRecordToActivitySummary, mapInteractionRecordToTodoSummary, @@ -150,7 +151,7 @@ async function resolveTodoDetails( const idsBySource = new Map>() for (const link of links) { - const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource : 'example:todo' + const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource : EXAMPLE_TODO_SOURCE const id = typeof link.todoId === 'string' && link.todoId.trim().length > 0 ? link.todoId : String(link.todoId ?? '') if (!id) continue if (!idsBySource.has(source)) idsBySource.set(source, new Set()) @@ -633,10 +634,10 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) { interactionFlags.unified ? canonicalTodoItems : [ - ...todoLinks - .filter((link) => !canonicalTodoBridgeIds.has(link.todoId)) - .map((link) => { - const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource : 'example:todo' + ...todoLinks + .filter((link) => !canonicalTodoBridgeIds.has(link.todoId)) + .map((link) => { + const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource : EXAMPLE_TODO_SOURCE const key = `${source}:${link.todoId}` const detail = todoDetails.get(key) return { diff --git a/packages/core/src/modules/customers/api/dashboard/widgets/customer-todos/__tests__/route.test.ts b/packages/core/src/modules/customers/api/dashboard/widgets/customer-todos/__tests__/route.test.ts new file mode 100644 index 00000000000..a58bd123db6 --- /dev/null +++ b/packages/core/src/modules/customers/api/dashboard/widgets/customer-todos/__tests__/route.test.ts @@ -0,0 +1,147 @@ +import { GET } from '../route' + +jest.mock('../../utils', () => ({ + resolveWidgetScope: jest.fn(async () => ({ + container: { + resolve: jest.fn((name: string) => { + if (name === 'queryEngine') return { kind: 'query-engine' } + throw new Error(`Unexpected container resolve: ${name}`) + }), + }, + em: {}, + tenantId: '33333333-3333-3333-3333-333333333333', + organizationIds: ['22222222-2222-2222-2222-222222222222'], + })), +})) + +jest.mock('@open-mercato/shared/lib/i18n/server', () => ({ + resolveTranslations: async () => ({ + translate: (key: string, fallback?: string) => fallback ?? key, + }), +})) + +jest.mock('../../../../../lib/interactionFeatureFlags', () => ({ + resolveCustomerInteractionFeatureFlags: jest.fn(), +})) + +jest.mock('../../../../../lib/todoCompatibility', () => ({ + listLegacyTodoRows: jest.fn(), + listCanonicalTodoRows: jest.fn(), + sortTodoRows: jest.fn((rows: unknown[]) => rows), +})) + +describe('customers customer-todos widget route', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('merges legacy and canonical rows while unified mode is disabled', async () => { + const { resolveCustomerInteractionFeatureFlags } = + jest.requireMock('../../../../../lib/interactionFeatureFlags') + const { listLegacyTodoRows, listCanonicalTodoRows } = + jest.requireMock('../../../../../lib/todoCompatibility') + + resolveCustomerInteractionFeatureFlags.mockResolvedValue({ unified: false }) + listLegacyTodoRows.mockResolvedValue([ + { + id: '11111111-1111-1111-1111-111111111111', + todoId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + todoSource: 'example:todo', + todoTitle: 'Legacy task', + createdAt: '2026-04-01T10:00:00.000Z', + organizationId: '22222222-2222-2222-2222-222222222222', + tenantId: '33333333-3333-3333-3333-333333333333', + customer: { + id: '44444444-4444-4444-4444-444444444444', + displayName: 'Legacy Co', + kind: 'company', + }, + }, + ]) + listCanonicalTodoRows.mockResolvedValue({ + items: [ + { + id: '55555555-5555-5555-5555-555555555555', + todoId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + todoSource: 'customers:interaction', + todoTitle: 'Canonical task', + createdAt: '2026-04-02T10:00:00.000Z', + organizationId: '22222222-2222-2222-2222-222222222222', + tenantId: '33333333-3333-3333-3333-333333333333', + _integrations: { + example: { href: '/backend/todos/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/edit' }, + }, + customer: { + id: '66666666-6666-6666-6666-666666666666', + displayName: 'Canonical Co', + kind: 'company', + }, + }, + ], + bridgeIds: new Set(), + }) + + const req = new Request('http://localhost/api?limit=5') + const res = await GET(req) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.items).toHaveLength(2) + expect(body.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '11111111-1111-1111-1111-111111111111', + todoSource: 'example:todo', + todoTitle: 'Legacy task', + }), + expect.objectContaining({ + id: '55555555-5555-5555-5555-555555555555', + todoSource: 'customers:interaction', + todoTitle: 'Canonical task', + }), + ]), + ) + }) + + it('uses only canonical rows while unified mode is enabled', async () => { + const { resolveCustomerInteractionFeatureFlags } = + jest.requireMock('../../../../../lib/interactionFeatureFlags') + const { listLegacyTodoRows, listCanonicalTodoRows } = + jest.requireMock('../../../../../lib/todoCompatibility') + + resolveCustomerInteractionFeatureFlags.mockResolvedValue({ unified: true }) + listLegacyTodoRows.mockResolvedValue([]) + listCanonicalTodoRows.mockResolvedValue({ + items: [ + { + id: '77777777-7777-7777-7777-777777777777', + todoId: 'cccccccc-cccc-cccc-cccc-cccccccccccc', + todoSource: 'customers:interaction', + todoTitle: 'Canonical only', + createdAt: '2026-04-03T10:00:00.000Z', + organizationId: '22222222-2222-2222-2222-222222222222', + tenantId: '33333333-3333-3333-3333-333333333333', + customer: { + id: '88888888-8888-8888-8888-888888888888', + displayName: 'Canonical Only Co', + kind: 'company', + }, + }, + ], + bridgeIds: new Set(), + }) + + const req = new Request('http://localhost/api?limit=5') + const res = await GET(req) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.items).toHaveLength(1) + expect(body.items[0]).toMatchObject({ + id: '77777777-7777-7777-7777-777777777777', + todoSource: 'customers:interaction', + todoTitle: 'Canonical only', + }) + expect(listLegacyTodoRows).not.toHaveBeenCalled() + }) +}) diff --git a/packages/core/src/modules/customers/api/dashboard/widgets/customer-todos/route.ts b/packages/core/src/modules/customers/api/dashboard/widgets/customer-todos/route.ts index d2b32e1b3e5..87718a5fea7 100644 --- a/packages/core/src/modules/customers/api/dashboard/widgets/customer-todos/route.ts +++ b/packages/core/src/modules/customers/api/dashboard/widgets/customer-todos/route.ts @@ -2,13 +2,17 @@ import { NextResponse } from 'next/server' import { z } from 'zod' import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors' -import { CustomerEntity, CustomerTodoLink } from '../../../../data/entities' import { resolveWidgetScope, type WidgetScopeContext } from '../utils' +import { resolveCustomerInteractionFeatureFlags } from '../../../../lib/interactionFeatureFlags' +import { CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE } from '../../../../lib/interactionCompatibility' import type { QueryEngine } from '@open-mercato/shared/lib/query/types' import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' -import type { FilterQuery } from '@mikro-orm/core' -import type { EntityId } from '@open-mercato/shared/modules/entities' -import { decryptEntitiesWithFallbackScope } from '@open-mercato/shared/lib/encryption/subscriber' +import { + listLegacyTodoRows, + listCanonicalTodoRows, + sortTodoRows, + type CustomerTodoRow, +} from '../../../../lib/todoCompatibility' const querySchema = z.object({ limit: z.coerce.number().min(1).max(20).default(5), @@ -47,135 +51,73 @@ async function resolveContext(req: Request, translate: (key: string, fallback?: } } -type TodoSummary = { - id: string - title: string | null -} - -const TODO_TITLE_FIELDS = ['title', 'subject', 'name', 'summary', 'text', 'description'] as const - -function extractTodoTitle(record: Record): string | null { - for (const key of TODO_TITLE_FIELDS) { - const value = record[key] - if (typeof value === 'string' && value.trim().length > 0) { - return value.trim() - } - } - return null -} - -async function resolveTodoSummaries( - queryEngine: QueryEngine, - links: CustomerTodoLink[], - tenantId: string, - organizationIds: string[] | null -): Promise> { - const results = new Map() - if (!links.length) return results - - const idsBySource = new Map>() - for (const link of links) { - const source = typeof link.todoSource === 'string' && link.todoSource.length > 0 ? link.todoSource : 'unknown' - const id = String(link.todoId ?? '') - if (!id) continue - if (!idsBySource.has(source)) idsBySource.set(source, new Set()) - idsBySource.get(source)!.add(id) - } - - const scopedOrgIds = Array.isArray(organizationIds) - ? Array.from(new Set(organizationIds.filter((id) => typeof id === 'string' && id.length > 0))) - : null - - for (const [source, idSet] of idsBySource.entries()) { - const ids = Array.from(idSet) - if (ids.length === 0 || source === 'unknown') continue - try { - const requestedFields = Array.from(new Set(['id', ...TODO_TITLE_FIELDS])) - const queryResult = await queryEngine.query>(source as EntityId, { - tenantId, - organizationIds: scopedOrgIds && scopedOrgIds.length > 0 ? scopedOrgIds : undefined, - filters: { id: { $in: ids } }, - fields: requestedFields, - includeCustomFields: false, - page: { page: 1, pageSize: Math.max(ids.length, 1) }, - }) - for (const item of queryResult.items ?? []) { - if (!item || typeof item !== 'object') continue - const raw = item as Record - const todoId = typeof raw.id === 'string' && raw.id.length > 0 ? raw.id : String(raw.id ?? '') - if (!todoId) continue - const title = extractTodoTitle(raw) - results.set(`${source}:${todoId}`, { id: todoId, title }) - } - } catch (err) { - console.warn(`customers.widgets.todos: failed to resolve todos for source ${source}`, err) - } - } - - return results -} - export async function GET(req: Request) { const { translate } = await resolveTranslations() try { const { container, em, tenantId, organizationIds, limit } = await resolveContext(req, translate) - const whereOrganization = Array.isArray(organizationIds) - ? organizationIds.length === 1 - ? organizationIds[0] - : { $in: Array.from(new Set(organizationIds)) } - : null - - const linkFilters = { - tenantId, - ...(whereOrganization ? { organizationId: whereOrganization } : {}), - entity: { - deletedAt: null, - } as FilterQuery, - } as FilterQuery - - const links = await em.find( - CustomerTodoLink, - linkFilters, - { - limit, - orderBy: { createdAt: 'desc' }, - populate: ['entity'], - } - ) - await decryptEntitiesWithFallbackScope(links, { - em, + const auth = { tenantId, - organizationId: organizationIds?.[0] ?? null, - }) - - const queryEngine = (container.resolve('queryEngine') as QueryEngine) - const todoSummaries = await resolveTodoSummaries(queryEngine, links, tenantId, organizationIds) - - const items = links.map((link) => { - const entity = link.entity - const entityRecord = entity && typeof entity !== 'string' ? (entity as CustomerEntity) : null - const todoKey = `${link.todoSource}:${link.todoId}` - const summary = todoSummaries.get(todoKey) ?? null + orgId: organizationIds?.[0] ?? null, + sub: 'customers.dashboard.todos', + } + const flags = await resolveCustomerInteractionFeatureFlags(container, tenantId) + const rows = flags.unified + ? (await listCanonicalTodoRows( + em, + container, + auth, + organizationIds?.[0] ?? null, + organizationIds ?? null, + )).items + : await Promise.all([ + listLegacyTodoRows( + em, + container.resolve('queryEngine') as QueryEngine, + tenantId, + organizationIds ?? null, + undefined, + ), + listCanonicalTodoRows( + em, + container, + auth, + organizationIds?.[0] ?? null, + organizationIds ?? null, + { + includeDeleted: true, + source: CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE, + }, + ), + ]).then(([legacyRows, canonicalRows]) => + sortTodoRows([ + ...legacyRows.filter((row) => !canonicalRows.bridgeIds.has(row.todoId)), + ...canonicalRows.items, + ]), + ) + + const items = rows.slice(0, limit).map((row: CustomerTodoRow) => { + const entity = row.customer ?? null return { - id: link.id, - todoId: link.todoId, - todoSource: link.todoSource, - todoTitle: summary?.title ?? null, - createdAt: link.createdAt.toISOString(), - organizationId: link.organizationId, - entity: entityRecord + id: row.id, + todoId: row.todoId, + todoSource: row.todoSource, + todoTitle: row.todoTitle ?? null, + createdAt: row.createdAt, + organizationId: row.organizationId ?? null, + _integrations: row._integrations ?? undefined, + entity: entity?.id ? { - id: entityRecord.id, - displayName: entityRecord.displayName, - kind: entityRecord.kind, - ownerUserId: entityRecord.ownerUserId, + id: entity.id, + displayName: entity.displayName ?? null, + kind: entity.kind ?? null, + ownerUserId: null, } : { - id: typeof entity === 'string' ? entity : null, - displayName: null, - kind: null, - ownerUserId: null, - }, + id: null, + displayName: null, + kind: null, + ownerUserId: null, + }, } }) @@ -198,6 +140,7 @@ const customerTodoWidgetItemSchema = z.object({ todoSource: z.string(), todoTitle: z.string().nullable().optional(), createdAt: z.string(), + _integrations: z.record(z.string(), z.unknown()).optional(), organizationId: z.string().uuid().nullable().optional(), entity: z .object({ @@ -222,8 +165,8 @@ export const openApi: OpenApiRouteDoc = { summary: 'Customer todos widget', methods: { GET: { - summary: 'Fetch recent customer todo links', - description: 'Returns the most recently created todo links for display on dashboards.', + summary: 'Fetch recent customer tasks', + description: 'Returns the most recent customer tasks for display on dashboards, including legacy compatibility rows when needed.', query: querySchema, responses: [ { status: 200, description: 'Widget payload', schema: customerTodoWidgetResponseSchema }, diff --git a/packages/core/src/modules/customers/api/interactions/tasks/route.ts b/packages/core/src/modules/customers/api/interactions/tasks/route.ts new file mode 100644 index 00000000000..32d3b882171 --- /dev/null +++ b/packages/core/src/modules/customers/api/interactions/tasks/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' +import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors' +import { parseBooleanToken } from '@open-mercato/shared/lib/boolean' +import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' +import type { QueryEngine } from '@open-mercato/shared/lib/query/types' +import { createCustomersCrudOpenApi, createPagedListResponseSchema } from '../../openapi' +import { resolveCustomerInteractionFeatureFlags } from '../../../lib/interactionFeatureFlags' +import { resolveCustomersRequestContext } from '../../../lib/interactionRequestContext' +import { CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE } from '../../../lib/interactionCompatibility' +import { + filterTodoRows, + listCanonicalTodoRows, + listLegacyTodoRows, + normalizeTodoSearch, + paginateTodoRows, + sortTodoRows, +} from '../../../lib/todoCompatibility' + +const querySchema = z.object({ + page: z.coerce.number().min(1).default(1), + pageSize: z.coerce.number().min(1).max(100).default(50), + search: z.string().optional(), + all: z.string().optional(), + entityId: z.string().uuid().optional(), +}) + +export const metadata = { + GET: { requireAuth: true, requireFeatures: ['customers.interactions.view'] }, +} + +export async function GET(request: Request): Promise { + const { translate } = await resolveTranslations() + try { + const { auth, em, organizationIds, container, selectedOrganizationId } = + await resolveCustomersRequestContext(request) + const query = querySchema.parse(Object.fromEntries(new URL(request.url).searchParams)) + const flags = await resolveCustomerInteractionFeatureFlags(container, auth.tenantId) + const exportAll = parseBooleanToken(query.all) === true + const search = normalizeTodoSearch(query.search) + const queryEngine = container.resolve('queryEngine') as QueryEngine + + const mergedRows = flags.unified + ? (await listCanonicalTodoRows( + em, + container, + auth, + selectedOrganizationId, + organizationIds, + { entityId: query.entityId }, + )).items + : await Promise.all([ + listLegacyTodoRows(em, queryEngine, auth.tenantId, organizationIds, query.entityId), + listCanonicalTodoRows( + em, + container, + auth, + selectedOrganizationId, + organizationIds, + { + entityId: query.entityId, + includeDeleted: true, + source: CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE, + }, + ), + ]).then(([legacyRows, canonicalRows]) => [ + ...legacyRows.filter((row) => !canonicalRows.bridgeIds.has(row.todoId)), + ...canonicalRows.items, + ]) + + const filteredRows = filterTodoRows(sortTodoRows(mergedRows), search) + const paged = paginateTodoRows(filteredRows, query.page, query.pageSize, exportAll) + + return NextResponse.json({ + items: paged.items, + total: paged.total, + page: paged.page, + pageSize: paged.pageSize, + totalPages: paged.totalPages, + }) + } catch (err) { + if (err instanceof CrudHttpError) { + return NextResponse.json(err.body, { status: err.status }) + } + if (err instanceof z.ZodError) { + return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed'), details: err.issues }, { status: 400 }) + } + console.error('customers.interactions.tasks.get failed', err) + return NextResponse.json({ error: translate('customers.errors.internalError', 'Internal server error') }, { status: 500 }) + } +} + +const todoItemSchema = z.object({ + id: z.string(), + todoId: z.string(), + todoSource: z.string(), + todoTitle: z.string().nullable(), + todoIsDone: z.boolean().nullable(), + todoPriority: z.number().nullable().optional(), + todoSeverity: z.string().nullable().optional(), + todoDescription: z.string().nullable().optional(), + todoDueAt: z.string().nullable().optional(), + todoCustomValues: z.record(z.string(), z.unknown()).nullable().optional(), + todoOrganizationId: z.string().nullable(), + organizationId: z.string(), + tenantId: z.string(), + createdAt: z.string(), + externalHref: z.string().nullable().optional(), + _integrations: z.record(z.string(), z.unknown()).optional(), + customer: z.object({ + id: z.string().nullable(), + displayName: z.string().nullable(), + kind: z.string().nullable(), + }), +}) + +export const openApi: OpenApiRouteDoc = createCustomersCrudOpenApi({ + resourceName: 'CustomerTask', + querySchema, + listResponseSchema: createPagedListResponseSchema(todoItemSchema), +}) diff --git a/packages/core/src/modules/customers/api/people/[id]/route.ts b/packages/core/src/modules/customers/api/people/[id]/route.ts index 10efc94f00b..ad6dc4d8f37 100644 --- a/packages/core/src/modules/customers/api/people/[id]/route.ts +++ b/packages/core/src/modules/customers/api/people/[id]/route.ts @@ -23,6 +23,7 @@ import { E } from '#generated/entities.ids.generated' import { mergePersonCustomFieldValues, resolvePersonCustomFieldRouting } from '../../../lib/customFieldRouting' import { CUSTOMER_INTERACTION_ACTIVITY_ADAPTER_SOURCE, + EXAMPLE_TODO_SOURCE, CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE, mapInteractionRecordToActivitySummary, mapInteractionRecordToTodoSummary, @@ -235,7 +236,7 @@ async function resolveTodoDetails( const idsBySource = new Map>() for (const link of links) { - const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource : 'example:todo' + const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource : EXAMPLE_TODO_SOURCE const id = typeof link.todoId === 'string' && link.todoId.trim().length > 0 ? link.todoId : String(link.todoId ?? '') if (!id) continue if (!idsBySource.has(source)) idsBySource.set(source, new Set()) @@ -775,7 +776,7 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) { ...todoLinks .filter((link) => !canonicalTodoBridgeIds.has(link.todoId)) .map((link) => { - const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource : 'example:todo' + const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource : EXAMPLE_TODO_SOURCE const key = `${source}:${link.todoId}` const detail = todoDetails.get(key) return { diff --git a/packages/core/src/modules/customers/api/todos/__tests__/route.test.ts b/packages/core/src/modules/customers/api/todos/__tests__/route.test.ts index 861e9d2e6bf..9076ba60ca3 100644 --- a/packages/core/src/modules/customers/api/todos/__tests__/route.test.ts +++ b/packages/core/src/modules/customers/api/todos/__tests__/route.test.ts @@ -74,6 +74,22 @@ jest.mock('../../../lib/todoCompatibility', () => ({ resolveLegacyTodoDetails: jest.fn(), mapLegacyTodoLinkToRow: jest.fn(), mapInteractionRecordToTodoRow: jest.fn(), + normalizeTodoSearch: jest.fn((value: string | undefined) => { + if (typeof value !== 'string') return null + const trimmed = value.trim().toLowerCase() + return trimmed.length > 0 ? trimmed : null + }), + sortTodoRows: jest.fn((rows: unknown[]) => rows), + filterTodoRows: jest.fn((rows: unknown[]) => rows), + paginateTodoRows: jest.fn((rows: unknown[], page: number, pageSize: number, exportAll: boolean) => ({ + items: rows, + total: rows.length, + page, + pageSize: exportAll ? rows.length : pageSize, + totalPages: 1, + })), + listLegacyTodoRows: jest.fn(), + listCanonicalTodoRows: jest.fn(), })) describe('customers todos adapter route', () => { @@ -87,10 +103,16 @@ describe('customers todos adapter route', () => { externalSync: false, }) - const { resolveLegacyTodoDetails, mapLegacyTodoLinkToRow, mapInteractionRecordToTodoRow } = + const { + resolveLegacyTodoDetails, + mapLegacyTodoLinkToRow, + mapInteractionRecordToTodoRow, + listLegacyTodoRows, + listCanonicalTodoRows, + } = jest.requireMock('../../../lib/todoCompatibility') resolveLegacyTodoDetails.mockResolvedValue(new Map()) - mapLegacyTodoLinkToRow.mockImplementation(() => ({ + const legacyRow = { id: LINK_ID, todoId: TODO_ID, todoSource: 'example:todo', @@ -110,8 +132,8 @@ describe('customers todos adapter route', () => { displayName: 'Acme Corp', kind: 'company', }, - })) - mapInteractionRecordToTodoRow.mockImplementation(() => ({ + } + const canonicalRow = { id: TODO_ID, todoId: TODO_ID, todoSource: 'customers:interaction', @@ -131,7 +153,14 @@ describe('customers todos adapter route', () => { displayName: 'Acme Corp', kind: 'company', }, - })) + } + mapLegacyTodoLinkToRow.mockImplementation(() => legacyRow) + mapInteractionRecordToTodoRow.mockImplementation(() => canonicalRow) + listLegacyTodoRows.mockResolvedValue([legacyRow]) + listCanonicalTodoRows.mockResolvedValue({ + items: [canonicalRow], + bridgeIds: new Set([TODO_ID]), + }) const { hydrateCanonicalInteractions, loadCustomerSummaries } = jest.requireMock('../../../lib/interactionReadModel') diff --git a/packages/core/src/modules/customers/api/todos/route.ts b/packages/core/src/modules/customers/api/todos/route.ts index 5a4e675b4a5..ca437b3d53d 100644 --- a/packages/core/src/modules/customers/api/todos/route.ts +++ b/packages/core/src/modules/customers/api/todos/route.ts @@ -22,17 +22,16 @@ import { todoLinkWithTodoCreateSchema } from '../../data/validators' import { resolveCustomerInteractionFeatureFlags } from '../../lib/interactionFeatureFlags' import { resolveCustomersRequestContext } from '../../lib/interactionRequestContext' import { - hydrateCanonicalInteractions, - loadCustomerSummaries, -} from '../../lib/interactionReadModel' -import { + EXAMPLE_TODO_SOURCE, CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE, - CUSTOMER_INTERACTION_TASK_SOURCE, } from '../../lib/interactionCompatibility' import { - type CustomerTodoRow, - mapInteractionRecordToTodoRow, - mapLegacyTodoLinkToRow, + filterTodoRows, + listCanonicalTodoRows, + listLegacyTodoRows, + normalizeTodoSearch, + paginateTodoRows, + sortTodoRows, resolveLegacyTodoDetails, } from '../../lib/todoCompatibility' @@ -79,11 +78,6 @@ const DEPRECATION_HEADERS = { Link: '; rel="successor-version"', } -type CanonicalTodoListResult = { - items: CustomerTodoRow[] - bridgeIds: Set -} - function resolveGuardUserId(auth: { sub?: string | null userId?: string | null @@ -118,64 +112,6 @@ async function legacyAdaptersDisabledResponse(): Promise { )) } -function normalizeSearch(value: string | undefined): string | null { - if (typeof value !== 'string') return null - const trimmed = value.trim().toLowerCase() - return trimmed.length > 0 ? trimmed : null -} - -function sortTodoRows(rows: CustomerTodoRow[]): CustomerTodoRow[] { - return [...rows].sort((left, right) => { - const leftTime = new Date(left.createdAt).getTime() - const rightTime = new Date(right.createdAt).getTime() - if (leftTime === rightTime) { - return right.id.localeCompare(left.id) - } - return rightTime - leftTime - }) -} - -function filterTodoRows(rows: CustomerTodoRow[], search: string | null): CustomerTodoRow[] { - if (!search) return rows - return rows.filter((row) => { - const haystack = [ - row.customer.displayName, - row.todoTitle, - row.todoDescription, - ] - .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) - .join(' ') - .toLowerCase() - return haystack.includes(search) - }) -} - -function paginateTodoRows( - rows: CustomerTodoRow[], - page: number, - pageSize: number, - exportAll: boolean, -): { items: CustomerTodoRow[]; total: number; page: number; pageSize: number; totalPages: number } { - const total = rows.length - if (exportAll) { - return { - items: rows, - total, - page: 1, - pageSize: total, - totalPages: 1, - } - } - const start = (page - 1) * pageSize - return { - items: rows.slice(start, start + pageSize), - total, - page, - pageSize, - totalPages: Math.max(1, Math.ceil(total / pageSize)), - } -} - function normalizeTodoStatusInput(body: z.infer): boolean | undefined { if (typeof body.isDone === 'boolean') return body.isDone if (typeof body.is_done === 'boolean') return body.is_done @@ -198,110 +134,6 @@ function collectTodoCustomValues( return Object.keys(direct).length > 0 ? direct : undefined } -async function listLegacyTodoRows( - em: EntityManager, - queryEngine: QueryEngine, - tenantId: string, - organizationIds: string[] | null, - entityId: string | undefined, -): Promise { - const where: Record = { tenantId } - if (organizationIds && organizationIds.length > 0) { - where.organizationId = { $in: organizationIds } - } - if (entityId) { - where.entity = entityId - } - - const links = await em.find(CustomerTodoLink, where, { - populate: ['entity'], - orderBy: { createdAt: 'desc' }, - }) - const details = await resolveLegacyTodoDetails( - queryEngine, - links, - tenantId, - organizationIds ?? [], - ) - - return links.map((link) => { - const source = - typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 - ? link.todoSource - : 'example:todo' - return mapLegacyTodoLinkToRow( - link, - details.get(`${source}:${link.todoId}`) ?? null, - ) - }) -} - -async function listCanonicalTodoRows( - em: EntityManager, - queryEngine: QueryEngine, - container: { resolve: (name: string) => unknown }, - auth: { tenantId: string | null; orgId: string | null; sub?: string | null; userId?: string | null; keyId?: string | null }, - selectedOrganizationId: string | null, - organizationIds: string[] | null, - query: z.infer, - options?: { includeDeleted?: boolean; source?: string | string[] | null }, -): Promise { - const where: Record = { - tenantId: auth.tenantId, - interactionType: 'task', - } - if (!options?.includeDeleted) { - where.deletedAt = null - } - if (organizationIds && organizationIds.length > 0) { - where.organizationId = { $in: organizationIds } - } - if (query.entityId) { - where.entity = query.entityId - } - if (options?.source) { - where.source = Array.isArray(options.source) ? { $in: options.source } : options.source - } - - const interactions = await em.find(CustomerInteraction, where, { - orderBy: { createdAt: 'desc' }, - }) - const activeInteractions = interactions.filter((interaction) => !interaction.deletedAt) - const hydrated = await hydrateCanonicalInteractions({ - em, - container, - auth, - selectedOrganizationId, - interactions: activeInteractions, - }) - const customerIds = Array.from( - new Set( - hydrated - .map((interaction) => interaction.entityId ?? null) - .filter((value): value is string => !!value), - ), - ) - const customerSummaries = await loadCustomerSummaries(em, customerIds, auth.tenantId, selectedOrganizationId) - - const items = hydrated.map((interaction) => - mapInteractionRecordToTodoRow( - interaction, - interaction.entityId ? customerSummaries.get(interaction.entityId) ?? null : null, - { - todoSource: - interaction.source === CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE - ? CUSTOMER_INTERACTION_TASK_SOURCE - : CUSTOMER_INTERACTION_TASK_SOURCE, - }, - ), - ) - - return { - items, - bridgeIds: new Set(interactions.map((interaction) => interaction.id)), - } -} - async function findLegacyTodoLink( em: EntityManager, target: { linkId?: string; todoId?: string }, @@ -335,7 +167,7 @@ async function ensureCanonicalTodoBridge( const source = typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 ? link.todoSource - : 'example:todo' + : EXAMPLE_TODO_SOURCE const detail = detailMap.get(`${source}:${link.todoId}`) ?? null const entityId = typeof link.entity === 'string' ? link.entity : link.entity.id @@ -396,29 +228,27 @@ export async function GET(request: Request): Promise { } const queryEngine = container.resolve('queryEngine') as QueryEngine const exportAll = parseBooleanToken(query.all) === true - const search = normalizeSearch(query.search) + const search = normalizeTodoSearch(query.search) const mergedRows = flags.unified ? (await listCanonicalTodoRows( em, - queryEngine, container, auth, selectedOrganizationId, organizationIds, - query, + { entityId: query.entityId }, )).items : await Promise.all([ listLegacyTodoRows(em, queryEngine, auth.tenantId, organizationIds, query.entityId), listCanonicalTodoRows( em, - queryEngine, container, auth, selectedOrganizationId, organizationIds, - query, { + entityId: query.entityId, includeDeleted: true, source: CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE, }, @@ -732,6 +562,8 @@ const todoItemSchema = z.object({ organizationId: z.string(), tenantId: z.string(), createdAt: z.string(), + externalHref: z.string().nullable().optional(), + _integrations: z.record(z.string(), z.unknown()).optional(), customer: z.object({ id: z.string().nullable(), displayName: z.string().nullable(), diff --git a/packages/core/src/modules/customers/backend/customer-tasks/page.meta.ts b/packages/core/src/modules/customers/backend/customer-tasks/page.meta.ts new file mode 100644 index 00000000000..5f5f84fda7a --- /dev/null +++ b/packages/core/src/modules/customers/backend/customer-tasks/page.meta.ts @@ -0,0 +1,23 @@ +import React from 'react' + +const tasksIcon = React.createElement( + 'svg', + { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, + React.createElement('rect', { x: 9, y: 3, width: 6, height: 4, rx: 1 }), + React.createElement('path', { d: 'M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2' }), + React.createElement('path', { d: 'M9 12h6' }), + React.createElement('path', { d: 'M9 16h4' }), +) + +export const metadata = { + requireAuth: true, + requireFeatures: ['customers.interactions.view'], + pageTitle: 'Customer related tasks', + pageTitleKey: 'customers.workPlan.customerTodos.page.title', + pageGroup: 'Customers', + pageGroupKey: 'customers.nav.group', + pagePriority: 10, + pageOrder: 120, + icon: tasksIcon, + breadcrumb: [{ label: 'Customer related tasks', labelKey: 'customers.workPlan.customerTodos.page.title' }], +} diff --git a/apps/mercato/src/modules/example/backend/customer-tasks/page.tsx b/packages/core/src/modules/customers/backend/customer-tasks/page.tsx similarity index 51% rename from apps/mercato/src/modules/example/backend/customer-tasks/page.tsx rename to packages/core/src/modules/customers/backend/customer-tasks/page.tsx index 7c693224689..0fea49c4194 100644 --- a/apps/mercato/src/modules/example/backend/customer-tasks/page.tsx +++ b/packages/core/src/modules/customers/backend/customer-tasks/page.tsx @@ -1,7 +1,7 @@ import { Page, PageBody } from '@open-mercato/ui/backend/Page' -import { CustomerTodosTable } from '@open-mercato/core/modules/customers/components/CustomerTodosTable' +import { CustomerTodosTable } from '../../components/CustomerTodosTable' -export default function WorkPlanCustomerTasksPage() { +export default function CustomerTasksPage() { return ( diff --git a/packages/core/src/modules/customers/commands/interactions.ts b/packages/core/src/modules/customers/commands/interactions.ts index 6b6772b7b19..fea312251ba 100644 --- a/packages/core/src/modules/customers/commands/interactions.ts +++ b/packages/core/src/modules/customers/commands/interactions.ts @@ -53,6 +53,26 @@ const interactionCrudEvents: CrudEventsConfig = { id: ctx.identifiers.id, organizationId: ctx.identifiers.organizationId, tenantId: ctx.identifiers.tenantId, + entityId: + ctx.entity && typeof ctx.entity === 'object' && 'entity' in (ctx.entity as Record) + ? (() => { + const entityRef = (ctx.entity as CustomerInteraction).entity + return typeof entityRef === 'string' ? entityRef : entityRef?.id ?? null + })() + : null, + interactionType: + ctx.entity && typeof ctx.entity === 'object' && 'interactionType' in (ctx.entity as Record) + ? (ctx.entity as CustomerInteraction).interactionType + : null, + status: + ctx.entity && typeof ctx.entity === 'object' && 'status' in (ctx.entity as Record) + ? (ctx.entity as CustomerInteraction).status + : null, + source: + ctx.entity && typeof ctx.entity === 'object' && 'source' in (ctx.entity as Record) + ? (ctx.entity as CustomerInteraction).source ?? null + : null, + ...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}), }), } @@ -167,8 +187,12 @@ async function emitInteractionRevertedEvent( id: interaction.id, organizationId: interaction.organizationId, tenantId: interaction.tenantId, + entityId: interaction.entityId, + interactionType: interaction.interactionType, + source: interaction.source ?? null, status: interaction.status, occurredAt: interaction.occurredAt?.toISOString() ?? null, + ...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}), }) } @@ -287,6 +311,7 @@ const createInteractionCommand: CommandHandler; organizationId: interaction.organizationId, tenantId: interaction.tenantId, }, + syncOrigin: ctx.syncOrigin, indexer: interactionCrudIndexer, events: interactionCrudEvents, }) @@ -939,6 +986,7 @@ const deleteInteractionCommand: CommandHandler<{ body?: Record; organizationId: interaction.organizationId, tenantId: interaction.tenantId, }, + syncOrigin: ctx.syncOrigin, indexer: interactionCrudIndexer, events: interactionCrudEvents, }) diff --git a/packages/core/src/modules/customers/components/CustomerTodosTable.tsx b/packages/core/src/modules/customers/components/CustomerTodosTable.tsx index 22d117824b6..9a9ba47b28e 100644 --- a/packages/core/src/modules/customers/components/CustomerTodosTable.tsx +++ b/packages/core/src/modules/customers/components/CustomerTodosTable.tsx @@ -11,10 +11,10 @@ import { RowActions } from '@open-mercato/ui/backend/RowActions' import { BooleanIcon } from '@open-mercato/ui/backend/ValueIcons' import { flash } from '@open-mercato/ui/backend/FlashMessages' import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall' -import { buildCrudExportUrl } from '@open-mercato/ui/backend/utils/crud' import { Button } from '@open-mercato/ui/primitives/button' import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope' import { useT } from '@open-mercato/shared/lib/i18n/context' +import { resolveTodoHref } from './detail/utils' type CustomerTodoItem = { id: string @@ -31,6 +31,8 @@ type CustomerTodoItem = { organizationId: string tenantId: string createdAt: string + externalHref?: string | null + _integrations?: Record customer: { id: string | null displayName: string | null @@ -47,6 +49,7 @@ type CustomerTodosResponse = { } const TASKS_TAB_QUERY = 'tab=tasks' +const CUSTOMER_TASKS_API_PATH = '/api/customers/interactions/tasks' function buildCustomerHref(item: CustomerTodoItem): string | null { const customerId = item.customer?.id @@ -59,25 +62,31 @@ function buildCustomerHref(item: CustomerTodoItem): string | null { return `${base}?${TASKS_TAB_QUERY}` } -// SPEC-046b: To enable canonical interactions mode, switch this table to -// /api/customers/interactions?status=planned&pageSize=N&page=N&search=... -// -// Column mapping (InteractionSummary → CustomerTodoItem shape): -// interaction.id → todoId (also use as id) -// interaction.title → todoTitle -// interaction.status → todoIsDone: status === 'done' -// interaction.entityId → requires a separate lookup or API enricher -// to resolve customer displayName/kind -// -// The main gap is the `customer` sub-object: the interactions API does not -// embed customer details. Options: -// a) Add a response enricher to the interactions API that populates -// customer displayName/kind from entityId -// b) Fetch customer details client-side for visible rows -// c) Add an /api/customers/interactions/table endpoint that joins entities -// -// Export config would switch from 'customers/todos' to 'customers/interactions' -// with exportScope: 'full' and an adjusted column mapping. +function buildCustomerTasksQueryString(input: { + page: number + pageSize: number + search: string + all?: boolean +}): string { + const usp = new URLSearchParams({ + page: String(input.page), + pageSize: String(input.pageSize), + }) + if (input.search.trim().length > 0) usp.set('search', input.search.trim()) + if (input.all) usp.set('all', 'true') + return usp.toString() +} + +function readValueAtPath(record: Record, path: string): unknown { + const segments = path.split('.').filter((segment) => segment.length > 0) + let current: unknown = record + for (const segment of segments) { + if (!current || typeof current !== 'object') return null + current = (current as Record)[segment] + } + return current ?? null +} + export function CustomerTodosTable(): React.JSX.Element { const t = useT() const router = useRouter() @@ -87,14 +96,11 @@ export function CustomerTodosTable(): React.JSX.Element { const [page, setPage] = React.useState(1) const [pageSize] = React.useState(50) - const params = React.useMemo(() => { - const usp = new URLSearchParams({ - page: String(page), - pageSize: String(pageSize), - }) - if (search.trim().length > 0) usp.set('search', search.trim()) - return usp.toString() - }, [page, pageSize, search]) + const params = React.useMemo(() => buildCustomerTasksQueryString({ + page, + pageSize, + search, + }), [page, pageSize, search]) const columns = React.useMemo[]>(() => [ { @@ -118,10 +124,10 @@ export function CustomerTodosTable(): React.JSX.Element { header: t('customers.workPlan.customerTodos.table.column.todo'), cell: ({ row }) => { const title = row.original.todoTitle ?? t('customers.workPlan.customerTodos.table.column.todo.unnamed') - const todoId = row.original.todoId - if (!todoId) return {title} + const todoHref = row.original.externalHref ?? resolveTodoHref(row.original.todoSource, row.original.todoId) + if (!todoHref) return {title} return ( - + {title} ) @@ -150,15 +156,30 @@ export function CustomerTodosTable(): React.JSX.Element { .filter((col): col is { field: string; header: string } => !!col) }, [columns]) - const { data, isLoading, error, refetch, isFetching } = useQuery({ - queryKey: ['customers-todos', params, scopeVersion], - queryFn: async () => { - return readApiResultOrThrow( - `/api/customers/todos?${params}`, - undefined, - { errorMessage: t('customers.workPlan.customerTodos.table.error.load') }, + const buildPreparedExport = React.useCallback(( + exportRows: CustomerTodoItem[], + exportColumns: Array<{ field: string; header: string }>, + ): PreparedExport => ({ + columns: exportColumns.map((col) => ({ field: col.field, header: col.header })), + rows: exportRows.map((row) => { + const record = row as Record + return Object.fromEntries( + exportColumns.map((col) => [col.field, readValueAtPath(record, col.field)]), ) - }, + }), + }), []) + + const fetchTasks = React.useCallback(async (queryString: string): Promise => { + return readApiResultOrThrow( + `${CUSTOMER_TASKS_API_PATH}?${queryString}`, + undefined, + { errorMessage: t('customers.workPlan.customerTodos.table.error.load') }, + ) + }, [t]) + + const { data, isLoading, error, refetch, isFetching } = useQuery({ + queryKey: ['customers-interactions-tasks', params, scopeVersion], + queryFn: async () => fetchTasks(params), placeholderData: keepPreviousData, }) @@ -168,27 +189,28 @@ export function CustomerTodosTable(): React.JSX.Element { view: { description: t('customers.workPlan.customerTodos.table.export.view'), prepare: async (): Promise<{ prepared: PreparedExport; filename: string }> => { - const rowsForExport = rows.map((row) => { - const out: Record = {} - for (const col of viewExportColumns) { - out[col.field] = (row as Record)[col.field] - } - return out - }) - const prepared: PreparedExport = { - columns: viewExportColumns.map((col) => ({ field: col.field, header: col.header })), - rows: rowsForExport, + return { + prepared: buildPreparedExport(rows, viewExportColumns), + filename: 'customer_todos_view', } - return { prepared, filename: 'customer_todos_view' } }, }, full: { description: t('customers.workPlan.customerTodos.table.export.full'), - getUrl: (format: DataTableExportFormat) => - buildCrudExportUrl('customers/todos', { exportScope: 'full', all: 'true' }, format), - filename: () => 'customer_todos_full', + prepare: async (_format: DataTableExportFormat): Promise<{ prepared: PreparedExport; filename: string }> => { + const fullData = await fetchTasks(buildCustomerTasksQueryString({ + page: 1, + pageSize, + search, + all: true, + })) + return { + prepared: buildPreparedExport(fullData.items, viewExportColumns), + filename: 'customer_todos_full', + } + }, }, - }), [rows, t, viewExportColumns]) + }), [buildPreparedExport, fetchTasks, pageSize, rows, search, t, viewExportColumns]) const handleRefresh = React.useCallback(async () => { try { @@ -234,18 +256,21 @@ export function CustomerTodosTable(): React.JSX.Element { perspective={{ tableId: 'customers.todos.list' }} rowActions={(row) => { const customerLink = buildCustomerHref(row) - if (!customerLink) return null - return ( - - ) + const todoHref = row.externalHref ?? resolveTodoHref(row.todoSource, row.todoId) + const items = [ + customerLink ? { + id: 'open-customer', + label: t('customers.workPlan.customerTodos.table.actions.openCustomer'), + href: customerLink, + } : null, + todoHref ? { + id: 'open-task', + label: t('customers.workPlan.customerTodos.table.actions.openTask'), + href: todoHref, + } : null, + ].filter((item): item is { id: string; label: string; href: string } => !!item) + if (!items.length) return null + return }} onRowClick={handleNavigate} pagination={{ diff --git a/packages/core/src/modules/customers/components/detail/TasksSection.tsx b/packages/core/src/modules/customers/components/detail/TasksSection.tsx index 5cbdef9730b..66f92cc233b 100644 --- a/packages/core/src/modules/customers/components/detail/TasksSection.tsx +++ b/packages/core/src/modules/customers/components/detail/TasksSection.tsx @@ -449,7 +449,7 @@ export function TasksSection({ ) : null} {sortedTasks.map((task) => { - const todoHref = resolveTodoHref(task.todoSource, task.todoId) + const todoHref = task.externalHref ?? resolveTodoHref(task.todoSource, task.todoId) const createdLabel = formatDateTime(task.createdAt) ?? emptyLabel const meta = renderTaskMeta(task) const title = task.title ?? t('customers.people.detail.tasks.untitled', 'Untitled task') @@ -570,15 +570,15 @@ export function TasksSection({ {t('customers.people.detail.tasks.loadingMore', 'Loading…')} ) : null} -
- -
) : null} +
+ +
({ + usePersonTasks: (...args: unknown[]) => usePersonTasksMock(...args), +})) + +jest.mock('../hooks/useInteractions', () => ({ + useInteractions: (...args: unknown[]) => useInteractionsMock(...args), +})) + +jest.mock('../TaskDialog', () => ({ + TaskDialog: () => null, +})) + +jest.mock('@open-mercato/ui/backend/detail', () => ({ + LoadingMessage: () => null, + TabEmptyState: ({ title }: { title: string }) =>
{title}
, +})) + +jest.mock('../../../lib/interactionCompatibility', () => ({ + mapInteractionRecordToTodoSummary: jest.fn((interaction: unknown) => interaction), +})) + +describe('TasksSection', () => { + beforeEach(() => { + jest.clearAllMocks() + usePersonTasksMock.mockReturnValue({ + tasks: [], + isInitialLoading: false, + isLoadingMore: false, + isMutating: false, + hasMore: false, + pendingTaskId: null, + error: null, + loadMore: jest.fn(async () => undefined), + refresh: jest.fn(async () => undefined), + createTask: jest.fn(async () => undefined), + updateTask: jest.fn(async () => undefined), + toggleTask: jest.fn(async () => undefined), + unlinkTask: jest.fn(async () => undefined), + }) + useInteractionsMock.mockReturnValue({ + interactions: [], + isInitialLoading: false, + isLoadingMore: false, + isMutating: false, + hasMore: false, + pendingId: null, + error: null, + loadMore: jest.fn(async () => undefined), + refresh: jest.fn(async () => undefined), + createInteraction: jest.fn(async () => undefined), + updateInteraction: jest.fn(async () => undefined), + completeInteraction: jest.fn(async () => undefined), + deleteInteraction: jest.fn(async () => undefined), + }) + }) + + it('keeps the View all tasks navigation visible even when the task list is empty', () => { + renderWithProviders( + , + ) + + expect(screen.getByRole('link', { name: 'View all tasks' })).toHaveAttribute('href', '/backend/customer-tasks') + }) +}) diff --git a/packages/core/src/modules/customers/components/detail/__tests__/utils.test.ts b/packages/core/src/modules/customers/components/detail/__tests__/utils.test.ts new file mode 100644 index 00000000000..6c95647763a --- /dev/null +++ b/packages/core/src/modules/customers/components/detail/__tests__/utils.test.ts @@ -0,0 +1,13 @@ +import { resolveTodoHref } from '../utils' + +describe('resolveTodoHref', () => { + it('uses the Example module editor path for legacy example todos', () => { + expect(resolveTodoHref('example:todo', '11111111-1111-1111-1111-111111111111')).toBe( + '/backend/todos/11111111-1111-1111-1111-111111111111/edit', + ) + }) + + it('keeps canonical interaction tasks non-linkable without an external integration href', () => { + expect(resolveTodoHref('customers:interaction', '11111111-1111-1111-1111-111111111111')).toBeNull() + }) +}) diff --git a/packages/core/src/modules/customers/components/detail/hooks/usePersonTasks.ts b/packages/core/src/modules/customers/components/detail/hooks/usePersonTasks.ts index 392f5666677..d2cc70b7881 100644 --- a/packages/core/src/modules/customers/components/detail/hooks/usePersonTasks.ts +++ b/packages/core/src/modules/customers/components/detail/hooks/usePersonTasks.ts @@ -6,8 +6,9 @@ import { resolveTodoApiPath } from '../utils' import type { TodoLinkSummary } from '../types' import { generateTempId } from '@open-mercato/core/modules/customers/lib/detailHelpers' import { parseBooleanToken } from '@open-mercato/shared/lib/boolean' +import { CUSTOMER_INTERACTION_TASK_SOURCE } from '../../../lib/interactionCompatibility' -const DEFAULT_TODO_SOURCE = 'example:todo' +const DEFAULT_TODO_SOURCE = CUSTOMER_INTERACTION_TASK_SOURCE type CustomerTodoRow = { id: string diff --git a/packages/core/src/modules/customers/components/detail/types.ts b/packages/core/src/modules/customers/components/detail/types.ts index b76de247a67..84471bc4b07 100644 --- a/packages/core/src/modules/customers/components/detail/types.ts +++ b/packages/core/src/modules/customers/components/detail/types.ts @@ -109,6 +109,7 @@ export type TodoLinkSummary = { dueAt?: string | null todoOrganizationId?: string | null customValues?: Record | null + externalHref?: string | null } export type InteractionSummary = { @@ -133,6 +134,11 @@ export type InteractionSummary = { authorEmail?: string | null dealTitle?: string | null customValues?: Record | null + customer?: { + id: string | null + displayName: string | null + kind: string | null + } | null _integrations?: Record createdAt: string updatedAt: string diff --git a/packages/core/src/modules/customers/components/detail/utils.ts b/packages/core/src/modules/customers/components/detail/utils.ts index bf8cfacc9e1..f817c4c8d3b 100644 --- a/packages/core/src/modules/customers/components/detail/utils.ts +++ b/packages/core/src/modules/customers/components/detail/utils.ts @@ -39,6 +39,9 @@ export function resolveTodoHref(source: string, todoId: string | null | undefine if (source === CUSTOMER_INTERACTION_TASK_SOURCE || source === CUSTOMER_INTERACTION_TASK_TYPE) return null const [module] = source.split(':') if (!module) return null + if (module === 'example') { + return `/backend/todos/${encodeURIComponent(todoId)}/edit` + } return `/backend/${module}/todos/${encodeURIComponent(todoId)}/edit` } diff --git a/packages/core/src/modules/customers/data/entities.ts b/packages/core/src/modules/customers/data/entities.ts index 3db139a36b9..1efb08d3ba7 100644 --- a/packages/core/src/modules/customers/data/entities.ts +++ b/packages/core/src/modules/customers/data/entities.ts @@ -809,8 +809,8 @@ export class CustomerTodoLink { @Property({ name: 'todo_id', type: 'uuid' }) todoId!: string - @Property({ name: 'todo_source', type: 'text', default: 'example:todo' }) - todoSource: string = 'example:todo' + @Property({ name: 'todo_source', type: 'text', default: 'customers:interaction' }) + todoSource: string = 'customers:interaction' @Property({ name: 'created_at', type: Date, onCreate: () => new Date() }) createdAt: Date = new Date() diff --git a/packages/core/src/modules/customers/data/validators.ts b/packages/core/src/modules/customers/data/validators.ts index 269d961708e..18fbf235868 100644 --- a/packages/core/src/modules/customers/data/validators.ts +++ b/packages/core/src/modules/customers/data/validators.ts @@ -282,7 +282,7 @@ export const tagAssignmentSchema = scopedSchema.extend({ export const todoLinkCreateSchema = scopedSchema.extend({ entityId: uuid(), todoId: uuid(), - todoSource: z.string().min(1).max(120).default('example:todo'), + todoSource: z.string().min(1).max(120).default('customers:interaction'), createdByUserId: uuid().optional(), }) @@ -291,7 +291,7 @@ export const todoLinkWithTodoCreateSchema = scopedSchema.extend({ title: z.string().min(1).max(200), isDone: z.boolean().optional(), is_done: z.boolean().optional(), - todoSource: z.string().min(1).max(120).default('example:todo'), + todoSource: z.string().min(1).max(120).default('customers:interaction'), createdByUserId: uuid().optional(), todoCustom: z.record(z.string(), z.any()).optional(), custom: z.record(z.string(), z.any()).optional(), diff --git a/packages/core/src/modules/customers/lib/interactionCompatibility.ts b/packages/core/src/modules/customers/lib/interactionCompatibility.ts index c3f6290dd8e..13b8868ec43 100644 --- a/packages/core/src/modules/customers/lib/interactionCompatibility.ts +++ b/packages/core/src/modules/customers/lib/interactionCompatibility.ts @@ -5,6 +5,7 @@ export const CUSTOMER_INTERACTION_TASK_SOURCE = 'customers:interaction' export const CUSTOMER_INTERACTION_TASK_TYPE = 'task' export const CUSTOMER_INTERACTION_ACTIVITY_ADAPTER_SOURCE = 'adapter:activity' export const CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE = 'adapter:todo' +export const EXAMPLE_TODO_SOURCE = 'example:todo' export type InteractionRecord = InteractionSummary & { authorName?: string | null @@ -48,6 +49,7 @@ export function mapInteractionRecordToActivitySummary(interaction: InteractionRe export function mapInteractionRecordToTodoSummary(interaction: InteractionRecord): TodoLinkSummary { const customValues: Record = { ...(interaction.customValues ?? {}) } + const externalHref = resolveExampleIntegrationHref(interaction) if (interaction.priority !== undefined) customValues.priority = interaction.priority if (interaction.body !== undefined && customValues.description === undefined) { customValues.description = interaction.body ?? null @@ -73,5 +75,19 @@ export function mapInteractionRecordToTodoSummary(interaction: InteractionRecord dueAt: interaction.scheduledAt ?? null, todoOrganizationId: null, customValues: Object.keys(customValues).length > 0 ? customValues : null, + externalHref, } } + +type IntegrationCarrier = { + _integrations?: { + example?: { href?: string | null; syncStatus?: string | null; [key: string]: unknown } + [key: string]: unknown + } +} + +export function resolveExampleIntegrationHref(item: IntegrationCarrier): string | null { + const example = item._integrations?.example + if (!example || typeof example !== 'object') return null + return typeof example.href === 'string' && example.href.trim().length > 0 ? example.href : null +} diff --git a/packages/core/src/modules/customers/lib/todoCompatibility.ts b/packages/core/src/modules/customers/lib/todoCompatibility.ts index 7cb328bb9eb..434b7483f1c 100644 --- a/packages/core/src/modules/customers/lib/todoCompatibility.ts +++ b/packages/core/src/modules/customers/lib/todoCompatibility.ts @@ -1,9 +1,18 @@ +import type { EntityManager } from '@mikro-orm/postgresql' import type { QueryEngine } from '@open-mercato/shared/lib/query/types' import type { EntityId } from '@open-mercato/shared/modules/entities' import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean' -import type { CustomerTodoLink } from '../data/entities' +import { + CustomerInteraction, + CustomerTodoLink, +} from '../data/entities' import type { InteractionRecord } from './interactionCompatibility' -import { CUSTOMER_INTERACTION_TASK_SOURCE } from './interactionCompatibility' +import { + CUSTOMER_INTERACTION_TASK_SOURCE, + EXAMPLE_TODO_SOURCE, + resolveExampleIntegrationHref, +} from './interactionCompatibility' +import { hydrateCanonicalInteractions, loadCustomerSummaries } from './interactionReadModel' export type CustomerTodoRow = { id: string @@ -20,6 +29,8 @@ export type CustomerTodoRow = { organizationId: string tenantId: string createdAt: string + externalHref?: string | null + _integrations?: Record customer: { id: string | null displayName: string | null @@ -44,6 +55,29 @@ type CustomerSummary = { kind: string | null } +type CustomersAuthLike = { + tenantId: string | null + orgId?: string | null + sub?: string | null + userId?: string | null + keyId?: string | null +} + +type CustomersContainerLike = { + resolve: (name: string) => unknown +} + +export type CanonicalTodoListResult = { + items: CustomerTodoRow[] + bridgeIds: Set +} + +function resolveLegacyTodoSource(source: string | null | undefined): string { + return typeof source === 'string' && source.trim().length > 0 + ? source + : EXAMPLE_TODO_SOURCE +} + function extractTodoTitle(record: Record): string | null { const candidates = ['title', 'subject', 'name', 'summary', 'text', 'description'] for (const key of candidates) { @@ -88,6 +122,64 @@ function readCustomField(record: Record, key: string): unknown return undefined } +export function normalizeTodoSearch(value: string | undefined): string | null { + if (typeof value !== 'string') return null + const trimmed = value.trim().toLowerCase() + return trimmed.length > 0 ? trimmed : null +} + +export function sortTodoRows(rows: CustomerTodoRow[]): CustomerTodoRow[] { + return [...rows].sort((left, right) => { + const leftTime = new Date(left.createdAt).getTime() + const rightTime = new Date(right.createdAt).getTime() + if (leftTime === rightTime) { + return right.id.localeCompare(left.id) + } + return rightTime - leftTime + }) +} + +export function filterTodoRows(rows: CustomerTodoRow[], search: string | null): CustomerTodoRow[] { + if (!search) return rows + return rows.filter((row) => { + const haystack = [ + row.customer.displayName, + row.todoTitle, + row.todoDescription, + ] + .filter((value): value is string => typeof value === 'string' && value.trim().length > 0) + .join(' ') + .toLowerCase() + return haystack.includes(search) + }) +} + +export function paginateTodoRows( + rows: CustomerTodoRow[], + page: number, + pageSize: number, + exportAll: boolean, +): { items: CustomerTodoRow[]; total: number; page: number; pageSize: number; totalPages: number } { + const total = rows.length + if (exportAll) { + return { + items: rows, + total, + page: 1, + pageSize: total, + totalPages: 1, + } + } + const start = (page - 1) * pageSize + return { + items: rows.slice(start, start + pageSize), + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + } +} + export async function resolveLegacyTodoDetails( queryEngine: QueryEngine, links: CustomerTodoLink[], @@ -103,10 +195,7 @@ export async function resolveLegacyTodoDetails( const idsBySource = new Map>() for (const link of links) { - const source = - typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 - ? link.todoSource - : 'example:todo' + const source = resolveLegacyTodoSource(link.todoSource) const id = typeof link.todoId === 'string' && link.todoId.trim().length > 0 ? link.todoId @@ -264,6 +353,136 @@ export async function resolveLegacyTodoDetails( return details } +export async function listLegacyTodoRows( + em: EntityManager, + queryEngine: QueryEngine, + tenantId: string, + organizationIds: string[] | null, + entityId: string | undefined, +): Promise { + const where: Record = { tenantId } + if (organizationIds && organizationIds.length > 0) { + where.organizationId = { $in: organizationIds } + } + if (entityId) { + where.entity = entityId + } + + const links = await em.find(CustomerTodoLink, where, { + populate: ['entity'], + orderBy: { createdAt: 'desc' }, + }) + const details = await resolveLegacyTodoDetails( + queryEngine, + links, + tenantId, + organizationIds ?? [], + ) + + return links.map((link) => { + const source = resolveLegacyTodoSource(link.todoSource) + return mapLegacyTodoLinkToRow( + link, + details.get(`${source}:${link.todoId}`) ?? null, + ) + }) +} + +export async function listCanonicalTodoRows( + em: EntityManager, + container: CustomersContainerLike, + auth: CustomersAuthLike, + selectedOrganizationId: string | null, + organizationIds: string[] | null, + options?: { + entityId?: string + includeDeleted?: boolean + source?: string | string[] | null + }, +): Promise { + const where: Record = { + tenantId: auth.tenantId, + interactionType: 'task', + } + if (!options?.includeDeleted) { + where.deletedAt = null + } + if (organizationIds && organizationIds.length > 0) { + where.organizationId = { $in: organizationIds } + } + if (options?.entityId) { + where.entity = options.entityId + } + if (options?.source) { + where.source = Array.isArray(options.source) ? { $in: options.source } : options.source + } + + const interactions = await em.find(CustomerInteraction, where, { + orderBy: { createdAt: 'desc' }, + }) + const activeInteractions = interactions.filter((interaction) => !interaction.deletedAt) + const groups = new Map() + + for (const interaction of activeInteractions) { + const organizationId = + typeof interaction.organizationId === 'string' && interaction.organizationId.trim().length > 0 + ? interaction.organizationId + : selectedOrganizationId ?? '' + const bucket = groups.get(organizationId) + if (bucket) { + bucket.push(interaction) + } else { + groups.set(organizationId, [interaction]) + } + } + + const rowByInteractionId = new Map() + + for (const [groupOrganizationId, groupedInteractions] of groups.entries()) { + const scopedOrganizationId = groupOrganizationId.length > 0 ? groupOrganizationId : null + const hydrated = await hydrateCanonicalInteractions({ + em, + container, + auth: { + ...auth, + orgId: auth.orgId ?? null, + }, + selectedOrganizationId: scopedOrganizationId, + interactions: groupedInteractions, + }) + const customerIds = Array.from( + new Set( + hydrated + .map((interaction) => interaction.entityId ?? null) + .filter((value): value is string => !!value), + ), + ) + const customerSummaries = await loadCustomerSummaries( + em, + customerIds, + auth.tenantId, + scopedOrganizationId, + ) + + for (const interaction of hydrated) { + rowByInteractionId.set( + interaction.id, + mapInteractionRecordToTodoRow( + interaction, + interaction.entityId ? customerSummaries.get(interaction.entityId) ?? null : null, + ), + ) + } + } + + return { + items: activeInteractions + .map((interaction) => rowByInteractionId.get(interaction.id) ?? null) + .filter((row): row is CustomerTodoRow => !!row), + bridgeIds: new Set(interactions.map((interaction) => interaction.id)), + } +} + export function mapLegacyTodoLinkToRow( link: CustomerTodoLink, detail: LegacyTodoDetail | null, @@ -278,10 +497,7 @@ export function mapLegacyTodoLinkToRow( return { id: link.id, todoId: link.todoId, - todoSource: - typeof link.todoSource === 'string' && link.todoSource.trim().length > 0 - ? link.todoSource - : 'example:todo', + todoSource: resolveLegacyTodoSource(link.todoSource), todoTitle: detail?.title ?? null, todoIsDone: detail?.isDone ?? null, todoPriority: detail?.priority ?? null, @@ -293,6 +509,7 @@ export function mapLegacyTodoLinkToRow( organizationId: link.organizationId, tenantId: link.tenantId, createdAt: link.createdAt.toISOString(), + _integrations: undefined, customer: entity, } } @@ -337,6 +554,8 @@ export function mapInteractionRecordToTodoRow( organizationId: interaction.organizationId ?? '', tenantId: interaction.tenantId ?? '', createdAt: interaction.createdAt, + externalHref: resolveExampleIntegrationHref(interaction), + _integrations: interaction._integrations ?? undefined, customer: customer ?? { id: interaction.entityId ?? null, displayName: null, diff --git a/packages/core/src/modules/customers/migrations/.snapshot-open-mercato.json b/packages/core/src/modules/customers/migrations/.snapshot-open-mercato.json index 88d2b48780f..3412eeefcc8 100644 --- a/packages/core/src/modules/customers/migrations/.snapshot-open-mercato.json +++ b/packages/core/src/modules/customers/migrations/.snapshot-open-mercato.json @@ -4045,7 +4045,7 @@ "length": null, "precision": null, "scale": null, - "default": "'example:todo'", + "default": "'customers:interaction'", "comment": null, "enumItems": [], "mappedType": "text" diff --git a/packages/core/src/modules/customers/migrations/Migration20260401172819.ts b/packages/core/src/modules/customers/migrations/Migration20260401172819.ts new file mode 100644 index 00000000000..0303a33248b --- /dev/null +++ b/packages/core/src/modules/customers/migrations/Migration20260401172819.ts @@ -0,0 +1,45 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260401172819 extends Migration { + + override async up(): Promise { + this.addSql(`alter table "customer_todo_links" alter column "todo_source" set default 'customers:interaction';`); + this.addSql(` + update "role_acls" as ra + set + "features_json" = case + when ra."features_json" is null or jsonb_typeof(ra."features_json") <> 'array' + then '["customers.interactions.view"]'::jsonb + else ra."features_json" || '"customers.interactions.view"'::jsonb + end, + "updated_at" = now() + where ra."deleted_at" is null + and ra."features_json" is not null + and jsonb_typeof(ra."features_json") = 'array' + and ra."features_json" ? 'example.todos.view' + and ra."features_json" ? 'customers.activities.view' + and not (ra."features_json" ? 'customers.interactions.view'); + `); + this.addSql(` + update "user_acls" as ua + set + "features_json" = case + when ua."features_json" is null or jsonb_typeof(ua."features_json") <> 'array' + then '["customers.interactions.view"]'::jsonb + else ua."features_json" || '"customers.interactions.view"'::jsonb + end, + "updated_at" = now() + where ua."deleted_at" is null + and ua."features_json" is not null + and jsonb_typeof(ua."features_json") = 'array' + and ua."features_json" ? 'example.todos.view' + and ua."features_json" ? 'customers.activities.view' + and not (ua."features_json" ? 'customers.interactions.view'); + `); + } + + override async down(): Promise { + this.addSql(`alter table "customer_todo_links" alter column "todo_source" set default 'example:todo';`); + } + +} diff --git a/packages/core/src/modules/customers/search.ts b/packages/core/src/modules/customers/search.ts index 914de8d9e98..93fba898ce6 100644 --- a/packages/core/src/modules/customers/search.ts +++ b/packages/core/src/modules/customers/search.ts @@ -6,6 +6,7 @@ import type { SearchResultLink, SearchIndexSource, } from '@open-mercato/shared/modules/search' +import { CUSTOMER_INTERACTION_TASK_SOURCE, EXAMPLE_TODO_SOURCE } from './lib/interactionCompatibility' // ============================================================================= // Context Types @@ -346,9 +347,9 @@ async function getLinkedTodo(ctx: SearchContext) { if (todoCache.has(ctx.record)) { return todoCache.get(ctx.record) } - const sourceRaw = typeof ctx.record.todo_source === 'string' ? ctx.record.todo_source : 'example:todo' + const sourceRaw = typeof ctx.record.todo_source === 'string' ? ctx.record.todo_source : EXAMPLE_TODO_SOURCE const [moduleId, entityName] = sourceRaw.split(':') - const entityId = moduleId && entityName ? `${moduleId}:${entityName}` : 'example:todo' + const entityId = moduleId && entityName ? `${moduleId}:${entityName}` : CUSTOMER_INTERACTION_TASK_SOURCE const todo = await loadRecord(ctx, entityId, ctx.record.todo_id as string ?? ctx.record.todoId as string) todoCache.set(ctx.record, todo ?? null) return todo ?? null diff --git a/packages/core/src/modules/customers/widgets/dashboard/customer-todos/widget.client.tsx b/packages/core/src/modules/customers/widgets/dashboard/customer-todos/widget.client.tsx index d99356fa9a6..d63a033f458 100644 --- a/packages/core/src/modules/customers/widgets/dashboard/customer-todos/widget.client.tsx +++ b/packages/core/src/modules/customers/widgets/dashboard/customer-todos/widget.client.tsx @@ -7,6 +7,8 @@ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall' import { Spinner } from '@open-mercato/ui/primitives/spinner' import { useT } from '@open-mercato/shared/lib/i18n/context' import { DEFAULT_SETTINGS, hydrateCustomerTodoSettings, type CustomerTodoWidgetSettings } from './config' +import { resolveExampleIntegrationHref } from '../../../lib/interactionCompatibility' +import { resolveTodoHref } from '../../../components/detail/utils' type TodoLinkSummary = { id: string @@ -14,6 +16,12 @@ type TodoLinkSummary = { todoSource: string todoTitle: string | null createdAt: string + _integrations?: { + example?: { + href?: string | null + } + [key: string]: unknown + } entity: { id: string | null displayName: string | null @@ -21,23 +29,6 @@ type TodoLinkSummary = { } } -// SPEC-046b: To enable canonical interactions mode, switch from -// /api/customers/dashboard/widgets/customer-todos to -// /api/customers/interactions?status=planned&pageSize={pageSize} -// -// Response mapping (InteractionSummary → TodoLinkSummary): -// interaction.id → id, todoId -// interaction.interactionType → todoSource -// interaction.title → todoTitle -// interaction.createdAt → createdAt -// interaction.entityId → entity.id (kind/displayName need enrichment -// or a follow-up customer lookup) -// -// The main gap is the `entity` sub-object: the interactions API returns a flat -// entityId but not customer displayName/kind. Options: -// a) Add an enricher to the interactions list API that resolves entity details -// b) Batch-fetch customer details client-side after loading interactions -// c) Add a dedicated /api/customers/interactions/widget endpoint async function loadTodos(settings: CustomerTodoWidgetSettings): Promise { const params = new URLSearchParams({ limit: String(settings.pageSize), @@ -67,6 +58,9 @@ async function loadTodos(settings: CustomerTodoWidgetSettings): Promise { const createdLabel = formatDate(item.createdAt, locale) const href = resolveDetailHref(item.entity) + const exampleHref = resolveExampleIntegrationHref(item) + const taskHref = exampleHref ?? resolveTodoHref(item.todoSource, item.todoId) return (
  • @@ -185,13 +181,18 @@ const CustomerTodosWidget: React.FC{item.todoSource}

    ) : null}
    - {href ? ( -
    +
    + {href ? ( {t('customers.widgets.common.viewRecord')} -
    - ) : null} + ) : null} + {taskHref ? ( + + {t('customers.workPlan.customerTodos.table.actions.openTask')} + + ) : null} +
  • ) })} diff --git a/packages/create-app/template/src/modules.ts b/packages/create-app/template/src/modules.ts index 909a06f9778..41b8195ec8f 100644 --- a/packages/create-app/template/src/modules.ts +++ b/packages/create-app/template/src/modules.ts @@ -52,6 +52,10 @@ export const enabledModules: ModuleEntry[] = [ { id: 'example', from: '@app' }, ] +if (enabledModules.some((entry) => entry.id === 'example')) { + enabledModules.push({ id: 'example_customers_sync', from: '@app' }) +} + const enterpriseModulesEnabled = parseBooleanWithDefault(process.env.OM_ENABLE_ENTERPRISE_MODULES, false) const enterpriseSsoEnabled = parseBooleanWithDefault(process.env.OM_ENABLE_ENTERPRISE_MODULES_SSO, false) const enterpriseSecurityEnabled = parseBooleanWithDefault(process.env.OM_ENABLE_ENTERPRISE_MODULES_SECURITY, false) @@ -69,4 +73,4 @@ if (enterpriseModulesEnabled && enterpriseSsoEnabled) { if (enterpriseModulesEnabled && enterpriseSecurityEnabled) { enabledModules.push({ id: 'security', from: '@open-mercato/enterprise' }) -} \ No newline at end of file +} diff --git a/packages/create-app/template/src/modules/example/__integration__/TC-UMES-021.spec.ts b/packages/create-app/template/src/modules/example/__integration__/TC-UMES-021.spec.ts new file mode 100644 index 00000000000..c5b0fec6ca5 --- /dev/null +++ b/packages/create-app/template/src/modules/example/__integration__/TC-UMES-021.spec.ts @@ -0,0 +1,145 @@ +import { test, expect } from '@playwright/test' +import { + apiRequest, + getAuthToken, +} from '@open-mercato/core/helpers/integration/api' +import { deleteEntityIfExists } from '@open-mercato/core/helpers/integration/crmFixtures' + +test.describe('Todo priority validation', () => { + let adminToken: string + + test.beforeAll(async ({ request }) => { + adminToken = await getAuthToken(request, 'admin') + }) + + test('rejects priorities above the configured max', async ({ request }) => { + const response = await apiRequest(request, 'POST', '/api/example/todos', { + token: adminToken, + data: { + title: `QA invalid priority ${Date.now()}`, + cf_priority: 8, + cf_severity: 'high', + }, + }) + + expect(response.status()).toBe(400) + const body = await response.json() as { + error?: string + fields?: Record + } + expect(body.error).toBe('Validation failed') + expect(body.fields?.cf_priority).toBe('Priority must be <= 5') + }) + + test('accepts priorities inside the configured range', async ({ request }) => { + let todoId: string | null = null + try { + const response = await apiRequest(request, 'POST', '/api/example/todos', { + token: adminToken, + data: { + title: `QA valid priority ${Date.now()}`, + cf_priority: 5, + cf_severity: 'medium', + }, + }) + + expect(response.ok()).toBeTruthy() + const body = await response.json() as { id?: string } + todoId = body.id ?? null + expect(todoId).toBeTruthy() + } finally { + await deleteEntityIfExists(request, adminToken, '/api/example/todos', todoId) + } + }) + + test('shows the max-priority error on blur before submit', async ({ page }) => { + test.slow() + const { login } = await import('@open-mercato/core/helpers/integration/auth') + await login(page, 'admin') + await page.goto('/backend/todos/create', { waitUntil: 'commit' }) + + const priorityField = page.locator('[data-crud-field-id="cf_priority"]').first() + const priorityInput = priorityField.locator('input[type="number"]').first() + + await expect(priorityInput).toBeVisible() + await priorityInput.scrollIntoViewIfNeeded() + await priorityInput.click() + await priorityInput.type('8') + await page.keyboard.press('Tab') + + await expect(priorityField.getByText('Priority must be <= 5')).toBeVisible() + + await priorityInput.fill('5') + await page.keyboard.press('Tab') + + await expect(priorityField.getByText('Priority must be <= 5')).toHaveCount(0) + }) + + test('accepts corrected priority after blur validation and creates the todo', async ({ page, request }) => { + test.slow() + const { login } = await import('@open-mercato/core/helpers/integration/auth') + const title = `QA corrected priority ${Date.now()}` + let createdIds: string[] = [] + + try { + await login(page, 'admin') + await page.goto('/backend/todos/create', { waitUntil: 'commit' }) + + const titleInput = page.locator('[data-crud-field-id="title"] input').first() + const priorityField = page.locator('[data-crud-field-id="cf_priority"]').first() + const priorityInput = priorityField.locator('input[type="number"]').first() + const severityField = page.locator('[data-crud-field-id="cf_severity"]').first() + const form = page.locator('[data-crud-field-id="title"]').first().locator('xpath=ancestor::form').first() + + await expect(severityField.locator('select')).toBeVisible() + await expect(titleInput).toBeVisible() + await titleInput.fill(title) + await expect(priorityInput).toBeVisible() + await priorityInput.scrollIntoViewIfNeeded() + await priorityInput.click() + await priorityInput.type('8') + await page.keyboard.press('Tab') + + await expect(priorityField.getByText('Priority must be <= 5')).toBeVisible() + + await priorityInput.fill('5') + await severityField.locator('select').selectOption('medium') + await form.locator('button[type="submit"]').first().click() + + await expect(page).toHaveURL(/\/backend\/todos(?:\?.*)?$/) + + const response = await apiRequest( + request, + 'GET', + `/api/example/todos?title=${encodeURIComponent(title)}&page=1&pageSize=10`, + { token: adminToken }, + ) + expect(response.ok()).toBeTruthy() + const body = await response.json() as { items?: Array<{ id?: string | null }> } + createdIds = (body.items ?? []) + .map((item) => item.id) + .filter((itemId): itemId is string => typeof itemId === 'string' && itemId.length > 0) + expect(createdIds.length).toBeGreaterThan(0) + } finally { + if (createdIds.length === 0) { + const response = await apiRequest( + request, + 'GET', + `/api/example/todos?title=${encodeURIComponent(title)}&page=1&pageSize=10`, + { token: adminToken }, + ) + + if (response.ok()) { + const body = await response.json() as { items?: Array<{ id?: string | null }> } + createdIds = (body.items ?? []) + .map((item) => item.id) + .filter((itemId): itemId is string => typeof itemId === 'string' && itemId.length > 0) + } + } + + for (const itemId of createdIds) { + await deleteEntityIfExists(request, adminToken, '/api/example/todos', itemId) + } + } + }) +}) diff --git a/packages/create-app/template/src/modules/example/backend/customer-tasks/page.meta.ts b/packages/create-app/template/src/modules/example/backend/customer-tasks/page.meta.ts deleted file mode 100644 index e3de63ce16c..00000000000 --- a/packages/create-app/template/src/modules/example/backend/customer-tasks/page.meta.ts +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react' - -const usersIcon = React.createElement( - 'svg', - { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 }, - React.createElement('path', { d: 'M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2' }), - React.createElement('circle', { cx: 9, cy: 7, r: 4 }), - React.createElement('path', { d: 'M23 21v-2a4 4 0 0 0-3-3.87' }), - React.createElement('path', { d: 'M16 3.13a4 4 0 0 1 0 7.75' }), -) - -export const metadata = { - requireAuth: true, - requireFeatures: ['example.todos.view', 'customers.activities.view'], - pageTitle: 'Customer related tasks', - pageTitleKey: 'customers.workPlan.customerTodos.page.title', - pageGroup: 'Work plan', - pageGroupKey: 'example.workPlan.nav.group', - pageOrder: 122, - icon: usersIcon, - breadcrumb: [ - { label: 'General tasks', labelKey: 'example.todos.page.title', href: '/backend/todos' }, - { label: 'Customer related tasks', labelKey: 'customers.workPlan.customerTodos.page.title' }, - ], -} diff --git a/packages/create-app/template/src/modules/example/backend/customer-tasks/page.tsx b/packages/create-app/template/src/modules/example/backend/customer-tasks/page.tsx deleted file mode 100644 index 7c693224689..00000000000 --- a/packages/create-app/template/src/modules/example/backend/customer-tasks/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Page, PageBody } from '@open-mercato/ui/backend/Page' -import { CustomerTodosTable } from '@open-mercato/core/modules/customers/components/CustomerTodosTable' - -export default function WorkPlanCustomerTasksPage() { - return ( - - - - - - ) -} diff --git a/packages/create-app/template/src/modules/example/commands/todos.ts b/packages/create-app/template/src/modules/example/commands/todos.ts index a16a554d9d3..960721831ce 100644 --- a/packages/create-app/template/src/modules/example/commands/todos.ts +++ b/packages/create-app/template/src/modules/example/commands/todos.ts @@ -25,6 +25,7 @@ import { } from '@open-mercato/shared/lib/commands/customFieldSnapshots' export const todoCreateSchema = z.object({ + id: z.string().uuid().optional(), title: z.string().min(1), is_done: z.boolean().optional(), }) @@ -52,6 +53,9 @@ export const todoCrudEvents: CrudEventsConfig = { id: ctx.identifiers.id, tenantId: ctx.identifiers.tenantId, organizationId: ctx.identifiers.organizationId, + title: ctx.entity?.title ?? null, + isDone: typeof ctx.entity?.isDone === 'boolean' ? ctx.entity.isDone : null, + ...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}), }), } @@ -82,6 +86,7 @@ const createTodoCommand: CommandHandler, Todo> = { const todo = await de.createOrmEntity({ entity: Todo, data: { + ...(parsed.id ? { id: parsed.id } : {}), title: parsed.title, isDone: parsed.is_done ?? false, tenantId: scope.tenantId, @@ -107,6 +112,7 @@ const createTodoCommand: CommandHandler, Todo> = { tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) @@ -172,6 +178,7 @@ const createTodoCommand: CommandHandler, Todo> = { tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) @@ -232,6 +239,7 @@ const updateTodoCommand: CommandHandler, Todo> = { tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) @@ -318,6 +326,7 @@ const updateTodoCommand: CommandHandler, Todo> = { tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) @@ -366,6 +375,7 @@ const deleteTodoCommand: CommandHandler<{ body?: Record; query? tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) @@ -435,6 +445,7 @@ const deleteTodoCommand: CommandHandler<{ body?: Record; query? tenantId: scope.tenantId, organizationId: scope.organizationId, }, + syncOrigin: ctx.syncOrigin, events: todoCrudEvents, indexer: todoCrudIndexer, }) diff --git a/packages/create-app/template/src/modules/example_customers_sync/acl.ts b/packages/create-app/template/src/modules/example_customers_sync/acl.ts new file mode 100644 index 00000000000..4900cee4536 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/acl.ts @@ -0,0 +1,6 @@ +export const features = [ + { id: 'example_customers_sync.view', title: 'View Example customer sync diagnostics', module: 'example_customers_sync' }, + { id: 'example_customers_sync.manage', title: 'Manage Example customer sync', module: 'example_customers_sync' }, +] + +export default features diff --git a/packages/create-app/template/src/modules/example_customers_sync/api/example-customers-sync/mappings/route.ts b/packages/create-app/template/src/modules/example_customers_sync/api/example-customers-sync/mappings/route.ts new file mode 100644 index 00000000000..80d71f88cde --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/api/example-customers-sync/mappings/route.ts @@ -0,0 +1,254 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import type { EntityManager } from '@mikro-orm/postgresql' +import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server' +import { createRequestContainer } from '@open-mercato/shared/lib/di/container' +import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' +import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' +import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope' +import { CustomerInteraction } from '@open-mercato/core/modules/customers/data/entities' +import { loadCustomerSummaries } from '@open-mercato/core/modules/customers/lib/interactionReadModel' +import { exampleTag } from '../../../../example/api/openapi' +import { mappingListQuerySchema } from '../../../data/validators' + +export const metadata = { + path: '/example-customers-sync/mappings', + requireAuth: true, + requireFeatures: ['example_customers_sync.view'], +} + +type MappingRow = { + id: string + interaction_id: string + todo_id: string + sync_status: string + last_synced_at: Date | null + last_error: string | null + source_updated_at: Date | null + created_at: Date + updated_at: Date + organization_id: string + tenant_id: string +} + +type CursorPayload = { + updatedAt: string + id: string +} + +function encodeCursor(payload: CursorPayload): string { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') +} + +function decodeCursor(token: string | undefined): CursorPayload | null { + if (!token) return null + try { + const parsed = JSON.parse(Buffer.from(token, 'base64').toString('utf8')) as CursorPayload + if (typeof parsed.id !== 'string' || typeof parsed.updatedAt !== 'string') return null + return parsed + } catch { + return null + } +} + +export async function GET(request: Request) { + const { translate } = await resolveTranslations() + try { + const auth = await getAuthFromRequest(request) + if (!auth?.tenantId) { + return NextResponse.json( + { error: translate('exampleCustomersSync.errors.unauthorized', 'Unauthorized') }, + { status: 401 }, + ) + } + + const url = new URL(request.url) + const query = mappingListQuerySchema.parse(Object.fromEntries(url.searchParams)) + const cursor = decodeCursor(query.cursor) + if (query.cursor && !cursor) { + return NextResponse.json( + { error: translate('exampleCustomersSync.errors.invalidCursor', 'Invalid cursor.') }, + { status: 400 }, + ) + } + + const container = await createRequestContainer() + const scope = await resolveOrganizationScopeForRequest({ container, auth, request }) + const organizationIds = Array.isArray(scope?.filterIds) && scope.filterIds.length > 0 + ? scope.filterIds + : auth.orgId + ? [auth.orgId] + : [] + + const em = (container.resolve('em') as EntityManager).fork() + const knex = em.getKnex() + const rowsQuery = knex('example_customer_interaction_mappings') + .select([ + 'id', + 'interaction_id', + 'todo_id', + 'sync_status', + 'last_synced_at', + 'last_error', + 'source_updated_at', + 'created_at', + 'updated_at', + 'organization_id', + 'tenant_id', + ]) + .where('tenant_id', auth.tenantId) + .orderBy('updated_at', 'desc') + .orderBy('id', 'desc') + .limit(query.limit + 1) + + if (organizationIds.length > 0) { + rowsQuery.whereIn('organization_id', organizationIds) + } + if (query.interactionId) { + rowsQuery.andWhere('interaction_id', query.interactionId) + } + if (query.todoId) { + rowsQuery.andWhere('todo_id', query.todoId) + } + if (cursor) { + rowsQuery.andWhere(function applyCursor() { + this.where('updated_at', '<', new Date(cursor.updatedAt)).orWhere(function applyTieBreaker() { + this.where('updated_at', new Date(cursor.updatedAt)).andWhere('id', '<', cursor.id) + }) + }) + } + + const rows = await rowsQuery + const pageRows = rows.slice(0, query.limit) + const interactionIds = Array.from(new Set(pageRows.map((row) => row.interaction_id))) + const interactions = interactionIds.length > 0 + ? await findWithDecryption( + em, + CustomerInteraction, + { + id: { $in: interactionIds }, + tenantId: auth.tenantId, + ...(organizationIds.length > 0 ? { organizationId: { $in: organizationIds } } : {}), + deletedAt: null, + }, + undefined, + { tenantId: auth.tenantId, organizationId: null }, + ) + : [] + const interactionById = new Map(interactions.map((interaction) => [interaction.id, interaction])) + const customerSummaries = await loadCustomerSummaries( + em, + Array.from(new Set( + interactions + .map((interaction) => (typeof interaction.entity === 'string' ? interaction.entity : interaction.entity.id)) + .filter((value): value is string => typeof value === 'string' && value.length > 0), + )), + auth.tenantId, + null, + ) + + const items = pageRows.map((row) => { + const interaction = interactionById.get(row.interaction_id) + const entityId = interaction + ? (typeof interaction.entity === 'string' ? interaction.entity : interaction.entity.id) + : null + return { + id: row.id, + interactionId: row.interaction_id, + todoId: row.todo_id, + syncStatus: row.sync_status, + lastSyncedAt: row.last_synced_at?.toISOString() ?? null, + lastError: row.last_error ?? null, + sourceUpdatedAt: row.source_updated_at?.toISOString() ?? null, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + organizationId: row.organization_id, + tenantId: row.tenant_id, + exampleHref: `/backend/todos/${encodeURIComponent(row.todo_id)}/edit`, + interaction: interaction ? { + id: interaction.id, + title: interaction.title ?? null, + status: interaction.status, + interactionType: interaction.interactionType, + customer: entityId ? (customerSummaries.get(entityId) ?? null) : null, + } : null, + } + }) + + const hasMore = rows.length > query.limit + const last = hasMore ? pageRows[pageRows.length - 1] : null + + return NextResponse.json({ + items, + ...(last ? { nextCursor: encodeCursor({ updatedAt: last.updated_at.toISOString(), id: last.id }) } : {}), + }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: translate('exampleCustomersSync.errors.validationFailed', 'Validation failed'), + details: error.issues, + }, + { status: 400 }, + ) + } + console.error('example-customers-sync.mappings.get failed', error) + return NextResponse.json( + { + error: translate( + 'exampleCustomersSync.errors.mappingsLoadFailed', + 'Failed to load Example customer sync mappings.', + ), + }, + { status: 500 }, + ) + } +} + +const mappingItemSchema = z.object({ + id: z.string().uuid(), + interactionId: z.string().uuid(), + todoId: z.string().uuid(), + syncStatus: z.string(), + lastSyncedAt: z.string().nullable(), + lastError: z.string().nullable(), + sourceUpdatedAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + organizationId: z.string().uuid(), + tenantId: z.string().uuid(), + exampleHref: z.string(), + interaction: z.object({ + id: z.string().uuid(), + title: z.string().nullable(), + status: z.string(), + interactionType: z.string(), + customer: z.object({ + id: z.string().uuid(), + displayName: z.string().nullable(), + kind: z.string().nullable(), + }).nullable(), + }).nullable(), +}) + +export const openApi: OpenApiRouteDoc = { + tag: exampleTag, + methods: { + GET: { + summary: 'List Example customer sync mappings', + tags: [exampleTag], + query: mappingListQuerySchema, + responses: [ + { + status: 200, + description: 'Sync mappings', + schema: z.object({ + items: z.array(mappingItemSchema), + nextCursor: z.string().optional(), + }), + }, + ], + }, + }, +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/api/example-customers-sync/reconcile/route.ts b/packages/create-app/template/src/modules/example_customers_sync/api/example-customers-sync/reconcile/route.ts new file mode 100644 index 00000000000..e668b3b6f27 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/api/example-customers-sync/reconcile/route.ts @@ -0,0 +1,141 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server' +import { createRequestContainer } from '@open-mercato/shared/lib/di/container' +import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' +import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' +import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope' +import { exampleTag } from '../../../../example/api/openapi' +import { reconcileSchema } from '../../../data/validators' +import { EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE, getExampleCustomersSyncQueue } from '../../../lib/queue' +import type { ExampleCustomersSyncReconcileJobPayload } from '../../../lib/sync' + +export const metadata = { + path: '/example-customers-sync/reconcile', + requireAuth: true, + requireFeatures: ['example_customers_sync.manage'], +} + +async function readJsonBody(request: Request): Promise> { + /* Manual parsing because readJsonSafe is for outbound fetch responses, not inbound request bodies */ + const text = await request.text() + if (!text.trim()) return {} + return JSON.parse(text) as Record +} + +export async function POST(request: Request) { + const { translate } = await resolveTranslations() + try { + const auth = await getAuthFromRequest(request) + if (!auth?.tenantId) { + return NextResponse.json( + { error: translate('exampleCustomersSync.errors.unauthorized', 'Unauthorized') }, + { status: 401 }, + ) + } + + const rawBody = await readJsonBody(request) + const body = reconcileSchema.parse(rawBody) + if (body.tenantId && body.tenantId !== auth.tenantId) { + return NextResponse.json( + { + error: translate( + 'exampleCustomersSync.errors.tenantScopeMismatch', + 'Tenant scope mismatch.', + ), + }, + { status: 403 }, + ) + } + + const container = await createRequestContainer() + const scope = await resolveOrganizationScopeForRequest({ container, auth, request }) + const organizationIds = Array.isArray(scope?.filterIds) && scope.filterIds.length > 0 + ? scope.filterIds + : auth.orgId + ? [auth.orgId] + : [] + const organizationId = body.organizationId ?? scope?.selectedId ?? auth.orgId ?? organizationIds[0] ?? null + + if (!organizationId) { + return NextResponse.json( + { + error: translate( + 'exampleCustomersSync.errors.organizationContextRequired', + 'Organization context is required.', + ), + }, + { status: 400 }, + ) + } + if (organizationIds.length > 0 && !organizationIds.includes(organizationId)) { + return NextResponse.json( + { + error: translate( + 'exampleCustomersSync.errors.organizationScopeMismatch', + 'Organization scope mismatch.', + ), + }, + { status: 403 }, + ) + } + + const queue = getExampleCustomersSyncQueue( + EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE, + ) + await queue.enqueue({ + tenantId: auth.tenantId, + organizationId, + limit: body.limit, + cursor: body.cursor, + }) + + return NextResponse.json({ queued: 1 }, { status: 202 }) + } catch (error) { + if (error instanceof SyntaxError) { + return NextResponse.json( + { error: translate('exampleCustomersSync.errors.invalidJson', 'Invalid JSON body.') }, + { status: 400 }, + ) + } + if (error instanceof z.ZodError) { + return NextResponse.json( + { + error: translate('exampleCustomersSync.errors.validationFailed', 'Validation failed'), + details: error.issues, + }, + { status: 400 }, + ) + } + console.error('example-customers-sync.reconcile.post failed', error) + return NextResponse.json( + { + error: translate( + 'exampleCustomersSync.errors.reconcileEnqueueFailed', + 'Failed to enqueue Example customer sync reconciliation.', + ), + }, + { status: 500 }, + ) + } +} + +export const openApi: OpenApiRouteDoc = { + tag: exampleTag, + methods: { + POST: { + summary: 'Backfill or reconcile Example todo mappings to canonical customer interactions', + tags: [exampleTag], + requestBody: { + schema: reconcileSchema, + }, + responses: [ + { + status: 202, + description: 'Reconcile job accepted', + schema: z.object({ queued: z.number().int().nonnegative() }), + }, + ], + }, + }, +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/data/enrichers.ts b/packages/create-app/template/src/modules/example_customers_sync/data/enrichers.ts new file mode 100644 index 00000000000..7f086a03884 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/data/enrichers.ts @@ -0,0 +1,71 @@ +import type { EntityManager } from '@mikro-orm/postgresql' +import type { ResponseEnricher } from '@open-mercato/shared/lib/crud/response-enricher' +import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { ExampleCustomerInteractionMapping } from './entities' +import { buildExampleTodoHref } from '../lib/mappings' + +type InteractionRecord = Record & { id: string } + +function mergeExampleIntegration( + record: InteractionRecord, + mapping: ExampleCustomerInteractionMapping | null, +): InteractionRecord { + if (!mapping) return record + const integrations = + record._integrations && typeof record._integrations === 'object' + ? { ...(record._integrations as Record) } + : {} + integrations.example = { + todoId: mapping.todoId, + href: buildExampleTodoHref(mapping.todoId), + syncStatus: mapping.syncStatus, + lastError: mapping.lastError ?? null, + lastSyncedAt: mapping.lastSyncedAt ? mapping.lastSyncedAt.toISOString() : null, + } + return { + ...record, + _integrations: integrations, + } +} + +const exampleCustomersSyncEnricher: ResponseEnricher = { + id: 'example_customers_sync.interaction-links', + targetEntity: 'customers.interaction', + features: ['example.todos.view'], + priority: 20, + timeout: 2000, + fallback: {}, + async enrichOne(record, context) { + const mapping = await findWithDecryption( + context.em as EntityManager, + ExampleCustomerInteractionMapping, + { + interactionId: record.id, + tenantId: context.tenantId, + organizationId: context.organizationId, + }, + undefined, + { tenantId: context.tenantId, organizationId: context.organizationId }, + ).then((items) => items[0] ?? null) + return mergeExampleIntegration(record, mapping) + }, + async enrichMany(records, context) { + const interactionIds = records.map((record) => record.id) + if (!interactionIds.length) return records + const mappings = await findWithDecryption( + context.em as EntityManager, + ExampleCustomerInteractionMapping, + { + interactionId: { $in: interactionIds }, + tenantId: context.tenantId, + organizationId: context.organizationId, + }, + undefined, + { tenantId: context.tenantId, organizationId: context.organizationId }, + ) + const mappingByInteractionId = new Map(mappings.map((mapping) => [mapping.interactionId, mapping])) + return records.map((record) => mergeExampleIntegration(record, mappingByInteractionId.get(record.id) ?? null)) + }, +} + +export const enrichers: ResponseEnricher[] = [exampleCustomersSyncEnricher] diff --git a/packages/create-app/template/src/modules/example_customers_sync/data/entities.ts b/packages/create-app/template/src/modules/example_customers_sync/data/entities.ts new file mode 100644 index 00000000000..7cbdcc3cdc7 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/data/entities.ts @@ -0,0 +1,52 @@ +import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/core' + +@Entity({ tableName: 'example_customer_interaction_mappings' }) +@Unique({ + name: 'example_customer_interaction_mappings_interaction_unique', + properties: ['organizationId', 'tenantId', 'interactionId'], +}) +@Unique({ + name: 'example_customer_interaction_mappings_todo_unique', + properties: ['organizationId', 'tenantId', 'todoId'], +}) +@Index({ + name: 'example_customer_interaction_mappings_status_idx', + properties: ['organizationId', 'tenantId', 'syncStatus', 'updatedAt'], +}) +export class ExampleCustomerInteractionMapping { + @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' }) + id!: string + + @Property({ name: 'organization_id', type: 'uuid' }) + organizationId!: string + + @Property({ name: 'tenant_id', type: 'uuid' }) + tenantId!: string + + @Property({ name: 'interaction_id', type: 'uuid' }) + interactionId!: string + + @Property({ name: 'todo_id', type: 'uuid' }) + todoId!: string + + @Property({ name: 'sync_status', type: 'text', default: 'pending' }) + syncStatus: 'pending' | 'synced' | 'error' = 'pending' + + @Property({ name: 'last_synced_at', type: Date, nullable: true }) + lastSyncedAt?: Date | null + + @Property({ name: 'last_error', type: 'text', nullable: true }) + lastError?: string | null + + @Property({ name: 'source_updated_at', type: Date, nullable: true }) + sourceUpdatedAt?: Date | null + + @Property({ name: 'deleted_at', type: Date, nullable: true }) + deletedAt?: Date | null + + @Property({ name: 'created_at', type: Date, onCreate: () => new Date() }) + createdAt: Date = new Date() + + @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date() }) + updatedAt: Date = new Date() +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/data/validators.ts b/packages/create-app/template/src/modules/example_customers_sync/data/validators.ts new file mode 100644 index 00000000000..fba6d76ab51 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/data/validators.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' + +export const mappingListQuerySchema = z.object({ + interactionId: z.string().uuid().optional(), + todoId: z.string().uuid().optional(), + limit: z.coerce.number().min(1).max(100).default(50), + cursor: z.string().optional(), +}) + +export const reconcileSchema = z.object({ + organizationId: z.string().uuid().optional(), + tenantId: z.string().uuid().optional(), + limit: z.coerce.number().min(1).max(500).optional(), + cursor: z.string().optional(), +}) + +export type MappingListQuery = z.infer +export type ReconcileInput = z.infer diff --git a/packages/create-app/template/src/modules/example_customers_sync/events.ts b/packages/create-app/template/src/modules/example_customers_sync/events.ts new file mode 100644 index 00000000000..e103c278208 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/events.ts @@ -0,0 +1,19 @@ +import { createModuleEvents } from '@open-mercato/shared/modules/events' + +const events = [ + { id: 'example_customers_sync.mapping.created', label: 'Example customer sync mapping created', entity: 'mapping', category: 'crud' }, + { id: 'example_customers_sync.mapping.updated', label: 'Example customer sync mapping updated', entity: 'mapping', category: 'crud' }, + { id: 'example_customers_sync.mapping.deleted', label: 'Example customer sync mapping deleted', entity: 'mapping', category: 'crud' }, + { id: 'example_customers_sync.sync.failed', label: 'Example customer sync failed', entity: 'mapping', category: 'custom' }, +] as const + +export const eventsConfig = createModuleEvents({ + moduleId: 'example_customers_sync', + events, +}) + +export const emitExampleCustomersSyncEvent = eventsConfig.emit + +export type ExampleCustomersSyncEventId = typeof events[number]['id'] + +export default eventsConfig diff --git a/packages/create-app/template/src/modules/example_customers_sync/i18n/de.json b/packages/create-app/template/src/modules/example_customers_sync/i18n/de.json new file mode 100644 index 00000000000..44caafa8bfa --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/i18n/de.json @@ -0,0 +1,11 @@ +{ + "exampleCustomersSync.errors.invalidCursor": "Ungueltiger Cursor.", + "exampleCustomersSync.errors.invalidJson": "Ungueltiger JSON-Text.", + "exampleCustomersSync.errors.mappingsLoadFailed": "Example-Kundensynchronisationszuordnungen konnten nicht geladen werden.", + "exampleCustomersSync.errors.organizationContextRequired": "Organisationskontext ist erforderlich.", + "exampleCustomersSync.errors.organizationScopeMismatch": "Organisationsbereich stimmt nicht ueberein.", + "exampleCustomersSync.errors.reconcileEnqueueFailed": "Die Example-Kundensynchronisationsabstimmung konnte nicht in die Warteschlange gestellt werden.", + "exampleCustomersSync.errors.tenantScopeMismatch": "Mandantenbereich stimmt nicht ueberein.", + "exampleCustomersSync.errors.unauthorized": "Nicht autorisiert", + "exampleCustomersSync.errors.validationFailed": "Validierung fehlgeschlagen" +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/i18n/en.json b/packages/create-app/template/src/modules/example_customers_sync/i18n/en.json new file mode 100644 index 00000000000..b97c72e3353 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/i18n/en.json @@ -0,0 +1,11 @@ +{ + "exampleCustomersSync.errors.invalidCursor": "Invalid cursor.", + "exampleCustomersSync.errors.invalidJson": "Invalid JSON body.", + "exampleCustomersSync.errors.mappingsLoadFailed": "Failed to load Example customer sync mappings.", + "exampleCustomersSync.errors.organizationContextRequired": "Organization context is required.", + "exampleCustomersSync.errors.organizationScopeMismatch": "Organization scope mismatch.", + "exampleCustomersSync.errors.reconcileEnqueueFailed": "Failed to enqueue Example customer sync reconciliation.", + "exampleCustomersSync.errors.tenantScopeMismatch": "Tenant scope mismatch.", + "exampleCustomersSync.errors.unauthorized": "Unauthorized", + "exampleCustomersSync.errors.validationFailed": "Validation failed" +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/i18n/es.json b/packages/create-app/template/src/modules/example_customers_sync/i18n/es.json new file mode 100644 index 00000000000..70e0b5bd0f2 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/i18n/es.json @@ -0,0 +1,11 @@ +{ + "exampleCustomersSync.errors.invalidCursor": "Cursor no valido.", + "exampleCustomersSync.errors.invalidJson": "Cuerpo JSON no valido.", + "exampleCustomersSync.errors.mappingsLoadFailed": "No se pudieron cargar las asignaciones de sincronizacion de clientes de Example.", + "exampleCustomersSync.errors.organizationContextRequired": "Se requiere el contexto de organizacion.", + "exampleCustomersSync.errors.organizationScopeMismatch": "El alcance de la organizacion no coincide.", + "exampleCustomersSync.errors.reconcileEnqueueFailed": "No se pudo encolar la reconciliacion de sincronizacion de clientes de Example.", + "exampleCustomersSync.errors.tenantScopeMismatch": "El alcance del tenant no coincide.", + "exampleCustomersSync.errors.unauthorized": "No autorizado", + "exampleCustomersSync.errors.validationFailed": "La validacion fallo" +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/i18n/pl.json b/packages/create-app/template/src/modules/example_customers_sync/i18n/pl.json new file mode 100644 index 00000000000..80e6fdbd7c2 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/i18n/pl.json @@ -0,0 +1,11 @@ +{ + "exampleCustomersSync.errors.invalidCursor": "Nieprawidlowy kursor.", + "exampleCustomersSync.errors.invalidJson": "Nieprawidlowe body JSON.", + "exampleCustomersSync.errors.mappingsLoadFailed": "Nie udalo sie zaladowac mapowan synchronizacji klientow Example.", + "exampleCustomersSync.errors.organizationContextRequired": "Kontekst organizacji jest wymagany.", + "exampleCustomersSync.errors.organizationScopeMismatch": "Zakres organizacji nie zgadza sie.", + "exampleCustomersSync.errors.reconcileEnqueueFailed": "Nie udalo sie zakolejkowac uzgadniania synchronizacji klientow Example.", + "exampleCustomersSync.errors.tenantScopeMismatch": "Zakres tenant nie zgadza sie.", + "exampleCustomersSync.errors.unauthorized": "Brak autoryzacji", + "exampleCustomersSync.errors.validationFailed": "Walidacja nie powiodla sie" +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/index.ts b/packages/create-app/template/src/modules/example_customers_sync/index.ts new file mode 100644 index 00000000000..fc115dae485 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/index.ts @@ -0,0 +1,12 @@ +import type { ModuleInfo } from '@open-mercato/shared/modules/registry' + +export const metadata: ModuleInfo = { + name: 'example_customers_sync', + title: 'Example Customers Sync', + version: '0.1.0', + description: 'Optional sync bridge between canonical customer interactions and the example todo module.', + author: 'Open Mercato Team', + license: 'MIT', +} + +export default metadata diff --git a/packages/create-app/template/src/modules/example_customers_sync/lib/__tests__/mappings.test.ts b/packages/create-app/template/src/modules/example_customers_sync/lib/__tests__/mappings.test.ts new file mode 100644 index 00000000000..8402143e40a --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/lib/__tests__/mappings.test.ts @@ -0,0 +1,159 @@ +import { + buildExampleTodoCustomValuesFromInteraction, + buildExampleTodoHref, + buildInteractionUpdateFromExampleTodo, +} from '../mappings' + +describe('example_customers_sync mappings', () => { + it('maps canonical interaction fields into example todo custom values', () => { + expect( + buildExampleTodoCustomValuesFromInteraction({ + priority: 9, + body: 'Follow up with procurement', + customValues: { severity: 'critical' }, + }), + ).toEqual({ + priority: 5, + __om_customer_interaction_priority_raw: 9, + description: 'Follow up with procurement', + severity: 'high', + __om_customer_interaction_severity_raw: 'critical', + }) + + expect( + buildExampleTodoCustomValuesFromInteraction({ + priority: 0, + body: null, + customValues: { severity: 'normal' }, + }), + ).toEqual({ + priority: 1, + __om_customer_interaction_priority_raw: 0, + severity: 'medium', + __om_customer_interaction_severity_raw: 'normal', + }) + + expect( + buildExampleTodoCustomValuesFromInteraction({ + priority: null, + body: null, + customValues: {}, + }, { + includeClears: true, + }), + ).toEqual({ + priority: null, + __om_customer_interaction_priority_raw: null, + description: null, + severity: null, + __om_customer_interaction_severity_raw: null, + }) + }) + + it('maps example todo payloads back into canonical interaction updates', () => { + const occurredAt = new Date('2026-04-01T10:00:00.000Z') + + expect( + buildInteractionUpdateFromExampleTodo({ + title: 'Call customer', + isDone: true, + occurredAt, + customValues: { + priority: '4', + description: 'Capture new renewal date', + severity: ' high ', + }, + }), + ).toEqual({ + title: 'Call customer', + status: 'done', + occurredAt, + priority: 4, + body: 'Capture new renewal date', + customValues: { severity: 'high' }, + }) + + expect( + buildInteractionUpdateFromExampleTodo({ + title: 'Call customer', + isDone: true, + occurredAt, + customValues: { + priority: 5, + __om_customer_interaction_priority_raw: 9, + description: 'Capture new renewal date', + severity: 'high', + __om_customer_interaction_severity_raw: 'critical', + }, + }), + ).toEqual({ + title: 'Call customer', + status: 'done', + occurredAt, + priority: 9, + body: 'Capture new renewal date', + customValues: { severity: 'critical' }, + }) + + expect( + buildInteractionUpdateFromExampleTodo({ + title: 'Call customer', + isDone: true, + occurredAt, + customValues: { + priority: 4, + __om_customer_interaction_priority_raw: 9, + description: 'Capture new renewal date', + severity: 'low', + __om_customer_interaction_severity_raw: 'critical', + }, + }), + ).toEqual({ + title: 'Call customer', + status: 'done', + occurredAt, + priority: 4, + body: 'Capture new renewal date', + customValues: { severity: 'low' }, + }) + + expect( + buildInteractionUpdateFromExampleTodo({ + title: 'Reopened task', + isDone: false, + customValues: { + priority: null, + description: null, + }, + }), + ).toEqual({ + title: 'Reopened task', + status: 'planned', + occurredAt: null, + priority: null, + body: null, + customValues: {}, + }) + + expect( + buildInteractionUpdateFromExampleTodo({ + title: 'Reopened task', + isDone: false, + customValues: {}, + }, { + includeClears: true, + }), + ).toEqual({ + title: 'Reopened task', + status: 'planned', + occurredAt: null, + priority: null, + body: null, + customValues: { severity: null }, + }) + }) + + it('builds stable example todo edit links', () => { + expect(buildExampleTodoHref('todo-id/with spaces')).toBe('/backend/todos/todo-id%2Fwith%20spaces/edit') + }) +}) diff --git a/packages/create-app/template/src/modules/example_customers_sync/lib/__tests__/sync.test.ts b/packages/create-app/template/src/modules/example_customers_sync/lib/__tests__/sync.test.ts new file mode 100644 index 00000000000..cae0615de75 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/lib/__tests__/sync.test.ts @@ -0,0 +1,53 @@ +import { + resolveInboundInteractionSyncStrategy, + resolveMappingTodoIdForSyncFailure, +} from '../sync' + +describe('example_customers_sync sync helpers', () => { + it('routes inbound done transitions through the canonical complete command', () => { + expect( + resolveInboundInteractionSyncStrategy({ + currentStatus: 'planned', + isDone: true, + }), + ).toEqual({ + updateStatusInCommand: false, + lifecycleCommandId: 'customers.interactions.complete', + }) + + expect( + resolveInboundInteractionSyncStrategy({ + currentStatus: 'done', + isDone: true, + }), + ).toEqual({ + updateStatusInCommand: false, + lifecycleCommandId: null, + }) + + expect( + resolveInboundInteractionSyncStrategy({ + currentStatus: 'done', + isDone: false, + }), + ).toEqual({ + updateStatusInCommand: true, + lifecycleCommandId: null, + }) + }) + + it('uses a deterministic todo id when the first outbound sync attempt fails', () => { + expect( + resolveMappingTodoIdForSyncFailure({ + interactionId: 'interaction-1', + }), + ).toBe('interaction-1') + + expect( + resolveMappingTodoIdForSyncFailure({ + interactionId: 'interaction-1', + mappingTodoId: 'todo-1', + }), + ).toBe('todo-1') + }) +}) diff --git a/packages/create-app/template/src/modules/example_customers_sync/lib/inbound-subscriber.ts b/packages/create-app/template/src/modules/example_customers_sync/lib/inbound-subscriber.ts new file mode 100644 index 00000000000..b5465869045 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/lib/inbound-subscriber.ts @@ -0,0 +1,29 @@ +import { getExampleCustomersSyncQueue, EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE } from '../lib/queue' +import { shouldEnqueueInboundSync } from '../lib/sync' +import { resolveExampleCustomersSyncFlags } from '../lib/toggles' + +type ResolverContext = { + resolve: (name: string) => T +} + +type InboundPayload = { + id?: string | null + tenantId?: string | null + organizationId?: string | null + syncOrigin?: string | null +} + +export function createInboundSubscriber(eventName: string) { + return async function handle(payload: InboundPayload, ctx: ResolverContext): Promise { + if (!shouldEnqueueInboundSync(payload)) return + const flags = await resolveExampleCustomersSyncFlags(ctx, payload.tenantId) + if (!flags.enabled || !flags.bidirectional) return + const queue = getExampleCustomersSyncQueue(EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE) + await queue.enqueue({ + eventId: eventName, + todoId: payload.id, + tenantId: payload.tenantId, + organizationId: payload.organizationId, + }) + } +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/lib/mappings.ts b/packages/create-app/template/src/modules/example_customers_sync/lib/mappings.ts new file mode 100644 index 00000000000..eb0f0b5c0fe --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/lib/mappings.ts @@ -0,0 +1,252 @@ +import type { EntityManager } from '@mikro-orm/postgresql' +import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { ExampleCustomerInteractionMapping } from '../data/entities' +import type { ExampleCustomersSyncScope } from './runtime' + +export type { ExampleCustomersSyncScope } + +export type ExampleCustomersSyncMappingInput = ExampleCustomersSyncScope & { + interactionId: string + todoId: string + syncStatus: 'pending' | 'synced' | 'error' + lastSyncedAt?: Date | null + lastError?: string | null + sourceUpdatedAt?: Date | null +} + +const EXAMPLE_PRIORITY_RAW_KEY = '__om_customer_interaction_priority_raw' +const EXAMPLE_SEVERITY_RAW_KEY = '__om_customer_interaction_severity_raw' + +export function buildExampleTodoHref(todoId: string): string { + return `/backend/todos/${encodeURIComponent(todoId)}/edit` +} + +function parsePriorityValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Number(value) + return Number.isNaN(parsed) ? null : parsed + } + return null +} + +function normalizeExamplePriorityValue(value: number): number { + return Math.min(5, Math.max(1, Math.round(value))) +} + +function normalizeSeverityValue(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 + ? value.trim().toLowerCase() + : null +} + +function normalizeExampleSeverityValue(value: unknown): string | null { + const normalized = normalizeSeverityValue(value) + if (!normalized) return null + if (normalized === 'critical') return 'high' + if (normalized === 'normal') return 'medium' + return normalized +} + +export function buildExampleTodoCustomValuesFromInteraction( + interaction: { + priority?: number | null + body?: string | null + customValues?: Record | null + }, + options: { + includeClears?: boolean + } = {}, +): Record { + const includeClears = options.includeClears === true + const values: Record = {} + if (typeof interaction.priority === 'number' && Number.isFinite(interaction.priority)) { + values.priority = normalizeExamplePriorityValue(interaction.priority) + values[EXAMPLE_PRIORITY_RAW_KEY] = interaction.priority + } else if (includeClears) { + values.priority = null + values[EXAMPLE_PRIORITY_RAW_KEY] = null + } + if (typeof interaction.body === 'string') { + values.description = interaction.body + } else if (includeClears) { + values.description = null + } + const severity = interaction.customValues?.severity + if (typeof severity === 'string' && severity.trim().length > 0) { + values.severity = normalizeExampleSeverityValue(severity) + values[EXAMPLE_SEVERITY_RAW_KEY] = normalizeSeverityValue(severity) + } else if (includeClears) { + values.severity = null + values[EXAMPLE_SEVERITY_RAW_KEY] = null + } + return values +} + +export function buildInteractionUpdateFromExampleTodo(input: { + title: string | null + isDone: boolean + customValues?: Record | null + occurredAt?: Date | null +}, options: { + includeClears?: boolean +} = {}) { + const severity = input.customValues?.severity + const priorityRaw = input.customValues?.priority + const priorityCanonicalRaw = input.customValues?.[EXAMPLE_PRIORITY_RAW_KEY] + const descriptionRaw = input.customValues?.description + const severityCanonicalRaw = input.customValues?.[EXAMPLE_SEVERITY_RAW_KEY] + const includeClears = options.includeClears === true + const priorityValue = parsePriorityValue(priorityRaw) + const priorityRawValue = parsePriorityValue(priorityCanonicalRaw) + const priority = + priorityValue !== null + ? priorityRawValue !== null && priorityValue === normalizeExamplePriorityValue(priorityRawValue) + ? priorityRawValue + : priorityValue + : includeClears + ? null + : priorityRawValue + const description = + typeof descriptionRaw === 'string' + ? descriptionRaw + : descriptionRaw == null + ? null + : String(descriptionRaw) + const severityValue = normalizeSeverityValue(severity) + const severityRawValue = normalizeSeverityValue(severityCanonicalRaw) + const resolvedSeverity = + severityValue + ? severityRawValue && severityValue === normalizeExampleSeverityValue(severityRawValue) + ? severityRawValue + : severityValue + : includeClears + ? null + : severityRawValue + + return { + title: input.title, + status: input.isDone ? 'done' : 'planned', + occurredAt: input.isDone ? (input.occurredAt ?? new Date()) : null, + priority, + body: description, + customValues: + resolvedSeverity !== null + ? { severity: resolvedSeverity } + : includeClears + ? { severity: null } + : {}, + } +} + +export async function findMappingByInteractionId( + em: EntityManager, + scope: ExampleCustomersSyncScope, + interactionId: string, +): Promise { + return await findOneWithDecryption( + em, + ExampleCustomerInteractionMapping, + { + interactionId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + }, + undefined, + scope, + ) +} + +export async function findMappingByTodoId( + em: EntityManager, + scope: ExampleCustomersSyncScope, + todoId: string, +): Promise { + return await findOneWithDecryption( + em, + ExampleCustomerInteractionMapping, + { + todoId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + }, + undefined, + scope, + ) +} + +function isDuplicateKeyError(error: unknown): boolean { + return Boolean( + error + && typeof error === 'object' + && ( + (typeof (error as { code?: unknown }).code === 'string' && (error as { code: string }).code === '23505') + || (typeof (error as { message?: unknown }).message === 'string' + && (error as { message: string }).message.toLowerCase().includes('duplicate key')) + ) + ) +} + +function applyMappingInput( + mapping: ExampleCustomerInteractionMapping, + input: ExampleCustomersSyncMappingInput, +): void { + mapping.organizationId = input.organizationId + mapping.tenantId = input.tenantId + mapping.interactionId = input.interactionId + mapping.todoId = input.todoId + mapping.syncStatus = input.syncStatus + mapping.lastSyncedAt = input.lastSyncedAt ?? null + mapping.lastError = input.lastError ?? null + mapping.sourceUpdatedAt = input.sourceUpdatedAt ?? null +} + +export async function upsertExampleCustomerInteractionMapping( + em: EntityManager, + input: ExampleCustomersSyncMappingInput, +): Promise<{ mapping: ExampleCustomerInteractionMapping; created: boolean }> { + let mapping = + await findMappingByInteractionId(em, input, input.interactionId) + ?? await findMappingByTodoId(em, input, input.todoId) + const created = !mapping + if (!mapping) { + mapping = em.create(ExampleCustomerInteractionMapping, { + organizationId: input.organizationId, + tenantId: input.tenantId, + interactionId: input.interactionId, + todoId: input.todoId, + syncStatus: input.syncStatus, + lastSyncedAt: input.lastSyncedAt ?? null, + lastError: input.lastError ?? null, + sourceUpdatedAt: input.sourceUpdatedAt ?? null, + createdAt: new Date(), + updatedAt: new Date(), + }) + em.persist(mapping) + } else { + applyMappingInput(mapping, input) + } + try { + await em.flush() + } catch (error) { + if (!isDuplicateKeyError(error)) throw error + em.clear() + const existing = + await findMappingByInteractionId(em, input, input.interactionId) + ?? await findMappingByTodoId(em, input, input.todoId) + if (!existing) throw error + applyMappingInput(existing, input) + await em.flush() + return { mapping: existing, created: false } + } + return { mapping, created } +} + +export async function deleteExampleCustomerInteractionMapping( + em: EntityManager, + mapping: ExampleCustomerInteractionMapping | null | undefined, +): Promise { + if (!mapping) return false + await em.removeAndFlush(mapping) + return true +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/lib/outbound-subscriber.ts b/packages/create-app/template/src/modules/example_customers_sync/lib/outbound-subscriber.ts new file mode 100644 index 00000000000..d29ead7a052 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/lib/outbound-subscriber.ts @@ -0,0 +1,30 @@ +import { getExampleCustomersSyncQueue, EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE } from '../lib/queue' +import { shouldEnqueueOutboundSync } from '../lib/sync' +import { resolveExampleCustomersSyncFlags } from '../lib/toggles' + +type ResolverContext = { + resolve: (name: string) => T +} + +type OutboundPayload = { + id?: string | null + interactionType?: string | null + tenantId?: string | null + organizationId?: string | null + syncOrigin?: string | null +} + +export function createOutboundSubscriber(eventName: string) { + return async function handle(payload: OutboundPayload, ctx: ResolverContext): Promise { + if (!shouldEnqueueOutboundSync(payload)) return + const flags = await resolveExampleCustomersSyncFlags(ctx, payload.tenantId) + if (!flags.enabled) return + const queue = getExampleCustomersSyncQueue(EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE) + await queue.enqueue({ + eventId: eventName, + interactionId: payload.id, + tenantId: payload.tenantId, + organizationId: payload.organizationId, + }) + } +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/lib/queue.ts b/packages/create-app/template/src/modules/example_customers_sync/lib/queue.ts new file mode 100644 index 00000000000..0d80ca611e4 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/lib/queue.ts @@ -0,0 +1,32 @@ +import { createQueue, type Queue } from '@open-mercato/queue' +import { getRedisUrl } from '@open-mercato/shared/lib/redis/connection' + +export const EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE = 'example-customers-sync-outbound' +export const EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE = 'example-customers-sync-inbound' +export const EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE = 'example-customers-sync-reconcile' + +const GLOBAL_KEY = '__example_customers_sync_queues__' as const + +function getQueueCache(): Map>> { + const g = globalThis as Record + if (!g[GLOBAL_KEY]) { + g[GLOBAL_KEY] = new Map>>() + } + return g[GLOBAL_KEY] as Map>> +} + +export function getExampleCustomersSyncQueue>(queueName: string): Queue { + const queues = getQueueCache() + const existing = queues.get(queueName) + if (existing) return existing as Queue + + const created = process.env.QUEUE_STRATEGY === 'async' + ? createQueue(queueName, 'async', { + connection: { url: getRedisUrl('QUEUE') }, + concurrency: Math.max(1, Number.parseInt(process.env.EXAMPLE_CUSTOMERS_SYNC_QUEUE_CONCURRENCY ?? '5', 10) || 5), + }) + : createQueue(queueName, 'local') + + queues.set(queueName, created as Queue>) + return created +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/lib/runtime.ts b/packages/create-app/template/src/modules/example_customers_sync/lib/runtime.ts new file mode 100644 index 00000000000..7fac0c5f916 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/lib/runtime.ts @@ -0,0 +1,29 @@ +import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands' + +export type ExampleCustomersSyncScope = { + tenantId: string + organizationId: string +} + +export const EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN = 'example_customers_sync:outbound' +export const EXAMPLE_CUSTOMERS_SYNC_INBOUND_ORIGIN = 'example_customers_sync:inbound' + +export function buildExampleCustomersSyncCommandContext( + container: { resolve: (name: string) => T }, + scope: ExampleCustomersSyncScope, + syncOrigin: string, +): CommandRuntimeContext { + return { + container: container as CommandRuntimeContext['container'], + auth: { + sub: `system:${syncOrigin}`, + tenantId: scope.tenantId, + orgId: scope.organizationId, + userId: `system:${syncOrigin}`, + }, + organizationScope: null, + selectedOrganizationId: scope.organizationId, + organizationIds: [scope.organizationId], + syncOrigin, + } +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/lib/sync.ts b/packages/create-app/template/src/modules/example_customers_sync/lib/sync.ts new file mode 100644 index 00000000000..197ce22881d --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/lib/sync.ts @@ -0,0 +1,960 @@ +import type { EntityManager } from '@mikro-orm/postgresql' +import type { CommandBus } from '@open-mercato/shared/lib/commands' +import { loadCustomFieldSnapshot } from '@open-mercato/shared/lib/commands/customFieldSnapshots' +import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors' +import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { + CustomerInteraction, + CustomerTodoLink, +} from '@open-mercato/core/modules/customers/data/entities' +import { + CUSTOMER_INTERACTION_TASK_TYPE, + CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE, + type InteractionRecord, +} from '@open-mercato/core/modules/customers/lib/interactionCompatibility' +import { hydrateCanonicalInteractions } from '@open-mercato/core/modules/customers/lib/interactionReadModel' +import { E } from '../../../../.mercato/generated/entities.ids.generated' +import { Todo } from '../../example/data/entities' +import { ExampleCustomerInteractionMapping } from '../data/entities' +import { emitExampleCustomersSyncEvent } from '../events' +import { + buildExampleTodoCustomValuesFromInteraction, + buildInteractionUpdateFromExampleTodo, + deleteExampleCustomerInteractionMapping, + findMappingByInteractionId, + findMappingByTodoId, + upsertExampleCustomerInteractionMapping, +} from './mappings' +import { + buildExampleCustomersSyncCommandContext, + EXAMPLE_CUSTOMERS_SYNC_INBOUND_ORIGIN, + EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN, + type ExampleCustomersSyncScope, +} from './runtime' +import { resolveExampleCustomersSyncFlags } from './toggles' + +type ContainerLike = { + resolve: (name: string) => T +} + +export type ExampleCustomersSyncOutboundJobPayload = ExampleCustomersSyncScope & { + eventId: string + interactionId: string +} + +export type ExampleCustomersSyncInboundJobPayload = ExampleCustomersSyncScope & { + eventId: string + todoId: string +} + +export type ExampleCustomersSyncReconcileJobPayload = ExampleCustomersSyncScope & { + limit?: number + cursor?: string +} + +type ExampleTodoSnapshot = { + id: string + title: string + isDone: boolean + updatedAt: Date | null + customValues: Record | null +} + +type LegacyExampleTodoLinkRow = { + id: string + entityId: string + todoId: string + createdByUserId: string | null + createdAt: Date +} + +export type ExampleCustomersSyncReconcileItem = { + linkId: string + todoId: string + interactionId: string | null + status: 'mapped' | 'created_interaction' | 'skipped' | 'failed' + message?: string | null +} + +export type ExampleCustomersSyncReconcileResult = { + items: ExampleCustomersSyncReconcileItem[] + nextCursor?: string + processed: number + mapped: number + createdInteractions: number + failed: number +} + +type CursorPayload = { + createdAt: string + id: string +} + +const DEFAULT_TASK_TITLE = 'Untitled task' + +function isSyncOriginFromBridge(syncOrigin: unknown): boolean { + return typeof syncOrigin === 'string' && syncOrigin.startsWith('example_customers_sync:') +} + +function isTaskEventPayload(payload: { interactionType?: string | null }): boolean { + return payload.interactionType === CUSTOMER_INTERACTION_TASK_TYPE +} + +function parseDateOrNull(value: string | Date | null | undefined): Date | null { + if (!value) return null + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value + } + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +function trimErrorMessage(value: unknown): string { + const message = value instanceof Error ? value.message : String(value ?? 'Unknown sync error') + return message.length > 2000 ? `${message.slice(0, 1997)}...` : message +} + +function isNotFoundError(error: unknown): boolean { + if (error instanceof CrudHttpError) return error.status === 404 + if (error instanceof Error) return /not found/i.test(error.message) + return false +} + +function isDuplicateKeyError(error: unknown): boolean { + return Boolean( + error + && typeof error === 'object' + && ( + (typeof (error as { code?: unknown }).code === 'string' && (error as { code: string }).code === '23505') + || (typeof (error as { message?: unknown }).message === 'string' + && (error as { message: string }).message.toLowerCase().includes('duplicate key')) + ) + ) +} + +async function emitMappingEvent( + eventId: 'example_customers_sync.mapping.created' | 'example_customers_sync.mapping.updated' | 'example_customers_sync.mapping.deleted', + mapping: Pick< + ExampleCustomerInteractionMapping, + 'id' | 'interactionId' | 'todoId' | 'organizationId' | 'tenantId' | 'syncStatus' | 'lastSyncedAt' | 'lastError' | 'sourceUpdatedAt' + >, +): Promise { + await emitExampleCustomersSyncEvent( + eventId, + { + id: mapping.id, + interactionId: mapping.interactionId, + todoId: mapping.todoId, + organizationId: mapping.organizationId, + tenantId: mapping.tenantId, + syncStatus: mapping.syncStatus, + lastSyncedAt: mapping.lastSyncedAt?.toISOString() ?? null, + lastError: mapping.lastError ?? null, + sourceUpdatedAt: mapping.sourceUpdatedAt?.toISOString() ?? null, + }, + { persistent: true }, + ).catch(() => undefined) +} + +async function emitSyncFailedEvent(payload: { + scope: ExampleCustomersSyncScope + interactionId?: string | null + todoId?: string | null + error: string + direction: 'outbound' | 'inbound' + eventId: string +}): Promise { + await emitExampleCustomersSyncEvent( + 'example_customers_sync.sync.failed', + { + interactionId: payload.interactionId ?? null, + todoId: payload.todoId ?? null, + organizationId: payload.scope.organizationId, + tenantId: payload.scope.tenantId, + error: payload.error, + direction: payload.direction, + eventId: payload.eventId, + }, + { persistent: true }, + ).catch(() => undefined) +} + +async function updateMappingAfterSync( + em: EntityManager, + input: ExampleCustomersSyncScope & { + interactionId: string + todoId: string + sourceUpdatedAt?: Date | null + }, +): Promise { + const { mapping, created } = await upsertExampleCustomerInteractionMapping(em, { + ...input, + syncStatus: 'synced', + lastSyncedAt: new Date(), + lastError: null, + sourceUpdatedAt: input.sourceUpdatedAt ?? null, + }) + await emitMappingEvent(created ? 'example_customers_sync.mapping.created' : 'example_customers_sync.mapping.updated', mapping) + return mapping +} + +async function markMappingError( + em: EntityManager, + input: { + scope: ExampleCustomersSyncScope + interactionId: string + todoId: string + error: string + mapping: ExampleCustomerInteractionMapping | null + sourceUpdatedAt?: Date | null + }, +): Promise { + if (input.mapping) { + input.mapping.syncStatus = 'error' + input.mapping.lastError = input.error + input.mapping.updatedAt = new Date() + await em.flush() + await emitMappingEvent('example_customers_sync.mapping.updated', input.mapping) + return input.mapping + } + + const { mapping, created } = await upsertExampleCustomerInteractionMapping(em, { + ...input.scope, + interactionId: input.interactionId, + todoId: input.todoId, + syncStatus: 'error', + lastSyncedAt: null, + lastError: input.error, + sourceUpdatedAt: input.sourceUpdatedAt ?? null, + }) + await emitMappingEvent(created ? 'example_customers_sync.mapping.created' : 'example_customers_sync.mapping.updated', mapping) + return mapping +} + +export function resolveInboundInteractionSyncStrategy(input: { + currentStatus?: string | null + isDone: boolean +}): { + updateStatusInCommand: boolean + lifecycleCommandId: 'customers.interactions.complete' | null +} { + if (input.isDone) { + return { + updateStatusInCommand: false, + lifecycleCommandId: input.currentStatus === 'done' ? null : 'customers.interactions.complete', + } + } + return { + updateStatusInCommand: true, + lifecycleCommandId: null, + } +} + +export function resolveMappingTodoIdForSyncFailure(input: { + interactionId: string + mappingTodoId?: string | null +}): string { + return typeof input.mappingTodoId === 'string' && input.mappingTodoId.length > 0 + ? input.mappingTodoId + : input.interactionId +} + +async function loadCanonicalInteractionRecord( + container: ContainerLike, + scope: ExampleCustomersSyncScope, + interactionId: string, +): Promise { + const em = (container.resolve('em') as EntityManager).fork() + const interaction = await findOneWithDecryption( + em, + CustomerInteraction, + { + id: interactionId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + deletedAt: null, + }, + undefined, + scope, + ) + if (!interaction) return null + const [record] = await hydrateCanonicalInteractions({ + em, + container, + auth: { + tenantId: scope.tenantId, + orgId: scope.organizationId, + sub: `system:${EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN}`, + }, + selectedOrganizationId: scope.organizationId, + interactions: [interaction], + enrich: false, + }) + return record ?? null +} + +async function loadExampleTodoSnapshot( + em: EntityManager, + scope: ExampleCustomersSyncScope, + todoId: string, +): Promise { + const todo = await findOneWithDecryption( + em, + Todo, + { + id: todoId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + deletedAt: null, + }, + undefined, + scope, + ) + if (!todo) return null + const customValues = await loadCustomFieldSnapshot(em, { + entityId: E.example.todo, + recordId: todo.id, + tenantId: todo.tenantId ?? null, + organizationId: todo.organizationId ?? null, + }) + return { + id: todo.id, + title: todo.title, + isDone: todo.isDone, + updatedAt: todo.updatedAt ?? null, + customValues: Object.keys(customValues).length > 0 ? customValues : null, + } +} + +async function deleteMappedExampleTodo(params: { + container: ContainerLike + scope: ExampleCustomersSyncScope + mapping: ExampleCustomerInteractionMapping +}): Promise { + const commandBus = params.container.resolve('commandBus') as CommandBus + const commandContext = buildExampleCustomersSyncCommandContext( + params.container, + params.scope, + EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN, + ) + try { + await commandBus.execute<{ id: string }, Todo>('example.todos.delete', { + input: { id: params.mapping.todoId }, + ctx: commandContext, + }) + } catch (error) { + if (!isNotFoundError(error)) throw error + } + const em = (params.container.resolve('em') as EntityManager).fork() + const existing = await findMappingByInteractionId(em, params.scope, params.mapping.interactionId) + const deleted = await deleteExampleCustomerInteractionMapping(em, existing) + if (deleted && existing) { + await emitMappingEvent('example_customers_sync.mapping.deleted', existing) + } +} + +function resolveLegacyLinkEntityId( + link: CustomerTodoLink, +): string | null { + const entityRef = link.entity as { id?: string } | string | null | undefined + if (typeof entityRef === 'string' && entityRef.trim().length > 0) return entityRef + if (entityRef && typeof entityRef === 'object' && typeof entityRef.id === 'string' && entityRef.id.trim().length > 0) { + return entityRef.id + } + return null +} + +async function loadLegacyExampleTodoLinkRow( + em: EntityManager, + scope: ExampleCustomersSyncScope, + todoId: string, +): Promise { + const link = await findOneWithDecryption( + em, + CustomerTodoLink, + { + todoId, + todoSource: 'example:todo', + tenantId: scope.tenantId, + organizationId: scope.organizationId, + }, + undefined, + scope, + ) + if (!link) return null + const entityId = resolveLegacyLinkEntityId(link) + if (!entityId) return null + return { + id: link.id, + entityId, + todoId: link.todoId, + createdByUserId: link.createdByUserId ?? null, + createdAt: link.createdAt, + } +} + +async function ensureLegacyExampleMapping( + em: EntityManager, + scope: ExampleCustomersSyncScope, + interactionId: string, +): Promise { + const legacyLink = await findOneWithDecryption( + em, + CustomerTodoLink, + { + todoId: interactionId, + todoSource: 'example:todo', + tenantId: scope.tenantId, + organizationId: scope.organizationId, + }, + undefined, + scope, + ) + if (!legacyLink) return null + return await updateMappingAfterSync(em, { + ...scope, + interactionId, + todoId: legacyLink.todoId, + sourceUpdatedAt: legacyLink.createdAt ?? null, + }) +} + +export async function syncCustomerInteractionToExampleTodo( + container: ContainerLike, + payload: ExampleCustomersSyncOutboundJobPayload, +): Promise { + const scope = { tenantId: payload.tenantId, organizationId: payload.organizationId } + const flags = await resolveExampleCustomersSyncFlags(container, scope.tenantId) + if (!flags.enabled) return + + const em = (container.resolve('em') as EntityManager).fork() + let mapping = await findMappingByInteractionId(em, scope, payload.interactionId) + + try { + const interaction = await loadCanonicalInteractionRecord(container, scope, payload.interactionId) + + if (!interaction) { + if (mapping) { + await deleteMappedExampleTodo({ container, scope, mapping }) + } + return + } + if (interaction.interactionType !== CUSTOMER_INTERACTION_TASK_TYPE) return + + if (!mapping && interaction.source === CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE) { + mapping = await ensureLegacyExampleMapping(em, scope, interaction.id) + } + + if (interaction.status === 'canceled' || payload.eventId === 'customers.interaction.deleted') { + if (mapping) { + await deleteMappedExampleTodo({ container, scope, mapping }) + } + return + } + + const commandBus = container.resolve('commandBus') as CommandBus + const commandContext = buildExampleCustomersSyncCommandContext( + container, + scope, + EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_ORIGIN, + ) + const title = + typeof interaction.title === 'string' && interaction.title.trim().length > 0 + ? interaction.title.trim() + : DEFAULT_TASK_TITLE + const customValues = buildExampleTodoCustomValuesFromInteraction(interaction, { + includeClears: !!mapping, + }) + const sourceUpdatedAt = parseDateOrNull(interaction.updatedAt) + + if (mapping) { + try { + await commandBus.execute, Todo>('example.todos.update', { + input: { + id: mapping.todoId, + title, + is_done: interaction.status === 'done', + ...(Object.keys(customValues).length > 0 ? { customValues } : {}), + }, + ctx: commandContext, + }) + await updateMappingAfterSync(em, { + ...scope, + interactionId: interaction.id, + todoId: mapping.todoId, + sourceUpdatedAt, + }) + return + } catch (error) { + if (!isNotFoundError(error)) throw error + } + } + + try { + const createResult = await commandBus.execute, Todo>('example.todos.create', { + input: { + id: interaction.id, + title, + is_done: interaction.status === 'done', + ...(Object.keys(customValues).length > 0 ? { customValues } : {}), + }, + ctx: commandContext, + }) + + await updateMappingAfterSync(em, { + ...scope, + interactionId: interaction.id, + todoId: String(createResult.result.id), + sourceUpdatedAt, + }) + } catch (error) { + if (!isDuplicateKeyError(error)) throw error + const existingTodo = await loadExampleTodoSnapshot(em, scope, interaction.id) + if (!existingTodo) throw error + await commandBus.execute, Todo>('example.todos.update', { + input: { + id: existingTodo.id, + title, + is_done: interaction.status === 'done', + ...(Object.keys(customValues).length > 0 ? { customValues } : {}), + }, + ctx: commandContext, + }) + await updateMappingAfterSync(em, { + ...scope, + interactionId: interaction.id, + todoId: existingTodo.id, + sourceUpdatedAt, + }) + } + } catch (error) { + const message = trimErrorMessage(error) + const erroredMapping = await markMappingError(em, { + scope, + interactionId: payload.interactionId, + todoId: resolveMappingTodoIdForSyncFailure({ + interactionId: payload.interactionId, + mappingTodoId: mapping?.todoId, + }), + error: message, + mapping, + }) + await emitSyncFailedEvent({ + scope, + interactionId: payload.interactionId, + todoId: erroredMapping.todoId, + error: message, + direction: 'outbound', + eventId: payload.eventId, + }) + throw error + } +} + +export async function syncExampleTodoToCanonicalInteraction( + container: ContainerLike, + payload: ExampleCustomersSyncInboundJobPayload, +): Promise { + const scope = { tenantId: payload.tenantId, organizationId: payload.organizationId } + const flags = await resolveExampleCustomersSyncFlags(container, scope.tenantId) + if (!flags.enabled || !flags.bidirectional) return + + const em = (container.resolve('em') as EntityManager).fork() + let mapping = await findMappingByTodoId(em, scope, payload.todoId) + let todo: ExampleTodoSnapshot | null = null + if (!mapping && payload.eventId !== 'example.todo.deleted') { + mapping = await ensureMappingForLegacyExampleTodo(container, scope, payload.todoId) + } + if (!mapping) return + + try { + const commandBus = container.resolve('commandBus') as CommandBus + const commandContext = buildExampleCustomersSyncCommandContext( + container, + scope, + EXAMPLE_CUSTOMERS_SYNC_INBOUND_ORIGIN, + ) + + if (payload.eventId === 'example.todo.deleted') { + try { + await commandBus.execute, { interactionId: string }>('customers.interactions.delete', { + input: { body: { id: mapping.interactionId } }, + ctx: commandContext, + }) + } catch (error) { + if (!isNotFoundError(error)) throw error + } + const deleted = await deleteExampleCustomerInteractionMapping(em, mapping) + if (deleted) { + await emitMappingEvent('example_customers_sync.mapping.deleted', mapping) + } + return + } + + todo = await loadExampleTodoSnapshot(em, scope, mapping.todoId) + if (!todo) { + try { + await commandBus.execute, { interactionId: string }>('customers.interactions.delete', { + input: { body: { id: mapping.interactionId } }, + ctx: commandContext, + }) + } catch (error) { + if (!isNotFoundError(error)) throw error + } + const deleted = await deleteExampleCustomerInteractionMapping(em, mapping) + if (deleted) { + await emitMappingEvent('example_customers_sync.mapping.deleted', mapping) + } + return + } + + const interaction = await findOneWithDecryption( + em, + CustomerInteraction, + { + id: mapping.interactionId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + deletedAt: null, + }, + undefined, + scope, + ) + if (!interaction) { + const deleted = await deleteExampleCustomerInteractionMapping(em, mapping) + if (deleted) { + await emitMappingEvent('example_customers_sync.mapping.deleted', mapping) + } + return + } + + const patch = buildInteractionUpdateFromExampleTodo({ + title: todo.title, + isDone: todo.isDone, + customValues: todo.customValues, + occurredAt: todo.isDone ? (todo.updatedAt ?? new Date()) : null, + }, { + includeClears: true, + }) + const strategy = resolveInboundInteractionSyncStrategy({ + currentStatus: interaction.status, + isDone: todo.isDone, + }) + const customValuesInput = Object.keys(patch.customValues).length > 0 + ? { customValues: patch.customValues } + : {} + + await commandBus.execute, { interactionId: string }>('customers.interactions.update', { + input: { + id: mapping.interactionId, + title: patch.title, + priority: patch.priority, + body: patch.body, + ...customValuesInput, + ...(strategy.updateStatusInCommand ? { + status: patch.status, + occurredAt: patch.occurredAt, + } : {}), + }, + ctx: commandContext, + }) + + if (strategy.lifecycleCommandId === 'customers.interactions.complete') { + await commandBus.execute, { interactionId: string }>('customers.interactions.complete', { + input: { + id: mapping.interactionId, + ...(patch.occurredAt ? { occurredAt: patch.occurredAt } : {}), + }, + ctx: commandContext, + }) + } + + await updateMappingAfterSync(em, { + ...scope, + interactionId: mapping.interactionId, + todoId: mapping.todoId, + sourceUpdatedAt: todo.updatedAt ?? null, + }) + } catch (error) { + const message = trimErrorMessage(error) + const erroredMapping = await markMappingError(em, { + scope, + interactionId: mapping.interactionId, + todoId: mapping.todoId, + error: message, + mapping, + sourceUpdatedAt: todo?.updatedAt ?? null, + }) + await emitSyncFailedEvent({ + scope, + interactionId: erroredMapping.interactionId, + todoId: erroredMapping.todoId, + error: message, + direction: 'inbound', + eventId: payload.eventId, + }) + throw error + } +} + +function encodeCursor(payload: CursorPayload): string { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64') +} + +function decodeCursor(token: string | undefined): CursorPayload | null { + if (!token) return null + try { + const parsed = JSON.parse(Buffer.from(token, 'base64').toString('utf8')) as CursorPayload + if (typeof parsed.id !== 'string' || typeof parsed.createdAt !== 'string') return null + return parsed + } catch { + /* malformed cursor token — treat as no cursor */ + return null + } +} + +async function loadLegacyExampleTodoLinks( + container: ContainerLike, + scope: ExampleCustomersSyncScope, + limit: number, + cursor?: string, +): Promise<{ rows: LegacyExampleTodoLinkRow[]; nextCursor?: string }> { + const em = (container.resolve('em') as EntityManager).fork() + const knex = em.getKnex() + const parsedCursor = decodeCursor(cursor) + const query = knex('customer_todo_links') + .select([ + 'id', + 'entity_id as entityId', + 'todo_id as todoId', + 'created_by_user_id as createdByUserId', + 'created_at as createdAt', + ]) + .where({ + tenant_id: scope.tenantId, + organization_id: scope.organizationId, + todo_source: 'example:todo', + }) + .orderBy('created_at', 'asc') + .orderBy('id', 'asc') + .limit(limit + 1) + + if (parsedCursor) { + query.andWhere(function applyCursor() { + this.where('created_at', '>', new Date(parsedCursor.createdAt)).orWhere(function applyTieBreaker() { + this.where('created_at', new Date(parsedCursor.createdAt)).andWhere('id', '>', parsedCursor.id) + }) + }) + } + + const rows = await query + const pageRows = rows.slice(0, limit) + const next = rows.length > limit ? pageRows[pageRows.length - 1] : null + return { + rows: pageRows, + ...(next ? { nextCursor: encodeCursor({ createdAt: next.createdAt.toISOString(), id: next.id }) } : {}), + } +} + +async function ensureCanonicalInteractionForLegacyLink( + container: ContainerLike, + scope: ExampleCustomersSyncScope, + link: LegacyExampleTodoLinkRow, +): Promise<{ interactionId: string; created: boolean } | null> { + const em = (container.resolve('em') as EntityManager).fork() + const existing = await findOneWithDecryption( + em, + CustomerInteraction, + { + id: link.todoId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + deletedAt: null, + }, + undefined, + scope, + ) + if (existing) { + return { interactionId: existing.id, created: false } + } + + const todo = await loadExampleTodoSnapshot(em, scope, link.todoId) + if (!todo) return null + + const patch = buildInteractionUpdateFromExampleTodo({ + title: todo.title, + isDone: todo.isDone, + customValues: todo.customValues, + occurredAt: todo.isDone ? (todo.updatedAt ?? link.createdAt) : null, + }) + + const commandBus = container.resolve('commandBus') as CommandBus + const commandContext = buildExampleCustomersSyncCommandContext( + container as never, + scope, + EXAMPLE_CUSTOMERS_SYNC_INBOUND_ORIGIN, + ) + try { + const result = await commandBus.execute, { interactionId: string }>('customers.interactions.create', { + input: { + id: link.todoId, + entityId: link.entityId, + interactionType: CUSTOMER_INTERACTION_TASK_TYPE, + title: patch.title, + status: patch.status, + occurredAt: patch.occurredAt, + priority: patch.priority, + body: patch.body, + source: CUSTOMER_INTERACTION_TODO_ADAPTER_SOURCE, + authorUserId: link.createdByUserId ?? null, + ...(Object.keys(patch.customValues).length > 0 ? { customValues: patch.customValues } : {}), + }, + ctx: commandContext, + }) + return { interactionId: result.result.interactionId, created: true } + } catch (error) { + if (!isDuplicateKeyError(error)) throw error + const existingAfterDuplicate = await findOneWithDecryption( + em, + CustomerInteraction, + { + id: link.todoId, + tenantId: scope.tenantId, + organizationId: scope.organizationId, + deletedAt: null, + }, + undefined, + scope, + ) + if (!existingAfterDuplicate) throw error + return { interactionId: existingAfterDuplicate.id, created: false } + } +} + +async function ensureMappingForLegacyExampleTodo( + container: ContainerLike, + scope: ExampleCustomersSyncScope, + todoId: string, +): Promise { + const em = (container.resolve('em') as EntityManager).fork() + const legacyLink = await loadLegacyExampleTodoLinkRow(em, scope, todoId) + if (!legacyLink) return null + const canonical = await ensureCanonicalInteractionForLegacyLink(container, scope, legacyLink) + if (!canonical) return null + const todo = await loadExampleTodoSnapshot(em, scope, todoId) + return await updateMappingAfterSync(em, { + ...scope, + interactionId: canonical.interactionId, + todoId, + sourceUpdatedAt: todo?.updatedAt ?? legacyLink.createdAt, + }) +} + +export async function reconcileLegacyExampleTodoLinks( + container: ContainerLike, + input: ExampleCustomersSyncScope & { limit?: number; cursor?: string }, +): Promise { + const scope = { tenantId: input.tenantId, organizationId: input.organizationId } + const limit = Math.min(Math.max(input.limit ?? 100, 1), 500) + const { rows, nextCursor } = await loadLegacyExampleTodoLinks(container, scope, limit, input.cursor) + const em = (container.resolve('em') as EntityManager).fork() + const items: ExampleCustomersSyncReconcileItem[] = [] + let mapped = 0 + let createdInteractions = 0 + let failed = 0 + + for (const row of rows) { + try { + const mapping = + await findMappingByTodoId(em, scope, row.todoId) + ?? await findMappingByInteractionId(em, scope, row.todoId) + const canonical = await ensureCanonicalInteractionForLegacyLink(container, scope, row) + if (!canonical) { + items.push({ + linkId: row.id, + todoId: row.todoId, + interactionId: null, + status: 'skipped', + message: 'Example todo not found', + }) + continue + } + + const todo = await loadExampleTodoSnapshot(em, scope, row.todoId) + const updatedMapping = await updateMappingAfterSync(em, { + ...scope, + interactionId: canonical.interactionId, + todoId: row.todoId, + sourceUpdatedAt: todo?.updatedAt ?? row.createdAt, + }) + items.push({ + linkId: row.id, + todoId: row.todoId, + interactionId: updatedMapping.interactionId, + status: canonical.created ? 'created_interaction' : 'mapped', + message: mapping ? 'Updated existing mapping' : null, + }) + mapped += 1 + if (canonical.created) createdInteractions += 1 + } catch (error) { + failed += 1 + items.push({ + linkId: row.id, + todoId: row.todoId, + interactionId: null, + status: 'failed', + message: trimErrorMessage(error), + }) + } + } + + return { + items, + processed: rows.length, + mapped, + createdInteractions, + failed, + ...(nextCursor ? { nextCursor } : {}), + } +} + +export function shouldEnqueueOutboundSync(payload: { + id?: string | null + interactionType?: string | null + tenantId?: string | null + organizationId?: string | null + syncOrigin?: string | null +}): payload is { + id: string + interactionType: string + tenantId: string + organizationId: string + syncOrigin?: string | null +} { + return ( + typeof payload.id === 'string' + && typeof payload.tenantId === 'string' + && typeof payload.organizationId === 'string' + && isTaskEventPayload(payload) + && !isSyncOriginFromBridge(payload.syncOrigin) + ) +} + +export function shouldEnqueueInboundSync(payload: { + id?: string | null + tenantId?: string | null + organizationId?: string | null + syncOrigin?: string | null +}): payload is { + id: string + tenantId: string + organizationId: string + syncOrigin?: string | null +} { + return ( + typeof payload.id === 'string' + && typeof payload.tenantId === 'string' + && typeof payload.organizationId === 'string' + && !isSyncOriginFromBridge(payload.syncOrigin) + ) +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/lib/toggles.ts b/packages/create-app/template/src/modules/example_customers_sync/lib/toggles.ts new file mode 100644 index 00000000000..6334ceb26fc --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/lib/toggles.ts @@ -0,0 +1,59 @@ +type ContainerLike = { + resolve: (name: string) => unknown +} + +type FeatureToggleResult = { + ok: boolean + value?: boolean +} + +type FeatureToggleServiceLike = { + getBoolConfig: (identifier: string, tenantId: string) => Promise +} + +export const exampleCustomersSyncFeatureIds = { + enabled: 'example.customers_sync.enabled', + bidirectional: 'example.customers_sync.bidirectional', +} as const + +export type ExampleCustomersSyncFlags = { + enabled: boolean + bidirectional: boolean +} + +async function resolveBooleanFeature( + service: FeatureToggleServiceLike | null, + tenantId: string | null | undefined, + identifier: string, + fallback: boolean, +): Promise { + if (!service || !tenantId) return fallback + try { + const result = await service.getBoolConfig(identifier, tenantId) + if (result.ok && typeof result.value === 'boolean') return result.value + } catch { + /* service unavailable or misconfigured — fall back to default */ + return fallback + } + return fallback +} + +function resolveFeatureToggleService(container: ContainerLike): FeatureToggleServiceLike | null { + try { + return container.resolve('featureTogglesService') as FeatureToggleServiceLike + } catch { + /* service not registered — module may be disabled or DI not yet wired */ + return null + } +} + +export async function resolveExampleCustomersSyncFlags( + container: ContainerLike, + tenantId: string | null | undefined, +): Promise { + const service = resolveFeatureToggleService(container) + return { + enabled: await resolveBooleanFeature(service, tenantId, exampleCustomersSyncFeatureIds.enabled, false), + bidirectional: await resolveBooleanFeature(service, tenantId, exampleCustomersSyncFeatureIds.bidirectional, false), + } +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/migrations/.snapshot-open-mercato.json b/packages/create-app/template/src/modules/example_customers_sync/migrations/.snapshot-open-mercato.json new file mode 100644 index 00000000000..7f0a11495e7 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/migrations/.snapshot-open-mercato.json @@ -0,0 +1,172 @@ +{ + "namespaces": [ + "public" + ], + "name": "public", + "tables": [ + { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "gen_random_uuid()", + "mappedType": "uuid" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "interaction_id": { + "name": "interaction_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "todo_id": { + "name": "todo_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "mappedType": "uuid" + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "default": "'pending'", + "mappedType": "text" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + }, + "last_error": { + "name": "last_error", + "type": "text", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "mappedType": "text" + }, + "source_updated_at": { + "name": "source_updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "length": 6, + "mappedType": "datetime" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "mappedType": "datetime" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "length": 6, + "mappedType": "datetime" + } + }, + "name": "example_customer_interaction_mappings", + "schema": "public", + "indexes": [ + { + "keyName": "example_customer_interaction_mappings_status_idx", + "columnNames": [ + "organization_id", + "tenant_id", + "sync_status", + "updated_at" + ], + "composite": true, + "constraint": false, + "primary": false, + "unique": false + }, + { + "keyName": "example_customer_interaction_mappings_todo_unique", + "columnNames": [ + "organization_id", + "tenant_id", + "todo_id" + ], + "composite": true, + "constraint": true, + "primary": false, + "unique": true + }, + { + "keyName": "example_customer_interaction_mappings_interaction_unique", + "columnNames": [ + "organization_id", + "tenant_id", + "interaction_id" + ], + "composite": true, + "constraint": true, + "primary": false, + "unique": true + }, + { + "keyName": "example_customer_interaction_mappings_pkey", + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "primary": true, + "unique": true + } + ], + "checks": [], + "foreignKeys": {}, + "nativeEnums": {} + } + ], + "nativeEnums": {} +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/migrations/Migration20260401173723.ts b/packages/create-app/template/src/modules/example_customers_sync/migrations/Migration20260401173723.ts new file mode 100644 index 00000000000..c3877b6cf58 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/migrations/Migration20260401173723.ts @@ -0,0 +1,89 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260401173723 extends Migration { + + override async up(): Promise { + this.addSql(`create table "example_customer_interaction_mappings" ("id" uuid not null default gen_random_uuid(), "organization_id" uuid not null, "tenant_id" uuid not null, "interaction_id" uuid not null, "todo_id" uuid not null, "sync_status" text not null default 'pending', "last_synced_at" timestamptz null, "last_error" text null, "source_updated_at" timestamptz null, "created_at" timestamptz not null, "updated_at" timestamptz not null, constraint "example_customer_interaction_mappings_pkey" primary key ("id"));`); + this.addSql(`create index "example_customer_interaction_mappings_status_idx" on "example_customer_interaction_mappings" ("organization_id", "tenant_id", "sync_status", "updated_at");`); + this.addSql(`alter table "example_customer_interaction_mappings" add constraint "example_customer_interaction_mappings_todo_unique" unique ("organization_id", "tenant_id", "todo_id");`); + this.addSql(`alter table "example_customer_interaction_mappings" add constraint "example_customer_interaction_mappings_interaction_unique" unique ("organization_id", "tenant_id", "interaction_id");`); + this.addSql(` + do $$ + begin + if exists ( + select 1 + from information_schema.tables + where table_schema = current_schema() + and table_name = 'feature_toggles' + ) then + insert into "feature_toggles" ("identifier", "name", "description", "category", "default_value", "type", "created_at", "updated_at") + select 'example.customers_sync.enabled', 'Example Customers Sync Enabled', 'When enabled, canonical customer tasks are synced to the example todo module.', 'example', 'false'::jsonb, 'boolean', now(), now() + where not exists ( + select 1 + from "feature_toggles" + where "identifier" = 'example.customers_sync.enabled' + and "deleted_at" is null + ); + + insert into "feature_toggles" ("identifier", "name", "description", "category", "default_value", "type", "created_at", "updated_at") + select 'example.customers_sync.bidirectional', 'Example Customers Sync Bidirectional', 'When enabled, updates from the example todo module sync back to canonical customer tasks.', 'example', 'false'::jsonb, 'boolean', now(), now() + where not exists ( + select 1 + from "feature_toggles" + where "identifier" = 'example.customers_sync.bidirectional' + and "deleted_at" is null + ); + end if; + end + $$; + `); + } + + override async down(): Promise { + this.addSql(` + do $$ + begin + if exists ( + select 1 + from information_schema.tables + where table_schema = current_schema() + and table_name = 'feature_toggles' + ) then + if exists ( + select 1 + from information_schema.tables + where table_schema = current_schema() + and table_name = 'feature_toggle_overrides' + ) then + delete from "feature_toggle_overrides" + where "toggle_id" in ( + select "id" + from "feature_toggles" + where "identifier" in ('example.customers_sync.enabled', 'example.customers_sync.bidirectional') + ); + end if; + + if exists ( + select 1 + from information_schema.tables + where table_schema = current_schema() + and table_name = 'feature_toggle_audit_logs' + ) then + delete from "feature_toggle_audit_logs" + where "toggle_id" in ( + select "id" + from "feature_toggles" + where "identifier" in ('example.customers_sync.enabled', 'example.customers_sync.bidirectional') + ); + end if; + + delete from "feature_toggles" + where "identifier" in ('example.customers_sync.enabled', 'example.customers_sync.bidirectional'); + end if; + end + $$; + `); + this.addSql(`drop table if exists "example_customer_interaction_mappings" cascade;`); + } + +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/setup.ts b/packages/create-app/template/src/modules/example_customers_sync/setup.ts new file mode 100644 index 00000000000..d55f1565223 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/setup.ts @@ -0,0 +1,51 @@ +import type { EntityManager } from '@mikro-orm/postgresql' +import { FeatureToggle } from '@open-mercato/core/modules/feature_toggles/data/entities' +import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup' + +const syncFeatureToggles = [ + { + identifier: 'example.customers_sync.enabled', + name: 'Example Customers Sync Enabled', + description: 'When enabled, canonical customer tasks are synced to the example todo module.', + category: 'example', + type: 'boolean' as const, + defaultValue: false, + }, + { + identifier: 'example.customers_sync.bidirectional', + name: 'Example Customers Sync Bidirectional', + description: 'When enabled, updates from the example todo module sync back to canonical customer tasks.', + category: 'example', + type: 'boolean' as const, + defaultValue: false, + }, +] as const + +async function seedSyncFeatureToggles(em: EntityManager): Promise { + for (const toggle of syncFeatureToggles) { + const existing = await em.findOne(FeatureToggle, { identifier: toggle.identifier, deletedAt: null }) + if (existing) continue + const entity = em.create(FeatureToggle, { + identifier: toggle.identifier, + name: toggle.name, + description: toggle.description, + category: toggle.category, + type: toggle.type, + defaultValue: toggle.defaultValue, + }) + em.persist(entity) + } + await em.flush() +} + +export const setup: ModuleSetupConfig = { + async seedDefaults({ em }) { + await seedSyncFeatureToggles(em) + }, + defaultRoleFeatures: { + superadmin: ['example_customers_sync.*'], + admin: ['example_customers_sync.*'], + }, +} + +export default setup diff --git a/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-canceled.ts b/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-canceled.ts new file mode 100644 index 00000000000..94bde3865d5 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-canceled.ts @@ -0,0 +1,9 @@ +import { createOutboundSubscriber } from '../lib/outbound-subscriber' + +export const metadata = { + event: 'customers.interaction.canceled', + persistent: true, + id: 'example-customers-sync:customers-interaction-canceled', +} + +export default createOutboundSubscriber(metadata.event) diff --git a/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-completed.ts b/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-completed.ts new file mode 100644 index 00000000000..a421fc523c3 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-completed.ts @@ -0,0 +1,9 @@ +import { createOutboundSubscriber } from '../lib/outbound-subscriber' + +export const metadata = { + event: 'customers.interaction.completed', + persistent: true, + id: 'example-customers-sync:customers-interaction-completed', +} + +export default createOutboundSubscriber(metadata.event) diff --git a/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-created.ts b/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-created.ts new file mode 100644 index 00000000000..b6192ce44d4 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-created.ts @@ -0,0 +1,9 @@ +import { createOutboundSubscriber } from '../lib/outbound-subscriber' + +export const metadata = { + event: 'customers.interaction.created', + persistent: true, + id: 'example-customers-sync:customers-interaction-created', +} + +export default createOutboundSubscriber(metadata.event) diff --git a/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-deleted.ts b/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-deleted.ts new file mode 100644 index 00000000000..0a36dc629a2 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-deleted.ts @@ -0,0 +1,9 @@ +import { createOutboundSubscriber } from '../lib/outbound-subscriber' + +export const metadata = { + event: 'customers.interaction.deleted', + persistent: true, + id: 'example-customers-sync:customers-interaction-deleted', +} + +export default createOutboundSubscriber(metadata.event) diff --git a/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-updated.ts b/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-updated.ts new file mode 100644 index 00000000000..be2c387a261 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/subscribers/customers-interaction-updated.ts @@ -0,0 +1,9 @@ +import { createOutboundSubscriber } from '../lib/outbound-subscriber' + +export const metadata = { + event: 'customers.interaction.updated', + persistent: true, + id: 'example-customers-sync:customers-interaction-updated', +} + +export default createOutboundSubscriber(metadata.event) diff --git a/packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-created.ts b/packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-created.ts new file mode 100644 index 00000000000..27d2efe199c --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-created.ts @@ -0,0 +1,9 @@ +import { createInboundSubscriber } from '../lib/inbound-subscriber' + +export const metadata = { + event: 'example.todo.created', + persistent: true, + id: 'example-customers-sync:example-todo-created', +} + +export default createInboundSubscriber(metadata.event) diff --git a/packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-deleted.ts b/packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-deleted.ts new file mode 100644 index 00000000000..55ebe5f20c3 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-deleted.ts @@ -0,0 +1,9 @@ +import { createInboundSubscriber } from '../lib/inbound-subscriber' + +export const metadata = { + event: 'example.todo.deleted', + persistent: true, + id: 'example-customers-sync:example-todo-deleted', +} + +export default createInboundSubscriber(metadata.event) diff --git a/packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-updated.ts b/packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-updated.ts new file mode 100644 index 00000000000..29744e00e4e --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/subscribers/example-todo-updated.ts @@ -0,0 +1,9 @@ +import { createInboundSubscriber } from '../lib/inbound-subscriber' + +export const metadata = { + event: 'example.todo.updated', + persistent: true, + id: 'example-customers-sync:example-todo-updated', +} + +export default createInboundSubscriber(metadata.event) diff --git a/packages/create-app/template/src/modules/example_customers_sync/workers/inbound.ts b/packages/create-app/template/src/modules/example_customers_sync/workers/inbound.ts new file mode 100644 index 00000000000..4a3814df81c --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/workers/inbound.ts @@ -0,0 +1,23 @@ +import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue' +import { EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE } from '../lib/queue' +import { + syncExampleTodoToCanonicalInteraction, + type ExampleCustomersSyncInboundJobPayload, +} from '../lib/sync' + +export const metadata: WorkerMeta = { + queue: EXAMPLE_CUSTOMERS_SYNC_INBOUND_QUEUE, + id: 'example-customers-sync:inbound', + concurrency: 5, +} + +type HandlerContext = JobContext & { + resolve: (name: string) => T +} + +export default async function handle( + job: QueuedJob, + ctx: HandlerContext, +): Promise { + await syncExampleTodoToCanonicalInteraction(ctx, job.payload) +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/workers/outbound.ts b/packages/create-app/template/src/modules/example_customers_sync/workers/outbound.ts new file mode 100644 index 00000000000..addca625265 --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/workers/outbound.ts @@ -0,0 +1,21 @@ +import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue' +import { EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE } from '../lib/queue' +import type { ExampleCustomersSyncOutboundJobPayload } from '../lib/sync' + +export const metadata: WorkerMeta = { + queue: EXAMPLE_CUSTOMERS_SYNC_OUTBOUND_QUEUE, + id: 'example-customers-sync:outbound', + concurrency: 5, +} + +type HandlerContext = JobContext & { + resolve: (name: string) => T +} + +export default async function handle( + job: QueuedJob, + ctx: HandlerContext, +): Promise { + const { syncCustomerInteractionToExampleTodo } = await import('../lib/sync') + await syncCustomerInteractionToExampleTodo(ctx, job.payload) +} diff --git a/packages/create-app/template/src/modules/example_customers_sync/workers/reconcile.ts b/packages/create-app/template/src/modules/example_customers_sync/workers/reconcile.ts new file mode 100644 index 00000000000..20b7acbd5bd --- /dev/null +++ b/packages/create-app/template/src/modules/example_customers_sync/workers/reconcile.ts @@ -0,0 +1,33 @@ +import type { JobContext, QueuedJob, WorkerMeta } from '@open-mercato/queue' +import { EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE } from '../lib/queue' +import { + reconcileLegacyExampleTodoLinks, + type ExampleCustomersSyncReconcileJobPayload, +} from '../lib/sync' + +export const metadata: WorkerMeta = { + queue: EXAMPLE_CUSTOMERS_SYNC_RECONCILE_QUEUE, + id: 'example-customers-sync:reconcile', + concurrency: 1, +} + +type HandlerContext = JobContext & { + resolve: (name: string) => T +} + +export default async function handle( + job: QueuedJob, + ctx: HandlerContext, +): Promise { + let nextCursor = job.payload.cursor + + do { + const result = await reconcileLegacyExampleTodoLinks(ctx, { + tenantId: job.payload.tenantId, + organizationId: job.payload.organizationId, + limit: job.payload.limit, + cursor: nextCursor, + }) + nextCursor = result.nextCursor + } while (nextCursor) +} diff --git a/packages/shared/src/lib/commands/helpers.ts b/packages/shared/src/lib/commands/helpers.ts index 7dabf18269c..e74b019bf1d 100644 --- a/packages/shared/src/lib/commands/helpers.ts +++ b/packages/shared/src/lib/commands/helpers.ts @@ -50,14 +50,16 @@ export async function emitCrudSideEffects(opts: { action: 'created' | 'updated' | 'deleted' entity: TEntity identifiers: CrudEmitContext['identifiers'] + syncOrigin?: string | null events?: CrudEventsConfig indexer?: CrudIndexerConfig }) { - const { dataEngine, action, entity, identifiers, events, indexer } = opts + const { dataEngine, action, entity, identifiers, syncOrigin, events, indexer } = opts dataEngine.markOrmEntityChange({ action, entity, identifiers, + syncOrigin, events, indexer, }) @@ -68,15 +70,17 @@ export async function emitCrudUndoSideEffects(opts: { action: 'created' | 'updated' | 'deleted' entity: TEntity | null | undefined identifiers: CrudEmitContext['identifiers'] + syncOrigin?: string | null events?: CrudEventsConfig indexer?: CrudIndexerConfig }) { - const { dataEngine, action, entity, identifiers, events, indexer } = opts + const { dataEngine, action, entity, identifiers, syncOrigin, events, indexer } = opts if (!entity) return dataEngine.markOrmEntityChange({ action, entity, identifiers, + syncOrigin, events, indexer, }) diff --git a/packages/shared/src/lib/commands/types.ts b/packages/shared/src/lib/commands/types.ts index 874cdd39482..c9e2728ba5e 100644 --- a/packages/shared/src/lib/commands/types.ts +++ b/packages/shared/src/lib/commands/types.ts @@ -10,6 +10,7 @@ export type CommandRuntimeContext = { selectedOrganizationId: string | null organizationIds: string[] | null request?: Request + syncOrigin?: string | null } export type CommandLogMetadata = { diff --git a/packages/shared/src/lib/crud/types.ts b/packages/shared/src/lib/crud/types.ts index 8daa2994456..2e7f98df37a 100644 --- a/packages/shared/src/lib/crud/types.ts +++ b/packages/shared/src/lib/crud/types.ts @@ -10,6 +10,7 @@ export type CrudEmitContext = { action: CrudEventAction entity: TEntity identifiers: CrudEntityIdentifiers + syncOrigin?: string | null } export type CrudEventsConfig = { diff --git a/packages/shared/src/lib/data/engine.ts b/packages/shared/src/lib/data/engine.ts index cced6727283..c3b24606769 100644 --- a/packages/shared/src/lib/data/engine.ts +++ b/packages/shared/src/lib/data/engine.ts @@ -33,6 +33,7 @@ type QueuedCrudSideEffect = { action: CrudEventAction entity: unknown identifiers: CrudEntityIdentifiers + syncOrigin?: string | null events?: CrudEventsConfig indexer?: CrudIndexerConfig } @@ -100,6 +101,7 @@ export interface DataEngine { events?: CrudEventsConfig indexer?: CrudIndexerConfig identifiers: CrudEntityIdentifiers + syncOrigin?: string | null }): Promise markOrmEntityChange(opts: { @@ -108,6 +110,7 @@ export interface DataEngine { events?: CrudEventsConfig indexer?: CrudIndexerConfig identifiers: CrudEntityIdentifiers + syncOrigin?: string | null }): void flushOrmEntityChanges(): Promise @@ -408,8 +411,15 @@ export class DefaultDataEngine implements DataEngine { return current } - async emitOrmEntityEvent(opts: { action: CrudEventAction; entity: T; events?: CrudEventsConfig; indexer?: CrudIndexerConfig; identifiers: CrudEntityIdentifiers }): Promise { - const { action, entity, events, indexer, identifiers } = opts + async emitOrmEntityEvent(opts: { + action: CrudEventAction + entity: T + events?: CrudEventsConfig + indexer?: CrudIndexerConfig + identifiers: CrudEntityIdentifiers + syncOrigin?: string | null + }): Promise { + const { action, entity, events, indexer, identifiers, syncOrigin } = opts if (!events && !indexer) return if (!identifiers?.id) return @@ -429,6 +439,7 @@ export class DefaultDataEngine implements DataEngine { organizationId: identifiers.organizationId ?? null, tenantId: identifiers.tenantId ?? null, }, + syncOrigin: syncOrigin ?? null, } if (events) { @@ -439,6 +450,7 @@ export class DefaultDataEngine implements DataEngine { id: ctx.identifiers.id, organizationId: ctx.identifiers.organizationId, tenantId: ctx.identifiers.tenantId, + ...(ctx.syncOrigin ? { syncOrigin: ctx.syncOrigin } : {}), } try { await bus.emitEvent(eventName, payload, { persistent: !!events.persistent }) @@ -467,6 +479,7 @@ export class DefaultDataEngine implements DataEngine { const enrichedPayload = payload as Record enrichedPayload.crudAction = action if (coverageBaseDelta !== undefined) enrichedPayload.coverageBaseDelta = coverageBaseDelta + if (ctx.syncOrigin) enrichedPayload.syncOrigin = ctx.syncOrigin try { await bus.emitEvent('query_index.delete_one', enrichedPayload) } catch { @@ -484,6 +497,7 @@ export class DefaultDataEngine implements DataEngine { const enrichedPayload = payload as Record enrichedPayload.crudAction = action if (coverageBaseDelta !== undefined) enrichedPayload.coverageBaseDelta = coverageBaseDelta + if (ctx.syncOrigin) enrichedPayload.syncOrigin = ctx.syncOrigin try { await bus.emitEvent('query_index.upsert_one', enrichedPayload) } catch { @@ -502,7 +516,14 @@ export class DefaultDataEngine implements DataEngine { } } - markOrmEntityChange(opts: { action: CrudEventAction; entity: T | null | undefined; events?: CrudEventsConfig; indexer?: CrudIndexerConfig; identifiers: CrudEntityIdentifiers }): void { + markOrmEntityChange(opts: { + action: CrudEventAction + entity: T | null | undefined + events?: CrudEventsConfig + indexer?: CrudIndexerConfig + identifiers: CrudEntityIdentifiers + syncOrigin?: string | null + }): void { const { entity, identifiers } = opts if (!entity) return if (!identifiers?.id) return @@ -515,6 +536,7 @@ export class DefaultDataEngine implements DataEngine { organizationId: identifiers.organizationId ?? null, tenantId: identifiers.tenantId ?? null, } + existing.syncOrigin = opts.syncOrigin ?? null if (opts.events) existing.events = opts.events as CrudEventsConfig if (opts.indexer) existing.indexer = opts.indexer as CrudIndexerConfig this.pendingSideEffects.set(key, existing) @@ -528,6 +550,7 @@ export class DefaultDataEngine implements DataEngine { organizationId: identifiers.organizationId ?? null, tenantId: identifiers.tenantId ?? null, }, + syncOrigin: opts.syncOrigin ?? null, } if (opts.events) entry.events = opts.events as CrudEventsConfig if (opts.indexer) entry.indexer = opts.indexer as CrudIndexerConfig @@ -544,6 +567,7 @@ export class DefaultDataEngine implements DataEngine { action: entry.action, entity: entry.entity, identifiers: entry.identifiers, + syncOrigin: entry.syncOrigin ?? null, events: entry.events as CrudEventsConfig, indexer: entry.indexer as CrudIndexerConfig, }) From 5803a5a2c11a9a2cfdd9f5827176c8df2923542f Mon Sep 17 00:00:00 2001 From: Patryk Lewczuk Date: Mon, 6 Apr 2026 08:14:31 +0200 Subject: [PATCH 006/215] feat(agentic): standalone app skills, navigation guide, and module-level guides (#1151) * feat(create-app): add implement-spec and integration-tests skills for standalone apps Add two new agentic skills adapted for standalone app development: - implement-spec: spec-driven implementation workflow with phase tracking, subagent coordination, unit/integration tests, and code-review gates. Removes core-only concerns (backward compatibility, extension mode decision). - integration-tests: Playwright test authoring and execution workflow with app exploration, fixture management, failure analysis, and module gating. Replaces ephemeral environment with dev server approach. Also scaffolds a Playwright config (.ai/qa/playwright.config.ts) using the framework's test discovery from @open-mercato/cli, with GitHub Actions reporter support. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(agentic): add navigation guide, module-level standalone guides, and build pipeline extension Navigation improvements for standalone apps: - Add navigation-patterns.md reference (page.meta.ts fields, sidebar grouping, settings pages, anti-patterns) - Fix incomplete page.meta.ts templates in module-scaffold skill (add pageGroup, pageGroupKey, pageOrder, breadcrumb, requireFeatures) - Add navigation checks to code-review checklist and module-scaffold rules - Add cross-reference in backend-ui-design skill Module-level standalone guide support: - Extend build.mjs to discover guides at packages/*/src/modules/*/agentic/standalone-guide.md (output as {pkg}.{mod}.md alongside existing package-level guides) - Create standalone guides for all 9 core modules: customers, workflows, catalog, sales, auth, currencies, integrations, data_sync, customer_accounts - Add Module-Specific Guides section to AGENTS.md.template task router Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .../modules/auth/agentic/standalone-guide.md | 101 +++++++ .../catalog/agentic/standalone-guide.md | 79 +++++ .../currencies/agentic/standalone-guide.md | 43 +++ .../agentic/standalone-guide.md | 124 ++++++++ .../customers/agentic/standalone-guide.md | 138 +++++++++ .../data_sync/agentic/standalone-guide.md | 107 +++++++ .../integrations/agentic/standalone-guide.md | 113 +++++++ .../modules/sales/agentic/standalone-guide.md | 84 ++++++ .../workflows/agentic/standalone-guide.md | 152 ++++++++++ .../agentic/shared/AGENTS.md.template | 22 ++ .../agentic/shared/ai/qa/playwright.config.ts | 50 ++++ .../ai/skills/backend-ui-design/SKILL.md | 8 + .../references/review-checklist.md | 3 + .../shared/ai/skills/implement-spec/SKILL.md | 162 ++++++++++ .../ai/skills/integration-tests/SKILL.md | 279 ++++++++++++++++++ .../shared/ai/skills/module-scaffold/SKILL.md | 46 ++- .../references/navigation-patterns.md | 97 ++++++ packages/create-app/build.mjs | 12 + packages/create-app/src/setup/tools/shared.ts | 19 ++ 19 files changed, 1628 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/modules/auth/agentic/standalone-guide.md create mode 100644 packages/core/src/modules/catalog/agentic/standalone-guide.md create mode 100644 packages/core/src/modules/currencies/agentic/standalone-guide.md create mode 100644 packages/core/src/modules/customer_accounts/agentic/standalone-guide.md create mode 100644 packages/core/src/modules/customers/agentic/standalone-guide.md create mode 100644 packages/core/src/modules/data_sync/agentic/standalone-guide.md create mode 100644 packages/core/src/modules/integrations/agentic/standalone-guide.md create mode 100644 packages/core/src/modules/sales/agentic/standalone-guide.md create mode 100644 packages/core/src/modules/workflows/agentic/standalone-guide.md create mode 100644 packages/create-app/agentic/shared/ai/qa/playwright.config.ts create mode 100644 packages/create-app/agentic/shared/ai/skills/implement-spec/SKILL.md create mode 100644 packages/create-app/agentic/shared/ai/skills/integration-tests/SKILL.md create mode 100644 packages/create-app/agentic/shared/ai/skills/module-scaffold/references/navigation-patterns.md diff --git a/packages/core/src/modules/auth/agentic/standalone-guide.md b/packages/core/src/modules/auth/agentic/standalone-guide.md new file mode 100644 index 00000000000..4ee3ab80d4d --- /dev/null +++ b/packages/core/src/modules/auth/agentic/standalone-guide.md @@ -0,0 +1,101 @@ +# Auth Module — Standalone App Guide + +The auth module handles staff authentication, authorization, users, roles, and RBAC. For customer portal authentication, see the `customer_accounts` module guide. + +## RBAC Implementation + +### Two-Layer Model + +1. **Role ACLs** — features assigned to roles (admin, employee, etc.) +2. **User ACLs** — per-user overrides (additional features or restrictions) + +Effective permissions = Role features + User-specific features. + +### Declaring Features + +Every module MUST declare features in `acl.ts` and wire them in `setup.ts`: + +```typescript +// src/modules//acl.ts +export const features = [ + 'your_module.view', + 'your_module.create', + 'your_module.update', + 'your_module.delete', +] + +// src/modules//setup.ts +import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup' + +export const setup: ModuleSetupConfig = { + defaultRoleFeatures: { + superadmin: ['your_module.*'], + admin: ['your_module.*'], + user: ['your_module.view'], + }, +} +``` + +### Feature Naming Convention + +Features follow the `.` pattern (e.g., `users.view`, `users.edit`). + +### Declarative Guards + +Prefer declarative guards in page and API metadata: + +```typescript +export const metadata = { + requireAuth: true, + requireRoles: ['admin'], + requireFeatures: ['users.manage'], +} +``` + +### Server-Side Checks + +```typescript +const rbacService = container.resolve('rbacService') +const hasAccess = await rbacService.userHasAllFeatures( + userId, + ['your_module.view'], + { tenantId, organizationId } +) +``` + +### Wildcards + +Wildcards are first-class ACL grants: `module.*` and `*` satisfy matching concrete features. When your code inspects raw granted feature arrays (instead of calling `rbacService`), use the shared wildcard-aware matchers (`matchFeature`, `hasFeature`, `hasAllFeatures`) — never use `includes(...)`. + +### Special Flags + +- `isSuperAdmin` — bypasses all feature checks +- Organization visibility list — restricts which organizations a user can access + +## Security Rules + +- Hash passwords with `bcryptjs` (cost >= 10) +- Never log credentials +- Return minimal auth error messages — never reveal whether an email exists +- Use `findWithDecryption` / `findOneWithDecryption` for user queries + +## Authentication Flow + +1. User submits credentials via `POST /api/auth/session` +2. Password verified with bcryptjs +3. JWT session token issued +4. Session attached to requests via middleware + +## Subscribing to Auth Events + +```typescript +export const metadata = { + event: 'auth.user.created', + persistent: true, + id: 'your-module-user-created', +} + +export default async function handler(payload, ctx) { + // React to new user registration +} +``` diff --git a/packages/core/src/modules/catalog/agentic/standalone-guide.md b/packages/core/src/modules/catalog/agentic/standalone-guide.md new file mode 100644 index 00000000000..0d92576f8b4 --- /dev/null +++ b/packages/core/src/modules/catalog/agentic/standalone-guide.md @@ -0,0 +1,79 @@ +# Catalog Module — Standalone App Guide + +Use the catalog module for products, categories, pricing, variants, and offers. + +## Pricing System + +Never reimplement pricing logic. Use the catalog pricing service via DI: + +```typescript +const pricingService = container.resolve('catalogPricingService') +``` + +- `selectBestPrice` — finds the best price for a given context (customer, channel, quantity) +- `resolvePriceVariantId` — resolves variant-level prices +- Register custom pricing resolvers with priority (higher = checked first): + +```typescript +import { registerCatalogPricingResolver } from '@open-mercato/core/modules/catalog/lib/pricing' +registerCatalogPricingResolver(myResolver, { priority: 10 }) +``` + +Price layers compose in order: base price → channel override → customer-specific → promotional. + +The pipeline emits `catalog.pricing.resolve.before` and `catalog.pricing.resolve.after` events that your module can subscribe to. + +## Data Model + +| Entity | Purpose | Key Constraints | +|--------|---------|----------------| +| **Products** | Core items with media and descriptions | MUST have at least a name | +| **Categories** | Hierarchical product grouping | No circular parent-child references | +| **Variants** | Product variations (size, color) | MUST reference valid option schemas | +| **Prices** | Multi-tier with channel scoping | Use `selectBestPrice` for resolution | +| **Offers** | Time-limited promotions | MUST have valid date ranges | +| **Option Schemas** | Variant option type definitions | Cannot delete while variants reference them | + +## Subscribing to Catalog Events + +React to product lifecycle events in your module: + +```typescript +// src/modules//subscribers/product-updated.ts +export const metadata = { + event: 'catalog.product.updated', + persistent: true, + id: 'your-module-product-updated', +} + +export default async function handler(payload, ctx) { + // payload.resourceId = product ID +} +``` + +Key events: +- `catalog.product.created` / `updated` / `deleted` +- `catalog.pricing.resolve.before` / `after` (excluded from workflow triggers) + +## Extending Catalog UI + +Use widget injection to add your module's UI into catalog pages: + +```typescript +// src/modules//widgets/injection-table.ts +export const widgetInjections = { + 'crud-form:catalog.catalog_product:fields': { + widgetId: 'your-module-product-fields', + priority: 100, + }, +} +``` + +Common injection spots: +- `crud-form:catalog.catalog_product:fields` — product edit form +- `data-table:catalog.products:columns` — product list columns +- `data-table:catalog.products:row-actions` — product row actions + +## Using Catalog in Sales + +When building sales-related features, use the catalog pricing service to resolve prices rather than reading price entities directly. This ensures channel scoping, customer-specific pricing, and promotional offers are applied correctly. diff --git a/packages/core/src/modules/currencies/agentic/standalone-guide.md b/packages/core/src/modules/currencies/agentic/standalone-guide.md new file mode 100644 index 00000000000..94a98265fda --- /dev/null +++ b/packages/core/src/modules/currencies/agentic/standalone-guide.md @@ -0,0 +1,43 @@ +# Currencies Module — Standalone App Guide + +Use the currencies module for multi-currency support, exchange rates, and currency conversion. + +## Key Rules + +1. **Store amounts with 4 decimal precision** — never truncate to 2 decimals internally +2. **Use date-based exchange rates** — always resolve rates for the transaction date, not the "current" rate +3. **Record both currencies** — dual recording (transaction currency + base currency) is mandatory for reporting +4. **Calculate realized gains/losses** on payment: `(payment rate - invoice rate) × foreign amount` +5. **Never hard-delete exchange rates** — they are historical reference data + +## Multi-Currency Transaction Pattern + +When processing multi-currency transactions (e.g., sales invoice in EUR with USD base): + +1. Retrieve the exchange rate for the transaction date +2. Generate the document in the transaction currency +3. Calculate the base currency equivalent: `foreign amount × rate` +4. Store both amounts on the document +5. On payment: calculate realized gain/loss from rate difference +6. Report in both transaction and base currencies + +## Data Model + +| Entity | Table | Purpose | +|--------|-------|---------| +| **Currency** | `currency` | Currency master data (code, name, symbol) | +| **Exchange Rate** | `exchange_rate` | Daily exchange rates per currency pair | + +## Adding a New Currency + +1. Add the currency record via the admin UI or `seedDefaults` hook in your `setup.ts` +2. Ensure exchange rates exist for the currency pair at required dates +3. Verify all sales/pricing logic resolves the new currency correctly + +## Using Currencies in Your Module + +When your module deals with monetary amounts: +- Store the currency code alongside the amount +- Reference the currencies module for exchange rate lookups +- Use the transaction date for rate resolution, not the current date +- Store both foreign and base amounts for reporting diff --git a/packages/core/src/modules/customer_accounts/agentic/standalone-guide.md b/packages/core/src/modules/customer_accounts/agentic/standalone-guide.md new file mode 100644 index 00000000000..9c455fa43f4 --- /dev/null +++ b/packages/core/src/modules/customer_accounts/agentic/standalone-guide.md @@ -0,0 +1,124 @@ +# Customer Accounts Module — Standalone App Guide + +Customer-facing identity and portal authentication. This module manages customer user accounts, sessions, roles, and the authentication flow for the customer portal. It is separate from the staff `auth` module. + +## Portal Authentication + +### Login Flow +1. Customer submits credentials via `POST /api/login` +2. Password verified with bcryptjs, lockout checked (5 attempts → 15 min lock) +3. JWT issued with customer claims (`type: 'customer'`, features, CRM links) +4. Two cookies set: `customer_auth_token` (JWT, 8h) + `customer_session_token` (raw, 30d) + +### Other Auth Methods +- **Signup**: `POST /api/signup` — self-registration with email verification +- **Magic Link**: `POST /api/magic-link/request` + `/verify` — passwordless login (15 min TTL) +- **Password Reset**: `POST /api/password/reset-request` + `/reset-confirm` (60 min TTL) +- **Invitation**: Admin invites user → `POST /api/invitations/accept` (72h TTL) + +## Customer RBAC + +### Two-Layer Model (mirrors staff RBAC) +1. **Role ACLs** — features assigned to roles +2. **User ACLs** — per-user overrides (takes precedence if present) + +### Default Roles (seeded on tenant creation) +| Role | Features | Portal Admin | +|------|----------|-------------| +| Portal Admin | `portal.*` | Yes | +| Buyer | Orders, quotes, catalog, account | No | +| Viewer | Read-only orders, invoices, catalog | No | + +### Feature Convention +Portal features use `portal..` naming (e.g., `portal.orders.view`, `portal.catalog.view`). + +### Cross-Module Feature Merging +Your module can declare `defaultCustomerRoleFeatures` in `setup.ts`. During tenant setup, these are merged into the corresponding customer role ACLs: + +```typescript +// src/modules//setup.ts +export const setup: ModuleSetupConfig = { + defaultCustomerRoleFeatures: { + portal_admin: ['portal.your_feature.*'], + buyer: ['portal.your_feature.view'], + }, +} +``` + +## Using Customer Auth in Your Module + +### Server Components (pages) +```typescript +import { getCustomerAuthFromCookies } from '@open-mercato/core/modules/customer_accounts/lib/customerAuthServer' + +const auth = await getCustomerAuthFromCookies() +if (!auth) redirect('/login') +``` + +### API Routes +```typescript +import { requireCustomerAuth, requireCustomerFeature } from '@open-mercato/core/modules/customer_accounts/lib/customerAuth' + +// In your API handler: +const auth = requireCustomerAuth(request) // throws 401 if not authenticated +requireCustomerFeature(auth, ['portal.orders.view']) // throws 403 if missing +``` + +### RBAC Service +```typescript +const rbacService = container.resolve('customerRbacService') +const hasAccess = await rbacService.userHasAllFeatures( + userId, ['portal.orders.view'], { tenantId, organizationId } +) +``` + +## Portal Page Guards + +Use declarative metadata for portal pages: + +```typescript +export const metadata = { + requireCustomerAuth: true, + requireCustomerFeatures: ['portal.orders.view'], +} +``` + +## Subscribing to Customer Events + +| Event | When | +|-------|------| +| `customer_accounts.user.created` | New customer signup | +| `customer_accounts.user.updated` | Profile updated | +| `customer_accounts.user.locked` | Account locked after failed logins | +| `customer_accounts.login.success` | Successful login | +| `customer_accounts.invitation.accepted` | Invitation accepted | + +```typescript +export const metadata = { + event: 'customer_accounts.user.created', + persistent: true, + id: 'your-module-customer-signup', +} + +export default async function handler(payload, ctx) { + // React to customer signup — e.g., create default preferences +} +``` + +## CRM Auto-Linking + +When a customer signs up, the module automatically searches for a matching CRM person by email and links them (`personEntityId`). The reverse also works — creating a CRM person auto-links to an existing customer user. + +## Widget Injection Spots + +| Spot | Widget | Purpose | +|------|--------|---------| +| `crud-form:customers:customer_person_profile:fields` | Account status | Shows portal account status on CRM person detail | +| `crud-form:customers:customer_company_profile:fields` | Company users | Shows portal users linked to a CRM company | + +## Security Notes + +- All public endpoints are rate-limited (per-email + per-IP) +- Tokens stored as SHA-256 hashes — raw tokens never persisted +- Emails use deterministic hash for lookups (`hashForLookup`) +- Error messages never confirm whether an email is registered diff --git a/packages/core/src/modules/customers/agentic/standalone-guide.md b/packages/core/src/modules/customers/agentic/standalone-guide.md new file mode 100644 index 00000000000..82b44f9a424 --- /dev/null +++ b/packages/core/src/modules/customers/agentic/standalone-guide.md @@ -0,0 +1,138 @@ +# Customers Module — Reference CRUD Patterns + +This is the **reference CRUD module**. When building new modules in your standalone app, follow these patterns. + +## CRUD API Pattern + +Use `makeCrudRoute` with `indexer: { entityType }` for query index coverage: + +```typescript +// src/modules//api/get/.ts +import { makeCrudRoute } from '@open-mercato/shared/lib/crud/make-crud-route' +import { YourEntity } from '../../entities/YourEntity' + +const handler = makeCrudRoute({ + entity: YourEntity, + entityId: 'your_module.your_entity', + operations: ['list', 'detail'], + indexer: { entityType: 'your_module.your_entity' }, +}) + +export default handler +export const openApi = { summary: 'List and retrieve entities', tags: ['Your Module'] } +``` + +Key points: +- Always set `indexer: { entityType }` — keeps custom entities indexed +- Wire custom field helpers for create/update if your module supports custom fields +- Export `openApi` on every API route file + +## Undoable Commands Pattern + +All write operations should use the Command pattern with undo support: + +```typescript +import { registerCommand } from '@open-mercato/shared/lib/commands' +import { extractUndoPayload } from '@open-mercato/shared/lib/commands/undo' + +registerCommand('your_module.entity.create', { + async execute(payload, ctx) { + // 1. Create entity + // 2. Capture snapshot for undo: extractUndoPayload(entity) + // 3. Side effects: emitCrudSideEffects({ indexer: { entityType, cacheAliases } }) + }, + async undo(payload, ctx) { + // 1. Restore from snapshot + // 2. Side effects: emitCrudUndoSideEffects({ indexer: { entityType, cacheAliases } }) + }, +}) +``` + +Key points: +- Include `indexer: { entityType, cacheAliases }` in both `emitCrudSideEffects` and `emitCrudUndoSideEffects` +- Capture custom field snapshots in `before`/`after` payloads (`snapshot.custom`) +- Restore custom fields via `buildCustomFieldResetMap(before.custom, after.custom)` in undo + +## Custom Field Integration + +```typescript +import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues' +``` + +- Pass `{ transform }` to normalize values (e.g., `normalizeCustomFieldSubmitValue`) +- Works for both `cf_` and `cf:` prefixed keys +- Pass `entityIds` to form helpers so correct custom-field sets are loaded +- If your module ships default custom fields, declare them in `ce.ts` via `entities[].fields` + +## Search Configuration + +Declare in `search.ts` with all three strategies: + +```typescript +import type { SearchModuleConfig } from '@open-mercato/shared/modules/search' + +export const searchConfig: SearchModuleConfig = { + entities: { + 'your_module.your_entity': { + fields: ['name', 'description'], // Fulltext indexing + // fieldPolicy for sensitive field handling + // buildSource for vector embeddings + // formatResult for search result display + }, + }, +} +``` + +Key points: +- Use `fieldPolicy.excluded` for sensitive fields (passwords, tokens) +- Use `fieldPolicy.hashOnly` for PII needing exact-match only (email, phone) +- Always define `formatResult` for human-friendly search results + +## Backend Page Structure + +Follow this pattern for each page type: + +| Page | Pattern | Key Features | +|------|---------|-------------| +| **List** | `DataTable` | Filters, search, export, row actions, pagination | +| **Create** | `CrudForm` mode=create | Fields, groups, custom fields, back link | +| **Detail/Edit** | `CrudForm` mode=edit or tabbed layout | Entity data, related entities, activities | + +## Module Files Checklist + +When scaffolding a new CRUD module, ensure all these files are present: + +| File | Purpose | +|------|---------| +| `index.ts` | Module metadata | +| `acl.ts` | Feature-based permissions | +| `setup.ts` | Tenant init, default role features | +| `di.ts` | Awilix DI registrations | +| `events.ts` | Typed event declarations | +| `data/entities.ts` | MikroORM entity classes | +| `data/validators.ts` | Zod validation schemas | +| `search.ts` | Search indexing configuration | +| `ce.ts` | Custom entities / custom field sets | + +Optional: +- `translations.ts` — translatable fields per entity +- `notifications.ts` — notification type definitions +- `cli.ts` — module CLI commands + +## Entity Update Safety + +When mutating entities across multiple phases that include queries: + +```typescript +import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush' + +await withAtomicFlush(em, [ + () => { record.name = 'New'; record.status = 'active' }, + () => syncEntityTags(em, record, tags), +], { transaction: true }) + +// Side effects AFTER the atomic flush +await emitCrudSideEffects({ ... }) +``` + +Never run `em.find`/`em.findOne` between scalar mutations and `em.flush()` without `withAtomicFlush` — changes will be silently lost. diff --git a/packages/core/src/modules/data_sync/agentic/standalone-guide.md b/packages/core/src/modules/data_sync/agentic/standalone-guide.md new file mode 100644 index 00000000000..65877ae7423 --- /dev/null +++ b/packages/core/src/modules/data_sync/agentic/standalone-guide.md @@ -0,0 +1,107 @@ +# Data Sync Module — Standalone App Guide + +The data sync module provides a streaming synchronization hub for import/export operations with external systems. Provider modules register `DataSyncAdapter` implementations. + +## Creating a Sync Adapter + +Implement the `DataSyncAdapter` interface in your provider module: + +```typescript +import type { DataSyncAdapter } from '@open-mercato/core/modules/data_sync/lib/adapter' + +const myAdapter: DataSyncAdapter = { + providerKey: 'my_provider', + direction: 'import', // 'import' | 'export' | 'bidirectional' + supportedEntities: ['catalog.product', 'customers.person'], + + async *streamImport(entityType, cursor, config) { + // Yield ImportBatch objects with records + yield { records: [...], cursor: 'next-page-token' } + }, + + async validateConnection(credentials) { + // Verify external system is reachable + return { valid: true } + }, + + async getInitialCursor(entityType) { + return null // Start from beginning + }, +} +``` + +Register in your module's `di.ts`: +```typescript +import { registerDataSyncAdapter } from '@open-mercato/core/modules/data_sync/lib/adapter-registry' +registerDataSyncAdapter(myAdapter) +``` + +## Run Lifecycle + +``` +pending → running → completed | failed | cancelled +``` + +- **Cursor persistence**: After each batch, cursor is saved — enables resume on failure +- **Progress**: Linked to `ProgressJob` for live progress display via `ProgressTopBar` +- **Cancellation**: Via `progressService.isCancellationRequested()` +- **Overlap protection**: Only one sync per integration + entityType + direction at a time + +## Key Services (DI) + +| Service | Purpose | +|---------|---------| +| `dataSyncRunService` | CRUD for sync runs, cursor management, overlap detection | +| `dataSyncEngine` | Orchestrates streaming import/export with batch processing and progress | +| `externalIdMappingService` | Maps local entity IDs to/from external system IDs | + +## Starting a Sync + +Via API: +``` +POST /api/data_sync/run +{ "integrationId": "my_provider", "entityType": "catalog.product", "direction": "import" } +``` + +Syncs run asynchronously via the queue system — never run inline in API handlers. + +## Queue Workers + +| Queue | Worker | Concurrency | +|-------|--------|-------------| +| `data-sync-import` | Import handler | 5 | +| `data-sync-export` | Export handler | 5 | +| `data-sync-scheduled` | Scheduled sync dispatch | 3 | + +## Events + +| Event | When | +|-------|------| +| `data_sync.run.started` | Sync begins processing | +| `data_sync.run.completed` | Sync finishes successfully | +| `data_sync.run.failed` | Sync fails | +| `data_sync.run.cancelled` | Sync is cancelled | + +Subscribe to these events to trigger post-sync side effects in your module. + +## UMES Extension Points + +Sync providers can extend the platform UI: + +| Extension | Use Case | +|-----------|----------| +| **Widget Injection** | Sync status badges, mapping previews on entity pages | +| **Event Subscribers** | React to sync lifecycle events | +| **Entity Extensions** | Link sync metadata to core entities | +| **Response Enrichers** | Attach external ID data to API responses | +| **Notifications** | Alerts on sync completion/failure | +| **DOM Event Bridge** | Real-time sync progress via SSE | +| **Menu Injection** | Provider-specific sync dashboards in sidebar | + +## Key Rules + +- Always scope queries by `organizationId` + `tenantId` +- Use the queue system — never run syncs inline +- Persist cursor after each batch — enables resume on failure +- Log item-level errors — don't stop the sync for individual failures +- Check for overlap before starting a new run diff --git a/packages/core/src/modules/integrations/agentic/standalone-guide.md b/packages/core/src/modules/integrations/agentic/standalone-guide.md new file mode 100644 index 00000000000..73a472c1d2d --- /dev/null +++ b/packages/core/src/modules/integrations/agentic/standalone-guide.md @@ -0,0 +1,113 @@ +# Integrations Module — Standalone App Guide + +The integrations module provides the foundation for all external connectors (payment gateways, shipping carriers, data sync providers, etc.). It offers three shared mechanisms: **Integration Registry**, **Credentials API**, and **Operation Logs**. + +## Creating an Integration Provider + +Create a new module in your app for each provider: + +1. Create `src/modules//` with standard module files +2. Add `integration.ts` at the module root exporting an `IntegrationDefinition`: + +```typescript +import type { IntegrationDefinition } from '@open-mercato/shared/modules/integrations/types' + +export const integration: IntegrationDefinition = { + id: 'my_provider', + name: 'My Provider', + description: 'Integration with My Provider', + category: 'payment', + credentials: { + fields: [ + { key: 'apiKey', label: 'API Key', type: 'password', required: true }, + { key: 'environment', label: 'Environment', type: 'select', options: ['sandbox', 'production'] }, + ], + }, + healthCheck: { service: 'myProviderHealthCheck' }, // optional + apiVersions: ['v1', 'v2'], // optional +} +``` + +3. Register the health check service in `di.ts` (if declared) +4. Run `yarn generate` to auto-discover the integration + +## Key Services (DI) + +| Service | Purpose | +|---------|---------| +| `integrationCredentialsService` | Encrypted credential CRUD with bundle fallthrough | +| `integrationStateService` | Enable/disable, API version, reauth, health state | +| `integrationLogService` | Structured logging with scoped loggers | +| `integrationHealthService` | Resolves and runs provider health checks | + +## Credential Resolution + +1. Direct credentials for the integration ID +2. If `bundleId` is set, fallback to bundle's credentials +3. Returns `null` if neither exists + +## Bundle Integrations + +For platform connectors with multiple integrations (e.g., an ERP with products + orders sync): + +```typescript +export const bundle: IntegrationBundle = { + id: 'my_erp', + name: 'My ERP', + integrations: ['my_erp_products', 'my_erp_orders'], +} +``` + +- Set `bundleId` on each child integration +- Bundle credentials are shared via fallthrough + +## Events + +| Event | When | +|-------|------| +| `integrations.credentials.updated` | Credentials saved | +| `integrations.state.updated` | Integration enabled/disabled | +| `integrations.version.changed` | API version changed | +| `integrations.log.created` | Log entry written | + +## Extending the Integration Detail Page + +Provider modules can add tabs, cards, or sections to the integration detail page: + +```typescript +// integration.ts +import { buildIntegrationDetailWidgetSpotId } from '@open-mercato/shared/modules/integrations/types' + +export const integration = { + id: 'my_provider', + detailPage: { + widgetSpotId: buildIntegrationDetailWidgetSpotId('my_provider'), + }, +} satisfies IntegrationDefinition +``` + +Register widgets for that spot in `widgets/injection-table.ts`. Use `placement.kind: 'tab'` for additional tabs, `'group'` for card panels, `'stack'` for inline sections. + +## UMES Extension Points + +Integration providers can leverage the full extension system: + +| Extension | Use Case | +|-----------|----------| +| **Widget Injection** | Inject status badges, config panels into other modules | +| **Event Subscribers** | React to integration events for side-effects | +| **Entity Extensions** | Link provider data to core entities (e.g., external IDs) | +| **Response Enrichers** | Attach provider data to API responses | +| **API Interceptors** | Intercept routes with before/after hooks | +| **Notifications** | In-app alerts on integration events | +| **DOM Event Bridge** | Real-time updates via SSE (`clientBroadcast: true`) | + +## Provider-Owned Env Preconfiguration + +If your provider needs credentials or settings after a fresh install: + +- Read env vars in a provider-local helper (e.g., `lib/preset.ts`) +- Apply from your module's `setup.ts` for automatic tenant bootstrap +- Expose a CLI command for rerunning the bootstrap +- Use provider-prefixed env names (e.g., `OM_INTEGRATION_MYPROVIDER_*`) +- Persist through normal integration services — never special-case in core diff --git a/packages/core/src/modules/sales/agentic/standalone-guide.md b/packages/core/src/modules/sales/agentic/standalone-guide.md new file mode 100644 index 00000000000..1def6c6ab42 --- /dev/null +++ b/packages/core/src/modules/sales/agentic/standalone-guide.md @@ -0,0 +1,84 @@ +# Sales Module — Standalone App Guide + +Use the sales module for orders, quotes, invoices, shipments, and payments. This module has the most complex business logic in the system. + +## Document Flow + +``` +Quote → Order → Invoice + ↓ + Shipments + Payments +``` + +- Quotes convert to orders — do not create orders without a source quote (unless configured) +- Orders track shipments and payments independently +- Each entity has its own status workflow — do not skip states +- Returns create line-level adjustments and update `returned_quantity` + +## Pricing Calculations + +Always use the sales calculation service — never inline price math: + +```typescript +const calcService = container.resolve('salesCalculationService') +``` + +- Dispatches `sales.line.calculate.*` and `sales.document.calculate.*` events +- For catalog pricing: use `selectBestPrice` from the catalog module +- Register custom line/totals calculators or override via DI + +## Channel Scoping + +All sales documents are scoped to channels. Channel selection affects: +- Available pricing tiers +- Document numbering sequences +- Visibility in admin UI + +## Data Model + +### Core Entities +| Entity | Purpose | Key Constraint | +|--------|---------|---------------| +| **Sales Orders** | Confirmed customer orders | MUST have a channel and at least one line | +| **Sales Quotes** | Proposed orders | MUST track conversion status | +| **Order/Quote Lines** | Individual items | MUST reference valid products | +| **Adjustments** | Discounts/surcharges | MUST use registered `AdjustmentKind` | + +### Fulfillment +| Entity | Purpose | +|--------|---------| +| **Shipments** | Delivery tracking with status workflow | +| **Payments** | Payment recording with status workflow | +| **Returns** | Order returns with line selection and automatic adjustments | + +### Configuration (do not modify directly) +Channels, statuses, payment/shipping methods, price kinds, adjustment kinds, and document numbers — configure via admin UI or `setup.ts` hooks. + +## Subscribing to Sales Events + +```typescript +// src/modules//subscribers/order-created.ts +export const metadata = { + event: 'sales.order.created', + persistent: true, + id: 'your-module-order-created', +} + +export default async function handler(payload, ctx) { + // React to new orders +} +``` + +Key events: `sales.order.created` / `updated` / `deleted`, `sales.quote.created` / `updated`, `sales.payment.created`, `sales.shipment.created` + +## Extending Sales UI + +Common widget injection spots: +- `crud-form:sales.sales_order:fields` — order detail form +- `data-table:sales.orders:columns` — order list columns +- `data-table:sales.orders:row-actions` — order row actions +- `sales.document.detail.order:details` — order detail page sections + +## Frontend Pages + +- `frontend/quote/` — public-facing quote view for customer acceptance diff --git a/packages/core/src/modules/workflows/agentic/standalone-guide.md b/packages/core/src/modules/workflows/agentic/standalone-guide.md new file mode 100644 index 00000000000..a7795f6573c --- /dev/null +++ b/packages/core/src/modules/workflows/agentic/standalone-guide.md @@ -0,0 +1,152 @@ +# Workflows Module — Standalone App Guide + +Use the workflows module for business process automation: defining step-based workflows, executing instances, handling user tasks, and triggering workflows from domain events. + +## Using Workflows in Your App + +The workflow engine is provided by `@open-mercato/core`. Your standalone app can: + +1. **Create workflow definitions** via the visual editor at `/backend/workflows` +2. **Trigger workflows** from domain events emitted by your modules +3. **Subscribe to workflow events** for side effects in your modules +4. **Inject UI widgets** into workflow pages or inject workflow widgets into your pages +5. **Define user tasks** that require human approval or data entry + +## Starting Workflows Programmatically + +Resolve the workflow executor via DI — never import lib functions directly: + +```typescript +// In your module's DI-aware context (API route, subscriber, worker) +const executor = container.resolve('workflowExecutor') + +await executor.startWorkflow({ + workflowId: 'order-approval', // matches a WorkflowDefinition.workflowId + context: { + orderId: order.id, + orderTotal: order.totalGross, + customerName: order.customerName, + }, + organizationId, + tenantId, +}) +``` + +## Event Triggers + +Configure automatic workflow starts from your module's domain events: + +1. Create a workflow definition with a `triggers[]` entry in the visual editor or via API +2. The workflow engine's wildcard subscriber evaluates all non-internal events +3. Use `filterConditions` to narrow which events match (e.g., only orders above a threshold) +4. Use `contextMapping` to extract event payload fields into workflow context variables +5. Use `debounceMs` and `maxConcurrentInstances` to prevent trigger storms + +Excluded event prefixes (never trigger workflows): `query_index`, `search`, `workflows`, `cache`, `queue`. + +## Subscribing to Workflow Events + +React to workflow lifecycle events in your module: + +```typescript +// src/modules//subscribers/workflow-completed.ts +export const metadata = { + event: 'workflows.instance.completed', + persistent: true, + id: 'your-module-workflow-completed', +} + +export default async function handler(payload, ctx) { + // payload.resourceId = instance ID + // payload.workflowId = definition ID + // payload.context = workflow context variables +} +``` + +Key workflow events your module can subscribe to: + +| Event | When it fires | +|-------|--------------| +| `workflows.instance.created` | New workflow instance started | +| `workflows.instance.completed` | Workflow finished successfully | +| `workflows.instance.failed` | Workflow failed | +| `workflows.instance.cancelled` | Workflow was cancelled | +| `workflows.task.created` | User task assigned | +| `workflows.task.completed` | User task completed | +| `workflows.step.completed` | Individual step finished | + +## Step Types + +| Step type | Use case | +|-----------|----------| +| `START` | Entry point — every definition has exactly one | +| `END` | Terminal step — marks workflow as COMPLETED | +| `USER_TASK` | Human approval or data entry — pauses until task completion | +| `AUTOMATED` | Executes transition activities immediately and advances | +| `SUB_WORKFLOW` | Invokes a nested workflow definition | +| `WAIT_FOR_SIGNAL` | Pauses for an external signal (e.g., payment confirmed) | +| `WAIT_FOR_TIMER` | Pauses for a configured duration | +| `PARALLEL_FORK` / `PARALLEL_JOIN` | Splits/merges parallel execution paths | + +## Activity Types + +Activities execute on transitions between steps: + +| Activity type | Use case | +|---------------|----------| +| `SEND_EMAIL` | Send templated email | +| `CALL_API` | Call an internal API endpoint | +| `CALL_WEBHOOK` | Call an external HTTP endpoint | +| `UPDATE_ENTITY` | Mutate an entity via the command bus | +| `EMIT_EVENT` | Emit a domain event | +| `EXECUTE_FUNCTION` | Run a registered custom function | +| `WAIT` | Delay execution for a configured duration | + +Use `{{context.*}}`, `{{workflow.*}}`, `{{env.*}}`, `{{now}}` for variable interpolation in activity config — never hardcode values. + +## Sending Signals + +Resume a workflow waiting for an external signal: + +```typescript +const executor = container.resolve('workflowExecutor') + +await executor.sendSignal({ + instanceId: workflowInstanceId, + signalName: 'payment_confirmed', + payload: { transactionId: '...' }, + organizationId, + tenantId, +}) +``` + +## Widget Injection + +Inject workflow-related UI into your module's pages, or inject your module's widgets into workflow pages: + +```typescript +// src/modules//widgets/injection-table.ts +export const widgetInjections = { + // Inject into workflow task detail page + 'workflows.task.detail:after': { + widgetId: 'your-module-task-context', + priority: 50, + }, +} +``` + +## Compensation (Saga Pattern) + +When a workflow step fails, compensation activities execute in reverse order to undo previous steps. This follows the saga pattern: + +- **Sync activities** execute inline and advance immediately +- **Async activities** enqueue to the `workflow-activities` queue; workflow pauses until completion +- On failure, compensation runs in reverse — keep activity handlers **idempotent** (check state before mutating) + +## Key Rules + +- MUST resolve services via DI (`container.resolve('workflowExecutor')`) — never import lib functions directly +- MUST use `workflowExecutor.startWorkflow()` to create instances — never insert rows directly +- MUST keep activity handlers idempotent — they may be retried on failure +- MUST scope all queries by `organization_id` — workflow data is tenant-scoped +- MUST NOT couple your module to workflow internals — use event triggers and signals for integration diff --git a/packages/create-app/agentic/shared/AGENTS.md.template b/packages/create-app/agentic/shared/AGENTS.md.template index f791c570363..b94bbed0516 100644 --- a/packages/create-app/agentic/shared/AGENTS.md.template +++ b/packages/create-app/agentic/shared/AGENTS.md.template @@ -44,6 +44,7 @@ step, you WILL produce incorrect imports and miss required patterns. | Add/modify an entity, create migration | `.ai/guides/core.md` → Module Files, then `yarn mercato db generate` | | Add a REST API endpoint | `.ai/guides/core.md` → API Routes | | Add a backend page | `.ai/guides/ui.md` → CrudForm / DataTable | +| Configure sidebar navigation, page groups, settings pages | `.ai/skills/module-scaffold/references/navigation-patterns.md` | | Add event subscribers or emit events | `.ai/guides/events.md` | | Add real-time browser updates (SSE) | `.ai/guides/events.md` → DOM Event Bridge | | Add search to a module | `.ai/guides/search.md` | @@ -56,6 +57,22 @@ step, you WILL produce incorrect imports and miss required patterns. | Add notifications | `.ai/guides/core.md` → Notifications | | Add custom fields | `.ai/guides/core.md` → Custom Fields | +### Module-Specific Guides + +These guides ship automatically when the corresponding module is installed. + +| Task | Load | +|---|---| +| Build CRUD modules — reference patterns, commands, custom fields, search | `.ai/guides/core.customers.md` (if available) | +| Use workflow automation, triggers, user tasks, signals | `.ai/guides/core.workflows.md` (if available) | +| Use product catalog, pricing engine, variants, offers | `.ai/guides/core.catalog.md` (if available) | +| Use sales orders, quotes, invoices, shipments, payments | `.ai/guides/core.sales.md` (if available) | +| Use staff authentication, RBAC, roles, feature guards | `.ai/guides/core.auth.md` (if available) | +| Use multi-currency, exchange rates, dual recording | `.ai/guides/core.currencies.md` (if available) | +| Build integration providers, credentials, health checks | `.ai/guides/core.integrations.md` (if available) | +| Build data sync adapters, import/export connectors | `.ai/guides/core.data_sync.md` (if available) | +| Use customer portal auth, customer RBAC, portal pages | `.ai/guides/core.customer_accounts.md` (if available) | + ### Quality & Process | Task | Load | @@ -63,6 +80,8 @@ step, you WILL produce incorrect imports and miss required patterns. | Debug / fix errors | `.ai/skills/troubleshooter/SKILL.md` | | Review code changes | `.ai/skills/code-review/SKILL.md` | | Write a spec | `.ai/skills/spec-writing/SKILL.md`, `.ai/specs/SPEC-000-template.md` | +| Implement a spec (or selected phases) | `.ai/skills/implement-spec/SKILL.md` | +| Create / run integration tests | `.ai/skills/integration-tests/SKILL.md` | ## Module Anatomy @@ -118,6 +137,9 @@ Register in `src/modules.ts`: `{ id: '', from: '@app' }` - Custom modules use `from: '@app'` in `src/modules.ts` - Sidebar icons MUST use `lucide-react` components — never inline SVG via `React.createElement` +- `page.meta.ts` MUST include `pageGroup`, `pageGroupKey`, and `pageOrder` for sidebar grouping +- Settings pages MUST use `pageContext: 'settings' as const` with `navHidden: true` +- All related pages within a module MUST share the same `pageGroupKey` - DataTable MUST wire pagination props (`page`, `pageSize`, `totalCount`, `onPageChange`) ## Naming Conventions diff --git a/packages/create-app/agentic/shared/ai/qa/playwright.config.ts b/packages/create-app/agentic/shared/ai/qa/playwright.config.ts new file mode 100644 index 00000000000..0a019f90022 --- /dev/null +++ b/packages/create-app/agentic/shared/ai/qa/playwright.config.ts @@ -0,0 +1,50 @@ +import { defineConfig } from '@playwright/test' +import path from 'node:path' +import { discoverIntegrationSpecFiles } from '@open-mercato/cli/lib/testing/integration-discovery' + +const captureScreenshots = process.env.PW_CAPTURE_SCREENSHOTS === '1' +const isGitHubActions = process.env.GITHUB_ACTIONS === 'true' +const projectRoot = path.resolve(__dirname, '..', '..') +const qaTestResultsRoot = path.join(projectRoot, '.ai', 'qa', 'test-results') +const normalizePath = (value: string) => value.split(path.sep).join('/') +const STATIC_TEST_IGNORES = [ + `${normalizePath(path.join(projectRoot, '.claude'))}/**`, + `${normalizePath(path.join(projectRoot, '.codex'))}/**`, + `${normalizePath(path.join(projectRoot, '.cursor'))}/**`, + `${normalizePath(path.join(projectRoot, 'node_modules'))}/**`, +] +const discoveredSpecs = discoverIntegrationSpecFiles(projectRoot, path.join(projectRoot, '.ai', 'qa', 'tests')) +const discoveredSpecPaths = discoveredSpecs.map((entry) => entry.path) + +export default defineConfig({ + testDir: projectRoot, + testMatch: discoveredSpecPaths.length > 0 ? discoveredSpecPaths : ['.ai/qa/tests/__no_tests__/*.spec.ts'], + testIgnore: [ + ...STATIC_TEST_IGNORES, + ], + timeout: 20_000, + expect: { + timeout: 20_000, + }, + retries: 1, + workers: 1, + use: { + baseURL: process.env.BASE_URL || 'http://localhost:3000', + headless: true, + screenshot: captureScreenshots ? 'on' : 'only-on-failure', + trace: 'on-first-retry', + }, + reporter: isGitHubActions + ? [ + ['github'], + ['list'], + ['json', { outputFile: path.join(qaTestResultsRoot, 'results.json') }], + ['html', { outputFolder: path.join(qaTestResultsRoot, 'html'), open: 'never' }], + ] + : [ + ['list'], + ['json', { outputFile: path.join(qaTestResultsRoot, 'results.json') }], + ['html', { outputFolder: path.join(qaTestResultsRoot, 'html'), open: 'never' }], + ], + outputDir: path.join(qaTestResultsRoot, 'artifacts'), +}) diff --git a/packages/create-app/agentic/shared/ai/skills/backend-ui-design/SKILL.md b/packages/create-app/agentic/shared/ai/skills/backend-ui-design/SKILL.md index aa1b271e372..afa27970b60 100644 --- a/packages/create-app/agentic/shared/ai/skills/backend-ui-design/SKILL.md +++ b/packages/create-app/agentic/shared/ai/skills/backend-ui-design/SKILL.md @@ -218,3 +218,11 @@ import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customF - **Detail pages**: Header + Tabs/Sections + Related data - **Create/Edit**: Full-page CrudForm or Dialog with embedded CrudForm - **Settings**: Grouped sections with inline editing + +## Page Navigation Metadata + +Every backend page needs correct `page.meta.ts` for sidebar placement. +See `.ai/skills/module-scaffold/references/navigation-patterns.md` for: +- Complete field reference (`pageGroup`, `pageOrder`, `pageContext`, `navHidden`) +- Settings page pattern (`pageContext: 'settings' as const` + `navHidden: true`) +- Common anti-patterns (missing group, mismatched keys, broken icons) diff --git a/packages/create-app/agentic/shared/ai/skills/code-review/references/review-checklist.md b/packages/create-app/agentic/shared/ai/skills/code-review/references/review-checklist.md index 1542a90b52b..7538fec6a37 100644 --- a/packages/create-app/agentic/shared/ai/skills/code-review/references/review-checklist.md +++ b/packages/create-app/agentic/shared/ai/skills/code-review/references/review-checklist.md @@ -55,6 +55,9 @@ - [ ] Empty states: `EmptyState` - [ ] `RowActions` items have stable `id` values - [ ] i18n: `useT()` client-side — no hardcoded strings +- [ ] `page.meta.ts` includes `pageGroup` + `pageGroupKey` for sidebar placement +- [ ] Settings pages have `pageContext: 'settings' as const` + `navHidden: true` +- [ ] Sidebar icon uses `lucide-react` (not inline SVG) ## 7. Naming Conventions diff --git a/packages/create-app/agentic/shared/ai/skills/implement-spec/SKILL.md b/packages/create-app/agentic/shared/ai/skills/implement-spec/SKILL.md new file mode 100644 index 00000000000..67c57e40949 --- /dev/null +++ b/packages/create-app/agentic/shared/ai/skills/implement-spec/SKILL.md @@ -0,0 +1,162 @@ +--- +name: implement-spec +description: Implement a specification (or specific phases of a spec) using coordinated subagents. Handles multi-phase spec implementation with unit tests, integration tests, documentation, and code-review compliance. Use when the user says "implement spec", "implement the spec", "implement phases", "build from spec", or "code the spec". Tracks progress by updating the spec with implementation status. +--- + +# Implement Spec Skill + +Implements a specification (or selected phases) end-to-end using a team of coordinated subagents. Every code change MUST pass the code-review checklist before the phase is considered done. + +## Pre-Flight + +1. **Identify the spec**: Locate the target spec file in `.ai/specs/`. +2. **Load context**: Read spec fully. Match affected tasks to the **Task → Context Map** in `AGENTS.md` and read all listed files (guides and skills). +3. **Load code-review checklist**: Read `.ai/skills/code-review/references/review-checklist.md` — this is the acceptance gate for every phase. +4. **Load lessons**: Read `.ai/lessons.md` for known pitfalls. +5. **Scope phases**: If the user specifies phases (e.g. "phases c-e"), filter to only those. Otherwise implement all phases sequentially. + +## Implementation Workflow + +For **each phase** in the spec, execute these steps: + +### Step 1 — Plan the Phase + +Read the phase from the spec. For each step within the phase: +- Identify files to create or modify (all paths under `src/modules/`) +- Identify which guides and skills apply (use the Task → Context Map in `AGENTS.md`) +- List required exports, conventions, and patterns from the relevant guides +- Note any cross-module impacts (events, extensions, widgets, enrichers) + +Present a brief plan to the user before coding. + +### Step 2 — Implement + +Use subagents liberally to parallelize independent work: +- **One subagent per independent file/component** when files don't depend on each other +- **Sequential execution** when there are dependencies (e.g., entity before API route before backend page) + +For every piece of code, enforce these code-review rules inline: + +| Area | Rule | +|------|------| +| Types | No `any` — use zod + `z.infer` | +| API routes | Export `openApi` and `metadata` with auth guards | +| Entities | Standard columns, snake_case, UUID PKs, `organization_id` + `tenant_id` | +| Security | `findWithDecryption`, tenant scoping, zod validation | +| UI | `CrudForm`/`DataTable`, `apiCall`, `flash()`, `LoadingMessage`/`ErrorMessage` | +| Events | `createModuleEvents()` with `as const`, subscribers export `metadata` | +| i18n | `useT()` client, `resolveTranslations()` server, no hardcoded strings | +| Imports | Package-level `@open-mercato//...` for framework imports | +| Mutations | `useGuardedMutation` when not using CrudForm | +| Keyboard | `Cmd/Ctrl+Enter` submit, `Escape` cancel on dialogs | +| Naming | Modules plural snake_case, events `module.entity.past_tense`, features `module.action` | + +### Step 3 — Unit Tests + +For every new feature/function implemented in the phase: +- Create unit tests colocated with the source (e.g., `*.test.ts` or `__tests__/`) +- Test happy path + key edge cases +- Test error paths for validation and authorization +- Mock external dependencies (DI services, data engine) +- Verify tests pass: `yarn test` + +### Step 4 — Integration Tests + +If the spec defines integration test scenarios (or the phase adds API endpoints / UI flows): +- Follow the `integration-tests` skill workflow (`.ai/skills/integration-tests/SKILL.md`) +- Place tests in `src/modules//__integration__/TC-{CATEGORY}-{XXX}.spec.ts` +- Tests MUST be self-contained: create fixtures in setup, clean up in teardown +- Tests MUST NOT rely on seeded/demo data +- Run and verify: `npx playwright test --config .ai/qa/playwright.config.ts --retries=0` + +If the spec does not explicitly list integration scenarios but the phase adds significant API or UI behavior, propose test scenarios to the user before writing them. + +### Step 5 — Documentation + +For each new feature: +- Add/update locale files for new i18n keys +- If new entities with user-facing text: create `translations.ts` +- If new convention files: run `yarn generate` +- Update relevant guides or `AGENTS.md` if the feature introduces new patterns developers should follow + +### Step 6 — Self-Review (Code-Review Gate) + +Before marking a phase complete, run a self-review against the checklist (`.ai/skills/code-review/references/review-checklist.md`): + +1. **Architecture & Module Independence** (section 1) +2. **Security** (section 2) +3. **Data Integrity & ORM** (section 3) +4. **API Routes** (section 4) — if applicable +5. **Events & Commands** (section 5) — if applicable +6. **UI & Backend Pages** (section 6) — if applicable +7. **Naming Conventions** (section 7) +8. **Anti-Patterns** (section 8) + +Fix any violations before proceeding to the next phase. + +### Step 7 — Update Spec with Progress + +After completing each phase, update the spec file: +- Add an `## Implementation Status` section at the bottom (or update it if it exists) +- Use this format: + +```markdown +## Implementation Status + +| Phase | Status | Date | Notes | +|-------|--------|------|-------| +| Phase A — Foundation | Done | 2026-02-20 | All steps implemented, tests passing | +| Phase B — Menu Injection | Done | 2026-02-21 | 3/3 steps complete | +| Phase C — Events Bridge | In Progress | 2026-02-22 | Step 1-2 done, step 3 pending | +| Phase D — Enrichers | Not Started | — | — | +``` + +- For the current phase, mark individual steps: + +```markdown +### Phase C — Detailed Progress +- [x] Step 1: Create event definitions +- [x] Step 2: Implement SSE bridge +- [ ] Step 3: Add client-side hooks +``` + +### Step 8 — Verification + +After all targeted phases are complete: + +1. **Generate check**: `yarn generate` — must complete without errors +2. **Type check**: `yarn typecheck` — must pass (if available) +3. **Build check**: `yarn build` — must pass +4. **Unit test check**: `yarn test` — must pass +5. **Integration test check**: run any new integration tests — must pass +6. **Migration check**: `yarn mercato db generate` — if any entities changed (verify generated migration is scoped correctly) + +Report results to the user. If any check fails, fix and re-verify. + +## Subagent Strategy + +| Task | Agent Type | When | +|------|-----------|------| +| Research existing patterns | Explore | Before implementing unfamiliar patterns | +| Implement independent files | general-purpose | When files have no dependencies on each other | +| Run tests | Bash | After each phase | +| Self-review | general-purpose | After each phase, against checklist | +| Integration tests | general-purpose | After phases with API/UI changes | + +**Concurrency rule**: Launch parallel subagents only for truly independent work. Sequential for dependent files. + +## Rules + +- MUST read the full spec before starting implementation +- MUST read all guides and skills listed in the Task → Context Map before coding +- MUST pass every applicable code-review checklist item before marking a phase done +- MUST update the spec with implementation progress after each phase +- MUST run `yarn build` after final phase to verify no build breaks +- MUST create unit tests for all new behavioral code +- MUST create or propose integration tests for phases with API endpoints or UI flows +- MUST NOT skip the self-review step — it is the quality gate +- MUST NOT introduce `any` types, hardcoded strings, raw `fetch`, or other anti-patterns +- MUST keep subagents focused — one task per subagent, clear boundaries +- MUST report blockers to the user immediately rather than working around them silently +- MUST run `yarn generate` after creating or modifying module convention files +- MUST run `yarn mercato db generate` after creating or modifying entities (and confirm migration with user before applying) diff --git a/packages/create-app/agentic/shared/ai/skills/integration-tests/SKILL.md b/packages/create-app/agentic/shared/ai/skills/integration-tests/SKILL.md new file mode 100644 index 00000000000..e8bafbac024 --- /dev/null +++ b/packages/create-app/agentic/shared/ai/skills/integration-tests/SKILL.md @@ -0,0 +1,279 @@ +--- +name: integration-tests +description: Run and create QA integration tests (Playwright TypeScript), including executing the full suite, converting optional markdown scenarios, and generating new tests from specs or feature descriptions. Use when the user says "run integration tests", "test this feature", "create test for", "convert test case", "run QA tests", or "integration test". +--- + +# Integration Tests Skill + +This skill generates executable Playwright tests in module-local `__integration__` directories (for example `src/modules/sales/__integration__/TC-SALES-*.spec.ts`) by exploring the running application. It also covers running existing integration tests after feature/bug implementation and reporting failures with artifact-based diagnosis. It optionally produces a markdown scenario (`.ai/qa/scenarios/TC-*.md`) for documentation — the scenario is **not required**. + +## Quick Reference + +| Action | Command | +|--------|---------| +| Run all tests | `npx playwright test --config .ai/qa/playwright.config.ts` | +| Run single test | `npx playwright test --config .ai/qa/playwright.config.ts ` | +| Debug (fail-fast) | `npx playwright test --config .ai/qa/playwright.config.ts --retries=0` | +| View report | `npx playwright show-report .ai/qa/test-results/html` | +| Test files location | `src/modules//__integration__/TC-XXX.spec.ts` | +| Scenario sources (optional) | `.ai/qa/scenarios/TC-XXX-*.md` | + +## Runtime Policy + +Default QA runtime policy: +- Keep global settings in `.ai/qa/playwright.config.ts`: + - `timeout: 20_000` + - `expect.timeout: 20_000` + - `retries: 1` +- Do not add per-test timeout or retry overrides in `.spec.ts` files (`test.setTimeout`, `test.describe.configure({ retries })`, `test.retry`). + +Debug/development policy (fail fast while authoring/fixing tests): +- Override retries at command level with `--retries=0`. +- Do not edit global config just to debug a single test. + +## Workflow + +### Phase 1 — Identify What to Test + +Determine the feature scope from one of these sources (in priority order): + +1. **Spec file**: If a spec is referenced or was just implemented, read it from `.ai/specs/*.md`. Extract testable scenarios from the API Contracts, UI/UX, and Data Models sections. +2. **User description**: If the user describes a feature ("test the company creation flow"), map it to the relevant module and pages. +3. **Recent changes**: If triggered after implementation, use `git diff` or recent commits to identify changed endpoints, pages, and components. + +For each feature, identify: +- Which **category** it belongs to (AUTH, CAT, CRM, SALES, ADMIN, INT, API-*) +- Whether it's a **UI test** or **API test** +- The **priority** (High for CRUD operations, Medium for settings/config, Low for edge cases) +- The **prerequisite role** (superadmin, admin, or employee) + +### Phase 2 — Find the Next TC Number + +List existing test cases in the target category to determine the next sequential number: + +```bash +ls .ai/qa/scenarios/TC-{CATEGORY}-*.md 2>/dev/null | sort | tail -1 +find src/modules -type f -name "TC-{CATEGORY}-*.spec.ts" 2>/dev/null | sort | tail -1 +``` + +Use the highest number found across both directories, then increment. For example, if the last scenario is TC-CRM-011 but the last test is TC-CRM-013, use TC-CRM-014. + +### Phase 3 — Verify the Dev Server Is Running + +Before writing or running tests, ensure the app is running: + +1. Check if `yarn dev` is active (the app should be listening on `http://localhost:3000` or the `BASE_URL` configured in `.env`). +2. If not running, tell the user to start it: `yarn dev`. +3. Use the base URL from `.env` or default to `http://localhost:3000`. + +### Phase 4 — Explore the Feature via Playwright MCP + +Use the active base URL for MCP navigation, then discover the actual UI: + +1. Login with the appropriate role +2. Navigate to the relevant page +3. Take snapshots to identify exact element labels, button text, form fields +4. Walk through the happy path to discover the actual flow +5. Note any validation messages, success states, redirects + +For API tests, use cURL to discover: +1. The exact endpoint path and method +2. Required request headers and body shape +3. The actual response structure +4. Error responses for invalid inputs + +### Phase 5 — Write the Playwright Test + +Create the test in the module where the behavior lives: + +``` +src/modules//__integration__/TC-{CATEGORY}-{XXX}.spec.ts +``` + +Use the locators discovered in Phase 4 (not guessed). If a scenario was written, reference it in a comment. +Do not hardcode entity IDs in routes, payloads, or assertions. Resolve entities dynamically at runtime by creating fixtures through API/UI steps or by selecting existing rows via stable UI text/role locators. + +**Helpers**: Import shared helpers from `@open-mercato/core/helpers/integration/*`: + +```typescript +import { login } from '@open-mercato/core/helpers/integration/auth' +import { getAuthToken, apiRequest } from '@open-mercato/core/helpers/integration/api' +``` + +| Helper Import | Main Exports | Typical Use | +|------|-------|--------| +| `@open-mercato/core/helpers/integration/auth` | `login`, `DEFAULT_CREDENTIALS` | UI authentication and role-based login | +| `@open-mercato/core/helpers/integration/api` | `getAuthToken`, `apiRequest` | Authenticated API calls in integration tests | +| `@open-mercato/core/helpers/integration/crmFixtures` | `createCompanyFixture`, `createPersonFixture`, `deleteEntityIfExists` | CRM fixture lifecycle | +| `@open-mercato/core/helpers/integration/catalogFixtures` | `createProductFixture`, `deleteCatalogProductIfExists` | Catalog fixture lifecycle | +| `@open-mercato/core/helpers/integration/salesFixtures` | `createSalesQuoteFixture`, `createSalesOrderFixture` | Sales fixture lifecycle | +| `@open-mercato/core/helpers/integration/authFixtures` | `createRoleFixture`, `createUserFixture` | Role and user fixture lifecycle | +| `@open-mercato/core/helpers/integration/generalFixtures` | `readJsonSafe`, `expectId` | General-purpose test utilities | + +**Metadata for conditional test enablement**: + +- Folder-level metadata (`__integration__/meta.ts`): + +```ts +export const integrationMeta = { + description: 'Sales flows requiring currencies', + dependsOnModules: ['sales', 'currencies'], +} +``` + +- Per-test metadata (sibling `.meta.ts` file): + +```ts +export const integrationMeta = { + dependsOnModules: ['catalog'], +} +``` + +If any required module is not enabled in the app, matching tests are skipped automatically. + +### Phase 6 — Optionally Write the Markdown Scenario + +If documentation is desired, create `.ai/qa/scenarios/TC-{CATEGORY}-{XXX}-{slug}.md` using this template: + +```markdown +# Test Scenario [NUMBER]: [TITLE] + +## Test ID +TC-{CATEGORY}-{XXX} + +## Category +{Category Name} + +## Priority +{High/Medium/Low} + +## Type +{UI Test / API Test} + +## Description +{What this test validates — derived from spec or feature description} + +## Prerequisites +- User is logged in as {role} +- {Other prerequisites from spec} + +## Test Steps +| Step | Action | Expected Result | +|------|--------|-----------------| +| 1 | {Discovered action} | {Observed result} | +| 2 | {Discovered action} | {Observed result} | + +## Expected Results +- {Derived from spec's API Contracts or UI/UX section} + +## Edge Cases / Error Scenarios +- {Derived from spec's Risks section or discovered during exploration} +``` + +Fill steps with **actual** actions and results observed during Phase 4, not hypothetical ones. + +This step is **optional** — skip it if the user only wants the executable test. + +### Phase 7 — Verify + +Run the new test to confirm it passes: + +```bash +npx playwright test --config .ai/qa/playwright.config.ts +``` + +When developing/debugging the test, run fail-fast with no retries: + +```bash +npx playwright test --config .ai/qa/playwright.config.ts --retries=0 +``` + +If it fails, fix it. Do not leave broken tests. + +### Failure Analysis and User Reporting (Mandatory on Failures) + +After any failed test run (single test or suite), analyze failure artifacts before responding: + +1. Parse terminal output to capture the failing test names and first error stack/assertion. +2. Inspect Playwright artifacts for each failed test from `test-results/`: + - `error-context.md` + - Screenshots (expected/actual/diff where available) + - Trace/video attachments if present +3. Classify each failure into one primary reason: + - Product regression / real app bug + - Test issue (stale locator, brittle assertion, bad fixture/cleanup) + - Environment / data issue (service unavailable, auth/session drift) +4. Decide ownership per failing test: + - `User/Product team` when behavior looks like a real regression + - `Agent/QA` when failure is test-code quality, selector drift, or fixture instability + - `Shared` when both product behavior and test assumptions need adjustment +5. Respond with a table (required format) before any optional narrative: + +| Failing test | Evidence used | Reasoning (why it failed) | Suggested owner | Next action | +|--------------|---------------|---------------------------|-----------------|-------------| +| `::` | `stdout + screenshot` | `Concise diagnosis` | `User/Product` / `Agent/QA` / `Shared` | `Fix recommendation` | + +Do not provide a generic "tests failed" summary without per-test reasoning. + +### Running-Only Mode (No New Test Authoring) + +If the user asks only to run integration tests (full suite/category/single file), skip authoring phases and execute the requested run directly. +If the run fails, apply the failure-analysis section above. + +## Deriving Scenarios from a Spec + +When reading a spec, extract test scenarios from these sections: + +| Spec Section | Generates | +|-------------|-----------| +| API Contracts — each endpoint | One API test per endpoint (CRUD) | +| UI/UX — each user flow | One UI test per flow | +| Edge Cases / Error Scenarios | One test per significant error path | +| Risks & Impact Review | Regression tests for documented failure modes | + +Typical spec produces 3-8 test cases. Prioritize: +1. **High**: CRUD happy paths, authentication, authorization +2. **Medium**: Validation errors, edge cases with business impact +3. **Low**: Cosmetic, minor UX edge cases + +## Example + +Given a spec for an Inventory Management module, the skill would produce: + +- `src/modules/inventory/__integration__/TC-INV-001.spec.ts` — UI: create and list inventory items +- `src/modules/inventory/__integration__/TC-INV-002.spec.ts` — API: CRUD operations on inventory items +- `src/modules/inventory/__integration__/TC-INV-003.spec.ts` — UI: validation errors on create form +- Optionally: matching `.ai/qa/scenarios/TC-INV-001-*.md` files for documentation + +## Default Credentials + +Created via `yarn initialize`: + +| Role | Email | Password | +|------|-------|----------| +| Superadmin | `superadmin@acme.com` | `secret` | +| Admin | `admin@acme.com` | `secret` | +| Employee | `employee@acme.com` | `secret` | + +Overridable via env: `OM_INIT_SUPERADMIN_EMAIL`, `OM_INIT_SUPERADMIN_PASSWORD` + +## Rules + +- MUST explore the running app before writing — never guess selectors or flows +- MUST verify the dev server is running before executing tests +- MUST NOT hardcode record IDs (UUIDs/PKs) in generated tests +- MUST discover or create test entities at runtime, then navigate using discovered links/URLs +- MUST NOT rely on seeded/demo data for prerequisites +- MUST create required fixtures per test (prefer API fixture setup for stability) +- MUST clean up any data created by the test in `finally`/teardown +- MUST keep tests deterministic and isolated from run order or retries +- MUST NOT add per-test timeout/retry overrides in `.spec.ts`; rely on global Playwright config (`timeout: 20s`, `expect.timeout: 20s`, `retries: 1`) +- MUST create the `.spec.ts` — the markdown scenario is optional +- MUST use actual locators from Playwright MCP snapshots (`getByRole`, `getByLabel`, `getByText`) +- MUST verify the test passes before finishing +- MUST analyze failed test artifacts (`stdout`, `error-context.md`, screenshots/report) before reporting failures +- MUST report failures in a per-test table that includes reason, evidence, and suggested owner +- MUST place new tests in module-local `__integration__` directories under `src/modules/` +- MUST use `meta.ts` dependency metadata for module-gated folders and per-test `.meta.ts` for individual gating +- When deriving from a spec, focus on the happy path first, then add edge cases as separate test cases +- Each test file covers one scenario — create multiple files for multiple scenarios diff --git a/packages/create-app/agentic/shared/ai/skills/module-scaffold/SKILL.md b/packages/create-app/agentic/shared/ai/skills/module-scaffold/SKILL.md index d0200c40c0c..7066dde4052 100644 --- a/packages/create-app/agentic/shared/ai/skills/module-scaffold/SKILL.md +++ b/packages/create-app/agentic/shared/ai/skills/module-scaffold/SKILL.md @@ -279,20 +279,27 @@ export const openApi = { Use `CrudForm` and `DataTable` from `@open-mercato/ui`. See the `backend-ui-design` skill for full component reference. -### Page Metadata & Sidebar Icons +### Page Metadata & Sidebar Navigation **File**: `src/modules//backend/page.meta.ts` -Icons for the admin sidebar MUST use components from `lucide-react`. Never use inline `React.createElement('svg', ...)` — it is fragile in bundler contexts and can produce broken/wrong icons after `yarn generate`. +Icons MUST use components from `lucide-react`. Never use inline `React.createElement('svg', ...)` — it breaks after `yarn generate`. + +For full field reference, settings pages, and anti-patterns, see [references/navigation-patterns.md](references/navigation-patterns.md). ```tsx import { Trophy } from 'lucide-react' export const metadata = { - title: '', - icon: , requireAuth: true, - features: ['.view'], + requireFeatures: ['.view'], + pageTitle: '', + pageTitleKey: '.nav.title', + pageGroup: '', // Sidebar section name + pageGroupKey: '.nav.group', // i18n key — items with same key grouped together + pageOrder: 100, // Sort within group (lower = higher) + icon: , + breadcrumb: [{ label: '', labelKey: '.nav.title' }], } ``` @@ -323,9 +330,13 @@ export default function ListPage() { } export const metadata = { - title: '', requireAuth: true, - features: ['.view'], + requireFeatures: ['.view'], + pageTitle: '', + pageTitleKey: '.nav.title', + pageGroup: '', + pageGroupKey: '.nav.group', + pageOrder: 100, } ``` @@ -357,9 +368,13 @@ export default function CreatePage() { } export const metadata = { - title: 'Create ', requireAuth: true, - features: ['.create'], + requireFeatures: ['.create'], + pageTitle: 'Create ', + pageTitleKey: '.create.title', + pageGroup: '', + pageGroupKey: '.nav.group', + navHidden: true, } ``` @@ -392,9 +407,12 @@ export default function EditPage({ params }: { params: { id: string } }) } export const metadata = { - title: 'Edit ', requireAuth: true, - features: ['.update'], + requireFeatures: ['.update'], + pageTitle: 'Edit ', + pageTitleKey: '.edit.title', + pageGroup: '', + pageGroupKey: '.nav.group', } ``` @@ -606,6 +624,10 @@ yarn dev # Start dev server - [ ] All API routes export `openApi` - [ ] Backend pages use `CrudForm` and `DataTable` - [ ] Sidebar icon uses `lucide-react` component (not inline SVG / `React.createElement`) +- [ ] `page.meta.ts` includes `pageGroup` + `pageGroupKey` for sidebar grouping +- [ ] `page.meta.ts` includes `pageOrder` for sort position +- [ ] All related pages share the same `pageGroupKey` +- [ ] Settings pages (if any) have `pageContext: 'settings' as const` and `navHidden: true` - [ ] ACL features declared and wired in `setup.ts` - [ ] Module registered in `src/modules.ts` with `from: '@app'` - [ ] `yarn generate` run after creating files @@ -624,6 +646,8 @@ yarn dev # Start dev server - **MUST** validate all inputs with zod schemas in `data/validators.ts` - **MUST** export `openApi` from every API route - **MUST** use `CrudForm` for forms and `DataTable` for tables +- **MUST** include `pageGroup` and `pageGroupKey` on list/root backend pages for sidebar grouping +- **MUST** use `as const` on `pageContext` values (e.g., `pageContext: 'settings' as const`) - **MUST** declare ACL features and wire them in `setup.ts` `defaultRoleFeatures` - **MUST** register module in `src/modules.ts` with `from: '@app'` - **MUST** run `yarn generate` after creating module files diff --git a/packages/create-app/agentic/shared/ai/skills/module-scaffold/references/navigation-patterns.md b/packages/create-app/agentic/shared/ai/skills/module-scaffold/references/navigation-patterns.md new file mode 100644 index 00000000000..d2d57c92a11 --- /dev/null +++ b/packages/create-app/agentic/shared/ai/skills/module-scaffold/references/navigation-patterns.md @@ -0,0 +1,97 @@ +# Navigation & Sidebar Patterns + +## page.meta.ts — Field Reference + +Every backend page needs a `page.meta.ts` file alongside its `page.tsx`. The metadata controls sidebar placement, access control, and display. + +| Field | Type | Default | Purpose | +|-------|------|---------|---------| +| `pageTitle` | string | — | Display title in sidebar and breadcrumb | +| `pageTitleKey` | string | — | i18n key for title (preferred over `pageTitle`) | +| `pageGroup` | string | — | **Sidebar section name** — items with same group appear together | +| `pageGroupKey` | string | — | **i18n key for group** — used as the group identifier for matching | +| `pageOrder` | number | 10000 | Sort position within group (lower = higher in sidebar) | +| `icon` | ReactNode | — | Sidebar icon — MUST use `lucide-react` components | +| `requireAuth` | boolean | false | Require authenticated user | +| `requireFeatures` | string[] | — | Required ACL feature IDs (from `acl.ts`) | +| `navHidden` | boolean | false | Hide from sidebar (page still accessible by URL) | +| `pageContext` | `'main'` \| `'settings'` \| `'profile'` | `'main'` | Which navigation tier this page belongs to | +| `breadcrumb` | `{ label, labelKey?, href? }[]` | — | Breadcrumb trail above page title | + +## Sidebar Group Configuration + +Items are grouped by `pageGroupKey` (falls back to `pageGroup` if no key). **All related pages in a module MUST share the same `pageGroupKey`** to appear in the same sidebar section. + +```typescript +// page.meta.ts — List page (appears in sidebar) +import { ShoppingCart } from 'lucide-react' + +export const metadata = { + requireAuth: true, + requireFeatures: ['order_items.view'], + pageTitle: 'Order Items', + pageTitleKey: 'order_items.nav.title', + pageGroup: 'Orders', // Display name for the sidebar section + pageGroupKey: 'order_items.nav.group', // Matching key — all module pages use this + pageOrder: 100, // Position in the group + icon: , + breadcrumb: [{ label: 'Order Items', labelKey: 'order_items.nav.title' }], +} +``` + +**Group sorting**: Core module groups (Customers, Catalog, Sales) appear first in a hardcoded order. Custom module groups appear after, sorted by their lowest `pageOrder` value. + +## Settings Pages + +Settings pages appear in the Settings hub, not the main sidebar. They require two fields: + +```typescript +// backend/config/page.meta.ts +import { Settings } from 'lucide-react' + +export const metadata = { + requireAuth: true, + requireFeatures: ['order_items.settings.manage'], + pageTitle: 'Order Items Settings', + pageTitleKey: 'order_items.config.title', + pageGroup: 'Module Configs', + pageGroupKey: 'settings.sections.moduleConfigs', + pageOrder: 10, + icon: , + pageContext: 'settings' as const, // ← Places in Settings hub + navHidden: true, // ← MUST set — prevents duplicate in main sidebar +} +``` + +**Standard settings section keys**: `settings.sections.system`, `settings.sections.auth`, `settings.sections.moduleConfigs`, `settings.sections.directory`. + +## Sub-Pages (Create / Edit / Detail) + +- **`[id]` pages**: Auto-excluded from sidebar (framework skips dynamic segments). Still need `pageGroupKey` for breadcrumb context. +- **Create pages** (`new.tsx`): Set `navHidden: true` since they're accessed via the list page's create button. + +```typescript +// backend//new.meta.ts +export const metadata = { + requireAuth: true, + requireFeatures: ['order_items.create'], + pageTitle: 'Create Order Item', + pageTitleKey: 'order_items.create.title', + pageGroup: 'Orders', + pageGroupKey: 'order_items.nav.group', // ← Same key as list page + navHidden: true, // ← Not shown in sidebar +} +``` + +## Anti-Patterns + +| Mistake | Symptom | Fix | +|---------|---------|-----| +| Missing `pageGroup` + `pageGroupKey` | Item creates orphan group or lands in "Uncategorized" | Add both fields matching your module's group | +| Mismatched `pageGroupKey` across pages | Items from same module split into separate sidebar sections | Use identical `pageGroupKey` on all module pages | +| Missing `icon` | Blank space in sidebar next to title | Add `lucide-react` icon component | +| Inline SVG via `React.createElement` | Broken/wrong icon after `yarn generate` | Use `import { X } from 'lucide-react'` | +| `pageContext: 'settings'` without `navHidden: true` | Page appears in both main sidebar AND settings hub | Always pair both fields | +| Missing `as const` on `pageContext` | TypeScript error — type widened to `string` | Use `pageContext: 'settings' as const` | +| Missing `pageOrder` | Unpredictable sort position (defaults to 10000) | Set explicit order value | +| Missing `requireFeatures` | Page visible to users without permission | Add feature IDs from `acl.ts` | diff --git a/packages/create-app/build.mjs b/packages/create-app/build.mjs index 75bfeb6ab92..ec79b34372c 100644 --- a/packages/create-app/build.mjs +++ b/packages/create-app/build.mjs @@ -34,11 +34,23 @@ mkdirSync(guidesDestDir, { recursive: true }) let guidesFound = 0 for (const pkg of readdirSync(packagesDir)) { + // Package-level guide: packages//agentic/standalone-guide.md → .md const guideSource = join(packagesDir, pkg, 'agentic', 'standalone-guide.md') if (existsSync(guideSource)) { cpSync(guideSource, join(guidesDestDir, `${pkg}.md`)) guidesFound++ } + + // Module-level guides: packages//src/modules//agentic/standalone-guide.md → ..md + const modulesDir = join(packagesDir, pkg, 'src', 'modules') + if (!existsSync(modulesDir)) continue + for (const mod of readdirSync(modulesDir)) { + const moduleGuideSource = join(modulesDir, mod, 'agentic', 'standalone-guide.md') + if (existsSync(moduleGuideSource)) { + cpSync(moduleGuideSource, join(guidesDestDir, `${pkg}.${mod}.md`)) + guidesFound++ + } + } } if (guidesFound > 0) { console.log(`Discovered ${guidesFound} standalone guides → dist/agentic/guides/`) diff --git a/packages/create-app/src/setup/tools/shared.ts b/packages/create-app/src/setup/tools/shared.ts index 216583665f0..980d2eb0e4e 100644 --- a/packages/create-app/src/setup/tools/shared.ts +++ b/packages/create-app/src/setup/tools/shared.ts @@ -105,6 +105,10 @@ export function generateShared(config: AgenticConfig): void { 'ai/skills/module-scaffold/references/naming-conventions.md', join(targetDir, '.ai', 'skills', 'module-scaffold', 'references', 'naming-conventions.md'), ) + copyFile( + 'ai/skills/module-scaffold/references/navigation-patterns.md', + join(targetDir, '.ai', 'skills', 'module-scaffold', 'references', 'navigation-patterns.md'), + ) // troubleshooter skill copyFile( @@ -132,6 +136,21 @@ export function generateShared(config: AgenticConfig): void { join(targetDir, '.ai', 'skills', 'data-model-design', 'references', 'mikro-orm-cheatsheet.md'), ) + // implement-spec skill + copyFile( + 'ai/skills/implement-spec/SKILL.md', + join(targetDir, '.ai', 'skills', 'implement-spec', 'SKILL.md'), + ) + + // integration-tests skill + copyFile( + 'ai/skills/integration-tests/SKILL.md', + join(targetDir, '.ai', 'skills', 'integration-tests', 'SKILL.md'), + ) + + // .ai/qa/ — Playwright config for integration tests + copyFile('ai/qa/playwright.config.ts', join(targetDir, '.ai', 'qa', 'playwright.config.ts')) + // Package guides — auto-discovered from sibling packages during build if (existsSync(GUIDES_DIR)) { const guidesDestDir = join(targetDir, '.ai', 'guides') From 54d40d164ac407a27e39aa18649be5a9da53f0e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jarek=20Ka=C5=82asz?= Date: Mon, 6 Apr 2026 08:26:54 +0200 Subject: [PATCH 007/215] docs(spec): add customers lead funnel specification (#1149) --- .ai/specs/2026-04-03-customers-lead-funnel.md | 887 ++++++++++++++++++ 1 file changed, 887 insertions(+) create mode 100644 .ai/specs/2026-04-03-customers-lead-funnel.md diff --git a/.ai/specs/2026-04-03-customers-lead-funnel.md b/.ai/specs/2026-04-03-customers-lead-funnel.md new file mode 100644 index 00000000000..59ff3a1a9b1 --- /dev/null +++ b/.ai/specs/2026-04-03-customers-lead-funnel.md @@ -0,0 +1,887 @@ +# Customers Lead Funnel + +## TLDR +**Key Points:** +- Add a first-class `lead` capability inside the existing `customers` module as a separate CRM area with its own list, detail page, pipelines, qualification workflow, conversion flow, analytics, and automation support. +- `Lead` is a dedicated staging object, not just `customer_entity.lifecycleStage = lead`. It exists to stop spam, bots, and low-quality inbound traffic from polluting canonical CRM records too early. +- A lead has its own ID, source payload, history, duplicate-check state, and explicit lineage to `person`, `company`, and `deal` records created or linked during qualification and conversion. +- Leads support multiple configurable pipelines from v1. +- Some lead fields are lead-only, while some are shared views of fields owned by downstream objects (`company`, `person`, `deal`). Shared fields must prefill created records and stay synchronized after linking/conversion. + +**Scope:** +- New lead domain model, API, ACL, events, search, and setup defaults in `customers` +- Dedicated backend lead list, lead detail, and pipeline board +- Configurable multi-pipeline lead model with stages and lost reasons +- Tenant-level enable/disable setting for the lead capability +- Conversion flow to create or link `person`, `company`, `deal`, or combinations of them +- Duplicate detection against existing `people` / `companies` by email, phone, VAT ID +- Raw inbound payload retention for audit, analytics, and future remapping +- Manual create/link of person/company before final lead closure +- Dashboard, reporting, search, and automation support + +**Concerns:** +- Shared fields must not create a second competing source of truth after link/conversion. +- Conversion and manual linking must preserve lineage without breaking existing `customers` / `deals` contracts. +- Pipeline and stage design must be future-proof without overcomplicating the MVP shape. + +## Overview +This specification introduces a dedicated lead funnel inside the `customers` CRM domain. Leads represent inbound commercial signals that are not yet canonical CRM records. They may come from forms, API ingestion, external CRM sync, campaign captures, and similar sources where data quality is uncertain and where spam or duplicates are common. + +Instead of creating `person`, `company`, or `deal` objects immediately, the system stores the signal as a `lead`, routes it through a configurable qualification pipeline, and only then allows operators to create or link downstream CRM objects. + +The lead area lives inside `customers`, similar in product weight to `deals`, but with a different purpose: +- `leads`: intake, triage, qualification, anti-spam buffer, provenance, source analytics +- `people` / `companies`: canonical CRM records +- `deals`: commercial opportunities + +The platform must support two tenant-selectable CRM operating models: +- **Direct CRM model**: `deal -> person/persons -> company` +- **Lead-first CRM model**: `lead -> deal -> person/persons -> company` + +Leads are therefore an optional capability, not a mandatory CRM layer for every tenant. + +> **Market Reference**: The closest reference model is the dedicated lead object found in Salesforce, HubSpot, and OroCRM. Open Mercato should adopt the dedicated pre-CRM object and explicit conversion lineage, while keeping implementation aligned with existing `customers` CRUD, command, event, dictionary, and UI patterns. + +## Problem Statement +Current CRM primitives are optimized for canonical records, not noisy inbound intake. + +This creates several problems: +- Spam, bots, and low-quality submissions can pollute `people` and `companies`. +- Teams lack a structured qualification space before creating real CRM objects. +- Conversion attribution is weak when a downstream record originated from inbound acquisition. +- Reporting on lead quality, source effectiveness, lost reasons, and funnel conversion is difficult. +- Existing `status`, `lifecycleStage`, and `source` fields on customer entities do not model a full lead intake and conversion workflow. +- Some business fields conceptually belong to downstream objects, but users still need to see and edit them during the lead process. + +## Proposed Solution +Introduce a dedicated `customer_lead` domain inside `customers` with its own storage, APIs, UI, search coverage, events, and conversion contract. + +### Core Rules +1. A lead is a first-class CRM record with its own ID and history. +2. A lead may contain person-side data, company-side data, deal-side data, and lead-only data in one workspace. +3. Leads are created primarily from external sources, but manual creation is supported. +4. Leads support multiple configurable pipelines from v1. +5. The lead capability can be enabled or disabled per tenant from settings. +5. Duplicate detection checks existing CRM data by email, phone, and VAT ID, warns the operator, and links to possible matches without blocking creation. +6. Operators may manually create and link `person` / `company` objects during qualification before final lead closure. +7. A successful lead conversion allows choosing whether to create new records or link to existing ones. +8. Losing a lead requires a configurable lost reason. +9. Raw source payload and ingest metadata are retained. +10. The lead remains in the system after conversion as the source record for analytics, attribution, and audit. +11. Some fields are lead-only. Some are shared with `person`, `company`, or `deal` and must behave as surfaced views of canonical downstream fields after a link exists. + +### Design Decisions +| Decision | Rationale | +|----------|-----------| +| Separate `lead` entity instead of `customer_entity.lifecycleStage = lead` | Prevents premature CRM pollution and preserves intake lineage | +| Lead stays after conversion | Needed for analytics, attribution, and audit | +| Multi-pipeline support in v1 | Explicit user requirement and avoids immediate redesign | +| Lead capability is tenant-optional | Some customers want a direct deal-driven CRM without lead qualification | +| Duplicate detection is advisory, not blocking | Sales intake is messy; false positives must not block work | +| Retain raw source payload | Supports debugging, provenance, remapping, and analytics | +| Allow manual create/link before closure | Matches real qualification workflows | +| Shared fields are projections of downstream-owned fields after link | Prevents dual truth while keeping the lead UI practical | + +### Alternatives Considered +| Alternative | Why Rejected | +|-------------|-------------| +| Use only `customer_entities` with `lifecycleStage = lead` | Pollutes canonical CRM and weakens conversion lineage | +| Auto-create `person` / `company` first, then qualify | Defeats the anti-spam staging purpose | +| Block duplicates hard | Too rigid for real sales operations | +| Copy shared fields once at conversion and never sync again | Violates user requirement and creates drift | + +## User Stories / Use Cases +- **Sales ops** wants inbound traffic to land in a lead queue so spam does not pollute CRM. +- **Sales rep** wants to review, assign, qualify, enrich, and convert a lead into the right CRM objects. +- **Sales rep** wants to create or link a person/company during qualification without closing the lead. +- **Manager** wants to measure pipeline throughput, source quality, conversion rate, and lost reasons. +- **Admin** wants to configure pipelines, stages, lost reasons, custom fields, and shared-field exposure rules without code changes. +- **Integrator** wants to send leads into OM through APIs/forms and keep full source provenance. + +## Architecture +Lead capability is implemented inside `packages/core/src/modules/customers/` and follows existing `customers` patterns: +- undoable commands for mutations +- `makeCrudRoute` + `openApi` for CRUD/list routes +- dictionary/config patterns for configurable values +- `DataTable` and `CrudForm` for backend UI +- events/subscribers for side effects +- additive schema only + +### Domain Layers +1. **Lead Core** + - `customer_leads` + - lead validators + - lead commands + - lead CRUD/list/detail APIs + - lead search/index coverage +2. **Lead Configuration** + - lead pipelines + - pipeline stages + - lost reasons + - shared-field exposure rules + - tenant-level enable/disable setting +3. **Lead Linkage & Conversion** + - link/create person + - link/create company + - convert to configured target set + - persistent lineage +4. **Lead Analytics & Automation** + - dashboard widgets + - reporting dimensions + - events for workflows/subscribers + +### Canonical Ownership Model +The spec distinguishes three field categories: + +1. **Lead-only fields** + - exist only on the lead + - never synchronize to downstream objects + - examples: raw source metadata, qualification notes, spam score, campaign capture payload + +2. **Prefill-only fields** + - entered on the lead + - copied into a new downstream object on create + - after conversion/link, they are no longer synchronized + +3. **Shared surfaced fields** + - conceptually belong to downstream objects such as `company`, `person`, or `deal` + - displayed on the lead form inside dedicated sections + - once the downstream object is linked/created, the lead field becomes a projection/proxy to the canonical field + - changing it on the lead updates the canonical object + +Default rule: +- after a link exists, the canonical downstream object is the storage owner +- the lead page may still edit the field, but that mutation writes through to the owner object + +Section storage rule: +- `person_data`, `company_data`, and `deal_data` are persistent lead-side section stores, not temporary conversion buffers +- these sections always exist as the lead workspace, before and after link/conversion +- field binding rules define per field whether the value is: + - stored only on the lead + - copied once into a target on create + - rendered on the lead as a live proxy to a canonical target field + +Custom field rule: +- field binding behavior applies to both standard fields and custom fields +- when an admin exposes a custom field from `company`, `person`, or `deal` on the lead card, the admin must choose binding mode explicitly +- example: + - `company.annualRevenue` exposed on lead as `shared` means the lead shows the company-owned field and editing it on the lead updates the linked company + - `company.comments` exposed on lead as `lead_only` means the lead may show a similarly named field in the company section, but it remains local to the lead and does not affect the company record + +Conversion multiplicity rule: +- lead conversion is one-time only +- after a lead reaches converted/won state, the system must not allow a second conversion workflow creating a new target plan +- post-conversion edits may still update lead data and shared bound fields, subject to permissions, but they do not reopen conversion + +### Commands & Events +**Commands** +- `customers.lead.create` +- `customers.lead.update` +- `customers.lead.assign` +- `customers.lead.advance_stage` +- `customers.lead.mark_lost` +- `customers.lead.link_person` +- `customers.lead.link_company` +- `customers.lead.link_deal` +- `customers.lead.create_person` +- `customers.lead.create_company` +- `customers.lead.create_deal` +- `customers.lead.convert` +- `customers.lead.delete` + +**Events** +- `customers.lead.created` +- `customers.lead.updated` +- `customers.lead.assigned` +- `customers.lead.stage_changed` +- `customers.lead.lost` +- `customers.lead.person_linked` +- `customers.lead.company_linked` +- `customers.lead.deal_linked` +- `customers.lead.person_created` +- `customers.lead.company_created` +- `customers.lead.deal_created` +- `customers.lead.converted` + +### Transaction & Undo Contract +- Lead-local mutations are undoable through standard command history. +- Link/create actions are explicit commands with before/after snapshots. +- Conversion is a compound command: + - validate lead state + - resolve duplicate and target selections + - create/link downstream records + - persist lineage + - transition lead outcome +- Undo for conversion is limited by downstream side effects: + - unlinking/reverting lead metadata is required + - hard-delete of created downstream records is allowed only if they have no independent modifications after creation + - when safe full rollback is impossible, the system must record partial compensation and surface it in audit history + +## Data Models +### CustomerLead (Singular) +Table: `customer_leads` + +- `id`: UUID PK +- `organization_id`: UUID required +- `tenant_id`: UUID required +- `pipeline_id`: UUID required +- `stage_id`: UUID required +- `outcome`: text nullable (`open`, `won`, `lost`) +- `lost_reason_id`: UUID nullable +- `display_name`: text required +- `owner_user_id`: UUID nullable +- `source`: text nullable +- `source_channel`: text nullable +- `source_external_id`: text nullable +- `source_payload_raw`: jsonb nullable +- `source_received_at`: timestamptz nullable +- `primary_email`: text nullable +- `primary_phone`: text nullable +- `vat_id`: text nullable +- `spam_score`: numeric nullable +- `qualification_notes`: text nullable +- `person_data`: jsonb nullable +- `company_data`: jsonb nullable +- `deal_data`: jsonb nullable +- `created_person_id`: UUID nullable +- `created_company_id`: UUID nullable +- `created_deal_id`: UUID nullable +- `linked_person_id`: UUID nullable +- `linked_company_id`: UUID nullable +- `linked_deal_id`: UUID nullable +- `converted_at`: timestamptz nullable +- `converted_by_user_id`: UUID nullable +- `conversion_locked_at`: timestamptz nullable +- `created_at`: timestamptz required +- `updated_at`: timestamptz required +- `deleted_at`: timestamptz nullable + +Indexes: +- `(organization_id, tenant_id, pipeline_id, stage_id, created_at)` +- `(organization_id, tenant_id, outcome, created_at)` +- `(organization_id, tenant_id, primary_email)` +- `(organization_id, tenant_id, primary_phone)` +- `(organization_id, tenant_id, vat_id)` +- `(organization_id, tenant_id, source, source_channel)` + +### CustomerLeadPipeline +Table: `customer_lead_pipelines` + +- `id`: UUID PK +- `organization_id`: UUID required +- `tenant_id`: UUID required +- `name`: text required +- `code`: text required stable internal identifier +- `is_default`: boolean required +- `is_active`: boolean required +- `created_at`: timestamptz required +- `updated_at`: timestamptz required + +### Lead Capability Setting +Lead usage must be tenant-configurable through module/customer settings. + +Required behavior: +- tenant can enable or disable leads without disabling the whole `customers` module +- when disabled: + - lead navigation is hidden + - lead create/list/detail/pipeline pages are inaccessible + - lead APIs reject standard UI usage unless used for migration/admin purposes explicitly allowed by policy + - direct CRM flow remains available: `deal -> person/persons -> company` +- when enabled: + - lead-first CRM flow is available: `lead -> deal -> person/persons -> company` + +Config storage options to finalize in implementation: +- customer module setting row in `configs` / module setup area +- or dedicated customer lead settings entity if more lead-specific switches are expected + +### CustomerLeadPipelineStage +Table: `customer_lead_pipeline_stages` + +- `id`: UUID PK +- `organization_id`: UUID required +- `tenant_id`: UUID required +- `pipeline_id`: UUID required +- `name`: text required +- `code`: text required +- `position`: int required +- `kind`: text required (`open`, `won`, `lost`) +- `is_active`: boolean required +- `created_at`: timestamptz required +- `updated_at`: timestamptz required + +Rules: +- multiple `open` stages allowed +- exactly one or more terminal `won` / `lost` stages allowed per pipeline +- `won` stages trigger conversion flow, not immediate silent conversion + +### CustomerLeadLostReason +Table: `customer_lead_lost_reasons` + +- `id`: UUID PK +- `organization_id`: UUID required +- `tenant_id`: UUID required +- `pipeline_id`: UUID nullable +- `name`: text required +- `code`: text required +- `is_active`: boolean required +- `sort_order`: int required +- `created_at`: timestamptz required +- `updated_at`: timestamptz required + +Rules: +- reasons may be global or pipeline-scoped +- add/remove/reorder must be admin-configurable + +### CustomerLeadFieldBinding +Table: `customer_lead_field_bindings` + +Purpose: +- declares which lead-visible fields are lead-only, prefill-only, or shared surfaced fields +- defines the target object owner and target path + +Fields: +- `id`: UUID PK +- `organization_id`: UUID required +- `tenant_id`: UUID required +- `pipeline_id`: UUID nullable +- `lead_field_key`: text required +- `binding_mode`: text required (`lead_only`, `prefill_only`, `shared`) +- `target_entity_kind`: text nullable (`person`, `company`, `deal`) +- `target_field_key`: text nullable +- `section_kind`: text required (`lead`, `person`, `company`, `deal`) +- `is_active`: boolean required +- `created_at`: timestamptz required +- `updated_at`: timestamptz required + +Notes: +- for shared bindings, the canonical target field remains source-of-truth after link/create +- UI uses binding metadata to render field origin badges/icons +- bindings apply to custom fields as well as standard fields +- bindings are resolved per field, not per section, so two fields in the same visual section may use different modes +- binding validation must prevent ambiguous ownership for a single lead field + +### CustomerLeadHistory +Table: `customer_lead_history` + +Purpose: +- timeline of stage transitions, assignment, duplicate warnings, links, conversion actions, and ingestion events + +Fields: +- `id`: UUID PK +- `organization_id`: UUID required +- `tenant_id`: UUID required +- `lead_id`: UUID required +- `entry_type`: text required (`created`, `updated`, `stage_changed`, `duplicate_detected`, `person_linked`, `company_linked`, `deal_linked`, `person_created`, `company_created`, `deal_created`, `lost`, `converted`, `source_ingested`, `note`) +- `actor_user_id`: UUID nullable +- `message`: text nullable +- `payload`: jsonb nullable +- `created_at`: timestamptz required + +Rules: +- history is append-only +- history is user-visible on the lead detail page +- conversion, link, create, and duplicate-detection actions must write explicit history entries + +## API Contracts +All routes MUST export `openApi`. + +### Lead CRUD +#### `GET /api/customers/leads` +- Query: + - `page`, `pageSize<=100` + - `search` + - `pipelineId` + - `stageId` + - `outcome` + - `ownerUserId` + - `source` + - `hasDuplicates` + - `createdFrom`, `createdTo` +- Response: + - paged list of lead rows + - duplicate summary + - linked/created object summary + +#### `POST /api/customers/leads` +- Body: + - lead core fields + - section payloads + - source metadata + - optional initial pipeline/stage +- Response: + - `{ id }` + +#### `PUT /api/customers/leads` +- Body: + - `id` + - mutable lead fields + - surfaced shared-field edits +- Response: + - `{ ok: true }` + +#### `DELETE /api/customers/leads?id=` +- Soft delete lead record + +### Qualification Actions +#### `POST /api/customers/leads/assign` +- Body: `id`, `ownerUserId` + +#### `POST /api/customers/leads/advance-stage` +- Body: `id`, `stageId` +- Validation: + - target stage must belong to lead pipeline + - transition to terminal won stage may require conversion readiness checks + - transition to lost stage requires `lostReasonId` + - once a lead is converted, stage changes must not trigger a second conversion flow + +#### `POST /api/customers/leads/mark-lost` +- Body: `id`, `lostReasonId`, optional `note` + +### Duplicate Detection +#### `POST /api/customers/leads/duplicate-check` +- Body: + - `primaryEmail` + - `primaryPhone` + - `vatId` + - optional current lead id +- Response: + - possible matching `people` + - possible matching `companies` + - confidence buckets by exact field match + +### Link / Create Before Final Conversion +#### `POST /api/customers/leads/link-person` +- Body: `leadId`, `personId` + +#### `POST /api/customers/leads/link-company` +- Body: `leadId`, `companyId` + +#### `POST /api/customers/leads/link-deal` +- Body: `leadId`, `dealId` + +#### `POST /api/customers/leads/create-person` +- Body: + - `leadId` + - optional override payload + - selected field bindings to use for prefill + +#### `POST /api/customers/leads/create-company` +- Body analogous to create person + +#### `POST /api/customers/leads/create-deal` +- Body analogous to create person + +### Conversion +#### `POST /api/customers/leads/convert` +- Body: + - `leadId` + - target plan describing which objects to create vs link + - optional overrides + - selected field transfers / shared bindings + - target won stage confirmation +- Response: + - created/linked object refs + - conversion summary + +Conversion contract: +- conversion is explicit and reviewable +- user must choose or confirm targets +- UI must show field origins and target ownership clearly +- default suggestions are allowed, silent conversion is not +- conversion is single-use; if the lead is already converted, the endpoint must reject a new conversion request +- at least one target object must be linked or created during conversion +- if a `deal` is part of the target plan, the spec implementation must define whether it may be newly created, linked, or both +- successful conversion sets lead outcome/state and stores immutable conversion lineage metadata + +### Configuration APIs +#### `GET/POST/PUT/DELETE /api/customers/lead-pipelines` +#### `GET/POST/PUT/DELETE /api/customers/lead-pipeline-stages` +#### `GET/POST/PUT/DELETE /api/customers/lead-lost-reasons` +#### `GET/POST/PUT/DELETE /api/customers/lead-field-bindings` +#### `GET/PUT /api/customers/lead-settings` + +## Internationalization (i18n) +Need i18n keys for: +- navigation and page titles +- list columns, filters, row actions +- pipeline board labels +- lead detail sections +- duplicate warnings +- link/create/convert actions +- field origin badges/icons +- pipeline and lost-reason configuration +- validation and error messages + +## UI/UX +Lead UI should follow existing `customers` and `deals` conventions: +- `DataTable` for list views +- `CrudForm` for create/edit/detail flows +- `FormHeader` / `FormFooter` patterns +- `ConfirmDialog` for destructive or terminal actions + +### Backend Routes +- `/backend/customers/leads` +- `/backend/customers/leads/create` +- `/backend/customers/leads/[id]` +- `/backend/customers/leads/pipeline` +- `/backend/config/customers/leads` for admin configuration + +### Navigation +- add `Leads` under `customers` +- add `Lead Pipelines` config entry under customer configuration for admins +- both entries appear only when lead capability is enabled and the user has access + +### Lead List +Main list view: +- columns: + - display name + - pipeline + - stage + - owner + - source + - duplicate indicator + - linked/created object summary + - created at +- filters: + - pipeline + - stage + - outcome + - owner + - source + - duplicate present +- exports supported + +### Lead Pipeline Board +Board view similar in spirit to deals pipeline: +- one board per pipeline +- columns by stage +- cards show owner, source, duplicate flags, and quick links +- drag/drop may be added if consistent with existing board patterns + +### Lead Detail Page +The lead detail page is the primary workspace. + +Recommended form sections: +- `Lead Overview` +- `Potential Person` +- `Potential Company` +- `Potential Deal` +- `Lead-only Metadata` +- `Source Payload / Intake` +- `Links & Conversion` +- `History` + +### Shared Field Rendering +Fields surfaced from target objects must be visually marked: +- company-origin field: company icon/badge +- person-origin field: person icon/badge +- deal-origin field: deal icon/badge + +The marker must communicate: +- where the field belongs canonically +- whether it is lead-only, prefill-only, or shared live field +- whether a linked target already exists +- for custom fields, which module/entity owns the canonical value + +### Conversion UX +Conversion must be explicit and reviewable. + +User flow: +1. open lead detail +2. choose `Convert` +3. review which targets to create or link +4. review transferred/shared fields +5. confirm target stage/outcome +6. execute conversion + +This review interaction may be a full-screen flow or structured dialog, but it must not be a silent one-click conversion. + +The review UI must also: +- show, per field, whether the value is lead-local, copied once, or live shared +- show field origin icons/badges for both standard fields and custom fields +- allow admins/operators with permission to understand which target object will own the value after conversion + +### Manual Create/Link During Qualification +On the lead detail page, users can: +- search and link an existing person/company/deal +- create a person/company/deal from the lead before final conversion +- keep the lead open after those actions + +### Permissions +Initial scope: +- all lead administration is `admin` only +- lead custom fields are admin-managed +- pipeline, lost reason, and field-binding configuration are admin-managed +- lead capability enable/disable setting is admin-managed + +## Configuration +Admin config area must support: +- enable/disable lead capability per tenant +- create/edit/archive lead pipelines +- create/edit/reorder stages +- create/edit/reorder lost reasons +- manage lead custom fields +- manage field binding rules and target ownership metadata + +## ACL & Feature IDs +Initial feature set to plan in `acl.ts` / `setup.ts`: + +| Feature ID | Purpose | Initial scope | +|------------|---------|---------------| +| `customers.leads.view` | View lead list/detail/pipeline | admin | +| `customers.leads.create` | Create leads manually or through internal UI flows | admin | +| `customers.leads.update` | Edit lead records and lead-local fields | admin | +| `customers.leads.assign` | Assign lead owner | admin | +| `customers.leads.stage` | Move lead between stages / mark lost | admin | +| `customers.leads.convert` | Execute one-time conversion flow | admin | +| `customers.leads.link` | Link or create person/company/deal during qualification | admin | +| `customers.leads.settings` | Enable/disable lead capability | admin | +| `customers.leads.pipeline.manage` | Manage pipelines and stages | admin | +| `customers.leads.reasons.manage` | Manage lost reasons | admin | +| `customers.leads.fields.manage` | Manage field bindings and lead custom fields | admin | +| `customers.leads.source.view` | View raw source payload and provenance | admin | + +Notes: +- future role expansion may grant subsets of these permissions to sales managers or reps +- raw source payload visibility should remain separately controllable because of privacy concerns + +## Search & Analytics +### Search +- add lead indexing in `customers/search.ts` +- searchable fields: + - display name + - email + - phone + - VAT ID + - source + - pipeline/stage labels + - selected lead-only text fields + +### Analytics +Add lead analytics dimensions: +- pipeline +- stage +- source +- owner +- won/lost outcome +- lost reason +- linked vs newly created conversion type + +### Dashboard Widgets +Initial widgets: +- leads by stage +- stale leads +- recent converted leads +- lost reasons breakdown +- source conversion efficiency + +## Example Field Bindings +The table below defines the initial reference set for the most important surfaced fields. It is intentionally limited to a small core set. Additional standard fields and custom fields from `person`, `company`, and later other related objects may be surfaced into lead sections through the same field-binding mechanism. + +### Potential Company Section + +| Lead field key | Canonical target | Default binding mode | Behavior on lead card | +|----------------|------------------|----------------------|-----------------------| +| `company.displayName` | `company.displayName` | `shared` | Main company name shown and edited from lead; after link/create it writes through to company | +| `company.primaryEmail` | `company.primaryEmail` | `shared` | Shared communication field; duplicate checks may use it | +| `company.primaryPhone` | `company.primaryPhone` | `shared` | Shared communication field; updates canonical company value after link | +| `company.vatId` | `company.taxId` or canonical VAT/tax field | `shared` | Shared company identifier used for duplicate checking and downstream consistency | +| `company.employeeCount` | `company.employeeCount` | `shared` | Shared business profile field; editing on lead updates company | +| `company.comments` | none | `lead_only` | Local lead qualification note in company context; does not update company | + +### Potential Person Section + +| Lead field key | Canonical target | Default binding mode | Behavior on lead card | +|----------------|------------------|----------------------|-----------------------| +| `person.displayName` | `person.displayName` | `shared` | Shared person name after link/create | +| `person.primaryEmail` | `person.primaryEmail` | `shared` | Shared identity/contact field used in duplicate checks | +| `person.primaryPhone` | `person.primaryPhone` | `shared` | Shared contact field after link/create | +| `person.jobTitle` | `person.jobTitle` | `shared` | Shared role/title field surfaced in lead qualification | +| `person.linkedinUrl` | `person.linkedinUrl` | `prefill_only` | Copied when creating person if present, but not kept in sync by default | +| `person.comments` | none | `lead_only` | Lead-local context note about the contact; independent from person notes/comments | + +### Extensibility Rule +- the initial surfaced set should stay intentionally small and high-value +- additional standard fields from `company`, `person`, or future related objects may be exposed later through `CustomerLeadFieldBinding` +- custom fields are first-class citizens of this mechanism +- for each newly surfaced field, admin must choose one binding mode: + - `lead_only` + - `prefill_only` + - `shared` + +## Migration & Compatibility +This change is additive and must not break existing contract surfaces. + +Backward compatibility rules: +- no existing `customers` or `deals` routes are renamed or removed +- no existing event IDs are renamed or removed +- no existing ACL feature IDs are renamed or removed +- no existing tables/columns are renamed or removed +- all additions are new routes, events, entities, and config surfaces + +Lineage requirements: +- downstream records created from a lead must preserve link back to the lead +- existing `person`, `company`, and `deal` APIs may gain additive optional fields showing lead origin references + +Future-proofing: +- direct CRM flow without leads remains supported +- lead-first flow is optional and tenant-controlled +- multi-pipeline is supported from v1 +- field binding rules are additive and configurable +- lead detail sections must use stable IDs for future widget injection + +## Implementation Plan +### Phase 1: Lead Core +1. Add entities, validators, ACL, setup defaults, events, search config, and command registry. +2. Add lead capability setting and tenant-level gating. +3. Add CRUD/list/detail APIs with `openApi`. +4. Add lead list and detail UI under `customers`. +5. Add admin configuration pages for lead enablement, pipelines, and lost reasons. + +### Phase 2: Qualification Workflow +1. Add multi-pipeline board view and stage transitions. +2. Add duplicate detection by email, phone, VAT ID. +3. Add assignment and lost-reason flow. +4. Add lead history timeline. + +### Phase 3: Linking & Shared Fields +1. Add manual link/create of person/company/deal from lead detail. +2. Add field-binding configuration and surfaced field indicators. +3. Implement write-through behavior for shared fields after link/create. + +### Phase 4: Conversion & Analytics +1. Implement explicit conversion review flow. +2. Persist lineage to downstream records. +3. Add dashboard/reporting support. +4. Add integration tests and finalize compliance review. + +## Testing Strategy +### Integration Coverage +Required scenarios: +- disable leads in settings and verify lead navigation/pages are hidden or blocked +- enable leads in settings and verify lead flow becomes available +- create lead manually +- create lead via API source payload +- duplicate warning on existing person/company +- move lead across stages in a selected pipeline +- mark lead lost with required reason +- create company from lead before final conversion +- link existing person/company to lead +- verify conversion can happen only once +- convert lead to: + - person + - company + - person + deal + - person + company + deal +- verify lineage from target objects back to lead +- verify shared company/person field edits on lead update canonical record after link +- verify custom field binding modes: + - lead-only custom field stays local + - prefill-only custom field copies once on create + - shared custom field updates canonical target object when edited on lead +- verify admin configuration for pipelines and lost reasons + +### Non-Functional Checks +- tenant and organization scoping on every query +- page size remains `<= 100` +- no raw fetch in backend pages +- zod validation for every mutation + +## Risks & Impact Review +#### Shared Field Drift +- **Scenario**: A field visible on the lead and on the linked company diverges because both persist separate values. +- **Severity**: Critical +- **Affected area**: Lead detail, company/person/deal data integrity, conversion trust +- **Mitigation**: After link/create, shared bindings become write-through projections to canonical target fields; no second independent value remains for shared mode. +- **Residual risk**: Misconfigured field bindings may still expose wrong target ownership; admin UI needs clear validation. + +#### Conversion Re-entry +- **Scenario**: An already converted lead is converted again with a new target plan, creating duplicate canonical records and broken lineage. +- **Severity**: High +- **Affected area**: Lead conversion, people/company/deal integrity, analytics +- **Mitigation**: Conversion is single-use only; endpoint and UI must reject repeated conversion attempts once conversion metadata is set. +- **Residual risk**: Admin-level repair tooling may still need to handle historical bad data imported from external systems. + +#### Conversion Partial Failure +- **Scenario**: Conversion creates one target object but fails on a second target or lineage write. +- **Severity**: High +- **Affected area**: Lead conversion, audit, downstream CRM consistency +- **Mitigation**: Use compound command with transaction boundaries for local writes; defer non-core side effects until after commit. +- **Residual risk**: If future integrations react asynchronously, external compensation may be delayed. + +#### Duplicate Misclassification +- **Scenario**: Duplicate detection suggests the wrong record or misses a real duplicate. +- **Severity**: Medium +- **Affected area**: Operator workflow, data cleanliness +- **Mitigation**: Advisory-only model, explicit linking, visible evidence for why a duplicate was suggested. +- **Residual risk**: Human operators may still create duplicates intentionally or accidentally. + +#### Pipeline Overconfiguration +- **Scenario**: Admin creates overly complex pipelines that are hard to operate or report on. +- **Severity**: Medium +- **Affected area**: Lead operations, analytics consistency +- **Mitigation**: Stable defaults, admin validation, one default pipeline, stage kind constraints. +- **Residual risk**: Cross-tenant variability will still complicate product support and documentation. + +#### Lead Capability Toggle Drift +- **Scenario**: Lead capability is disabled in settings but lead menu items, routes, or APIs remain partially accessible. +- **Severity**: High +- **Affected area**: Navigation, route guards, admin UX, tenant configuration consistency +- **Mitigation**: Single tenant-level source of truth for lead enablement, checked by navigation builders, page metadata/guards, and API handlers. +- **Residual risk**: Existing bookmarked URLs may still hit disabled pages and must return a clear access/configuration error. + +#### Source Payload Sensitivity +- **Scenario**: Raw inbound payload contains sensitive or noisy data that is shown too broadly. +- **Severity**: High +- **Affected area**: Privacy, UI, audit surfaces +- **Mitigation**: Restrict source payload access to authorized users, sanitize known secret-like keys in logs/UI. +- **Residual risk**: Third-party payload schemas are unpredictable. + +## Final Compliance Report +## Final Compliance Report — 2026-04-04 + +### AGENTS.md Files Reviewed +- `AGENTS.md` (root) +- `.ai/specs/AGENTS.md` +- `packages/core/AGENTS.md` +- `packages/core/src/modules/customers/AGENTS.md` +- `packages/ui/AGENTS.md` + +### Compliance Matrix + +| Rule Source | Rule | Status | Notes | +|-------------|------|--------|-------| +| root AGENTS.md | No direct ORM relationships between modules | Compliant | Spec uses linkage IDs and lineage refs, not cross-module ORM | +| root AGENTS.md | Always filter by `organization_id` | Compliant | Included in all entities and non-functional checks | +| root AGENTS.md | Validate all inputs with zod | Compliant | Required for all mutations | +| root AGENTS.md | API/UI use shared patterns | Compliant | `DataTable`, `CrudForm`, shared dialogs, shared API helpers | +| `.ai/specs/AGENTS.md` | Include TLDR, Overview, Problem, Solution, Architecture, Data Models, API Contracts, Risks, Compliance, Changelog | Compliant | All required sections present | +| `packages/core/AGENTS.md` | API routes MUST export `openApi` | Compliant | Explicitly required in API section | +| `packages/core/AGENTS.md` | Events declared with `createModuleEvents()` | Compliant | Event family specified for declaration | +| `packages/core/src/modules/customers/AGENTS.md` | Use customers module as reference CRUD pattern | Compliant | Lead implemented inside customers using existing CRUD/command patterns | +| `packages/ui/AGENTS.md` | Use `DataTable` for list views | Compliant | Lead list and pipeline UX follow backend patterns | +| `packages/ui/AGENTS.md` | Use `CrudForm` for create/edit flows | Compliant | Lead detail/create flows use `CrudForm` | + +### Internal Consistency Check + +| Check | Status | Notes | +|-------|--------|-------| +| Data models match API contracts | Pass | CRUD, config, linking, and conversion endpoints align with entities | +| API contracts match UI/UX section | Pass | List, detail, pipeline, config, and conversion are represented in both | +| Risks cover all write operations | Pass | Lead update, stage changes, linking, conversion, shared fields covered | +| Commands defined for all mutations | Pass | All core mutations mapped to commands | +| Cache strategy covers all read APIs | Pass | No dedicated cache introduced in spec; read paths remain direct/query-driven | + +### Non-Compliant Items +- None identified at spec stage. + +### Verdict +- **Partially compliant**: Needs one more refinement pass before implementation, mainly around field-binding examples, target-plan validation, and endpoint metadata/ACL detail. + +## Changelog +### 2026-04-04 +- Expanded the skeleton into a full working specification for the customers lead funnel. +- Added multi-pipeline support, shared-field binding model, manual pre-conversion linking/creation, and lineage rules. + +### 2026-04-03 +- Initial skeleton specification created for customers lead funnel. From 50152f3ee950f662a690bc93203826c10112ca3a Mon Sep 17 00:00:00 2001 From: zielivia <48693228+zielivia@users.noreply.github.com> Date: Mon, 6 Apr 2026 08:29:15 +0200 Subject: [PATCH 008/215] Spec/perspectives views panel (#1148) * spec(perspectives): add SPEC-070 views panel redesign * spec(perspectives): add SPEC-070 content * spec(perspectives): update deps - dnd-kit from PR #1144 --------- Co-authored-by: Piotr Karwatka --- ...070-2026-04-04-perspectives-views-panel.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 .ai/specs/SPEC-070-2026-04-04-perspectives-views-panel.md diff --git a/.ai/specs/SPEC-070-2026-04-04-perspectives-views-panel.md b/.ai/specs/SPEC-070-2026-04-04-perspectives-views-panel.md new file mode 100644 index 00000000000..a96547e5020 --- /dev/null +++ b/.ai/specs/SPEC-070-2026-04-04-perspectives-views-panel.md @@ -0,0 +1,194 @@ +# SPEC-070: Perspectives Views Panel Redesign + +## Overview + +Redesign the perspectives UI panel (`PerspectiveSidebar.tsx`) to provide an intuitive views management experience with field search, clear private/public distinction, and streamlined view switching. This replaces the current unintuitive "perspectives" mechanism with a user-friendly "Views" panel. + +Reference: T-FE-02 + +## Problem Statement + +The current `PerspectiveSidebar` has several UX issues: + +1. **No field search in column configuration** — users with 20+ columns must scroll through the entire list to find and toggle a specific field. There is no way to filter or search. +2. **Unintuitive "perspectives" terminology** — the concept of "perspectives" is unclear to most users. Industry-standard term is "views" or "customize" (as in Notion, Airtable, Linear). +3. **No clear private/public distinction** — the current split between "My perspectives" and "Role perspectives" is confusing. Users don't immediately understand who can see what. +4. **Clunky view switching** — activating a view requires opening the sidebar, finding the view, and clicking "Use". There's no quick-switch mechanism. +5. **Column reordering with arrows only** — moving columns up/down one position at a time is slow for large column sets. Drag-and-drop would be more efficient. + +## Proposed Solution + +A frontend-only redesign of the perspectives panel. The backend API and data model remain unchanged — this is purely a UI/UX improvement. + +### 1. Rename "Perspectives" to "Views" + +All user-facing labels change from "Perspectives" to "Views": +- "My perspectives" → "My views" (private) +- "Role perspectives" → "Shared views" (public) +- "Save perspective" → "Save view" +- Update all i18n keys under `ui.perspectives.*` → `ui.views.*` (keep old keys as fallbacks) + +### 2. Field Search in Column Configuration + +Add a search input at the top of the Columns section: + +``` +┌─────────────────────────────┐ +│ 🔍 Search fields... │ +├─────────────────────────────┤ +│ ☑ Company name │ +│ ☑ Contact email │ +│ ☐ Created at │ +│ ...filtered results... │ +└─────────────────────────────┘ +``` + +- Filter `columnOptions` by `label` matching the search query (case-insensitive) +- Show match count: "3 of 24 fields" +- Clear button to reset search +- Empty state: "No fields matching '[query]'" + +### 3. Private / Public Views Distinction + +Replace the current two-section layout with a tabbed interface: + +``` +┌──────────────────────────────────┐ +│ [Private] [Shared] │ +├──────────────────────────────────┤ +│ ★ My default view ✕ │ +│ Condensed contacts │ +│ Full details ✕ │ +│ │ +│ ┌────────────────────────────┐ │ +│ │ + Save current as new view│ │ +│ └────────────────────────────┘ │ +└──────────────────────────────────┘ +``` + +**Private tab**: shows `perspectives` (user's own views) +**Shared tab**: shows `rolePerspectives` grouped by role, with role name as section header + +Each view card shows: +- View name (bold) +- "Default" badge if `isDefault` +- Last updated date (relative: "2 hours ago", "yesterday") +- Delete button (private only) +- Active indicator (highlighted border when selected) + +### 4. Quick View Switcher + +Add a compact dropdown/popover above the DataTable (outside the sidebar) for fast view switching without opening the full panel: + +``` +┌─────────────────────────────────────────┐ +│ Current view: [My default view ▾] ⚙️ │ +└─────────────────────────────────────────┘ +``` + +- Dropdown lists all available views (private + shared) +- Click to switch instantly +- ⚙️ icon opens the full sidebar panel +- Shows "Unsaved changes" indicator if current table state differs from the active view + +### 5. Improved Column Management + +Replace arrow-based reordering with drag-and-drop: +- Each column row gets a drag handle (⠿) +- Drag to reorder +- Keep checkbox toggle for visibility +- Search filter (from point 2) works alongside drag-and-drop +- Consider using `@dnd-kit/core` if available in the project, otherwise `react-beautiful-dnd` + +## Architecture + +### Component Structure + +``` +packages/ui/src/backend/ +├── PerspectiveSidebar.tsx → ViewsPanel.tsx (rename + rewrite) +├── ViewsPanelPrivateTab.tsx (new - private views list) +├── ViewsPanelSharedTab.tsx (new - shared/role views list) +├── ViewsColumnConfig.tsx (new - column search + drag reorder) +├── ViewsSaveForm.tsx (new - save view form) +└── ViewsQuickSwitcher.tsx (new - dropdown above DataTable) +``` + +### Impact Analysis + +**Files modified:** +- `packages/ui/src/backend/PerspectiveSidebar.tsx` — replaced by `ViewsPanel.tsx` +- `packages/ui/src/backend/DataTable.tsx` — add `ViewsQuickSwitcher` integration + +**Files created:** +- 5 new component files (see structure above) + +**No backend changes required.** The existing API (`/api/[tableId]/perspectives`) and data model (`Perspective`, `RolePerspective`) remain unchanged. The frontend maps the API response to the new UI structure. + +**No database migrations required.** + +**UMES events:** No new events needed. Existing perspective CRUD operations remain the same. + +**i18n:** New translation keys under `ui.views.*` namespace. Old `ui.perspectives.*` keys kept as fallbacks during transition. + +### Dependencies + +Drag-and-drop uses `@dnd-kit/core` + `@dnd-kit/sortable` added to `packages/ui` +by PR #1144 (DataTable column reordering). No additional dependencies needed. + +## Alternatives Considered + +### A. Modify PerspectiveSidebar in place +Rejected — the component is 337 lines with mixed concerns (views list, column config, save form). Splitting into focused components is cleaner and more maintainable. + +### B. Build as overlay module instead of editing packages/ui +Considered — but this is a core UI improvement that benefits all users. The perspectives module is ejectable, so custom implementations can still override. The core team should decide if this goes into core or as an overlay. + +### C. Add backend support for "public" views (not role-scoped) +Deferred — the current `RolePerspective` model covers sharing via roles. A true "public to all users" view type could be added later with a new entity, but is out of scope for T-FE-02. + +## Implementation Approach + +### Phase 1: Component Refactor (no visual changes) +1. Split `PerspectiveSidebar.tsx` into 5 smaller components +2. Keep exact same behavior and appearance +3. Verify all existing tests pass + +### Phase 2: Field Search +1. Add search input to `ViewsColumnConfig` +2. Filter column list by search query +3. Add match count display + +### Phase 3: Private/Shared Tabs + Rename +1. Replace section layout with tabs +2. Rename all labels from "Perspectives" to "Views" +3. Add i18n keys + +### Phase 4: Quick Switcher +1. Create `ViewsQuickSwitcher` dropdown +2. Integrate above `DataTable` +3. Add "unsaved changes" indicator + +### Phase 5: Drag-and-Drop Columns +1. Add drag-and-drop library (if needed) +2. Replace arrow buttons with drag handles +3. Keep checkbox toggles + +## Success Metrics + +- Users can find a column in <3 seconds (via search) vs current scroll-based approach +- View switching takes 1 click (via quick switcher) vs current 3 clicks (open sidebar → find view → click Use) +- New users understand "Views" terminology without explanation + +## Open Questions + +1. **Core vs overlay?** — Should this go directly into `packages/ui` or be implemented as an overlay? Discuss with core team. +2. **Drag-and-drop library choice** — Is `@dnd-kit` preferred, or does the project have a different standard? +3. **Quick switcher placement** — Should it be part of the DataTable toolbar or a separate component above it? +4. **Migration path** — Should old `ui.perspectives.*` i18n keys be removed immediately or deprecated gradually? + +## Changelog +### 2026-04-04 (update) +- Updated dependencies section: @dnd-kit provided by PR #1144 (M.D.) +### 2026-04-04 +- Initial specification From 574a0719008f9c34fb29aae8e90908171eaa7c93 Mon Sep 17 00:00:00 2001 From: Maciej Dudziak Date: Mon, 6 Apr 2026 21:21:36 +0200 Subject: [PATCH 009/215] feat: advanced datatable CRM (spec + implementation) (#1150) * advanced datatable * integration tests * fix integration tests (change of search box) * one more pass of integration tests adjustments * adjust for new search approach --- .ai/lessons.md | 40 ++ .ai/specs/2026-04-03-advanced-datatable-ux.md | 625 ++++++++++++++++++ apps/mercato/src/i18n/de.json | 56 ++ apps/mercato/src/i18n/en.json | 56 ++ apps/mercato/src/i18n/es.json | 56 ++ apps/mercato/src/i18n/pl.json | 56 ++ .../integration/TC-INT-002.spec.ts | 2 +- .../__integration__/TC-CRM-001.spec.ts | 2 +- .../__integration__/TC-CRM-007.spec.ts | 2 +- .../__integration__/TC-CRM-013.spec.ts | 2 +- .../__integration__/TC-CRM-014.spec.ts | 4 +- .../__integration__/TC-CRM-015.spec.ts | 2 +- .../__integration__/TC-CRM-029.spec.ts | 66 ++ .../__integration__/TC-CRM-030.spec.ts | 78 +++ .../__integration__/TC-CRM-031.spec.ts | 70 ++ .../__integration__/TC-CRM-032.spec.ts | 47 ++ .../__integration__/TC-CRM-033.spec.ts | 31 + .../modules/customers/api/companies/route.ts | 154 ++++- .../src/modules/customers/api/deals/route.ts | 57 +- .../src/modules/customers/api/people/route.ts | 163 ++++- .../core/src/modules/customers/api/utils.ts | 286 ++++++++ .../backend/customers/companies/page.tsx | 193 +++++- .../backend/customers/deals/page.tsx | 162 ++++- .../backend/customers/people/page.tsx | 201 +++++- .../customers/commands/interactions.ts | 7 + .../customers/components/detail/DealForm.tsx | 1 + .../core/src/modules/customers/i18n/de.json | 12 + .../core/src/modules/customers/i18n/en.json | 18 +- .../core/src/modules/customers/i18n/es.json | 12 + .../core/src/modules/customers/i18n/pl.json | 12 + .../src/modules/query_index/lib/engine.ts | 96 ++- .../lib/crud/advanced-filter-integration.ts | 74 +++ packages/shared/src/lib/crud/factory.ts | 23 +- .../query/__tests__/advanced-filter.test.ts | 75 +++ .../shared/src/lib/query/advanced-filter.ts | 304 +++++++++ packages/shared/src/lib/query/engine.ts | 76 ++- packages/shared/src/lib/query/join-utils.ts | 49 +- packages/ui/package.json | 4 + packages/ui/src/backend/DataTable.tsx | 444 ++++++++++++- packages/ui/src/backend/FilterBar.tsx | 53 +- .../backend/columns/ColumnChooserPanel.tsx | 241 +++++++ .../confirm-dialog/useConfirmDialog.tsx | 3 +- .../backend/filters/AdvancedFilterBuilder.tsx | 339 ++++++++++ .../ui/src/backend/hooks/useAdvancedFilter.ts | 77 +++ .../src/backend/utils/customFieldColumns.ts | 48 +- .../src/backend/utils/customFieldFilters.ts | 29 + .../backend/utils/useAutoDiscoveredFields.ts | 138 ++++ packages/ui/src/index.ts | 2 + packages/ui/src/primitives/table.tsx | 4 +- yarn.lock | 72 ++ 50 files changed, 4477 insertions(+), 147 deletions(-) create mode 100644 .ai/specs/2026-04-03-advanced-datatable-ux.md create mode 100644 packages/core/src/modules/customers/__integration__/TC-CRM-029.spec.ts create mode 100644 packages/core/src/modules/customers/__integration__/TC-CRM-030.spec.ts create mode 100644 packages/core/src/modules/customers/__integration__/TC-CRM-031.spec.ts create mode 100644 packages/core/src/modules/customers/__integration__/TC-CRM-032.spec.ts create mode 100644 packages/core/src/modules/customers/__integration__/TC-CRM-033.spec.ts create mode 100644 packages/shared/src/lib/crud/advanced-filter-integration.ts create mode 100644 packages/shared/src/lib/query/__tests__/advanced-filter.test.ts create mode 100644 packages/shared/src/lib/query/advanced-filter.ts create mode 100644 packages/ui/src/backend/columns/ColumnChooserPanel.tsx create mode 100644 packages/ui/src/backend/filters/AdvancedFilterBuilder.tsx create mode 100644 packages/ui/src/backend/hooks/useAdvancedFilter.ts create mode 100644 packages/ui/src/backend/utils/useAutoDiscoveredFields.ts diff --git a/.ai/lessons.md b/.ai/lessons.md index 571049984ee..58cb959020c 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -198,6 +198,16 @@ Centralize shared command utilities like undo extraction in `packages/shared/src 1. In integration tests/helpers, do not use `waitForLoadState('networkidle')` as a generic readiness gate on backend pages. 2. Prefer `waitForLoadState('domcontentloaded')` plus one explicit UI readiness assertion for the interaction target (for example, a key button/input becoming visible). + +## Projection updates that change indexed parent fields must emit query-index upserts + +**Context**: Customer interaction commands recomputed `next_interaction_*` fields directly on `customer_entities`, which changed list/search-visible data without mutating the `CustomerEntity` through its normal CRUD command path. + +**Problem**: The companies/people grids showed the fresh `next_interaction_name`, but `entity_indexes` and `search_tokens` for `customers:customer_entity` stayed stale. Global search and token-backed filters then missed values that were visibly present in the grid until a manual reindex happened. + +**Rule**: Any command or projection that directly updates fields surfaced through query-indexed docs must also emit `query_index.upsert_one` for the affected entity records. If child/profile docs denormalize the same parent fields, review whether they need matching upserts too. + +**Applies to**: Projection helpers, lifecycle commands, and any write path that bypasses the primary CRUD/indexer helpers while changing search/list-visible fields. 3. Keep selectors user-facing and stable (`Edit`, `Filter`) rather than translation keys or positional indexing (`nth(...)`) when possible. **Applies to**: `packages/*/__integration__/**` Playwright tests and shared integration helpers (especially sales/customer flows). @@ -568,3 +578,33 @@ Centralize shared command utilities like undo extraction in `packages/shared/src **Rule**: Any publishable cross-package registry that must be visible across bootstrap, API routes, and request containers must persist via `globalThis` with a stable key. Do not store bootstrap-critical registries only in module-local variables. **Applies to**: ORM/entity registries, DI registrars, module registries, and other standalone-sensitive bootstrap state in `@open-mercato/*` packages. + +## Auto-discovered DataTable fields must only advertise controls the table can actually honor + +**Context**: The customer grids auto-discovered custom fields into the advanced-filter builder and column chooser, but the table pages only registered `listVisible` custom columns. Hidden-but-selectable fields like `cf_executive_notes` appeared in the chooser without a matching TanStack column id. + +**Problem**: The chooser could surface legitimate field labels that crashed at toggle time with `Column with id 'cf_executive_notes' does not exist`, because discovery and actual column registration drifted apart. + +**Rule**: When a DataTable auto-discovers fields from entity/custom-field metadata, keep the discovery surface aligned with the concrete column registry. If a field should be selectable later, register a real hidden column for it; otherwise keep it out of chooser/filter discovery entirely. + +**Applies to**: `DataTable` auto-discovery, custom-field-backed list pages, and any future metadata-driven chooser/filter UI. + +## Mixed advanced filters need per-row join state, not one shared logic flag + +**Context**: The advanced-filter builder initially stored a single `logic` value for the whole filter state and reused it for every non-first row. + +**Problem**: Toggling one row from `And` to `Or` changed every row, making mixed expressions impossible and causing the backend to over-collapse distinct filter rows into one global boolean mode. + +**Rule**: For row-based filter builders, store the boolean connector on each non-first condition and keep any old global logic only as backward-compatible fallback when reading legacy URLs or state. + +**Applies to**: Shared advanced-filter state, URL serialization/deserialization, and any future query-builder UI that supports multiple rows. + +## dnd-kit contexts rendered in SSR need stable ids + +**Context**: The advanced datatable uses dnd-kit for header and column-chooser drag-and-drop, and those contexts are rendered during SSR on backend pages. + +**Problem**: Letting dnd-kit generate its own accessibility ids caused server/client `aria-describedby` mismatches, which showed up as React hydration errors on customer grid pages even though the table still rendered. + +**Rule**: Whenever a dnd-kit `DndContext` can be server-rendered, pass a deterministic `id` derived from stable page/table identity instead of relying on auto-generated ids. + +**Applies to**: `DataTable` header drag-and-drop, column chooser drag-and-drop, and any future SSR-rendered dnd-kit surface. diff --git a/.ai/specs/2026-04-03-advanced-datatable-ux.md b/.ai/specs/2026-04-03-advanced-datatable-ux.md new file mode 100644 index 00000000000..599e9189094 --- /dev/null +++ b/.ai/specs/2026-04-03-advanced-datatable-ux.md @@ -0,0 +1,625 @@ +# Advanced DataTable UX + +> **Status**: Implemented +> **Scope**: OSS (`packages/ui`, `packages/shared`, `packages/core`) +> **Created**: 2026-04-03 + +--- + +## TLDR + +Upgrade the shared `DataTable` component with eight production-grade features: (1) clickable column-header sorting, (2) configurable bulk-action checkboxes with select-all, (3) page-size selector, (4) advanced filter builder with AND/OR logic and per-field operators, (5) scalable column chooser with search for entities with hundreds of fields, (6) sticky first data column and drag-and-drop column reorder, (7) virtual scrolling for large datasets, (8) auto-discovery of filter fields and column chooser fields from entity metadata. Initial rollout targets customers (people, companies) and deals; the component changes are platform-wide by design. + +**UX designer requirements**: sticky first data column (not the checkbox), drag & drop columns, checkboxes, pagination, sorting on header click, reusable logic across companies/people/deals, table margins (not edge-to-edge). + +**Post-implementation requirements** (added 2026-04-04): +- Bulk action buttons (e.g., "Delete selected") must only be visible when at least one row is selected +- Column chooser must not show a column in both "Selected" and "Available" sections simultaneously +- Advanced filter panel must open with one pre-populated condition row ready to fill (not empty requiring extra click) +- "Select field..." placeholder must not be selectable as a value in the filter field dropdown +- All entity fields (including detail-page-only fields like "Legal Name", "Website", "Domain") must appear in the filter and column chooser — auto-discovered from custom field definitions API +- Filter fields, column chooser fields, and search behavior must be auto-discoverable without hardcoded per-page arrays +- Search must be full-text across all main text columns (name, email, phone, description), not just the name column +- Sticky column must be the first data column (after the checkbox), not the checkbox itself — and must follow column reorder (whichever column is first becomes sticky) + +--- + +## Problem Statement + +The current `DataTable` component — used across every list page — lacks several features expected in a professional business application: + +1. **No sorting on customers pages**: The `sortable` prop exists in DataTable but is not wired on the customers people/companies/deals pages. Users cannot sort by name, date, status, etc. +2. **Bulk actions require injection**: Checkboxes only appear when a module injects bulk actions via the widget system. There is no code-level configuration to enable built-in bulk actions (e.g., mass delete). +3. **Fixed page size**: Page size is hardcoded per page (typically 20). Users cannot choose 10/25/50/100. +4. **Primitive filtering**: The current `FilterOverlay` uses a flat form with predefined filter types. There is no AND/OR logic, no per-field operator selection (is, contains, equals, greater than), and no ability to add/remove filter conditions dynamically — unlike the CRM-style filter builder in the reference screenshot. +5. **Column chooser doesn't scale**: The Perspectives sidebar shows a checkbox list with up/down arrows. With 50+ fields (standard + custom), it becomes unusable — no search, no grouping, no way to find a specific field quickly. +6. **No sticky first column**: When tables have many columns and horizontal scroll, the identifier column (name/title) scrolls off-screen. +7. **No drag-and-drop column reorder**: Only up/down arrows in the Perspectives sidebar; no direct manipulation. +8. **Table extends to screen edges**: No breathing room / margins around the table. +9. **No virtual scrolling**: With large page sizes (50-100 rows) or wide tables with many columns, DOM rendering becomes sluggish. There is no row or column virtualization. + +--- + +## Proposed Solution + +Enhance the existing `DataTable` component in `packages/ui` (TanStack React Table foundation) with backward-compatible additions. All changes are opt-in via props so existing pages are unaffected until they adopt the new features. + +### Architecture Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Grid library | Keep TanStack Table | Already integrated, headless = full UI control, MIT license | +| Drag & drop | `@dnd-kit/core` + `@dnd-kit/sortable` | MIT, best React DnD library, works with any renderer | +| Virtual scrolling | `@tanstack/react-virtual` | Same TanStack ecosystem, MIT, tiny (~2KB), designed to pair with TanStack Table | +| Filter state shape | `{ logic: 'and'|'or', conditions: FilterCondition[] }` | Simple, serializable, extensible | +| Column discovery | Entity schema + custom fields auto-discovery | Eliminates manual column declaration for large field sets | +| Bulk actions | Code-level `bulkActions` prop | Configurable per page without injection system | + +--- + +## Feature Design + +### F1 — Column Header Sorting + +**Current state**: `sortable` prop exists in DataTable, renders clickable headers with ▲/▼. Not used on customers pages. + +**Changes**: +- Enable `sortable` on customers people, companies, and deals pages +- Add sort indicators that are always visible (muted up/down arrows), with the active direction highlighted +- Wire sorting to the API query parameter (`?sort=name&order=asc`) +- Support multi-column sort display (TanStack Table already supports this) + +**Props** (no new props needed — just enable `sortable={true}` and wire `sorting`/`onSortingChange`): +```tsx + +``` + +### F2 — Configurable Bulk Actions with Checkboxes + +**Current state**: Checkboxes appear only when `hasInjectedBulkActions` is true. + +**Changes**: +- Add a `bulkActions` prop that accepts an array of action definitions +- When `bulkActions` is provided (or injected bulk actions exist), show checkbox column +- Select-all checkbox selects all rows on the current page +- Bulk action toolbar appears above the table when rows are selected, showing count and action buttons + +**New prop**: +```tsx +type BulkAction = { + id: string + label: string + icon?: React.ComponentType + destructive?: boolean + onExecute: (selectedRows: T[]) => Promise | void +} + + +``` + +### F3 — Page Size Selector + +**Current state**: `pageSize` is set per-page (usually 20) with no UI to change it. Perspectives can persist it but there's no control. + +**Changes**: +- Add a page-size dropdown next to the pagination controls +- Options: 10, 25, 50, 100 (configurable via prop) +- Default to current page's `pageSize` value +- Changing page size resets to page 1 and re-fetches +- Persist selection in perspective if perspectives are active + +**New prop**: +```tsx + +``` + +### F4 — Advanced Filter Builder (AND/OR Logic) + +**Current state**: `FilterOverlay` renders a flat form with predefined typed filters. All conditions are implicitly AND. No dynamic add/remove. + +**Changes**: +- Replace or extend `FilterOverlay` with a new `AdvancedFilterBuilder` component +- Each row: `[Logic toggle (AND/OR)] [Field selector] [Operator selector] [Value input] [Delete]` +- Logic toggle: first row shows "Where", subsequent rows show "And ▼" / "Or ▼" (clickable to toggle) +- `+ Add filter` button to append a condition row +- Field selector: searchable dropdown listing all entity fields + custom fields +- Operator selector: context-aware operators based on field type: + +| Field type | Operators | +|------------|-----------| +| text/string | is, is not, contains, does not contain, starts with, ends with, is empty, is not empty | +| number | equals, not equals, greater than, less than, greater or equal, less or equal, between, is empty | +| date | is, is before, is after, between, is empty, is not empty | +| select/enum | is, is not, is any of, is none of, is empty | +| boolean | is true, is false | +| tags/multi | has any of, has all of, has none of, is empty | + +- Value input adapts to field type (text input, number input, date picker, select dropdown, multi-select) +- Filter state is ephemeral (not saved to perspectives per Q2 answer) +- Displayed as a popover/dropdown panel from the "Filter" button in the toolbar + +**New types**: +```tsx +type FilterCondition = { + id: string // unique row ID + field: string // field accessor key + operator: FilterOperator // type-aware operator + value: unknown // typed value +} + +type AdvancedFilterState = { + logic: 'and' | 'or' // global logic between conditions + conditions: FilterCondition[] +} + +type FilterFieldDef = { + key: string // field accessor + label: string // display name (i18n) + type: 'text' | 'number' | 'date' | 'select' | 'boolean' | 'tags' + group?: string // for grouping in field selector (e.g., "Custom Fields", "Contact Info") + loadOptions?: (query?: string) => Promise // for select/tags +} +``` + +**New prop**: +```tsx + +``` + +### F5 — Scalable Column Chooser with Search + +**Current state**: Perspectives sidebar shows a flat checkbox list with up/down arrows. Unusable at scale. + +**Changes**: +- New `ColumnChooser` panel (replaces the column section in PerspectiveSidebar) +- Opens as a side panel or dialog (not a dropdown — too small for hundreds of fields) +- **Search box** at the top for instant filtering of available columns +- **Grouped sections** (e.g., "Basic Info", "Contact", "Custom Fields", "Dates") — collapsible +- **Checkboxes** to toggle column visibility +- **Selected columns** section at the top showing active columns with drag handles for reorder +- **Auto-discovery**: columns are populated from entity schema + custom field definitions +- Each page declares a `columnDiscovery` config that maps entity fields to column definitions + +**New prop**: +```tsx +type ColumnChooserConfig = { + availableColumns: ColumnChooserField[] // all possible columns + // OR auto-discover from entityId: + entityId?: string // auto-load from entity schema + custom fields +} + +type ColumnChooserField = { + key: string // field accessor + label: string // display name + group: string // grouping category + defaultVisible?: boolean // shown by default + alwaysVisible?: boolean // cannot be hidden (e.g., name column) + columnDef: ColumnDef // TanStack column definition +} + + +``` + +### F6 — Sticky First Data Column & Table Margins + +**Changes**: +- The first data column (after the checkbox, if present) gets `position: sticky; left: 0; z-index: 10; background: bg-background` so it stays visible during horizontal scroll +- The checkbox column is NOT sticky — only the first data column (typically name/title) is pinned +- When columns are reordered via drag-and-drop, whichever column ends up in the first position automatically becomes the new sticky column +- Table container gets horizontal padding/margins (`mx-1 sm:mx-2`) so it doesn't touch screen edges +- Implemented via CSS — applied dynamically based on `headerIndex === 0` / `cellIndex === 0` + +**New prop** — opt-in via `stickyFirstColumn={true}`. Defaults to `false` so existing pages are unaffected. Enabled on customers people, companies, and deals pages. + +### F7 — Drag & Drop Column Reorder + +**Changes**: +- Column headers become drag handles (or a grip icon appears on hover) +- Uses `@dnd-kit/core` + `@dnd-kit/sortable` for accessible, performant DnD +- Reorder persists in component state; saved if perspectives are active +- Works alongside the column chooser (reorder in chooser panel OR directly on headers) + +**Implementation**: Wrap header row with `SortableContext` from dnd-kit; each header is a `useSortable` item. + +### F8 — Virtual Scrolling + +**Current state**: DataTable renders all rows in the DOM. With 50-100 rows and many columns (especially custom fields), this creates hundreds or thousands of DOM nodes, causing layout recalculation lag. + +**Changes**: +- Integrate `@tanstack/react-virtual` for row virtualization +- Only render rows visible in the viewport plus an overscan buffer (default 10 rows) +- Table body gets a fixed max height (configurable, default: fill available viewport) with vertical scroll +- Row heights are measured dynamically (not fixed) to support variable-height content +- Column virtualization for tables with 20+ visible columns: only render columns in the horizontal viewport +- Sticky checkbox column is excluded from column virtualization (always rendered) + +**New prop**: +```tsx + +``` + +**How it works**: +- `useVirtualizer` from `@tanstack/react-virtual` manages visible row range +- Table `` is a scroll container with `overflow-y: auto` and a spacer element for total height +- Only visible `` elements are mounted; rows outside viewport are unmounted +- For column virtualization: `useVirtualizer` on horizontal axis, sticky checkbox column excluded via `rangeExtractor` +- Compatible with sorting, filtering, bulk selection, pagination — virtualization is purely a rendering optimization + +**Performance targets**: +- 100 rows x 50 columns: smooth 60fps scroll +- 1000 rows (if page size is uncapped for export previews): no jank + +### F9 — Auto-Discovery of Filter Fields & Column Chooser Fields + +> Added 2026-04-04. Eliminates hardcoded per-page field arrays. + +**Problem**: Each list page required manually maintaining three parallel arrays — `advancedFilterFields`, `columnChooserFields`, and `columns` — that diverged over time. Custom fields and detail-page fields (e.g., "Legal Name", "Website") were missing from filters and column chooser. + +**Solution**: A `useAutoDiscoveredFields` hook in `packages/ui` derives both `AdvancedFilterFieldDef[]` and `ColumnChooserField[]` automatically from: +1. The DataTable `columns` prop (existing rendered columns with `accessorKey` + `header`) +2. Custom field definitions fetched via `useCustomFieldDefs(entityIds)` (all fields, not just filterable/listVisible) + +**Auto mode API**: +```tsx + +``` + +**Column metadata hints** (optional, via TanStack `ColumnMeta`): +```tsx +{ + accessorKey: 'status', + header: 'Status', + meta: { + filterType: 'select', // override auto-inferred type + filterOptions: statusOptions, // provide select options + columnChooserGroup: 'Basic Info', // grouping in column chooser + alwaysVisible: true, // cannot be hidden + }, +} +``` + +**Type mapping** (`CustomFieldDefDto.kind` → `FilterFieldType`): +| Kind | FilterFieldType | +|------|----------------| +| `text`, `multiline` | `text` | +| `select`, `dictionary`, `currency`, `relation` | `select` | +| `boolean` | `boolean` | +| `integer`, `float` | `number` | +| `date` | `date` | +| `attachment` | skipped | + +**Backward compatible**: Pages passing `{ fields: [...] }` or `{ availableColumns: [...] }` continue to work unchanged. + +### F10 — Full-Text Search Across All Text Columns + +> Added 2026-04-04. Search box was only querying `display_name`. + +**Problem**: The search box on list pages only filtered by the name column (`display_name` via `$ilike`). Users expected to find records by email, phone, or description. + +**Solution**: Server-side `buildFilters` in CRUD routes now queries multiple text columns via `$or`: +- **People/Companies**: `display_name`, `primary_email`, `primary_phone`, `description` +- **Deals**: `title`, `description` + +**Query engine enhancement**: Added `$or` support to `normalizeFilters` in `packages/shared/src/lib/query/join-utils.ts` and OR-group handling in the query engine. Filters with an `orGroup` marker are applied as `WHERE (col1 ILIKE ? OR col2 ILIKE ? OR ...)` instead of individual AND conditions. + +**Search placeholder** updated to "Search by name, email, phone…" to indicate broader scope. + +--- + +## Backward Compatibility + +All features are opt-in via new props. Existing pages that don't pass these props see zero behavior change. + +| Feature | Opt-in mechanism | Default | +|---------|-----------------|---------| +| Sorting | `sortable={true}` | `false` (unchanged) | +| Bulk actions | `bulkActions={[...]}` | `undefined` (unchanged) | +| Page size selector | `pagination.pageSizeOptions` | `undefined` (no selector) | +| Advanced filter | `advancedFilter={...}` | `undefined` (old FilterOverlay) | +| Column chooser | `columnChooser={...}` | `undefined` (old perspective sidebar) | +| Sticky first data column | `stickyFirstColumn={true}` | `false` (not sticky) | +| DnD column reorder | Enabled when `columnChooser` is set | Disabled | +| Virtual scrolling | `virtualized` | `false` | +| Auto-discovery | `advancedFilter.auto` / `columnChooser.auto` | Manual fields | +| Full-text search | Server-side `$or` in `buildFilters` | Name only | + +The existing `filters`/`filterValues`/`onFiltersApply`/`onFiltersClear` props remain functional for pages using the old filter model. The `advancedFilter` prop is a separate code path. + +--- + +## Data Flow + +``` +User interacts with filter/sort/page-size + → Component state updates (sorting, advancedFilterState, pageSize) + → Parent page re-fetches from API with query params: + ?sort=name&order=asc + &filter[logic]=and + &filter[conditions][0][field]=status&filter[conditions][0][op]=is&filter[conditions][0][value]=active + &filter[conditions][1][field]=created_at&filter[conditions][1][op]=after&filter[conditions][1][value]=2026-01-01 + &pageSize=50&page=1 + → API applies conditions server-side + → Response rendered in DataTable +``` + +**Column chooser flow**: +``` +User opens column chooser + → Panel shows all available columns (from entity schema + custom fields) + → User searches, checks/unchecks columns + → Column visibility state updates + → DataTable re-renders with selected columns + → If perspectives active, state is auto-saved +``` + +--- + +## Implementation Plan + +### Phase 1 — DataTable Core Enhancements (packages/ui) + +**Step 1.1 — Table margins and layout cleanup** +- Add consistent horizontal padding to `DataTable` container +- Ensure table doesn't extend to screen edges +- Verify no visual regressions across existing pages + +**Step 1.2 — Sorting activation and improved indicators** +- Update sort indicator to always show muted arrows, highlight active direction +- No DataTable code change needed for the indicator beyond CSS +- Wire `sortable`, `sorting`, `onSortingChange` on customers people, companies, and deals pages +- Map sorting state to API query params in each page's fetch logic + +**Step 1.3 — Page size selector** +- Add `pageSizeOptions` and `onPageSizeChange` to `PaginationProps` +- Render a select dropdown next to pagination controls +- Reset to page 1 on page size change +- Wire on customers people, companies, and deals pages + +**Step 1.4 — Configurable bulk actions** +- Add `bulkActions` prop to `DataTable` +- Show checkbox column when `bulkActions` is provided (merge with existing injection-based logic) +- Render bulk action toolbar above table when rows are selected +- Wire "Delete selected" bulk action on customers people, companies, and deals pages +- Ensure confirmation dialog before destructive bulk actions + +**Step 1.5 — Virtual scrolling** +- Install `@tanstack/react-virtual` +- Create `VirtualizedTableBody` component that replaces the standard `` when `virtualized` is true +- Implement row virtualization with `useVirtualizer` — scroll container with measured row heights +- Implement column virtualization for wide tables (20+ columns) — exclude sticky columns from virtual range +- Add `virtualized`, `virtualizedMaxHeight`, `virtualizedOverscan` props to DataTable +- Enable on customers people, companies, and deals pages +- Verify compatibility with sorting, bulk selection, sticky column, and DnD reorder + +### Phase 2 — Advanced Filter Builder (packages/ui) + +**Step 2.1 — FilterCondition types and state management** +- Define `FilterCondition`, `AdvancedFilterState`, `FilterFieldDef` types in `packages/shared` +- Create `useAdvancedFilter` hook for state management (add/remove/update conditions, toggle logic) + +**Step 2.2 — AdvancedFilterBuilder component** +- Build the filter builder UI component matching the reference screenshot +- Row layout: `[Logic] [Field ▼] [Operator ▼] [Value input] [🗑]` +- First row: "Where" label; subsequent rows: "And/Or" toggle +- `+ Add filter` button +- Field selector: searchable dropdown with grouping +- Operator selector: adapts to selected field type +- Value input: adapts to field type (text, number, date picker, select, multi-select) + +**Step 2.3 — Wire advanced filter to DataTable** +- Add `advancedFilter` prop to DataTable +- Render filter button in toolbar that opens the builder as a popover panel +- Show active filter count badge on the button +- Display active filter summary chips below the toolbar + +**Step 2.4 — API query parameter contract** +- Define how advanced filter state serializes to API query params +- Implement server-side parsing in the CRUD factory / query engine +- Support AND/OR logic in query construction +- Wire on customers people, companies, and deals pages + +### Phase 3 — Column Chooser & Reorder (packages/ui) + +**Step 3.1 — Column auto-discovery infrastructure** +- Create `useEntityColumns` hook that loads available columns from entity schema + custom field definitions +- Map entity fields to `ColumnChooserField` with type, label, group +- Support custom field groups (fieldsets) as column groups + +**Step 3.2 — ColumnChooser panel component** +- Build side panel / dialog UI +- Search box at top with instant filtering +- Grouped, collapsible sections +- Checkboxes for visibility toggle +- "Selected columns" section at top with drag handles +- "Select all" / "Deselect all" per group + +**Step 3.3 — Drag & drop column reorder** +- Install `@dnd-kit/core` + `@dnd-kit/sortable` +- Implement DnD on column headers (grip handle on hover) +- Implement DnD in the column chooser "selected columns" list +- Persist order in component state; sync with perspectives if active + +**Step 3.4 — Sticky checkbox column** +- Apply `position: sticky; left: 0; z-index: 1` to the checkbox column ``/`` when `bulkActions` is provided +- Add subtle right border shadow on the sticky column when table is scrolled horizontally +- Ensure sticky works with horizontal overflow on the table container +- No separate prop — automatic when checkboxes are present + +**Step 3.5 — Wire column chooser on customers pages** +- Define full column sets for people, companies, and deals (all entity fields + custom fields) +- Enable `columnChooser` prop +- Verify drag & drop reorder works end-to-end + +### Phase 4 — Polish & Integration Testing + +**Step 4.1 — Responsive behavior** +- Ensure filter builder works on smaller screens (stack layout) +- Column chooser panel responsive behavior +- Page size selector placement on narrow viewports + +**Step 4.2 — i18n** +- Add translation keys for all new UI strings (filter operators, column chooser labels, bulk action labels) +- Verify translations work with `useT()` + +**Step 4.3 — Integration tests** +- Test sorting: click header → verify sort order changes, API called with sort params +- Test bulk actions: select rows → execute action → verify operation and selection clear +- Test page size: change size → verify re-fetch with new size +- Test advanced filter: add conditions with AND/OR → verify results match +- Test column chooser: search, toggle columns, verify table updates +- Test drag & drop: reorder columns, verify new order persists +- Test sticky column: horizontal scroll, verify first column stays visible +- Test virtual scrolling: scroll through 100 rows, verify no missing rows, correct row content after scroll + +--- + +## Risks & Impact Review + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Advanced filter API query format is a new contract surface | Medium | Define a clear, versioned query param schema; keep backward compat with existing `?status=active` style filters | +| `@dnd-kit` adds bundle weight (~15-20KB gzipped) | Low | Tree-shakable; only loaded on pages using DnD | +| Column auto-discovery may expose internal fields | Medium | Whitelist mechanism: pages declare which entity fields are column-eligible | +| Sticky column CSS conflicts with existing table styles | Low | Scoped via DataTable-specific class names; test across all pages | +| Server-side AND/OR filter parsing complexity | Medium | Limit to flat conditions (no nested groups); validate condition count (max 20) | +| Bulk delete of many records could timeout | Medium | Use background job for large selections; show progress feedback | +| Virtual scrolling breaks accessibility (screen readers) | Medium | Keep semantic `` structure; use `aria-rowcount`/`aria-rowindex` on virtualized rows; test with VoiceOver | +| Variable row heights cause scroll jumpiness | Low | Use `measureElement` for dynamic measurement; overscan buffer smooths edge cases | +| Auto-discovery exposes all custom fields including internal ones | Low | All fields come from the tenant-scoped definitions API; no system/internal fields exposed | +| `$or` query engine support may have edge cases | Medium | Limited to flat OR groups; validated via `orGroup` marker; only applied to resolved base columns | +| Full-text search on multiple columns may be slow without indexes | Medium | Fields searched are standard indexed columns; custom fields use the search token system | + +--- + +## Out of Scope + +- Infinite scroll (continuous loading without pagination) +- Nested filter groups (AND within OR) — flat conditions only for now +- Column pinning (beyond sticky first column) +- Inline cell editing +- Excel export +- Filter persistence in perspectives (explicitly deferred per requirements) +- Drag & drop row reorder + +--- + +## Dependencies + +| Dependency | Version | License | Size | Purpose | +|------------|---------|---------|------|---------| +| `@dnd-kit/core` | ^6.x | MIT | ~12KB gzip | DnD foundation | +| `@dnd-kit/sortable` | ^8.x | MIT | ~5KB gzip | Sortable preset for column reorder | +| `@dnd-kit/utilities` | ^3.x | MIT | ~2KB gzip | DnD utility hooks | +| `@tanstack/react-virtual` | ^3.x | MIT | ~2KB gzip | Row and column virtualization | + +No other new dependencies. All other features built on existing TanStack React Table APIs. + +--- + +## Implementation Status + +| Phase | Status | Date | Notes | +|-------|--------|------|-------| +| Phase 1 — DataTable Core Enhancements | Done | 2026-04-03 | Margins, sorting indicators, page size selector, bulk actions, virtual scrolling | +| Phase 2 — Advanced Filter Builder | Done | 2026-04-03 | Types, UI component, server-side parser, wired on customer pages | +| Phase 3 — Column Chooser & DnD Reorder | Done | 2026-04-03 | ColumnChooserPanel, header DnD, sticky first data column, wired on customer pages | +| Phase 4 — Polish & Integration Testing | Done | 2026-04-03 | i18n keys, build verified, all features wired end-to-end | +| Phase 5 — Auto-Discovery & Full-Text Search | Done | 2026-04-04 | useAutoDiscoveredFields hook, auto mode props, $or query engine support, multi-field search | +| Phase 6 — UX Bug Fixes | Done | 2026-04-04 | Sticky column corrected, bulk action visibility, column chooser dedup, filter UX, page size dropdown | + +### Phase 1 — Detailed Progress +- [x] Step 1.1: Table margins and layout cleanup +- [x] Step 1.2: Sorting activation and improved indicators (always-visible muted arrows) +- [x] Step 1.3: Page size selector (pageSizeOptions + onPageSizeChange) +- [x] Step 1.4: Configurable bulk actions (bulkActions prop + selected count display) +- [x] Step 1.5: Virtual scrolling (@tanstack/react-virtual, row virtualization with spacer rows) + +### Phase 2 — Detailed Progress +- [x] Step 2.1: FilterCondition types and state management (packages/shared) +- [x] Step 2.2: AdvancedFilterBuilder component with condition rows +- [x] Step 2.3: Wire advanced filter to DataTable (toggle button, filter panel, active count badge) +- [x] Step 2.4: Server-side query parameter parsing (deserialize + convert to Where) +- [x] Step 2.5: Wire advancedFilter on people, companies, deals pages + +### Phase 3 — Detailed Progress +- [x] Step 3.1: ColumnChooserPanel with search, grouping, checkboxes +- [x] Step 3.2: Drag & drop column reorder via @dnd-kit (in chooser panel + table headers) +- [x] Step 3.3: Sticky first data column (not checkbox) — follows reorder +- [x] Step 3.4: Wire column chooser on customer pages (people, companies, deals) +- [x] Step 3.5: DnD column reorder on table headers via SortableHeaderCell + HeaderDndWrapper + +### Phase 4 — Detailed Progress +- [x] Step 4.1: i18n keys for all new UI strings (en.json) +- [ ] Step 4.2: Integration tests (deferred to separate PR) +- [x] Step 4.3: Build verification — all 18 packages pass +- [x] Step 4.4: Virtual scrolling enabled on customer pages + +### Phase 5 — Auto-Discovery & Full-Text Search (added 2026-04-04) +- [x] Step 5.1: `buildAdvancedFilterFieldsFromCustomFields()` in customFieldFilters.ts +- [x] Step 5.2: `useAutoDiscoveredFields` hook — derives filter + column chooser fields from columns + custom field defs +- [x] Step 5.3: Extended DataTable props with `{ auto: true }` mode for advancedFilter and columnChooser +- [x] Step 5.4: Migrated people, companies, deals pages to auto mode (deleted hardcoded arrays, added column meta hints) +- [x] Step 5.5: Added `$or` support to query engine normalizer + engine (orGroup-based WHERE ... OR ...) +- [x] Step 5.6: Multi-field search in CRUD routes (people/companies: name+email+phone+description; deals: title+description) +- [x] Step 5.7: Updated search placeholders to indicate broader scope + +### Phase 6 — UX Bug Fixes (added 2026-04-04) +- [x] Step 6.1: Sticky column corrected — first data column, not checkbox; follows DnD reorder +- [x] Step 6.2: Bulk action buttons hidden when no rows selected (was: disabled but visible) +- [x] Step 6.3: Column chooser dedup — available section excludes already-selected columns +- [x] Step 6.4: Advanced filter opens with pre-populated condition row (was: empty requiring extra click) +- [x] Step 6.5: "Select field..." placeholder disabled in filter dropdown (was: selectable) +- [x] Step 6.6: All custom field defs included in auto-discovery (was: only filterable/listVisible) +- [x] Step 6.7: Page size dropdown spacing fixed (was: text and arrow overlapping) +- [x] Step 6.8: DnD column reorder state persistence fixed (was: snapping back to original position) +- [x] Step 6.9: DndContext moved outside `
    ` to prevent invalid `
    ` inside `
    ` hydration errors + +--- + +## Changelog + +| Date | Change | +|------|--------| +| 2026-04-03 | Initial skeleton with open questions | +| 2026-04-03 | Full spec after Q&A — 4 phases, 8 features, UX designer notes incorporated | +| 2026-04-03 | Added F8 — virtual scrolling via `@tanstack/react-virtual` (row + column virtualization) | +| 2026-04-03 | Implementation: Phase 1-3 complete, Phase 4 i18n done | +| 2026-04-03 | All phases complete: server-side filter parsing, column chooser + DnD on pages, advanced filter on pages, virtual scrolling enabled | +| 2026-04-04 | Added F9 (auto-discovery) and F10 (full-text search). Post-implementation requirements documented. | +| 2026-04-04 | Phase 5: useAutoDiscoveredFields hook, auto mode props, $or query engine support, customer pages migrated to auto mode | +| 2026-04-04 | Phase 6: UX bug fixes — sticky column, bulk action visibility, column chooser dedup, filter UX, page size dropdown, DnD persistence, hydration fix | diff --git a/apps/mercato/src/i18n/de.json b/apps/mercato/src/i18n/de.json index c47791198e3..1d9454d4fce 100644 --- a/apps/mercato/src/i18n/de.json +++ b/apps/mercato/src/i18n/de.json @@ -622,9 +622,62 @@ "ui.actions.refresh": "Aktualisieren", "ui.actions.save": "Speichern", "ui.actions.saveShortcut": "Speichern (⌘/Strg+Eingabe)", + "ui.advancedFilter.activeCount": "{count} active filters", + "ui.advancedFilter.addFilter": "Add filter", + "ui.advancedFilter.and": "And", + "ui.advancedFilter.apply": "Apply", + "ui.advancedFilter.clear": "Clear", + "ui.advancedFilter.clearAll": "Clear all", + "ui.advancedFilter.dateValue": "Date value", + "ui.advancedFilter.edit": "Edit", + "ui.advancedFilter.noConditions": "No filter conditions. Click \"Add filter\" to start.", + "ui.advancedFilter.numberPlaceholder": "Value", + "ui.advancedFilter.numberValue": "Number value", + "ui.advancedFilter.operator.between": "between", + "ui.advancedFilter.operator.contains": "contains", + "ui.advancedFilter.operator.does_not_contain": "does not contain", + "ui.advancedFilter.operator.ends_with": "ends with", + "ui.advancedFilter.operator.equals": "equals", + "ui.advancedFilter.operator.greater_or_equal": "greater or equal", + "ui.advancedFilter.operator.greater_than": "greater than", + "ui.advancedFilter.operator.has_all_of": "has all of", + "ui.advancedFilter.operator.has_any_of": "has any of", + "ui.advancedFilter.operator.has_none_of": "has none of", + "ui.advancedFilter.operator.is": "is", + "ui.advancedFilter.operator.is_after": "is after", + "ui.advancedFilter.operator.is_any_of": "is any of", + "ui.advancedFilter.operator.is_before": "is before", + "ui.advancedFilter.operator.is_empty": "is empty", + "ui.advancedFilter.operator.is_false": "is false", + "ui.advancedFilter.operator.is_none_of": "is none of", + "ui.advancedFilter.operator.is_not": "is not", + "ui.advancedFilter.operator.is_not_empty": "is not empty", + "ui.advancedFilter.operator.is_true": "is true", + "ui.advancedFilter.operator.less_or_equal": "less or equal", + "ui.advancedFilter.operator.less_than": "less than", + "ui.advancedFilter.operator.not_equals": "not equals", + "ui.advancedFilter.operator.starts_with": "starts with", + "ui.advancedFilter.or": "Or", + "ui.advancedFilter.removeCondition": "Remove condition", + "ui.advancedFilter.selectField": "Select field", + "ui.advancedFilter.selectFieldPlaceholder": "Select field...", + "ui.advancedFilter.selectOperator": "Select operator", + "ui.advancedFilter.selectValue": "Select value", + "ui.advancedFilter.selectValuePlaceholder": "Select...", + "ui.advancedFilter.textPlaceholder": "Value...", + "ui.advancedFilter.textValue": "Text value", + "ui.advancedFilter.toggle": "Advanced filters", + "ui.advancedFilter.where": "Where", "ui.badges.severity.high": "Hoch", "ui.badges.severity.low": "Niedrig", "ui.badges.severity.medium": "Mittel", + "ui.columnChooser.available": "Available columns", + "ui.columnChooser.close": "Close", + "ui.columnChooser.search": "Search columns...", + "ui.columnChooser.selected": "Selected columns", + "ui.columnChooser.title": "Columns", + "ui.columnChooser.toggle": "Choose columns", + "ui.columnChooser.ungrouped": "Other", "ui.contextHelp.hide": "Ausblenden", "ui.contextHelp.show": "Anzeigen", "ui.dataLoader.loading": "Wird geladen...", @@ -632,6 +685,7 @@ "ui.dataTable.bulkAction.error": "Bulk action failed.", "ui.dataTable.bulkAction.selectAll": "Select all", "ui.dataTable.bulkAction.selectRow": "Select row", + "ui.dataTable.bulkAction.selectedCount": "{count} selected", "ui.dataTable.bulkAction.started": "Massenaktion gestartet. Fortschritt in der oberen Leiste verfolgen.", "ui.dataTable.bulkAction.success": "Bulk action completed.", "ui.dataTable.customizeColumns.ariaLabel": "Spalten anpassen", @@ -648,10 +702,12 @@ "ui.dataTable.pagination.next": "Weiter", "ui.dataTable.pagination.nextAriaLabel": "Zur nächsten Seite", "ui.dataTable.pagination.pageInfo": "Seite {page} von {totalPages}", + "ui.dataTable.pagination.perPage": "per page", "ui.dataTable.pagination.previous": "Zurück", "ui.dataTable.pagination.previousAriaLabel": "Zur vorherigen Seite", "ui.dataTable.pagination.results": "Zeige {start} bis {end} von {total} Ergebnissen", "ui.dataTable.pagination.resultsWithDuration": "Zeige {start} bis {end} von {total} Ergebnissen in {duration}", + "ui.dataTable.pagination.rowsPerPage": "Rows per page", "ui.dataTable.perspectives.button": "Perspektiven", "ui.dataTable.perspectives.error.apiUnavailable": "Perspektiven-API ist nicht verfügbar. Führen Sie `npm run modules:prepare` aus und starten Sie den Entwicklungsserver neu.", "ui.dataTable.perspectives.error.clearRoles": "Rollenperspektiven konnten nicht gelöscht werden", diff --git a/apps/mercato/src/i18n/en.json b/apps/mercato/src/i18n/en.json index 4a4e3148766..a843c1179ab 100644 --- a/apps/mercato/src/i18n/en.json +++ b/apps/mercato/src/i18n/en.json @@ -622,9 +622,62 @@ "ui.actions.refresh": "Refresh", "ui.actions.save": "Save", "ui.actions.saveShortcut": "Save (⌘/Ctrl+Enter)", + "ui.advancedFilter.activeCount": "{count} active filters", + "ui.advancedFilter.addFilter": "Add filter", + "ui.advancedFilter.and": "And", + "ui.advancedFilter.apply": "Apply", + "ui.advancedFilter.clear": "Clear", + "ui.advancedFilter.clearAll": "Clear all", + "ui.advancedFilter.dateValue": "Date value", + "ui.advancedFilter.edit": "Edit", + "ui.advancedFilter.noConditions": "No filter conditions. Click \"Add filter\" to start.", + "ui.advancedFilter.numberPlaceholder": "Value", + "ui.advancedFilter.numberValue": "Number value", + "ui.advancedFilter.operator.between": "between", + "ui.advancedFilter.operator.contains": "contains", + "ui.advancedFilter.operator.does_not_contain": "does not contain", + "ui.advancedFilter.operator.ends_with": "ends with", + "ui.advancedFilter.operator.equals": "equals", + "ui.advancedFilter.operator.greater_or_equal": "greater or equal", + "ui.advancedFilter.operator.greater_than": "greater than", + "ui.advancedFilter.operator.has_all_of": "has all of", + "ui.advancedFilter.operator.has_any_of": "has any of", + "ui.advancedFilter.operator.has_none_of": "has none of", + "ui.advancedFilter.operator.is": "is", + "ui.advancedFilter.operator.is_after": "is after", + "ui.advancedFilter.operator.is_any_of": "is any of", + "ui.advancedFilter.operator.is_before": "is before", + "ui.advancedFilter.operator.is_empty": "is empty", + "ui.advancedFilter.operator.is_false": "is false", + "ui.advancedFilter.operator.is_none_of": "is none of", + "ui.advancedFilter.operator.is_not": "is not", + "ui.advancedFilter.operator.is_not_empty": "is not empty", + "ui.advancedFilter.operator.is_true": "is true", + "ui.advancedFilter.operator.less_or_equal": "less or equal", + "ui.advancedFilter.operator.less_than": "less than", + "ui.advancedFilter.operator.not_equals": "not equals", + "ui.advancedFilter.operator.starts_with": "starts with", + "ui.advancedFilter.or": "Or", + "ui.advancedFilter.removeCondition": "Remove condition", + "ui.advancedFilter.selectField": "Select field", + "ui.advancedFilter.selectFieldPlaceholder": "Select field...", + "ui.advancedFilter.selectOperator": "Select operator", + "ui.advancedFilter.selectValue": "Select value", + "ui.advancedFilter.selectValuePlaceholder": "Select...", + "ui.advancedFilter.textPlaceholder": "Value...", + "ui.advancedFilter.textValue": "Text value", + "ui.advancedFilter.toggle": "Advanced filters", + "ui.advancedFilter.where": "Where", "ui.badges.severity.high": "High", "ui.badges.severity.low": "Low", "ui.badges.severity.medium": "Medium", + "ui.columnChooser.available": "Available columns", + "ui.columnChooser.close": "Close", + "ui.columnChooser.search": "Search columns...", + "ui.columnChooser.selected": "Selected columns", + "ui.columnChooser.title": "Columns", + "ui.columnChooser.toggle": "Choose columns", + "ui.columnChooser.ungrouped": "Other", "ui.contextHelp.hide": "Hide", "ui.contextHelp.show": "Show", "ui.dataLoader.loading": "Loading...", @@ -632,6 +685,7 @@ "ui.dataTable.bulkAction.error": "Bulk action failed.", "ui.dataTable.bulkAction.selectAll": "Select all", "ui.dataTable.bulkAction.selectRow": "Select row", + "ui.dataTable.bulkAction.selectedCount": "{count} selected", "ui.dataTable.bulkAction.started": "Bulk action started. Track progress in the top bar.", "ui.dataTable.bulkAction.success": "Bulk action completed.", "ui.dataTable.customizeColumns.ariaLabel": "Customize columns", @@ -648,10 +702,12 @@ "ui.dataTable.pagination.next": "Next", "ui.dataTable.pagination.nextAriaLabel": "Go to next page", "ui.dataTable.pagination.pageInfo": "Page {page} of {totalPages}", + "ui.dataTable.pagination.perPage": "per page", "ui.dataTable.pagination.previous": "Previous", "ui.dataTable.pagination.previousAriaLabel": "Go to previous page", "ui.dataTable.pagination.results": "Showing {start} to {end} of {total} results", "ui.dataTable.pagination.resultsWithDuration": "Showing {start} to {end} of {total} results in {duration}", + "ui.dataTable.pagination.rowsPerPage": "Rows per page", "ui.dataTable.perspectives.button": "Perspectives", "ui.dataTable.perspectives.error.apiUnavailable": "Perspectives API is not available. Run `npm run modules:prepare` and restart the dev server.", "ui.dataTable.perspectives.error.clearRoles": "Failed to clear role perspectives", diff --git a/apps/mercato/src/i18n/es.json b/apps/mercato/src/i18n/es.json index 2bf69f1c82d..e23a5b5ebda 100644 --- a/apps/mercato/src/i18n/es.json +++ b/apps/mercato/src/i18n/es.json @@ -622,9 +622,62 @@ "ui.actions.refresh": "Actualizar", "ui.actions.save": "Guardar", "ui.actions.saveShortcut": "Guardar (⌘/Ctrl+Enter)", + "ui.advancedFilter.activeCount": "{count} active filters", + "ui.advancedFilter.addFilter": "Add filter", + "ui.advancedFilter.and": "And", + "ui.advancedFilter.apply": "Apply", + "ui.advancedFilter.clear": "Clear", + "ui.advancedFilter.clearAll": "Clear all", + "ui.advancedFilter.dateValue": "Date value", + "ui.advancedFilter.edit": "Edit", + "ui.advancedFilter.noConditions": "No filter conditions. Click \"Add filter\" to start.", + "ui.advancedFilter.numberPlaceholder": "Value", + "ui.advancedFilter.numberValue": "Number value", + "ui.advancedFilter.operator.between": "between", + "ui.advancedFilter.operator.contains": "contains", + "ui.advancedFilter.operator.does_not_contain": "does not contain", + "ui.advancedFilter.operator.ends_with": "ends with", + "ui.advancedFilter.operator.equals": "equals", + "ui.advancedFilter.operator.greater_or_equal": "greater or equal", + "ui.advancedFilter.operator.greater_than": "greater than", + "ui.advancedFilter.operator.has_all_of": "has all of", + "ui.advancedFilter.operator.has_any_of": "has any of", + "ui.advancedFilter.operator.has_none_of": "has none of", + "ui.advancedFilter.operator.is": "is", + "ui.advancedFilter.operator.is_after": "is after", + "ui.advancedFilter.operator.is_any_of": "is any of", + "ui.advancedFilter.operator.is_before": "is before", + "ui.advancedFilter.operator.is_empty": "is empty", + "ui.advancedFilter.operator.is_false": "is false", + "ui.advancedFilter.operator.is_none_of": "is none of", + "ui.advancedFilter.operator.is_not": "is not", + "ui.advancedFilter.operator.is_not_empty": "is not empty", + "ui.advancedFilter.operator.is_true": "is true", + "ui.advancedFilter.operator.less_or_equal": "less or equal", + "ui.advancedFilter.operator.less_than": "less than", + "ui.advancedFilter.operator.not_equals": "not equals", + "ui.advancedFilter.operator.starts_with": "starts with", + "ui.advancedFilter.or": "Or", + "ui.advancedFilter.removeCondition": "Remove condition", + "ui.advancedFilter.selectField": "Select field", + "ui.advancedFilter.selectFieldPlaceholder": "Select field...", + "ui.advancedFilter.selectOperator": "Select operator", + "ui.advancedFilter.selectValue": "Select value", + "ui.advancedFilter.selectValuePlaceholder": "Select...", + "ui.advancedFilter.textPlaceholder": "Value...", + "ui.advancedFilter.textValue": "Text value", + "ui.advancedFilter.toggle": "Advanced filters", + "ui.advancedFilter.where": "Where", "ui.badges.severity.high": "Alto", "ui.badges.severity.low": "Bajo", "ui.badges.severity.medium": "Medio", + "ui.columnChooser.available": "Available columns", + "ui.columnChooser.close": "Close", + "ui.columnChooser.search": "Search columns...", + "ui.columnChooser.selected": "Selected columns", + "ui.columnChooser.title": "Columns", + "ui.columnChooser.toggle": "Choose columns", + "ui.columnChooser.ungrouped": "Other", "ui.contextHelp.hide": "Ocultar", "ui.contextHelp.show": "Mostrar", "ui.dataLoader.loading": "Cargando...", @@ -632,6 +685,7 @@ "ui.dataTable.bulkAction.error": "Bulk action failed.", "ui.dataTable.bulkAction.selectAll": "Select all", "ui.dataTable.bulkAction.selectRow": "Select row", + "ui.dataTable.bulkAction.selectedCount": "{count} selected", "ui.dataTable.bulkAction.started": "Acción masiva iniciada. Siga el progreso en la barra superior.", "ui.dataTable.bulkAction.success": "Bulk action completed.", "ui.dataTable.customizeColumns.ariaLabel": "Personalizar columnas", @@ -648,10 +702,12 @@ "ui.dataTable.pagination.next": "Siguiente", "ui.dataTable.pagination.nextAriaLabel": "Ir a la página siguiente", "ui.dataTable.pagination.pageInfo": "Página {page} de {totalPages}", + "ui.dataTable.pagination.perPage": "per page", "ui.dataTable.pagination.previous": "Anterior", "ui.dataTable.pagination.previousAriaLabel": "Ir a la página anterior", "ui.dataTable.pagination.results": "Mostrando {start} a {end} de {total} resultados", "ui.dataTable.pagination.resultsWithDuration": "Mostrando {start} a {end} de {total} resultados en {duration}", + "ui.dataTable.pagination.rowsPerPage": "Rows per page", "ui.dataTable.perspectives.button": "Perspectivas", "ui.dataTable.perspectives.error.apiUnavailable": "La API de perspectivas no está disponible. Ejecute `npm run modules:prepare` y reinicie el servidor de desarrollo.", "ui.dataTable.perspectives.error.clearRoles": "Error al limpiar perspectivas de rol", diff --git a/apps/mercato/src/i18n/pl.json b/apps/mercato/src/i18n/pl.json index e491f2b82c0..35263ee8312 100644 --- a/apps/mercato/src/i18n/pl.json +++ b/apps/mercato/src/i18n/pl.json @@ -622,9 +622,62 @@ "ui.actions.refresh": "Odśwież", "ui.actions.save": "Zapisz", "ui.actions.saveShortcut": "Zapisz (⌘/Ctrl+Enter)", + "ui.advancedFilter.activeCount": "{count} active filters", + "ui.advancedFilter.addFilter": "Add filter", + "ui.advancedFilter.and": "And", + "ui.advancedFilter.apply": "Apply", + "ui.advancedFilter.clear": "Clear", + "ui.advancedFilter.clearAll": "Clear all", + "ui.advancedFilter.dateValue": "Date value", + "ui.advancedFilter.edit": "Edit", + "ui.advancedFilter.noConditions": "No filter conditions. Click \"Add filter\" to start.", + "ui.advancedFilter.numberPlaceholder": "Value", + "ui.advancedFilter.numberValue": "Number value", + "ui.advancedFilter.operator.between": "between", + "ui.advancedFilter.operator.contains": "contains", + "ui.advancedFilter.operator.does_not_contain": "does not contain", + "ui.advancedFilter.operator.ends_with": "ends with", + "ui.advancedFilter.operator.equals": "equals", + "ui.advancedFilter.operator.greater_or_equal": "greater or equal", + "ui.advancedFilter.operator.greater_than": "greater than", + "ui.advancedFilter.operator.has_all_of": "has all of", + "ui.advancedFilter.operator.has_any_of": "has any of", + "ui.advancedFilter.operator.has_none_of": "has none of", + "ui.advancedFilter.operator.is": "is", + "ui.advancedFilter.operator.is_after": "is after", + "ui.advancedFilter.operator.is_any_of": "is any of", + "ui.advancedFilter.operator.is_before": "is before", + "ui.advancedFilter.operator.is_empty": "is empty", + "ui.advancedFilter.operator.is_false": "is false", + "ui.advancedFilter.operator.is_none_of": "is none of", + "ui.advancedFilter.operator.is_not": "is not", + "ui.advancedFilter.operator.is_not_empty": "is not empty", + "ui.advancedFilter.operator.is_true": "is true", + "ui.advancedFilter.operator.less_or_equal": "less or equal", + "ui.advancedFilter.operator.less_than": "less than", + "ui.advancedFilter.operator.not_equals": "not equals", + "ui.advancedFilter.operator.starts_with": "starts with", + "ui.advancedFilter.or": "Or", + "ui.advancedFilter.removeCondition": "Remove condition", + "ui.advancedFilter.selectField": "Select field", + "ui.advancedFilter.selectFieldPlaceholder": "Select field...", + "ui.advancedFilter.selectOperator": "Select operator", + "ui.advancedFilter.selectValue": "Select value", + "ui.advancedFilter.selectValuePlaceholder": "Select...", + "ui.advancedFilter.textPlaceholder": "Value...", + "ui.advancedFilter.textValue": "Text value", + "ui.advancedFilter.toggle": "Advanced filters", + "ui.advancedFilter.where": "Where", "ui.badges.severity.high": "Wysoki", "ui.badges.severity.low": "Niski", "ui.badges.severity.medium": "Średni", + "ui.columnChooser.available": "Available columns", + "ui.columnChooser.close": "Close", + "ui.columnChooser.search": "Search columns...", + "ui.columnChooser.selected": "Selected columns", + "ui.columnChooser.title": "Columns", + "ui.columnChooser.toggle": "Choose columns", + "ui.columnChooser.ungrouped": "Other", "ui.contextHelp.hide": "Ukryj", "ui.contextHelp.show": "Pokaż", "ui.dataLoader.loading": "Ładowanie...", @@ -632,6 +685,7 @@ "ui.dataTable.bulkAction.error": "Bulk action failed.", "ui.dataTable.bulkAction.selectAll": "Select all", "ui.dataTable.bulkAction.selectRow": "Select row", + "ui.dataTable.bulkAction.selectedCount": "{count} selected", "ui.dataTable.bulkAction.started": "Akcja zbiorcza rozpoczęta. Śledź postęp na górnym pasku.", "ui.dataTable.bulkAction.success": "Bulk action completed.", "ui.dataTable.customizeColumns.ariaLabel": "Dostosuj kolumny", @@ -648,10 +702,12 @@ "ui.dataTable.pagination.next": "Następna", "ui.dataTable.pagination.nextAriaLabel": "Przejdź do następnej strony", "ui.dataTable.pagination.pageInfo": "Strona {page} z {totalPages}", + "ui.dataTable.pagination.perPage": "per page", "ui.dataTable.pagination.previous": "Poprzednia", "ui.dataTable.pagination.previousAriaLabel": "Przejdź do poprzedniej strony", "ui.dataTable.pagination.results": "Wyświetlanie {start} do {end} z {total} wyników", "ui.dataTable.pagination.resultsWithDuration": "Wyświetlanie {start} do {end} z {total} wyników w {duration}", + "ui.dataTable.pagination.rowsPerPage": "Rows per page", "ui.dataTable.perspectives.button": "Perspektywy", "ui.dataTable.perspectives.error.apiUnavailable": "API perspektyw nie jest dostępne. Uruchom `npm run modules:prepare` i zrestartuj serwer deweloperski.", "ui.dataTable.perspectives.error.clearRoles": "Nie udało się wyczyścić perspektyw ról", diff --git a/packages/core/src/modules/core/__integration__/integration/TC-INT-002.spec.ts b/packages/core/src/modules/core/__integration__/integration/TC-INT-002.spec.ts index 9283545fbf2..6cf3cbe7ba1 100644 --- a/packages/core/src/modules/core/__integration__/integration/TC-INT-002.spec.ts +++ b/packages/core/src/modules/core/__integration__/integration/TC-INT-002.spec.ts @@ -66,7 +66,7 @@ test.describe('TC-INT-002: Customer to Deal to Quote to Order Flow', () => { await page.getByRole('button', { name: 'Create deal' }).first().click(); await expect(page).toHaveURL(/\/backend\/customers\/deals$/i); - await page.getByRole('textbox', { name: /Search deals/i }).fill(dealTitle); + await page.getByPlaceholder(/Search by title/i).fill(dealTitle); await page.locator('tr').filter({ hasText: dealTitle }).first().click(); await expect(page).toHaveURL(/\/backend\/customers\/deals\/[0-9a-f-]{36}$/i); dealId = page.url().match(/\/backend\/customers\/deals\/([0-9a-f-]{36})$/i)?.[1] ?? null; diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-001.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-001.spec.ts index d12b7b7fc5f..e186081f53d 100644 --- a/packages/core/src/modules/customers/__integration__/TC-CRM-001.spec.ts +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-001.spec.ts @@ -61,7 +61,7 @@ test.describe('TC-CRM-001: Company Creation', () => { if (await contactDialog.count()) { await contactDialog.getByRole('button', { name: 'Close' }).click().catch(() => {}); } - const searchInput = page.getByRole('textbox', { name: /Search companies/i }); + const searchInput = page.getByPlaceholder(/Search by name/i); await searchInput.fill(companyName); await page.waitForTimeout(1200); await expect diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-007.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-007.spec.ts index 2aa476992f5..7b9b38470ad 100644 --- a/packages/core/src/modules/customers/__integration__/TC-CRM-007.spec.ts +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-007.spec.ts @@ -45,7 +45,7 @@ test.describe('TC-CRM-007: Create Deal', () => { await page.getByRole('button', { name: 'Create deal' }).first().click(); await expect(page).toHaveURL(/\/backend\/customers\/deals$/i); - await page.getByRole('textbox', { name: /Search deals/i }).fill(dealTitle); + await page.getByPlaceholder(/Search by title/i).fill(dealTitle); const dealRow = page.locator('tr').filter({ hasText: dealTitle }).first(); await expect(dealRow).toBeVisible(); await dealRow.click(); diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-013.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-013.spec.ts index e7d7c60234d..07187780ab6 100644 --- a/packages/core/src/modules/customers/__integration__/TC-CRM-013.spec.ts +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-013.spec.ts @@ -57,7 +57,7 @@ test.describe('TC-CRM-013: Pipeline View Navigation', () => { await page.goto('/backend/customers/deals'); await expect(page.getByRole('heading', { name: 'Deals' })).toBeVisible(); - await page.getByRole('textbox', { name: /Search deals/i }).fill(dealTitle); + await page.getByPlaceholder(/Search by title/i).fill(dealTitle); const dealRow = page.locator('tr').filter({ hasText: dealTitle }).first(); await expect(dealRow).toBeVisible(); await expect(dealRow).toContainText('Opportunity'); diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-014.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-014.spec.ts index 23cb278bc9d..12a5187a9a6 100644 --- a/packages/core/src/modules/customers/__integration__/TC-CRM-014.spec.ts +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-014.spec.ts @@ -20,10 +20,10 @@ test.describe('TC-CRM-014: Delete Customer', () => { await page.getByRole('button', { name: 'Confirm' }).click(); await expect(page).toHaveURL(/\/backend\/customers\/companies$/); - await page.getByRole('textbox', { name: /Search companies/i }).fill(companyName); + await page.getByPlaceholder(/Search by name/i).fill(companyName); await expect(page.getByRole('link', { name: companyName, exact: true })).toHaveCount(0); - await page.getByRole('textbox', { name: /Search companies/i }).fill(companyId); + await page.getByPlaceholder(/Search by name/i).fill(companyId); await expect(page.getByText(companyId)).toHaveCount(0); }); }); diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-015.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-015.spec.ts index 1129b2f3c70..991ca2bc036 100644 --- a/packages/core/src/modules/customers/__integration__/TC-CRM-015.spec.ts +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-015.spec.ts @@ -110,7 +110,7 @@ test.describe('TC-CRM-015: Customer Search and Filter', () => { filteredListItems.some((item) => item && typeof item === 'object' && (item as { id?: unknown }).id === companyId), ).toBeTruthy(); - const search = page.getByRole('textbox', { name: /Search companies/i }); + const search = page.getByPlaceholder(/Search by name/i); const waitForCompanyInList = async () => { await expect .poll( diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-029.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-029.spec.ts new file mode 100644 index 00000000000..ec2ce598aa4 --- /dev/null +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-029.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '@playwright/test'; +import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'; +import { apiRequest, getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'; +import { deleteEntityIfExists, readJsonSafe } from '@open-mercato/core/modules/core/__integration__/helpers/crmFixtures'; + +/** + * TC-CRM-029: DataTable Column Sorting + * Verifies that clicking column headers sorts the people list ascending/descending. + */ +test.describe('TC-CRM-029: DataTable Column Sorting', () => { + test('should sort people list by name when clicking column header', async ({ page, request }) => { + test.slow(); + + let token: string | null = null; + const personIds: string[] = []; + const ts = Date.now(); + + try { + token = await getAuthToken(request); + + for (const [first, last] of [['Zulu', `Sort${ts}`], ['Alpha', `Sort${ts}`]] as const) { + const createResponse = await apiRequest(request, 'POST', '/api/customers/people', { + token, + data: { firstName: first, lastName: last, displayName: `${first} ${last}` }, + }); + expect(createResponse.ok(), `Create person failed: ${await createResponse.text()}`).toBeTruthy(); + const body = (await readJsonSafe<{ id?: unknown }>(createResponse)) ?? {}; + const id = typeof body.id === 'string' ? body.id : null; + expect(id).toBeTruthy(); + personIds.push(id!); + } + + await login(page, 'admin'); + await page.goto('/backend/customers/people', { waitUntil: 'domcontentloaded' }); + + const searchInput = page.getByPlaceholder(/Search by name/i); + await searchInput.fill(`Sort${ts}`); + await page.waitForTimeout(1500); + await page.getByText('Loading table', { exact: false }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + + await expect + .poll(async () => page.locator('tbody tr').count(), { timeout: 15000 }) + .toBeGreaterThanOrEqual(2); + + const nameHeader = page.locator('thead button', { hasText: 'Name' }).first(); + await expect(nameHeader).toBeVisible(); + + await nameHeader.click(); + await page.waitForTimeout(800); + await page.getByText('Loading table', { exact: false }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + + const firstRowTextAsc = await page.locator('tbody tr').first().textContent(); + + await nameHeader.click(); + await page.waitForTimeout(800); + await page.getByText('Loading table', { exact: false }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + + const firstRowTextDesc = await page.locator('tbody tr').first().textContent(); + expect(firstRowTextDesc).not.toBe(firstRowTextAsc); + } finally { + for (const id of personIds) { + await deleteEntityIfExists(request, token, '/api/customers/people', id); + } + } + }); +}); diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-030.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-030.spec.ts new file mode 100644 index 00000000000..892f4c99b3b --- /dev/null +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-030.spec.ts @@ -0,0 +1,78 @@ +import { test, expect } from '@playwright/test'; +import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'; +import { apiRequest, getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'; +import { deleteEntityIfExists, readJsonSafe } from '@open-mercato/core/modules/core/__integration__/helpers/crmFixtures'; + +/** + * TC-CRM-030: DataTable Bulk Delete + * Verifies that selecting rows via checkboxes and executing bulk delete + * removes the selected records with a confirmation dialog. + */ +test.describe('TC-CRM-030: DataTable Bulk Delete', () => { + test('should bulk delete selected companies via checkbox selection', async ({ page, request }) => { + test.slow(); + + let token: string | null = null; + const companyIds: string[] = []; + const prefix = `QA TC-CRM-030 ${Date.now()}`; + + try { + token = await getAuthToken(request); + + for (let i = 0; i < 2; i++) { + const createResponse = await apiRequest(request, 'POST', '/api/customers/companies', { + token, + data: { displayName: `${prefix} Co${i}` }, + }); + expect(createResponse.ok()).toBeTruthy(); + const body = (await readJsonSafe<{ id?: unknown }>(createResponse)) ?? {}; + const id = typeof body.id === 'string' ? body.id : null; + expect(id).toBeTruthy(); + companyIds.push(id!); + } + + await login(page, 'admin'); + await page.goto('/backend/customers/companies', { waitUntil: 'domcontentloaded' }); + + const searchInput = page.getByPlaceholder(/Search by name/i); + await searchInput.fill(prefix); + await page.waitForTimeout(1200); + await page.getByText('Loading table', { exact: false }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + + const selectAllCheckbox = page.locator('thead').getByRole('checkbox'); + await expect(selectAllCheckbox).toBeVisible(); + await selectAllCheckbox.check(); + + const deleteButton = page.getByRole('button', { name: /Delete selected/i }); + await expect(deleteButton).toBeVisible(); + await deleteButton.click(); + + const confirmDialog = page.getByRole('alertdialog'); + await expect(confirmDialog).toBeVisible(); + await confirmDialog.getByRole('button', { name: /Delete|Confirm/i }).click(); + + await expect + .poll( + async () => { + const listResponse = await apiRequest( + request, + 'GET', + `/api/customers/companies?search=${encodeURIComponent(prefix)}&pageSize=20`, + { token: token! }, + ); + if (!listResponse.ok()) return -1; + const payload = (await readJsonSafe<{ items?: unknown[] }>(listResponse)) ?? {}; + return Array.isArray(payload.items) ? payload.items.length : -1; + }, + { timeout: 15000 }, + ) + .toBe(0); + + companyIds.length = 0; + } finally { + for (const id of companyIds) { + await deleteEntityIfExists(request, token, '/api/customers/companies', id); + } + } + }); +}); diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-031.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-031.spec.ts new file mode 100644 index 00000000000..35ff725576b --- /dev/null +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-031.spec.ts @@ -0,0 +1,70 @@ +import { test, expect } from '@playwright/test'; +import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'; +import { apiRequest, getAuthToken } from '@open-mercato/core/modules/core/__integration__/helpers/api'; +import { deleteEntityIfExists, readJsonSafe } from '@open-mercato/core/modules/core/__integration__/helpers/crmFixtures'; + +/** + * TC-CRM-031: DataTable Advanced Filter Builder + * Verifies that the advanced filter builder can add conditions, + * apply them, and the results are filtered accordingly. + */ +test.describe('TC-CRM-031: DataTable Advanced Filter Builder', () => { + test('should filter people using advanced filter with contains condition', async ({ page, request }) => { + test.slow(); + + let token: string | null = null; + let personId: string | null = null; + const ts = Date.now(); + const uniqueName = `QAFilter TC031${ts}`; + + try { + token = await getAuthToken(request); + + const createResponse = await apiRequest(request, 'POST', '/api/customers/people', { + token, + data: { firstName: 'QAFilter', lastName: `TC031${ts}`, displayName: uniqueName, status: 'active' }, + }); + expect(createResponse.ok(), `Create person failed: ${await createResponse.text()}`).toBeTruthy(); + const body = (await readJsonSafe<{ id?: unknown }>(createResponse)) ?? {}; + personId = typeof body.id === 'string' ? body.id : null; + expect(personId).toBeTruthy(); + + await login(page, 'admin'); + await page.goto('/backend/customers/people', { waitUntil: 'domcontentloaded' }); + await page.getByText('Loading table', { exact: false }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + + const advancedFilterToggle = page.getByRole('button', { name: 'Advanced filters' }); + await expect(advancedFilterToggle).toBeVisible(); + await advancedFilterToggle.click(); + + const whereLabel = page.getByText('Where'); + await expect(whereLabel).toBeVisible(); + + const fieldSelect = page.locator('select[aria-label="Select field"]').first(); + await fieldSelect.selectOption({ label: 'Name' }); + + const operatorSelect = page.locator('select[aria-label="Select operator"]').first(); + await operatorSelect.selectOption('contains'); + + const valueInput = page.locator('input[aria-label="Text value"]').first(); + await valueInput.fill(`TC031${ts}`); + + const applyButton = page.getByRole('button', { name: 'Apply' }); + await applyButton.click(); + + await page.getByText('Loading table', { exact: false }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + + await expect + .poll( + async () => { + const text = await page.locator('tbody').textContent(); + return text?.includes(`TC031${ts}`) ?? false; + }, + { timeout: 15000 }, + ) + .toBe(true); + } finally { + await deleteEntityIfExists(request, token, '/api/customers/people', personId); + } + }); +}); diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-032.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-032.spec.ts new file mode 100644 index 00000000000..9194c5c517b --- /dev/null +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-032.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/test'; +import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'; + +/** + * TC-CRM-032: DataTable Column Chooser + * Verifies that the column chooser panel opens, allows toggling column visibility, + * and the table reflects the changes. + */ +test.describe('TC-CRM-032: DataTable Column Chooser', () => { + test('should toggle column visibility via column chooser on people page', async ({ page }) => { + test.slow(); + + await login(page, 'admin'); + await page.goto('/backend/customers/people', { waitUntil: 'domcontentloaded' }); + await page.getByText('Loading table', { exact: false }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + + const emailHeader = page.locator('thead button', { hasText: 'Email' }).first(); + await expect(emailHeader).toBeVisible(); + + const columnChooserButton = page.getByRole('button', { name: 'Choose columns' }); + await expect(columnChooserButton).toBeVisible(); + await columnChooserButton.click(); + + const panelTitle = page.getByRole('heading', { name: 'Columns' }); + await expect(panelTitle).toBeVisible(); + + // Email is in "Selected columns" section — find its checkbox (role=checkbox sibling of "Email" span) + const emailItem = page.locator('div').filter({ hasText: /^Email$/ }).locator('button[role="checkbox"]').first(); + await expect(emailItem).toBeVisible(); + await emailItem.click(); + + const closeButton = page.getByRole('button', { name: 'Close' }); + await closeButton.click(); + + await expect(emailHeader).not.toBeVisible(); + + // Re-enable: Email is now in "Available columns" section as a label with checkbox + await columnChooserButton.click(); + await expect(panelTitle).toBeVisible(); + const emailAvailable = page.locator('label').filter({ hasText: 'Email' }).locator('button[role="checkbox"]').first(); + await expect(emailAvailable).toBeVisible(); + await emailAvailable.click(); + await page.getByRole('button', { name: 'Close' }).click(); + + await expect(page.locator('thead button', { hasText: 'Email' }).first()).toBeVisible(); + }); +}); diff --git a/packages/core/src/modules/customers/__integration__/TC-CRM-033.spec.ts b/packages/core/src/modules/customers/__integration__/TC-CRM-033.spec.ts new file mode 100644 index 00000000000..0086d899cfa --- /dev/null +++ b/packages/core/src/modules/customers/__integration__/TC-CRM-033.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test'; +import { login } from '@open-mercato/core/modules/core/__integration__/helpers/auth'; + +/** + * TC-CRM-033: DataTable Page Size Selector + * Verifies that the page size dropdown changes the number of rows displayed. + */ +test.describe('TC-CRM-033: DataTable Page Size Selector', () => { + test('should change page size on people list via dropdown', async ({ page }) => { + test.slow(); + + await login(page, 'admin'); + await page.goto('/backend/customers/people', { waitUntil: 'domcontentloaded' }); + await page.getByText('Loading table', { exact: false }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + + await expect + .poll(async () => page.locator('tbody tr').count(), { timeout: 15000 }) + .toBeGreaterThan(0); + + const pageSizeSelect = page.locator('select').filter({ hasText: /per page/i }).or( + page.locator('select').filter({ has: page.locator('option[value="25"]') }) + ).first(); + await expect(pageSizeSelect).toBeVisible({ timeout: 5000 }); + + await pageSizeSelect.selectOption('10'); + await page.getByText('Loading table', { exact: false }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); + + const rowCount = await page.locator('tbody tr').count(); + expect(rowCount).toBeLessThanOrEqual(10); + }); +}); diff --git a/packages/core/src/modules/customers/api/companies/route.ts b/packages/core/src/modules/customers/api/companies/route.ts index 51d877fe5b9..ad36bf4c69d 100644 --- a/packages/core/src/modules/customers/api/companies/route.ts +++ b/packages/core/src/modules/customers/api/companies/route.ts @@ -2,11 +2,17 @@ import { z } from 'zod' import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory' import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors' -import { CustomerEntity } from '../../data/entities' +import { CustomerCompanyProfile, CustomerEntity } from '../../data/entities' import { E } from '#generated/entities.ids.generated' import { companyCreateSchema, companyUpdateSchema } from '../../data/validators' import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' -import { withScopedPayload } from '../utils' +import { + applyEntityIdRestriction, + consumeAdvancedFilterState, + findMatchingEntityIdsWithQueryEngine, + findMatchingEntityIdsBySearchTokensAcrossSources, + withScopedPayload, +} from '../utils' import { buildCustomFieldFiltersFromQuery, extractAllCustomFieldEntries, @@ -14,6 +20,8 @@ import { } from '@open-mercato/shared/lib/crud/custom-fields' import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern' import { parseBooleanToken } from '@open-mercato/shared/lib/boolean' +import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { mergeAdvancedFilters } from '@open-mercato/shared/lib/crud/advanced-filter-integration' import { createCustomersCrudOpenApi, createPagedListResponseSchema, @@ -93,10 +101,67 @@ const crud = makeCrudRoute({ updatedAt: 'updated_at', }, buildFilters: async (query: any, ctx) => { + const advancedQuery = { ...query } + const advancedFilterState = consumeAdvancedFilterState(query) const filters: Record = { kind: { $eq: 'company' } } if (query.id) filters.id = { $eq: query.id } if (query.search) { - filters.display_name = { $ilike: `%${escapeLikePattern(query.search)}%` } + const matchingIds = ctx + ? await findMatchingEntityIdsBySearchTokensAcrossSources({ + ctx, + query: query.search, + sources: [ + { + entityType: E.customers.customer_entity, + fields: [ + 'display_name', + 'primary_email', + 'primary_phone', + 'description', + 'status', + 'lifecycle_stage', + 'source', + 'next_interaction_name', + ], + }, + { + entityType: E.customers.customer_company_profile, + fields: [ + 'display_name', + 'primary_email', + 'primary_phone', + 'description', + 'status', + 'lifecycle_stage', + 'source', + 'legal_name', + 'brand_name', + 'domain', + 'website_url', + 'industry', + 'size_bucket', + 'annual_revenue', + ], + mapToEntityIds: { + table: 'customer_companies', + targetColumn: 'entity_id', + }, + }, + ], + }) + : null + if (matchingIds !== null && matchingIds.length > 0) { + applyEntityIdRestriction(filters, matchingIds) + } else { + const searchPattern = `%${escapeLikePattern(query.search)}%` + filters.$or = [ + { display_name: { $ilike: searchPattern } }, + { primary_email: { $ilike: searchPattern } }, + { primary_phone: { $ilike: searchPattern } }, + { description: { $ilike: searchPattern } }, + { next_interaction_name: { $ilike: searchPattern } }, + ] + } } if (query.status) { filters.status = { $eq: query.status } @@ -166,6 +231,36 @@ const crud = makeCrudRoute({ // ignore custom field filter errors; fall back to base filters } } + if (ctx && advancedFilterState) { + const advancedFilters = mergeAdvancedFilters( + { ...filters }, + advancedQuery as Record, + ) + const matchedIds = await findMatchingEntityIdsWithQueryEngine({ + ctx, + entityId: E.customers.customer_entity, + filters: advancedFilters, + customFieldSources: [ + { + entityId: E.customers.customer_company_profile, + table: 'customer_companies', + alias: 'company_profile', + recordIdColumn: 'id', + join: { fromField: 'id', toField: 'entity_id' }, + }, + ], + joins: [ + { + alias: 'tag_assignments', + table: 'customer_tag_assignments', + from: { field: 'id' }, + to: { field: 'entity_id' }, + type: 'left', + }, + ], + }) + applyEntityIdRestriction(filters, matchedIds) + } return filters }, customFieldSources: [ @@ -244,6 +339,59 @@ const crud = makeCrudRoute({ response: () => ({ ok: true }), }, }, + hooks: { + afterList: async (payload, ctx) => { + const items = Array.isArray(payload?.items) ? payload.items : [] + const ids = items + .map((item: unknown) => (item && typeof item === 'object' && typeof (item as Record).id === 'string' + ? (item as Record).id as string + : null)) + .filter((id: string | null): id is string => typeof id === 'string' && id.length > 0) + if (!ids.length) return + + const where: Record = { + entity: { $in: ids }, + tenantId: ctx.auth?.tenantId ?? null, + } + if (ctx.selectedOrganizationId) { + where.organizationId = ctx.selectedOrganizationId + } + + const profiles = await findWithDecryption( + ctx.container.resolve('em') as any, + CustomerCompanyProfile, + where as any, + { populate: ['entity'] } as any, + { + tenantId: ctx.auth?.tenantId ?? null, + organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null, + }, + ) + + const profilesByEntityId = new Map() + for (const profile of profiles) { + const entityId = typeof (profile as any)?.entity?.id === 'string' ? (profile as any).entity.id : null + if (entityId) profilesByEntityId.set(entityId, profile) + } + + payload.items = items.map((item: unknown) => { + if (!item || typeof item !== 'object') return item + const record = item as Record + const profile = typeof record.id === 'string' ? profilesByEntityId.get(record.id) : undefined + if (!profile) return item + return { + ...record, + legal_name: profile.legalName ?? null, + brand_name: profile.brandName ?? null, + domain: profile.domain ?? null, + website_url: profile.websiteUrl ?? null, + industry: profile.industry ?? null, + size_bucket: profile.sizeBucket ?? null, + annual_revenue: profile.annualRevenue ?? null, + } + }) + }, + }, }) const { POST, PUT, DELETE } = crud diff --git a/packages/core/src/modules/customers/api/deals/route.ts b/packages/core/src/modules/customers/api/deals/route.ts index f0c886ef6bd..0b8e29d0aaf 100644 --- a/packages/core/src/modules/customers/api/deals/route.ts +++ b/packages/core/src/modules/customers/api/deals/route.ts @@ -6,7 +6,13 @@ import { CustomerDeal, CustomerDealPersonLink, CustomerDealCompanyLink } from '. import { dealCreateSchema, dealUpdateSchema } from '../../data/validators' import { E } from '#generated/entities.ids.generated' import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' -import { parseScopedCommandInput } from '../utils' +import { + applyEntityIdRestriction, + consumeAdvancedFilterState, + findMatchingEntityIdsWithQueryEngine, + findMatchingEntityIdsBySearchTokensAcrossSources, + parseScopedCommandInput, +} from '../utils' import type { EntityManager } from '@mikro-orm/postgresql' import { createCustomersCrudOpenApi, @@ -15,6 +21,7 @@ import { } from '../openapi' import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find' import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern' +import { mergeAdvancedFilters } from '@open-mercato/shared/lib/crud/advanced-filter-integration' const rawBodySchema = z.object({}).passthrough() @@ -122,10 +129,42 @@ const crud = makeCrudRoute({ title: 'title', value: 'value_amount', }, - buildFilters: async (query: any) => { + buildFilters: async (query: any, ctx) => { + const advancedQuery = { ...query } + const advancedFilterState = consumeAdvancedFilterState(query) const filters: Record = {} if (query.search) { - filters.title = { $ilike: `%${escapeLikePattern(query.search)}%` } + const matchingIds = ctx + ? await findMatchingEntityIdsBySearchTokensAcrossSources({ + ctx, + query: query.search, + sources: [ + { + entityType: E.customers.customer_deal, + fields: [ + 'title', + 'description', + 'status', + 'pipeline_stage', + 'source', + 'value_amount', + 'value_currency', + 'cf:competitive_risk', + 'cf:implementation_complexity', + ], + }, + ], + }) + : null + if (matchingIds !== null && matchingIds.length > 0) { + applyEntityIdRestriction(filters, matchingIds) + } else { + const searchPattern = `%${escapeLikePattern(query.search)}%` + filters.$or = [ + { title: { $ilike: searchPattern } }, + { description: { $ilike: searchPattern } }, + ] + } } if (query.status) { filters.status = { $eq: query.status } @@ -139,6 +178,18 @@ const crud = makeCrudRoute({ if (query.pipelineStageId) { filters.pipeline_stage_id = { $eq: query.pipelineStageId } } + if (ctx && advancedFilterState) { + const advancedFilters = mergeAdvancedFilters( + { ...filters }, + advancedQuery as Record, + ) + const matchedIds = await findMatchingEntityIdsWithQueryEngine({ + ctx, + entityId: E.customers.customer_deal, + filters: advancedFilters, + }) + applyEntityIdRestriction(filters, matchedIds) + } return filters }, }, diff --git a/packages/core/src/modules/customers/api/people/route.ts b/packages/core/src/modules/customers/api/people/route.ts index 202b9ed608f..60ceec5a854 100644 --- a/packages/core/src/modules/customers/api/people/route.ts +++ b/packages/core/src/modules/customers/api/people/route.ts @@ -2,14 +2,22 @@ import { z } from 'zod' import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory' import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors' -import { CustomerEntity } from '../../data/entities' +import { CustomerEntity, CustomerPersonProfile } from '../../data/entities' import { E } from '#generated/entities.ids.generated' import { personCreateSchema, personUpdateSchema } from '../../data/validators' import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' -import { withScopedPayload } from '../utils' +import { + applyEntityIdRestriction, + consumeAdvancedFilterState, + findMatchingEntityIdsWithQueryEngine, + findMatchingEntityIdsBySearchTokensAcrossSources, + withScopedPayload, +} from '../utils' import { buildCustomFieldFiltersFromQuery, extractAllCustomFieldEntries, splitCustomFieldPayload } from '@open-mercato/shared/lib/crud/custom-fields' import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern' import { parseBooleanToken } from '@open-mercato/shared/lib/boolean' +import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { mergeAdvancedFilters } from '@open-mercato/shared/lib/crud/advanced-filter-integration' import { createCustomersCrudOpenApi, createPagedListResponseSchema, @@ -90,10 +98,68 @@ const crud = makeCrudRoute({ updatedAt: 'updated_at', }, buildFilters: async (query: any, ctx) => { + const advancedQuery = { ...query } + const advancedFilterState = consumeAdvancedFilterState(query) const filters: Record = { kind: { $eq: 'person' } } if (query.id) filters.id = { $eq: query.id } if (query.search) { - filters.display_name = { $ilike: `%${escapeLikePattern(query.search)}%` } + const matchingIds = ctx + ? await findMatchingEntityIdsBySearchTokensAcrossSources({ + ctx, + query: query.search, + sources: [ + { + entityType: E.customers.customer_entity, + fields: [ + 'display_name', + 'primary_email', + 'primary_phone', + 'description', + 'status', + 'lifecycle_stage', + 'source', + 'next_interaction_name', + ], + }, + { + entityType: E.customers.customer_person_profile, + fields: [ + 'display_name', + 'primary_email', + 'primary_phone', + 'status', + 'lifecycle_stage', + 'source', + 'first_name', + 'last_name', + 'preferred_name', + 'job_title', + 'department', + 'seniority', + 'timezone', + 'linked_in_url', + 'twitter_url', + ], + mapToEntityIds: { + table: 'customer_people', + targetColumn: 'entity_id', + }, + }, + ], + }) + : null + if (matchingIds !== null && matchingIds.length > 0) { + applyEntityIdRestriction(filters, matchingIds) + } else { + const searchPattern = `%${escapeLikePattern(query.search)}%` + filters.$or = [ + { display_name: { $ilike: searchPattern } }, + { primary_email: { $ilike: searchPattern } }, + { primary_phone: { $ilike: searchPattern } }, + { description: { $ilike: searchPattern } }, + { next_interaction_name: { $ilike: searchPattern } }, + ] + } } const email = typeof query.email === 'string' ? query.email.trim().toLowerCase() : '' const emailStartsWith = typeof query.emailStartsWith === 'string' ? query.emailStartsWith.trim().toLowerCase() : '' @@ -163,6 +229,36 @@ const crud = makeCrudRoute({ // ignore custom field filter errors; fall back to base filters } } + if (ctx && advancedFilterState) { + const advancedFilters = mergeAdvancedFilters( + { ...filters }, + advancedQuery as Record, + ) + const matchedIds = await findMatchingEntityIdsWithQueryEngine({ + ctx, + entityId: E.customers.customer_entity, + filters: advancedFilters, + customFieldSources: [ + { + entityId: E.customers.customer_person_profile, + table: 'customer_people', + alias: 'person_profile', + recordIdColumn: 'id', + join: { fromField: 'id', toField: 'entity_id' }, + }, + ], + joins: [ + { + alias: 'tag_assignments', + table: 'customer_tag_assignments', + from: { field: 'id' }, + to: { field: 'entity_id' }, + type: 'left', + }, + ], + }) + applyEntityIdRestriction(filters, matchedIds) + } return filters }, customFieldSources: [ @@ -241,6 +337,67 @@ const crud = makeCrudRoute({ response: () => ({ ok: true }), }, }, + hooks: { + afterList: async (payload, ctx) => { + const items = Array.isArray(payload?.items) ? payload.items : [] + const ids = items + .map((item: unknown) => ( + item && typeof item === 'object' && typeof (item as Record).id === 'string' + ? (item as Record).id as string + : null + )) + .filter((id: string | null): id is string => typeof id === 'string' && id.length > 0) + if (!ids.length) return + + const where: Record = { + entity: { $in: ids }, + tenantId: ctx.auth?.tenantId ?? null, + } + if (ctx.selectedOrganizationId) { + where.organizationId = ctx.selectedOrganizationId + } + + const profiles = await findWithDecryption( + ctx.container.resolve('em') as any, + CustomerPersonProfile, + where as any, + { populate: ['entity', 'company'] } as any, + { + tenantId: ctx.auth?.tenantId ?? null, + organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null, + }, + ) + + const profilesByEntityId = new Map() + for (const profile of profiles) { + const entityId = typeof (profile as any)?.entity?.id === 'string' ? (profile as any).entity.id : null + if (entityId) profilesByEntityId.set(entityId, profile) + } + + payload.items = items.map((item: unknown) => { + if (!item || typeof item !== 'object') return item + const record = item as Record + const profile = typeof record.id === 'string' ? profilesByEntityId.get(record.id) : undefined + if (!profile) return item + return { + ...record, + first_name: profile.firstName ?? null, + last_name: profile.lastName ?? null, + preferred_name: profile.preferredName ?? null, + job_title: profile.jobTitle ?? null, + department: profile.department ?? null, + seniority: profile.seniority ?? null, + timezone: profile.timezone ?? null, + linked_in_url: profile.linkedInUrl ?? null, + twitter_url: profile.twitterUrl ?? null, + company_entity_id: + profile.company && typeof profile.company === 'object' + ? profile.company.id + : profile.company ?? null, + } + }) + }, + }, }) const { POST, PUT, DELETE } = crud diff --git a/packages/core/src/modules/customers/api/utils.ts b/packages/core/src/modules/customers/api/utils.ts index 1e14acb4971..978280088a6 100644 --- a/packages/core/src/modules/customers/api/utils.ts +++ b/packages/core/src/modules/customers/api/utils.ts @@ -1,4 +1,12 @@ import { createScopedApiHelpers } from '@open-mercato/shared/lib/api/scoped' +import type { EntityManager } from '@mikro-orm/postgresql' +import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory' +import type { EntityId } from '@open-mercato/shared/modules/entities' +import type { QueryCustomFieldSource, QueryJoinEdge, QueryEngine } from '@open-mercato/shared/lib/query/types' +import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config' +import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize' +import { deserializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter' +import { SortDir } from '@open-mercato/shared/lib/query/types' const { withScopedPayload, parseScopedCommandInput } = createScopedApiHelpers({ messages: { @@ -7,4 +15,282 @@ const { withScopedPayload, parseScopedCommandInput } = createScopedApiHelpers({ }, }) +const NO_MATCH_ID = '00000000-0000-0000-0000-000000000000' + +type SearchTokenMatchInput = { + ctx: CrudCtx + entityType: string + fields: string[] + query: string +} + +type SearchTokenSource = { + entityType: string + fields: string[] + mapToEntityIds?: { + table: string + sourceColumn?: string + targetColumn: string + tenantColumn?: string + organizationColumn?: string + } +} + +async function enrichSearchSourcesWithCustomFieldTokens( + ctx: CrudCtx, + sources: SearchTokenSource[], +): Promise { + const entityTypes = Array.from( + new Set( + sources + .map((source) => source.entityType) + .filter((value): value is string => typeof value === 'string' && value.length > 0), + ), + ) + if (!entityTypes.length) return sources + + const em = ctx.container.resolve('em') as EntityManager + const knex = (em as any).getConnection().getKnex() + let defsQuery = knex('custom_field_defs') + .select('entity_id', 'key', 'kind') + .whereIn('entity_id', entityTypes) + .andWhere('is_active', true) + + defsQuery = defsQuery.andWhere((builder: any) => { + builder.where({ tenant_id: ctx.auth?.tenantId ?? null }).orWhereNull('tenant_id') + }) + + if (ctx.selectedOrganizationId) { + defsQuery = defsQuery.andWhere((builder: any) => { + builder.where({ organization_id: ctx.selectedOrganizationId }).orWhereNull('organization_id') + }) + } else if (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length > 0) { + defsQuery = defsQuery.andWhere((builder: any) => { + builder.whereIn('organization_id', ctx.organizationIds).orWhereNull('organization_id') + }) + } + + const customFieldKeysByEntity = new Map>() + const rows = await defsQuery + for (const row of rows as Array<{ entity_id?: unknown; key?: unknown; kind?: unknown }>) { + if (row.kind === 'attachment') continue + const entityType = typeof row.entity_id === 'string' ? row.entity_id : null + const key = typeof row.key === 'string' ? row.key.trim() : '' + if (!entityType || !key) continue + const bucket = customFieldKeysByEntity.get(entityType) ?? new Set() + bucket.add(`cf:${key}`) + customFieldKeysByEntity.set(entityType, bucket) + } + + return sources.map((source) => { + const customFieldKeys = customFieldKeysByEntity.get(source.entityType) + return { + ...source, + fields: Array.from(new Set([ + 'search_text', + ...source.fields, + ...(customFieldKeys ? Array.from(customFieldKeys) : []), + ])), + } + }) +} + +async function findSearchTokenEntityIds({ + ctx, + entityType, + fields, + query, +}: SearchTokenMatchInput): Promise { + const trimmed = query.trim() + if (!trimmed) return null + + const tokens = tokenizeText(trimmed, resolveSearchConfig()) + if (!tokens.hashes.length) return [] + + const em = ctx.container.resolve('em') as EntityManager + const knex = (em as any).getConnection().getKnex() + let searchQuery = knex('search_tokens') + .select('entity_id') + .where('entity_type', entityType) + .whereIn('field', fields) + .whereIn('token_hash', tokens.hashes) + .groupBy('entity_id') + .havingRaw('count(distinct token_hash) >= ?', [tokens.hashes.length]) + + if (ctx.auth?.tenantId !== undefined) { + searchQuery = searchQuery.whereRaw('tenant_id is not distinct from ?', [ctx.auth?.tenantId ?? null]) + } + if (ctx.selectedOrganizationId) { + searchQuery = searchQuery.where('organization_id', ctx.selectedOrganizationId) + } else if (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length > 0) { + searchQuery = searchQuery.whereIn('organization_id', ctx.organizationIds) + } + + const rows = await searchQuery + return rows + .map((row: { entity_id?: unknown }) => (typeof row.entity_id === 'string' ? row.entity_id : null)) + .filter((id: string | null): id is string => typeof id === 'string' && id.length > 0) +} + +async function mapScopedEntityIds({ + ctx, + ids, + config, +}: { + ctx: CrudCtx + ids: string[] + config: NonNullable +}): Promise { + if (!ids.length) return [] + + const em = ctx.container.resolve('em') as EntityManager + const knex = (em as any).getConnection().getKnex() + const sourceColumn = config.sourceColumn ?? 'id' + const tenantColumn = config.tenantColumn ?? 'tenant_id' + const organizationColumn = config.organizationColumn ?? 'organization_id' + + let mapQuery = knex(config.table) + .select(config.targetColumn) + .whereIn(sourceColumn, ids) + + if (ctx.auth?.tenantId !== undefined) { + mapQuery = mapQuery.whereRaw('?? is not distinct from ?', [tenantColumn, ctx.auth?.tenantId ?? null]) + } + if (ctx.selectedOrganizationId) { + mapQuery = mapQuery.where(organizationColumn, ctx.selectedOrganizationId) + } else if (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length > 0) { + mapQuery = mapQuery.whereIn(organizationColumn, ctx.organizationIds) + } + + const rows = await mapQuery + return rows + .map((row: Record) => { + const value = row[config.targetColumn] + return typeof value === 'string' ? value : null + }) + .filter((id: string | null): id is string => typeof id === 'string' && id.length > 0) +} + +export async function findMatchingEntityIdsBySearchTokensAcrossSources({ + ctx, + sources, + query, +}: { + ctx: CrudCtx + sources: SearchTokenSource[] + query: string +}): Promise { + const trimmed = query.trim() + if (!trimmed) return null + + const enrichedSources = await enrichSearchSourcesWithCustomFieldTokens(ctx, sources) + const matchedIds = new Set() + for (const source of enrichedSources) { + const rawIds = await findSearchTokenEntityIds({ + ctx, + entityType: source.entityType, + fields: source.fields, + query: trimmed, + }) + if (rawIds === null) return null + const entityIds = source.mapToEntityIds + ? await mapScopedEntityIds({ ctx, ids: rawIds, config: source.mapToEntityIds }) + : rawIds + entityIds.forEach((id) => matchedIds.add(id)) + } + + return Array.from(matchedIds) +} + +export async function findMatchingEntityIdsBySearchTokens({ + ctx, + entityType, + fields, + query, +}: SearchTokenMatchInput): Promise { + return findMatchingEntityIdsBySearchTokensAcrossSources({ + ctx, + query, + sources: [{ entityType, fields }], + }) +} + +export function applyEntityIdRestriction( + filters: Record, + ids: string[] | null, +): void { + if (ids === null) return + const currentIdFilter = + filters.id && typeof filters.id === 'object' && !Array.isArray(filters.id) + ? (filters.id as { $eq?: unknown; $in?: unknown }) + : null + const currentEq = typeof currentIdFilter?.$eq === 'string' ? currentIdFilter.$eq : null + + if (currentEq) { + filters.id = ids.includes(currentEq) ? { $eq: currentEq } : { $eq: NO_MATCH_ID } + return + } + + filters.id = ids.length > 0 ? { $in: ids } : { $eq: NO_MATCH_ID } +} + +export function consumeAdvancedFilterState(query: Record) { + const state = deserializeAdvancedFilter(query) + if (!state) return null + + for (const key of Object.keys(query)) { + if (key.startsWith('filter[')) { + delete query[key] + } + } + + return state +} + +export async function findMatchingEntityIdsWithQueryEngine({ + ctx, + entityId, + filters, + customFieldSources, + joins, +}: { + ctx: CrudCtx + entityId: EntityId + filters: Record + customFieldSources?: QueryCustomFieldSource[] + joins?: QueryJoinEdge[] +}): Promise { + const qe = ctx.container.resolve('queryEngine') as QueryEngine + const ids = new Set() + const pageSize = 100 + let page = 1 + let total = 0 + + do { + const result = await qe.query(entityId, { + fields: ['id'], + filters, + page: { page, pageSize }, + sort: [{ field: 'id', dir: SortDir.Asc }], + tenantId: ctx.auth?.tenantId ?? undefined, + organizationId: ctx.selectedOrganizationId ?? undefined, + organizationIds: ctx.organizationIds ?? undefined, + customFieldSources, + joins, + }) + + total = result.total ?? 0 + for (const item of result.items ?? []) { + const id = item && typeof item === 'object' ? (item as Record).id : null + if (typeof id === 'string' && id.length > 0) { + ids.add(id) + } + } + if (!result.items?.length) break + page += 1 + } while (ids.size < total) + + return Array.from(ids) +} + export { withScopedPayload, parseScopedCommandInput } diff --git a/packages/core/src/modules/customers/backend/customers/companies/page.tsx b/packages/core/src/modules/customers/backend/customers/companies/page.tsx index 2b421d0a4dd..7893c6b94b2 100644 --- a/packages/core/src/modules/customers/backend/customers/companies/page.tsx +++ b/packages/core/src/modules/customers/backend/customers/companies/page.tsx @@ -17,6 +17,8 @@ import { useT } from '@open-mercato/shared/lib/i18n/context' import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog' import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar' import type { FilterOption } from '@open-mercato/ui/backend/FilterOverlay' +import type { AdvancedFilterState } from '@open-mercato/shared/lib/query/advanced-filter' +import { serializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter' import { DictionaryValue, renderDictionaryColor, @@ -26,8 +28,12 @@ import { } from '../../../lib/dictionaries' import { useCustomFieldDefs, - filterCustomFieldDefs, } from '@open-mercato/ui/backend/utils/customFieldDefs' +import { + mapCustomFieldKindToFilterType, + normalizeCustomFieldFilterOptions, + supportsCustomFieldColumn, +} from '@open-mercato/ui/backend/utils/customFieldColumns' import { useQueryClient } from '@tanstack/react-query' import { ensureCustomerDictionary } from '../../../components/detail/hooks/useCustomerDictionary' @@ -37,6 +43,13 @@ type CompanyRow = { description?: string | null email?: string | null phone?: string | null + legalName?: string | null + brandName?: string | null + domain?: string | null + websiteUrl?: string | null + industry?: string | null + sizeBucket?: string | null + annualRevenue?: string | null status?: string | null lifecycleStage?: string | null nextInteractionAt?: string | null @@ -71,6 +84,18 @@ function mapApiItem(item: Record): CompanyRow | null { const description = typeof item.description === 'string' ? item.description : null const email = typeof item.primary_email === 'string' ? item.primary_email : null const phone = typeof item.primary_phone === 'string' ? item.primary_phone : null + const legalName = typeof item.legal_name === 'string' ? item.legal_name : null + const brandName = typeof item.brand_name === 'string' ? item.brand_name : null + const domain = typeof item.domain === 'string' ? item.domain : null + const websiteUrl = typeof item.website_url === 'string' ? item.website_url : null + const industry = typeof item.industry === 'string' ? item.industry : null + const sizeBucket = typeof item.size_bucket === 'string' ? item.size_bucket : null + const annualRevenue = + typeof item.annual_revenue === 'string' + ? item.annual_revenue + : typeof item.annual_revenue === 'number' + ? String(item.annual_revenue) + : null const status = typeof item.status === 'string' ? item.status : null const lifecycleStage = typeof item.lifecycle_stage === 'string' ? item.lifecycle_stage : null const nextInteractionAt = typeof item.next_interaction_at === 'string' ? item.next_interaction_at : null @@ -91,6 +116,13 @@ function mapApiItem(item: Record): CompanyRow | null { description, email, phone, + legalName, + brandName, + domain, + websiteUrl, + industry, + sizeBucket, + annualRevenue, status, lifecycleStage, nextInteractionAt, @@ -107,11 +139,13 @@ export default function CustomersCompaniesPage() { const { confirm, ConfirmDialogElement } = useConfirmDialog() const [rows, setRows] = React.useState([]) const [page, setPage] = React.useState(1) - const [pageSize] = React.useState(20) + const [pageSize, setPageSize] = React.useState(20) + const [sorting, setSorting] = React.useState([]) const [total, setTotal] = React.useState(0) const [totalPages, setTotalPages] = React.useState(1) const [search, setSearch] = React.useState('') const [filterValues, setFilterValues] = React.useState({}) + const [advancedFilterState, setAdvancedFilterState] = React.useState({ logic: 'and', conditions: [] }) const [isLoading, setIsLoading] = React.useState(true) const [reloadToken, setReloadToken] = React.useState(0) const [cacheStatus, setCacheStatus] = React.useState<'hit' | 'miss' | null>(null) @@ -131,6 +165,10 @@ export default function CustomersCompaniesPage() { const queryClient = useQueryClient() const t = useT() const router = useRouter() + const handlePageSizeChange = React.useCallback((newSize: number) => { + setPageSize(newSize) + setPage(1) + }, []) const fetchDictionaryEntries = React.useCallback(async (kind: DictionaryKindKey) => { try { const data = await ensureCustomerDictionary(queryClient, kind, scopeVersion) @@ -299,6 +337,10 @@ export default function CustomersCompaniesPage() { const params = new URLSearchParams() params.set('page', String(page)) params.set('pageSize', String(pageSize)) + if (sorting.length > 0) { + params.set('sort', sorting[0].id) + params.set('order', sorting[0].desc ? 'desc' : 'asc') + } if (search.trim()) params.set('search', search.trim()) const status = filterValues.status if (typeof status === 'string' && status.trim()) params.set('status', status) @@ -358,8 +400,12 @@ export default function CustomersCompaniesPage() { if (stringValue) params.set(key, stringValue) } }) + const advancedParams = serializeAdvancedFilter(advancedFilterState) + for (const [key, val] of Object.entries(advancedParams)) { + params.set(key, val) + } return params.toString() - }, [filterValues, page, pageSize, search, tagIdToLabel, tagLabelToId]) + }, [advancedFilterState, filterValues, page, pageSize, search, sorting, tagIdToLabel, tagLabelToId]) const currentParams = React.useMemo(() => Object.fromEntries(new URLSearchParams(queryParams)), [queryParams]) const exportConfig = React.useMemo(() => ({ @@ -439,6 +485,35 @@ export default function CustomersCompaniesPage() { } }, [confirm, handleRefresh, t]) + const handleBulkDelete = React.useCallback(async (selectedRows: CompanyRow[]) => { + const confirmed = await confirm({ + title: t('customers.companies.list.bulkDelete.title', 'Delete {count} companies?', { count: selectedRows.length }), + description: t('customers.companies.list.bulkDelete.description', 'This action cannot be undone.'), + variant: 'destructive', + }) + if (!confirmed) return false + let deletedCount = 0 + for (const row of selectedRows) { + try { + await apiCallOrThrow(`/api/customers/companies?id=${encodeURIComponent(row.id)}`, { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + }) + deletedCount++ + } catch {} + } + if (deletedCount > 0) { + setRows((prev) => { + const deletedIds = new Set(selectedRows.map((r) => r.id)) + return prev.filter((r) => !deletedIds.has(r.id)) + }) + setTotal((prev) => Math.max(0, prev - deletedCount)) + flash(t('customers.companies.list.bulkDelete.success', '{count} companies deleted', { count: deletedCount }), 'success') + setReloadToken((prev) => prev + 1) + } + return deletedCount > 0 + }, [confirm, t]) + const handleFiltersApply = React.useCallback((values: FilterValues) => { const next: FilterValues = {} Object.entries(values).forEach(([key, value]) => { @@ -509,6 +584,7 @@ export default function CustomersCompaniesPage() { { accessorKey: 'name', header: t('customers.companies.list.columns.name'), + meta: { alwaysVisible: true, columnChooserGroup: 'Basic Info', filterKey: 'display_name' }, cell: ({ row }) => ( {row.original.name} @@ -518,21 +594,36 @@ export default function CustomersCompaniesPage() { { accessorKey: 'email', header: t('customers.companies.list.columns.email'), + meta: { columnChooserGroup: 'Contact', filterKey: 'primary_email' }, cell: ({ row }) => row.original.email || noValue, }, + { + accessorKey: 'phone', + header: t('customers.companies.detail.highlights.primaryPhone', 'Primary phone'), + meta: { columnChooserGroup: 'Contact', hidden: true, filterKey: 'primary_phone' }, + cell: ({ row }) => row.original.phone || noValue, + }, { accessorKey: 'status', header: t('customers.companies.list.columns.status'), + meta: { filterType: 'select' as const, filterOptions: dictionaryOptions.statuses, columnChooserGroup: 'Basic Info' }, cell: ({ row }) => renderDictionaryCell('statuses', row.original.status), }, { accessorKey: 'lifecycleStage', header: t('customers.companies.list.columns.lifecycleStage'), + meta: { + filterType: 'select' as const, + filterOptions: dictionaryOptions.lifecycleStages, + columnChooserGroup: 'Basic Info', + filterKey: 'lifecycle_stage', + }, cell: ({ row }) => renderDictionaryCell('lifecycle-stages', row.original.lifecycleStage), }, { accessorKey: 'nextInteractionAt', header: t('customers.companies.list.columns.nextInteraction'), + meta: { columnChooserGroup: 'Dates', filterKey: 'next_interaction_at' }, cell: ({ row }) => row.original.nextInteractionAt ? ( @@ -560,15 +651,78 @@ export default function CustomersCompaniesPage() { { accessorKey: 'source', header: t('customers.companies.list.columns.source'), + meta: { filterType: 'select' as const, filterOptions: dictionaryOptions.sources, columnChooserGroup: 'Basic Info' }, cell: ({ row }) => renderDictionaryCell('sources', row.original.source), }, + { + accessorKey: 'legalName', + header: t('customers.companies.detail.fields.legalName', 'Legal name'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.legal_name' }, + cell: ({ row }) => row.original.legalName || noValue, + }, + { + accessorKey: 'brandName', + header: t('customers.companies.detail.fields.brandName', 'Brand name'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.brand_name' }, + cell: ({ row }) => row.original.brandName || noValue, + }, + { + accessorKey: 'domain', + header: t('customers.companies.detail.fields.domain', 'Domain'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.domain' }, + cell: ({ row }) => row.original.domain || noValue, + }, + { + accessorKey: 'websiteUrl', + header: t('customers.companies.detail.fields.website', 'Website'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.website_url' }, + cell: ({ row }) => row.original.websiteUrl || noValue, + }, + { + accessorKey: 'industry', + header: t('customers.companies.detail.fields.industry', 'Industry'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.industry' }, + cell: ({ row }) => row.original.industry || noValue, + }, + { + accessorKey: 'sizeBucket', + header: t('customers.companies.detail.fields.sizeBucket', 'Company size'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'company_profile.size_bucket' }, + cell: ({ row }) => row.original.sizeBucket || noValue, + }, + { + accessorKey: 'annualRevenue', + header: t('customers.companies.detail.highlights.annualRevenue', 'Annual revenue'), + meta: { + columnChooserGroup: 'Profile', + hidden: true, + filterKey: 'company_profile.annual_revenue', + filterType: 'number' as const, + }, + cell: ({ row }) => row.original.annualRevenue || noValue, + }, + { + accessorKey: 'description', + header: t('customers.companies.detail.fields.description', 'Description'), + meta: { columnChooserGroup: 'Notes', hidden: true, filterKey: 'description' }, + cell: ({ row }) => row.original.description || noValue, + }, ] - const customColumns = filterCustomFieldDefs(customFieldDefs, 'list').map>((def) => ({ - accessorKey: `cf_${def.key}`, - header: def.label || def.key, - cell: ({ getValue }) => renderCustomFieldCell(getValue()), - })) + const customColumns = customFieldDefs + .filter((def) => supportsCustomFieldColumn(def)) + .map>((def) => ({ + accessorKey: `cf_${def.key}`, + header: def.label || def.key, + meta: { + columnChooserGroup: def.group?.title ?? 'Custom Fields', + filterGroup: def.group?.title ?? 'Custom Fields', + filterType: mapCustomFieldKindToFilterType(def.kind), + filterOptions: normalizeCustomFieldFilterOptions(def.options), + hidden: def.listVisible === false, + }, + cell: ({ getValue }) => renderCustomFieldCell(getValue()), + })) return [...baseColumns, ...customColumns] }, [customFieldDefs, dictionaryMaps, t]) @@ -577,6 +731,7 @@ export default function CustomersCompaniesPage() { + stickyFirstColumn title={t('customers.companies.list.title')} refreshButton={{ label: t('customers.companies.list.actions.refresh'), @@ -590,6 +745,7 @@ export default function CustomersCompaniesPage() { )} columns={columns} + columnChooser={{ auto: true }} data={rows} exporter={exportConfig} searchValue={search} @@ -602,6 +758,17 @@ export default function CustomersCompaniesPage() { entityIds={[E.customers.customer_entity, E.customers.customer_company_profile]} onRowClick={(row) => router.push(`/backend/customers/companies-v2/${row.id}`)} perspective={{ tableId: 'customers.companies.list' }} + sortable + sorting={sorting} + onSortingChange={setSorting} + bulkActions={[ + { + id: 'delete', + label: t('customers.companies.list.actions.bulkDelete', 'Delete selected'), + destructive: true, + onExecute: handleBulkDelete, + }, + ]} rowActions={(row) => ( )} - pagination={{ page, pageSize, total, totalPages, onPageChange: setPage, cacheStatus }} + advancedFilter={{ + auto: true, + value: advancedFilterState, + onChange: setAdvancedFilterState, + onApply: () => { setPage(1) }, + onClear: () => { setAdvancedFilterState({ logic: 'and', conditions: [] }); setPage(1) }, + }} + virtualized + pagination={{ page, pageSize, total, totalPages, onPageChange: setPage, pageSizeOptions: [10, 25, 50, 100], onPageSizeChange: handlePageSizeChange, cacheStatus }} isLoading={isLoading} /> diff --git a/packages/core/src/modules/customers/backend/customers/deals/page.tsx b/packages/core/src/modules/customers/backend/customers/deals/page.tsx index 6b7bb780b5c..65626d2406c 100644 --- a/packages/core/src/modules/customers/backend/customers/deals/page.tsx +++ b/packages/core/src/modules/customers/backend/customers/deals/page.tsx @@ -8,6 +8,8 @@ import type { ColumnDef } from '@tanstack/react-table' import { Page, PageBody } from '@open-mercato/ui/backend/Page' import { DataTable, type DataTableExportFormat, withDataTableNamespaces } from '@open-mercato/ui/backend/DataTable' import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar' +import type { AdvancedFilterState } from '@open-mercato/shared/lib/query/advanced-filter' +import { serializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter' import { apiCall } from '@open-mercato/ui/backend/utils/apiCall' import { buildCrudExportUrl, deleteCrud } from '@open-mercato/ui/backend/utils/crud' import { flash } from '@open-mercato/ui/backend/FlashMessages' @@ -28,8 +30,12 @@ import { } from '../../../components/detail/hooks/useCustomerDictionary' import { useCustomFieldDefs, - filterCustomFieldDefs, } from '@open-mercato/ui/backend/utils/customFieldDefs' +import { + mapCustomFieldKindToFilterType, + normalizeCustomFieldFilterOptions, + supportsCustomFieldColumn, +} from '@open-mercato/ui/backend/utils/customFieldColumns' type DealRow = { id: string @@ -276,6 +282,8 @@ export default function CustomersDealsPage() { const raw = Number(searchParams?.get('page') ?? '1') return Number.isFinite(raw) && raw > 0 ? raw : 1 }) + const [pageSize, setPageSize] = React.useState(PAGE_SIZE) + const [sorting, setSorting] = React.useState([]) const [total, setTotal] = React.useState(0) const [totalPages, setTotalPages] = React.useState(1) const [search, setSearch] = React.useState(() => searchParams?.get('search')?.trim() ?? '') @@ -283,6 +291,7 @@ export default function CustomersDealsPage() { const [reloadToken, setReloadToken] = React.useState(0) const [pendingDeleteId, setPendingDeleteId] = React.useState(null) const [filterValues, setFilterValues] = React.useState({}) + const [advancedFilterState, setAdvancedFilterState] = React.useState({ logic: 'and', conditions: [] }) const [cacheStatus, setCacheStatus] = React.useState<'hit' | 'miss' | null>(null) const initialPersonIds = React.useMemo( @@ -580,7 +589,11 @@ export default function CustomersDealsPage() { const queryParams = React.useMemo(() => { const params = new URLSearchParams() params.set('page', String(page)) - params.set('pageSize', String(PAGE_SIZE)) + params.set('pageSize', String(pageSize)) + if (sorting.length > 0) { + params.set('sort', sorting[0].id) + params.set('order', sorting[0].desc ? 'desc' : 'asc') + } if (search.trim().length) params.set('search', search.trim()) if (selectedPersonIds.length) params.set('personId', selectedPersonIds.join(',')) if (selectedCompanyIds.length) params.set('companyId', selectedCompanyIds.join(',')) @@ -607,8 +620,12 @@ export default function CustomersDealsPage() { if (stringValue) params.set(key, stringValue) } }) + const advancedParams = serializeAdvancedFilter(advancedFilterState) + for (const [key, val] of Object.entries(advancedParams)) { + params.set(key, val) + } return params.toString() - }, [filterValues, page, search, selectedCompanyIds, selectedPersonIds]) + }, [advancedFilterState, filterValues, page, pageSize, search, selectedCompanyIds, selectedPersonIds, sorting]) const currentParams = React.useMemo( () => Object.fromEntries(new URLSearchParams(queryParams)), @@ -731,6 +748,39 @@ export default function CustomersDealsPage() { [confirm, handleRefresh, pendingDeleteId, t], ) + const handlePageSizeChange = React.useCallback((newSize: number) => { + setPageSize(newSize) + setPage(1) + }, []) + + const handleBulkDelete = React.useCallback(async (selectedRows: DealRow[]) => { + const confirmed = await confirm({ + title: t('customers.deals.list.bulkDelete.title', 'Delete {count} deals?', { count: selectedRows.length }), + description: t('customers.deals.list.bulkDelete.description', 'This action cannot be undone.'), + variant: 'destructive', + }) + if (!confirmed) return false + let deletedCount = 0 + for (const row of selectedRows) { + try { + await deleteCrud('customers/deals', { + body: { id: row.id }, + errorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'), + }) + deletedCount++ + } catch {} + } + if (deletedCount > 0) { + setRows((prev) => { + const deletedIds = new Set(selectedRows.map((r) => r.id)) + return prev.filter((r) => !deletedIds.has(r.id)) + }) + setTotal((prev) => Math.max(0, prev - deletedCount)) + flash(t('customers.deals.list.bulkDelete.success', '{count} deals deleted', { count: deletedCount }), 'success') + } + return deletedCount > 0 + }, [confirm, t]) + const personOptions = peopleState.options const companyOptions = companiesState.options @@ -786,57 +836,70 @@ export default function CustomersDealsPage() { ) } - const customColumns = filterCustomFieldDefs(customFieldDefs, 'list').map>((def) => ({ - accessorKey: `cf_${def.key}`, - header: def.label || def.key, - cell: ({ getValue }) => { - const value = getValue() - if (value == null) return noValue - if (Array.isArray(value)) { - const normalized = value - .map((item) => { - if (item == null) return '' - if (typeof item === 'string') return item.trim() - return String(item).trim() - }) - .filter((item) => item.length > 0) - if (!normalized.length) return noValue - return {normalized.join(', ')} - } - if (typeof value === 'boolean') { - return ( - - {value - ? t('customers.deals.list.booleanYes', 'Yes') - : t('customers.deals.list.booleanNo', 'No')} - - ) - } - const stringValue = typeof value === 'string' ? value.trim() : String(value) - if (!stringValue) return noValue - return {stringValue} - }, - })) + const customColumns = customFieldDefs + .filter((def) => supportsCustomFieldColumn(def)) + .map>((def) => ({ + accessorKey: `cf_${def.key}`, + header: def.label || def.key, + meta: { + columnChooserGroup: def.group?.title ?? 'Custom Fields', + filterGroup: def.group?.title ?? 'Custom Fields', + filterType: mapCustomFieldKindToFilterType(def.kind), + filterOptions: normalizeCustomFieldFilterOptions(def.options), + hidden: def.listVisible === false, + }, + cell: ({ getValue }) => { + const value = getValue() + if (value == null) return noValue + if (Array.isArray(value)) { + const normalized = value + .map((item) => { + if (item == null) return '' + if (typeof item === 'string') return item.trim() + return String(item).trim() + }) + .filter((item) => item.length > 0) + if (!normalized.length) return noValue + return {normalized.join(', ')} + } + if (typeof value === 'boolean') { + return ( + + {value + ? t('customers.deals.list.booleanYes', 'Yes') + : t('customers.deals.list.booleanNo', 'No')} + + ) + } + const stringValue = typeof value === 'string' ? value.trim() : String(value) + if (!stringValue) return noValue + return {stringValue} + }, + })) return [ { accessorKey: 'title', header: t('customers.deals.list.columns.title'), + meta: { alwaysVisible: true, columnChooserGroup: 'Basic Info', filterKey: 'title' }, cell: ({ row }) => {row.original.title}, }, { accessorKey: 'status', header: t('customers.deals.list.columns.status'), + meta: { filterType: 'select' as const, columnChooserGroup: 'Basic Info', filterKey: 'status' }, cell: ({ row }) => renderDictionaryCell('deal-statuses', row.original.status), }, { accessorKey: 'pipelineStage', header: t('customers.deals.list.columns.pipelineStage'), + meta: { columnChooserGroup: 'Pipeline', filterKey: 'pipeline_stage' }, cell: ({ row }) => renderDictionaryCell('pipeline-stages', row.original.pipelineStage), }, { accessorKey: 'pipelineId', header: t('customers.deals.list.columns.pipeline', 'Pipeline'), + meta: { columnChooserGroup: 'Pipeline', filterKey: 'pipeline_id' }, cell: ({ row }) => { const name = row.original.pipelineId ? pipelineNames[row.original.pipelineId] : null return name ? {name} : noValue @@ -845,6 +908,7 @@ export default function CustomersDealsPage() { { accessorKey: 'valueAmount', header: t('customers.deals.list.columns.value'), + meta: { filterType: 'number' as const, columnChooserGroup: 'Financial', filterKey: 'value_amount' }, cell: ({ row }) => ( {formatCurrency(row.original.valueAmount ?? null, row.original.valueCurrency ?? null, t('customers.deals.list.noValue'))} @@ -854,6 +918,7 @@ export default function CustomersDealsPage() { { accessorKey: 'probability', header: t('customers.deals.list.columns.probability'), + meta: { filterType: 'number' as const, columnChooserGroup: 'Financial', filterKey: 'probability' }, cell: ({ row }) => { const value = row.original.probability if (typeof value === 'number' && Number.isFinite(value)) { @@ -865,6 +930,7 @@ export default function CustomersDealsPage() { { accessorKey: 'expectedCloseAt', header: t('customers.deals.list.columns.expectedClose'), + meta: { columnChooserGroup: 'Dates', filterKey: 'expected_close_at' }, cell: ({ row }) => ( {formatDateValue(row.original.expectedCloseAt ?? null, t('customers.deals.list.noValue'))} @@ -874,16 +940,19 @@ export default function CustomersDealsPage() { { accessorKey: 'companies', header: t('customers.deals.list.columns.companies'), + meta: { columnChooserGroup: 'Associations', filterable: false }, cell: ({ row }) => renderAssociationList(row.original.companies, t('customers.deals.list.unnamedCompany')), }, { accessorKey: 'people', header: t('customers.deals.list.columns.people'), + meta: { columnChooserGroup: 'Associations', filterable: false }, cell: ({ row }) => renderAssociationList(row.original.people, t('customers.deals.list.unnamedPerson')), }, { accessorKey: 'updatedAt', header: t('customers.deals.list.columns.updatedAt'), + meta: { columnChooserGroup: 'Dates', filterKey: 'updated_at' }, cell: ({ row }) => ( {formatDateValue(row.original.updatedAt ?? null, t('customers.deals.list.noValue'))} @@ -898,6 +967,7 @@ export default function CustomersDealsPage() { + stickyFirstColumn title={t('customers.deals.list.title')} actions={( )} columns={columns} + columnChooser={{ auto: true }} data={rows} onRowClick={(row) => { router.push(`/backend/customers/deals/${row.id}`) @@ -942,6 +1013,17 @@ export default function CustomersDealsPage() { /> ) }} + sortable + sorting={sorting} + onSortingChange={setSorting} + bulkActions={[ + { + id: 'delete', + label: t('customers.deals.list.actions.delete', 'Delete'), + destructive: true, + onExecute: handleBulkDelete, + }, + ]} searchValue={search} onSearchChange={handleSearchChange} searchPlaceholder={t('customers.deals.list.searchPlaceholder')} @@ -951,10 +1033,12 @@ export default function CustomersDealsPage() { onFiltersClear={handleFiltersClear} pagination={{ page, - pageSize: PAGE_SIZE, + pageSize, total, totalPages, onPageChange: (nextPage) => setPage(nextPage), + pageSizeOptions: [10, 25, 50, 100], + onPageSizeChange: handlePageSizeChange, cacheStatus, }} isLoading={isLoading} @@ -965,6 +1049,14 @@ export default function CustomersDealsPage() { exporter={exportConfig} entityId={E.customers.customer_deal} perspective={{ tableId: 'customers.deals.list' }} + advancedFilter={{ + auto: true, + value: advancedFilterState, + onChange: setAdvancedFilterState, + onApply: () => { setPage(1) }, + onClear: () => { setAdvancedFilterState({ logic: 'and', conditions: [] }); setPage(1) }, + }} + virtualized /> {ConfirmDialogElement} diff --git a/packages/core/src/modules/customers/backend/customers/people/page.tsx b/packages/core/src/modules/customers/backend/customers/people/page.tsx index 219d7cfd71c..450443db5e1 100644 --- a/packages/core/src/modules/customers/backend/customers/people/page.tsx +++ b/packages/core/src/modules/customers/backend/customers/people/page.tsx @@ -17,6 +17,8 @@ import { useT } from '@open-mercato/shared/lib/i18n/context' import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog' import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar' import type { FilterOption } from '@open-mercato/ui/backend/FilterOverlay' +import type { AdvancedFilterState } from '@open-mercato/shared/lib/query/advanced-filter' +import { serializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter' import { DictionaryValue, renderDictionaryColor, @@ -26,8 +28,12 @@ import { } from '../../../lib/dictionaries' import { useCustomFieldDefs, - filterCustomFieldDefs, } from '@open-mercato/ui/backend/utils/customFieldDefs' +import { + mapCustomFieldKindToFilterType, + normalizeCustomFieldFilterOptions, + supportsCustomFieldColumn, +} from '@open-mercato/ui/backend/utils/customFieldColumns' import { useQueryClient } from '@tanstack/react-query' import { ensureCustomerDictionary } from '../../../components/detail/hooks/useCustomerDictionary' @@ -37,6 +43,16 @@ type PersonRow = { description?: string | null email?: string | null phone?: string | null + firstName?: string | null + lastName?: string | null + preferredName?: string | null + jobTitle?: string | null + department?: string | null + seniority?: string | null + timezone?: string | null + linkedInUrl?: string | null + twitterUrl?: string | null + companyEntityId?: string | null status?: string | null lifecycleStage?: string | null nextInteractionAt?: string | null @@ -87,6 +103,16 @@ function mapApiItem(item: Record): PersonRow | null { const description = typeof item.description === 'string' ? item.description : null const email = typeof item.primary_email === 'string' ? item.primary_email : null const phone = typeof item.primary_phone === 'string' ? item.primary_phone : null + const firstName = typeof item.first_name === 'string' ? item.first_name : null + const lastName = typeof item.last_name === 'string' ? item.last_name : null + const preferredName = typeof item.preferred_name === 'string' ? item.preferred_name : null + const jobTitle = typeof item.job_title === 'string' ? item.job_title : null + const department = typeof item.department === 'string' ? item.department : null + const seniority = typeof item.seniority === 'string' ? item.seniority : null + const timezone = typeof item.timezone === 'string' ? item.timezone : null + const linkedInUrl = typeof item.linked_in_url === 'string' ? item.linked_in_url : null + const twitterUrl = typeof item.twitter_url === 'string' ? item.twitter_url : null + const companyEntityId = typeof item.company_entity_id === 'string' ? item.company_entity_id : null const status = typeof item.status === 'string' ? item.status : null const lifecycleStage = typeof item.lifecycle_stage === 'string' ? item.lifecycle_stage : null const nextInteractionAt = typeof item.next_interaction_at === 'string' ? item.next_interaction_at : null @@ -107,6 +133,16 @@ function mapApiItem(item: Record): PersonRow | null { description, email, phone, + firstName, + lastName, + preferredName, + jobTitle, + department, + seniority, + timezone, + linkedInUrl, + twitterUrl, + companyEntityId, status, lifecycleStage, nextInteractionAt, @@ -123,11 +159,13 @@ export default function CustomersPeoplePage() { const { confirm, ConfirmDialogElement } = useConfirmDialog() const [rows, setRows] = React.useState([]) const [page, setPage] = React.useState(1) - const [pageSize] = React.useState(20) + const [pageSize, setPageSize] = React.useState(20) + const [sorting, setSorting] = React.useState([]) const [total, setTotal] = React.useState(0) const [totalPages, setTotalPages] = React.useState(1) const [search, setSearch] = React.useState('') const [filterValues, setFilterValues] = React.useState({}) + const [advancedFilterState, setAdvancedFilterState] = React.useState({ logic: 'and', conditions: [] }) const [isLoading, setIsLoading] = React.useState(true) const [reloadToken, setReloadToken] = React.useState(0) const [cacheStatus, setCacheStatus] = React.useState<'hit' | 'miss' | null>(null) @@ -137,6 +175,10 @@ export default function CustomersPeoplePage() { const queryClient = useQueryClient() const t = useT() const router = useRouter() + const handlePageSizeChange = React.useCallback((newSize: number) => { + setPageSize(newSize) + setPage(1) + }, []) const fetchDictionaryEntries = React.useCallback(async (kind: DictionaryKindKey) => { try { const data = await ensureCustomerDictionary(queryClient, kind, scopeVersion) @@ -305,6 +347,10 @@ export default function CustomersPeoplePage() { const params = new URLSearchParams() params.set('page', String(page)) params.set('pageSize', String(pageSize)) + if (sorting.length > 0) { + params.set('sort', sorting[0].id) + params.set('order', sorting[0].desc ? 'desc' : 'asc') + } if (search.trim()) params.set('search', search.trim()) const status = filterValues.status if (typeof status === 'string' && status.trim()) params.set('status', status) @@ -364,8 +410,12 @@ export default function CustomersPeoplePage() { if (stringValue) params.set(key, stringValue) } }) + const advancedParams = serializeAdvancedFilter(advancedFilterState) + for (const [key, val] of Object.entries(advancedParams)) { + params.set(key, val) + } return params.toString() - }, [filterValues, page, pageSize, search, tagIdToLabel, tagLabelToId]) + }, [advancedFilterState, filterValues, page, pageSize, search, sorting, tagIdToLabel, tagLabelToId]) const currentParams = React.useMemo(() => Object.fromEntries(new URLSearchParams(queryParams)), [queryParams]) const exportConfig = React.useMemo(() => ({ @@ -446,6 +496,35 @@ export default function CustomersPeoplePage() { } }, [confirm, handleRefresh, t]) + const handleBulkDelete = React.useCallback(async (selectedRows: PersonRow[]) => { + const confirmed = await confirm({ + title: t('customers.people.list.bulkDelete.title', 'Delete {count} people?', { count: selectedRows.length }), + description: t('customers.people.list.bulkDelete.description', 'This action cannot be undone.'), + variant: 'destructive', + }) + if (!confirmed) return false + let deletedCount = 0 + for (const row of selectedRows) { + try { + await apiCallOrThrow(`/api/customers/people?id=${encodeURIComponent(row.id)}`, { + method: 'DELETE', + headers: { 'content-type': 'application/json' }, + }) + deletedCount++ + } catch {} + } + if (deletedCount > 0) { + setRows((prev) => { + const deletedIds = new Set(selectedRows.map((r) => r.id)) + return prev.filter((r) => !deletedIds.has(r.id)) + }) + setTotal((prev) => Math.max(0, prev - deletedCount)) + flash(t('customers.people.list.bulkDelete.success', '{count} people deleted', { count: deletedCount }), 'success') + setReloadToken((prev) => prev + 1) + } + return deletedCount > 0 + }, [confirm, t]) + const handleFiltersApply = React.useCallback((values: FilterValues) => { const next: FilterValues = {} Object.entries(values).forEach(([key, value]) => { @@ -518,6 +597,7 @@ export default function CustomersPeoplePage() { { accessorKey: 'name', header: t('customers.people.list.columns.name'), + meta: { alwaysVisible: true, columnChooserGroup: 'Basic Info', filterKey: 'display_name' }, cell: ({ row }) => ( {row.original.name} @@ -527,22 +607,32 @@ export default function CustomersPeoplePage() { { accessorKey: 'email', header: t('customers.people.list.columns.email'), + meta: { columnChooserGroup: 'Contact', filterKey: 'primary_email' }, cell: ({ row }) => row.original.email || {t('customers.people.list.noValue')}, }, { accessorKey: 'status', header: t('customers.people.list.columns.status'), + meta: { filterType: 'select' as const, filterOptions: dictionaryOptions.statuses, columnChooserGroup: 'Basic Info' }, cell: ({ row }) => renderDictionaryCell('statuses', row.original.status), }, { accessorKey: 'lifecycleStage', header: t('customers.people.list.columns.lifecycleStage'), + meta: { + filterType: 'select' as const, + filterOptions: dictionaryOptions.lifecycleStages, + columnChooserGroup: 'Basic Info', + filterKey: 'lifecycle_stage', + }, cell: ({ row }) => renderDictionaryCell('lifecycle-stages', row.original.lifecycleStage), }, { accessorKey: 'nextInteractionAt', header: t('customers.people.list.columns.nextInteraction'), meta: { + columnChooserGroup: 'Dates', + filterKey: 'next_interaction_at', tooltipContent: (row: PersonRow) => { if (!row.nextInteractionAt) return undefined const date = formatDate(row.nextInteractionAt, '') @@ -577,23 +667,94 @@ export default function CustomersPeoplePage() { { accessorKey: 'source', header: t('customers.people.list.columns.source'), + meta: { filterType: 'select' as const, filterOptions: dictionaryOptions.sources, columnChooserGroup: 'Basic Info' }, cell: ({ row }) => renderDictionaryCell('sources', row.original.source), }, + { + accessorKey: 'firstName', + header: t('customers.people.form.firstName', 'First name'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.first_name' }, + cell: ({ row }) => row.original.firstName || noValue, + }, + { + accessorKey: 'lastName', + header: t('customers.people.form.lastName', 'Last name'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.last_name' }, + cell: ({ row }) => row.original.lastName || noValue, + }, + { + accessorKey: 'preferredName', + header: t('customers.people.form.preferredName', 'Preferred name'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.preferred_name' }, + cell: ({ row }) => row.original.preferredName || noValue, + }, + { + accessorKey: 'jobTitle', + header: t('customers.people.form.jobTitle', 'Job title'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.job_title' }, + cell: ({ row }) => row.original.jobTitle || noValue, + }, + { + accessorKey: 'department', + header: t('customers.people.detail.fields.department', 'Department'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.department' }, + cell: ({ row }) => row.original.department || noValue, + }, + { + accessorKey: 'seniority', + header: t('customers.people.detail.fields.seniority', 'Seniority'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.seniority' }, + cell: ({ row }) => row.original.seniority || noValue, + }, + { + accessorKey: 'timezone', + header: t('customers.people.detail.fields.timezone', 'Timezone'), + meta: { columnChooserGroup: 'Profile', hidden: true, filterKey: 'person_profile.timezone' }, + cell: ({ row }) => row.original.timezone || noValue, + }, + { + accessorKey: 'linkedInUrl', + header: t('customers.people.detail.fields.linkedIn', 'LinkedIn'), + meta: { columnChooserGroup: 'Socials', hidden: true, filterKey: 'person_profile.linked_in_url' }, + cell: ({ row }) => row.original.linkedInUrl || noValue, + }, + { + accessorKey: 'twitterUrl', + header: t('customers.people.detail.fields.twitter', 'Twitter'), + meta: { columnChooserGroup: 'Socials', hidden: true, filterKey: 'person_profile.twitter_url' }, + cell: ({ row }) => row.original.twitterUrl || noValue, + }, + { + accessorKey: 'description', + header: t('customers.people.form.description', 'Description'), + meta: { columnChooserGroup: 'Notes', hidden: true, filterKey: 'description' }, + cell: ({ row }) => row.original.description || noValue, + }, ] - const customColumns = filterCustomFieldDefs(customFieldDefs, 'list').map>((def) => ({ - accessorKey: `cf_${def.key}`, - header: def.label || def.key, - cell: ({ getValue }) => renderCustomFieldCell(getValue()), - })) + const customColumns = customFieldDefs + .filter((def) => supportsCustomFieldColumn(def)) + .map>((def) => ({ + accessorKey: `cf_${def.key}`, + header: def.label || def.key, + meta: { + columnChooserGroup: def.group?.title ?? 'Custom Fields', + filterGroup: def.group?.title ?? 'Custom Fields', + filterType: mapCustomFieldKindToFilterType(def.kind), + filterOptions: normalizeCustomFieldFilterOptions(def.options), + hidden: def.listVisible === false, + }, + cell: ({ getValue }) => renderCustomFieldCell(getValue()), + })) return [...baseColumns, ...customColumns] - }, [customFieldDefs, dictionaryMaps, t]) + }, [customFieldDefs, dictionaryMaps, dictionaryOptions, t]) return ( + stickyFirstColumn title={t('customers.people.list.title')} refreshButton={{ label: t('customers.people.list.actions.refresh'), @@ -607,6 +768,7 @@ export default function CustomersPeoplePage() { )} columns={columns} + columnChooser={{ auto: true }} data={rows} exporter={exportConfig} searchValue={search} @@ -619,6 +781,17 @@ export default function CustomersPeoplePage() { entityIds={[E.customers.customer_entity, E.customers.customer_person_profile]} perspective={{ tableId: 'customers.people.list' }} onRowClick={(row) => router.push(`/backend/customers/people-v2/${row.id}`)} + sortable + sorting={sorting} + onSortingChange={setSorting} + bulkActions={[ + { + id: 'delete', + label: t('customers.people.list.bulkDelete.action', 'Delete selected'), + destructive: true, + onExecute: handleBulkDelete, + }, + ]} rowActions={(row) => ( )} - pagination={{ page, pageSize, total, totalPages, onPageChange: setPage, cacheStatus }} + advancedFilter={{ + auto: true, + value: advancedFilterState, + onChange: setAdvancedFilterState, + onApply: () => { setPage(1) }, + onClear: () => { setAdvancedFilterState({ logic: 'and', conditions: [] }); setPage(1) }, + }} + virtualized + pagination={{ page, pageSize, total, totalPages, onPageChange: setPage, cacheStatus, pageSizeOptions: [10, 25, 50, 100], onPageSizeChange: handlePageSizeChange }} isLoading={isLoading} /> diff --git a/packages/core/src/modules/customers/commands/interactions.ts b/packages/core/src/modules/customers/commands/interactions.ts index fea312251ba..fbeba9df38d 100644 --- a/packages/core/src/modules/customers/commands/interactions.ts +++ b/packages/core/src/modules/customers/commands/interactions.ts @@ -28,6 +28,7 @@ import { ensureTenantScope, requireCustomerEntity, extractUndoPayload, + emitQueryIndexUpsertEvents, requireDealInScope, resolveParentResourceKind, } from './shared' @@ -233,6 +234,12 @@ async function emitNextInteractionUpdatedEvent( projection: InteractionProjectionMutation, identifiers: InteractionIdentifiers, ): Promise { + await emitQueryIndexUpsertEvents(ctx, [{ + entityType: 'customers:customer_entity', + recordId: projection.entityId, + organizationId: identifiers.organizationId, + tenantId: identifiers.tenantId, + }]) await emitLifecycleEvent(ctx, 'customers.next_interaction.updated', { id: projection.entityId, entityId: projection.entityId, diff --git a/packages/core/src/modules/customers/components/detail/DealForm.tsx b/packages/core/src/modules/customers/components/detail/DealForm.tsx index 07fef3715a2..232d1b1326e 100644 --- a/packages/core/src/modules/customers/components/detail/DealForm.tsx +++ b/packages/core/src/modules/customers/components/detail/DealForm.tsx @@ -392,6 +392,7 @@ function EntityMultiSelect({ onMouseDown={(event) => event.preventDefault()} onClick={() => addOption(option)} disabled={disabled} + aria-label={option.label} > {option.label} diff --git a/packages/core/src/modules/customers/i18n/de.json b/packages/core/src/modules/customers/i18n/de.json index 748a41dea18..80cc0100f1a 100644 --- a/packages/core/src/modules/customers/i18n/de.json +++ b/packages/core/src/modules/customers/i18n/de.json @@ -218,6 +218,10 @@ "customers.companies.list.actions.view": "Details anzeigen", "customers.companies.list.booleanNo": "Nein", "customers.companies.list.booleanYes": "Ja", + "customers.companies.list.bulkDelete.action": "Delete selected", + "customers.companies.list.bulkDelete.description": "This action cannot be undone.", + "customers.companies.list.bulkDelete.success": "{count} companies deleted", + "customers.companies.list.bulkDelete.title": "Delete {count} companies?", "customers.companies.list.columns.email": "E-Mail", "customers.companies.list.columns.lifecycleStage": "Lebenszyklusphase", "customers.companies.list.columns.name": "Name", @@ -420,6 +424,10 @@ "customers.deals.list.actions.openInNewTab": "In neuem Tab öffnen", "customers.deals.list.booleanNo": "Nein", "customers.deals.list.booleanYes": "Ja", + "customers.deals.list.bulkDelete.action": "Delete selected", + "customers.deals.list.bulkDelete.description": "This action cannot be undone.", + "customers.deals.list.bulkDelete.success": "{count} deals deleted", + "customers.deals.list.bulkDelete.title": "Delete {count} deals?", "customers.deals.list.columns.companies": "Unternehmen", "customers.deals.list.columns.expectedClose": "Voraussichtlicher Abschluss", "customers.deals.list.columns.people": "Personen", @@ -979,6 +987,10 @@ "customers.people.list.actions.view": "Details anzeigen", "customers.people.list.booleanNo": "Nein", "customers.people.list.booleanYes": "Ja", + "customers.people.list.bulkDelete.action": "Delete selected", + "customers.people.list.bulkDelete.description": "This action cannot be undone.", + "customers.people.list.bulkDelete.success": "{count} people deleted", + "customers.people.list.bulkDelete.title": "Delete {count} people?", "customers.people.list.columns.email": "E-Mail", "customers.people.list.columns.lifecycleStage": "Lebenszyklusphase", "customers.people.list.columns.name": "Name", diff --git a/packages/core/src/modules/customers/i18n/en.json b/packages/core/src/modules/customers/i18n/en.json index 77616455c6c..54bc93cc364 100644 --- a/packages/core/src/modules/customers/i18n/en.json +++ b/packages/core/src/modules/customers/i18n/en.json @@ -218,6 +218,10 @@ "customers.companies.list.actions.view": "View details", "customers.companies.list.booleanNo": "No", "customers.companies.list.booleanYes": "Yes", + "customers.companies.list.bulkDelete.action": "Delete selected", + "customers.companies.list.bulkDelete.description": "This action cannot be undone.", + "customers.companies.list.bulkDelete.success": "{count} companies deleted", + "customers.companies.list.bulkDelete.title": "Delete {count} companies?", "customers.companies.list.columns.email": "Email", "customers.companies.list.columns.lifecycleStage": "Lifecycle stage", "customers.companies.list.columns.name": "Name", @@ -242,7 +246,7 @@ "customers.companies.list.filters.status": "Status", "customers.companies.list.filters.tags": "Tags", "customers.companies.list.noValue": "Not set", - "customers.companies.list.searchPlaceholder": "Search companies", + "customers.companies.list.searchPlaceholder": "Search by name, email, phone…", "customers.companies.list.tags.loadError": "Failed to load tags.", "customers.companies.list.title": "Companies", "customers.config.addressFormat.description": "Choose how address forms and displays should be structured across the customer module.", @@ -420,6 +424,10 @@ "customers.deals.list.actions.openInNewTab": "Open in new tab", "customers.deals.list.booleanNo": "No", "customers.deals.list.booleanYes": "Yes", + "customers.deals.list.bulkDelete.action": "Delete selected", + "customers.deals.list.bulkDelete.description": "This action cannot be undone.", + "customers.deals.list.bulkDelete.success": "{count} deals deleted", + "customers.deals.list.bulkDelete.title": "Delete {count} deals?", "customers.deals.list.columns.companies": "Companies", "customers.deals.list.columns.expectedClose": "Expected close", "customers.deals.list.columns.people": "People", @@ -440,7 +448,7 @@ "customers.deals.list.filters.peoplePlaceholder": "Filter by people", "customers.deals.list.noValue": "Not set", "customers.deals.list.refresh": "Refresh", - "customers.deals.list.searchPlaceholder": "Search deals…", + "customers.deals.list.searchPlaceholder": "Search by title, description…", "customers.deals.list.title": "Deals", "customers.deals.list.unnamedCompany": "Unnamed company", "customers.deals.list.unnamedPerson": "Unnamed person", @@ -979,6 +987,10 @@ "customers.people.list.actions.view": "View details", "customers.people.list.booleanNo": "No", "customers.people.list.booleanYes": "Yes", + "customers.people.list.bulkDelete.action": "Delete selected", + "customers.people.list.bulkDelete.description": "This action cannot be undone.", + "customers.people.list.bulkDelete.success": "{count} people deleted", + "customers.people.list.bulkDelete.title": "Delete {count} people?", "customers.people.list.columns.email": "Email", "customers.people.list.columns.lifecycleStage": "Lifecycle stage", "customers.people.list.columns.name": "Name", @@ -1003,7 +1015,7 @@ "customers.people.list.filters.status": "Status", "customers.people.list.filters.tags": "Tags", "customers.people.list.noValue": "Not set", - "customers.people.list.searchPlaceholder": "Search people", + "customers.people.list.searchPlaceholder": "Search by name, email, phone…", "customers.people.list.title": "People", "customers.pipelines.actions.create": "Add pipeline", "customers.pipelines.actions.delete": "Delete", diff --git a/packages/core/src/modules/customers/i18n/es.json b/packages/core/src/modules/customers/i18n/es.json index b4ae45a57a2..353cd6d5a66 100644 --- a/packages/core/src/modules/customers/i18n/es.json +++ b/packages/core/src/modules/customers/i18n/es.json @@ -218,6 +218,10 @@ "customers.companies.list.actions.view": "Ver detalles", "customers.companies.list.booleanNo": "No", "customers.companies.list.booleanYes": "Sí", + "customers.companies.list.bulkDelete.action": "Delete selected", + "customers.companies.list.bulkDelete.description": "This action cannot be undone.", + "customers.companies.list.bulkDelete.success": "{count} companies deleted", + "customers.companies.list.bulkDelete.title": "Delete {count} companies?", "customers.companies.list.columns.email": "Correo electrónico", "customers.companies.list.columns.lifecycleStage": "Etapa del ciclo de vida", "customers.companies.list.columns.name": "Nombre", @@ -420,6 +424,10 @@ "customers.deals.list.actions.openInNewTab": "Abrir en nueva pestaña", "customers.deals.list.booleanNo": "No", "customers.deals.list.booleanYes": "Sí", + "customers.deals.list.bulkDelete.action": "Delete selected", + "customers.deals.list.bulkDelete.description": "This action cannot be undone.", + "customers.deals.list.bulkDelete.success": "{count} deals deleted", + "customers.deals.list.bulkDelete.title": "Delete {count} deals?", "customers.deals.list.columns.companies": "Empresas", "customers.deals.list.columns.expectedClose": "Cierre esperado", "customers.deals.list.columns.people": "Personas", @@ -979,6 +987,10 @@ "customers.people.list.actions.view": "Ver detalles", "customers.people.list.booleanNo": "No", "customers.people.list.booleanYes": "Sí", + "customers.people.list.bulkDelete.action": "Delete selected", + "customers.people.list.bulkDelete.description": "This action cannot be undone.", + "customers.people.list.bulkDelete.success": "{count} people deleted", + "customers.people.list.bulkDelete.title": "Delete {count} people?", "customers.people.list.columns.email": "Correo electrónico", "customers.people.list.columns.lifecycleStage": "Etapa del ciclo de vida", "customers.people.list.columns.name": "Nombre", diff --git a/packages/core/src/modules/customers/i18n/pl.json b/packages/core/src/modules/customers/i18n/pl.json index 2c82683a14a..72bdd1cfd3d 100644 --- a/packages/core/src/modules/customers/i18n/pl.json +++ b/packages/core/src/modules/customers/i18n/pl.json @@ -218,6 +218,10 @@ "customers.companies.list.actions.view": "Zobacz szczegóły", "customers.companies.list.booleanNo": "Nie", "customers.companies.list.booleanYes": "Tak", + "customers.companies.list.bulkDelete.action": "Delete selected", + "customers.companies.list.bulkDelete.description": "This action cannot be undone.", + "customers.companies.list.bulkDelete.success": "{count} companies deleted", + "customers.companies.list.bulkDelete.title": "Delete {count} companies?", "customers.companies.list.columns.email": "E-mail", "customers.companies.list.columns.lifecycleStage": "Etap cyklu życia", "customers.companies.list.columns.name": "Nazwa", @@ -420,6 +424,10 @@ "customers.deals.list.actions.openInNewTab": "Otwórz w nowej karcie", "customers.deals.list.booleanNo": "Nie", "customers.deals.list.booleanYes": "Tak", + "customers.deals.list.bulkDelete.action": "Delete selected", + "customers.deals.list.bulkDelete.description": "This action cannot be undone.", + "customers.deals.list.bulkDelete.success": "{count} deals deleted", + "customers.deals.list.bulkDelete.title": "Delete {count} deals?", "customers.deals.list.columns.companies": "Firmy", "customers.deals.list.columns.expectedClose": "Planowana finalizacja", "customers.deals.list.columns.people": "Osoby", @@ -979,6 +987,10 @@ "customers.people.list.actions.view": "Zobacz szczegóły", "customers.people.list.booleanNo": "Nie", "customers.people.list.booleanYes": "Tak", + "customers.people.list.bulkDelete.action": "Delete selected", + "customers.people.list.bulkDelete.description": "This action cannot be undone.", + "customers.people.list.bulkDelete.success": "{count} people deleted", + "customers.people.list.bulkDelete.title": "Delete {count} people?", "customers.people.list.columns.email": "E-mail", "customers.people.list.columns.lifecycleStage": "Etap cyklu życia", "customers.people.list.columns.name": "Nazwa", diff --git a/packages/core/src/modules/query_index/lib/engine.ts b/packages/core/src/modules/query_index/lib/engine.ts index 3b43654bed8..f54a4c28274 100644 --- a/packages/core/src/modules/query_index/lib/engine.ts +++ b/packages/core/src/modules/query_index/lib/engine.ts @@ -445,6 +445,7 @@ export class HybridQueryEngine implements QueryEngine { ? await this.searchSourcesHaveTokens(searchSources, opts.tenantId ?? null, orgScope) : false const searchRuntime: SearchRuntime = { ...searchRuntimeBase, searchSources, enabled: searchEnabled && hasSearchTokens } + const joinSearchAvailability = new Map() const searchFilters = normalizeFilters(opts.filters).filter((filter) => filter.op === 'like' || filter.op === 'ilike') if (searchFilters.length) { this.logSearchDebug('search:init', { @@ -591,7 +592,10 @@ export class HybridQueryEngine implements QueryEngine { ) } - for (const filter of baseFilters) { + const regularBaseFilters = baseFilters.filter((filter) => !filter.orGroup) + const orGroupFilters = baseFilters.filter((filter) => filter.orGroup) + + for (const filter of regularBaseFilters) { const fieldName = String(filter.field) const baseField = resolveBaseColumn(fieldName) if (!baseField) { @@ -640,6 +644,61 @@ export class HybridQueryEngine implements QueryEngine { } } + const applyOrGroupedBaseFilters = (target: ResultBuilder | null): ResultBuilder | null => { + if (!target || orGroupFilters.length === 0) return target + const groups = new Map() + for (const filter of orGroupFilters) { + if (!filter.orGroup) continue + const existing = groups.get(filter.orGroup) ?? [] + existing.push(filter) + groups.set(filter.orGroup, existing) + } + let next = target + for (const [, groupFilters] of groups) { + if (!groupFilters.length) continue + next = next.where((groupBuilder) => { + groupFilters.forEach((filter, index) => { + const fieldName = String(filter.field) + const baseField = resolveBaseColumn(fieldName) + const applyCondition = (conditionBuilder: ResultBuilder) => { + if (!baseField) { + this.applyIndexDocFilterFromAlias( + knex, + conditionBuilder, + 'ei', + entity, + fieldName, + filter.op, + filter.value, + 'b.id', + searchRuntime, + ) + return + } + this.applyColumnFilter(conditionBuilder, qualify(baseField), filter, { + ...searchRuntime, + knex, + entity, + field: fieldName, + recordIdColumn: 'b.id', + }) + } + if (index === 0) { + applyCondition(groupBuilder as ResultBuilder) + return + } + groupBuilder.orWhere((conditionBuilder) => { + applyCondition(conditionBuilder as ResultBuilder) + }) + }) + }) + } + return next + } + + builder = applyOrGroupedBaseFilters(builder) ?? builder + optimizedCountBuilder = applyOrGroupedBaseFilters(optimizedCountBuilder) + const applyAliasScopes = async (target: ResultBuilder, aliasName: string) => { const tableName = aliasTables.get(aliasName) if (!tableName) return @@ -688,6 +747,37 @@ export class HybridQueryEngine implements QueryEngine { } } + const applyJoinSearchFilterOp = async ( + target: ResultBuilder, + filter: { column: string; op: FilterOp; value?: unknown }, + _qualified: string, + join: ResolvedJoin, + ): Promise => { + if (!searchEnabled || !join.entityId) return false + if (!['eq', 'like', 'ilike'].includes(filter.op)) return false + if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return false + + let searchAvailable = joinSearchAvailability.get(join.entityId) + if (searchAvailable === undefined) { + searchAvailable = await this.hasSearchTokens(String(join.entityId), opts.tenantId ?? null, orgScope) + joinSearchAvailability.set(join.entityId, searchAvailable) + } + if (!searchAvailable) return false + + const tokens = tokenizeText(String(filter.value), searchConfig) + if (!tokens.hashes.length) return false + + return this.applySearchTokens(target, { + knex, + entity: String(join.entityId), + field: filter.column, + hashes: tokens.hashes, + recordIdColumn: `${join.alias}.id`, + tenantId: opts.tenantId ?? null, + organizationScope: orgScope, + }) + } + await applyJoinFilters({ knex, baseTable, @@ -698,6 +788,8 @@ export class HybridQueryEngine implements QueryEngine { qualifyBase: (column) => qualify(column), applyAliasScope: (target, alias) => applyAliasScopes(target, alias), applyFilterOp: (target, column, op, value) => applyJoinFilterOp(target as ResultBuilder, column, op, value), + applyJoinFilterOp: (target, filter, qualified, join) => + applyJoinSearchFilterOp(target as ResultBuilder, filter, qualified, join), columnExists: (tbl, column) => this.columnExists(tbl, column), }) as ResultBuilder @@ -712,6 +804,8 @@ export class HybridQueryEngine implements QueryEngine { qualifyBase: (column) => qualify(column), applyAliasScope: (target, alias) => applyAliasScopes(target, alias), applyFilterOp: (target, column, op, value) => applyJoinFilterOp(target as ResultBuilder, column, op, value), + applyJoinFilterOp: (target, filter, qualified, join) => + applyJoinSearchFilterOp(target as ResultBuilder, filter, qualified, join), columnExists: (tbl, column) => this.columnExists(tbl, column), }) } diff --git a/packages/shared/src/lib/crud/advanced-filter-integration.ts b/packages/shared/src/lib/crud/advanced-filter-integration.ts new file mode 100644 index 00000000000..e8faec0ebc4 --- /dev/null +++ b/packages/shared/src/lib/crud/advanced-filter-integration.ts @@ -0,0 +1,74 @@ +import { deserializeAdvancedFilter, convertAdvancedFilterToWhere } from '../query/advanced-filter' + +function splitOrClauses(filters: Record): { + directFilters: Record + orClauses: Record[] | null +} { + const directFilters: Record = {} + let orClauses: Record[] | null = null + + for (const [key, value] of Object.entries(filters)) { + if (key === '$or' && Array.isArray(value)) { + orClauses = value.filter((entry): entry is Record => Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry)) + continue + } + directFilters[key] = value + } + + return { directFilters, orClauses } +} + +/** + * Parse advanced filter query params and merge with existing Where filters. + * Call this in buildFilters callback to support advanced filter query params. + */ +export function mergeAdvancedFilters( + existingFilters: Record, + query: Record, +): Record { + const advancedState = deserializeAdvancedFilter(query) + if (!advancedState) return existingFilters + + const advancedWhere = convertAdvancedFilterToWhere(advancedState) + if (!Object.keys(advancedWhere).length) return existingFilters + + if ('$or' in advancedWhere) { + if (!Object.keys(existingFilters).length) return advancedWhere + + const { directFilters: existingDirect, orClauses: existingOrClauses } = splitOrClauses(existingFilters) + const advancedClauses = Array.isArray((advancedWhere as { $or?: unknown }).$or) + ? ((advancedWhere as { $or: unknown[] }).$or).filter( + (entry): entry is Record => Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), + ) + : [] + + if (!advancedClauses.length) return existingFilters + + if (!existingOrClauses?.length) { + return { + ...existingDirect, + $or: advancedClauses, + } + } + + const combinedClauses: Record[] = [] + for (const leftClause of existingOrClauses) { + for (const rightClause of advancedClauses) { + combinedClauses.push({ + ...leftClause, + ...rightClause, + }) + } + } + + return combinedClauses.length + ? { + ...existingDirect, + $or: combinedClauses, + } + : existingFilters + } + + // AND logic: merge directly + return { ...existingFilters, ...advancedWhere } +} diff --git a/packages/shared/src/lib/crud/factory.ts b/packages/shared/src/lib/crud/factory.ts index 3cb9dc2a2fb..f50e23d07d8 100644 --- a/packages/shared/src/lib/crud/factory.ts +++ b/packages/shared/src/lib/crud/factory.ts @@ -59,12 +59,22 @@ import type { EnricherContext } from './response-enricher' import type { ApiInterceptorMethod, InterceptorRequest, InterceptorResponse } from './api-interceptor' import { runApiInterceptorsAfter, runApiInterceptorsBefore } from './interceptor-runner' import { mergeIdFilter, parseIdsParam } from './ids' +import { mergeAdvancedFilters } from './advanced-filter-integration' import { parseExtensionHeaders } from '../umes/extension-headers' type RbacServiceLike = { getGrantedFeatures: (userId: string, opts: { tenantId: string | null; organizationId: string | null }) => Promise } +function resolveSortParams(queryParams: Record) { + const rawSortField = queryParams.sortField ?? queryParams.sort ?? 'id' + const rawSortDir = queryParams.sortDir ?? queryParams.order ?? 'asc' + const sortField = typeof rawSortField === 'string' && rawSortField.trim().length > 0 ? rawSortField.trim() : 'id' + const normalizedDir = typeof rawSortDir === 'string' ? rawSortDir.trim().toLowerCase() : 'asc' + const sortDir = normalizedDir === 'desc' ? SortDir.Desc : SortDir.Asc + return { sortField, sortDir } +} + export type CrudHooks = { beforeList?: (q: TList, ctx: CrudCtx) => Promise | void afterList?: (res: any, ctx: CrudCtx & { query: TList }) => Promise | void @@ -1330,16 +1340,18 @@ export function makeCrudRoute(opts: C profiler.mark('query_engine_prepare') const qe = (ctx.container.resolve('queryEngine') as QueryEngine) profiler.mark('query_engine_resolved') - const sortFieldRaw = (queryParams as any).sortField || 'id' - const sortDirRaw = ((queryParams as any).sortDir || 'asc').toLowerCase() === 'desc' ? SortDir.Desc : SortDir.Asc + const { sortField: sortFieldRaw, sortDir: sortDirRaw } = resolveSortParams(queryParams as Record) const sortField = (opts.list.sortFieldMap && opts.list.sortFieldMap[sortFieldRaw]) || sortFieldRaw const sort: Sort[] = [{ field: sortField as any, dir: sortDirRaw } as any] const page: Page = exportRequested ? { page: 1, pageSize: exportPageSize } : { page: requestedPage, pageSize: requestedPageSize } - const filters = exportFullRequested + const baseFilters = exportFullRequested ? ({} as Where) : (opts.list.buildFilters ? await opts.list.buildFilters(validated as any, ctx) : ({} as Where)) + const filters = exportFullRequested + ? baseFilters + : mergeAdvancedFilters(baseFilters as Record, validated as Record) as Where const mergedFilters = exportFullRequested ? filters : mergeIdFilter(filters, parsedIds) const withDeleted = parseBooleanToken((queryParams as any).withDeleted) === true profiler.mark('filters_ready', { withDeleted }) @@ -1604,9 +1616,12 @@ export function makeCrudRoute(opts: C }) return response } - const fallbackFilters = exportFullRequested + const fallbackBaseFilters = exportFullRequested ? ({} as Where) : (opts.list.buildFilters ? await opts.list.buildFilters(validated as any, ctx) : ({} as Where)) + const fallbackFilters = exportFullRequested + ? fallbackBaseFilters + : mergeAdvancedFilters(fallbackBaseFilters as Record, validated as Record) as Where const mergedFallbackFilters = exportFullRequested ? fallbackFilters : mergeIdFilter(fallbackFilters, parsedIds) diff --git a/packages/shared/src/lib/query/__tests__/advanced-filter.test.ts b/packages/shared/src/lib/query/__tests__/advanced-filter.test.ts new file mode 100644 index 00000000000..f5dd859eaff --- /dev/null +++ b/packages/shared/src/lib/query/__tests__/advanced-filter.test.ts @@ -0,0 +1,75 @@ +/** @jest-environment node */ + +import { + convertAdvancedFilterToWhere, + deserializeAdvancedFilter, + serializeAdvancedFilter, + type AdvancedFilterState, +} from '../advanced-filter' + +describe('advanced filter', () => { + it('serializes per-row join operators and compiles mixed joins into OR clauses', () => { + const state: AdvancedFilterState = { + logic: 'and', + conditions: [ + { id: '1', field: 'display_name', operator: 'contains', value: 'solar', join: 'and' }, + { id: '2', field: 'primary_email', operator: 'contains', value: 'info', join: 'or' }, + { id: '3', field: 'company_profile.domain', operator: 'contains', value: 'com', join: 'and' }, + ], + } + + expect(serializeAdvancedFilter(state)).toMatchObject({ + 'filter[logic]': 'and', + 'filter[conditions][1][join]': 'or', + 'filter[conditions][2][join]': 'and', + }) + + expect(convertAdvancedFilterToWhere(state)).toEqual({ + $or: [ + { + display_name: { $ilike: '%solar%' }, + 'company_profile.domain': { $ilike: '%com%' }, + }, + { + primary_email: { $ilike: '%info%' }, + 'company_profile.domain': { $ilike: '%com%' }, + }, + ], + }) + }) + + it('keeps backward compatibility with legacy global logic query params', () => { + expect( + deserializeAdvancedFilter({ + 'filter[logic]': 'or', + 'filter[conditions][0][field]': 'display_name', + 'filter[conditions][0][op]': 'contains', + 'filter[conditions][0][value]': 'solar', + 'filter[conditions][1][field]': 'primary_email', + 'filter[conditions][1][op]': 'contains', + 'filter[conditions][1][value]': 'info', + }), + ).toEqual({ + logic: 'or', + conditions: [ + { id: '0', field: 'display_name', operator: 'contains', value: 'solar', join: 'and' }, + { id: '1', field: 'primary_email', operator: 'contains', value: 'info', join: 'or' }, + ], + }) + }) + + it('ignores empty values for operators that require a concrete value', () => { + expect( + convertAdvancedFilterToWhere({ + logic: 'and', + conditions: [ + { id: '0', field: 'next_interaction_at', operator: 'is_after', value: '' }, + { id: '1', field: 'lifecycle_stage', operator: 'is', value: 'customer', join: 'or' }, + { id: '2', field: 'primary_email', operator: 'contains', value: ' ' }, + ], + }), + ).toEqual({ + lifecycle_stage: { $eq: 'customer' }, + }) + }) +}) diff --git a/packages/shared/src/lib/query/advanced-filter.ts b/packages/shared/src/lib/query/advanced-filter.ts new file mode 100644 index 00000000000..9d238ac34b4 --- /dev/null +++ b/packages/shared/src/lib/query/advanced-filter.ts @@ -0,0 +1,304 @@ +import { escapeLikePattern } from '../db/escapeLikePattern' + +export type FilterOperator = + | 'is' | 'is_not' | 'contains' | 'does_not_contain' | 'starts_with' | 'ends_with' | 'is_empty' | 'is_not_empty' + | 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'greater_or_equal' | 'less_or_equal' | 'between' + | 'is_before' | 'is_after' + | 'is_any_of' | 'is_none_of' + | 'is_true' | 'is_false' + | 'has_any_of' | 'has_all_of' | 'has_none_of' + +export type FilterFieldType = 'text' | 'number' | 'date' | 'select' | 'boolean' | 'tags' + +export type FilterOption = { + value: string + label: string +} + +export type FilterFieldDef = { + key: string + label: string + type: FilterFieldType + group?: string + loadOptions?: (query?: string) => Promise + options?: FilterOption[] +} + +export type FilterJoinOperator = 'and' | 'or' + +export type FilterCondition = { + id: string + field: string + operator: FilterOperator + value: unknown + join?: FilterJoinOperator +} + +export type AdvancedFilterState = { + logic: FilterJoinOperator + conditions: FilterCondition[] +} + +export const OPERATORS_BY_FIELD_TYPE: Record = { + text: ['is', 'is_not', 'contains', 'does_not_contain', 'starts_with', 'ends_with', 'is_empty', 'is_not_empty'], + number: ['equals', 'not_equals', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal', 'between', 'is_empty'], + date: ['is', 'is_before', 'is_after', 'between', 'is_empty', 'is_not_empty'], + select: ['is', 'is_not', 'is_any_of', 'is_none_of', 'is_empty'], + boolean: ['is_true', 'is_false'], + tags: ['has_any_of', 'has_all_of', 'has_none_of', 'is_empty'], +} + +export function getDefaultOperator(fieldType: FilterFieldType): FilterOperator { + switch (fieldType) { + case 'text': return 'contains' + case 'number': return 'equals' + case 'date': return 'is_after' + case 'select': return 'is' + case 'boolean': return 'is_true' + case 'tags': return 'has_any_of' + } +} + +const VALID_OPERATORS = new Set([ + 'is', 'is_not', 'contains', 'does_not_contain', 'starts_with', 'ends_with', 'is_empty', 'is_not_empty', + 'equals', 'not_equals', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal', 'between', + 'is_before', 'is_after', + 'is_any_of', 'is_none_of', + 'is_true', 'is_false', + 'has_any_of', 'has_all_of', 'has_none_of', +]) + +export function isValidOperator(op: string): op is FilterOperator { + return VALID_OPERATORS.has(op) +} + +export function isValuelessOperator(operator: FilterOperator): boolean { + return operator === 'is_empty' || operator === 'is_not_empty' || operator === 'is_true' || operator === 'is_false' +} + +export function createEmptyCondition(): FilterCondition { + return { + id: crypto.randomUUID(), + field: '', + operator: 'contains', + value: '', + join: 'and', + } +} + +function normalizeJoinOperator(value: unknown, fallback: FilterJoinOperator = 'and'): FilterJoinOperator { + return value === 'or' ? 'or' : value === 'and' ? 'and' : fallback +} + +function parseSerializedFilterValue(value: unknown): unknown { + if (typeof value !== 'string') return value ?? null + const trimmed = value.trim() + if (!trimmed) return value + if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) return value + try { + return JSON.parse(trimmed) + } catch { + return value + } +} + +export function normalizeAdvancedFilterState(state: AdvancedFilterState): AdvancedFilterState { + const logic = normalizeJoinOperator(state.logic) + return { + logic, + conditions: state.conditions.map((condition, index) => ({ + ...condition, + join: index === 0 ? 'and' : normalizeJoinOperator(condition.join, logic), + })), + } +} + +export function serializeAdvancedFilter(state: AdvancedFilterState): Record { + const params: Record = {} + const normalized = normalizeAdvancedFilterState(state) + if (!normalized.conditions.length) return params + params['filter[logic]'] = normalized.logic + normalized.conditions.forEach((condition, index) => { + const prefix = `filter[conditions][${index}]` + params[`${prefix}[field]`] = condition.field + params[`${prefix}[op]`] = condition.operator + if (index > 0) { + params[`${prefix}[join]`] = condition.join ?? normalized.logic + } + if (!isValuelessOperator(condition.operator) && condition.value != null) { + params[`${prefix}[value]`] = typeof condition.value === 'object' + ? JSON.stringify(condition.value) + : String(condition.value) + } + }) + return params +} + +export function deserializeAdvancedFilter(query: Record): AdvancedFilterState | null { + const logic = normalizeJoinOperator(query['filter[logic]']) + const conditions: FilterCondition[] = [] + for (let i = 0; i < 20; i++) { + const field = query[`filter[conditions][${i}][field]`] + const op = query[`filter[conditions][${i}][op]`] + if (typeof field !== 'string' || typeof op !== 'string') break + if (!isValidOperator(op)) continue + const value = query[`filter[conditions][${i}][value]`] + const join = i === 0 + ? 'and' + : normalizeJoinOperator(query[`filter[conditions][${i}][join]`], logic) + conditions.push({ + id: String(i), + field, + operator: op, + value: parseSerializedFilterValue(value), + join, + }) + } + + if (!conditions.length) return null + return normalizeAdvancedFilterState({ logic, conditions }) +} + +function buildConditionFilter(condition: FilterCondition): Record | null { + if (!condition.field || !condition.operator) return null + const normalizeSingleValue = (value: unknown): unknown => { + if (typeof value !== 'string') return value + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null + } + const normalizeListValue = (value: unknown): unknown[] => { + const list = Array.isArray(value) ? value : [value] + return list + .map((entry) => normalizeSingleValue(entry)) + .filter((entry) => entry !== null) + } + const filter: Record = {} + switch (condition.operator) { + case 'is': + case 'equals': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $eq: normalizeSingleValue(condition.value) } + break + case 'is_not': + case 'not_equals': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $ne: normalizeSingleValue(condition.value) } + break + case 'contains': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $ilike: `%${escapeLikePattern(String(normalizeSingleValue(condition.value)))}%` } + break + case 'does_not_contain': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $not: { $ilike: `%${escapeLikePattern(String(normalizeSingleValue(condition.value)))}%` } } + break + case 'starts_with': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $ilike: `${escapeLikePattern(String(normalizeSingleValue(condition.value)))}%` } + break + case 'ends_with': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $ilike: `%${escapeLikePattern(String(normalizeSingleValue(condition.value)))}` } + break + case 'is_empty': + filter[condition.field] = { $exists: false } + break + case 'is_not_empty': + filter[condition.field] = { $exists: true } + break + case 'greater_than': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $gt: normalizeSingleValue(condition.value) } + break + case 'less_than': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $lt: normalizeSingleValue(condition.value) } + break + case 'greater_or_equal': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $gte: normalizeSingleValue(condition.value) } + break + case 'less_or_equal': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $lte: normalizeSingleValue(condition.value) } + break + case 'between': + if (Array.isArray(condition.value) && condition.value.length === 2) { + const start = normalizeSingleValue(condition.value[0]) + const end = normalizeSingleValue(condition.value[1]) + if (start === null && end === null) return null + if (start !== null && end !== null) { + filter[condition.field] = { $gte: start, $lte: end } + } else if (start !== null) { + filter[condition.field] = { $gte: start } + } else if (end !== null) { + filter[condition.field] = { $lte: end } + } + } + break + case 'is_before': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $lt: normalizeSingleValue(condition.value) } + break + case 'is_after': + if (normalizeSingleValue(condition.value) === null) return null + filter[condition.field] = { $gt: normalizeSingleValue(condition.value) } + break + case 'is_any_of': + case 'has_any_of': + if (normalizeListValue(condition.value).length === 0) return null + filter[condition.field] = { $in: normalizeListValue(condition.value) } + break + case 'is_none_of': + case 'has_none_of': + if (normalizeListValue(condition.value).length === 0) return null + filter[condition.field] = { $nin: normalizeListValue(condition.value) } + break + case 'has_all_of': { + const allOfValues = normalizeListValue(condition.value) + if (allOfValues.length === 0) return null + filter[condition.field] = { $contains: allOfValues } + break + } + case 'is_true': + filter[condition.field] = { $eq: true } + break + case 'is_false': + filter[condition.field] = { $eq: false } + break + } + return Object.keys(filter).length > 0 ? filter : null +} + +export function convertAdvancedFilterToWhere(state: AdvancedFilterState): Record { + const normalized = normalizeAdvancedFilterState(state) + if (!normalized.conditions.length) return {} + + const conditionEntries = normalized.conditions + .map((condition) => { + const filter = buildConditionFilter(condition) + return filter + ? { + join: normalizeJoinOperator(condition.join, normalized.logic), + filter, + } + : null + }) + .filter((entry): entry is { join: FilterJoinOperator; filter: Record } => entry !== null) + + if (!conditionEntries.length) return {} + + let clauses: Record[] = [conditionEntries[0].filter] + for (const entry of conditionEntries.slice(1)) { + if (entry.join === 'or') { + clauses = [...clauses, entry.filter] + continue + } + clauses = clauses.map((clause) => ({ + ...clause, + ...entry.filter, + })) + } + + return clauses.length > 1 ? { $or: clauses } : clauses[0] +} diff --git a/packages/shared/src/lib/query/engine.ts b/packages/shared/src/lib/query/engine.ts index 1ac7b68c107..51cf38dd90c 100644 --- a/packages/shared/src/lib/query/engine.ts +++ b/packages/shared/src/lib/query/engine.ts @@ -235,6 +235,7 @@ export class BasicQueryEngine implements QueryEngine { ? await this.hasSearchTokens(String(entity), opts.tenantId ?? null, orgScope) : false const searchActive = searchEnabled && hasSearchTokens + const joinSearchAvailability = new Map() const searchFilters = [...baseFilters, ...cfFilters].filter((filter) => filter.op === 'like' || filter.op === 'ilike') if (searchFilters.length) { const fields = searchFilters.map((filter) => String(filter.field)) @@ -322,7 +323,41 @@ export class BasicQueryEngine implements QueryEngine { return builder } - for (const filter of baseFilters) { + const applyJoinFilterOp = async ( + builder: any, + filter: { column: string; op: string; value?: unknown }, + _qualified: string, + join: ResolvedJoin, + ): Promise => { + if (!searchEnabled || !join.entityId) return false + if (!['eq', 'like', 'ilike'].includes(filter.op)) return false + if (typeof filter.value !== 'string' || filter.value.trim().length === 0) return false + + let searchAvailable = joinSearchAvailability.get(join.entityId) + if (searchAvailable === undefined) { + searchAvailable = await this.hasSearchTokens(join.entityId, opts.tenantId ?? null, orgScope) + joinSearchAvailability.set(join.entityId, searchAvailable) + } + if (!searchAvailable) return false + + const tokens = tokenizeText(String(filter.value), searchConfig) + if (!tokens.hashes.length) return false + + return this.applySearchTokens(builder, { + entity: join.entityId, + field: filter.column, + hashes: tokens.hashes, + recordIdColumn: `${join.alias}.id`, + tenantId: opts.tenantId ?? null, + organizationScope: orgScope, + tokens: tokens.tokens, + }) + } + + const regularBaseFilters = baseFilters.filter((f) => !f.orGroup) + const orGroupFilters = baseFilters.filter((f) => f.orGroup) + + for (const filter of regularBaseFilters) { const fieldName = String(filter.field) let qualified = filter.qualified ?? null if (!qualified) { @@ -347,6 +382,44 @@ export class BasicQueryEngine implements QueryEngine { applyFilterOp(q, qualified, filter.op, filter.value, fieldName) } + // Apply OR-grouped filters as a single WHERE (... OR ... OR ...) + if (orGroupFilters.length > 0) { + const groups = new Map() + for (const f of orGroupFilters) { + const group = groups.get(f.orGroup!) ?? [] + group.push(f) + groups.set(f.orGroup!, group) + } + for (const [, groupFilters] of groups) { + const resolvedOrFilters: Array<{ qualified: string; op: string; value: unknown; fieldName: string }> = [] + for (const filter of groupFilters) { + const column = await this.resolveBaseColumn(table, String(filter.field)) + if (column) { + resolvedOrFilters.push({ + qualified: qualify(column), + op: filter.op, + value: filter.value, + fieldName: String(filter.field), + }) + } + } + if (resolvedOrFilters.length > 0) { + q = q.where(function (this: any) { + for (let i = 0; i < resolvedOrFilters.length; i++) { + const rf = resolvedOrFilters[i] + if (i === 0) { + applyFilterOp(this, rf.qualified, rf.op, rf.value, rf.fieldName) + continue + } + this.orWhere(function (this: any) { + applyFilterOp(this, rf.qualified, rf.op, rf.value, rf.fieldName) + }) + } + }) + } + } + } + const applyAliasScopes = async (builder: any, aliasName: string) => { const targetTable = aliasTables.get(aliasName) if (!targetTable) return @@ -367,6 +440,7 @@ export class BasicQueryEngine implements QueryEngine { qualifyBase: (column) => qualify(column), applyAliasScope: (builder, alias) => applyAliasScopes(builder, alias), applyFilterOp, + applyJoinFilterOp, columnExists: (tbl, column) => this.columnExists(tbl, column), }) // Selection (base columns only here; cf:* handled later) diff --git a/packages/shared/src/lib/query/join-utils.ts b/packages/shared/src/lib/query/join-utils.ts index 1b1c5886eb5..cd4efb44d07 100644 --- a/packages/shared/src/lib/query/join-utils.ts +++ b/packages/shared/src/lib/query/join-utils.ts @@ -2,7 +2,7 @@ import type { Knex } from 'knex' import type { QueryOptions, QueryJoinEdge } from './types' import type { FilterOp } from './types' -export type NormalizedFilter = { field: string; op: FilterOp; value?: unknown } +export type NormalizedFilter = { field: string; op: FilterOp; value?: unknown; orGroup?: string; qualified?: string | null } export function normalizeFilters(filters?: QueryOptions['filters']): NormalizedFilter[] { if (!filters) return [] @@ -15,10 +15,32 @@ export function normalizeFilters(filters?: QueryOptions['filters']): NormalizedF } const out: NormalizedFilter[] = [] const obj = filters as Record - const push = (field: string, op: FilterOp, value?: unknown) => { - out.push({ field, op, value }) + const push = (field: string, op: FilterOp, value?: unknown, orGroup?: string) => { + out.push({ field, op, value, orGroup }) + } + // Handle $or at top level + if (Array.isArray(obj.$or)) { + const orGroupId = `or_${Date.now()}` + for (const clause of obj.$or as Record[]) { + if (clause && typeof clause === 'object') { + for (const [rawKey, rawVal] of Object.entries(clause)) { + const field = normalizeField(rawKey) + if (rawVal !== null && typeof rawVal === 'object' && !Array.isArray(rawVal)) { + for (const [opKey, opVal] of Object.entries(rawVal as Record)) { + const op = opKey.replace('$', '') as FilterOp + if (['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'ilike', 'exists'].includes(op)) { + push(field, op, opVal, orGroupId) + } + } + } else { + push(field, 'eq', rawVal, orGroupId) + } + } + } + } } for (const [rawKey, rawVal] of Object.entries(obj)) { + if (rawKey === '$or') continue const field = normalizeField(rawKey) if (rawVal !== null && typeof rawVal === 'object' && !Array.isArray(rawVal)) { for (const [opKey, opVal] of Object.entries(rawVal as Record)) { @@ -68,13 +90,14 @@ export function normalizeFilters(filters?: QueryOptions['filters']): NormalizedF export type ResolvedJoin = { alias: string table: string + entityId?: string | null fromAlias: string fromField: string toField: string type: 'left' | 'inner' } -export type BaseFilter = NormalizedFilter & { qualified?: string } +export type BaseFilter = NormalizedFilter & { qualified?: string | null } export type JoinFilter = { alias: string; column: string; op: FilterOp; value?: unknown } export function resolveJoins( @@ -100,7 +123,15 @@ export function resolveJoins( const fromAliasRaw = entry.from?.alias?.trim() const fromAlias = fromAliasRaw && fromAliasRaw.length > 0 ? fromAliasRaw : 'base' const type: 'left' | 'inner' = entry.type === 'inner' ? 'inner' : 'left' - resolved.push({ alias, table, fromAlias, fromField, toField, type }) + resolved.push({ + alias, + table, + entityId: entry.entityId ? String(entry.entityId) : null, + fromAlias, + fromField, + toField, + type, + }) seen.add(alias) } return resolved @@ -171,6 +202,7 @@ type ApplyJoinFiltersOptions = { qualifyBase: (column: string) => string applyAliasScope: (builder: Knex.QueryBuilder, alias: string, table: string) => Promise | void applyFilterOp: (builder: Knex.QueryBuilder, column: string, op: FilterOp, value?: unknown) => void + applyJoinFilterOp?: (builder: Knex.QueryBuilder, filter: JoinFilter, qualified: string, join: ResolvedJoin, table: string) => Promise | boolean columnExists?: (table: string, column: string) => Promise | boolean } @@ -184,6 +216,7 @@ export async function applyJoinFilters({ qualifyBase, applyAliasScope, applyFilterOp, + applyJoinFilterOp, columnExists, }: ApplyJoinFiltersOptions): Promise { const resolveAliasName = (aliasName?: string | null) => { @@ -222,6 +255,8 @@ export async function applyJoinFilters({ else if (existsDirective === null) existsDirective = true continue } + const join = joinMap.get(filter.alias) + if (!join) continue const targetTable = aliasTables.get(filter.alias) if (!targetTable) continue if (columnExists) { @@ -229,6 +264,10 @@ export async function applyJoinFilters({ if (!exists) continue } const qualified = `${filter.alias}.${filter.column}` + if (applyJoinFilterOp) { + const handled = await applyJoinFilterOp(sub, filter, qualified, join, targetTable) + if (handled) continue + } applyFilterOp(sub, qualified, filter.op, filter.value) } if (existsDirective === false) builder = builder.whereNotExists(sub) diff --git a/packages/ui/package.json b/packages/ui/package.json index 8cfea0c2149..de5c642ae40 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -116,8 +116,12 @@ } }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-virtual": "^3.13.23", "date-fns": "^4.1.0", "react-big-calendar": "^1.19.4", "react-day-picker": "^9.6.4", diff --git a/packages/ui/src/backend/DataTable.tsx b/packages/ui/src/backend/DataTable.tsx index 82913e05d3a..a22b5f6ebf9 100644 --- a/packages/ui/src/backend/DataTable.tsx +++ b/packages/ui/src/backend/DataTable.tsx @@ -3,7 +3,7 @@ import * as React from 'react' import { useRouter } from 'next/navigation' import { useReactTable, getCoreRowModel, getSortedRowModel, flexRender, type ColumnDef, type SortingState, type Column as TableColumn, type VisibilityState, type RowSelectionState } from '@tanstack/react-table' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle } from 'lucide-react' +import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, Columns3 } from 'lucide-react' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../primitives/table' import { Button } from '../primitives/button' import { Checkbox } from '../primitives/checkbox' @@ -41,6 +41,27 @@ import type { } from '@open-mercato/shared/modules/widgets/injection' import { ComponentReplacementHandles } from '@open-mercato/shared/modules/widgets/component-registry' import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position' +import { useVirtualizer } from '@tanstack/react-virtual' +import type { AdvancedFilterState, FilterFieldDef as AdvancedFilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter' +import { createEmptyCondition, getDefaultOperator } from '@open-mercato/shared/lib/query/advanced-filter' +import { AdvancedFilterBuilder } from './filters/AdvancedFilterBuilder' +import { ColumnChooserPanel, type ColumnChooserField } from './columns/ColumnChooserPanel' +import { useAutoDiscoveredFields } from './utils/useAutoDiscoveredFields' +import { useCustomFieldDefs } from './utils/customFieldDefs' +import { + DndContext, + closestCenter, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core' +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' let refreshScheduled = false @@ -65,6 +86,8 @@ export type PaginationProps = { onPageChange: (page: number) => void durationMs?: number | null cacheStatus?: 'hit' | 'miss' | null + pageSizeOptions?: number[] + onPageSizeChange?: (pageSize: number) => void } export type DataTableRefreshButton = { @@ -91,6 +114,16 @@ export function withDataTableNamespaces>( } } +function resolveDataTableRowId(row: T, index: number): string { + if (row && typeof row === 'object') { + const candidate = (row as Record).id + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate + } + } + return String(index) +} + function resolveDefaultRowAction(items: RowActionItem[], preferredIds: string[]): RowActionItem | null { for (const preferredId of preferredIds) { const match = items.find((item) => item.id === preferredId && (item.href || item.onSelect)) @@ -142,6 +175,14 @@ export type DataTablePerspectiveConfig = { } } +export type BulkAction> = { + id: string + label: string + icon?: React.ComponentType<{ className?: string }> + destructive?: boolean + onExecute: (selectedRows: T[]) => Promise | void | boolean | BulkActionExecuteResult +} + export type DataTableProps = { columns: ColumnDef[] data: T[] @@ -156,16 +197,11 @@ export type DataTableProps = { isLoading?: boolean emptyState?: React.ReactNode error?: React.ReactNode | string | null - // Optional per-row actions renderer. When provided, an extra trailing column is rendered. rowActions?: (row: T) => React.ReactNode - // Optional row click handler. When provided, rows become clickable and show pointer cursor. - // If not provided, DataTable will execute the first row action whose id matches rowClickActionIds. onRowClick?: (row: T) => void - // Preferred action ids for default row clicks (applies when onRowClick is not set). - // Defaults to ['edit', 'open']. rowClickActionIds?: string[] - // Disable row click navigation when rowActions are present. disableRowClick?: boolean + bulkActions?: BulkAction[] // Auto FilterBar options (rendered as toolbar when provided and no custom toolbar passed) searchValue?: string @@ -176,7 +212,6 @@ export type DataTableProps = { filterValues?: FilterValues onFiltersApply?: (values: FilterValues) => void onFiltersClear?: () => void - // When provided, DataTable will fetch custom field definitions and append filter controls for filterable ones. entityId?: string entityIds?: string[] exporter?: DataTableExportConfig | false @@ -187,6 +222,22 @@ export type DataTableProps = { injectionSpotId?: string injectionContext?: Record replacementHandle?: string + stickyFirstColumn?: boolean + virtualized?: boolean + virtualizedMaxHeight?: number | string + virtualizedOverscan?: number + advancedFilter?: { + fields?: AdvancedFilterFieldDef[] + auto?: boolean + value: AdvancedFilterState + onChange: (state: AdvancedFilterState) => void + onApply: () => void + onClear: () => void + } + columnChooser?: { + availableColumns?: ColumnChooserField[] + auto?: boolean + } } const DEFAULT_EXPORT_FORMATS: DataTableExportFormat[] = ['csv', 'json', 'xml', 'markdown'] @@ -622,6 +673,50 @@ function ExportMenu({ config, sections }: { config: DataTableExportConfig; secti ) } +function sanitizeDndContextId(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + return normalized.length > 0 ? normalized : 'data-table' +} + +function HeaderDndWrapper({ enabled, contextId, sensors, columnIds, onDragEnd, children }: { + enabled: boolean + contextId: string + sensors: ReturnType + columnIds: string[] + onDragEnd: (event: DragEndEvent) => void + children: React.ReactNode +}) { + if (!enabled) return <>{children} + return ( + + + {children} + + + ) +} + +function SortableHeaderCell({ id, children, className }: { id: string; children: React.ReactNode; className?: string }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }) + const isSticky = typeof className === 'string' && className.includes('sticky') + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + cursor: 'grab', + position: isSticky ? 'sticky' : 'relative', + } + return ( + + {children} + + ) +} + export function DataTable({ columns, data, @@ -640,10 +735,11 @@ export function DataTable({ onRowClick, rowClickActionIds, disableRowClick = false, + bulkActions: bulkActionsProp, searchValue, onSearchChange, searchPlaceholder, - searchAlign = 'right', + searchAlign = 'left', filters: baseFilters = EMPTY_FILTER_DEFS, filterValues = EMPTY_FILTER_VALUES, onFiltersApply, @@ -658,6 +754,12 @@ export function DataTable({ injectionSpotId, injectionContext, replacementHandle, + stickyFirstColumn = false, + virtualized = false, + virtualizedMaxHeight, + virtualizedOverscan = 10, + advancedFilter, + columnChooser, }: DataTableProps) { const t = useT() const { confirm, ConfirmDialogElement } = useConfirmDialog() @@ -705,6 +807,8 @@ export function DataTable({ const mergedInitialSettings = initialSettingsFromConfig ?? initialSettingsFromSnapshot ?? null const initialActiveId = perspectiveConfig?.initialState?.activePerspectiveId ?? initialSnapshot?.perspectiveId ?? null const [isPerspectiveOpen, setPerspectiveOpen] = React.useState(false) + const [isAdvancedFilterOpen, setAdvancedFilterOpen] = React.useState(false) + const [isColumnChooserOpen, setColumnChooserOpen] = React.useState(false) const [activePerspectiveId, setActivePerspectiveId] = React.useState(initialActiveId) const [columnVisibility, setColumnVisibility] = React.useState(() => mergedInitialSettings?.columnVisibility ?? {}) const [columnOrder, setColumnOrder] = React.useState(() => mergedInitialSettings?.columnOrder ?? []) @@ -1033,7 +1137,7 @@ export function DataTable({ // All other columns are always rendered; horizontal scroll (min-w + overflow-auto) // handles narrow viewports so users can swipe to reach every column. const responsiveClass = (_priority?: number, hidden?: boolean) => { - if (hidden) return 'hidden' + if (hidden) return '' return '' } @@ -1066,13 +1170,15 @@ export function DataTable({ activeClientFilters.every((cf) => cf.filterFn(row, filterValues[cf.id])), ) }, [data, injectedClientFilters, filterValues]) - const hasInjectedBulkActions = injectedBulkActions.length > 0 + const hasPropBulkActions = Array.isArray(bulkActionsProp) && bulkActionsProp.length > 0 + const hasInjectedBulkActions = injectedBulkActions.length > 0 || hasPropBulkActions const [rowSelection, setRowSelection] = React.useState({}) const table = useReactTable({ data: clientFilteredData, columns: mergedColumns, getCoreRowModel: getCoreRowModel(), ...(sortable ? { getSortedRowModel: getSortedRowModel() } : {}), + getRowId: resolveDataTableRowId, state: { sorting, columnVisibility, columnOrder, rowSelection }, enableRowSelection: hasInjectedBulkActions, onSortingChange: (updater) => { @@ -1404,6 +1510,57 @@ export function DataTable({ }) }, [table]) + const handleColumnChooserToggle = React.useCallback((key: string) => { + const column = table.getColumn(key) + if (!column) return + const nextVisible = !column.getIsVisible() + if (nextVisible) { + setColumnOrder((prev) => (prev.includes(key) ? prev : [...prev, key])) + } + setColumnVisibility((prev) => { + const next = { ...prev } + if (nextVisible) delete next[key] + else next[key] = false + return next + }) + column.toggleVisibility(nextVisible) + }, [table]) + + const handleColumnChooserReorder = React.useCallback((newOrder: string[]) => { + setColumnOrder(newOrder) + table.setColumnOrder(newOrder) + }, [table]) + + const dndSensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })) + const enableHeaderDnd = Boolean(columnChooser) + const stableDndContextId = React.useMemo( + () => sanitizeDndContextId( + extensionTableId + ?? perspectiveTableId + ?? resolvedReplacementHandle + ?? (typeof title === 'string' && title.trim().length > 0 ? title : 'data-table'), + ), + [extensionTableId, perspectiveTableId, resolvedReplacementHandle, title], + ) + const headerColumnIds = React.useMemo(() => { + if (!enableHeaderDnd) return [] + return table.getHeaderGroups().flatMap((hg) => hg.headers.map((h) => h.id)) + }, [enableHeaderDnd, table, columnOrder]) + + const handleHeaderDragEnd = React.useCallback((event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + const currentIds = columnOrder.length ? columnOrder : table.getAllLeafColumns().map((c) => c.id) + const oldIdx = currentIds.indexOf(String(active.id)) + const newIdx = currentIds.indexOf(String(over.id)) + if (oldIdx === -1 || newIdx === -1) return + const next = [...currentIds] + const [moved] = next.splice(oldIdx, 1) + next.splice(newIdx, 0, moved) + setColumnOrder(next) + table.setColumnOrder(next) + }, [columnOrder, table]) + const perspectiveApiWarning = perspectiveApiMissing && canUsePerspectives ? t('ui.dataTable.perspectives.warning.apiUnavailable', 'Perspectives API is not available yet. Run `npm run modules:prepare` to regenerate module routes, then restart the server.') : null @@ -1498,9 +1655,35 @@ export function DataTable({ ) : null + const pageSizeOptions = Array.isArray(pagination.pageSizeOptions) + ? Array.from(new Set( + [pagination.pageSize, ...pagination.pageSizeOptions] + .filter((size): size is number => typeof size === 'number' && Number.isFinite(size) && size > 0) + .map((size) => Math.max(1, Math.floor(size))), + )).sort((left, right) => left - right) + : [] + const pageSizeSelect = pageSizeOptions.length > 0 && pagination.onPageSizeChange ? ( + + + {t('ui.dataTable.pagination.perPage', 'per page')} + + ) : null + return (
    -
    +
    {durationLabel ? t('ui.dataTable.pagination.resultsWithDuration', 'Showing {start} to {end} of {total} results in {duration}', { start: startItem, end: endItem, total: pagination.total, duration: durationLabel }) @@ -1508,6 +1691,7 @@ export function DataTable({ } {cacheBadge} + {pageSizeSelect}
    ) })} + {selectedRows.length > 0 ? (bulkActionsProp ?? []).map((action) => { + const ActionIcon = action.icon + return ( + + ) + }) : null}
    ) : null return ( @@ -1853,9 +2085,13 @@ export function DataTable({ handleCustomFieldFilterFieldsetChange, cfFilterFieldsetsByEntity, hasInjectedBulkActions, + hasPropBulkActions, injectedBulkActions, + bulkActionsProp, selectedRows.length, + selectedRows, runBulkAction, + runPropBulkAction, ]) const hasTitle = title != null @@ -1868,16 +2104,35 @@ export function DataTable({ const hasRefreshButton = Boolean(refreshButtonConfig) const hasToolbar = builtToolbar != null const hasToolbarInjection = Boolean(toolbarInjectionSpotId) - const shouldRenderActionsWrapper = hasActions || hasRefreshButton || shouldReserveActionsSpace || hasExport || hasToolbarInjection + const shouldRenderActionsWrapper = hasActions || hasRefreshButton || shouldReserveActionsSpace || hasExport || hasToolbarInjection || Boolean(advancedFilter) const renderToolbarInline = embedded && hasToolbar const shouldRenderToolbarBelow = hasToolbar && !renderToolbarInline const shouldRenderHeader = hasTitle || renderToolbarInline || shouldRenderActionsWrapper || shouldRenderToolbarBelow - const containerClassName = embedded ? '' : 'rounded-lg border bg-card' + const containerClassName = embedded ? '' : 'rounded-lg border bg-card mx-1 sm:mx-2' const headerWrapperClassName = embedded ? 'pb-3' : 'px-4 py-3 border-b' const headerContentClassName = 'flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between' const toolbarWrapperClassName = embedded ? 'mt-2' : 'mt-3 pt-3 border-t' const tableScrollWrapperClassName = embedded ? '' : 'overflow-auto' + const virtualScrollRef = React.useRef(null) + const allRows = table.getRowModel().rows + const rowVirtualizer = virtualized + ? useVirtualizer({ + count: allRows.length, + getScrollElement: () => virtualScrollRef.current, + estimateSize: () => 48, + overscan: virtualizedOverscan, + }) + : null + const virtualMaxHeightStyle: React.CSSProperties | undefined = virtualized + ? { + maxHeight: typeof virtualizedMaxHeight === 'number' + ? `${virtualizedMaxHeight}px` + : virtualizedMaxHeight ?? 'calc(100vh - 300px)', + overflow: 'auto', + } + : undefined + const titleContent = hasTitle ? (
    {typeof title === 'string' ?

    {title}

    : title} @@ -1914,6 +2169,46 @@ export function DataTable({ {refreshButtonConfig.label} ) : null} + {advancedFilter ? ( + + ) : null} + {columnChooser ? ( + + ) : null} {canUsePerspectives ? (
    )} -
    + {advancedFilter && isAdvancedFilterOpen ? ( +
    + { advancedFilter.onApply(); setAdvancedFilterOpen(false) }} + onClear={() => { advancedFilter.onClear(); setAdvancedFilterOpen(false) }} + /> +
    + ) : null} + {advancedFilter && advancedFilter.value.conditions.length > 0 && !isAdvancedFilterOpen ? ( +
    + + {t('ui.advancedFilter.activeCount', '{count} active filters', { count: advancedFilter.value.conditions.length })} + + + +
    + ) : null} + +
    {table.getHeaderGroups().map((hg) => ( @@ -1960,23 +2286,34 @@ export function DataTable({ /> ) : null} - {hg.headers.map((header) => { + {hg.headers.map((header, headerIndex) => { const columnMeta = (header.column.columnDef as any)?.meta const priority = resolvePriority(header.column) - return ( - - {header.isPlaceholder ? null : ( - - )} + const isFirstDataColumn = headerIndex === 0 + const stickyClass = stickyFirstColumn && isFirstDataColumn ? ' sticky left-0 z-10 bg-background' : '' + const headerCellContent = header.isPlaceholder ? null : ( + + ) + return enableHeaderDnd ? ( + + {headerCellContent} + + ) : ( + + {headerCellContent} ) })} @@ -2004,8 +2341,19 @@ export function DataTable({ {error} - ) : table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => { + ) : allRows.length ? ( + <> + {virtualized && rowVirtualizer ? ( + <> + {rowVirtualizer.getVirtualItems()[0]?.start > 0 ? ( + + ) : null} + + ) : null} + {(virtualized && rowVirtualizer + ? rowVirtualizer.getVirtualItems().map((vi) => allRows[vi.index]) + : allRows + ).map((row) => { const rowActionsElement = resolvedRowActions(row.original as T) const defaultRowAction = onRowClick ? null : pickDefaultRowAction(rowActionsElement, resolvedRowClickActionIds) const isClickable = !disableRowClick && (onRowClick || defaultRowAction) @@ -2042,9 +2390,10 @@ export function DataTable({ /> ) : null} - {row.getVisibleCells().map((cell) => { + {row.getVisibleCells().map((cell, cellIndex) => { const columnMeta = (cell.column.columnDef as any)?.meta const priority = resolvePriority(cell.column) + const isStickyCell = stickyFirstColumn && cellIndex === 0 const hasCustomCell = Boolean(cell.column.columnDef.cell) const columnId = String((cell.column as any).id || '') const accessorKey = String((cell.column.columnDef as any)?.accessorKey || '') @@ -2083,7 +2432,7 @@ export function DataTable({ ) : content return ( - + {wrappedContent} ) @@ -2095,7 +2444,14 @@ export function DataTable({ ) : null} ) - }) + })} + {virtualized && rowVirtualizer ? (() => { + const virtualItems = rowVirtualizer.getVirtualItems() + const lastItem = virtualItems[virtualItems.length - 1] + const bottomPadding = lastItem ? rowVirtualizer.getTotalSize() - lastItem.end : 0 + return bottomPadding > 0 ? : null + })() : null} + ) : ( 0 ? 1 : 0) + (hasInjectedBulkActions ? 1 : 0)} className="h-24 text-center text-muted-foreground"> @@ -2106,6 +2462,7 @@ export function DataTable({
    + {footerInjectionSpotId ? (
    @@ -2136,6 +2493,21 @@ export function DataTable({ apiWarning={perspectiveApiWarning} /> ) : null} + {columnChooser ? ( + column.getIsVisible()) + .map((column) => column.id)} + columnOrder={columnOrder} + onToggleColumn={handleColumnChooserToggle} + onReorderColumns={handleColumnChooserReorder} + dndContextId={`${stableDndContextId}-chooser`} + /> + ) : null}
    ) diff --git a/packages/ui/src/backend/FilterBar.tsx b/packages/ui/src/backend/FilterBar.tsx index 10f4af08031..cbeb2b7b7fc 100644 --- a/packages/ui/src/backend/FilterBar.tsx +++ b/packages/ui/src/backend/FilterBar.tsx @@ -71,33 +71,40 @@ export function FilterBar({ }, [values]) const containerClass = `flex flex-col ${layout === 'inline' ? 'gap-1 sm:gap-2' : 'gap-2'} w-full` + const searchInput = onSearchChange ? ( +
    + setSearchDraft(e.target.value)} + placeholder={resolvedSearchPlaceholder} + className="h-9 w-full rounded border pl-8 pr-2 text-sm" + suppressHydrationWarning + /> + 🔍 +
    + ) : null + const controls = ( +
    + {filters.length > 0 && ( + + )} + {leadingItems} + {trailingItems} +
    + ) return (
    - {filters.length > 0 && ( - - )} - {leadingItems} - {trailingItems} - {onSearchChange && ( -
    - setSearchDraft(e.target.value)} - placeholder={resolvedSearchPlaceholder} - className="h-9 w-full rounded border pl-8 pr-2 text-sm" - suppressHydrationWarning - /> - 🔍 -
    - )} + {searchAlign === 'left' ? searchInput : null} + {controls} + {searchAlign === 'right' ? searchInput : null}
    {/* Active filter chips */} {filters.length > 0 && activeCount > 0 && ( diff --git a/packages/ui/src/backend/columns/ColumnChooserPanel.tsx b/packages/ui/src/backend/columns/ColumnChooserPanel.tsx new file mode 100644 index 00000000000..5192819d30d --- /dev/null +++ b/packages/ui/src/backend/columns/ColumnChooserPanel.tsx @@ -0,0 +1,241 @@ +"use client" +import * as React from 'react' +import { Search, GripVertical, X, ChevronDown } from 'lucide-react' +import { Button } from '../../primitives/button' +import { IconButton } from '../../primitives/icon-button' +import { Checkbox } from '../../primitives/checkbox' +import { useT } from '@open-mercato/shared/lib/i18n/context' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core' +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' + +export type ColumnChooserField = { + key: string + label: string + group: string + defaultVisible?: boolean + alwaysVisible?: boolean +} + +export type ColumnChooserPanelProps = { + open: boolean + onOpenChange: (open: boolean) => void + availableColumns: ColumnChooserField[] + visibleColumnKeys: string[] + columnOrder: string[] + onToggleColumn: (key: string) => void + onReorderColumns: (newOrder: string[]) => void + dndContextId?: string +} + +function SortableColumnItem({ + column, + onToggle, +}: { + column: ColumnChooserField + onToggle: (key: string) => void +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: column.key }) + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( +
    + + + + onToggle(column.key)} + /> + {column.label} +
    + ) +} + +export function ColumnChooserPanel({ + open, + onOpenChange, + availableColumns, + visibleColumnKeys, + columnOrder, + onToggleColumn, + onReorderColumns, + dndContextId = 'column-chooser', +}: ColumnChooserPanelProps) { + const t = useT() + const [searchQuery, setSearchQuery] = React.useState('') + const [collapsedGroups, setCollapsedGroups] = React.useState>(new Set()) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + const visibleSet = React.useMemo(() => new Set(visibleColumnKeys), [visibleColumnKeys]) + + const selectedColumns = React.useMemo(() => { + const ordered: ColumnChooserField[] = [] + for (const key of columnOrder) { + const col = availableColumns.find((c) => c.key === key) + if (col && visibleSet.has(key)) ordered.push(col) + } + for (const col of availableColumns) { + if (visibleSet.has(col.key) && !ordered.some((o) => o.key === col.key)) { + ordered.push(col) + } + } + return ordered + }, [availableColumns, visibleSet, columnOrder]) + + const groupedAvailable = React.useMemo(() => { + const lowerQuery = searchQuery.toLowerCase() + const filtered = availableColumns + .filter((c) => !visibleSet.has(c.key)) + .filter((c) => !searchQuery || c.label.toLowerCase().includes(lowerQuery)) + + const groups = new Map() + for (const col of filtered) { + const group = col.group || t('ui.columnChooser.ungrouped', 'Other') + const list = groups.get(group) ?? [] + list.push(col) + groups.set(group, list) + } + return groups + }, [availableColumns, searchQuery, visibleSet, t]) + + const toggleGroup = React.useCallback((group: string) => { + setCollapsedGroups((prev) => { + const next = new Set(prev) + if (next.has(group)) next.delete(group) + else next.add(group) + return next + }) + }, []) + + const handleDragEnd = React.useCallback((event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + const oldIndex = selectedColumns.findIndex((c) => c.key === active.id) + const newIndex = selectedColumns.findIndex((c) => c.key === over.id) + if (oldIndex === -1 || newIndex === -1) return + const reordered = [...selectedColumns] + const [moved] = reordered.splice(oldIndex, 1) + reordered.splice(newIndex, 0, moved) + onReorderColumns(reordered.map((c) => c.key)) + }, [selectedColumns, onReorderColumns]) + + if (!open) return null + + return ( +
    +
    +

    + {t('ui.columnChooser.title', 'Columns')} +

    + onOpenChange(false)} aria-label={t('ui.columnChooser.close', 'Close')}> + + +
    + +
    +
    + + setSearchQuery(e.target.value)} + /> +
    +
    + +
    + {selectedColumns.length > 0 ? ( +
    +
    + {t('ui.columnChooser.selected', 'Selected columns')} +
    + + c.key)} strategy={verticalListSortingStrategy}> + {selectedColumns.map((col) => ( + + ))} + + +
    + ) : null} + +
    +
    + {t('ui.columnChooser.available', 'Available columns')} +
    + {Array.from(groupedAvailable.entries()).map(([group, columns]) => { + const isCollapsed = collapsedGroups.has(group) + return ( +
    + + {!isCollapsed ? ( +
    + {columns.map((col) => ( + + ))} +
    + ) : null} +
    + ) + })} +
    +
    +
    + ) +} diff --git a/packages/ui/src/backend/confirm-dialog/useConfirmDialog.tsx b/packages/ui/src/backend/confirm-dialog/useConfirmDialog.tsx index 2017d398462..30f58ea69af 100644 --- a/packages/ui/src/backend/confirm-dialog/useConfirmDialog.tsx +++ b/packages/ui/src/backend/confirm-dialog/useConfirmDialog.tsx @@ -15,6 +15,7 @@ function DialogMountTracker({ trackerRef }: { trackerRef: React.MutableRefObject export type ConfirmDialogOptions = { title?: string; text?: string; + description?: string; confirmText?: string | false; cancelText?: string | false; variant?: "default" | "destructive"; @@ -121,7 +122,7 @@ export function useConfirmDialog(): UseConfirmDialogReturn { onOpenChange={handleOpenChange} onConfirm={handleConfirm} title={options.title} - text={options.text} + text={options.text ?? options.description} confirmText={options.confirmText} cancelText={options.cancelText} variant={options.variant} diff --git a/packages/ui/src/backend/filters/AdvancedFilterBuilder.tsx b/packages/ui/src/backend/filters/AdvancedFilterBuilder.tsx new file mode 100644 index 00000000000..c33ca27c450 --- /dev/null +++ b/packages/ui/src/backend/filters/AdvancedFilterBuilder.tsx @@ -0,0 +1,339 @@ +"use client" +import * as React from 'react' +import { ChevronDown, Plus, Trash2, X } from 'lucide-react' +import { Button } from '../../primitives/button' +import { IconButton } from '../../primitives/icon-button' +import { useT } from '@open-mercato/shared/lib/i18n/context' +import type { + AdvancedFilterState, + FilterCondition, + FilterFieldDef, + FilterFieldType, + FilterJoinOperator, + FilterOperator, +} from '@open-mercato/shared/lib/query/advanced-filter' +import { + OPERATORS_BY_FIELD_TYPE, + getDefaultOperator, + isValuelessOperator, + createEmptyCondition, + normalizeAdvancedFilterState, +} from '@open-mercato/shared/lib/query/advanced-filter' + +export type AdvancedFilterBuilderProps = { + fields: FilterFieldDef[] + value: AdvancedFilterState + onChange: (state: AdvancedFilterState) => void + onApply: () => void + onClear: () => void +} + +const OPERATOR_LABELS: Record = { + is: 'is', + is_not: 'is not', + contains: 'contains', + does_not_contain: 'does not contain', + starts_with: 'starts with', + ends_with: 'ends with', + is_empty: 'is empty', + is_not_empty: 'is not empty', + equals: 'equals', + not_equals: 'not equals', + greater_than: 'greater than', + less_than: 'less than', + greater_or_equal: 'greater or equal', + less_or_equal: 'less or equal', + between: 'between', + is_before: 'is before', + is_after: 'is after', + is_any_of: 'is any of', + is_none_of: 'is none of', + is_true: 'is true', + is_false: 'is false', + has_any_of: 'has any of', + has_all_of: 'has all of', + has_none_of: 'has none of', +} + +function getFieldType(fields: FilterFieldDef[], fieldKey: string): FilterFieldType { + const field = fields.find((f) => f.key === fieldKey) + return field?.type ?? 'text' +} + +function ConditionRow({ + condition, + index, + fields, + join, + onUpdate, + onRemove, + onToggleJoin, + t, +}: { + condition: FilterCondition + index: number + fields: FilterFieldDef[] + join: FilterJoinOperator + onUpdate: (id: string, updates: Partial) => void + onRemove: (id: string) => void + onToggleJoin: (id: string) => void + t: ReturnType +}) { + const fieldType = getFieldType(fields, condition.field) + const operators = OPERATORS_BY_FIELD_TYPE[fieldType] ?? OPERATORS_BY_FIELD_TYPE.text + const valueless = isValuelessOperator(condition.operator) + + const handleFieldChange = (newField: string) => { + const newType = getFieldType(fields, newField) + const newOp = getDefaultOperator(newType) + onUpdate(condition.id, { field: newField, operator: newOp, value: '' }) + } + + const joinLabel = join === 'and' + ? t('ui.advancedFilter.and', 'And') + : t('ui.advancedFilter.or', 'Or') + + return ( +
    +
    + {index === 0 ? ( + {t('ui.advancedFilter.where', 'Where')} + ) : ( + + )} +
    + + + + + + {!valueless ? ( + + ) : null} + + onRemove(condition.id)} + aria-label={t('ui.advancedFilter.removeCondition', 'Remove condition')} + > + + +
    + ) +} + +function ValueInput({ + condition, + fields, + fieldType, + onUpdate, + t, +}: { + condition: FilterCondition + fields: FilterFieldDef[] + fieldType: FilterFieldType + onUpdate: (id: string, updates: Partial) => void + t: ReturnType +}) { + const fieldDef = fields.find((f) => f.key === condition.field) + const value = condition.value + + if (fieldType === 'select' && fieldDef?.options) { + return ( + + ) + } + + if (fieldType === 'date') { + return ( + onUpdate(condition.id, { value: e.target.value })} + aria-label={t('ui.advancedFilter.dateValue', 'Date value')} + /> + ) + } + + if (fieldType === 'number') { + return ( + onUpdate(condition.id, { value: e.target.value })} + placeholder={t('ui.advancedFilter.numberPlaceholder', 'Value')} + aria-label={t('ui.advancedFilter.numberValue', 'Number value')} + /> + ) + } + + return ( + onUpdate(condition.id, { value: e.target.value })} + placeholder={t('ui.advancedFilter.textPlaceholder', 'Value...')} + aria-label={t('ui.advancedFilter.textValue', 'Text value')} + /> + ) +} + +export function AdvancedFilterBuilder({ + fields, + value, + onChange, + onApply, + onClear, +}: AdvancedFilterBuilderProps) { + const t = useT() + const normalizedValue = React.useMemo(() => normalizeAdvancedFilterState(value), [value]) + const emitChange = React.useCallback((next: AdvancedFilterState) => { + onChange(normalizeAdvancedFilterState(next)) + }, [onChange]) + + const updateCondition = React.useCallback((id: string, updates: Partial) => { + emitChange({ + ...normalizedValue, + conditions: normalizedValue.conditions.map((c) => (c.id === id ? { ...c, ...updates } : c)), + }) + }, [emitChange, normalizedValue]) + + const removeCondition = React.useCallback((id: string) => { + emitChange({ + ...normalizedValue, + conditions: normalizedValue.conditions.filter((c) => c.id !== id), + }) + }, [emitChange, normalizedValue]) + + const addCondition = React.useCallback(() => { + const newCondition = createEmptyCondition() + if (fields.length > 0) { + newCondition.field = fields[0].key + newCondition.operator = getDefaultOperator(fields[0].type) + } + emitChange({ + ...normalizedValue, + conditions: [...normalizedValue.conditions, newCondition], + }) + }, [emitChange, fields, normalizedValue]) + + const toggleConditionJoin = React.useCallback((id: string) => { + emitChange({ + ...normalizedValue, + conditions: normalizedValue.conditions.map((condition) => ( + condition.id === id + ? { ...condition, join: condition.join === 'or' ? 'and' : 'or' } + : condition + )), + }) + }, [emitChange, normalizedValue]) + + return ( +
    + {normalizedValue.conditions.length === 0 ? ( +

    + {t('ui.advancedFilter.noConditions', 'No filter conditions. Click "Add filter" to start.')} +

    + ) : ( +
    + {normalizedValue.conditions.map((condition, index) => ( + + ))} +
    + )} + +
    + + {normalizedValue.conditions.length > 0 ? ( + + ) : null} + {normalizedValue.conditions.length > 0 ? ( + + ) : null} +
    +
    + ) +} diff --git a/packages/ui/src/backend/hooks/useAdvancedFilter.ts b/packages/ui/src/backend/hooks/useAdvancedFilter.ts new file mode 100644 index 00000000000..f3c25cea12b --- /dev/null +++ b/packages/ui/src/backend/hooks/useAdvancedFilter.ts @@ -0,0 +1,77 @@ +"use client" +import * as React from 'react' +import type { AdvancedFilterState, FilterCondition, FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter' +import { createEmptyCondition, getDefaultOperator, normalizeAdvancedFilterState } from '@open-mercato/shared/lib/query/advanced-filter' + +export type UseAdvancedFilterOptions = { + fields: FilterFieldDef[] + onChange?: (state: AdvancedFilterState) => void +} + +export function useAdvancedFilter({ fields, onChange }: UseAdvancedFilterOptions) { + const [state, setState] = React.useState({ + logic: 'and', + conditions: [], + }) + + const updateState = React.useCallback((next: AdvancedFilterState) => { + const normalized = normalizeAdvancedFilterState(next) + setState(normalized) + onChange?.(normalized) + }, [onChange]) + + const addCondition = React.useCallback(() => { + const newCondition = createEmptyCondition() + if (fields.length > 0) { + newCondition.field = fields[0].key + newCondition.operator = getDefaultOperator(fields[0].type) + } + updateState({ + ...state, + conditions: [...state.conditions, newCondition], + }) + }, [fields, state, updateState]) + + const removeCondition = React.useCallback((conditionId: string) => { + updateState({ + ...state, + conditions: state.conditions.filter((c) => c.id !== conditionId), + }) + }, [state, updateState]) + + const updateCondition = React.useCallback((conditionId: string, updates: Partial) => { + updateState({ + ...state, + conditions: state.conditions.map((c) => + c.id === conditionId ? { ...c, ...updates } : c, + ), + }) + }, [state, updateState]) + + const toggleLogic = React.useCallback(() => { + const nextLogic = state.logic === 'and' ? 'or' : 'and' + updateState({ + logic: nextLogic, + conditions: state.conditions.map((condition, index) => ( + index === 0 ? condition : { ...condition, join: nextLogic } + )), + }) + }, [state, updateState]) + + const clearAll = React.useCallback(() => { + updateState({ logic: 'and', conditions: [] }) + }, [updateState]) + + const hasActiveConditions = state.conditions.some((c) => c.field && c.operator) + + return { + state, + setState: updateState, + addCondition, + removeCondition, + updateCondition, + toggleLogic, + clearAll, + hasActiveConditions, + } +} diff --git a/packages/ui/src/backend/utils/customFieldColumns.ts b/packages/ui/src/backend/utils/customFieldColumns.ts index c4e9232751f..cffcc4f1f2d 100644 --- a/packages/ui/src/backend/utils/customFieldColumns.ts +++ b/packages/ui/src/backend/utils/customFieldColumns.ts @@ -1,7 +1,52 @@ import type { ColumnDef } from '@tanstack/react-table' +import type { FilterFieldType, FilterOption } from '@open-mercato/shared/lib/query/advanced-filter' import type { CustomFieldDefDto, CustomFieldVisibility } from './customFieldDefs' import { isDefVisible } from './customFieldDefs' +type RawCustomFieldOption = string | number | { value?: unknown; label?: unknown } + +export function supportsCustomFieldColumn(def: CustomFieldDefDto): boolean { + return def.kind !== 'attachment' +} + +export function mapCustomFieldKindToFilterType(kind: string): FilterFieldType { + switch (kind) { + case 'boolean': + return 'boolean' + case 'integer': + case 'float': + return 'number' + case 'date': + return 'date' + case 'select': + case 'dictionary': + case 'currency': + case 'relation': + return 'select' + default: + return 'text' + } +} + +export function normalizeCustomFieldFilterOptions(options?: RawCustomFieldOption[]): FilterOption[] { + if (!Array.isArray(options)) return [] + return options.map((option) => { + if (option && typeof option === 'object' && 'value' in option) { + const rawValue = option.value + const rawLabel = option.label ?? rawValue + return { + value: String(rawValue), + label: typeof rawLabel === 'string' ? rawLabel : String(rawLabel), + } + } + const value = String(option) + return { + value, + label: value.charAt(0).toUpperCase() + value.slice(1), + } + }) +} + // Filters and annotates columns with custom-field definitions: // - Drops cf_* columns when no definition exists or listVisible === false // - Uses definition label as header when header is missing @@ -14,6 +59,7 @@ export function applyCustomFieldVisibility(columns: ColumnDef[], defs const cfKey = key.slice(3) const def = byKey.get(cfKey) if (!def) return false + if (!supportsCustomFieldColumn(def)) return false if (!isDefVisible(def, mode)) return false const currentHeader = (c as any).header const fallbackHeader = typeof currentHeader === 'string' && currentHeader.trim().length ? currentHeader : key @@ -54,7 +100,7 @@ export function applyCustomFieldVisibility(columns: ColumnDef[], defs .map((k) => k.slice(3))) const visibleSorted = defs - .filter((d) => isDefVisible(d, mode)) + .filter((d) => supportsCustomFieldColumn(d) && isDefVisible(d, mode)) .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0)) const missing = visibleSorted.filter((d) => !existingCfKeys.has(d.key)) diff --git a/packages/ui/src/backend/utils/customFieldFilters.ts b/packages/ui/src/backend/utils/customFieldFilters.ts index 2d6ea5e2d7e..d74ae27d663 100644 --- a/packages/ui/src/backend/utils/customFieldFilters.ts +++ b/packages/ui/src/backend/utils/customFieldFilters.ts @@ -2,6 +2,8 @@ import * as React from 'react' import { useCustomFieldDefs, type UseCustomFieldDefsOptions } from './customFieldDefs' import { Filter } from '@open-mercato/shared/lib/query/types' import type { FilterDef } from '../FilterOverlay' +import type { FilterFieldDef as AdvancedFilterFieldDef, FilterFieldType, FilterOption } from '@open-mercato/shared/lib/query/advanced-filter' +import { mapCustomFieldKindToFilterType, normalizeCustomFieldFilterOptions } from './customFieldColumns' import type { CustomFieldDefDto } from './customFieldDefs' export type { CustomFieldDefDto } import { filterCustomFieldDefs, fetchCustomFieldDefs as loadCustomFieldDefs } from './customFieldDefs' @@ -119,6 +121,33 @@ export function buildFilterDefsFromCustomFields(defs: CustomFieldDefDto[]): Filt return out } +export function buildAdvancedFilterFieldsFromCustomFields( + defs: CustomFieldDefDto[], + groupLabel?: string, +): AdvancedFilterFieldDef[] { + const visible = filterCustomFieldDefs(defs, 'filter') + const seenKeys = new Set() + const fields: AdvancedFilterFieldDef[] = [] + for (const d of visible) { + const keyLower = String(d.key).toLowerCase() + if (seenKeys.has(keyLower)) continue + seenKeys.add(keyLower) + const type = mapCustomFieldKindToFilterType(d.kind) + const field: AdvancedFilterFieldDef = { + key: `cf_${d.key}`, + label: d.label || d.key, + type, + group: groupLabel ?? 'Custom Fields', + } + if (type === 'select') { + const opts = normalizeOptions(d.options) as FilterOption[] + if (opts.length) field.options = opts + } + fields.push(field) + } + return fields +} + export async function fetchCustomFieldFilterDefs( entityIds: string | string[], fetchImpl?: typeof fetch, diff --git a/packages/ui/src/backend/utils/useAutoDiscoveredFields.ts b/packages/ui/src/backend/utils/useAutoDiscoveredFields.ts new file mode 100644 index 00000000000..355f08db296 --- /dev/null +++ b/packages/ui/src/backend/utils/useAutoDiscoveredFields.ts @@ -0,0 +1,138 @@ +"use client" +import * as React from 'react' +import type { ColumnDef } from '@tanstack/react-table' +import type { FilterFieldDef as AdvancedFilterFieldDef, FilterFieldType, FilterOption } from '@open-mercato/shared/lib/query/advanced-filter' +import type { ColumnChooserField } from '../columns/ColumnChooserPanel' +import type { CustomFieldDefDto } from './customFieldDefs' +import { + mapCustomFieldKindToFilterType, + normalizeCustomFieldFilterOptions, + supportsCustomFieldColumn, +} from './customFieldColumns' + +type ColumnMeta = { + filterKey?: string + filterType?: FilterFieldType + filterOptions?: FilterOption[] + filterLoadOptions?: (query?: string) => Promise + filterable?: boolean + filterGroup?: string + columnChooserGroup?: string + alwaysVisible?: boolean + hidden?: boolean + truncate?: boolean + maxWidth?: string + priority?: number + tooltipContent?: (row: unknown) => string | undefined +} + +function resolveHeaderLabel(column: ColumnDef): string { + const header = (column as any).header + if (typeof header === 'string') return header + const accessorKey = (column as any).accessorKey as string | undefined + if (!accessorKey) return '' + return accessorKey + .replace(/^cf_/, '') + .replace(/_/g, ' ') + .replace(/\b\w/g, (c: string) => c.toUpperCase()) +} + +function inferFilterType(accessorKey: string, meta?: ColumnMeta): FilterFieldType { + if (meta?.filterType) return meta.filterType + if (accessorKey.endsWith('_at') || accessorKey === 'createdAt' || accessorKey === 'updatedAt' || accessorKey === 'expectedCloseAt' || accessorKey.endsWith('At')) return 'date' + if (accessorKey === 'probability' || accessorKey === 'valueAmount' || accessorKey === 'value_amount') return 'number' + if (meta?.filterOptions) return 'select' + return 'text' +} + +export type UseAutoDiscoveredFieldsInput = { + columns: ColumnDef[] + customFieldDefs: CustomFieldDefDto[] +} + +export type UseAutoDiscoveredFieldsResult = { + advancedFilterFields: AdvancedFilterFieldDef[] + columnChooserFields: ColumnChooserField[] +} + +export function useAutoDiscoveredFields({ + columns, + customFieldDefs, +}: UseAutoDiscoveredFieldsInput): UseAutoDiscoveredFieldsResult { + return React.useMemo(() => { + const filterFields: AdvancedFilterFieldDef[] = [] + const chooserFields: ColumnChooserField[] = [] + const seenFilterKeys = new Set() + const seenChooserKeys = new Set() + + for (let i = 0; i < columns.length; i++) { + const col = columns[i] + const accessorKey = (col as any).accessorKey as string | undefined + if (!accessorKey) continue + + const meta = (col as any).meta as ColumnMeta | undefined + const label = resolveHeaderLabel(col) + if (!label) continue + const filterKey = meta?.filterKey ?? accessorKey + + // Advanced filter field + if (meta?.filterable !== false && filterKey && !seenFilterKeys.has(filterKey)) { + seenFilterKeys.add(filterKey) + const type = inferFilterType(filterKey, meta) + const field: AdvancedFilterFieldDef = { + key: filterKey, + label, + type, + group: meta?.filterGroup ?? meta?.columnChooserGroup, + } + if (meta?.filterOptions) field.options = meta.filterOptions + if (meta?.filterLoadOptions) field.loadOptions = meta.filterLoadOptions + filterFields.push(field) + } + + // Column chooser field + if (!seenChooserKeys.has(accessorKey)) { + seenChooserKeys.add(accessorKey) + chooserFields.push({ + key: accessorKey, + label, + group: meta?.columnChooserGroup ?? 'Columns', + alwaysVisible: meta?.alwaysVisible ?? i === 0, + defaultVisible: true, + }) + } + } + + // Append ALL custom field definitions (not just filterable/listVisible) + // so that every entity field appears in both the column chooser and advanced filter + for (const def of customFieldDefs) { + if (!supportsCustomFieldColumn(def)) continue + const filterKey = `cf_${def.key}` + if (!seenFilterKeys.has(filterKey)) { + seenFilterKeys.add(filterKey) + const type = mapCustomFieldKindToFilterType(def.kind) + const field: AdvancedFilterFieldDef = { + key: filterKey, + label: def.label || def.key, + type, + group: 'Custom Fields', + } + if (type === 'select' && Array.isArray(def.options) && def.options.length) { + field.options = normalizeCustomFieldFilterOptions(def.options) + } + filterFields.push(field) + } + if (!seenChooserKeys.has(filterKey)) { + seenChooserKeys.add(filterKey) + chooserFields.push({ + key: filterKey, + label: def.label || def.key, + group: def.group?.title ?? 'Custom Fields', + defaultVisible: false, + }) + } + } + + return { advancedFilterFields: filterFields, columnChooserFields: chooserFields } + }, [columns, customFieldDefs]) +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index fd6ee42fa76..102273f6847 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -4,6 +4,8 @@ export * from './theme/QueryProvider' export * from './backend/AppShell' export * from './backend/Page' export * from './backend/DataTable' +export * from './backend/filters/AdvancedFilterBuilder' +export * from './backend/columns/ColumnChooserPanel' export * from './backend/FilterBar' export * from './backend/ValueIcons' export * from './backend/confirm-dialog' diff --git a/packages/ui/src/primitives/table.tsx b/packages/ui/src/primitives/table.tsx index 8af5c53118b..fb7a7b2a409 100644 --- a/packages/ui/src/primitives/table.tsx +++ b/packages/ui/src/primitives/table.tsx @@ -17,8 +17,8 @@ export function TableRow({ className, ...props }: React.HTMLAttributes } -export function TableHead({ className, ...props }: React.ThHTMLAttributes) { - return +export function TableHead({ className, ref, ...props }: React.ThHTMLAttributes & { ref?: React.Ref }) { + return } export function TableCell({ className, ...props }: React.TdHTMLAttributes) { diff --git a/yarn.lock b/yarn.lock index a536f5e55d4..eabbdd94d1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2585,6 +2585,55 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/accessibility@npm:^3.1.1": + version: 3.1.1 + resolution: "@dnd-kit/accessibility@npm:3.1.1" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10/961000456a36700a9cd13be51147a818bc100f7dfabb332b80438d02e06f3b556aa0ff46ddf13bdff3b70bc8f9b63dd5a392cc285597ab1f7026e672660c54b6 + languageName: node + linkType: hard + +"@dnd-kit/core@npm:^6.3.1": + version: 6.3.1 + resolution: "@dnd-kit/core@npm:6.3.1" + dependencies: + "@dnd-kit/accessibility": "npm:^3.1.1" + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10/a5ae6fa8404765712aa80e308f58cb79bac9a306c274ec8272c405c2a59dd277d24b966348fe8ca6340bb3f0d75f90b8a021fa781edcf65255114d3cf2bef891 + languageName: node + linkType: hard + +"@dnd-kit/sortable@npm:^10.0.0": + version: 10.0.0 + resolution: "@dnd-kit/sortable@npm:10.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10/bc61c25e76905204a53f91294b8116bf106fa27247eebca2c66478450b2051d7177115a384054e7e5639e6c4430083ade63056f79ee45f549da537cf05bc5288 + languageName: node + linkType: hard + +"@dnd-kit/utilities@npm:^3.2.2": + version: 3.2.2 + resolution: "@dnd-kit/utilities@npm:3.2.2" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10/6cfe46a5fcdaced943982e7ae66b08b89235493e106eb5bc833737c25905e13375c6ecc3aa0c357d136cb21dae3966213dba063f19b7a60b1235a29a7b05ff84 + languageName: node + linkType: hard + "@docsearch/core@npm:4.5.3": version: 4.5.3 resolution: "@docsearch/core@npm:4.5.3" @@ -5976,9 +6025,13 @@ __metadata: version: 0.0.0-use.local resolution: "@open-mercato/ui@workspace:packages/ui" dependencies: + "@dnd-kit/core": "npm:^6.3.1" + "@dnd-kit/sortable": "npm:^10.0.0" + "@dnd-kit/utilities": "npm:^3.2.2" "@open-mercato/shared": "workspace:*" "@radix-ui/react-popover": "npm:^1.1.6" "@radix-ui/react-tooltip": "npm:^1.2.8" + "@tanstack/react-virtual": "npm:^3.13.23" "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^6.9.1" "@testing-library/react": "npm:^16.3.1" @@ -8085,6 +8138,18 @@ __metadata: languageName: node linkType: hard +"@tanstack/react-virtual@npm:^3.13.23": + version: 3.13.23 + resolution: "@tanstack/react-virtual@npm:3.13.23" + dependencies: + "@tanstack/virtual-core": "npm:3.13.23" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/fb60d65b61f299648325d8d69d00e5d9434ec20e075496c2e451426087d6216d617558565f05d7a503393a482db29112140b19082f709785f4a469800ddc4e31 + languageName: node + linkType: hard + "@tanstack/table-core@npm:8.21.3": version: 8.21.3 resolution: "@tanstack/table-core@npm:8.21.3" @@ -8092,6 +8157,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/virtual-core@npm:3.13.23": + version: 3.13.23 + resolution: "@tanstack/virtual-core@npm:3.13.23" + checksum: 10/17f6e3c3f56d2d91d5818981416a628db2e1038f6a13f39f60e5b2bdcff04b99fba0e79640a6667ea9ef70756a2a086fb468f8b5f065faea9f1a47f8aa3722a5 + languageName: node + linkType: hard + "@testing-library/dom@npm:^10.4.0, @testing-library/dom@npm:^10.4.1": version: 10.4.1 resolution: "@testing-library/dom@npm:10.4.1" From 0acff7b08e2547e9938d2409e740efd2a4090366 Mon Sep 17 00:00:00 2001 From: zielivia <48693228+zielivia@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:22:06 +0200 Subject: [PATCH 010/215] spec(catalog): add SPEC-071 SEO helper validation visibility (#1155) --- ...-04-06-seo-helper-validation-visibility.md | 249 ++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 .ai/specs/SPEC-071-2026-04-06-seo-helper-validation-visibility.md diff --git a/.ai/specs/SPEC-071-2026-04-06-seo-helper-validation-visibility.md b/.ai/specs/SPEC-071-2026-04-06-seo-helper-validation-visibility.md new file mode 100644 index 00000000000..b8d98031aba --- /dev/null +++ b/.ai/specs/SPEC-071-2026-04-06-seo-helper-validation-visibility.md @@ -0,0 +1,249 @@ +# SPEC-071: Product SEO Helper — Improve Validation Visibility + +## Overview + +Improve the visibility and user experience of Product SEO Helper validation when it blocks product save. Currently, the validation error appears only as a red toast at the top of the page and inside the SEO Helper widget in the sidebar — users must scroll to find what's wrong. The Description field is not marked as required despite being enforced by SEO validation. + +**Reference:** Issue #948, Related: #901 + +--- + +## Problem Statement + +1. **Hidden validation feedback** — When SEO Helper blocks save, the user sees a red toast ("SEO helper blocked save. Improve the highlighted fields.") but the actual issues are only visible inside the SEO Helper widget panel in the right sidebar. On smaller screens or when scrolled up, the widget is not visible. + +2. **Description not marked as required** — The Description field has no `*` indicator, yet the product cannot be saved without it because SEO Helper enforces `description.trim().length > 0`. + +3. **No inline field highlighting** — When SEO validation fails, the problematic fields (Title, Description) are not visually highlighted with red borders or inline error messages in the main form. + +4. **Toast is too generic** — "Improve the highlighted fields" says highlighted, but no fields are actually highlighted in the main form area. + +--- + +## Validation Contract + +Formalize the `onBeforeSave` return type for injection widgets: + +```typescript +export type InjectionBeforeSaveResult = + | { ok: true } + | { + ok: false + message?: string + fieldErrors?: Record + } +``` + +**Behavior:** +- `ok: false` blocks save +- `message` is shown in toast +- `fieldErrors` are merged into CrudForm validation state +- First field error receives scroll/focus priority +- If no field-mapped error exists, scroll to widget container + +**Field key constraint:** `fieldErrors` keys returned by injection widgets must use the same field path format consumed by CrudForm validation. If SEO widget returns `description`, the CrudForm field must also be keyed as `description`. Mismatched keys will silently fail to highlight. + +--- + +## Proposed Solution + +### 1. Inline Field Errors on Save Block + +When `onBeforeSave` returns `{ ok: false, fieldErrors }`, CrudForm must consume those `fieldErrors` and merge them into the same field validation state used for standard server-side validation errors. Injection-originated field errors must render identically to server-originated field errors — same red border, same error text position, same clear behavior. + +**Current flow:** +``` +onBeforeSave → { ok: false, fieldErrors: { description: "..." } } +→ Red toast appears +→ SEO widget updates internally +→ Main form fields: NO visual change ❌ +``` + +**Proposed flow:** +``` +onBeforeSave → { ok: false, fieldErrors: { description: "..." } } +→ Red toast appears (keep) +→ SEO widget updates internally (keep) +→ Main form fields: red border + inline error message ✅ NEW +→ Auto-scroll to first errored field ✅ NEW +``` + +### 2. Multi-Source Error Merge + +CrudForm must support field errors from multiple sources simultaneously: +- Server validation errors +- Injection widget A errors (e.g., SEO Helper) +- Injection widget B errors (future widgets) + +**Merge rules:** +- Errors are merged by field key into the existing validation state +- If multiple sources return errors for the same field, the first blocking error is displayed +- All sources remain available for logging/debugging +- Save is blocked if any source returns `ok: false` + +### 3. Error Clear Behavior + +Injection-originated field errors clear on edit for the affected field, matching existing server-validation UX: +- User edits Description → Description inline error disappears immediately +- Full validation is re-evaluated on the next save attempt +- SEO widget internal state updates independently (via `subscribeProductSeoValidation`) + +### 4. Mark Description as Required (metadata-driven) + +**Decision:** Use widget metadata `requiredFields`. This keeps injection widgets self-describing and avoids catalog-specific hardcoding. + +Add to injection widget metadata type: + +```typescript +export interface InjectionWidgetMetadata { + // ...existing fields + requiredFields?: string[] +} +``` + +**Behavior:** +- CrudForm adds required marker `*` if any active widget declares that field required +- Required marker is present only while that widget is active/enabled +- Marker is visual only — enforcement still comes from `onBeforeSave` validation +- If base form already marks a field required, widget metadata does not change behavior +- Required fields from multiple widgets are unioned + +**SEO Helper widget metadata update:** +```typescript +metadata: { + // ...existing + requiredFields: ['description'] +} +``` + +### 5. Auto-scroll to First Error + +When save is blocked: +- Auto-scroll prioritizes the first field in `fieldErrors` +- Use `element.scrollIntoView({ behavior: 'smooth', block: 'center' })` +- If no field-mapped errors exist (widget returns only `message`, no `fieldErrors`), scroll to the blocking widget container + +### 6. Improved Toast Message + +Replace generic text with specific field-based issues: + +**Format rules:** +- 1 issue: show exact issue +- 2–3 issues: list concise field-based issues +- >3 issues: summarize count and show first 2 + +**Examples:** +- `"SEO helper: Description is missing."` +- `"SEO helper: Description is missing. Title must be at least 10 characters."` +- `"SEO helper: 4 issues found. Description is missing. Title must be at least 10 characters."` + +--- + +## Architecture + +### Files to Modify + +**`packages/shared/src/modules/widgets/injection.ts`** +- Add formal `InjectionBeforeSaveResult` type +- Add optional `requiredFields?: string[]` to `InjectionWidgetMetadata` + +**`packages/core/src/modules/catalog/widgets/injection/product-seo/widget.ts`** +- Add `requiredFields: ['description']` to metadata +- Update `onBeforeSave` to return concise, field-specific `message` +- Keep `fieldErrors` as-is (already returns correct structure) + +**`packages/core/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx`** +- No changes needed (already displays issues correctly in the widget) + +**`packages/ui/src/backend/` (CrudForm or form infrastructure)** +- Capture failed `onBeforeSave` result from injection widgets +- Merge `fieldErrors` into form validation state (same pipeline as server errors) +- Trigger same rendering path as server errors +- Scroll to first errored field, fall back to widget container if no field target +- Read `requiredFields` from active widget metadata, add `*` to matching fields + +### Impact Analysis + +- **Injection widget system** — additive changes to types. Existing widgets without `fieldErrors` or `requiredFields` continue working unchanged. +- **CrudForm** — needs to accept `fieldErrors` from injection widget results, not just from server responses. This reuses the existing error rendering pipeline. +- **Catalog module** — only the SEO widget metadata and message are updated. +- **UMES events** — no new events needed. +- **Database** — no migrations required. + +--- + +## Acceptance Criteria + +### Functional +1. When SEO Helper blocks save with `fieldErrors.description`, the Description field shows error styling and inline message in the main form. +2. When SEO Helper blocks save with `fieldErrors.title`, the Title field shows error styling and inline message in the main form. +3. The existing SEO widget continues to display its internal issue list. +4. The toast still appears with specific field-based message. +5. The form scrolls to the first errored field if it is off-screen. +6. If no field-mapped errors exist, the page scrolls to the SEO Helper widget. +7. Description shows a required indicator `*` while SEO Helper is active and declares it required. +8. Injection widgets that do not return `fieldErrors` continue working without changes. + +### UX Behavior +9. Editing an errored field clears its visible inline error state immediately. +10. Re-saving re-runs SEO validation and re-adds errors if still invalid. +11. The toast message references actual failing fields rather than "highlighted fields" generically. + +### Non-regression +12. Standard server-side validation styling and behavior remain unchanged. +13. Multiple injection widgets can return `fieldErrors` without crashing or dropping standard validation errors. +14. Disabling SEO Helper removes the `*` from Description and stops validation blocking. + +--- + +## Alternatives Considered + +### A. Move validation from SEO Helper to standard form validation +**Rejected** — SEO Helper is an injection widget (example/demo). Moving its logic to core form validation defeats the purpose of the injection system. + +### B. Show all SEO issues only in the toast +**Rejected as sole solution** — toast disappears after a few seconds. Inline field errors persist and are scannable. + +### C. Remove save-blocking behavior, make SEO Helper warning-only +**Out of scope** — configurable `blockSave: true/false` in widget metadata is a separate concern. Fix visibility first. + +### D. Create parallel "widget validation" display path +**Rejected** — reuse the existing form error mechanism. No separate rendering for injection widget errors. + +--- + +## Implementation Phases + +### Phase 1: Validation Contract + Inline Field Errors (highest impact) +- Add `InjectionBeforeSaveResult` type to `packages/shared` +- Propagate `fieldErrors` from `onBeforeSave` to CrudForm field state +- Red border + error message under Title/Description when SEO blocks save +- Define merge behavior for multiple error sources +- Clears on field edit, re-validates on next save + +### Phase 2: Required Indicator +- Add `requiredFields` to `InjectionWidgetMetadata` +- Add `requiredFields: ['description']` to SEO Helper metadata +- CrudForm reads metadata and adds `*` to declared fields + +### Phase 3: Auto-scroll +- On save block, scroll to first field with error +- Fall back to widget container if no field-mapped errors + +### Phase 4: Improved Toast +- Update SEO Helper `onBeforeSave` message to include specific fields +- Apply toast format rules (1 issue / 2-3 issues / >3 issues) + +--- + +## Open Questions + +1. **Field key mapping** — Does CrudForm use flat field names (`description`) or nested paths (`content.description`)? If nested, SEO Helper must return matching keys. Verify before implementation. + +--- + +## Changelog + +### 2026-04-06 +- Initial specification +- Incorporated review feedback: formalized validation contract, multi-source merge rules, error clear behavior, metadata-driven required fields, acceptance criteria From 20ce92f196a40aeb5ed16405822a920ccd295881 Mon Sep 17 00:00:00 2001 From: Piotr Karwatka Date: Tue, 7 Apr 2026 10:44:42 +0200 Subject: [PATCH 011/215] feat: move backend chrome hydration to the client (#1145) * feat: layout optimization spec * fix: gaps filled * feat: hydrate backend chrome on the client * fix: build fix * fix: fix * fix: CR fixes * fix: integration test fix * fix: integration tests stabilzied --- ...backend-first-load-client-first-sidebar.md | 342 +++++++++++++ .../src/app/(backend)/backend/layout.tsx | 453 +++--------------- .../AiAssistantShellIntegration.tsx | 42 ++ .../src/components/BackendHeaderChrome.tsx | 98 ++++ .../src/frontend/hooks/useMcpTools.ts | 11 +- .../auth/__integration__/TC-AUTH-022.spec.ts | 1 + .../auth/api/__tests__/admin-nav.test.ts | 47 ++ .../modules/auth/api/__tests__/login.test.ts | 50 ++ .../core/src/modules/auth/api/admin/nav.ts | 431 +++++------------ packages/core/src/modules/auth/api/login.ts | 68 ++- .../src/modules/auth/lib/backendChrome.tsx | 359 ++++++++++++++ .../src/app/(backend)/backend/layout.tsx | 453 +++--------------- .../AiAssistantShellIntegration.tsx | 42 ++ .../src/components/BackendHeaderChrome.tsx | 98 ++++ .../src/modules/navigation/backendChrome.ts | 48 ++ packages/ui/src/backend/AppShell.tsx | 236 ++++----- .../ui/src/backend/BackendChromeProvider.tsx | 99 ++++ .../src/backend/__tests__/AppShell.test.tsx | 66 +++ .../backend/injection/useInjectedMenuItems.ts | 82 +--- .../src/backend/messages/useMessagesPoll.ts | 14 +- packages/ui/src/backend/section-page/types.ts | 1 + 21 files changed, 1739 insertions(+), 1302 deletions(-) create mode 100644 .ai/specs/2026-04-03-backend-first-load-client-first-sidebar.md create mode 100644 apps/mercato/src/components/AiAssistantShellIntegration.tsx create mode 100644 apps/mercato/src/components/BackendHeaderChrome.tsx create mode 100644 packages/core/src/modules/auth/lib/backendChrome.tsx create mode 100644 packages/create-app/template/src/components/AiAssistantShellIntegration.tsx create mode 100644 packages/create-app/template/src/components/BackendHeaderChrome.tsx create mode 100644 packages/shared/src/modules/navigation/backendChrome.ts create mode 100644 packages/ui/src/backend/BackendChromeProvider.tsx diff --git a/.ai/specs/2026-04-03-backend-first-load-client-first-sidebar.md b/.ai/specs/2026-04-03-backend-first-load-client-first-sidebar.md new file mode 100644 index 00000000000..0f9bf470150 --- /dev/null +++ b/.ai/specs/2026-04-03-backend-first-load-client-first-sidebar.md @@ -0,0 +1,342 @@ +# Backend First Load Client First Sidebar + +## TLDR +Move backend chrome resolution off the critical SSR path. Keep server-side authorization for the active page, but switch the backend sidebar and related chrome to a client-first bootstrap model that hydrates from a single backend chrome payload after first paint. Preserve final behavior, sidebar customization, injection support, and route/module loading semantics. + +## Implementation Status +Status on 2026-04-03: +- implemented shared backend chrome payload types and a single backend chrome resolver used by the admin-nav API +- implemented additive `GET /api/auth/admin/nav` response fields for settings/profile sections, granted features, and roles +- implemented a shared backend chrome client provider in `packages/ui` with scoped caching and refresh support +- refactored `AppShell` to hydrate sidebar/settings/profile chrome from the provider and expose a deterministic chrome readiness marker +- refactored `useInjectedMenuItems` to consume provider-backed features and roles instead of per-surface profile/feature-check requests +- refactored the Mercato backend layout to stop building sidebar chrome on the SSR critical path and to use lazy client header chrome wrappers +- synced the create-app template backend layout/components with the same backend chrome bootstrap model + +Follow-up still recommended: +- update broader Playwright backend-shell coverage to wait on the chrome readiness marker where assertions depend on hydrated sidebar content +- optionally defer the mobile sidebar organization switcher with the same lazy pattern if bundle pressure there becomes measurable + +## Overview +The current backend first load does too much synchronous work in the backend layout. It resolves auth, locale, route metadata, sidebar entries, org-scoped RBAC checks, custom entity sidebar items, role and user sidebar preferences, and mounts multiple client-side chrome features that each perform additional data fetching. This delays first paint even though most of that work is only needed for navigation chrome, not for rendering the requested page content. + +This spec reduces first-load latency by splitting concerns: +- The requested backend page remains server-authorized and server-rendered. +- The surrounding navigation shell becomes client-hydrated. +- Sidebar, settings/profile nav, injected menu visibility, and topbar action visibility are loaded from one shared bootstrap payload. +- Sidebar customization remains available and functionally unchanged. + +Implementation priority: +- first priority is code deduplication of backend nav/chrome resolution so layout, bootstrap API, and injected menu filtering stop maintaining parallel logic paths +- second priority is moving non-critical chrome work off the blocking SSR path +- third priority is caching and lazy hydration improvements once the authoritative nav pipeline is unified + +## Problem Statement +Current first-load cost is concentrated in [`apps/mercato/src/app/(backend)/backend/layout.tsx`](/Users/piotrkarwatka/Projects/mercato-development-two/apps/mercato/src/app/(backend)/backend/layout.tsx), which currently: +- builds the full admin nav on the server for every backend request +- resolves org/tenant feature-check scope before nav filtering +- performs RBAC feature checks during nav assembly +- loads custom entities for sidebar display +- loads role-based sidebar defaults and user sidebar preferences +- computes settings/profile navigation on the server + +There is also duplicated access work: +- [`apps/mercato/src/app/(backend)/backend/[...slug]/page.tsx`](/Users/piotrkarwatka/Projects/mercato-development-two/apps/mercato/src/app/(backend)/backend/[...slug]/page.tsx) separately resolves page-level `requireFeatures` +- client menu surfaces use [`useInjectedMenuItems`](/Users/piotrkarwatka/Projects/mercato-development-two/packages/ui/src/backend/injection/useInjectedMenuItems.ts), which repeats feature and role fetching per surface + +There are currently 5 menu surfaces using independent injected-menu resolution: +- main sidebar +- settings sidebar +- profile sidebar +- topbar actions +- profile dropdown + +The result is unnecessary SSR latency plus redundant client bootstrap calls. + +## Proposed Solution +Adopt a client-first backend chrome model. + +The first implementation step is not UI refactoring. It is consolidating backend chrome resolution into one authoritative server-side pipeline that can be reused by: +- backend page authorization support code where relevant +- `GET /api/auth/admin/nav` +- client-side chrome hydration + +Client-first hydration should be built on top of that shared pipeline rather than introducing a second nav implementation. + +Server responsibilities: +- authenticate the request +- authorize the requested backend page +- resolve locale and lightweight shell props +- render page content immediately +- render a stable sidebar/topbar placeholder + +Client responsibilities: +- fetch a single backend chrome bootstrap payload once after hydration +- populate main sidebar, settings/profile nav, profile dropdown, and topbar injected actions from shared data +- lazily initialize non-critical topbar widgets +- keep sidebar customization editing and persistence behavior unchanged + +The sidebar remains behaviorally identical after hydration: +- same groups/items +- same route-derived visibility +- same injected menu support +- same sidebar customization result +- same custom entity entries +- same settings/profile navigation result + +## Architecture +Introduce a single backend chrome bootstrap data flow. + +### 0. Prioritize nav/chrome code deduplication +Before moving more logic client-side, extract one shared backend chrome resolver responsible for: +- route discovery and grouping +- scope-aware RBAC filtering +- custom entity sidebar entries +- role-default sidebar preferences +- user sidebar preferences +- settings/profile section derivation + +This resolver should become the single source of truth used by: +- [`apps/mercato/src/app/(backend)/backend/layout.tsx`](/Users/piotrkarwatka/Projects/mercato-development-two/apps/mercato/src/app/(backend)/backend/layout.tsx) +- [`packages/core/src/modules/auth/api/admin/nav.ts`](/Users/piotrkarwatka/Projects/mercato-development-two/packages/core/src/modules/auth/api/admin/nav.ts) +- any future backend chrome provider bootstrap loader + +Goals: +- remove duplicate route scanning/grouping/sorting logic +- remove duplicate scope and RBAC resolution paths where possible +- ensure sidebar/settings/profile outputs are derived from one implementation +- make caching and invalidation operate on one authoritative payload shape + +Non-goals: +- keep multiple “equivalent” nav builders in sync manually +- optimize SSR and client hydration separately with divergent filtering rules + +### 1. Lightweight backend layout +Refactor [`apps/mercato/src/app/(backend)/backend/layout.tsx`](/Users/piotrkarwatka/Projects/mercato-development-two/apps/mercato/src/app/(backend)/backend/layout.tsx) so it no longer: +- calls `buildAdminNav()` +- runs feature-check fallback loops for nav construction +- queries custom entities for sidebar nav +- loads sidebar preferences on initial request +- computes settings/profile sections on the server + +It should keep only: +- auth/session context needed by the shell +- locale/dictionary resolution +- path/current title/breadcrumb basics +- product/version/header shell props +- `PageInjectionBoundary` +- page content wrapper + +### 2. Single chrome bootstrap endpoint +Reuse and extend [`packages/core/src/modules/auth/api/admin/nav.ts`](/Users/piotrkarwatka/Projects/mercato-development-two/packages/core/src/modules/auth/api/admin/nav.ts) as the single backend chrome payload endpoint. + +It must return: +- `groups` +- `settingsSections` +- `settingsPathPrefixes` +- `profileSections` +- `profilePathPrefixes` +- `grantedFeatures` +- `roles` + +It continues to apply: +- selected tenant/org scope +- route metadata filtering +- role-based sidebar defaults +- user sidebar preferences +- dynamic custom entity sidebar items + +It should be backed by the shared backend chrome resolver introduced in step 0 rather than its own standalone nav-building implementation. + +### 3. Shared backend chrome provider +Add a client provider in `packages/ui` that: +- fetches the bootstrap payload once +- caches it for the current route scope +- exposes shared data to `AppShell`, settings/profile section nav, `ProfileDropdown`, and injected menu hooks +- supports explicit refresh on scope changes and `om:refresh-sidebar` + +### 4. AppShell becomes provider-driven +Refactor [`packages/ui/src/backend/AppShell.tsx`](/Users/piotrkarwatka/Projects/mercato-development-two/packages/ui/src/backend/AppShell.tsx) so the authoritative sidebar/settings/profile data comes from the shared provider rather than server-passed resolved nav. + +Initial render: +- page content is visible +- sidebar/topbar chrome uses placeholders/skeletons +- no nav-blocking SSR work is required + +Hydrated render: +- sidebar and section nav appear with final data +- customization mode continues to operate on the hydrated nav snapshot + +### 5. Remove per-surface feature/role fetches +Refactor [`packages/ui/src/backend/injection/useInjectedMenuItems.ts`](/Users/piotrkarwatka/Projects/mercato-development-two/packages/ui/src/backend/injection/useInjectedMenuItems.ts) to stop calling: +- `/api/auth/feature-check` +- `/api/auth/profile` + +Instead it must: +- consume `grantedFeatures` and `roles` from the shared backend chrome provider +- continue loading widget definitions per surface +- filter injected items locally against the provider-backed features/roles + +This is also a code deduplication task: +- role and feature context should be fetched once per chrome bootstrap, not once per injected menu surface +- injected menu filtering rules must reuse the same granted-feature/role snapshot as the sidebar bootstrap payload + +### 6. Further optimization opportunities after deduplication +Once the shared resolver is in place, additional optimizations become safe and lower-risk: +- enable read-through caching for `GET /api/auth/admin/nav` using the existing cache tags +- precompute static route manifest/grouping metadata during module generation and apply only scope/RBAC/preferences at request time +- split chrome data into static manifest, role/scope-filtered snapshot, and user preference overlay +- cache hydrated chrome payload across backend navigations for the active org/tenant scope and revalidate in the background +- fetch injected menu widget definitions once for all menu surfaces and fan out locally +- send stable icon identifiers instead of resolving heavier icon payloads on the critical path when possible +- instrument timings for scope resolution, ACL load, custom entity lookup, preference loading, and payload build time + +### 7. Keep page authorization server-side +Do not move page authorization client-side. + +[`apps/mercato/src/app/(backend)/backend/[...slug]/page.tsx`](/Users/piotrkarwatka/Projects/mercato-development-two/apps/mercato/src/app/(backend)/backend/[...slug]/page.tsx) must continue to enforce: +- `requireAuth` +- `requireRoles` +- `requireFeatures` + +The optimization is for chrome rendering only, not access control. + +### 8. Lazy non-critical header chrome +Defer initialization of: +- `GlobalSearchDialog` +- `AiAssistantIntegration` +- `NotificationBellWrapper` +- `MessagesIcon` +- `OrganizationSwitcher` + +Use lightweight placeholders where needed. Final behavior remains unchanged after hydration. + +### 9. Integration-test-safe hydration contract +Client-first chrome must preserve test stability for existing Playwright coverage. + +Requirements: +- keep final sidebar, settings nav, profile nav, profile dropdown, and injected menu item `href` values unchanged +- keep existing stable menu item IDs, widget IDs, and test IDs unchanged wherever they already exist +- expose one deterministic backend chrome readiness marker for tests, for example a shell-level `data-testid` or equivalent ready attribute +- render placeholders in a stable container so hydration does not replace the surrounding shell structure unexpectedly +- avoid speculative SSR nav entries that could reorder or disappear during hydration +- ensure sidebar customization applies to the hydrated final nav snapshot without changing item/group identifiers + +Testing guidance: +- integration tests that assert sidebar content should wait for the chrome readiness marker before asserting hydrated nav content +- integration tests must not rely on pre-hydration placeholder ordering +- tests that only verify page authorization or page content should continue to pass without waiting for sidebar hydration + +## Data Models +No database schema changes. + +New shared response type should be introduced for the bootstrap payload, for example: +- `BackendChromePayload` + +It should contain: +- `groups` +- `settingsSections` +- `settingsPathPrefixes` +- `profileSections` +- `profilePathPrefixes` +- `grantedFeatures` +- `roles` + +Existing sidebar preference schema remains unchanged. + +## API Contracts +Extend `GET /api/auth/admin/nav` response instead of introducing a second bootstrap endpoint. + +Contract additions: +- additive-only fields +- no renaming or removal of existing `groups` +- stable group/item IDs preserved +- settings/profile section data included in final response +- granted features and user roles included for injected menu filtering + +Do not change: +- `PUT /api/auth/sidebar/preferences` +- `GET /api/auth/sidebar/preferences` + +Those APIs must continue to support: +- reorder +- rename +- hide +- apply-to-roles +- reset behavior + +## Risks & Impact Review +### Risk: Hydration mismatch between server placeholder and final client nav +Severity: Medium +Affected area: Backend shell UX +Mitigation: render stable placeholders only, not speculative nav content + +### Risk: Client-first nav briefly hides available routes until bootstrap resolves +Severity: Medium +Affected area: perceived navigation availability +Mitigation: page content renders immediately; sidebar skeleton is acceptable by product decision + +### Risk: Scope changes produce stale nav +Severity: High +Affected area: org/tenant-aware navigation correctness +Mitigation: provider refreshes on organization/tenant change events and `om:refresh-sidebar` + +### Risk: injected menu behavior drifts from previous implementation +Severity: High +Affected area: UMES/menu injection contract +Mitigation: preserve existing widget-loading path and stable menu IDs; only centralize features/roles sourcing + +### Risk: customization mode breaks for injected items +Severity: High +Affected area: sidebar customization +Mitigation: preserve current item/group IDs and apply customization only after merged hydrated nav snapshot is available + +### Risk: duplicated RBAC/scope logic remains in multiple places +Severity: Medium +Affected area: performance and correctness +Mitigation: extract a shared scope-resolution helper used by page guard and nav/bootstrap endpoint + +### Risk: deduplication is skipped and client-first hydration ships on top of parallel nav implementations +Severity: High +Affected area: backend chrome correctness, caching, menu injection parity, long-term maintenance +Mitigation: make shared backend chrome resolver extraction the first implementation phase and block client-first rollout on it + +### Risk: existing integration tests become flaky after sidebar hydration moves client-side +Severity: High +Affected area: Playwright coverage for sidebar, settings nav, injected menu surfaces, and profile dropdown +Mitigation: provide a deterministic chrome readiness marker, preserve stable IDs/test IDs/hrefs, and update affected tests to wait for hydration where they assert chrome content + +Residual risk: +- first paint improves, but total interactive chrome readiness still depends on one post-hydration bootstrap request + +## Test Plan +- unit test shared backend chrome resolver for route grouping, hierarchy, scope filtering, custom entities, and preference application +- unit test `GET /api/auth/admin/nav` for scoped route filtering, custom entities, role defaults, and user preferences +- unit test additive response fields for settings/profile sections, granted features, and roles +- unit test `useInjectedMenuItems` to verify it uses provider data and no longer calls profile/feature-check endpoints +- component test `AppShell` initial placeholder render followed by hydrated final nav +- component test backend chrome readiness marker toggles only after shared payload is applied +- component test sidebar customization after hydration for reorder/rename/hide/reset +- integration test backend page first load: page content visible before nav hydration, final nav unchanged +- integration test existing sidebar-dependent flows remain stable when waiting on the chrome readiness marker before nav assertions +- integration test injected menu visibility across all 5 surfaces +- integration test org/tenant switch triggers bootstrap refresh and nav update +- regression test unauthorized backend pages remain denied server-side before client nav loads + +## Final Compliance Report +- contract-surface impact: additive-only API response expansion on existing admin-nav endpoint +- no route URL changes +- no ACL feature ID changes +- no widget/menu surface ID changes +- no existing stable test IDs or hydrated menu `href` targets should be renamed or removed +- no sidebar preference schema changes +- no database migration required +- page authorization remains server-enforced +- menu injection and sidebar customization remain supported +- implementation priority requires deduplicating backend chrome/nav resolution before client-first hydration rollout + +## Changelog +- 2026-04-03: Initial draft for backend first-load optimization via client-first sidebar and shared backend chrome bootstrap +- 2026-04-03: Added integration-test stability requirements for client-hydrated backend chrome +- 2026-04-03: Prioritized backend nav/chrome code deduplication as phase-zero optimization work +- 2026-04-03: Implemented shared backend chrome resolver, provider-driven AppShell hydration, additive admin-nav bootstrap fields, and lazy backend header chrome diff --git a/apps/mercato/src/app/(backend)/backend/layout.tsx b/apps/mercato/src/app/(backend)/backend/layout.tsx index a75b9addda7..31995d6eebc 100644 --- a/apps/mercato/src/app/(backend)/backend/layout.tsx +++ b/apps/mercato/src/app/(backend)/backend/layout.tsx @@ -1,89 +1,46 @@ import { cookies, headers } from 'next/headers' -import type { ReactNode } from 'react' import { modules } from '@/.mercato/generated/modules.generated' import { findBackendMatch } from '@open-mercato/shared/modules/registry' import { getAuthFromCookies } from '@open-mercato/shared/lib/auth/server' import { AppShell } from '@open-mercato/ui/backend/AppShell' -import { - buildAdminNav, - buildSettingsSections, - computeSettingsPathPrefixes, - convertToSectionNavGroups, -} from '@open-mercato/ui/backend/utils/nav' -import type { AdminNavItem } from '@open-mercato/ui/backend/utils/nav' -import { ProfileDropdown } from '@open-mercato/ui/backend/ProfileDropdown' -import { IntegrationsButton } from '@open-mercato/ui/backend/IntegrationsButton' -import { SettingsButton } from '@open-mercato/ui/backend/SettingsButton' -import { MessagesIcon } from '@open-mercato/ui/backend/messages' -import { GlobalSearchDialog } from '@open-mercato/search/modules/search/frontend' -import OrganizationSwitcher from '@/components/OrganizationSwitcher' -import { NotificationBellWrapper } from '@/components/NotificationBellWrapper' import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' import { I18nProvider } from '@open-mercato/shared/lib/i18n/context' -import { createRequestContainer } from '@open-mercato/shared/lib/di/container' -import { - applySidebarPreference, - loadFirstRoleSidebarPreference, - loadSidebarPreference, -} from '@open-mercato/core/modules/auth/services/sidebarPreferencesService' -import type { SidebarPreferencesSettings } from '@open-mercato/shared/modules/navigation/sidebarPreferences' -import { Role } from '@open-mercato/core/modules/auth/data/entities' -import type { EntityManager } from '@mikro-orm/postgresql' -import type { FilterQuery } from '@mikro-orm/core' -import type { AwilixContainer } from 'awilix' -import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService' -import { resolveFeatureCheckContext } from '@open-mercato/core/modules/directory/utils/organizationScope' -import { profileSections, profilePathPrefixes } from '@open-mercato/core/modules/auth/lib/profile-sections' +import { profilePathPrefixes } from '@open-mercato/core/modules/auth/lib/profile-sections' import { APP_VERSION } from '@open-mercato/shared/lib/version' import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean' import { PageInjectionBoundary } from '@open-mercato/ui/backend/injection/PageInjectionBoundary' import { DemoFeedbackWidget } from '@/components/DemoFeedbackWidget' -import { AiAssistantIntegration, AiChatHeaderButton } from '@open-mercato/ai-assistant/frontend' -import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities' - -type NavItem = { - href: string - title: string - defaultTitle: string - enabled: boolean - hidden?: boolean - icon?: ReactNode - pageContext?: 'main' | 'admin' | 'settings' | 'profile' - children?: NavItem[] -} - -type NavGroup = { - id: string - name: string - defaultName: string - items: NavItem[] - weight: number +import OrganizationSwitcher from '@/components/OrganizationSwitcher' +import { BackendHeaderChrome } from '@/components/BackendHeaderChrome' + +function collectStaticSettingsPathPrefixes(): string[] { + const prefixes = new Set() + for (const module of modules) { + for (const route of module.backendRoutes ?? []) { + if (route.pageContext !== 'settings') continue + const href = route.pattern ?? route.path ?? '' + if (!href || href.includes('[')) continue + const parts = href.split('/') + const lastSegment = parts[parts.length - 1] + if (parts.length > 3 && lastSegment !== 'settings') { + prefixes.add(parts.slice(0, -1).join('/')) + } + prefixes.add(href) + } + } + return Array.from(prefixes) } -export default async function BackendLayout({ children, params }: { children: React.ReactNode; params: Promise<{ slug?: string[] }> }) { +export default async function BackendLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ slug?: string[] }> +}) { const auth = await getAuthFromCookies() const cookieStore = await cookies() const headerStore = await headers() - const rawSelectedOrg = cookieStore.get('om_selected_org')?.value - const rawSelectedTenant = cookieStore.get('om_selected_tenant')?.value - const selectedOrgForScope = rawSelectedOrg === undefined - ? undefined - : rawSelectedOrg && rawSelectedOrg.trim().length > 0 - ? rawSelectedOrg - : null - const selectedTenantForScope = rawSelectedTenant === undefined - ? undefined - : rawSelectedTenant && rawSelectedTenant.trim().length > 0 - ? rawSelectedTenant - : null - - let requestContainer: AwilixContainer | null = null - const ensureContainer = async (): Promise => { - if (!requestContainer) { - requestContainer = await createRequestContainer() - } - return requestContainer - } let path = headerStore.get('x-next-url') ?? '' if (path.includes('?')) path = path.split('?')[0] @@ -95,19 +52,9 @@ export default async function BackendLayout({ children, params }: { children: Re } if (!path) { const slug = resolvedParams.slug ?? [] - path = '/backend' + (Array.isArray(slug) && slug.length ? '/' + slug.join('/') : '') + path = '/backend' + (Array.isArray(slug) && slug.length > 0 ? `/${slug.join('/')}` : '') } - const ctxAuth = auth - ? { - roles: auth.roles || [], - sub: auth.sub, - tenantId: auth.tenantId, - orgId: auth.orgId, - } - : undefined - const ctx = { auth: ctxAuth, path } - const { translate, locale, dict } = await resolveTranslations() const embeddingConfigured = Boolean( process.env.OPENAI_API_KEY || @@ -115,271 +62,32 @@ export default async function BackendLayout({ children, params }: { children: Re process.env.MISTRAL_API_KEY || process.env.COHERE_API_KEY || process.env.AWS_ACCESS_KEY_ID || - process.env.OLLAMA_BASE_URL + process.env.OLLAMA_BASE_URL, ) - const missingConfigMessage = translate('search.messages.missingConfig', 'Search requires configuring an embedding provider for semantic search.') - - const featureChecker = auth - ? async (features: string[]): Promise> => { - if (!features?.length) return new Set() - try { - const container = await ensureContainer() - const rbac = container.resolve('rbacService') - const { organizationId, scope, allowedOrganizationIds } = await resolveFeatureCheckContext({ - container, - auth, - selectedId: selectedOrgForScope, - tenantId: selectedTenantForScope, - }) - if (Array.isArray(allowedOrganizationIds) && allowedOrganizationIds.length === 0) { - return new Set() - } - const tenantForCheck = scope.tenantId ?? auth.tenantId ?? null - const orgForCheck = organizationId ?? null - const context = { tenantId: tenantForCheck, organizationId: orgForCheck } - const hasAll = await rbac.userHasAllFeatures(auth.sub, features, context) - if (hasAll) return new Set(features) - const granted: string[] = [] - for (const feature of features) { - const hasFeature = await rbac.userHasAllFeatures(auth.sub, [feature], context) - if (hasFeature) granted.push(feature) - } - return new Set(granted) - } catch { - return new Set() - } - } - : undefined - - let userEntities: Array<{ entityId: string; label: string; href: string }> | undefined - if (auth) { - try { - const container = await ensureContainer() - const em = container.resolve('em') as EntityManager - const where: FilterQuery = { - isActive: true, - showInSidebar: true, - } - where.$and = [ - { $or: [{ organizationId: auth.orgId ?? undefined }, { organizationId: null }] }, - { $or: [{ tenantId: auth.tenantId ?? undefined }, { tenantId: null }] }, - ] - const entities = await em.find(CustomEntity, where, { orderBy: { label: 'asc' } }) - userEntities = entities.map((entity) => ({ - entityId: entity.entityId, - label: entity.label, - href: `/backend/entities/user/${encodeURIComponent(entity.entityId)}/records`, - })) - } catch { - userEntities = undefined - } - } - - const entries = await buildAdminNav( - modules, - ctx, - userEntities, - (key, fallback) => (key ? translate(key, fallback) : fallback), - featureChecker ? { checkFeatures: featureChecker } : undefined, + const missingConfigMessage = translate( + 'search.messages.missingConfig', + 'Search requires configuring an embedding provider for semantic search.', ) - const showIntegrationsButton = entries.some( - (entry) => entry.href === '/backend/integrations' && entry.enabled !== false && entry.hidden !== true, - ) - - const groupMap = new Map() - for (const entry of entries) { - const weight = entry.priority ?? entry.order ?? 10_000 - if (!groupMap.has(entry.groupId)) { - groupMap.set(entry.groupId, { - id: entry.groupId, - key: entry.groupKey, - name: entry.group, - defaultName: entry.groupDefaultName, - items: [entry], - weight, - }) - } else { - const group = groupMap.get(entry.groupId)! - group.items.push(entry) - if (weight < group.weight) group.weight = weight - if (!group.key && entry.groupKey) group.key = entry.groupKey - } - } - - const mapItem = (item: AdminNavItem): NavItem => ({ - href: item.href, - title: item.title, - defaultTitle: item.defaultTitle, - enabled: item.enabled, - hidden: item.hidden, - icon: item.icon, - pageContext: item.pageContext, - children: item.children?.map(mapItem), - }) - - const baseGroups: NavGroup[] = Array.from(groupMap.values()).map((group) => ({ - id: group.id, - name: group.name, - defaultName: group.defaultName, - weight: group.weight, - items: group.items.map(mapItem), - })) - const defaultGroupOrder = [ - 'customers.nav.group', - 'catalog.nav.group', - 'customers~sales.nav.group', - 'resources.nav.group', - 'staff.nav.group', - 'entities.nav.group', - 'directory.nav.group', - 'customers.storage.nav.group', - ] - const groupOrderIndex = new Map(defaultGroupOrder.map((id, index) => [id, index])) - baseGroups.sort((a, b) => { - const aIndex = groupOrderIndex.get(a.id) - const bIndex = groupOrderIndex.get(b.id) - if (aIndex !== undefined || bIndex !== undefined) { - if (aIndex === undefined) return 1 - if (bIndex === undefined) return -1 - if (aIndex !== bIndex) return aIndex - bIndex - } - if (a.weight !== b.weight) return a.weight - b.weight - return a.name.localeCompare(b.name) - }) - const defaultGroupCount = defaultGroupOrder.length - baseGroups.forEach((group, index) => { - const rank = groupOrderIndex.get(group.id) - const fallbackWeight = typeof group.weight === 'number' ? group.weight : 10_000 - const normalized = - (rank !== undefined ? rank : defaultGroupCount + index) * 1_000_000 + - Math.min(Math.max(fallbackWeight, 0), 999_999) - group.weight = normalized - }) - - let rolePreference: SidebarPreferencesSettings | null = null - let sidebarPreference: SidebarPreferencesSettings | null = null - if (auth) { - try { - const container = await ensureContainer() - const em = container.resolve('em') as EntityManager - if (Array.isArray(auth.roles) && auth.roles.length) { - const roleScope: FilterQuery = auth.tenantId - ? { $or: [{ tenantId: auth.tenantId }, { tenantId: null }] } - : { tenantId: null } - const roleRecords = await em.find(Role, { - name: { $in: auth.roles }, - ...roleScope, - }) - const roleIds = roleRecords.map((role) => role.id) - if (roleIds.length) { - rolePreference = await loadFirstRoleSidebarPreference(em, { - roleIds, - tenantId: auth.tenantId ?? null, - locale, - }) - } - } - // For API key auth, use userId (the actual user) if available - const effectiveUserId: string | undefined = auth.isApiKey ? auth.userId : auth.sub - if (effectiveUserId) { - sidebarPreference = await loadSidebarPreference(em, { - userId: effectiveUserId, - tenantId: auth.tenantId ?? null, - organizationId: auth.orgId ?? null, - locale, - }) - } - } catch { - // ignore preference loading failures; render with default navigation - } - } - const groupsWithRole = rolePreference ? applySidebarPreference(baseGroups, rolePreference) : baseGroups - const baseForUser = adoptSidebarDefaults(groupsWithRole) - const appliedGroups = sidebarPreference ? applySidebarPreference(baseForUser, sidebarPreference) : baseForUser - - const materializeItem = (item: NavItem): NavItem => ({ - href: item.href, - title: item.title, - defaultTitle: item.defaultTitle, - enabled: item.enabled, - hidden: item.hidden, - icon: item.icon, - pageContext: item.pageContext, - children: item.children?.map(materializeItem), - }) - - const groups: NavGroup[] = appliedGroups.map((group) => ({ - id: group.id, - name: group.name, - defaultName: group.defaultName, - items: group.items.map(materializeItem), - weight: group.weight, - })) - - type NavEntry = NavItem & { group: string } - const allEntries: NavEntry[] = groups.flatMap((group) => - group.items.map((item) => ({ ...item, group: group.name })), - ) - const current = allEntries.find((item) => path.startsWith(item.href)) - const currentTitle = current?.title || '' const match = findBackendMatch(modules, path) + const currentTitle = match?.route.titleKey + ? translate(match.route.titleKey, match.route.title) + : (match?.route.title ?? '') const rawBreadcrumb = match?.route.breadcrumb - const breadcrumb = rawBreadcrumb?.map((item) => { - const fallback = item.label - const label = item.labelKey ? translate(item.labelKey, fallback || item.labelKey) : fallback - return { ...item, label } - }) - - const settingsSectionOrder: Record = { - 'system': 1, - 'auth': 2, - 'customer-portal': 3, - 'data-designer': 4, - 'module-configs': 5, - 'directory': 6, - 'feature-toggles': 7, - } - const generatedSettingsSections = buildSettingsSections(entries, settingsSectionOrder) - const settingsPathPrefixes = computeSettingsPathPrefixes(generatedSettingsSections) - const filteredSettingsSections = convertToSectionNavGroups( - generatedSettingsSections, - (key, fallback) => (key ? translate(key, fallback) : fallback) - ) + const breadcrumb = rawBreadcrumb?.map((item) => ({ + ...item, + label: item.labelKey ? translate(item.labelKey, item.label || item.labelKey) : item.label, + })) const collapsedCookie = cookieStore.get('om_sidebar_collapsed')?.value const initialCollapsed = collapsedCookie === '1' - - const rightHeaderContent = ( - <> - - -
    - -
    - {showIntegrationsButton ? : null} - - - - - - ) - - const mobileSidebarContent = - const demoModeEnabled = parseBooleanWithDefault(process.env.DEMO_MODE, true) const deployEnv = process.env.DEPLOY_ENV const baseProductName = translate('appShell.productName', 'Open Mercato') const productName = deployEnv && deployEnv !== 'local' ? `${baseProductName} (${deployEnv.charAt(0).toUpperCase() + deployEnv.slice(1)})` : baseProductName + const injectionContext = { path, userId: auth?.sub ?? null, @@ -388,54 +96,41 @@ export default async function BackendLayout({ children, params }: { children: Re } return ( - <> - - - + - - {children} - - {demoModeEnabled ? : null} - - - - + embeddingConfigured={embeddingConfigured} + missingConfigMessage={missingConfigMessage} + tenantId={auth?.tenantId ?? null} + organizationId={auth?.orgId ?? null} + /> + )} + mobileSidebarSlot={} + adminNavApi="/api/auth/admin/nav" + version={APP_VERSION} + settingsPathPrefixes={collectStaticSettingsPathPrefixes()} + settingsSections={[]} + settingsSectionTitle={translate('backend.nav.settings', 'Settings')} + profileSections={[]} + profileSectionTitle={translate('profile.page.title', 'Profile')} + profilePathPrefixes={profilePathPrefixes} + > + + {children} + + {demoModeEnabled ? : null} + + ) } -export const dynamic = 'force-dynamic' - -function adoptSidebarDefaults(groups: NavGroup[]): NavGroup[] { - const adoptItems = (items: NavItem[]): NavItem[] => - items.map((item) => ({ - ...item, - defaultTitle: item.title, - children: item.children ? adoptItems(item.children) : undefined, - })) - return groups.map((group) => ({ - ...group, - defaultName: group.name, - items: adoptItems(group.items), - })) -} +export const dynamic = 'force-dynamic' diff --git a/apps/mercato/src/components/AiAssistantShellIntegration.tsx b/apps/mercato/src/components/AiAssistantShellIntegration.tsx new file mode 100644 index 00000000000..ae9cfe7f335 --- /dev/null +++ b/apps/mercato/src/components/AiAssistantShellIntegration.tsx @@ -0,0 +1,42 @@ +'use client' + +import * as React from 'react' + +type AiAssistantIntegrationComponent = React.ComponentType<{ + tenantId: string | null + organizationId: string | null + children: React.ReactNode +}> + +type AiAssistantShellIntegrationProps = { + tenantId: string | null + organizationId: string | null + children: React.ReactNode +} + +export function AiAssistantShellIntegration({ + tenantId, + organizationId, + children, +}: AiAssistantShellIntegrationProps) { + const [IntegrationComponent, setIntegrationComponent] = React.useState(null) + + React.useEffect(() => { + let cancelled = false + void import('@open-mercato/ai-assistant/frontend').then((module) => { + if (cancelled) return + setIntegrationComponent(() => module.AiAssistantIntegration) + }) + return () => { + cancelled = true + } + }, []) + + if (!IntegrationComponent) return <>{children} + + return ( + + {children} + + ) +} diff --git a/apps/mercato/src/components/BackendHeaderChrome.tsx b/apps/mercato/src/components/BackendHeaderChrome.tsx new file mode 100644 index 00000000000..cab67730104 --- /dev/null +++ b/apps/mercato/src/components/BackendHeaderChrome.tsx @@ -0,0 +1,98 @@ +'use client' + +import dynamic from 'next/dynamic' +import * as React from 'react' +import { hasFeature } from '@open-mercato/shared/security/features' +import { IntegrationsButton } from '@open-mercato/ui/backend/IntegrationsButton' +import { ProfileDropdown } from '@open-mercato/ui/backend/ProfileDropdown' +import { SettingsButton } from '@open-mercato/ui/backend/SettingsButton' +import { useBackendChrome } from '@open-mercato/ui/backend/BackendChromeProvider' +import { AiAssistantShellIntegration } from '@/components/AiAssistantShellIntegration' + +const LazyAiChatHeaderButton = dynamic( + () => import('@open-mercato/ai-assistant/frontend').then((module) => module.AiChatHeaderButton), + { ssr: false, loading: () => null }, +) +const LazyGlobalSearchDialog = dynamic( + () => import('@open-mercato/search/modules/search/frontend').then((module) => module.GlobalSearchDialog), + { ssr: false, loading: () => null }, +) +const LazyOrganizationSwitcher = dynamic(() => import('@/components/OrganizationSwitcher'), { + ssr: false, + loading: () => null, +}) +const LazyNotificationBellWrapper = dynamic( + () => import('@/components/NotificationBellWrapper').then((module) => module.NotificationBellWrapper), + { ssr: false, loading: () => null }, +) +const LazyMessagesIcon = dynamic( + () => import('@open-mercato/ui/backend/messages').then((module) => module.MessagesIcon), + { ssr: false, loading: () => null }, +) + +type BackendHeaderChromeProps = { + email?: string + embeddingConfigured: boolean + missingConfigMessage: string + tenantId: string | null + organizationId: string | null +} + +function hasVisibleRoute(groups: Array<{ items?: Array<{ href: string; hidden?: boolean; enabled?: boolean; children?: unknown[] }> }> | undefined, href: string): boolean { + if (!groups) return false + for (const group of groups) { + for (const item of group.items ?? []) { + if (item.href === href && item.hidden !== true && item.enabled !== false) return true + const children = Array.isArray(item.children) ? item.children as Array<{ href: string; hidden?: boolean; enabled?: boolean; children?: unknown[] }> : [] + if (hasVisibleRoute([{ items: children }], href)) return true + } + } + return false +} + +export function BackendHeaderChrome({ + email, + embeddingConfigured, + missingConfigMessage, + tenantId, + organizationId, +}: BackendHeaderChromeProps) { + const { payload, isReady } = useBackendChrome() + const grantedFeatures = payload?.grantedFeatures ?? [] + const showIntegrationsButton = React.useMemo( + () => hasVisibleRoute(payload?.groups, '/backend/integrations'), + [payload?.groups], + ) + const showAiAssistant = React.useMemo( + () => hasFeature(grantedFeatures, 'ai_assistant.view'), + [grantedFeatures], + ) + const showMessages = React.useMemo( + () => hasFeature(grantedFeatures, 'messages.view'), + [grantedFeatures], + ) + + return ( + <> + {isReady && showAiAssistant ? ( + + + + ) : null} + {isReady ? ( + + ) : null} +
    + {isReady ? : null} +
    + {showIntegrationsButton ? : null} + + + {isReady ? : null} + {isReady && showMessages ? : null} + + ) +} diff --git a/packages/ai-assistant/src/frontend/hooks/useMcpTools.ts b/packages/ai-assistant/src/frontend/hooks/useMcpTools.ts index 2597abbb15d..6dc096209e4 100644 --- a/packages/ai-assistant/src/frontend/hooks/useMcpTools.ts +++ b/packages/ai-assistant/src/frontend/hooks/useMcpTools.ts @@ -12,7 +12,11 @@ export function useMcpTools() { setIsLoading(true) setError(null) try { - const response = await fetch('/api/ai_assistant/tools') + const response = await fetch('/api/ai_assistant/tools', { + headers: { + 'x-om-forbidden-redirect': '0', + }, + }) if (!response.ok) { throw new Error(`Failed to fetch tools: ${response.status}`) } @@ -31,7 +35,10 @@ export function useMcpTools() { try { const response = await fetch('/api/ai_assistant/tools/execute', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-om-forbidden-redirect': '0', + }, body: JSON.stringify({ toolName, args }), }) diff --git a/packages/core/src/modules/auth/__integration__/TC-AUTH-022.spec.ts b/packages/core/src/modules/auth/__integration__/TC-AUTH-022.spec.ts index f8d247b7f0a..891dce40dfa 100644 --- a/packages/core/src/modules/auth/__integration__/TC-AUTH-022.spec.ts +++ b/packages/core/src/modules/auth/__integration__/TC-AUTH-022.spec.ts @@ -145,6 +145,7 @@ test.describe('TC-AUTH-022: customer_accounts wildcard shows customer portal sid await loginWithCredentials(page, userEmail, userPassword) await page.goto('/backend/customer_accounts/users', { waitUntil: 'domcontentloaded' }) + await expect(page.getByTestId('backend-chrome-ready')).toHaveAttribute('data-ready', 'true') await expect(page.locator('a[href="/backend/customer_accounts/users"]').first()).toBeVisible() await expect(page.locator('a[href="/backend/customer_accounts/roles"]').first()).toBeVisible() } finally { diff --git a/packages/core/src/modules/auth/api/__tests__/admin-nav.test.ts b/packages/core/src/modules/auth/api/__tests__/admin-nav.test.ts index c6e59c60ca0..1dad73c17b7 100644 --- a/packages/core/src/modules/auth/api/__tests__/admin-nav.test.ts +++ b/packages/core/src/modules/auth/api/__tests__/admin-nav.test.ts @@ -252,4 +252,51 @@ describe('GET /api/auth/admin/nav', () => { expect(customerPortalGroup?.items.map((item) => item.href)).toContain('/backend/customer_accounts/users') }) + + it('returns the extended backend chrome payload fields for client hydration', async () => { + mockGetAuthFromRequest.mockResolvedValue({ + sub: 'user-1', + tenantId: 'tenant-1', + orgId: 'org-1', + roles: ['admin'], + }) + mockLoadAcl.mockResolvedValue({ + isSuperAdmin: false, + features: ['customer_accounts.*', 'auth.*'], + }) + mockGetModules.mockReturnValue([ + { + id: 'auth', + backendRoutes: [ + { + pattern: '/backend/settings/auth/users', + title: 'Users', + pageGroupKey: 'auth.settings.section', + group: 'Auth', + order: 1, + pageContext: 'settings', + } as BackendRoute & { pageContext: 'settings' }, + ], + }, + ]) + setupCustomEntities([]) + + const response = await GET(makeRequest()) + expect(response.status).toBe(200) + const payload = (await response.json()) as { + settingsSections: Array<{ id: string; items: Array<{ href: string }> }> + settingsPathPrefixes: string[] + profileSections: Array<{ id: string }> + profilePathPrefixes: string[] + grantedFeatures: string[] + roles: string[] + } + + expect(payload.settingsSections[0]?.items.map((item) => item.href)).toContain('/backend/settings/auth/users') + expect(payload.settingsPathPrefixes).toContain('/backend/settings/auth') + expect(payload.profileSections.length).toBeGreaterThan(0) + expect(payload.profilePathPrefixes).toContain('/backend/profile/') + expect(payload.grantedFeatures).toEqual(expect.arrayContaining(['customer_accounts.*', 'auth.*'])) + expect(payload.roles).toEqual(['admin']) + }) }) diff --git a/packages/core/src/modules/auth/api/__tests__/login.test.ts b/packages/core/src/modules/auth/api/__tests__/login.test.ts index 720ee5f4adf..e3cfaec95cf 100644 --- a/packages/core/src/modules/auth/api/__tests__/login.test.ts +++ b/packages/core/src/modules/auth/api/__tests__/login.test.ts @@ -36,6 +36,10 @@ jest.mock('@open-mercato/shared/lib/di/container', () => ({ jest.mock('@open-mercato/shared/lib/auth/jwt', () => ({ signJwt: () => 'jwt-token' })) +jest.mock('@open-mercato/core/modules/auth/events', () => ({ + emitAuthEvent: jest.fn(async () => undefined), +})) + function makeFormData(data: Record) { const formData = new FormData() for (const [key, value] of Object.entries(data)) formData.append(key, value) @@ -48,6 +52,52 @@ describe('POST /api/auth/login with custom route interceptors', () => { jest.clearAllMocks() }) + test('accepts application/x-www-form-urlencoded login payloads', async () => { + const form = new URLSearchParams() + form.set('email', 'user@example.com') + form.set('password', 'secret') + form.set('remember', '1') + + const req = new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + body: form.toString(), + }) + + const res = await POST(req) + expect(res.status).toBe(200) + + const body = await res.json() + expect(body).toEqual({ + ok: true, + token: 'jwt-token', + redirect: '/backend', + refreshToken: 'session-token', + }) + }) + + test('returns 400 for malformed multipart login bodies instead of throwing', async () => { + const req = new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { + 'content-type': 'multipart/form-data', + }, + body: 'email=user@example.com&password=secret', + }) + + const res = await POST(req) + expect(res.status).toBe(400) + expect(authServiceMock.findUsersByEmail).not.toHaveBeenCalled() + + const body = await res.json() + expect(body).toEqual({ + ok: false, + error: 'Invalid credentials', + }) + }) + test('returns unchanged login response when no interceptor matches', async () => { const req = new Request('http://localhost/api/auth/login', { method: 'POST', diff --git a/packages/core/src/modules/auth/api/admin/nav.ts b/packages/core/src/modules/auth/api/admin/nav.ts index d3e9c457005..c41040b9948 100644 --- a/packages/core/src/modules/auth/api/admin/nav.ts +++ b/packages/core/src/modules/auth/api/admin/nav.ts @@ -1,386 +1,179 @@ import { NextResponse } from 'next/server' import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' import { z } from 'zod' -import { getModules } from '@open-mercato/shared/lib/i18n/server' +import { getModules, resolveTranslations } from '@open-mercato/shared/lib/i18n/server' import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server' import { createRequestContainer } from '@open-mercato/shared/lib/di/container' -import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' -import { hasAllFeatures } from '@open-mercato/shared/security/features' -import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities' -import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences' -import { applySidebarPreference, loadFirstRoleSidebarPreference, loadSidebarPreference } from '../../services/sidebarPreferencesService' -import { Role } from '../../data/entities' +import { resolveFeatureCheckContext } from '@open-mercato/core/modules/directory/utils/organizationScope' +import { resolveBackendChromePayload } from '../../lib/backendChrome' export const metadata = { GET: { requireAuth: true }, } -const sidebarNavItemSchema: z.ZodType<{ href: string; title: string; defaultTitle: string; enabled: boolean; hidden?: boolean; children?: any[] }> = z.lazy(() => +const sidebarNavItemSchema: z.ZodType<{ + id?: string + href: string + title: string + defaultTitle?: string + enabled?: boolean + hidden?: boolean + pageContext?: 'main' | 'admin' | 'settings' | 'profile' + iconMarkup?: string + children?: any[] +}> = z.lazy(() => z.object({ + id: z.string().optional(), href: z.string(), title: z.string(), - defaultTitle: z.string(), - enabled: z.boolean(), + defaultTitle: z.string().optional(), + enabled: z.boolean().optional(), hidden: z.boolean().optional(), + pageContext: z.enum(['main', 'admin', 'settings', 'profile']).optional(), + iconMarkup: z.string().optional(), children: z.array(sidebarNavItemSchema).optional(), - }) + }), +) + +const sectionItemSchema: z.ZodType<{ + id: string + label: string + labelKey?: string + href: string + order?: number + iconMarkup?: string + children?: any[] +}> = z.lazy(() => + z.object({ + id: z.string(), + label: z.string(), + labelKey: z.string().optional(), + href: z.string(), + order: z.number().optional(), + iconMarkup: z.string().optional(), + children: z.array(sectionItemSchema).optional(), + }), ) +const sectionGroupSchema = z.object({ + id: z.string(), + label: z.string(), + labelKey: z.string().optional(), + order: z.number().optional(), + items: z.array(sectionItemSchema), +}) + const adminNavResponseSchema = z.object({ groups: z.array( z.object({ - id: z.string(), + id: z.string().optional(), name: z.string(), - defaultName: z.string(), + defaultName: z.string().optional(), items: z.array(sidebarNavItemSchema), - }) + }), ), + settingsSections: z.array(sectionGroupSchema), + settingsPathPrefixes: z.array(z.string()), + profileSections: z.array(sectionGroupSchema), + profilePathPrefixes: z.array(z.string()), + grantedFeatures: z.array(z.string()), + roles: z.array(z.string()), }) const adminNavErrorSchema = z.object({ error: z.string(), }) -type SidebarItemNode = { - href: string - title: string - defaultTitle: string - enabled: boolean - hidden?: boolean - children?: SidebarItemNode[] -} - export async function GET(req: Request) { const auth = await getAuthFromRequest(req) if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const { translate, locale } = await resolveTranslations() - - const { resolve } = await createRequestContainer() - const em = resolve('em') as any - const rbac = resolve('rbacService') as any - const cache = resolve('cache') as any - - // Cache key is user + tenant + organization scoped - const cacheKey = `nav:sidebar:${locale}:${auth.sub}:${auth.tenantId || 'null'}:${auth.orgId || 'null'}` - // try { - // if (cache) { - // const cached = await cache.get(cacheKey) - // if (cached) return NextResponse.json(cached) - // } - // } catch {} - - // Load ACL once; we'll evaluate features locally without multiple calls - const acl = await rbac.loadAcl(auth.sub, { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null }) - - // Build nav entries from discovered backend routes - type Entry = { - groupId: string - groupName: string - groupKey?: string - title: string - titleKey?: string - href: string - enabled: boolean - order?: number - priority?: number - children?: Entry[] - } - const entries: Entry[] = [] - - function capitalize(s: string) { return s.charAt(0).toUpperCase() + s.slice(1) } - function deriveTitleFromPath(p: string) { - const seg = p.split('/').filter(Boolean).pop() || '' - return seg ? seg.split('-').map(capitalize).join(' ') : 'Home' - } - - const ctx = { auth: { roles: auth.roles || [], sub: auth.sub, tenantId: auth.tenantId, orgId: auth.orgId } } - const modules = getModules() - for (const m of (modules as any[])) { - const groupDefault = capitalize(m.id) - for (const r of (m.backendRoutes || [])) { - const href = (r.pattern ?? r.path ?? '') as string - if (!href || href.includes('[')) continue - if ((r as any).navHidden) continue - const title = (r.title as string) || deriveTitleFromPath(href) - const titleKey = (r as any).pageTitleKey ?? (r as any).titleKey - const groupName = (r.group as string) || groupDefault - const groupKey = (r as any).pageGroupKey ?? (r as any).groupKey - const groupId = typeof groupKey === 'string' && groupKey ? groupKey : slugifySidebarId(groupName) - const visible = r.visible ? await Promise.resolve(r.visible(ctx)) : true - if (!visible) continue - const enabled = r.enabled ? await Promise.resolve(r.enabled(ctx)) : true - const requiredRoles = (r.requireRoles as string[]) || [] - if (requiredRoles.length) { - const roles = auth.roles || [] - const ok = requiredRoles.some((role) => roles.includes(role)) - if (!ok) continue - } - const features = (r as any).requireFeatures as string[] | undefined - if (!acl.isSuperAdmin && !hasAllFeatures(acl.features, features)) continue - const order = (r as any).order as number | undefined - const priority = ((r as any).priority as number | undefined) ?? order - entries.push({ groupId, groupName, groupKey, title, titleKey, href, enabled, order, priority }) - } - } - - // Parent-child relationships within the same group by href prefix - const roots: any[] = [] - for (const e of entries) { - let parent: any | undefined - for (const p of entries) { - if (p === e) continue - if (p.groupId !== e.groupId) continue - if (!e.href.startsWith(p.href + '/')) continue - if (!parent || p.href.length > parent.href.length) parent = p - } - if (parent) { - ;(parent as any).children = (parent as any).children || [] - ;(parent as any).children.push(e) - } else { - roots.push(e) - } - } - - // Add dynamic user entities into Data designer > User Entities - const where: any = { isActive: true, showInSidebar: true } - where.$and = [ - { $or: [ { organizationId: auth.orgId ?? undefined as any }, { organizationId: null } ] }, - { $or: [ { tenantId: auth.tenantId ?? undefined as any }, { tenantId: null } ] }, - ] + const container = await createRequestContainer() + const cache = container.resolve('cache') as { + get?: (key: string) => Promise + set?: (key: string, value: unknown, options?: { tags?: string[] }) => Promise + } | null + + let selectedOrganizationId: string | null | undefined + let selectedTenantId: string | null | undefined try { - const entities = await em.find(CustomEntity as any, where as any, { orderBy: { label: 'asc' } as any }) - const items = (entities as any[]).map((e) => ({ - entityId: e.entityId, - label: e.label, - href: `/backend/entities/user/${encodeURIComponent(e.entityId)}/records` - })) - if (items.length) { - const userEntitiesLegacyGroupKeys = new Set(['settings.sections.dataDesigner', 'entities.nav.group']) - const userEntitiesAnchor = entries.find((entry: Entry) => entry.href === '/backend/entities/user') - ?? entries.find((entry: Entry) => - entry.titleKey === 'entities.nav.userEntities' && - typeof entry.groupKey === 'string' && - userEntitiesLegacyGroupKeys.has(entry.groupKey), - ) - if (userEntitiesAnchor) { - const existing = userEntitiesAnchor.children || [] - const dynamic = items.map((it) => ({ - groupId: userEntitiesAnchor.groupId, - groupName: userEntitiesAnchor.groupName, - groupKey: userEntitiesAnchor.groupKey, - title: it.label, - href: it.href, - enabled: true, - order: 1000, - priority: 1000, - })) - const byHref = new Map() - for (const c of existing) if (!byHref.has(c.href)) byHref.set(c.href, c) - for (const c of dynamic) if (!byHref.has(c.href)) byHref.set(c.href, c) - userEntitiesAnchor.children = Array.from(byHref.values()) - } - } - } catch (e) { - console.error('Error loading user entities', e) + const url = new URL(req.url) + const orgParam = url.searchParams.get('orgId') + const tenantParam = url.searchParams.get('tenantId') + selectedOrganizationId = orgParam === null ? undefined : orgParam || null + selectedTenantId = tenantParam === null ? undefined : tenantParam || null + } catch { + selectedOrganizationId = undefined + selectedTenantId = undefined } - // Sort roots and children - const sortItems = (arr: any[]) => { - arr.sort((a, b) => { - if (a.group !== b.group) return a.group.localeCompare(b.group) - const ap = a.priority ?? a.order ?? 10000 - const bp = b.priority ?? b.order ?? 10000 - if (ap !== bp) return ap - bp - return String(a.title).localeCompare(String(b.title)) + let cacheScopeTenantId = auth.tenantId ?? null + let cacheScopeOrganizationId = auth.orgId ?? null + try { + const { organizationId, scope } = await resolveFeatureCheckContext({ + container, + auth, + selectedId: selectedOrganizationId, + tenantId: selectedTenantId, + request: req, }) - for (const it of arr) if (it.children?.length) sortItems(it.children) - } - sortItems(roots) - - // Group into sidebar groups - type GroupBucket = { - id: string - rawName: string - key?: string - weight: number - entries: Entry[] - } - - const groupBuckets = new Map() - for (const entry of roots) { - const weight = entry.priority ?? entry.order ?? 10_000 - if (!groupBuckets.has(entry.groupId)) { - groupBuckets.set(entry.groupId, { - id: entry.groupId, - rawName: entry.groupName, - key: entry.groupKey as string | undefined, - weight, - entries: [entry], - }) - } else { - const bucket = groupBuckets.get(entry.groupId)! - bucket.entries.push(entry) - if (weight < bucket.weight) bucket.weight = weight - if (!bucket.key && entry.groupKey) bucket.key = entry.groupKey as string - if (!bucket.rawName && entry.groupName) bucket.rawName = entry.groupName - } + cacheScopeOrganizationId = organizationId + cacheScopeTenantId = scope.tenantId ?? auth.tenantId ?? null + } catch { + cacheScopeOrganizationId = auth.orgId ?? null + cacheScopeTenantId = auth.tenantId ?? null } - const toItem = (entry: Entry): SidebarItemNode => { - const defaultTitle = entry.titleKey ? translate(entry.titleKey, entry.title) : entry.title - return { - href: entry.href, - title: defaultTitle, - defaultTitle, - enabled: entry.enabled, - children: entry.children?.map((child) => toItem(child)), + const cacheKey = `nav:sidebar:${locale}:${auth.sub}:${cacheScopeTenantId || 'null'}:${cacheScopeOrganizationId || 'null'}` + try { + if (cache?.get) { + const cached = await cache.get(cacheKey) + if (cached) return NextResponse.json(cached) } + } catch { + // ignore cache read failures } - const groups = Array.from(groupBuckets.values()).map((bucket) => { - const defaultName = bucket.key ? translate(bucket.key, bucket.rawName) : bucket.rawName - return { - id: bucket.id, - key: bucket.key, - name: defaultName, - defaultName, - weight: bucket.weight, - items: bucket.entries.map((entry) => toItem(entry)), - } - }) - const defaultGroupOrder = [ - 'customers.nav.group', - 'catalog.nav.group', - 'customers~sales.nav.group', - 'resources.nav.group', - 'staff.nav.group', - 'entities.nav.group', - 'directory.nav.group', - 'customers.storage.nav.group', - ] - const groupOrderIndex = new Map(defaultGroupOrder.map((id, index) => [id, index])) - groups.sort((a, b) => { - const aIndex = groupOrderIndex.get(a.id) - const bIndex = groupOrderIndex.get(b.id) - if (aIndex !== undefined || bIndex !== undefined) { - if (aIndex === undefined) return 1 - if (bIndex === undefined) return -1 - if (aIndex !== bIndex) return aIndex - bIndex - } - if (a.weight !== b.weight) return a.weight - b.weight - return a.name.localeCompare(b.name) - }) - const defaultGroupCount = defaultGroupOrder.length - groups.forEach((group, index) => { - const rank = groupOrderIndex.get(group.id) - const fallbackWeight = typeof group.weight === 'number' ? group.weight : 10_000 - const normalized = - (rank !== undefined ? rank : defaultGroupCount + index) * 1_000_000 + - Math.min(Math.max(fallbackWeight, 0), 999_999) - group.weight = normalized + const payload = await resolveBackendChromePayload({ + auth, + locale, + modules: getModules(), + translate: (key, fallback) => (key ? translate(key, fallback) : fallback), + selectedOrganizationId, + selectedTenantId, }) - let rolePreference = null - if (Array.isArray(auth.roles) && auth.roles.length) { - const roleScope = auth.tenantId - ? { $or: [{ tenantId: auth.tenantId }, { tenantId: null }] } - : { tenantId: null } - const roleRecords = await em.find(Role, { - name: { $in: auth.roles }, - ...roleScope, - } as any) - const roleIds = roleRecords.map((role: Role) => role.id) - if (roleIds.length) { - rolePreference = await loadFirstRoleSidebarPreference(em, { - roleIds, - tenantId: auth.tenantId ?? null, - locale, - }) - } - } - - const groupsWithRole = rolePreference ? applySidebarPreference(groups, rolePreference) : groups - const baseForUser = adoptSidebarDefaults(groupsWithRole) - - // For API key auth, use userId (the actual user) if available; otherwise skip user preferences - const effectiveUserId = auth.isApiKey ? auth.userId : auth.sub - const preference = effectiveUserId - ? await loadSidebarPreference(em, { - userId: effectiveUserId, - tenantId: auth.tenantId ?? null, - organizationId: auth.orgId ?? null, - locale, - }) - : null - - const withPreference = applySidebarPreference(baseForUser, preference) - - const payload = { - groups: withPreference.map((group) => ({ - id: group.id, - name: group.name, - defaultName: group.defaultName, - items: (group.items as SidebarItemNode[]).map((item) => ({ - href: item.href, - title: item.title, - defaultTitle: item.defaultTitle, - enabled: item.enabled, - hidden: item.hidden, - children: item.children?.map((child) => ({ - href: child.href, - title: child.title, - defaultTitle: child.defaultTitle, - enabled: child.enabled, - hidden: child.hidden, - })), - })), - })), - } - try { - if (cache) { + if (cache?.set) { const tags = [ `rbac:user:${auth.sub}`, - auth.tenantId ? `rbac:tenant:${auth.tenantId}` : undefined, - `nav:entities:${auth.tenantId || 'null'}`, + cacheScopeTenantId ? `rbac:tenant:${cacheScopeTenantId}` : undefined, + `nav:entities:${cacheScopeTenantId || 'null'}`, `nav:locale:${locale}`, `nav:sidebar:user:${auth.sub}`, - `nav:sidebar:scope:${auth.sub}:${auth.tenantId || 'null'}:${auth.orgId || 'null'}:${locale}`, - ...(Array.isArray(auth.roles) ? auth.roles.map((role: string) => `nav:sidebar:role:${role}`) : []), + `nav:sidebar:scope:${auth.sub}:${cacheScopeTenantId || 'null'}:${cacheScopeOrganizationId || 'null'}:${locale}`, + ...((Array.isArray(auth.roles) ? auth.roles : []).map((role) => `nav:sidebar:role:${role}`)), ].filter(Boolean) as string[] await cache.set(cacheKey, payload, { tags }) } - } catch {} + } catch { + // ignore cache write failures + } return NextResponse.json(payload) } -function adoptSidebarDefaults(groups: ReturnType) { - const adoptItems = (items: T[]): T[] => - items.map((item) => ({ - ...item, - defaultTitle: item.title, - children: item.children ? adoptItems(item.children) : undefined, - })) - - return groups.map((group) => ({ - ...group, - defaultName: group.name, - items: adoptItems(group.items), - })) -} - export const openApi: OpenApiRouteDoc = { tag: 'Authentication & Accounts', summary: 'Admin sidebar navigation', methods: { GET: { - summary: 'Resolve sidebar entries', + summary: 'Resolve backend chrome bootstrap payload', description: - 'Returns the backend navigation tree available to the authenticated administrator after applying role and personal sidebar preferences.', + 'Returns the backend chrome payload available to the authenticated administrator after applying scope, RBAC, role defaults, and personal sidebar preferences.', responses: [ - { status: 200, description: 'Sidebar navigation structure', schema: adminNavResponseSchema }, + { status: 200, description: 'Backend chrome payload', schema: adminNavResponseSchema }, { status: 401, description: 'Unauthorized', schema: adminNavErrorSchema }, ], }, diff --git a/packages/core/src/modules/auth/api/login.ts b/packages/core/src/modules/auth/api/login.ts index b87a38e9738..9079d297bad 100644 --- a/packages/core/src/modules/auth/api/login.ts +++ b/packages/core/src/modules/auth/api/login.ts @@ -25,15 +25,62 @@ export const metadata = {} // validation comes from userLoginSchema +type ParsedLoginForm = { + email: string + password: string + remember: boolean + tenantIdRaw: string + requiredRoles: string[] +} + +function parseRequiredRoles(rawValue: string): string[] { + return rawValue + .split(',') + .map((value) => value.trim()) + .filter(Boolean) +} + +async function parseLoginForm(req: Request): Promise { + const rawContentType = req.headers.get('content-type') ?? '' + const contentType = rawContentType.split(';')[0].trim().toLowerCase() + + try { + if (contentType === 'application/x-www-form-urlencoded') { + const body = await req.text() + const params = new URLSearchParams(body) + const requireRoleRaw = String(params.get('requireRole') ?? params.get('role') ?? '').trim() + return { + email: String(params.get('email') ?? ''), + password: String(params.get('password') ?? ''), + remember: parseBooleanToken(params.get('remember')) === true, + tenantIdRaw: String(params.get('tenantId') ?? params.get('tenant') ?? '').trim(), + requiredRoles: requireRoleRaw ? parseRequiredRoles(requireRoleRaw) : [], + } + } + + const form = await req.formData() + const requireRoleRaw = String(form.get('requireRole') ?? form.get('role') ?? '').trim() + return { + email: String(form.get('email') ?? ''), + password: String(form.get('password') ?? ''), + remember: parseBooleanToken(form.get('remember')?.toString()) === true, + tenantIdRaw: String(form.get('tenantId') ?? form.get('tenant') ?? '').trim(), + requiredRoles: requireRoleRaw ? parseRequiredRoles(requireRoleRaw) : [], + } + } catch { + return { + email: '', + password: '', + remember: false, + tenantIdRaw: '', + requiredRoles: [], + } + } +} + export async function POST(req: Request) { const { translate } = await resolveTranslations() - const form = await req.formData() - const email = String(form.get('email') ?? '') - const password = String(form.get('password') ?? '') - const remember = parseBooleanToken(form.get('remember')?.toString()) === true - const tenantIdRaw = String(form.get('tenantId') ?? form.get('tenant') ?? '').trim() - const requireRoleRaw = (String(form.get('requireRole') ?? form.get('role') ?? '')).trim() - const requiredRoles = requireRoleRaw ? requireRoleRaw.split(',').map((s) => s.trim()).filter(Boolean) : [] + const { email, password, remember, tenantIdRaw, requiredRoles } = await parseLoginForm(req) // Rate limit — two layers, both checked before validation and DB work const { error: rateLimitError, compoundKey: rateLimitCompoundKey } = await checkAuthRateLimit({ req, ipConfig: loginIpRateLimitConfig, compoundConfig: loginRateLimitConfig, compoundIdentifier: email, @@ -103,14 +150,14 @@ export async function POST(req: Request) { roles: userRoleNames }) void emitAuthEvent('auth.login.success', { id: String(user.id), email: user.email, tenantId: resolvedTenantId, organizationId: user.organizationId ? String(user.organizationId) : null }).catch(() => undefined) + const rememberMeDays = Number(process.env.REMEMBER_ME_DAYS || '30') const responseData: { ok: true; token: string; redirect: string; refreshToken?: string } = { ok: true, token, redirect: '/backend', } if (remember) { - const days = Number(process.env.REMEMBER_ME_DAYS || '30') - const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000) + const expiresAt = new Date(Date.now() + rememberMeDays * 24 * 60 * 60 * 1000) const sess = await auth.createSession(user, expiresAt) responseData.refreshToken = sess.token } @@ -154,8 +201,7 @@ export async function POST(req: Request) { const res = NextResponse.json(interceptedBody, { status: interceptedResponse.statusCode }) res.cookies.set('auth_token', authTokenForCookie, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 8 }) if (remember && refreshTokenForCookie) { - const days = Number(process.env.REMEMBER_ME_DAYS || '30') - const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000) + const expiresAt = new Date(Date.now() + rememberMeDays * 24 * 60 * 60 * 1000) res.cookies.set('session_token', refreshTokenForCookie, { httpOnly: true, path: '/', sameSite: 'lax', secure: process.env.NODE_ENV === 'production', expires: expiresAt }) } return res diff --git a/packages/core/src/modules/auth/lib/backendChrome.tsx b/packages/core/src/modules/auth/lib/backendChrome.tsx new file mode 100644 index 00000000000..d4608da8338 --- /dev/null +++ b/packages/core/src/modules/auth/lib/backendChrome.tsx @@ -0,0 +1,359 @@ +import * as React from 'react' +import type { FilterQuery } from '@mikro-orm/core' +import type { EntityManager } from '@mikro-orm/postgresql' +import type { AwilixContainer } from 'awilix' +import type { AuthContext } from '@open-mercato/shared/lib/auth/server' +import type { Module } from '@open-mercato/shared/modules/registry' +import type { + BackendChromePayload, + BackendChromeNavGroup, + BackendChromeNavItem, + BackendChromeSectionGroup, + BackendChromeSectionItem, +} from '@open-mercato/shared/modules/navigation/backendChrome' +import { + buildAdminNav, + buildSettingsSections, + computeSettingsPathPrefixes, + convertToSectionNavGroups, + type AdminNavItem, +} from '@open-mercato/ui/backend/utils/nav' +import { profilePathPrefixes, profileSections } from './profile-sections' +import { createRequestContainer } from '@open-mercato/shared/lib/di/container' +import { resolveFeatureCheckContext } from '@open-mercato/core/modules/directory/utils/organizationScope' +import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities' +import { Role } from '@open-mercato/core/modules/auth/data/entities' +import { + applySidebarPreference, + loadFirstRoleSidebarPreference, + loadSidebarPreference, +} from '@open-mercato/core/modules/auth/services/sidebarPreferencesService' +import type { SidebarPreferencesSettings } from '@open-mercato/shared/modules/navigation/sidebarPreferences' + +type TranslationFn = (key: string | undefined, fallback: string) => string + +type RouteModule = Pick + +type SerializableSectionItem = { + id: string + label: string + labelKey?: string + href: string + icon?: React.ReactNode + order?: number + children?: SerializableSectionItem[] +} + +type SerializableSectionGroup = { + id: string + label: string + labelKey?: string + order?: number + items: SerializableSectionItem[] +} + +type ResolvedNavItem = Omit & { + defaultTitle: string + children?: ResolvedNavItem[] +} + +type ResolveBackendChromePayloadArgs = { + auth: Exclude + locale: string + modules: RouteModule[] + translate: TranslationFn + selectedOrganizationId?: string | null + selectedTenantId?: string | null +} + +const settingsSectionOrder: Record = { + system: 1, + auth: 2, + 'customer-portal': 3, + 'data-designer': 4, + 'module-configs': 5, + directory: 6, + 'feature-toggles': 7, +} + +type NavGroupWithWeight = Omit & { + id: string + defaultName: string + items: ResolvedNavItem[] + weight: number +} + +let renderToStaticMarkupPromise: Promise | null = null + +async function serializeIconMarkup(icon: React.ReactNode | undefined): Promise { + if (!icon) return undefined + if (!renderToStaticMarkupPromise) { + renderToStaticMarkupPromise = import('react-dom/server') + } + const { renderToStaticMarkup } = await renderToStaticMarkupPromise + const markup = renderToStaticMarkup(<>{icon}) + return markup.trim().length > 0 ? markup : undefined +} + +async function serializeNavItem(item: AdminNavItem): Promise { + return { + id: item.href, + href: item.href, + title: item.title, + defaultTitle: item.defaultTitle, + enabled: item.enabled, + hidden: item.hidden, + pageContext: item.pageContext, + iconMarkup: await serializeIconMarkup(item.icon), + children: item.children ? await Promise.all(item.children.map((child) => serializeNavItem(child))) : undefined, + } +} + +function normalizeGroupWeights(groups: NavGroupWithWeight[]): NavGroupWithWeight[] { + const defaultGroupOrder = [ + 'customers.nav.group', + 'catalog.nav.group', + 'customers~sales.nav.group', + 'resources.nav.group', + 'staff.nav.group', + 'entities.nav.group', + 'directory.nav.group', + 'customers.storage.nav.group', + ] + const groupOrderIndex = new Map(defaultGroupOrder.map((id, index) => [id, index])) + groups.sort((a, b) => { + const aIndex = groupOrderIndex.get(a.id) + const bIndex = groupOrderIndex.get(b.id) + if (aIndex !== undefined || bIndex !== undefined) { + if (aIndex === undefined) return 1 + if (bIndex === undefined) return -1 + if (aIndex !== bIndex) return aIndex - bIndex + } + if (a.weight !== b.weight) return a.weight - b.weight + return a.name.localeCompare(b.name) + }) + const defaultGroupCount = defaultGroupOrder.length + groups.forEach((group, index) => { + const rank = groupOrderIndex.get(group.id) + const fallbackWeight = typeof group.weight === 'number' ? group.weight : 10_000 + group.weight = + (rank !== undefined ? rank : defaultGroupCount + index) * 1_000_000 + + Math.min(Math.max(fallbackWeight, 0), 999_999) + }) + return groups +} + +async function groupEntries(entries: AdminNavItem[]): Promise { + const groupMap = new Map() + for (const entry of entries) { + const weight = entry.priority ?? entry.order ?? 10_000 + const serializedItem = await serializeNavItem(entry) + const existing = groupMap.get(entry.groupId) + if (existing) { + existing.items.push(serializedItem) + if (weight < existing.weight) existing.weight = weight + continue + } + groupMap.set(entry.groupId, { + id: entry.groupId, + name: entry.group, + defaultName: entry.groupDefaultName, + items: [serializedItem], + weight, + }) + } + return normalizeGroupWeights(Array.from(groupMap.values())) +} + +function adoptSidebarDefaults(groups: NavGroupWithWeight[]): NavGroupWithWeight[] { + const adoptItems = (items: ResolvedNavItem[]): ResolvedNavItem[] => + items.map((item) => ({ + ...item, + defaultTitle: item.title, + children: item.children ? adoptItems(item.children) : undefined, + })) + + return groups.map((group) => ({ + ...group, + defaultName: group.name, + items: adoptItems(group.items), + })) +} + +async function serializeSectionItem(item: { + id: string + label: string + labelKey?: string + href: string + icon?: React.ReactNode + order?: number + children?: SerializableSectionItem[] +}): Promise { + return { + id: item.id, + label: item.label, + labelKey: item.labelKey, + href: item.href, + order: item.order, + iconMarkup: await serializeIconMarkup(item.icon), + children: item.children ? await Promise.all(item.children.map((child) => serializeSectionItem(child))) : undefined, + } +} + +async function serializeSectionGroups(groups: SerializableSectionGroup[]): Promise { + return Promise.all(groups.map(async (group) => ({ + id: group.id, + label: group.label, + labelKey: group.labelKey, + order: group.order, + items: await Promise.all(group.items.map((item) => serializeSectionItem(item))), + }))) +} + +async function loadScopedContainer(): Promise { + return createRequestContainer() +} + +export async function resolveBackendChromePayload({ + auth, + locale, + modules, + translate, + selectedOrganizationId, + selectedTenantId, +}: ResolveBackendChromePayloadArgs): Promise { + const container = await loadScopedContainer() + const em = container.resolve('em') as EntityManager + const rbac = container.resolve('rbacService') as { + loadAcl: (userId: string, scope: { tenantId: string | null; organizationId: string | null }) => Promise<{ + isSuperAdmin: boolean + features: string[] + }> + } + + let scopedOrganizationId: string | null = auth.orgId ?? null + let scopedTenantId: string | null = auth.tenantId ?? null + let allowNavigation = true + + try { + const { organizationId, scope, allowedOrganizationIds } = await resolveFeatureCheckContext({ + container, + auth, + selectedId: selectedOrganizationId, + tenantId: selectedTenantId, + }) + scopedOrganizationId = organizationId + scopedTenantId = scope.tenantId ?? auth.tenantId ?? null + if (Array.isArray(allowedOrganizationIds) && allowedOrganizationIds.length === 0) { + allowNavigation = false + } + } catch { + scopedOrganizationId = auth.orgId ?? null + scopedTenantId = auth.tenantId ?? null + } + + const acl = allowNavigation + ? await rbac.loadAcl(auth.sub, { + tenantId: scopedTenantId, + organizationId: scopedOrganizationId, + }) + : { isSuperAdmin: false, features: [] } + + const grantedFeatures = acl.isSuperAdmin ? ['*'] : acl.features + const featureChecker = async (): Promise => grantedFeatures + + let userEntities: Array<{ entityId: string; label: string; href: string }> = [] + if (allowNavigation) { + try { + const where: FilterQuery = { + isActive: true, + showInSidebar: true, + } + where.$and = [ + { $or: [{ organizationId: scopedOrganizationId ?? undefined }, { organizationId: null }] }, + { $or: [{ tenantId: scopedTenantId ?? undefined }, { tenantId: null }] }, + ] + const entities = await em.find(CustomEntity, where, { orderBy: { label: 'asc' } }) + userEntities = entities.map((entity) => ({ + entityId: entity.entityId, + label: entity.label, + href: `/backend/entities/user/${encodeURIComponent(entity.entityId)}/records`, + })) + } catch { + userEntities = [] + } + } + + const ctxAuth = { + roles: auth.roles || [], + sub: auth.sub, + tenantId: scopedTenantId, + orgId: scopedOrganizationId, + } + const entries = allowNavigation + ? await buildAdminNav( + modules, + { auth: ctxAuth }, + userEntities, + translate, + { checkFeatures: featureChecker }, + ) + : [] + + let rolePreference: SidebarPreferencesSettings | null = null + let userPreference: SidebarPreferencesSettings | null = null + + if (Array.isArray(auth.roles) && auth.roles.length > 0) { + const roleScope: FilterQuery = scopedTenantId + ? { $or: [{ tenantId: scopedTenantId }, { tenantId: null }] } + : { tenantId: null } + const roleRecords = await em.find(Role, { + name: { $in: auth.roles }, + ...roleScope, + }) + const roleIds = Array.isArray(roleRecords) ? roleRecords.map((role) => role.id) : [] + if (roleIds.length > 0) { + rolePreference = await loadFirstRoleSidebarPreference(em, { + roleIds, + tenantId: scopedTenantId, + locale, + }) + } + } + + const effectiveUserId = auth.isApiKey ? auth.userId : auth.sub + if (effectiveUserId) { + userPreference = await loadSidebarPreference(em, { + userId: effectiveUserId, + tenantId: scopedTenantId, + organizationId: scopedOrganizationId, + locale, + }) + } + + const baseGroups = await groupEntries(entries) + const groupsWithRole = rolePreference + ? applySidebarPreference(baseGroups, rolePreference) + : baseGroups + const baseForUser = adoptSidebarDefaults(groupsWithRole) + const appliedGroups = userPreference + ? applySidebarPreference(baseForUser, userPreference) + : baseForUser + + const settingsSections = await serializeSectionGroups( + convertToSectionNavGroups( + buildSettingsSections(entries, settingsSectionOrder), + translate, + ), + ) + + return { + groups: appliedGroups.map(({ weight: _weight, ...group }) => group), + settingsSections, + settingsPathPrefixes: computeSettingsPathPrefixes(buildSettingsSections(entries, settingsSectionOrder)), + profileSections: await serializeSectionGroups(profileSections), + profilePathPrefixes, + grantedFeatures, + roles: Array.isArray(auth.roles) ? auth.roles : [], + } +} diff --git a/packages/create-app/template/src/app/(backend)/backend/layout.tsx b/packages/create-app/template/src/app/(backend)/backend/layout.tsx index a75b9addda7..31995d6eebc 100644 --- a/packages/create-app/template/src/app/(backend)/backend/layout.tsx +++ b/packages/create-app/template/src/app/(backend)/backend/layout.tsx @@ -1,89 +1,46 @@ import { cookies, headers } from 'next/headers' -import type { ReactNode } from 'react' import { modules } from '@/.mercato/generated/modules.generated' import { findBackendMatch } from '@open-mercato/shared/modules/registry' import { getAuthFromCookies } from '@open-mercato/shared/lib/auth/server' import { AppShell } from '@open-mercato/ui/backend/AppShell' -import { - buildAdminNav, - buildSettingsSections, - computeSettingsPathPrefixes, - convertToSectionNavGroups, -} from '@open-mercato/ui/backend/utils/nav' -import type { AdminNavItem } from '@open-mercato/ui/backend/utils/nav' -import { ProfileDropdown } from '@open-mercato/ui/backend/ProfileDropdown' -import { IntegrationsButton } from '@open-mercato/ui/backend/IntegrationsButton' -import { SettingsButton } from '@open-mercato/ui/backend/SettingsButton' -import { MessagesIcon } from '@open-mercato/ui/backend/messages' -import { GlobalSearchDialog } from '@open-mercato/search/modules/search/frontend' -import OrganizationSwitcher from '@/components/OrganizationSwitcher' -import { NotificationBellWrapper } from '@/components/NotificationBellWrapper' import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server' import { I18nProvider } from '@open-mercato/shared/lib/i18n/context' -import { createRequestContainer } from '@open-mercato/shared/lib/di/container' -import { - applySidebarPreference, - loadFirstRoleSidebarPreference, - loadSidebarPreference, -} from '@open-mercato/core/modules/auth/services/sidebarPreferencesService' -import type { SidebarPreferencesSettings } from '@open-mercato/shared/modules/navigation/sidebarPreferences' -import { Role } from '@open-mercato/core/modules/auth/data/entities' -import type { EntityManager } from '@mikro-orm/postgresql' -import type { FilterQuery } from '@mikro-orm/core' -import type { AwilixContainer } from 'awilix' -import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService' -import { resolveFeatureCheckContext } from '@open-mercato/core/modules/directory/utils/organizationScope' -import { profileSections, profilePathPrefixes } from '@open-mercato/core/modules/auth/lib/profile-sections' +import { profilePathPrefixes } from '@open-mercato/core/modules/auth/lib/profile-sections' import { APP_VERSION } from '@open-mercato/shared/lib/version' import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean' import { PageInjectionBoundary } from '@open-mercato/ui/backend/injection/PageInjectionBoundary' import { DemoFeedbackWidget } from '@/components/DemoFeedbackWidget' -import { AiAssistantIntegration, AiChatHeaderButton } from '@open-mercato/ai-assistant/frontend' -import { CustomEntity } from '@open-mercato/core/modules/entities/data/entities' - -type NavItem = { - href: string - title: string - defaultTitle: string - enabled: boolean - hidden?: boolean - icon?: ReactNode - pageContext?: 'main' | 'admin' | 'settings' | 'profile' - children?: NavItem[] -} - -type NavGroup = { - id: string - name: string - defaultName: string - items: NavItem[] - weight: number +import OrganizationSwitcher from '@/components/OrganizationSwitcher' +import { BackendHeaderChrome } from '@/components/BackendHeaderChrome' + +function collectStaticSettingsPathPrefixes(): string[] { + const prefixes = new Set() + for (const module of modules) { + for (const route of module.backendRoutes ?? []) { + if (route.pageContext !== 'settings') continue + const href = route.pattern ?? route.path ?? '' + if (!href || href.includes('[')) continue + const parts = href.split('/') + const lastSegment = parts[parts.length - 1] + if (parts.length > 3 && lastSegment !== 'settings') { + prefixes.add(parts.slice(0, -1).join('/')) + } + prefixes.add(href) + } + } + return Array.from(prefixes) } -export default async function BackendLayout({ children, params }: { children: React.ReactNode; params: Promise<{ slug?: string[] }> }) { +export default async function BackendLayout({ + children, + params, +}: { + children: React.ReactNode + params: Promise<{ slug?: string[] }> +}) { const auth = await getAuthFromCookies() const cookieStore = await cookies() const headerStore = await headers() - const rawSelectedOrg = cookieStore.get('om_selected_org')?.value - const rawSelectedTenant = cookieStore.get('om_selected_tenant')?.value - const selectedOrgForScope = rawSelectedOrg === undefined - ? undefined - : rawSelectedOrg && rawSelectedOrg.trim().length > 0 - ? rawSelectedOrg - : null - const selectedTenantForScope = rawSelectedTenant === undefined - ? undefined - : rawSelectedTenant && rawSelectedTenant.trim().length > 0 - ? rawSelectedTenant - : null - - let requestContainer: AwilixContainer | null = null - const ensureContainer = async (): Promise => { - if (!requestContainer) { - requestContainer = await createRequestContainer() - } - return requestContainer - } let path = headerStore.get('x-next-url') ?? '' if (path.includes('?')) path = path.split('?')[0] @@ -95,19 +52,9 @@ export default async function BackendLayout({ children, params }: { children: Re } if (!path) { const slug = resolvedParams.slug ?? [] - path = '/backend' + (Array.isArray(slug) && slug.length ? '/' + slug.join('/') : '') + path = '/backend' + (Array.isArray(slug) && slug.length > 0 ? `/${slug.join('/')}` : '') } - const ctxAuth = auth - ? { - roles: auth.roles || [], - sub: auth.sub, - tenantId: auth.tenantId, - orgId: auth.orgId, - } - : undefined - const ctx = { auth: ctxAuth, path } - const { translate, locale, dict } = await resolveTranslations() const embeddingConfigured = Boolean( process.env.OPENAI_API_KEY || @@ -115,271 +62,32 @@ export default async function BackendLayout({ children, params }: { children: Re process.env.MISTRAL_API_KEY || process.env.COHERE_API_KEY || process.env.AWS_ACCESS_KEY_ID || - process.env.OLLAMA_BASE_URL + process.env.OLLAMA_BASE_URL, ) - const missingConfigMessage = translate('search.messages.missingConfig', 'Search requires configuring an embedding provider for semantic search.') - - const featureChecker = auth - ? async (features: string[]): Promise> => { - if (!features?.length) return new Set() - try { - const container = await ensureContainer() - const rbac = container.resolve('rbacService') - const { organizationId, scope, allowedOrganizationIds } = await resolveFeatureCheckContext({ - container, - auth, - selectedId: selectedOrgForScope, - tenantId: selectedTenantForScope, - }) - if (Array.isArray(allowedOrganizationIds) && allowedOrganizationIds.length === 0) { - return new Set() - } - const tenantForCheck = scope.tenantId ?? auth.tenantId ?? null - const orgForCheck = organizationId ?? null - const context = { tenantId: tenantForCheck, organizationId: orgForCheck } - const hasAll = await rbac.userHasAllFeatures(auth.sub, features, context) - if (hasAll) return new Set(features) - const granted: string[] = [] - for (const feature of features) { - const hasFeature = await rbac.userHasAllFeatures(auth.sub, [feature], context) - if (hasFeature) granted.push(feature) - } - return new Set(granted) - } catch { - return new Set() - } - } - : undefined - - let userEntities: Array<{ entityId: string; label: string; href: string }> | undefined - if (auth) { - try { - const container = await ensureContainer() - const em = container.resolve('em') as EntityManager - const where: FilterQuery = { - isActive: true, - showInSidebar: true, - } - where.$and = [ - { $or: [{ organizationId: auth.orgId ?? undefined }, { organizationId: null }] }, - { $or: [{ tenantId: auth.tenantId ?? undefined }, { tenantId: null }] }, - ] - const entities = await em.find(CustomEntity, where, { orderBy: { label: 'asc' } }) - userEntities = entities.map((entity) => ({ - entityId: entity.entityId, - label: entity.label, - href: `/backend/entities/user/${encodeURIComponent(entity.entityId)}/records`, - })) - } catch { - userEntities = undefined - } - } - - const entries = await buildAdminNav( - modules, - ctx, - userEntities, - (key, fallback) => (key ? translate(key, fallback) : fallback), - featureChecker ? { checkFeatures: featureChecker } : undefined, + const missingConfigMessage = translate( + 'search.messages.missingConfig', + 'Search requires configuring an embedding provider for semantic search.', ) - const showIntegrationsButton = entries.some( - (entry) => entry.href === '/backend/integrations' && entry.enabled !== false && entry.hidden !== true, - ) - - const groupMap = new Map() - for (const entry of entries) { - const weight = entry.priority ?? entry.order ?? 10_000 - if (!groupMap.has(entry.groupId)) { - groupMap.set(entry.groupId, { - id: entry.groupId, - key: entry.groupKey, - name: entry.group, - defaultName: entry.groupDefaultName, - items: [entry], - weight, - }) - } else { - const group = groupMap.get(entry.groupId)! - group.items.push(entry) - if (weight < group.weight) group.weight = weight - if (!group.key && entry.groupKey) group.key = entry.groupKey - } - } - - const mapItem = (item: AdminNavItem): NavItem => ({ - href: item.href, - title: item.title, - defaultTitle: item.defaultTitle, - enabled: item.enabled, - hidden: item.hidden, - icon: item.icon, - pageContext: item.pageContext, - children: item.children?.map(mapItem), - }) - - const baseGroups: NavGroup[] = Array.from(groupMap.values()).map((group) => ({ - id: group.id, - name: group.name, - defaultName: group.defaultName, - weight: group.weight, - items: group.items.map(mapItem), - })) - const defaultGroupOrder = [ - 'customers.nav.group', - 'catalog.nav.group', - 'customers~sales.nav.group', - 'resources.nav.group', - 'staff.nav.group', - 'entities.nav.group', - 'directory.nav.group', - 'customers.storage.nav.group', - ] - const groupOrderIndex = new Map(defaultGroupOrder.map((id, index) => [id, index])) - baseGroups.sort((a, b) => { - const aIndex = groupOrderIndex.get(a.id) - const bIndex = groupOrderIndex.get(b.id) - if (aIndex !== undefined || bIndex !== undefined) { - if (aIndex === undefined) return 1 - if (bIndex === undefined) return -1 - if (aIndex !== bIndex) return aIndex - bIndex - } - if (a.weight !== b.weight) return a.weight - b.weight - return a.name.localeCompare(b.name) - }) - const defaultGroupCount = defaultGroupOrder.length - baseGroups.forEach((group, index) => { - const rank = groupOrderIndex.get(group.id) - const fallbackWeight = typeof group.weight === 'number' ? group.weight : 10_000 - const normalized = - (rank !== undefined ? rank : defaultGroupCount + index) * 1_000_000 + - Math.min(Math.max(fallbackWeight, 0), 999_999) - group.weight = normalized - }) - - let rolePreference: SidebarPreferencesSettings | null = null - let sidebarPreference: SidebarPreferencesSettings | null = null - if (auth) { - try { - const container = await ensureContainer() - const em = container.resolve('em') as EntityManager - if (Array.isArray(auth.roles) && auth.roles.length) { - const roleScope: FilterQuery = auth.tenantId - ? { $or: [{ tenantId: auth.tenantId }, { tenantId: null }] } - : { tenantId: null } - const roleRecords = await em.find(Role, { - name: { $in: auth.roles }, - ...roleScope, - }) - const roleIds = roleRecords.map((role) => role.id) - if (roleIds.length) { - rolePreference = await loadFirstRoleSidebarPreference(em, { - roleIds, - tenantId: auth.tenantId ?? null, - locale, - }) - } - } - // For API key auth, use userId (the actual user) if available - const effectiveUserId: string | undefined = auth.isApiKey ? auth.userId : auth.sub - if (effectiveUserId) { - sidebarPreference = await loadSidebarPreference(em, { - userId: effectiveUserId, - tenantId: auth.tenantId ?? null, - organizationId: auth.orgId ?? null, - locale, - }) - } - } catch { - // ignore preference loading failures; render with default navigation - } - } - const groupsWithRole = rolePreference ? applySidebarPreference(baseGroups, rolePreference) : baseGroups - const baseForUser = adoptSidebarDefaults(groupsWithRole) - const appliedGroups = sidebarPreference ? applySidebarPreference(baseForUser, sidebarPreference) : baseForUser - - const materializeItem = (item: NavItem): NavItem => ({ - href: item.href, - title: item.title, - defaultTitle: item.defaultTitle, - enabled: item.enabled, - hidden: item.hidden, - icon: item.icon, - pageContext: item.pageContext, - children: item.children?.map(materializeItem), - }) - - const groups: NavGroup[] = appliedGroups.map((group) => ({ - id: group.id, - name: group.name, - defaultName: group.defaultName, - items: group.items.map(materializeItem), - weight: group.weight, - })) - - type NavEntry = NavItem & { group: string } - const allEntries: NavEntry[] = groups.flatMap((group) => - group.items.map((item) => ({ ...item, group: group.name })), - ) - const current = allEntries.find((item) => path.startsWith(item.href)) - const currentTitle = current?.title || '' const match = findBackendMatch(modules, path) + const currentTitle = match?.route.titleKey + ? translate(match.route.titleKey, match.route.title) + : (match?.route.title ?? '') const rawBreadcrumb = match?.route.breadcrumb - const breadcrumb = rawBreadcrumb?.map((item) => { - const fallback = item.label - const label = item.labelKey ? translate(item.labelKey, fallback || item.labelKey) : fallback - return { ...item, label } - }) - - const settingsSectionOrder: Record = { - 'system': 1, - 'auth': 2, - 'customer-portal': 3, - 'data-designer': 4, - 'module-configs': 5, - 'directory': 6, - 'feature-toggles': 7, - } - const generatedSettingsSections = buildSettingsSections(entries, settingsSectionOrder) - const settingsPathPrefixes = computeSettingsPathPrefixes(generatedSettingsSections) - const filteredSettingsSections = convertToSectionNavGroups( - generatedSettingsSections, - (key, fallback) => (key ? translate(key, fallback) : fallback) - ) + const breadcrumb = rawBreadcrumb?.map((item) => ({ + ...item, + label: item.labelKey ? translate(item.labelKey, item.label || item.labelKey) : item.label, + })) const collapsedCookie = cookieStore.get('om_sidebar_collapsed')?.value const initialCollapsed = collapsedCookie === '1' - - const rightHeaderContent = ( - <> - - -
    - -
    - {showIntegrationsButton ? : null} - - - - - - ) - - const mobileSidebarContent = - const demoModeEnabled = parseBooleanWithDefault(process.env.DEMO_MODE, true) const deployEnv = process.env.DEPLOY_ENV const baseProductName = translate('appShell.productName', 'Open Mercato') const productName = deployEnv && deployEnv !== 'local' ? `${baseProductName} (${deployEnv.charAt(0).toUpperCase() + deployEnv.slice(1)})` : baseProductName + const injectionContext = { path, userId: auth?.sub ?? null, @@ -388,54 +96,41 @@ export default async function BackendLayout({ children, params }: { children: Re } return ( - <> - - - + - - {children} - - {demoModeEnabled ? : null} - - - - + embeddingConfigured={embeddingConfigured} + missingConfigMessage={missingConfigMessage} + tenantId={auth?.tenantId ?? null} + organizationId={auth?.orgId ?? null} + /> + )} + mobileSidebarSlot={} + adminNavApi="/api/auth/admin/nav" + version={APP_VERSION} + settingsPathPrefixes={collectStaticSettingsPathPrefixes()} + settingsSections={[]} + settingsSectionTitle={translate('backend.nav.settings', 'Settings')} + profileSections={[]} + profileSectionTitle={translate('profile.page.title', 'Profile')} + profilePathPrefixes={profilePathPrefixes} + > + + {children} + + {demoModeEnabled ? : null} + + ) } -export const dynamic = 'force-dynamic' - -function adoptSidebarDefaults(groups: NavGroup[]): NavGroup[] { - const adoptItems = (items: NavItem[]): NavItem[] => - items.map((item) => ({ - ...item, - defaultTitle: item.title, - children: item.children ? adoptItems(item.children) : undefined, - })) - return groups.map((group) => ({ - ...group, - defaultName: group.name, - items: adoptItems(group.items), - })) -} +export const dynamic = 'force-dynamic' diff --git a/packages/create-app/template/src/components/AiAssistantShellIntegration.tsx b/packages/create-app/template/src/components/AiAssistantShellIntegration.tsx new file mode 100644 index 00000000000..ae9cfe7f335 --- /dev/null +++ b/packages/create-app/template/src/components/AiAssistantShellIntegration.tsx @@ -0,0 +1,42 @@ +'use client' + +import * as React from 'react' + +type AiAssistantIntegrationComponent = React.ComponentType<{ + tenantId: string | null + organizationId: string | null + children: React.ReactNode +}> + +type AiAssistantShellIntegrationProps = { + tenantId: string | null + organizationId: string | null + children: React.ReactNode +} + +export function AiAssistantShellIntegration({ + tenantId, + organizationId, + children, +}: AiAssistantShellIntegrationProps) { + const [IntegrationComponent, setIntegrationComponent] = React.useState(null) + + React.useEffect(() => { + let cancelled = false + void import('@open-mercato/ai-assistant/frontend').then((module) => { + if (cancelled) return + setIntegrationComponent(() => module.AiAssistantIntegration) + }) + return () => { + cancelled = true + } + }, []) + + if (!IntegrationComponent) return <>{children} + + return ( + + {children} + + ) +} diff --git a/packages/create-app/template/src/components/BackendHeaderChrome.tsx b/packages/create-app/template/src/components/BackendHeaderChrome.tsx new file mode 100644 index 00000000000..cab67730104 --- /dev/null +++ b/packages/create-app/template/src/components/BackendHeaderChrome.tsx @@ -0,0 +1,98 @@ +'use client' + +import dynamic from 'next/dynamic' +import * as React from 'react' +import { hasFeature } from '@open-mercato/shared/security/features' +import { IntegrationsButton } from '@open-mercato/ui/backend/IntegrationsButton' +import { ProfileDropdown } from '@open-mercato/ui/backend/ProfileDropdown' +import { SettingsButton } from '@open-mercato/ui/backend/SettingsButton' +import { useBackendChrome } from '@open-mercato/ui/backend/BackendChromeProvider' +import { AiAssistantShellIntegration } from '@/components/AiAssistantShellIntegration' + +const LazyAiChatHeaderButton = dynamic( + () => import('@open-mercato/ai-assistant/frontend').then((module) => module.AiChatHeaderButton), + { ssr: false, loading: () => null }, +) +const LazyGlobalSearchDialog = dynamic( + () => import('@open-mercato/search/modules/search/frontend').then((module) => module.GlobalSearchDialog), + { ssr: false, loading: () => null }, +) +const LazyOrganizationSwitcher = dynamic(() => import('@/components/OrganizationSwitcher'), { + ssr: false, + loading: () => null, +}) +const LazyNotificationBellWrapper = dynamic( + () => import('@/components/NotificationBellWrapper').then((module) => module.NotificationBellWrapper), + { ssr: false, loading: () => null }, +) +const LazyMessagesIcon = dynamic( + () => import('@open-mercato/ui/backend/messages').then((module) => module.MessagesIcon), + { ssr: false, loading: () => null }, +) + +type BackendHeaderChromeProps = { + email?: string + embeddingConfigured: boolean + missingConfigMessage: string + tenantId: string | null + organizationId: string | null +} + +function hasVisibleRoute(groups: Array<{ items?: Array<{ href: string; hidden?: boolean; enabled?: boolean; children?: unknown[] }> }> | undefined, href: string): boolean { + if (!groups) return false + for (const group of groups) { + for (const item of group.items ?? []) { + if (item.href === href && item.hidden !== true && item.enabled !== false) return true + const children = Array.isArray(item.children) ? item.children as Array<{ href: string; hidden?: boolean; enabled?: boolean; children?: unknown[] }> : [] + if (hasVisibleRoute([{ items: children }], href)) return true + } + } + return false +} + +export function BackendHeaderChrome({ + email, + embeddingConfigured, + missingConfigMessage, + tenantId, + organizationId, +}: BackendHeaderChromeProps) { + const { payload, isReady } = useBackendChrome() + const grantedFeatures = payload?.grantedFeatures ?? [] + const showIntegrationsButton = React.useMemo( + () => hasVisibleRoute(payload?.groups, '/backend/integrations'), + [payload?.groups], + ) + const showAiAssistant = React.useMemo( + () => hasFeature(grantedFeatures, 'ai_assistant.view'), + [grantedFeatures], + ) + const showMessages = React.useMemo( + () => hasFeature(grantedFeatures, 'messages.view'), + [grantedFeatures], + ) + + return ( + <> + {isReady && showAiAssistant ? ( + + + + ) : null} + {isReady ? ( + + ) : null} +
    + {isReady ? : null} +
    + {showIntegrationsButton ? : null} + + + {isReady ? : null} + {isReady && showMessages ? : null} + + ) +} diff --git a/packages/shared/src/modules/navigation/backendChrome.ts b/packages/shared/src/modules/navigation/backendChrome.ts new file mode 100644 index 00000000000..8fc6c81d790 --- /dev/null +++ b/packages/shared/src/modules/navigation/backendChrome.ts @@ -0,0 +1,48 @@ +export type BackendChromePageContext = 'main' | 'admin' | 'settings' | 'profile' + +export type BackendChromeNavItem = { + id?: string + href: string + title: string + defaultTitle?: string + enabled?: boolean + hidden?: boolean + pageContext?: BackendChromePageContext + iconMarkup?: string + children?: BackendChromeNavItem[] +} + +export type BackendChromeNavGroup = { + id?: string + name: string + defaultName?: string + items: BackendChromeNavItem[] +} + +export type BackendChromeSectionItem = { + id: string + label: string + labelKey?: string + href: string + order?: number + iconMarkup?: string + children?: BackendChromeSectionItem[] +} + +export type BackendChromeSectionGroup = { + id: string + label: string + labelKey?: string + items: BackendChromeSectionItem[] + order?: number +} + +export type BackendChromePayload = { + groups: BackendChromeNavGroup[] + settingsSections: BackendChromeSectionGroup[] + settingsPathPrefixes: string[] + profileSections: BackendChromeSectionGroup[] + profilePathPrefixes: string[] + grantedFeatures: string[] + roles: string[] +} diff --git a/packages/ui/src/backend/AppShell.tsx b/packages/ui/src/backend/AppShell.tsx index a4562847df5..fbea713fa1e 100644 --- a/packages/ui/src/backend/AppShell.tsx +++ b/packages/ui/src/backend/AppShell.tsx @@ -27,6 +27,7 @@ import { resolveInjectedIcon } from './injection/resolveInjectedIcon' import { useEventBridge } from './injection/eventBridge' import { StatusBadgeInjectionSpot } from './injection/StatusBadgeInjectionSpot' import { UmesDevToolsPanel } from './devtools' +import { BackendChromeProvider, useBackendChrome } from './BackendChromeProvider' import { BACKEND_LAYOUT_FOOTER_INJECTION_SPOT_ID, BACKEND_LAYOUT_TOP_INJECTION_SPOT_ID, @@ -53,6 +54,7 @@ export type AppShellProps = { title: string defaultTitle?: string icon?: React.ReactNode + iconMarkup?: string enabled?: boolean hidden?: boolean pageContext?: 'main' | 'admin' | 'settings' | 'profile' @@ -62,6 +64,7 @@ export type AppShellProps = { title: string defaultTitle?: string icon?: React.ReactNode + iconMarkup?: string enabled?: boolean hidden?: boolean pageContext?: 'main' | 'admin' | 'settings' | 'profile' @@ -295,6 +298,16 @@ function resolveItemKey(item: { id?: string; href: string }): string { return item.href } +function SerializedIcon({ markup }: { markup: string }) { + return