Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 235 additions & 0 deletions .ai/specs/2026-09-15-competition-addons-mercato-sandboxes.md

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions .ai/specs/briefs/2026-09-15-competition-addons.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions openmercato.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version = 2

[preview]
command = ["env", "OM_DEV_INOTIFY_CHECK=0", "OM_DEV_BUNDLER=turbopack", "yarn", "dev", "--watch=auto-optimized"]
1 change: 1 addition & 0 deletions src/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
6 changes: 6 additions & 0 deletions src/modules/addons/acl.ts
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions src/modules/addons/api/competitions/route.ts
Original file line number Diff line number Diff line change
@@ -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 } } }
62 changes: 62 additions & 0 deletions src/modules/addons/api/helpers.ts
Original file line number Diff line number Diff line change
@@ -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<string> { return pathParamsSchema.parse(await context.params).id.toLowerCase() }
export function queryInput(req: Request, repeatedRoles = false): Record<string, unknown> {
const result: Record<string, unknown> = 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<unknown> {
// 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<T>(schema: z.ZodType<T>, 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<Response>): Promise<Response> {
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>('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.' }))
Original file line number Diff line number Diff line change
@@ -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 } } }
Original file line number Diff line number Diff line change
@@ -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 } } }
11 changes: 11 additions & 0 deletions src/modules/addons/api/mercato-sandboxes/batches/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 } } }
19 changes: 19 additions & 0 deletions src/modules/addons/api/mercato-sandboxes/batches/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
11 changes: 11 additions & 0 deletions src/modules/addons/api/mercato-sandboxes/recipients/route.ts
Original file line number Diff line number Diff line change
@@ -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 } } }
Loading