From feb23a073b6039fcdf9b7bf9b8371a6331d35209 Mon Sep 17 00:00:00 2001 From: "openmercato[bot]" <264865371+openmercato[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:44:42 +0000 Subject: [PATCH 1/2] docs: specify competition-scoped Mercato Sandboxes addons --- ...15-competition-addons-mercato-sandboxes.md | 216 ++++++++++++++++++ .../briefs/2026-09-15-competition-addons.md | 81 +++++++ 2 files changed, 297 insertions(+) create mode 100644 .ai/specs/2026-09-15-competition-addons-mercato-sandboxes.md create mode 100644 .ai/specs/briefs/2026-09-15-competition-addons.md diff --git a/.ai/specs/2026-09-15-competition-addons-mercato-sandboxes.md b/.ai/specs/2026-09-15-competition-addons-mercato-sandboxes.md new file mode 100644 index 0000000..fe50c7d --- /dev/null +++ b/.ai/specs/2026-09-15-competition-addons-mercato-sandboxes.md @@ -0,0 +1,216 @@ +# Dodatki: Mercato Sandboxes per Competition + +**Date**: 2026-09-15 +**Status**: Ready for implementation; implementation and runtime verification not started +**Source brief**: [Approved brief](briefs/2026-09-15-competition-addons.md) + +## 📝 TLDR + +Backoffice operators select existing participants in one Competition, assign the Mercato Sandboxes addon and persist the results of a bulk simulation. Assignment belongs to a customer user within that Competition, addon, tenant and organization. This release sends no messages, calls no provisioning API and grants no access; simulated success must never count as real provisioning. + +## 📝 Problem Statement + +Operators need a dedicated **Dodatki → Mercato Sandboxes** screen to select recipients by Competition and participation role, confirm a precise recipient count, and inspect individual outcomes. A global user flag would conflate different Competitions, while an email-based identity or participation-row identity would create incorrect duplicates after account changes or participation recreation. A transient UI simulation would lose audit history and could not support reliable retries. + +The approved brief resolves the critical product questions. Assignment, selection, simulation and history form one operator workflow; there is no separate catalog, notification or real-provisioning capability bundled here. No open product decision blocks this design. Numerical limits and transaction choices below are explicit implementation defaults, adjustable after load validation without changing the product model. + +## 📝 Proposed Solution + +Create a dedicated app module `addons` with one fixed addon key, `mercato_sandboxes`. Three persisted concepts represent assignment, a confirmation/delivery batch, and each selected recipient's attempt. An assignment is created on confirmed execution, independently of whether simulation succeeds. Preparing or cancelling a selection does not assign the addon. + +A server-created draft batch freezes recipient UUIDs before the confirmation dialog opens. Confirmation executes a bounded, synchronous, database-only simulation transaction. This is sufficient for mock delivery and avoids introducing a worker lifecycle solely for simulated external work. Previous terminal attempts remain immutable; retry creates a new batch. No standalone assignment-management or revocation UI is introduced. + +Alternatives considered: + +- A bulk action on the existing participant page misses the requested dedicated navigation and history area. +- A configurable addon catalog or provider framework adds independently deployable scope; defer it. +- A queue for mock execution adds leases and crash recovery without an external side effect to manage. Revisit for actual provisioning in a separate spec. + +### Research and adopted lessons + +Sources fetched on 2026-09-15: + +| Open-source reference | Relevant behavior | Decision here | +| --- | --- | --- | +| [Django admin actions](https://docs.djangoproject.com/en/5.2/ref/contrib/admin/actions/) | Bulk actions operate on selected querysets and support an intermediate confirmation page; bulk operations may bypass per-object behavior. | Explicit confirmation binds persisted recipient IDs; bulk execution must still invoke mutation guards for affected records. No generic admin-action engine. | +| [BullMQ job IDs](https://docs.bullmq.io/guide/jobs/job-ids) | Custom IDs suppress duplicates within a queue, but removed jobs cease to participate in deduplication. | Durable mode-specific success evidence belongs in application tables. Queue identity alone would not provide future provisioning idempotency. No BullMQ dependency for this release. | + +### Repository evidence and reuse boundaries + +- `src/modules/competitions/data/entities.ts`: `CompetitionParticipation` references `customerUserId`, has `role` and `deletedAt`, and is unique by Competition and customer user. It has **no `isActive` field**. `Competition` does have `isActive` and `deletedAt`. +- `src/modules/competitions/api/participations/route.ts`: the custom GET filters by tenant, but does not constrain organization. Do not use this endpoint as the new eligibility or recipient-list authority. Correcting existing screens is separate work. +- `src/modules/competitions/api/admin/customer-users/route.ts`: demonstrates `findWithDecryption` for names/email, but is a generic account lookup with its own ACL and scope behavior, not a Competition eligibility API. +- Installed `CustomerUser` in `@open-mercato/core` has `organizationId`, `tenantId`, `isActive` and `deletedAt`. Resolve current display fields through encrypted-query helpers; never copy ciphertext or names into addon history. +- `src/modules/competitions/backend/competitions/participants/page.tsx`: reuse DataTable/FilterBar, organization scope version and Competition scope patterns, not its split client-side account lookup as an authorization boundary. +- `src/modules/competitions/setup.ts`: admin and superadmin are the existing backoffice operator defaults. No root `BACKWARD_COMPATIBILITY.md` was present during design. + +## 📝 Architecture + +`addons` owns `data/entities.ts`, `data/validators.ts`, `di.ts`, an eligibility reader, batch service, mock simulator, API routes, ACL/setup, backend pages and PL/EN translations. Register `{ id: 'addons', from: '@app' }`. It depends on competitions and customer_accounts being enabled; if either is absent, hide this screen and reject operations with `503 dependency_unavailable` before writing. + +Use a DI service boundary, `addonRecipientReader`, for read-only access to Competition, participation and customer-account data. Return scoped UUIDs/roles and, only for displayed pages, decrypted display fields. Cross-module references are scalar UUIDs, never ORM relationships; no commands or direct writes to peer modules. No modification of core packages or generated files. + +Every operation resolves one authenticated tenant and one explicitly selected, authorized organization. Reject missing/all-organizations selection with `400 organization_required`; a superadmin still selects one organization. Every query, join, lock and ID lookup includes both scopes. Client-supplied tenant/organization fields are rejected. + +Eligibility means: Competition exists in that exact scope, is active and not deleted; participation in that Competition and scope is not deleted; customer account exists in the same scope, is active and not deleted. Selected participation roles use OR semantics over `participant | mentor | judge`; empty roles means all three. Competition stage/dates, checked-in state and email verification do not add unrequested eligibility rules. Customer roles are not participation roles. + +At preview, resolve the set under one consistent database snapshot, deduplicate by customerUserId and persist it atomically. At execution, revalidate only these IDs against the frozen role filter. A changed role still matching the filter remains eligible; a user who no longer matches is skipped. New participants are never added. Lock eligible Competition, participation and customer records for the execution transaction so concurrent deactivation/soft deletion is ordered before or after execution; existence and scope must be checked after obtaining locks. + +### Transaction and concurrency contract + +1. Preparation is idempotent by scoped `requestId`: store the normalized request, create a `draft` batch and its recipient rows in one transaction. Reusing the key with identical input returns that batch; different input returns `409 request_conflict`. Freeze draft membership permanently. +2. Confirmation authenticates and checks current ACL, creator ownership, scope, draft expiry and required guards, then takes the batch row lock. Repeated confirmation of a terminal batch returns the stored result; cancelled/expired drafts return `409 batch_not_confirmable`. +3. Lock peer records and recheck eligibility before assignment creation. Upsert assignments for currently eligible recipients using the permanent scoped natural key, ordered by customer UUID; lock assignment rows in the same order. Lock ordering must be fixed across all calls: batch, Competition, participants ordered by UUID, customer accounts ordered by UUID, assignments ordered by customer UUID. This avoids overlapping-batch deadlocks. +4. Under assignment locks, check successful attempts for the **same mode** before simulating. Same-mode success becomes `skipped/already_succeeded`. An existing pending attempt becomes `skipped/already_pending`; never bypass it merely because it is old. New executable rows transition `selected → pending → succeeded | failed` inside this transaction. +5. Persist all outcomes and aggregate counters, then commit the terminal batch. A request retry after a lost response reads the existing result. A process crash or database error before commit rolls back all execution writes to the original draft; there is no committed mock `pending` state to orphan. +6. Lock contention has a bounded five-second lock timeout. Return `409 operation_in_progress` without partially committing; allow retry of the same confirmation. Do not convert infrastructure errors into simulated failures. + +Use `withAtomicFlush(em, phases, { transaction: true, label: 'addons.batch.confirm' })` for multi-phase ORM mutations, with an explicit flush per phase. Never query between scalar mutation and flush. Guard callbacks and cache invalidation occur after commit, are caught/logged with safe codes, and cannot turn a committed success into an apparent failed send. DB uniqueness is a second line of defense, not a substitute for transaction locking. With mock synchronous execution, waiting overlapping requests observe the first committed success, or receive lock timeout; they cannot both simulate successfully. + +## 📝 Data Model + +All three tables have UUID v4 `id`, UUID `tenantId`/`organizationId`, `createdAt`/`updatedAt` (`timestamptz`), `isActive` (default true) and nullable `deletedAt`. TypeScript properties are camelCase with explicit snake_case DB column mapping. Index scope columns and every reference ID. Short string fields have explicit lengths; state/key/code strings fit `varchar(64)`, request IDs are UUIDs. Use Zod enums and checks for valid state combinations. + +| Entity / table | Additional fields | Invariants | +| --- | --- | --- | +| `AddonAssignment` / `addons_assignments` | `competitionId`, `customerUserId` UUID; `addonKey`; `assignedAt` timestamp; `assignedBy` backoffice UUID | Permanent unique `(tenant_id, organization_id, competition_id, customer_user_id, addon_key)`, including inactive/soft-deleted rows. Assignment says who owns the bonus; no generic `sent` flag. | +| `AddonDeliveryBatch` / `addons_delivery_batches` | `competitionId`; `addonKey`; `mode`; `createdBy`; `requestId`; `selectionKind: explicit|all_filtered`; `roles` JSONB enum array; `requestedCustomerUserIds` JSONB UUID array for explicit selection, null otherwise; `status: draft|completed|completed_with_errors|cancelled|expired`; `expiresAt`; nullable `confirmedAt`, `finishedAt`; integer `recipientCount`, `succeededCount`, `failedCount`, `skippedCount` | Unique `(tenant_id, organization_id, request_id)`; normalized source selection immutable. Stored recipient count equals snapshot rows. Terminal counters sum to recipient count. | +| `AddonDeliveryAttempt` / `addons_delivery_attempts` | `batchId`, `customerUserId` UUID; nullable `assignmentId`; `mode`; `status: selected|pending|succeeded|failed|skipped|cancelled|expired`; nullable `reasonCode`, `startedAt`, `finishedAt`; nullable positive `attemptNumber` | Unique `(batch_id, customer_user_id)`. Assignment link is populated only at execution for eligible recipients. Mode and scope must match batch and assignment. `attemptNumber` exists only when simulator was executed. | + +The recipient row doubles as the immutable confirmation snapshot; this avoids a fourth snapshot table. It does not claim an execution happened while `selected`. For same-module references use scoped integrity checks and composite foreign keys including tenant/organization where supported; never cascade-delete history. Cross-module IDs are verified via the reader, without ORM relations or new cross-module cascade dependencies. + +Add an index on `(tenant_id, organization_id, assignment_id, mode, status)`, and a partial unique index on `(tenant_id, organization_id, assignment_id, mode)` for non-null assignment IDs whose status is `pending` or `succeeded`. Failed, selected and skipped rows do not occupy the success slot. Include soft-deleted records in deduplication checks and this constraint; otherwise deleting history would re-enable delivery. Within the assignment lock, `attemptNumber = 1 + max(previous executed attemptNumber)` for this assignment/mode, including history. A later real provider must reserve its pending slot in a committed transaction before calling out; that lifecycle is deliberately not implemented here. + +Persisted `mode` allows `mock` and reserves `real` as a distinct namespace; public stage-one inputs accept only the literal `mock` and reject `real`. No real-mode status is synthesized from mock evidence. Display state derives from attempts in the selected mode: succeeded, pending, latest failed, otherwise not simulated. A later skipped duplicate does not replace earlier successful display state. + +Only IDs, enum codes, timestamps and counters are stored. Names/email remain in customer_accounts and are read with `findWithDecryption` / `findOneWithDecryption` in the exact scope. No new sensitive string field requires an encryption map. If implementation introduces PII/free-text failure descriptions, add `encryption.ts` framework maps before persistence; do not hand-roll encryption. UUIDs are access-controlled personal references, not public data. Do not log recipient lists, email, credentials or exception payloads. + +History is immutable in this release; no generic update/delete endpoints. Standard soft-delete fields do not constitute a user-facing delete or revocation feature. An inactive/soft-deleted assignment yields `skipped/assignment_unavailable`, never creates a replacement or implicitly reactivates it. Existing data-erasure obligations must include these references through the application's retention process; this spec adds no independent retention policy. + +## 📝 API Contracts + +Prefix `/api/addons`. Every handler exports per-method `metadata` and `openApi`. Authenticate backoffice users; derive request/response types from strict Zod schemas. Every read requires `addons.view` and `competitions.participants.manage`; every mutation additionally requires `addons.send`. Grant `addons.view` and `addons.send` to existing admin/superadmin defaults in `setup.ts`, then sync role ACLs for existing tenants. Customer/portal roles receive neither feature. Scoped object misses return `404`, absent auth `401`, missing feature `403`. + +| Method / endpoint | Request | Response | +| --- | --- | --- | +| `GET /competitions` | `page=1`, `pageSize=25` (1–100) | `{items:[{id,name}],totalCount,page,pageSize}` for eligible Competitions in the selected organization. Paginate selector options; never assume there are ≤100 competitions. | +| `GET /mercato-sandboxes/recipients` | Required `competitionId`; optional repeated `roles`; `page`, `pageSize` | `{items:[{customerUserId,displayName,email,role,assignmentId:null|uuid,mockStatus}],totalCount,page,pageSize}`. Deterministic role then UUID sort. No assignment is needed for a row to appear. | +| `POST /mercato-sandboxes/batches` | `{requestId,competitionId,mode:"mock",selection:{kind:"explicit",customerUserIds:[uuid],roles:[role]}}` or `{...,selection:{kind:"all_filtered",roles:[role]}}` | `201 {id,status:"draft",competitionId,competitionName,mode,recipientCount,expiresAt}`; replay `200` with existing batch. No assignment/simulation yet. | +| `GET /mercato-sandboxes/batches` | Required `competitionId`; `page`, `pageSize` | `{items:[BatchSummary],totalCount,page,pageSize}`, latest first with UUID tie-break. | +| `GET /mercato-sandboxes/batches/:id` | `page`, `pageSize` for recipient results | `{batch:BatchSummary,items:[{customerUserId,displayName,email,role:null|role,assignmentId,status,reasonCode,attemptNumber,startedAt,finishedAt}],totalCount,page,pageSize}`. Display fields are current, nullable after deletion; history still shows safe outcomes. | +| `POST /mercato-sandboxes/batches/:id/confirm` | `{}`; no new selection, mode or count accepted | `200 BatchSummary` terminal result; idempotent replay. Only creating operator may confirm. | +| `POST /mercato-sandboxes/batches/:id/cancel` | `{}` | `200 BatchSummary`; draft and selected rows become cancelled. Same cancellation is idempotent; terminal execution cannot be cancelled. Only creator may cancel. | + +`BatchSummary` contains `id,competitionId,addonKey,mode,status,createdBy,createdAt,expiresAt,confirmedAt,finishedAt,recipientCount,succeededCount,failedCount,skippedCount`. Never return unrestricted serialized ORM entities. + +Explicit selection is deduplicated and normalized; validate every ID against eligibility at preparation. Return a generic `422 invalid_selection` for any invalid or out-of-scope ID and create nothing; do not reveal which foreign account exists. All-filtered selection evaluates the same scoped role predicate as listing across every page in one database snapshot. Both paths reject zero recipients (`422 empty_selection`). No name/email/status search filter is introduced in stage one, avoiding ambiguous encrypted search behavior. + +Drafts expire 15 minutes after preparation. Reads may report an expired effective status without a write; confirmation checks `expiresAt` under lock and persists expired batch/recipient states instead of executing. No periodic job is required. Cancel/confirm/replay always check auth, scope and creator before returning mutation results. A confirmation of an already completed batch returns stored evidence even if eligibility later changes; it performs no new send. + +Default execution limit: 1,000 recipients per batch, with detection of limit+1 before persisting a snapshot. Reject oversize sets with `422 selection_too_large` and `{limit:1000,totalCount}`; never silently truncate all-filtered selection. Explain the limit in the UI and let the operator narrow roles or explicitly select a smaller set. Validate worst-case 1,000-recipient transaction duration before release; raise the limit only with evidence or redesign execution as a separate change. + +### Mutation guards + +Use `runRouteMutationGuards` from `@open-mercato/shared/lib/crud/route-mutation-guard` on every custom write. Preparation maps to create for `addons:addon_delivery_batch` and selected recipient rows; confirmation maps to update for batch and attempts and create for new assignments. Cancellation maps to update. Use matching colon-separated entity IDs `addons:addon_assignment` and `addons:addon_delivery_attempt`. + +Pass `{ userFeatures }`, merge returned `modifiedPayload`, revalidate with Zod and recheck scope/invariants before writing. Guard modification cannot silently expand/change the confirmed membership, addon, mode, identity or role filter: reject with `409 selection_changed` and require a new preview. Run all affected-record guards; a blocked guard aborts the operation, returns its blocking Response and leaves the draft unchanged rather than bypassing policy in a bulk loop. Prepare guards operate before persisting a draft; execution guards apply to every actual write, including reused attempts. Queue only returned `afterSuccessCallbacks` for after commit; catch/log callback failures. Keep guard execution deterministic and transaction-compatible; no simulator effect precedes successful guard checks. + +## 📝 UI/UX + +Backend route `/backend/addons/mercato-sandboxes`, with sibling `page.meta.ts`: `requireAuth: true`, required read features, `pageGroup: 'Dodatki'`, translated `pageGroupKey` and title. Batch detail route `/backend/addons/mercato-sandboxes/batches/[id]` also has metadata and is hidden from sidebar. No portal page or notification is added. + +Use framework Page, DataTable, FilterBar, CrudForm for dialog forms, Button, Dialog, EnumBadge, LoadingMessage/ErrorMessage and `flash`; all UI API calls use `apiCall`/`apiCallOrThrow`. Include PL/EN translations and accessible labels; dialogs support Cmd/Ctrl+Enter and Escape, with duplicate submission disabled during requests. + +Flow: + +1. Require a Competition. When the global Competition scope is set, it controls the selector; otherwise expose a paginated Competition picker. No organization/Competition means an explanatory empty state and disabled actions. +2. Show name, email, participation role and **status symulacji**. Always display “Tryb symulacji — nie przyznaje dostępu i nie wysyła wiadomości”. Separate row selection, current-page selection and explicit “Wybierz wszystkie wyniki (N)”; selection count is never merely the current page count disguised as total. +3. Selection uses customer UUIDs. Changing organization, global Competition, local Competition or roles clears selection and closes any confirmation. Stale query/preview responses must be discarded using scope/version checks; query cache keys include organization, Competition and roles. An existing server draft remains immutable until cancellation/expiry. +4. “Symuluj wysyłkę” prepares the draft. Dialog displays the server-confirmed Competition name, exact snapshot count and mock notice, and allows paginated inspection of the snapshot. Confirmation count refers to selected recipients; some may subsequently be skipped after revalidation. Cancel explicitly cancels draft; closing may best-effort cancel, with expiry as fallback. +5. Confirmation leads to persisted batch results. Success label is “Symulacja zakończona”, not “Dostęp przyznany”. Distinguish successful, failed and skipped counts with per-recipient safe translated reasons. An all-skipped batch says no new simulations were executed. Retrying selected failures prepares a new explicit-selection draft for reconfirmation, preserving earlier results. +6. Show history for the selected Competition, including draft/cancelled/expired batches. Read-only operators can inspect it without send controls. Loading, error, empty, oversized-selection, expired-preview and lost-response states are explicit. After a network error, reload batch status before presenting a retry. + +## 📝 Edge Cases & Failure Scenarios + +| Scenario | Persisted/user-visible behavior | +| --- | --- | +| Same user in A and B, changed role or recreated participation | Independent assignment per Competition; same natural key within one Competition, independent of participation row ID. | +| Overlapping confirmations / double-click | Same batch returns stored outcome; different batches serialize on assignments and later one skips same-mode successes. Pending blocks another execution; timeout allows safe retry. | +| Participant removed, role no longer matches, customer disabled or deleted after preview | Preserve frozen recipient row as `skipped/no_longer_eligible`; no new assignment. New eligible users are never appended. | +| Competition disabled/deleted before confirmation | Reject `409 competition_unavailable`; draft remains unexecuted and can expire/cancel. Reauthorization failure also executes nothing. | +| Simulation fails for one recipient | Assignment remains; attempt is `failed/mock_failure`, batch `completed_with_errors`; others may succeed. Retry creates fresh evidence. | +| Database/process failure or lock timeout | Entire confirmation rolls back; original draft remains. Read status before retry when commit outcome is unknown to client. | +| Guard rejection or invariant-changing modified payload | Entire operation aborts; return guard response or `409 selection_changed`. No silent partial recipient replacement. | +| Decryption unavailable | List/detail reports safe error; never displays ciphertext. Simulation eligibility reads only non-PII identity/status fields; history stores no fallback PII. | +| Mode separation | Mock history is explicitly mock. Stage-one API rejects real mode; future real-mode deduplication must ignore mock successes. | +| Cancelling after execution / undo | Terminal simulation is immutable; cancellation returns `409 batch_not_confirmable`. No real access exists to revoke. Assignment revocation/reset is outside this release. | + +Deterministic mock algorithm v1: remove hyphens from the customer UUID and interpret the last byte as hex. On the first executed mock attempt for an assignment, values divisible by five yield `failed/mock_failure`; all others succeed. Subsequent executed mock attempts succeed. Skipped and draft rows never increment `attemptNumber`. Thus fixtures ending `00` fail once then succeed, and `01` succeed immediately. Label this deterministic artificial behavior in operator help; do not use random failures or expose a client override. Real mode never calls this simulator. + +Safe reason codes: `mock_failure`, `already_succeeded`, `already_pending`, `no_longer_eligible`, `assignment_unavailable`. No provider errors, stacks or free-text descriptions in history. `completed_with_errors` means at least one failed simulator attempt; skipped counts remain separately visible even on `completed` batches. + +## 📝 Risks & Impact Review + +Additive schema and new routes/pages only; existing participant routes and core entities remain unchanged. No import/backfill of assignments from participation rows, no email credential configuration and no external network side effects. No claim of real-provider exactly-once delivery: stable scoped assignment identity, separate modes and durable attempts are groundwork; external idempotency, reconciliation, leases, rate limits, notifications and revocation need their own integration spec. + +A synchronous transaction can hold many locks. Cap input, use ordered batched reads/upserts and fixed ordering, verify against PostgreSQL with parallel connections, and measure the maximum supported batch. If the maximum cannot meet the deployed request budget, reduce the documented limit and update UI/contracts together; do not ship a request that predictably times out. No eager all-recipient PII enrichment is needed during preparation/execution. + +Deployment sequence: create scoped additive migration and matching snapshot; register/generate; apply approved migration before exposing routes; sync new ACLs; enable navigation only with dependencies and schema ready. After editing `src/modules/addons/data/entities.ts`, stop and ask “I modified an entity in module addons. Should I create a migration?” per AGENTS.md. On approval run `yarn db:generate`, show SQL, obtain separate application confirmation, then run `yarn db:migrate` and `yarn generate`. Keep only addon SQL plus its updated snapshot; never modify applied migrations. Immediately run `yarn generate` after editing `src/modules.ts`. + +Rollback disables the addons module/routes/navigation and preserves additive tables/history; regenerate module artifacts. Re-enabling restores the same durable evidence. Do not delete successes to simulate rollback or run destructive down migrations as routine rollback. Cancellation is the supported reversal before execution; failed simulation can be retried, but terminal history cannot be erased through this UI. + +## 📝 Acceptance Criteria + +- [ ] One scoped assignment per customer, Competition and addon; role/participation recreation does not duplicate it. +- [ ] Selection supports rows, current page and all filtered pages, with server-frozen count and explicit confirmation. +- [ ] Every read/write enforces tenant, one authorized organization, Competition, active eligibility and ACL; foreign IDs reveal no account data. +- [ ] Successful mock attempts suppress same-mode retries only; concurrent operations and pending evidence cannot create duplicate success. +- [ ] Deterministic failure succeeds on retry, with all previous attempts retained. +- [ ] No external provisioning, email, invitation, credential, access grant or portal notification occurs. +- [ ] New participants after preview are excluded; eligibility changes produce safe skips; cancellation and expiry execute nothing. +- [ ] Guards cover bulk child mutations and cannot silently replace a confirmed set; callbacks run after commit. +- [ ] UI labels all outcomes as simulations; history includes recipient failures and skipped duplicates. +- [ ] All validation below passes before implementation is considered delivered. + +## 🔍 Architectural Review + +Review completed 2026-09-15 against AGENTS.md and the specification checklist. Verdict: ready for implementation planning; no unresolved Critical or High design findings. This is document review, not evidence that the feature or its tests already exist. + +| Severity | Finding / disposition | +| --- | --- | +| Critical | None unresolved. All new access paths explicitly scope tenant, organization and Competition; existing participant GET is not reused as the eligibility authority. | +| High | None unresolved. Corrected execution ordering to acquire peer-record locks and revalidate before creating assignments; fixed lock ordering is now consistent throughout the transaction contract. | +| Medium | Synchronous capacity is bounded at 1,000 and must be measured before rollout. This is a delivery validation requirement, not an assertion of measured performance. | +| Low | None requiring a design change. | + +| Review criterion | Verdict and evidence | +| --- | --- | +| Architectural diff | Pass: concentrates on snapshots, assignment identity, mock evidence and concurrency; reuses framework UI/auth/guards. | +| Scope cohesion | Pass: fresh-context reviewer received only this spec path and found one Competition-scoped operator workflow; API/UI phases are implementation increments. | +| Canonical mechanisms | Pass: DI, encrypted-query helpers, mutation-guard registry, shared UI and MikroORM atomic flush are explicit. | +| Contracts and compatibility | Pass: additive endpoints/schema; strict mock-only HTTP input; existing peer routes remain unchanged. | +| Reversibility | Pass: draft cancellation, transactional rollback, immutable terminal evidence and disable/re-enable rollout are defined. | +| Boundaries and coupling | Pass: scalar cross-module IDs, a read-only DI boundary and graceful dependency-disabled behavior; no peer writes. | +| Sensitive data | Pass: existing encryption protects names/email; new history stores scoped IDs and enum codes, not copied PII. | +| Failure scenarios | Pass: concurrency, stale eligibility, expiry, partial simulated failure, database errors and lost responses are covered. | +| Testability | Pass: each implementation step specifies observable checks, including real PostgreSQL concurrency and browser evidence. | + +## 📋 Phasing + +**Phase 1 — Persisted mock operation.** Deliver schema, scope reader and complete preparation/confirmation/history contracts behind feature permissions. Existing app remains working; this phase is API-usable without UI. + +**Phase 2 — Operator UI and release validation.** Deliver the dedicated navigation, recipient selection, confirmation and history; validate the complete workflow. These are delivery increments of one capability. Real provisioning is a separate future spec, not an unchecked phase here. + +## 📋 Implementation Plan + +### Phase 1 — Persisted mock operation + +1. **Schema and registration.** Add the `addons` module, validators, three entities, scoped indexes/constraints and migration snapshot; follow the explicit entity/migration approvals above. Add ACL declarations/default grants. Keep routes unavailable until the migration is applied. Verify uniqueness, invalid-state rejection, scoped references and mode separation against PostgreSQL; verify repeat schema generation produces no addon churn. +2. **Scope reader and preview.** Implement the DI reader, paginated Competition/recipient GETs and transactional draft creation/cancellation with guards and strict OpenAPI schemas. Verify cross-tenant/cross-organization denial, inactive entities, role OR filters, deduplicated explicit IDs, empty/oversize selections, all-filtered selection above 100 rows, immutable snapshots and requestId replay/conflict. +3. **Execution and history.** Implement confirmation, deterministic simulator, lock/guard behavior and paginated history. Test failure→retry, same-mode skip, future real-mode isolation at persistence/service level while HTTP rejects real, participation recreation, eligibility changes, expiry/cancellation and immutable results. Use real PostgreSQL parallel connections for overlap, pending exclusion and rollback/crash-boundary tests; mocks alone cannot prove locking. Inject a failure before commit and a lost response after commit; verify safe replay in each case. + +### Phase 2 — Operator UI and release validation + +1. **Recipient screen.** Add metadata/navigation and PL/EN UI using shared components. Verify scope switching, stale responses, selectors beyond 100 Competitions, role changes, page/row/all-filtered selection and view-only permissions; confirm the 1,000 limit is explicit and never truncates selections. +2. **Confirmation/results/history.** Wire server draft preparation, snapshot inspection, keyboard controls, cancellation, persisted results and retry-as-new-draft. Browser tests with fixtures ending `00` and `01` cover both simulator paths, accurate counts across pages, reload after network error, safe translated reasons and clear no-access mock labels. Assert no outbound provisioning/notification calls. +3. **Validation and rollout.** Prepare the configured integration environment; run `yarn generate`, `yarn typecheck`, `yarn lint`, `yarn test`, `yarn build`, plus targeted PostgreSQL integration and browser tests. Load-test 1,000 recipients with overlapping requests against the deployed request timeout; record duration and lock-timeout behavior. Verify new ACLs on an existing tenant, dependency-disabled behavior and rollback/re-enable preserving deduplication. Update acceptance checkboxes only with recorded evidence; preserve this brief/spec as the implementation source. diff --git a/.ai/specs/briefs/2026-09-15-competition-addons.md b/.ai/specs/briefs/2026-09-15-competition-addons.md new file mode 100644 index 0000000..b21262b --- /dev/null +++ b/.ai/specs/briefs/2026-09-15-competition-addons.md @@ -0,0 +1,81 @@ +# Brief: Dodatki — Mercato Sandboxes per Competition + +Date: 2026-09-15 +Status: User-approved brainstorm handoff; not an implementation specification. +Conclusion: Ramp 4 — feature to co-design through `om-spec-writing`. + +## Goal + +Let backoffice operators assign the Mercato Sandboxes bonus to competition participants and send selected recipients in bulk. Start with persisted mock delivery; integrate the external provisioning API in a later stage. Plan the entities and backend UI before implementation. + +## Confirmed scope and resolved unknowns + +| Question | Decision | +| --- | --- | +| Who owns a bonus? | One customer user within one Competition, per addon, tenant and organization. The user explicitly corrected the earlier global-per-user answer; that earlier answer is superseded. | +| Navigation | Sidebar group **Dodatki**, entry **Mercato Sandboxes**. | +| Recipients | Existing competition participants; use customer account IDs, not backoffice auth-user IDs or email as identity. | +| Filters | Select one Competition for sending and filter by participation roles. | +| Table | Name, email, participation role and delivery status for the selected Competition. | +| Bulk selection | Individual rows, current page, or explicitly all filtered results across pages. Confirm the Competition and recipient count before sending. | +| First-stage delivery | Mock only, with persisted simulated results, no external API call. A successful simulation does not mean actual sandbox access. | +| Retry behavior | Avoid duplicate successful sends in the same mode; allow failed attempts to be retried. Mock success must never suppress a later real send. | +| Next step | User approved the proposed scope and routing to interactive `om-spec-writing`. | + +## Proposed model to develop in the spec + +Three concepts, with exact entity names and fields left to specification: + +- **Addon assignment:** user ID, Competition ID, stable addon key, tenant and organization; unique within that scope. Track assignment separately from evidence of actual provisioning. A role change or participation record recreation must not create another assignment. +- **Delivery batch:** the bulk operation, selected Competition, addon, mode, initiating operator, timestamps and aggregate outcome. +- **Recipient attempt:** a batch recipient linked to the assignment, with outcome, timestamps and a safe failure reason. Preserve previous attempts when retrying. + +Reuse existing accounts and participation data. Initially register only Mercato Sandboxes; a configurable addon catalog or catalog-management UI is unnecessary. Follow repository UUID, tenant isolation, encryption, migration and cross-module ID-reference conventions. Avoid duplicating personal data unless necessary; encrypt any persisted sensitive fields using framework maps. + +## UI and behavior defaults + +- Use existing backend DataTable and filter components with pagination, loading, empty and error states. +- A user can appear independently in Competition A and Competition B, each with its own addon status. +- Multiple chosen roles match participants with any chosen role in the selected Competition. +- Changing Competition clears selection. Changing filters must not silently change a confirmed recipient set. +- Freeze the recipient set associated with the confirmation, rather than re-evaluating filters later and silently including new users. Revalidate eligibility before mutation. +- Clearly identify mock mode and simulated completion in the action, result and history. No emails, invitations, credentials or actual access are sent during this stage. +- Show results per recipient, including failures and skipped duplicates. Specify deterministic mock success/failure coverage for verification. + +## Constraints and challenger findings + +A fresh-context challenger found no critical product decision blocking routing. Carry these requirements into the spec: + +- Distinguish mock outcomes from real provisioning outcomes in persisted data and eligibility checks. +- Enforce active participation, selected Competition, tenant, organization and operator permissions server-side for both listing and sending. Verify existing routes before reuse; do not assume they enforce all scopes. +- Prevent concurrent duplicate operations, including recipients already pending. The future provider contract should support stable user + Competition + addon idempotency; a database unique constraint alone does not prevent repeated external calls. +- Keep delivery/provisioning success distinct from notification delivery when the real integration is designed. +- Follow `AGENTS.md`: no ORM relations across module boundaries, mutation guards on custom write routes, MikroORM 7 flush rules, page metadata, ACL grants, and confirmed migration lifecycle. + +## Out of scope for stage 1 + +Real API integration, credentials, provisioning or invitations; expiry and revocation workflows; a generic integration marketplace; addon-catalog administration. The future API contract and provider-specific lifecycle remain deferred, not assumed solved. + +## Alternatives considered + +1. Dedicated simple Dodatki module with assignments and delivery history — selected because it matches the requested navigation and gives operators a place to inspect state. +2. Bulk action on the existing participants page — less work, but lacks the requested addon area. +3. Build nothing and pass lists manually — viable for a one-off operation, but not selected for the requested workflow. + +## Repository evidence + +- `src/modules/competitions/data/entities.ts`: `CompetitionParticipation` is unique by `competitionId` and `customerUserId`; roles are `participant`, `mentor`, `judge`. +- `src/modules/competitions/backend/competitions/participants/page.tsx`: existing participant UI and filtering/pagination patterns. +- `AGENTS.md`, `.ai/skills/om-data-model-design/SKILL.md`, `.ai/skills/om-backend-ui-design/SKILL.md`: project conventions. +- `.ai/agentic.config.json`: specs directory is `.ai/specs`; GitHub tracker descriptor is installed. +- Existing spec search found no matching addon/sandbox specification. Read-only open issue and PR searches for `sandbox`, `addons`, and `bulk send` in `comerito/om-hackathon-starter` returned no matches on 2026-09-15. This does not establish absence among closed items or other terminology. + +## Handoff and lifecycle + +No unresolved critical product question remains from the brainstorm. The spec should develop fields, API shapes, status transitions, permissions, failure behavior and a testable implementation plan. Ask only genuinely new blocking questions; do not reopen the per-Competition decision. + +Approved invocation: + +`om-spec-writing "Dodatki: Mercato Sandboxes per Competition — brief: .ai/specs/briefs/2026-09-15-competition-addons.md"` + +This brief remains uncommitted. The routed skill should preserve it alongside the resulting specification. Brainstorming does not execute that next skill or implement the feature. From 56c01d0f938407c6be079e27573fb84a6f950535 Mon Sep 17 00:00:00 2001 From: "openmercato[bot]" <264865371+openmercato[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:12:53 +0000 Subject: [PATCH 2/2] feat/mercato-sandboxes-addon --- ...15-competition-addons-mercato-sandboxes.md | 21 +- openmercato.toml | 4 + src/modules.ts | 1 + src/modules/addons/acl.ts | 6 + src/modules/addons/api/competitions/route.ts | 11 + src/modules/addons/api/helpers.ts | 62 + .../batches/[id]/cancel/route.ts | 14 + .../batches/[id]/confirm/route.ts | 14 + .../mercato-sandboxes/batches/[id]/route.ts | 11 + .../api/mercato-sandboxes/batches/route.ts | 19 + .../api/mercato-sandboxes/recipients/route.ts | 11 + src/modules/addons/components/BatchPanel.tsx | 76 + src/modules/addons/components/client.ts | 30 + src/modules/addons/components/contracts.ts | 18 + .../addons/data/__tests__/schema.test.ts | 58 + .../addons/data/__tests__/validators.test.ts | 67 + src/modules/addons/data/entities.ts | 241 +++ src/modules/addons/data/responses.ts | 24 + src/modules/addons/data/validators.ts | 54 + src/modules/addons/index.ts | 10 + .../lib/__tests__/recipient-reader.test.ts | 99 ++ src/modules/addons/lib/errors.ts | 6 + src/modules/addons/lib/guards.ts | 50 + src/modules/addons/lib/recipient-reader.ts | 92 ++ src/modules/addons/lib/request-context.ts | 30 + src/modules/addons/lib/simulator.ts | 13 + .../migrations/.snapshot-open-mercato.json | 1300 +++++++++++++++++ .../Migration20260915095357_addons.ts | 55 + .../Migration20260915100142_addons.ts | 17 + src/modules/addons/setup.ts | 10 + 30 files changed, 2423 insertions(+), 1 deletion(-) create mode 100644 openmercato.toml create mode 100644 src/modules/addons/acl.ts create mode 100644 src/modules/addons/api/competitions/route.ts create mode 100644 src/modules/addons/api/helpers.ts create mode 100644 src/modules/addons/api/mercato-sandboxes/batches/[id]/cancel/route.ts create mode 100644 src/modules/addons/api/mercato-sandboxes/batches/[id]/confirm/route.ts create mode 100644 src/modules/addons/api/mercato-sandboxes/batches/[id]/route.ts create mode 100644 src/modules/addons/api/mercato-sandboxes/batches/route.ts create mode 100644 src/modules/addons/api/mercato-sandboxes/recipients/route.ts create mode 100644 src/modules/addons/components/BatchPanel.tsx create mode 100644 src/modules/addons/components/client.ts create mode 100644 src/modules/addons/components/contracts.ts create mode 100644 src/modules/addons/data/__tests__/schema.test.ts create mode 100644 src/modules/addons/data/__tests__/validators.test.ts create mode 100644 src/modules/addons/data/entities.ts create mode 100644 src/modules/addons/data/responses.ts create mode 100644 src/modules/addons/data/validators.ts create mode 100644 src/modules/addons/index.ts create mode 100644 src/modules/addons/lib/__tests__/recipient-reader.test.ts create mode 100644 src/modules/addons/lib/errors.ts create mode 100644 src/modules/addons/lib/guards.ts create mode 100644 src/modules/addons/lib/recipient-reader.ts create mode 100644 src/modules/addons/lib/request-context.ts create mode 100644 src/modules/addons/lib/simulator.ts create mode 100644 src/modules/addons/migrations/.snapshot-open-mercato.json create mode 100644 src/modules/addons/migrations/Migration20260915095357_addons.ts create mode 100644 src/modules/addons/migrations/Migration20260915100142_addons.ts create mode 100644 src/modules/addons/setup.ts diff --git a/.ai/specs/2026-09-15-competition-addons-mercato-sandboxes.md b/.ai/specs/2026-09-15-competition-addons-mercato-sandboxes.md index fe50c7d..1fedc0f 100644 --- a/.ai/specs/2026-09-15-competition-addons-mercato-sandboxes.md +++ b/.ai/specs/2026-09-15-competition-addons-mercato-sandboxes.md @@ -1,7 +1,7 @@ # Dodatki: Mercato Sandboxes per Competition **Date**: 2026-09-15 -**Status**: Ready for implementation; implementation and runtime verification not started +**Status**: Phase 1 foundation in progress; migration application approval and runtime verification pending **Source brief**: [Approved brief](briefs/2026-09-15-competition-addons.md) ## 📝 TLDR @@ -214,3 +214,22 @@ Review completed 2026-09-15 against AGENTS.md and the specification checklist. V 1. **Recipient screen.** Add metadata/navigation and PL/EN UI using shared components. Verify scope switching, stale responses, selectors beyond 100 Competitions, role changes, page/row/all-filtered selection and view-only permissions; confirm the 1,000 limit is explicit and never truncates selections. 2. **Confirmation/results/history.** Wire server draft preparation, snapshot inspection, keyboard controls, cancellation, persisted results and retry-as-new-draft. Browser tests with fixtures ending `00` and `01` cover both simulator paths, accurate counts across pages, reload after network error, safe translated reasons and clear no-access mock labels. Assert no outbound provisioning/notification calls. 3. **Validation and rollout.** Prepare the configured integration environment; run `yarn generate`, `yarn typecheck`, `yarn lint`, `yarn test`, `yarn build`, plus targeted PostgreSQL integration and browser tests. Load-test 1,000 recipients with overlapping requests against the deployed request timeout; record duration and lock-timeout behavior. Verify new ACLs on an existing tenant, dependency-disabled behavior and rollback/re-enable preserving deduplication. Update acceptance checkboxes only with recorded evidence; preserve this brief/spec as the implementation source. + +## Implementation Status + +| Phase | Status | Date | Notes | +|-------|--------|------|-------| +| Phase 1 — Persisted mock operation | In Progress | 2026-09-15 | Module registration, ACL/default grants, validators and entity definitions added. Two scoped migrations generated and reviewed; application awaits approval. | +| Phase 2 — Operator UI and release validation | Not Started | — | Depends on persisted operation and approved migration. | + +### Phase 1 — Detailed Progress + +- [ ] Step 1: Schema and registration — module registered; 22 request-validation and schema-metadata tests pass; generation, typecheck, structural-cache refresh and existing-tenant role ACL synchronization passed. Two scoped migrations add three tables and composite FKs; snapshot is current and repeat addon generation reports no changes. Application approval and PostgreSQL runtime tests remain pending; no phase completion claimed. +- [ ] Step 2: Scope reader and preview. +- [ ] Step 3: Execution and history. + +### Phase 2 — Detailed Progress + +- [ ] Step 1: Recipient screen. +- [ ] Step 2: Confirmation/results/history. +- [ ] Step 3: Validation and rollout. diff --git a/openmercato.toml b/openmercato.toml new file mode 100644 index 0000000..cff9d7e --- /dev/null +++ b/openmercato.toml @@ -0,0 +1,4 @@ +version = 2 + +[preview] +command = ["env", "OM_DEV_INOTIFY_CHECK=0", "OM_DEV_BUNDLER=turbopack", "yarn", "dev", "--watch=auto-optimized"] diff --git a/src/modules.ts b/src/modules.ts index 616891a..0d8600d 100644 --- a/src/modules.ts +++ b/src/modules.ts @@ -48,6 +48,7 @@ export const enabledModules: ModuleEntry[] = [ { id: 'customer_accounts', from: '@open-mercato/core' }, // App modules BEFORE portal so app pages override core portal defaults { id: 'competitions', from: '@app' }, + { id: 'addons', from: '@app' }, { id: 'tracks', from: '@app' }, { id: 'teams', from: '@app' }, { id: 'projects', from: '@app' }, diff --git a/src/modules/addons/acl.ts b/src/modules/addons/acl.ts new file mode 100644 index 0000000..a69f3f4 --- /dev/null +++ b/src/modules/addons/acl.ts @@ -0,0 +1,6 @@ +export const features = [ + { id: 'addons.view', title: 'View addon simulations', module: 'addons' }, + { id: 'addons.send', title: 'Run addon simulations', module: 'addons' }, +] + +export default features diff --git a/src/modules/addons/api/competitions/route.ts b/src/modules/addons/api/competitions/route.ts new file mode 100644 index 0000000..be6417d --- /dev/null +++ b/src/modules/addons/api/competitions/route.ts @@ -0,0 +1,11 @@ +import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' +import { competitionsQuerySchema } from '../../data/validators' +import { competitionsResponseSchema } from '../../data/responses' +import { addonReadFeatures } from '../../lib/request-context' +import { addonRoute, queryInput, responseJson, apiErrors } from '../helpers' + +export const metadata = { GET: { requireAuth: true, requireFeatures: addonReadFeatures } } +export async function GET(req: Request) { + return addonRoute(req, false, async (ctx, service) => responseJson(competitionsResponseSchema, await service.competitions(ctx, competitionsQuerySchema.parse(queryInput(req)))) ) +} +export const openApi: OpenApiRouteDoc = { tag: 'Addons', methods: { GET: { summary: 'List scoped addon competitions', query: competitionsQuerySchema, responses: [{ status: 200, schema: competitionsResponseSchema }], errors: apiErrors } } } diff --git a/src/modules/addons/api/helpers.ts b/src/modules/addons/api/helpers.ts new file mode 100644 index 0000000..5392d07 --- /dev/null +++ b/src/modules/addons/api/helpers.ts @@ -0,0 +1,62 @@ +import { z } from 'zod' +import type { OpenApiResponseDoc } from '@open-mercato/shared/lib/openapi' +import { AddonError } from '../lib/errors' +import { resolveAddonContext, type AddonRequestContext } from '../lib/request-context' +import type { AddonBatchService } from '../lib/batch-service' + +export const pathParamsSchema = z.object({ id: z.string().uuid() }).strict() +export type RouteContext = { params: Promise<{ id: string }> | { id: string } } +export async function batchId(context: RouteContext): Promise { return pathParamsSchema.parse(await context.params).id.toLowerCase() } +export function queryInput(req: Request, repeatedRoles = false): Record { + const result: Record = Object.create(null) + for (const [key, value] of new URL(req.url).searchParams) { + if (key === 'roles' && repeatedRoles) { + const roles = result.roles as string[] | undefined + result.roles = [...(roles ?? []), value] + } else { + if (Object.hasOwn(result, key)) throw new AddonError('invalid_request', 400) + result[key] = value + } + } + return result +} +export async function jsonInput(req: Request): Promise { + // The largest legal explicit selection is under 400 KiB. Bound streaming bodies too. + if (Number(req.headers.get('content-length')) > 512_000) throw new AddonError('invalid_request', 400) + const reader = req.body?.getReader() + if (!reader) throw new AddonError('invalid_request', 400) + const chunks: Uint8Array[] = [] + let size = 0 + while (true) { + const next = await reader.read() + if (next.done) break + size += next.value.byteLength + if (size > 512_000) { await reader.cancel(); throw new AddonError('invalid_request', 400) } + chunks.push(next.value) + } + const bytes = new Uint8Array(size) + let offset = 0 + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length } + try { return JSON.parse(new TextDecoder().decode(bytes)) as unknown } catch { throw new AddonError('invalid_request', 400) } +} +export function responseJson(schema: z.ZodType, value: unknown, status = 200): Response { + const parsed = schema.safeParse(value) + if (!parsed.success) throw new AddonError('internal_error', 500) + return Response.json(parsed.data, { status }) +} +export async function addonRoute(req: Request, write: boolean, handler: (ctx: AddonRequestContext, service: AddonBatchService) => Promise): Promise { + try { + const ctx = await resolveAddonContext(req, write) + // The service runs runRouteMutationGuards for batch AND child writes within its transaction. + return await handler(ctx, ctx.container.resolve('addonBatchService')) + } catch (error) { + if (error instanceof Response) return error + if (error instanceof AddonError) return Response.json({ error: error.code, ...error.details }, { status: error.status }) + if (error instanceof z.ZodError) return Response.json({ error: 'invalid_request' }, { status: 400 }) + if (typeof error === 'object' && error !== null && 'code' in error && error.code === '55P03') return Response.json({ error: 'operation_in_progress' }, { status: 409 }) + console.error('[addons.api] request_failed') + return Response.json({ error: 'internal_error' }, { status: 500 }) + } +} +const errorSchema = z.object({ error: z.string(), limit: z.number().optional(), totalCount: z.number().optional() }) +export const apiErrors: OpenApiResponseDoc[] = [400, 401, 403, 404, 409, 422, 500, 503].map((status) => ({ status, schema: errorSchema, description: 'Safe operation error code; no account information.' })) diff --git a/src/modules/addons/api/mercato-sandboxes/batches/[id]/cancel/route.ts b/src/modules/addons/api/mercato-sandboxes/batches/[id]/cancel/route.ts new file mode 100644 index 0000000..2a96caa --- /dev/null +++ b/src/modules/addons/api/mercato-sandboxes/batches/[id]/cancel/route.ts @@ -0,0 +1,14 @@ +import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' +import { emptyMutationSchema } from '../../../../../data/validators' +import { batchSummarySchema } from '../../../../../data/responses' +import { addonWriteFeatures } from '../../../../../lib/request-context' +import { addonRoute, queryInput, jsonInput, responseJson, batchId, pathParamsSchema, apiErrors, type RouteContext } from '../../../../helpers' +export const metadata = { POST: { requireAuth: true, requireFeatures: addonWriteFeatures } } +export async function POST(req: Request, context: RouteContext) { + return addonRoute(req, true, async (ctx, service) => { + emptyMutationSchema.parse(queryInput(req)) + emptyMutationSchema.parse(await jsonInput(req)) + return responseJson(batchSummarySchema, await service.cancel(ctx, await batchId(context))) + }) +} +export const openApi: OpenApiRouteDoc = { tag: 'Addons', methods: { POST: { summary: 'Cancel a frozen addon batch owned by the operator', pathParams: pathParamsSchema, requestBody: { schema: emptyMutationSchema }, responses: [{ status: 200, schema: batchSummarySchema }], errors: apiErrors } } } diff --git a/src/modules/addons/api/mercato-sandboxes/batches/[id]/confirm/route.ts b/src/modules/addons/api/mercato-sandboxes/batches/[id]/confirm/route.ts new file mode 100644 index 0000000..e5d4126 --- /dev/null +++ b/src/modules/addons/api/mercato-sandboxes/batches/[id]/confirm/route.ts @@ -0,0 +1,14 @@ +import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' +import { emptyMutationSchema } from '../../../../../data/validators' +import { batchSummarySchema } from '../../../../../data/responses' +import { addonWriteFeatures } from '../../../../../lib/request-context' +import { addonRoute, queryInput, jsonInput, responseJson, batchId, pathParamsSchema, apiErrors, type RouteContext } from '../../../../helpers' +export const metadata = { POST: { requireAuth: true, requireFeatures: addonWriteFeatures } } +export async function POST(req: Request, context: RouteContext) { + return addonRoute(req, true, async (ctx, service) => { + emptyMutationSchema.parse(queryInput(req)) + emptyMutationSchema.parse(await jsonInput(req)) + return responseJson(batchSummarySchema, await service.confirm(ctx, await batchId(context))) + }) +} +export const openApi: OpenApiRouteDoc = { tag: 'Addons', methods: { POST: { summary: 'Confirm a frozen addon batch owned by the operator', pathParams: pathParamsSchema, requestBody: { schema: emptyMutationSchema }, responses: [{ status: 200, schema: batchSummarySchema }], errors: apiErrors } } } diff --git a/src/modules/addons/api/mercato-sandboxes/batches/[id]/route.ts b/src/modules/addons/api/mercato-sandboxes/batches/[id]/route.ts new file mode 100644 index 0000000..c6df775 --- /dev/null +++ b/src/modules/addons/api/mercato-sandboxes/batches/[id]/route.ts @@ -0,0 +1,11 @@ +import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' +import { paginationSchema } from '../../../../data/validators' +import { batchDetailResponseSchema } from '../../../../data/responses' +import { addonReadFeatures } from '../../../../lib/request-context' +import { addonRoute, queryInput, responseJson, apiErrors, batchId, pathParamsSchema, type RouteContext } from '../../../helpers' + +export const metadata = { GET: { requireAuth: true, requireFeatures: addonReadFeatures } } +export async function GET(req: Request, context: RouteContext) { + return addonRoute(req, false, async (ctx, service) => responseJson(batchDetailResponseSchema, await service.detail(ctx, await batchId(context), paginationSchema.parse(queryInput(req)))) ) +} +export const openApi: OpenApiRouteDoc = { tag: 'Addons', methods: { GET: { summary: 'List scoped addon detail', query: paginationSchema, pathParams: pathParamsSchema, responses: [{ status: 200, schema: batchDetailResponseSchema }], errors: apiErrors } } } diff --git a/src/modules/addons/api/mercato-sandboxes/batches/route.ts b/src/modules/addons/api/mercato-sandboxes/batches/route.ts new file mode 100644 index 0000000..2e12589 --- /dev/null +++ b/src/modules/addons/api/mercato-sandboxes/batches/route.ts @@ -0,0 +1,19 @@ +import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' +import { batchesQuerySchema, prepareBatchSchema } from '../../../data/validators' +import { batchListResponseSchema, preparedBatchSchema } from '../../../data/responses' +import { addonReadFeatures, addonWriteFeatures } from '../../../lib/request-context' +import { addonRoute, queryInput, responseJson, apiErrors, jsonInput } from '../../helpers' + +export const metadata = { GET: { requireAuth: true, requireFeatures: addonReadFeatures, addonWriteFeatures } } +export async function GET(req: Request) { + return addonRoute(req, false, async (ctx, service) => responseJson(batchListResponseSchema, await service.list(ctx, batchesQuerySchema.parse(queryInput(req)))) ) +} +export const openApi: OpenApiRouteDoc = { tag: 'Addons', methods: { GET: { summary: 'List scoped addon list', query: batchesQuerySchema, responses: [{ status: 200, schema: batchListResponseSchema, preparedBatchSchema }], errors: apiErrors } } } + +export async function POST(req: Request) { + return addonRoute(req, true, async (ctx, service) => { + const result = await service.prepare(ctx, prepareBatchSchema.parse(await jsonInput(req))) + return responseJson(preparedBatchSchema, result.batch, result.replayed ? 200 : 201) + }) +} +openApi.methods.POST = { summary: 'Freeze a guarded recipient selection for confirmation', requestBody: { schema: prepareBatchSchema }, responses: [{ status: 201, schema: preparedBatchSchema }, { status: 200, schema: preparedBatchSchema }], errors: apiErrors } diff --git a/src/modules/addons/api/mercato-sandboxes/recipients/route.ts b/src/modules/addons/api/mercato-sandboxes/recipients/route.ts new file mode 100644 index 0000000..739b0bb --- /dev/null +++ b/src/modules/addons/api/mercato-sandboxes/recipients/route.ts @@ -0,0 +1,11 @@ +import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi' +import { recipientsQuerySchema } from '../../../data/validators' +import { recipientsResponseSchema } from '../../../data/responses' +import { addonReadFeatures } from '../../../lib/request-context' +import { addonRoute, queryInput, responseJson, apiErrors } from '../../helpers' + +export const metadata = { GET: { requireAuth: true, requireFeatures: addonReadFeatures } } +export async function GET(req: Request) { + return addonRoute(req, false, async (ctx, service) => responseJson(recipientsResponseSchema, await service.recipients(ctx, recipientsQuerySchema.parse(queryInput(req, true)))) ) +} +export const openApi: OpenApiRouteDoc = { tag: 'Addons', methods: { GET: { summary: 'List scoped addon recipients', query: recipientsQuerySchema, responses: [{ status: 200, schema: recipientsResponseSchema }], errors: apiErrors } } } diff --git a/src/modules/addons/components/BatchPanel.tsx b/src/modules/addons/components/BatchPanel.tsx new file mode 100644 index 0000000..85b2666 --- /dev/null +++ b/src/modules/addons/components/BatchPanel.tsx @@ -0,0 +1,76 @@ +'use client' +import * as React from 'react' +import { useQuery } from '@tanstack/react-query' +import type { ColumnDef } from '@tanstack/react-table' +import { useT } from '@open-mercato/shared/lib/i18n/context' +import { DataTable } from '@open-mercato/ui/backend/DataTable' +import { EnumBadge } from '@open-mercato/ui/backend/ValueIcons' +import { CrudForm } from '@open-mercato/ui/backend/CrudForm' +import { Button } from '@open-mercato/ui/primitives/button' +import { Checkbox } from '@open-mercato/ui/primitives/checkbox' +import { ErrorMessage, LoadingMessage } from '@open-mercato/ui/backend/detail' +import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation' +import { flash } from '@open-mercato/ui/backend/FlashMessages' +import { detailSchema, batchSchema, mayConfirm, toggleSelection, type Result } from './contracts' +import { batchPath, readApi, AddonApiError } from './client' + +export function BatchPanel({ id, scope, canSend, userId, onRetry, onChanged, onClose }: { id: string; scope: string; canSend: boolean; userId: string | null; onRetry: (ids: string[], competitionId: string) => void; onChanged: () => void; onClose?: () => void }) { + const t = useT() + const [page, setPage] = React.useState(1) + const [selected, setSelected] = React.useState([]) + const [busy, setBusy] = React.useState(false) + const [error, setError] = React.useState(null) + const [uncertain, setUncertain] = React.useState(false) + const [now, setNow] = React.useState(Date.now()) + const mounted = React.useRef(true) + const lock = React.useRef(false) + React.useEffect(() => { mounted.current = true; const timer = setInterval(() => setNow(Date.now()), 1000); return () => { mounted.current = false; clearInterval(timer) } }, []) + const query = useQuery({ queryKey: ['addons', scope, 'batch', id, page], queryFn: () => readApi(`${batchPath}/${id}?page=${page}&pageSize=25`, detailSchema), retry: false }) + const { runMutation, retryLastMutation } = useGuardedMutation({ contextId: 'addons.batch', blockedMessage: t('addons.error.guard_blocked') }) + const batch = query.data?.batch + const reload = async () => { const result = await query.refetch(); if (mounted.current && !result.isError) setUncertain(false) } + const mutate = async (action: 'confirm' | 'cancel') => { + if (lock.current || !batch || uncertain) return + lock.current = true; setBusy(true); setError(null) + try { + await runMutation({ context: { entityId: 'addons:addon_delivery_batch', recordId: id, retryLastMutation }, mutationPayload: {}, operation: () => readApi(`${batchPath}/${id}/${action}`, batchSchema, {}) }) + if (!mounted.current) return + await query.refetch(); onChanged() + if (action === 'confirm') flash(t('addons.simulationComplete'), 'success') + else onClose?.() + } catch (cause) { + if (!mounted.current) return + setError(cause instanceof AddonApiError ? t(`addons.error.${cause.code}`, t('addons.error.request_failed')) : t('addons.error.lost_response')) + // A failed response can follow a successful commit. Always read durable evidence first. + setUncertain(true) + const current = await query.refetch() + if (mounted.current && !current.isError) { setUncertain(false); onChanged() } + } finally { lock.current = false; if (mounted.current) setBusy(false) } + } + const columns = React.useMemo[]>(() => [ + ...(canSend ? [{ id: 'select', header: t('addons.selectFailures'), cell: ({ row }: { row: { original: Result } }) => row.original.status === 'failed' ? setSelected((ids) => toggleSelection(ids, row.original.customerUserId))} /> : null }] : []), + { accessorKey: 'displayName', header: t('addons.name'), cell: ({ row }) => row.original.displayName ?? row.original.customerUserId }, + { accessorKey: 'email', header: t('addons.email'), cell: ({ getValue }) => getValue() ?? '—' }, + { accessorKey: 'role', header: t('addons.role'), cell: ({ row }) => row.original.role ? t(`addons.role.${row.original.role}`) : '—' }, + { accessorKey: 'status', header: t('addons.simulationStatus'), cell: ({ row }) => }, + { accessorKey: 'reasonCode', header: t('addons.reason'), cell: ({ row }) => row.original.reasonCode ? t(`addons.reason.${row.original.reasonCode}`) : '—' }, + { accessorKey: 'attemptNumber', header: t('addons.attempt'), cell: ({ getValue }) => getValue() ?? '—' }, + ], [canSend, selected, t]) + if (query.isPending) return + if (!batch) return + const confirmable = mayConfirm(batch, userId, canSend, now) + return
+

{t('addons.snapshotCount', { count: batch.recipientCount })}

+

{t(`addons.status.${batch.status}`)} · {t('addons.expiresAt', { date: new Date(batch.expiresAt).toLocaleString() })}

+ {batch.status !== 'draft' &&

{t('addons.counts', { succeeded: batch.succeededCount, failed: batch.failedCount, skipped: batch.skippedCount })}

} + {batch.recipientCount > 0 && batch.skippedCount === batch.recipientCount &&

{t('addons.allSkipped')}

} + {batch.status === 'draft' && !confirmable && batch.createdBy === userId && new Date(batch.expiresAt).getTime() <= now && } + {error && } + {query.isError && } + {uncertain && } + + {confirmable && mutate('confirm')} />} + {canSend && batch.createdBy === userId && batch.status === 'draft' && } + {canSend && selected.length > 0 && batch.status !== 'draft' && } +
+} diff --git a/src/modules/addons/components/client.ts b/src/modules/addons/components/client.ts new file mode 100644 index 0000000..3ee274d --- /dev/null +++ b/src/modules/addons/components/client.ts @@ -0,0 +1,30 @@ +'use client' +import * as React from 'react' +import { z } from 'zod' +import { apiCall } from '@open-mercato/ui/backend/utils/apiCall' +import { hasAllFeatures } from '@open-mercato/shared/lib/auth/featureMatch' + +export class AddonApiError extends Error { + constructor(public code: string, public limit?: number, public totalCount?: number) { super(code) } +} +export async function readApi(path: string, schema: S, body?: Record): Promise> { + const response = await apiCall(path, body ? { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) } : undefined) + if (!response.ok) { + const error = z.object({ code: z.string().optional(), error: z.string().optional(), limit: z.number().optional(), totalCount: z.number().optional() }).safeParse(response.result) + throw new AddonApiError(error.success ? error.data.code ?? error.data.error ?? 'request_failed' : 'request_failed', error.success ? error.data.limit : undefined, error.success ? error.data.totalCount : undefined) + } + return schema.parse(response.result) +} +export function useAddonPermissions(scope: string) { + const [permissions, setPermissions] = React.useState({ canSend: false, userId: null as string | null }) + React.useEffect(() => { + let active = true + setPermissions({ canSend: false, userId: null }) + void readApi('/api/auth/feature-check', z.object({ granted: z.array(z.string()), userId: z.string() }), { features: ['addons.send', 'addons.view', 'competitions.participants.manage'] }).then((result) => { + if (active) setPermissions({ canSend: hasAllFeatures(['addons.send', 'addons.view', 'competitions.participants.manage'], result.granted), userId: result.userId }) + }).catch(() => {}) + return () => { active = false } + }, [scope]) + return permissions +} +export const batchPath = '/api/addons/mercato-sandboxes/batches' diff --git a/src/modules/addons/components/contracts.ts b/src/modules/addons/components/contracts.ts new file mode 100644 index 0000000..b65400a --- /dev/null +++ b/src/modules/addons/components/contracts.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' +import { batchStatusSchema, attemptStatusSchema, participationRoleSchema, reasonCodeSchema, uuidSchema } from '../data/validators' + +export const competitionSchema = z.object({ id: uuidSchema, name: z.string() }) +export const recipientSchema = z.object({ customerUserId: uuidSchema, displayName: z.string().nullable(), email: z.string().nullable(), role: participationRoleSchema, assignmentId: uuidSchema.nullable(), mockStatus: z.string() }) +export const batchSchema = z.object({ id: uuidSchema, competitionId: uuidSchema, addonKey: z.literal('mercato_sandboxes'), mode: z.enum(['mock', 'real']), status: batchStatusSchema, createdBy: uuidSchema, createdAt: z.string(), expiresAt: z.string(), confirmedAt: z.string().nullable(), finishedAt: z.string().nullable(), recipientCount: z.number(), succeededCount: z.number(), failedCount: z.number(), skippedCount: z.number() }) +export const previewSchema = z.object({ id: uuidSchema, status: batchStatusSchema, competitionId: uuidSchema, competitionName: z.string(), mode: z.literal('mock'), recipientCount: z.number(), expiresAt: z.string() }) +export const resultSchema = z.object({ customerUserId: uuidSchema, displayName: z.string().nullable(), email: z.string().nullable(), role: participationRoleSchema.nullable(), assignmentId: uuidSchema.nullable(), status: attemptStatusSchema, reasonCode: reasonCodeSchema.nullable(), attemptNumber: z.number().nullable(), startedAt: z.string().nullable(), finishedAt: z.string().nullable() }) +export function paginated(schema: S) { return z.object({ items: z.array(schema), totalCount: z.number(), page: z.number(), pageSize: z.number() }) } +export const detailSchema = paginated(resultSchema).extend({ batch: batchSchema }) +export type Recipient = z.infer +export type Batch = z.infer +export type Result = z.infer +export type Preview = z.infer + +export function toggleSelection(ids: string[], id: string): string[] { return ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id] } +export function selectionCount(allFiltered: boolean, ids: string[], total: number): number { return allFiltered ? total : ids.length } +export function mayConfirm(batch: Pick, userId: string | null, canSend: boolean, now = Date.now()): boolean { return canSend && batch.createdBy === userId && batch.mode === 'mock' && batch.status === 'draft' && new Date(batch.expiresAt).getTime() > now } diff --git a/src/modules/addons/data/__tests__/schema.test.ts b/src/modules/addons/data/__tests__/schema.test.ts new file mode 100644 index 0000000..fc6ac1e --- /dev/null +++ b/src/modules/addons/data/__tests__/schema.test.ts @@ -0,0 +1,58 @@ +import { MikroORM } from '@mikro-orm/core' +import { ReflectMetadataProvider } from '@mikro-orm/decorators/legacy' +import { PostgreSqlDriver } from '@mikro-orm/postgresql' +import { AddonAssignment, AddonDeliveryAttempt, AddonDeliveryBatch } from '../entities' + +describe('addon persistence metadata', () => { + let orm: MikroORM + + beforeAll(async () => { + orm = await MikroORM.init({ + driver: PostgreSqlDriver, + dbName: 'addons_metadata_test', + entities: [AddonAssignment, AddonDeliveryBatch, AddonDeliveryAttempt], + metadataProvider: ReflectMetadataProvider, + }) + }) + + afterAll(async () => { await orm?.close() }) + + it('preserves scalar nullability and defaults alongside scoped references', async () => { + const sql = await orm.schema.getCreateSchemaSQL({ wrap: false }) + expect(sql).toContain('"assignment_id" uuid null') + expect(sql).toContain('"mode" varchar(64) not null default \'mock\'') + expect(sql).toContain('foreign key ("batch_id", "tenant_id", "organization_id", "mode") references "addons_delivery_batches" ("id", "tenant_id", "organization_id", "mode") on update restrict on delete restrict') + expect(sql).toContain('foreign key ("assignment_id", "tenant_id", "organization_id", "customer_user_id") references "addons_assignments" ("id", "tenant_id", "organization_id", "customer_user_id") on update restrict on delete restrict') + }) + + it('retains the permanent identity and mode-specific pending/success slot', async () => { + const sql = await orm.schema.getCreateSchemaSQL({ wrap: false }) + expect(sql).toContain('unique ("tenant_id", "organization_id", "competition_id", "customer_user_id", "addon_key")') + expect(sql).toContain('where assignment_id is not null and status in (\'pending\', \'succeeded\')') + expect(sql).not.toMatch(/where[^;]*deleted_at/) + }) + + it('keeps references out of hydration and scalar change sets', () => { + const em = orm.em.fork() + const row = { + id: '11111111-1111-4111-8111-111111111111', + tenant_id: '22222222-2222-4222-8222-222222222222', + organization_id: '33333333-3333-4333-8333-333333333333', + batch_id: '44444444-4444-4444-8444-444444444444', + customer_user_id: '55555555-5555-4555-8555-555555555555', + assignment_id: null, + mode: 'mock' as const, status: 'selected' as const, + created_at: new Date(), updated_at: new Date(), is_active: true, + deleted_at: null, reason_code: null, started_at: null, finished_at: null, attempt_number: null, + } + const attempt = em.map(AddonDeliveryAttempt, row) + expect(attempt.batchId).toBe(row.batch_id) + expect(attempt.assignmentId).toBeNull() + expect(Reflect.get(attempt, 'batchReference')).toBeUndefined() + expect(Reflect.get(attempt, 'assignmentReference')).toBeUndefined() + attempt.assignmentId = '66666666-6666-4666-8666-666666666666' + em.getUnitOfWork().computeChangeSets() + const change = em.getUnitOfWork().getChangeSets().find((item) => item.entity === attempt) + expect(change?.payload).toEqual({ assignmentId: attempt.assignmentId, updatedAt: expect.any(Date) }) + }) +}) diff --git a/src/modules/addons/data/__tests__/validators.test.ts b/src/modules/addons/data/__tests__/validators.test.ts new file mode 100644 index 0000000..6d8409b --- /dev/null +++ b/src/modules/addons/data/__tests__/validators.test.ts @@ -0,0 +1,67 @@ +import { deliveryModeSchema, emptyMutationSchema, paginationSchema, prepareBatchSchema, recipientsQuerySchema } from '../validators' + +const competitionId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' +const customerId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const input = { + requestId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + competitionId, + mode: 'mock', + selection: { kind: 'explicit', customerUserIds: [customerId], roles: [] }, +} + +describe('addon request validation', () => { + it('normalizes UUID and role sets for idempotent preparation', () => { + const parsed = prepareBatchSchema.parse({ + ...input, + competitionId: competitionId.toUpperCase(), + selection: { kind: 'explicit', customerUserIds: [customerId.toUpperCase(), customerId], roles: ['mentor', 'judge', 'mentor'] }, + }) + expect(parsed.competitionId).toBe(competitionId) + expect(parsed.selection).toEqual({ kind: 'explicit', customerUserIds: [customerId], roles: ['judge', 'mentor'] }) + }) + + it('reserves real mode in persistence but rejects it over HTTP', () => { + expect(deliveryModeSchema.parse('real')).toBe('real') + expect(prepareBatchSchema.safeParse({ ...input, mode: 'real' }).success).toBe(false) + }) + + it.each(['tenantId', 'organizationId', 'createdBy', 'status'])('rejects injected %s', (field) => { + expect(prepareBatchSchema.safeParse({ ...input, [field]: competitionId }).success).toBe(false) + }) + + it('accepts all-filtered roles without client membership', () => { + expect(prepareBatchSchema.parse({ ...input, selection: { kind: 'all_filtered', roles: ['participant'] } }).selection) + .toEqual({ kind: 'all_filtered', roles: ['participant'] }) + expect(prepareBatchSchema.safeParse({ ...input, selection: { kind: 'all_filtered', roles: [], customerUserIds: [customerId] } }).success).toBe(false) + }) + + it('leaves empty and oversize selection to service domain errors without truncating', () => { + expect(prepareBatchSchema.parse({ ...input, selection: { kind: 'explicit', roles: [], customerUserIds: [] } }).selection) + .toEqual({ kind: 'explicit', roles: [], customerUserIds: [] }) + const ids = Array.from({ length: 1001 }, (_, index) => `dddddddd-dddd-4ddd-8ddd-${index.toString(16).padStart(12, '0')}`) + const parsed = prepareBatchSchema.parse({ ...input, selection: { kind: 'explicit', roles: [], customerUserIds: ids } }) + expect(parsed.selection.kind === 'explicit' && parsed.selection.customerUserIds.length).toBe(1001) + }) + + it('rejects invalid recipient IDs and participation roles', () => { + expect(prepareBatchSchema.safeParse({ ...input, selection: { kind: 'explicit', roles: [], customerUserIds: ['invalid'] } }).success).toBe(false) + expect(recipientsQuerySchema.safeParse({ competitionId, roles: ['admin'] }).success).toBe(false) + }) + + it('uses bounded pagination defaults and requires competition scope', () => { + expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 25 }) + expect(paginationSchema.parse({ page: '2', pageSize: '100' })).toEqual({ page: 2, pageSize: 100 }) + expect(recipientsQuerySchema.safeParse({}).success).toBe(false) + expect(recipientsQuerySchema.parse({ competitionId }).roles).toEqual([]) + }) + + it.each([0, -1, 1.2, '', '1e2', true, null, '9007199254740992'])('rejects invalid page %p', (page) => { + expect(paginationSchema.safeParse({ page }).success).toBe(false) + }) + + it('rejects excessive page size and replacement confirmation payloads', () => { + expect(paginationSchema.safeParse({ pageSize: 101 }).success).toBe(false) + expect(emptyMutationSchema.parse({})).toEqual({}) + expect(emptyMutationSchema.safeParse({ selection: input.selection }).success).toBe(false) + }) +}) diff --git a/src/modules/addons/data/entities.ts b/src/modules/addons/data/entities.ts new file mode 100644 index 0000000..e606d09 --- /dev/null +++ b/src/modules/addons/data/entities.ts @@ -0,0 +1,241 @@ +import { randomUUID } from 'node:crypto' +import { Check, Entity, Index, ManyToOne, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy' +import type { AddonKey, AttemptStatus, BatchStatus, DeliveryMode, ParticipationRole, ReasonCode, SelectionKind } from './validators' + +// Natural keys deliberately include inactive and soft-deleted history. +@Entity({ tableName: 'addons_assignments' }) +@Unique({ name: 'addons_assignment_identity', properties: ['tenantId', 'organizationId', 'competitionId', 'customerUserId', 'addonKey'] }) +@Unique({ name: 'addons_assignment_scoped_id', properties: ['id', 'tenantId', 'organizationId', 'customerUserId'] }) +@Check({ name: 'addons_assignment_key', expression: "addon_key = 'mercato_sandboxes'" }) +export class AddonAssignment { + + @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' }) + id: string = randomUUID() + + @Index() + @Property({ type: 'uuid', fieldName: 'tenant_id' }) + tenantId!: string + + @Index() + @Property({ type: 'uuid', fieldName: 'organization_id' }) + organizationId!: string + + @Property({ type: 'timestamptz', fieldName: 'created_at' }) + createdAt: Date = new Date() + + @Property({ type: 'timestamptz', fieldName: 'updated_at', onUpdate: () => new Date() }) + updatedAt: Date = new Date() + + @Property({ type: 'boolean', fieldName: 'is_active', default: true }) + isActive: boolean = true + + @Property({ type: 'timestamptz', fieldName: 'deleted_at', nullable: true }) + deletedAt: Date | null = null + + @Index() + @Property({ type: 'uuid', fieldName: 'competition_id' }) + competitionId!: string + + @Index() + @Property({ type: 'uuid', fieldName: 'customer_user_id' }) + customerUserId!: string + + @Property({ type: 'varchar', length: 64, fieldName: 'addon_key' }) + addonKey: AddonKey = 'mercato_sandboxes' + + @Property({ type: 'timestamptz', fieldName: 'assigned_at' }) + assignedAt!: Date + + @Index() + @Property({ type: 'uuid', fieldName: 'assigned_by' }) + assignedBy!: string + +} + +@Entity({ tableName: 'addons_delivery_batches' }) +@Unique({ name: 'addons_batch_request', properties: ['tenantId', 'organizationId', 'requestId'] }) +@Unique({ name: 'addons_batch_scoped_id', properties: ['id', 'tenantId', 'organizationId', 'mode'] }) +@Index({ name: 'addons_batch_history', properties: ['tenantId', 'organizationId', 'competitionId', 'createdAt', 'id'] }) +@Check({ name: 'addons_batch_key', expression: "addon_key = 'mercato_sandboxes'" }) +@Check({ name: 'addons_batch_mode', expression: "mode in ('mock', 'real')" }) +@Check({ name: 'addons_batch_status', expression: "status in ('draft', 'completed', 'completed_with_errors', 'cancelled', 'expired')" }) +@Check({ name: 'addons_batch_roles', expression: "jsonb_typeof(roles) = 'array' and roles <@ '[\"participant\",\"mentor\",\"judge\"]'::jsonb" }) +@Check({ name: 'addons_batch_selection', expression: "(selection_kind = 'explicit' and requested_customer_user_ids is not null and jsonb_typeof(requested_customer_user_ids) = 'array') or (selection_kind = 'all_filtered' and requested_customer_user_ids is null)" }) +@Check({ name: 'addons_batch_counts', expression: "recipient_count between 1 and 1000 and succeeded_count >= 0 and failed_count >= 0 and skipped_count >= 0 and succeeded_count + failed_count + skipped_count <= recipient_count" }) +@Check({ name: 'addons_batch_outcome', expression: "(status in ('completed', 'completed_with_errors') and succeeded_count + failed_count + skipped_count = recipient_count and confirmed_at is not null and finished_at is not null and ((status = 'completed' and failed_count = 0) or (status = 'completed_with_errors' and failed_count > 0))) or (status in ('draft', 'cancelled', 'expired') and succeeded_count = 0 and failed_count = 0 and skipped_count = 0 and confirmed_at is null and ((status = 'draft' and finished_at is null) or (status in ('cancelled', 'expired') and finished_at is not null)))" }) +export class AddonDeliveryBatch { + + @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' }) + id: string = randomUUID() + + @Index() + @Property({ type: 'uuid', fieldName: 'tenant_id' }) + tenantId!: string + + @Index() + @Property({ type: 'uuid', fieldName: 'organization_id' }) + organizationId!: string + + @Property({ type: 'timestamptz', fieldName: 'created_at' }) + createdAt: Date = new Date() + + @Property({ type: 'timestamptz', fieldName: 'updated_at', onUpdate: () => new Date() }) + updatedAt: Date = new Date() + + @Property({ type: 'boolean', fieldName: 'is_active', default: true }) + isActive: boolean = true + + @Property({ type: 'timestamptz', fieldName: 'deleted_at', nullable: true }) + deletedAt: Date | null = null + + @Index() + @Property({ type: 'uuid', fieldName: 'competition_id' }) + competitionId!: string + + @Property({ type: 'varchar', length: 64, fieldName: 'addon_key' }) + addonKey: AddonKey = 'mercato_sandboxes' + + @Property({ type: 'varchar', length: 64, fieldName: 'mode' }) + mode: DeliveryMode = 'mock' + + @Index() + @Property({ type: 'uuid', fieldName: 'created_by' }) + createdBy!: string + + @Index() + @Property({ type: 'uuid', fieldName: 'request_id' }) + requestId!: string + + @Property({ type: 'varchar', length: 64, fieldName: 'selection_kind' }) + selectionKind!: SelectionKind + + @Property({ type: 'jsonb', fieldName: 'roles' }) + roles: ParticipationRole[] = [] + + @Property({ type: 'jsonb', fieldName: 'requested_customer_user_ids', nullable: true }) + requestedCustomerUserIds: string[] | null = null + + @Property({ type: 'varchar', length: 64, fieldName: 'status' }) + status: BatchStatus = 'draft' + + @Property({ type: 'timestamptz', fieldName: 'expires_at' }) + expiresAt!: Date + + @Property({ type: 'timestamptz', fieldName: 'confirmed_at', nullable: true }) + confirmedAt: Date | null = null + + @Property({ type: 'timestamptz', fieldName: 'finished_at', nullable: true }) + finishedAt: Date | null = null + + @Property({ type: 'integer', fieldName: 'recipient_count' }) + recipientCount!: number + + @Property({ type: 'integer', fieldName: 'succeeded_count', default: 0 }) + succeededCount: number = 0 + + @Property({ type: 'integer', fieldName: 'failed_count', default: 0 }) + failedCount: number = 0 + + @Property({ type: 'integer', fieldName: 'skipped_count', default: 0 }) + skippedCount: number = 0 +} + +@Entity({ tableName: 'addons_delivery_attempts' }) +@Unique({ name: 'addons_attempt_batch_customer', properties: ['batchId', 'customerUserId'] }) +@Index({ name: 'addons_attempt_evidence', properties: ['tenantId', 'organizationId', 'assignmentId', 'mode', 'status'] }) +@Unique({ + name: 'addons_attempt_delivery_slot', + properties: ['tenantId', 'organizationId', 'assignmentId', 'mode'], + where: "assignment_id is not null and status in ('pending', 'succeeded')", +}) +@Check({ name: 'addons_attempt_mode', expression: "mode in ('mock', 'real')" }) +@Check({ name: 'addons_attempt_status', expression: "status in ('selected', 'pending', 'succeeded', 'failed', 'skipped', 'cancelled', 'expired')" }) +@Check({ name: 'addons_attempt_number', expression: "attempt_number is null or attempt_number > 0" }) +@Check({ name: 'addons_attempt_outcome', expression: "(status = 'selected' and assignment_id is null and reason_code is null and attempt_number is null and started_at is null and finished_at is null)\nor (status = 'pending' and assignment_id is not null and reason_code is null and attempt_number is null and started_at is not null and finished_at is null)\nor (status = 'succeeded' and assignment_id is not null and reason_code is null and attempt_number is not null and started_at is not null and finished_at is not null)\nor (status = 'failed' and assignment_id is not null and reason_code is not null and reason_code = 'mock_failure' and mode = 'mock' and attempt_number is not null and started_at is not null and finished_at is not null)\nor (status = 'skipped' and reason_code is not null and reason_code in ('already_succeeded', 'already_pending', 'no_longer_eligible', 'assignment_unavailable') and attempt_number is null and started_at is null and finished_at is not null and ((reason_code = 'no_longer_eligible' and assignment_id is null) or (reason_code <> 'no_longer_eligible' and assignment_id is not null)))\nor (status in ('cancelled', 'expired') and assignment_id is null and reason_code is null and attempt_number is null and started_at is null and finished_at is not null)" }) +export class AddonDeliveryAttempt { + + @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' }) + id: string = randomUUID() + + @Index() + @Property({ type: 'uuid', fieldName: 'tenant_id' }) + tenantId!: string + + @Index() + @Property({ type: 'uuid', fieldName: 'organization_id' }) + organizationId!: string + + @Property({ type: 'timestamptz', fieldName: 'created_at' }) + createdAt: Date = new Date() + + @Property({ type: 'timestamptz', fieldName: 'updated_at', onUpdate: () => new Date() }) + updatedAt: Date = new Date() + + @Property({ type: 'boolean', fieldName: 'is_active', default: true }) + isActive: boolean = true + + @Property({ type: 'timestamptz', fieldName: 'deleted_at', nullable: true }) + deletedAt: Date | null = null + + @Index() + @Property({ type: 'uuid', fieldName: 'batch_id' }) + batchId!: string + + @Index() + @Property({ type: 'uuid', fieldName: 'customer_user_id' }) + customerUserId!: string + + @Index() + @Property({ type: 'uuid', fieldName: 'assignment_id', nullable: true }) + assignmentId: string | null = null + + // Schema-only references share scalar columns. Keep them after IDs and before mode: + // MikroORM preserves the first column nullability and the last column default. + @ManyToOne(() => AddonDeliveryBatch, { + joinColumns: ['batch_id', 'tenant_id', 'organization_id', 'mode'], + referencedColumnNames: ['id', 'tenant_id', 'organization_id', 'mode'], + columnTypes: ['uuid', 'uuid', 'uuid', 'varchar(64)'], + foreignKeyName: 'addons_attempt_batch_scope_fk', + hydrate: false, + hidden: true, + nullable: false, + ownColumns: [], + cascade: [], + deleteRule: 'restrict', + updateRule: 'restrict', + }) + private batchReference?: AddonDeliveryBatch + + @ManyToOne(() => AddonAssignment, { + joinColumns: ['assignment_id', 'tenant_id', 'organization_id', 'customer_user_id'], + referencedColumnNames: ['id', 'tenant_id', 'organization_id', 'customer_user_id'], + columnTypes: ['uuid', 'uuid', 'uuid', 'uuid'], + foreignKeyName: 'addons_attempt_assignment_scope_fk', + hydrate: false, + hidden: true, + nullable: true, + ownColumns: [], + cascade: [], + deleteRule: 'restrict', + updateRule: 'restrict', + }) + private assignmentReference?: AddonAssignment + + @Property({ type: 'varchar', length: 64, fieldName: 'mode' }) + mode: DeliveryMode = 'mock' + + @Property({ type: 'varchar', length: 64, fieldName: 'status' }) + status: AttemptStatus = 'selected' + + @Property({ type: 'varchar', length: 64, fieldName: 'reason_code', nullable: true }) + reasonCode: ReasonCode | null = null + + @Property({ type: 'timestamptz', fieldName: 'started_at', nullable: true }) + startedAt: Date | null = null + + @Property({ type: 'timestamptz', fieldName: 'finished_at', nullable: true }) + finishedAt: Date | null = null + + @Property({ type: 'integer', fieldName: 'attempt_number', nullable: true }) + attemptNumber: number | null = null +} diff --git a/src/modules/addons/data/responses.ts b/src/modules/addons/data/responses.ts new file mode 100644 index 0000000..d138665 --- /dev/null +++ b/src/modules/addons/data/responses.ts @@ -0,0 +1,24 @@ +import { z } from 'zod' +import { addonKeySchema, deliveryModeSchema, batchStatusSchema, attemptStatusSchema, reasonCodeSchema, participationRoleSchema } from './validators' +const id = z.string().uuid() +const timestamp = z.string().datetime() +const count = z.number().int().nonnegative() +export const mockStatusSchema = z.enum(['not_simulated', 'pending', 'failed', 'succeeded']) +export const batchSummarySchema = z.object({ + id, competitionId: id, addonKey: addonKeySchema, mode: deliveryModeSchema, status: batchStatusSchema, + createdBy: id, createdAt: timestamp, expiresAt: timestamp, confirmedAt: timestamp.nullable(), finishedAt: timestamp.nullable(), + recipientCount: count, succeededCount: count, failedCount: count, skippedCount: count, +}).strict() +export const preparedBatchSchema = z.object({ id, status: batchStatusSchema, competitionId: id, competitionName: z.string(), mode: deliveryModeSchema, recipientCount: count, expiresAt: timestamp }).strict() +const page = { totalCount: count, page: z.number().int().positive(), pageSize: z.number().int().min(1).max(100) } +const display = { customerUserId: id, displayName: z.string().nullable(), email: z.string().nullable() } +export const competitionsResponseSchema = z.object({ items: z.array(z.object({ id, name: z.string() }).strict()), ...page }).strict() +export const recipientsResponseSchema = z.object({ items: z.array(z.object({ ...display, role: participationRoleSchema, assignmentId: id.nullable(), mockStatus: mockStatusSchema }).strict()), ...page }).strict() +export const batchListResponseSchema = z.object({ items: z.array(batchSummarySchema), ...page }).strict() +export const batchDetailResponseSchema = z.object({ batch: batchSummarySchema, items: z.array(z.object({ ...display, role: participationRoleSchema.nullable(), assignmentId: id.nullable(), status: attemptStatusSchema, reasonCode: reasonCodeSchema.nullable(), attemptNumber: z.number().int().positive().nullable(), startedAt: timestamp.nullable(), finishedAt: timestamp.nullable() }).strict()), ...page }).strict() +export type BatchSummary = z.infer +export type PreparedBatch = z.infer +export type CompetitionsResponse = z.infer +export type RecipientsResponse = z.infer +export type BatchListResponse = z.infer +export type BatchDetailResponse = z.infer diff --git a/src/modules/addons/data/validators.ts b/src/modules/addons/data/validators.ts new file mode 100644 index 0000000..adeb4d8 --- /dev/null +++ b/src/modules/addons/data/validators.ts @@ -0,0 +1,54 @@ +import { z } from 'zod' + +export const MAX_BATCH_RECIPIENTS = 1_000 +export const DRAFT_TTL_MS = 15 * 60 * 1_000 +export const addonKeySchema = z.literal('mercato_sandboxes') +export const deliveryModeSchema = z.enum(['mock', 'real']) +export const participationRoleSchema = z.enum(['participant', 'mentor', 'judge']) +export const batchStatusSchema = z.enum(['draft', 'completed', 'completed_with_errors', 'cancelled', 'expired']) +export const attemptStatusSchema = z.enum(['selected', 'pending', 'succeeded', 'failed', 'skipped', 'cancelled', 'expired']) +export const reasonCodeSchema = z.enum(['mock_failure', 'already_succeeded', 'already_pending', 'no_longer_eligible', 'assignment_unavailable']) +export const selectionKindSchema = z.enum(['explicit', 'all_filtered']) +export const uuidSchema = z.string().uuid().transform((value) => value.toLowerCase()) + +const rolesSchema = z.array(participationRoleSchema).max(100).transform((roles) => [...new Set(roles)].sort()) +const selectionSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('explicit'), + // The service rejects empty/oversize normalized sets with the specified domain errors. + customerUserIds: z.array(uuidSchema).max(10_000).transform((ids) => [...new Set(ids)].sort()), + roles: rolesSchema, + }).strict(), + z.object({ kind: z.literal('all_filtered'), roles: rolesSchema }).strict(), +]) + +export const prepareBatchSchema = z.object({ + requestId: uuidSchema, + competitionId: uuidSchema, + mode: z.literal('mock'), + selection: selectionSchema, +}).strict() + +const positiveIntegerQuerySchema = z.union([ + z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + z.string().regex(/^[1-9]\d*$/).transform(Number).pipe(z.number().int().positive().max(Number.MAX_SAFE_INTEGER)), +]) +export const paginationSchema = z.object({ + page: positiveIntegerQuerySchema.default(1), + pageSize: positiveIntegerQuerySchema.pipe(z.number().max(100)).default(25), +}).strict() +export const competitionsQuerySchema = paginationSchema +export const batchesQuerySchema = paginationSchema.extend({ competitionId: uuidSchema }) +export const recipientsQuerySchema = batchesQuerySchema.extend({ roles: rolesSchema.default([]) }) +export const emptyMutationSchema = z.object({}).strict() + +export type AddonKey = z.infer +export type DeliveryMode = z.infer +export type ParticipationRole = z.infer +export type BatchStatus = z.infer +export type AttemptStatus = z.infer +export type ReasonCode = z.infer +export type SelectionKind = z.infer +export type PrepareBatchInput = z.infer +export type Pagination = z.infer +export type RecipientsQuery = z.infer diff --git a/src/modules/addons/index.ts b/src/modules/addons/index.ts new file mode 100644 index 0000000..bb215cb --- /dev/null +++ b/src/modules/addons/index.ts @@ -0,0 +1,10 @@ +import type { ModuleInfo } from '@open-mercato/shared/modules/registry' + +export const metadata: ModuleInfo = { + name: 'addons', + title: 'Addons', + version: '0.1.0', + description: 'Competition-scoped addon assignments and persisted sandbox simulations.', +} + +export { features } from './acl' diff --git a/src/modules/addons/lib/__tests__/recipient-reader.test.ts b/src/modules/addons/lib/__tests__/recipient-reader.test.ts new file mode 100644 index 0000000..2ee65e3 --- /dev/null +++ b/src/modules/addons/lib/__tests__/recipient-reader.test.ts @@ -0,0 +1,99 @@ +import type { EntityManager } from '@mikro-orm/postgresql' +import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { createAddonRecipientReader } from '../recipient-reader' + +jest.mock('@open-mercato/core/modules/customer_accounts/data/entities', () => ({ CustomerUser: class CustomerUser {} })) +jest.mock('@open-mercato/shared/lib/encryption/find', () => ({ findWithDecryption: jest.fn() })) +const scope = { tenantId: '10000000-0000-4000-8000-000000000001', organizationId: '20000000-0000-4000-8000-000000000001' } +const competitionId = '30000000-0000-4000-8000-000000000001' +const customerId = '40000000-0000-4000-8000-000000000001' +const reader = createAddonRecipientReader() +function manager() { + const execute = jest.fn().mockResolvedValue([]) + const getTransactionContext = jest.fn().mockReturnValue({}) + return { execute, getTransactionContext, em: { execute, getTransactionContext } as unknown as EntityManager } +} + +beforeEach(() => jest.clearAllMocks()) + +test('empty frozen sets never produce invalid SQL or fetch account data', async () => { + const { em, execute } = manager() + expect(await reader.eligible(em, scope, competitionId, [], { customerUserIds: [] })).toEqual({ items: [], totalCount: 0 }) + expect(await reader.display(em, scope, [])).toEqual(new Map()) + expect(execute).not.toHaveBeenCalled() + expect(findWithDecryption).not.toHaveBeenCalled() +}) + +test('eligible query constrains both join scopes, active states, roles and competition and paginates in SQL', async () => { + const { em, execute } = manager() + execute.mockResolvedValueOnce([{ totalCount: '120' }]).mockResolvedValueOnce([{ customerUserId: customerId, role: 'mentor' }]) + expect(await reader.eligible(em, scope, competitionId, ['participant', 'mentor'], { limit: 25, offset: 100 })).toEqual({ items: [{ customerUserId: customerId, role: 'mentor' }], totalCount: 120 }) + const [sql, params] = execute.mock.calls[1] + for (const fragment of ['c.tenant_id = p.tenant_id', 'c.organization_id = p.organization_id', 'u.tenant_id = p.tenant_id', 'u.organization_id = p.organization_id', 'p.deleted_at is null', 'c.is_active = true', 'c.deleted_at is null', 'u.is_active = true', 'u.deleted_at is null', 'group by p.customer_user_id order by role, p.customer_user_id limit ? offset ?']) expect(sql).toContain(fragment) + expect(params).toEqual([scope.tenantId, scope.organizationId, competitionId, ['mentor', 'participant'], 25, 100]) + expect(execute.mock.calls[0][0]).toContain('count(distinct p.customer_user_id)') + expect(sql).not.toMatch(/email|display_name/) +}) + +test('empty roles means all valid roles and explicit ids are deduplicated', async () => { + const { em, execute } = manager() + await reader.eligible(em, scope, competitionId, [], { customerUserIds: [customerId, customerId] }) + expect(execute.mock.calls[0][1]).toEqual([scope.tenantId, scope.organizationId, competitionId, ['judge', 'mentor', 'participant'], [customerId]]) +}) + +test('competition selector supports later pages and filters exact scope', async () => { + const { em, execute } = manager() + execute.mockResolvedValueOnce([{ totalCount: '150' }]).mockResolvedValueOnce([{ id: competitionId, name: 'Competition' }]) + expect(await reader.competitions(em, scope, { page: 6, pageSize: 25 })).toMatchObject({ totalCount: 150, page: 6, pageSize: 25 }) + expect(execute.mock.calls[1][1]).toEqual([scope.tenantId, scope.organizationId, 25, 125]) + expect(execute.mock.calls[1][0]).toContain('order by name, id limit ? offset ?') +}) + +test('lock path locks invalidated rows in fixed order then rechecks eligible state', async () => { + const { em, execute } = manager() + execute.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: competitionId, name: 'Competition' }]) + await reader.eligible(em, scope, competitionId, ['mentor'], { customerUserIds: [customerId], lock: true }) + expect(execute.mock.calls[0][0]).toContain('competitions_competition') + expect(execute.mock.calls[0][0]).toContain('for update') + expect(execute.mock.calls[0][0]).not.toContain('is_active') + expect(execute.mock.calls[1][0]).toContain('is_active = true') + expect(execute.mock.calls[2][0]).toContain('competitions_participation') + expect(execute.mock.calls[2][0]).toContain('order by id for update') + expect(execute.mock.calls[2][0]).not.toContain('deleted_at') + expect(execute.mock.calls[3][0]).toContain('customer_users') + expect(execute.mock.calls[3][0]).toContain('order by id for update') + expect(execute.mock.calls[3][0]).not.toContain('is_active') + expect(execute.mock.calls[4][0]).toContain('u.is_active = true') +}) + +test('inactive competition prevents further locks', async () => { + const { em, execute } = manager() + expect(await reader.eligible(em, scope, competitionId, [], { lock: true, customerUserIds: [customerId] })).toEqual({ items: [], totalCount: 0 }) + expect(execute).toHaveBeenCalledTimes(2) +}) + +test('lock requests require transaction and bounded frozen membership', async () => { + const { em, execute, getTransactionContext } = manager() + await expect(reader.eligible(em, scope, competitionId, [], { lock: true })).rejects.toThrow('addons_frozen_selection_required') + getTransactionContext.mockReturnValue(undefined) + await expect(reader.eligible(em, scope, competitionId, [], { lock: true, customerUserIds: [customerId] })).rejects.toThrow('addons_transaction_required') + expect(execute).not.toHaveBeenCalled() +}) + +test('invalid scope, roles and page bounds are rejected before SQL', async () => { + const { em, execute } = manager() + await expect(reader.competition(em, { ...scope, organizationId: '' }, competitionId)).rejects.toThrow() + await expect(reader.eligible(em, scope, competitionId, ['admin'])).rejects.toThrow() + await expect(reader.eligible(em, scope, competitionId, [], { limit: 1002 })).rejects.toThrow() + await expect(reader.eligible(em, scope, competitionId, [], { offset: -1 })).rejects.toThrow() + await expect(reader.competitions(em, scope, { page: 1, pageSize: 101 })).rejects.toThrow() + await expect(reader.competitions(em, scope, { page: Number.MAX_SAFE_INTEGER, pageSize: 100 })).rejects.toThrow() + expect(execute).not.toHaveBeenCalled() +}) + +test('display resolves only scoped undeleted IDs through encryption helper', async () => { + const { em } = manager() + jest.mocked(findWithDecryption).mockResolvedValue([{ id: customerId, displayName: 'Name', email: 'safe@example.test' }]) + expect(await reader.display(em, scope, [customerId])).toEqual(new Map([[customerId, { displayName: 'Name', email: 'safe@example.test' }]])) + expect(findWithDecryption).toHaveBeenCalledWith(em, expect.any(Function), { ...scope, id: { $in: [customerId] }, deletedAt: null }, { fields: ['id', 'displayName', 'email', 'tenantId', 'organizationId'] }, scope) +}) diff --git a/src/modules/addons/lib/errors.ts b/src/modules/addons/lib/errors.ts new file mode 100644 index 0000000..0782f35 --- /dev/null +++ b/src/modules/addons/lib/errors.ts @@ -0,0 +1,6 @@ +export class AddonError extends Error { + constructor(readonly code: string, readonly status: number, readonly details: Record = {}) { + super(code) + this.name = 'AddonError' + } +} diff --git a/src/modules/addons/lib/guards.ts b/src/modules/addons/lib/guards.ts new file mode 100644 index 0000000..35a8c89 --- /dev/null +++ b/src/modules/addons/lib/guards.ts @@ -0,0 +1,50 @@ +import { isDeepStrictEqual } from 'node:util' +import { z } from 'zod' +import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard' +import { addonKeySchema, attemptStatusSchema, batchStatusSchema, deliveryModeSchema, participationRoleSchema, reasonCodeSchema, selectionKindSchema } from '../data/validators' +import type { AddonRequestContext } from './request-context' +import { AddonError } from './errors' + +const scoped = { id: z.string().uuid(), tenantId: z.string().uuid(), organizationId: z.string().uuid() } +export const assignmentWriteSchema = z.object({ + ...scoped, competitionId: z.string().uuid(), customerUserId: z.string().uuid(), addonKey: addonKeySchema, + assignedBy: z.string().uuid(), assignedAt: z.date(), +}).strict() +export const batchWriteSchema = z.object({ + ...scoped, competitionId: z.string().uuid(), addonKey: addonKeySchema, mode: deliveryModeSchema, + createdBy: z.string().uuid(), requestId: z.string().uuid(), selectionKind: selectionKindSchema, + roles: z.array(participationRoleSchema), requestedCustomerUserIds: z.array(z.string().uuid()).nullable(), + status: batchStatusSchema, expiresAt: z.date(), confirmedAt: z.date().nullable(), finishedAt: z.date().nullable(), + recipientCount: z.number().int().min(1).max(1000), succeededCount: z.number().int().nonnegative(), + failedCount: z.number().int().nonnegative(), skippedCount: z.number().int().nonnegative(), +}).strict() +export const attemptWriteSchema = z.object({ + ...scoped, batchId: z.string().uuid(), customerUserId: z.string().uuid(), assignmentId: z.string().uuid().nullable(), + mode: deliveryModeSchema, status: attemptStatusSchema, reasonCode: reasonCodeSchema.nullable(), + attemptNumber: z.number().int().positive().nullable(), startedAt: z.date().nullable(), finishedAt: z.date().nullable(), +}).strict() + +export async function guardWrite>>( + ctx: AddonRequestContext, callbacks: Array<() => Promise>, entity: string, + operation: 'create' | 'update', schema: T, source: z.input, +): Promise> { + const original = schema.parse(source) + const result = await runRouteMutationGuards({ + container: ctx.container, req: ctx.req, + auth: { userId: ctx.userId, tenantId: ctx.tenantId, organizationId: ctx.organizationId, userFeatures: ctx.userFeatures }, + input: { resourceKind: `addons:${entity}`, resourceId: String(original.id), operation, mutationPayload: original }, + }) + if (!result.ok) throw result.response + const parsed = schema.safeParse({ ...original, ...result.modifiedPayload }) + // All persisted fields are identity, frozen membership, execution evidence or scope. + // Changing any of them requires a fresh preview; guards may allow or block these writes. + if (!parsed.success || !isDeepStrictEqual(parsed.data, original)) throw new AddonError('selection_changed', 409) + callbacks.push(result.runAfterSuccess) + return parsed.data +} + +export async function afterCommit(callbacks: Array<() => Promise>) { + for (const callback of callbacks) { + try { await callback() } catch { console.error('[addons] guard_callback_failed') } + } +} diff --git a/src/modules/addons/lib/recipient-reader.ts b/src/modules/addons/lib/recipient-reader.ts new file mode 100644 index 0000000..43034b8 --- /dev/null +++ b/src/modules/addons/lib/recipient-reader.ts @@ -0,0 +1,92 @@ +import type { EntityManager } from '@mikro-orm/postgresql' +import { CustomerUser } from '@open-mercato/core/modules/customer_accounts/data/entities' +import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find' +import { z } from 'zod' +import { MAX_BATCH_RECIPIENTS, paginationSchema, participationRoleSchema, uuidSchema } from '../data/validators' + +const scopeSchema = z.object({ tenantId: uuidSchema, organizationId: uuidSchema }).strict() +export type AddonRecipientScope = z.infer +const competitionRowSchema = z.object({ id: uuidSchema, name: z.string() }) +const recipientRowSchema = z.object({ customerUserId: uuidSchema, role: participationRoleSchema }) +const countSchema = z.array(z.object({ totalCount: z.coerce.number().int().nonnegative().safe() })) +const optionsSchema = z.object({ + customerUserIds: z.array(uuidSchema).max(10_000).transform((ids) => [...new Set(ids)].sort()).optional(), + limit: z.number().int().min(1).max(MAX_BATCH_RECIPIENTS + 1).default(MAX_BATCH_RECIPIENTS + 1), + offset: z.number().int().nonnegative().safe().default(0), + lock: z.boolean().default(false), +}).strict() + +/** Read-only cross-module boundary. Raw queries deliberately never select account PII. */ +export function createAddonRecipientReader() { + async function competition(em: EntityManager, inputScope: AddonRecipientScope, inputId: string, options: { lock?: boolean } = {}) { + const scope = scopeSchema.parse(inputScope) + const id = uuidSchema.parse(inputId) + const params = [scope.tenantId, scope.organizationId, id] + if (options.lock) { + if (!em.getTransactionContext()) throw new Error('addons_transaction_required') + // Lock before testing active/deleted state, including records invalidated since preview. + await em.execute('select id from competitions_competition where tenant_id = ? and organization_id = ? and id = ? for update', params) + } + const rows = competitionRowSchema.array().parse(await em.execute( + 'select id, name from competitions_competition where tenant_id = ? and organization_id = ? and id = ? and is_active = true and deleted_at is null', params, + )) + return rows[0] ?? null + } + + return { + competition, + async competitions(em: EntityManager, inputScope: AddonRecipientScope, inputPage: { page: number; pageSize: number }) { + const scope = scopeSchema.parse(inputScope) + const { page, pageSize } = paginationSchema.parse(inputPage) + const offset = z.number().int().nonnegative().safe().parse((page - 1) * pageSize) + const where = 'from competitions_competition where tenant_id = ? and organization_id = ? and is_active = true and deleted_at is null' + const params = [scope.tenantId, scope.organizationId] + const counts = countSchema.parse(await em.execute(`select count(*) as "totalCount" ${where}`, params)) + const items = competitionRowSchema.array().parse(await em.execute(`select id, name ${where} order by name, id limit ? offset ?`, [...params, pageSize, offset])) + return { items, totalCount: counts[0]?.totalCount ?? 0, page, pageSize } + }, + async eligible(em: EntityManager, inputScope: AddonRecipientScope, inputCompetitionId: string, inputRoles: string[], inputOptions: z.input = {}) { + const scope = scopeSchema.parse(inputScope) + const competitionId = uuidSchema.parse(inputCompetitionId) + const roles = z.array(participationRoleSchema).parse(inputRoles) + const { customerUserIds, limit, offset, lock } = optionsSchema.parse(inputOptions) + if (lock && (!customerUserIds || customerUserIds.length > MAX_BATCH_RECIPIENTS)) throw new Error('addons_frozen_selection_required') + if (customerUserIds?.length === 0) return { items: [], totalCount: 0 } + if (lock) { + if (!await competition(em, scope, competitionId, { lock: true })) return { items: [], totalCount: 0 } + // Do not filter status/role before locking: revalidate against the final locked state. + await em.execute(`select id from competitions_participation + where tenant_id = ? and organization_id = ? and competition_id = ? and customer_user_id in (?) + order by id for update`, [scope.tenantId, scope.organizationId, competitionId, customerUserIds]) + await em.execute(`select id from customer_users where tenant_id = ? and organization_id = ? and id in (?) + order by id for update`, [scope.tenantId, scope.organizationId, customerUserIds]) + } + const where = `from competitions_participation p + join competitions_competition c on c.id = p.competition_id and c.tenant_id = p.tenant_id and c.organization_id = p.organization_id + join customer_users u on u.id = p.customer_user_id and u.tenant_id = p.tenant_id and u.organization_id = p.organization_id + where p.tenant_id = ? and p.organization_id = ? and p.competition_id = ? + and p.deleted_at is null and c.is_active = true and c.deleted_at is null + and u.is_active = true and u.deleted_at is null and p.role in (?) + ${customerUserIds ? 'and p.customer_user_id in (?)' : ''}` + const params: unknown[] = [scope.tenantId, scope.organizationId, competitionId, roles.length ? [...new Set(roles)].sort() : ['judge', 'mentor', 'participant']] + if (customerUserIds) params.push(customerUserIds) + const counts = countSchema.parse(await em.execute(`select count(distinct p.customer_user_id) as "totalCount" ${where}`, params)) + const items = recipientRowSchema.array().parse(await em.execute(`select p.customer_user_id as "customerUserId", min(p.role) as role ${where} + group by p.customer_user_id order by role, p.customer_user_id limit ? offset ?`, [...params, limit, offset])) + return { items, totalCount: counts[0]?.totalCount ?? 0 } + }, + async display(em: EntityManager, inputScope: AddonRecipientScope, inputIds: string[]) { + const scope = scopeSchema.parse(inputScope) + const ids = [...new Set(z.array(uuidSchema).max(100).parse(inputIds))] + const result = new Map() + if (!ids.length) return result + const users = await findWithDecryption(em, CustomerUser, { ...scope, id: { $in: ids }, deletedAt: null }, { + fields: ['id', 'displayName', 'email', 'tenantId', 'organizationId'], + }, scope) + for (const user of users) result.set(user.id, { displayName: user.displayName ?? null, email: user.email ?? null }) + return result + }, + } +} + +export type AddonRecipientReader = ReturnType diff --git a/src/modules/addons/lib/request-context.ts b/src/modules/addons/lib/request-context.ts new file mode 100644 index 0000000..8733271 --- /dev/null +++ b/src/modules/addons/lib/request-context.ts @@ -0,0 +1,30 @@ +import type { EntityManager } from '@mikro-orm/postgresql' +import type { AwilixContainer } from 'awilix' +import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server' +import { createRequestContainer } from '@open-mercato/shared/lib/di/container' +import { getSelectedOrganizationFromRequest, resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope' +import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService' +import { enabledModules } from '@/modules' +import { uuidSchema } from '../data/validators' +import { AddonError } from './errors' + +export type AddonRequestContext = { em: EntityManager; container: AwilixContainer; req: Request; tenantId: string; organizationId: string; userId: string; userFeatures: string[] } +export const addonReadFeatures = ['addons.view', 'competitions.participants.manage'] +export const addonWriteFeatures = [...addonReadFeatures, 'addons.send'] + +export async function resolveAddonContext(req: Request, write = false): Promise { + const auth = await getAuthFromRequest(req) + if (!auth?.sub || !auth.tenantId || auth.isApiKey || auth.type === 'customer') throw new AddonError('unauthorized', 401) + if (!['competitions', 'customer_accounts'].every((id) => enabledModules.some((entry) => entry.id === id))) throw new AddonError('dependency_unavailable', 503) + // An auth default organization does not constitute an explicit operator selection. + const selected = uuidSchema.safeParse(getSelectedOrganizationFromRequest(req)) + if (!selected.success) throw new AddonError('organization_required', 400) + const container = await createRequestContainer() + const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req, selectedId: selected.data, tenantId: auth.tenantId }) + if (scope.selectionRejected || scope.selectedId !== selected.data || scope.tenantId !== auth.tenantId || (scope.allowedIds !== null && !scope.allowedIds.includes(selected.data))) throw new AddonError('forbidden', 403) + const rbac = container.resolve('rbacService') + const featureScope = { tenantId: auth.tenantId, organizationId: selected.data } + if (!await rbac.userHasAllFeatures(auth.sub, write ? addonWriteFeatures : addonReadFeatures, featureScope)) throw new AddonError('forbidden', 403) + const userFeatures = await rbac.getGrantedFeatures(auth.sub, featureScope) + return { em: container.resolve('em'), container, req, tenantId: auth.tenantId, organizationId: selected.data, userId: auth.sub, userFeatures } +} diff --git a/src/modules/addons/lib/simulator.ts b/src/modules/addons/lib/simulator.ts new file mode 100644 index 0000000..04e6e0c --- /dev/null +++ b/src/modules/addons/lib/simulator.ts @@ -0,0 +1,13 @@ +import { z } from 'zod' +import { uuidSchema } from '../data/validators' + +const simulationSchema = z.object({ customerUserId: uuidSchema, attemptNumber: z.number().int().positive() }).strict() + +/** Pure mock outcome; no provider, invitation, notification or account mutation. */ +export function simulateSandbox(input: z.infer) { + const { customerUserId, attemptNumber } = simulationSchema.parse(input) + const fails = attemptNumber === 1 && Number.parseInt(customerUserId.replaceAll('-', '').slice(-2), 16) % 5 === 0 + return fails + ? { status: 'failed' as const, reasonCode: 'mock_failure' as const } + : { status: 'succeeded' as const, reasonCode: null } +} diff --git a/src/modules/addons/migrations/.snapshot-open-mercato.json b/src/modules/addons/migrations/.snapshot-open-mercato.json new file mode 100644 index 0000000..51264c8 --- /dev/null +++ b/src/modules/addons/migrations/.snapshot-open-mercato.json @@ -0,0 +1,1300 @@ +{ + "name": "public", + "namespaces": [ + "public" + ], + "tables": [ + { + "name": "addons_assignments", + "schema": "public", + "columns": { + "addon_key": { + "name": "addon_key", + "type": "varchar(64)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 64, + "precision": null, + "scale": null, + "default": "'mercato_sandboxes'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "assigned_by": { + "name": "assigned_by", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "competition_id": { + "name": "competition_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "customer_user_id": { + "name": "customer_user_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": "gen_random_uuid()", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "true", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "tenant_id", + "organization_id", + "competition_id", + "customer_user_id", + "addon_key" + ], + "composite": true, + "constraint": true, + "keyName": "addons_assignment_identity", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "id", + "tenant_id", + "organization_id", + "customer_user_id" + ], + "composite": true, + "constraint": true, + "keyName": "addons_assignment_scoped_id", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "assigned_by" + ], + "composite": false, + "constraint": false, + "keyName": "addons_assignments_assigned_by_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "competition_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_assignments_competition_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "customer_user_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_assignments_customer_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "organization_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_assignments_organization_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "addons_assignments_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "tenant_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_assignments_tenant_id_index", + "primary": false, + "unique": false + } + ], + "checks": [ + { + "name": "addons_assignment_key", + "expression": "addon_key = 'mercato_sandboxes'", + "definition": "check (addon_key = 'mercato_sandboxes')" + } + ], + "triggers": [], + "foreignKeys": {}, + "comment": null + }, + { + "name": "addons_delivery_attempts", + "schema": "public", + "columns": { + "assignment_id": { + "name": "assignment_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "attempt_number": { + "name": "attempt_number", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "batch_id": { + "name": "batch_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "customer_user_id": { + "name": "customer_user_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "gen_random_uuid()", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "true", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "mode": { + "name": "mode", + "type": "varchar(64)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 64, + "precision": null, + "scale": null, + "default": "'mock'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "reason_code": { + "name": "reason_code", + "type": "varchar(64)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 64, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "started_at": { + "name": "started_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "status": { + "name": "status", + "type": "varchar(64)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 64, + "precision": null, + "scale": null, + "default": "'selected'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "batch_id", + "customer_user_id" + ], + "composite": true, + "constraint": true, + "keyName": "addons_attempt_batch_customer", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "tenant_id", + "organization_id", + "assignment_id", + "mode" + ], + "composite": true, + "constraint": false, + "keyName": "addons_attempt_delivery_slot", + "primary": false, + "unique": true, + "where": "assignment_id is not null and status in ('pending', 'succeeded')" + }, + { + "columnNames": [ + "tenant_id", + "organization_id", + "assignment_id", + "mode", + "status" + ], + "composite": true, + "constraint": false, + "keyName": "addons_attempt_evidence", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "assignment_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_delivery_attempts_assignment_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "batch_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_delivery_attempts_batch_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "customer_user_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_delivery_attempts_customer_user_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "organization_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_delivery_attempts_organization_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "addons_delivery_attempts_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "tenant_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_delivery_attempts_tenant_id_index", + "primary": false, + "unique": false + } + ], + "checks": [ + { + "name": "addons_attempt_mode", + "expression": "mode in ('mock', 'real')", + "definition": "check (mode in ('mock', 'real'))" + }, + { + "name": "addons_attempt_number", + "expression": "attempt_number is null or attempt_number > 0", + "definition": "check (attempt_number is null or attempt_number > 0)" + }, + { + "name": "addons_attempt_outcome", + "expression": "(status = 'selected' and assignment_id is null and reason_code is null and attempt_number is null and started_at is null and finished_at is null)\nor (status = 'pending' and assignment_id is not null and reason_code is null and attempt_number is null and started_at is not null and finished_at is null)\nor (status = 'succeeded' and assignment_id is not null and reason_code is null and attempt_number is not null and started_at is not null and finished_at is not null)\nor (status = 'failed' and assignment_id is not null and reason_code is not null and reason_code = 'mock_failure' and mode = 'mock' and attempt_number is not null and started_at is not null and finished_at is not null)\nor (status = 'skipped' and reason_code is not null and reason_code in ('already_succeeded', 'already_pending', 'no_longer_eligible', 'assignment_unavailable') and attempt_number is null and started_at is null and finished_at is not null and ((reason_code = 'no_longer_eligible' and assignment_id is null) or (reason_code <> 'no_longer_eligible' and assignment_id is not null)))\nor (status in ('cancelled', 'expired') and assignment_id is null and reason_code is null and attempt_number is null and started_at is null and finished_at is not null)", + "definition": "check ((status = 'selected' and assignment_id is null and reason_code is null and attempt_number is null and started_at is null and finished_at is null)\nor (status = 'pending' and assignment_id is not null and reason_code is null and attempt_number is null and started_at is not null and finished_at is null)\nor (status = 'succeeded' and assignment_id is not null and reason_code is null and attempt_number is not null and started_at is not null and finished_at is not null)\nor (status = 'failed' and assignment_id is not null and reason_code is not null and reason_code = 'mock_failure' and mode = 'mock' and attempt_number is not null and started_at is not null and finished_at is not null)\nor (status = 'skipped' and reason_code is not null and reason_code in ('already_succeeded', 'already_pending', 'no_longer_eligible', 'assignment_unavailable') and attempt_number is null and started_at is null and finished_at is not null and ((reason_code = 'no_longer_eligible' and assignment_id is null) or (reason_code <> 'no_longer_eligible' and assignment_id is not null)))\nor (status in ('cancelled', 'expired') and assignment_id is null and reason_code is null and attempt_number is null and started_at is null and finished_at is not null))" + }, + { + "name": "addons_attempt_status", + "expression": "status in ('selected', 'pending', 'succeeded', 'failed', 'skipped', 'cancelled', 'expired')", + "definition": "check (status in ('selected', 'pending', 'succeeded', 'failed', 'skipped', 'cancelled', 'expired'))" + } + ], + "triggers": [], + "foreignKeys": { + "addons_attempt_assignment_scope_fk": { + "columnNames": [ + "assignment_id", + "tenant_id", + "organization_id", + "customer_user_id" + ], + "constraintName": "addons_attempt_assignment_scope_fk", + "localTableName": "public.addons_delivery_attempts", + "referencedColumnNames": [ + "id", + "tenant_id", + "organization_id", + "customer_user_id" + ], + "referencedTableName": "public.addons_assignments", + "updateRule": "restrict", + "deleteRule": "restrict" + }, + "addons_attempt_batch_scope_fk": { + "columnNames": [ + "batch_id", + "tenant_id", + "organization_id", + "mode" + ], + "constraintName": "addons_attempt_batch_scope_fk", + "localTableName": "public.addons_delivery_attempts", + "referencedColumnNames": [ + "id", + "tenant_id", + "organization_id", + "mode" + ], + "referencedTableName": "public.addons_delivery_batches", + "updateRule": "restrict", + "deleteRule": "restrict" + } + }, + "comment": null + }, + { + "name": "addons_delivery_batches", + "schema": "public", + "columns": { + "addon_key": { + "name": "addon_key", + "type": "varchar(64)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 64, + "precision": null, + "scale": null, + "default": "'mercato_sandboxes'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "competition_id": { + "name": "competition_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "created_at": { + "name": "created_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "failed_count": { + "name": "failed_count", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "0", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + }, + "id": { + "name": "id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": true, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": "gen_random_uuid()", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "true", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "boolean" + }, + "mode": { + "name": "mode", + "type": "varchar(64)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 64, + "precision": null, + "scale": null, + "default": "'mock'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "recipient_count": { + "name": "recipient_count", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "requested_customer_user_ids": { + "name": "requested_customer_user_ids", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": true, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "roles": { + "name": "roles", + "type": "jsonb", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "json" + }, + "selection_kind": { + "name": "selection_kind", + "type": "varchar(64)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 64, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "skipped_count": { + "name": "skipped_count", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "0", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "status": { + "name": "status", + "type": "varchar(64)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 64, + "precision": null, + "scale": null, + "default": "'draft'", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "string" + }, + "succeeded_count": { + "name": "succeeded_count", + "type": "int", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": null, + "precision": null, + "scale": null, + "default": "0", + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "integer" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": true, + "length": null, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "uuid" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamptz(6)", + "unsigned": false, + "autoincrement": false, + "primary": false, + "nullable": false, + "unique": false, + "length": 6, + "precision": null, + "scale": null, + "default": null, + "comment": null, + "collation": null, + "enumItems": [], + "mappedType": "datetime" + } + }, + "indexes": [ + { + "columnNames": [ + "tenant_id", + "organization_id", + "competition_id", + "created_at", + "id" + ], + "composite": true, + "constraint": false, + "keyName": "addons_batch_history", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "tenant_id", + "organization_id", + "request_id" + ], + "composite": true, + "constraint": true, + "keyName": "addons_batch_request", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "id", + "tenant_id", + "organization_id", + "mode" + ], + "composite": true, + "constraint": true, + "keyName": "addons_batch_scoped_id", + "primary": false, + "unique": true + }, + { + "columnNames": [ + "competition_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_delivery_batches_competition_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "created_by" + ], + "composite": false, + "constraint": false, + "keyName": "addons_delivery_batches_created_by_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "organization_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_delivery_batches_organization_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "id" + ], + "composite": false, + "constraint": true, + "keyName": "addons_delivery_batches_pkey", + "primary": true, + "unique": true + }, + { + "columnNames": [ + "request_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_delivery_batches_request_id_index", + "primary": false, + "unique": false + }, + { + "columnNames": [ + "tenant_id" + ], + "composite": false, + "constraint": false, + "keyName": "addons_delivery_batches_tenant_id_index", + "primary": false, + "unique": false + } + ], + "checks": [ + { + "name": "addons_batch_counts", + "expression": "recipient_count between 1 and 1000 and succeeded_count >= 0 and failed_count >= 0 and skipped_count >= 0 and succeeded_count + failed_count + skipped_count <= recipient_count", + "definition": "check (recipient_count between 1 and 1000 and succeeded_count >= 0 and failed_count >= 0 and skipped_count >= 0 and succeeded_count + failed_count + skipped_count <= recipient_count)" + }, + { + "name": "addons_batch_key", + "expression": "addon_key = 'mercato_sandboxes'", + "definition": "check (addon_key = 'mercato_sandboxes')" + }, + { + "name": "addons_batch_mode", + "expression": "mode in ('mock', 'real')", + "definition": "check (mode in ('mock', 'real'))" + }, + { + "name": "addons_batch_outcome", + "expression": "(status in ('completed', 'completed_with_errors') and succeeded_count + failed_count + skipped_count = recipient_count and confirmed_at is not null and finished_at is not null and ((status = 'completed' and failed_count = 0) or (status = 'completed_with_errors' and failed_count > 0))) or (status in ('draft', 'cancelled', 'expired') and succeeded_count = 0 and failed_count = 0 and skipped_count = 0 and confirmed_at is null and ((status = 'draft' and finished_at is null) or (status in ('cancelled', 'expired') and finished_at is not null)))", + "definition": "check ((status in ('completed', 'completed_with_errors') and succeeded_count + failed_count + skipped_count = recipient_count and confirmed_at is not null and finished_at is not null and ((status = 'completed' and failed_count = 0) or (status = 'completed_with_errors' and failed_count > 0))) or (status in ('draft', 'cancelled', 'expired') and succeeded_count = 0 and failed_count = 0 and skipped_count = 0 and confirmed_at is null and ((status = 'draft' and finished_at is null) or (status in ('cancelled', 'expired') and finished_at is not null))))" + }, + { + "name": "addons_batch_roles", + "expression": "jsonb_typeof(roles) = 'array' and roles <@ '[\"participant\",\"mentor\",\"judge\"]'::jsonb", + "definition": "check (jsonb_typeof(roles) = 'array' and roles <@ '[\"participant\",\"mentor\",\"judge\"]'::jsonb)" + }, + { + "name": "addons_batch_selection", + "expression": "(selection_kind = 'explicit' and requested_customer_user_ids is not null and jsonb_typeof(requested_customer_user_ids) = 'array') or (selection_kind = 'all_filtered' and requested_customer_user_ids is null)", + "definition": "check ((selection_kind = 'explicit' and requested_customer_user_ids is not null and jsonb_typeof(requested_customer_user_ids) = 'array') or (selection_kind = 'all_filtered' and requested_customer_user_ids is null))" + }, + { + "name": "addons_batch_status", + "expression": "status in ('draft', 'completed', 'completed_with_errors', 'cancelled', 'expired')", + "definition": "check (status in ('draft', 'completed', 'completed_with_errors', 'cancelled', 'expired'))" + } + ], + "triggers": [], + "foreignKeys": {}, + "comment": null + } + ], + "views": [], + "nativeEnums": {} +} \ No newline at end of file diff --git a/src/modules/addons/migrations/Migration20260915095357_addons.ts b/src/modules/addons/migrations/Migration20260915095357_addons.ts new file mode 100644 index 0000000..644855a --- /dev/null +++ b/src/modules/addons/migrations/Migration20260915095357_addons.ts @@ -0,0 +1,55 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260915095357_addons extends Migration { + + override name = 'Migration20260915095357'; + + override up(): void | Promise { + this.addSql(`create table "addons_assignments" ("id" uuid not null default gen_random_uuid(), "tenant_id" uuid not null, "organization_id" uuid not null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "is_active" boolean not null default true, "deleted_at" timestamptz null, "competition_id" uuid not null, "customer_user_id" uuid not null, "addon_key" varchar(64) not null default 'mercato_sandboxes', "assigned_at" timestamptz not null, "assigned_by" uuid not null, primary key ("id"));`); + this.addSql(`create index "addons_assignments_tenant_id_index" on "addons_assignments" ("tenant_id");`); + this.addSql(`create index "addons_assignments_organization_id_index" on "addons_assignments" ("organization_id");`); + this.addSql(`create index "addons_assignments_competition_id_index" on "addons_assignments" ("competition_id");`); + this.addSql(`create index "addons_assignments_customer_user_id_index" on "addons_assignments" ("customer_user_id");`); + this.addSql(`create index "addons_assignments_assigned_by_index" on "addons_assignments" ("assigned_by");`); + this.addSql(`alter table "addons_assignments" add constraint "addons_assignment_scoped_id" unique ("id", "tenant_id", "organization_id", "customer_user_id");`); + this.addSql(`alter table "addons_assignments" add constraint "addons_assignment_identity" unique ("tenant_id", "organization_id", "competition_id", "customer_user_id", "addon_key");`); + this.addSql(`alter table "addons_assignments" add constraint "addons_assignment_key" check (addon_key = 'mercato_sandboxes');`); + + this.addSql(`create table "addons_delivery_attempts" ("id" uuid not null default gen_random_uuid(), "tenant_id" uuid not null, "organization_id" uuid not null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "is_active" boolean not null default true, "deleted_at" timestamptz null, "batch_id" uuid not null, "customer_user_id" uuid not null, "assignment_id" uuid null, "mode" varchar(64) not null default 'mock', "status" varchar(64) not null default 'selected', "reason_code" varchar(64) null, "started_at" timestamptz null, "finished_at" timestamptz null, "attempt_number" int null, primary key ("id"));`); + this.addSql(`create index "addons_delivery_attempts_tenant_id_index" on "addons_delivery_attempts" ("tenant_id");`); + this.addSql(`create index "addons_delivery_attempts_organization_id_index" on "addons_delivery_attempts" ("organization_id");`); + this.addSql(`create index "addons_delivery_attempts_batch_id_index" on "addons_delivery_attempts" ("batch_id");`); + this.addSql(`create index "addons_delivery_attempts_customer_user_id_index" on "addons_delivery_attempts" ("customer_user_id");`); + this.addSql(`create index "addons_delivery_attempts_assignment_id_index" on "addons_delivery_attempts" ("assignment_id");`); + this.addSql(`create index "addons_attempt_evidence" on "addons_delivery_attempts" ("tenant_id", "organization_id", "assignment_id", "mode", "status");`); + this.addSql(`create unique index "addons_attempt_delivery_slot" on "addons_delivery_attempts" ("tenant_id", "organization_id", "assignment_id", "mode") where assignment_id is not null and status in ('pending', 'succeeded');`); + this.addSql(`alter table "addons_delivery_attempts" add constraint "addons_attempt_batch_customer" unique ("batch_id", "customer_user_id");`); + this.addSql(`alter table "addons_delivery_attempts" add constraint "addons_attempt_outcome" check ((status = 'selected' and assignment_id is null and reason_code is null and attempt_number is null and started_at is null and finished_at is null) +or (status = 'pending' and assignment_id is not null and reason_code is null and attempt_number is null and started_at is not null and finished_at is null) +or (status = 'succeeded' and assignment_id is not null and reason_code is null and attempt_number is not null and started_at is not null and finished_at is not null) +or (status = 'failed' and assignment_id is not null and reason_code is not null and reason_code = 'mock_failure' and mode = 'mock' and attempt_number is not null and started_at is not null and finished_at is not null) +or (status = 'skipped' and reason_code is not null and reason_code in ('already_succeeded', 'already_pending', 'no_longer_eligible', 'assignment_unavailable') and attempt_number is null and started_at is null and finished_at is not null and ((reason_code = 'no_longer_eligible' and assignment_id is null) or (reason_code <> 'no_longer_eligible' and assignment_id is not null))) +or (status in ('cancelled', 'expired') and assignment_id is null and reason_code is null and attempt_number is null and started_at is null and finished_at is not null));`); + this.addSql(`alter table "addons_delivery_attempts" add constraint "addons_attempt_number" check (attempt_number is null or attempt_number > 0);`); + this.addSql(`alter table "addons_delivery_attempts" add constraint "addons_attempt_status" check (status in ('selected', 'pending', 'succeeded', 'failed', 'skipped', 'cancelled', 'expired'));`); + this.addSql(`alter table "addons_delivery_attempts" add constraint "addons_attempt_mode" check (mode in ('mock', 'real'));`); + + this.addSql(`create table "addons_delivery_batches" ("id" uuid not null default gen_random_uuid(), "tenant_id" uuid not null, "organization_id" uuid not null, "created_at" timestamptz not null, "updated_at" timestamptz not null, "is_active" boolean not null default true, "deleted_at" timestamptz null, "competition_id" uuid not null, "addon_key" varchar(64) not null default 'mercato_sandboxes', "mode" varchar(64) not null default 'mock', "created_by" uuid not null, "request_id" uuid not null, "selection_kind" varchar(64) not null, "roles" jsonb not null, "requested_customer_user_ids" jsonb null, "status" varchar(64) not null default 'draft', "expires_at" timestamptz not null, "confirmed_at" timestamptz null, "finished_at" timestamptz null, "recipient_count" int not null, "succeeded_count" int not null default 0, "failed_count" int not null default 0, "skipped_count" int not null default 0, primary key ("id"));`); + this.addSql(`create index "addons_delivery_batches_tenant_id_index" on "addons_delivery_batches" ("tenant_id");`); + this.addSql(`create index "addons_delivery_batches_organization_id_index" on "addons_delivery_batches" ("organization_id");`); + this.addSql(`create index "addons_delivery_batches_competition_id_index" on "addons_delivery_batches" ("competition_id");`); + this.addSql(`create index "addons_delivery_batches_created_by_index" on "addons_delivery_batches" ("created_by");`); + this.addSql(`create index "addons_delivery_batches_request_id_index" on "addons_delivery_batches" ("request_id");`); + this.addSql(`create index "addons_batch_history" on "addons_delivery_batches" ("tenant_id", "organization_id", "competition_id", "created_at", "id");`); + this.addSql(`alter table "addons_delivery_batches" add constraint "addons_batch_scoped_id" unique ("id", "tenant_id", "organization_id", "mode");`); + this.addSql(`alter table "addons_delivery_batches" add constraint "addons_batch_request" unique ("tenant_id", "organization_id", "request_id");`); + this.addSql(`alter table "addons_delivery_batches" add constraint "addons_batch_outcome" check ((status in ('completed', 'completed_with_errors') and succeeded_count + failed_count + skipped_count = recipient_count and confirmed_at is not null and finished_at is not null and ((status = 'completed' and failed_count = 0) or (status = 'completed_with_errors' and failed_count > 0))) or (status in ('draft', 'cancelled', 'expired') and succeeded_count = 0 and failed_count = 0 and skipped_count = 0 and confirmed_at is null and ((status = 'draft' and finished_at is null) or (status in ('cancelled', 'expired') and finished_at is not null))));`); + this.addSql(`alter table "addons_delivery_batches" add constraint "addons_batch_counts" check (recipient_count between 1 and 1000 and succeeded_count >= 0 and failed_count >= 0 and skipped_count >= 0 and succeeded_count + failed_count + skipped_count <= recipient_count);`); + this.addSql(`alter table "addons_delivery_batches" add constraint "addons_batch_selection" check ((selection_kind = 'explicit' and requested_customer_user_ids is not null and jsonb_typeof(requested_customer_user_ids) = 'array') or (selection_kind = 'all_filtered' and requested_customer_user_ids is null));`); + this.addSql(`alter table "addons_delivery_batches" add constraint "addons_batch_roles" check (jsonb_typeof(roles) = 'array' and roles <@ '["participant","mentor","judge"]'::jsonb);`); + this.addSql(`alter table "addons_delivery_batches" add constraint "addons_batch_status" check (status in ('draft', 'completed', 'completed_with_errors', 'cancelled', 'expired'));`); + this.addSql(`alter table "addons_delivery_batches" add constraint "addons_batch_mode" check (mode in ('mock', 'real'));`); + this.addSql(`alter table "addons_delivery_batches" add constraint "addons_batch_key" check (addon_key = 'mercato_sandboxes');`); + } + +} diff --git a/src/modules/addons/migrations/Migration20260915100142_addons.ts b/src/modules/addons/migrations/Migration20260915100142_addons.ts new file mode 100644 index 0000000..2a20a44 --- /dev/null +++ b/src/modules/addons/migrations/Migration20260915100142_addons.ts @@ -0,0 +1,17 @@ +import { Migration } from '@mikro-orm/migrations'; + +export class Migration20260915100142_addons extends Migration { + + override name = 'Migration20260915100142'; + + override up(): void | Promise { + this.addSql(`alter table "addons_delivery_attempts" add constraint "addons_attempt_batch_scope_fk" foreign key ("batch_id", "tenant_id", "organization_id", "mode") references "addons_delivery_batches" ("id", "tenant_id", "organization_id", "mode") on update restrict on delete restrict;`); + this.addSql(`alter table "addons_delivery_attempts" add constraint "addons_attempt_assignment_scope_fk" foreign key ("assignment_id", "tenant_id", "organization_id", "customer_user_id") references "addons_assignments" ("id", "tenant_id", "organization_id", "customer_user_id") on update restrict on delete restrict;`); + } + + override down(): void | Promise { + this.addSql(`alter table "addons_delivery_attempts" drop constraint if exists "addons_attempt_batch_scope_fk";`); + this.addSql(`alter table "addons_delivery_attempts" drop constraint if exists "addons_attempt_assignment_scope_fk";`); + } + +} diff --git a/src/modules/addons/setup.ts b/src/modules/addons/setup.ts new file mode 100644 index 0000000..2758bd1 --- /dev/null +++ b/src/modules/addons/setup.ts @@ -0,0 +1,10 @@ +import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup' + +export const setup: ModuleSetupConfig = { + defaultRoleFeatures: { + superadmin: ['addons.view', 'addons.send'], + admin: ['addons.view', 'addons.send'], + }, +} + +export default setup