Skip to content

feat(backend): Cascade Engine — automatic order-to-venture assignment #219

Description

@masch

Context

The backend currently creates orders in SEARCHING status, and PATCH /orders/:id/status allows transitions. But the Cascade Engine that bridges these two points — assigning a venture, offering the order, and handling the response — does not exist.

This issue covers the ENTIRE flow defined in openspec/specs/01-master-system.md §3.1.


Full Flow Architecture

POST /orders (tourist)
  → status = SEARCHING, no venture
        ↓
╔══════════════════════════════════╗
║     CASCADE ENGINE (new)        ║
╠══════════════════════════════════╣
║ 1. Iterate ventures by cascade   ║
║ 2. Filter: active, capacity,     ║
║    hours, pauses                ║
║ 3. Assign first valid venture   ║
║ 4. status = OFFER_PENDING       ║
║ 5. Cascade_Assignment + deadline ║
╚══════════════════════════════════╝
        ↓
GET /orders/pending (entrepreneur)
  → sees offer with deadline
        ↓
┌─ POST /orders/:id/accept ─┐
│   → CONFIRMED              │
└────────────────────────────┘
┌─ POST /orders/:id/reject ──┐
│   → SEARCHING → cascade     │
│     continues to next       │
└─────────────────────────────┘
┌─ Timeout 30s ──────────────┐
│   → auto-assigns to next   │
│   available venture         │
└─────────────────────────────┘

Implementation Tasks

T-001: Cascade_Assignment — DB Schema

Create cascade_assignments table in Drizzle:

// apps/backend/src/db/schema/cascade-assignments.ts
export const cascadeAssignments = pgTable("cascade_assignments", {
  zzz_id: uuid("zzz_id").defaultRandom().primaryKey(),
  zzz_order_id: uuid("zzz_order_id").notNull().references(() => orders.zzz_id),
  zzz_venture_id: integer("zzz_venture_id").notNull().references(() => ventures.id),
  zzz_offer_status: pgEnum("offer_status", [
    "WAITING_FOR_RESPONSE", "ACCEPTED", "REJECTED", "TIMEOUT", "SKIPPED",
  ])().notNull().default("WAITING_FOR_RESPONSE"),
  zzz_skip_reason: pgEnum("skip_reason", [
    "VENTURE_INACTIVE", "GENERAL_PAUSE", "INDIVIDUAL_PAUSE",
    "CAPACITY_EXCEEDED", "CLOSED_THAT_DAY", "OUTSIDE_OPENING_HOURS",
    "OUTSIDE_MOMENT_HOURS", "NOT_OFFERED",
  ])(),
  zzz_response_deadline: timestamp("response_deadline", { withTimezone: true }),
  zzz_cascade_step: integer("cascade_step").notNull(),
  zzz_created_at: timestamp("zzz_created_at", { withTimezone: true }).defaultNow().notNull(),
  zzz_resolved_at: timestamp("zzz_resolved_at", { withTimezone: true }),
});

Relations: cascade_assignments → orders (N:1), cascade_assignments → ventures (N:1).

Include Drizzle migration.

T-002: CascadeEngine — Core Service

Create apps/backend/src/services/cascade-engine.service.ts with these methods:

2a. startCascade(db, order, catalogTypeId)

Executed after creating the order (POST /orders):

  1. Get ventures for the project that match the catalog_type_id
  2. Sort by cascade_order ascending
  3. Iterate running filterVenture() for each
  4. First venture that passes: call offerToVenture()
  5. If none passes: mark order as EXPIRED with cancel_reason = NO_VENTURE_AVAILABLE

2b. filterVenture(db, venture, order)

Validations in order:

  • venture.is_active === true → skip VENTURE_INACTIVE
  • venture.is_paused === false → skip GENERAL_PAUSE
  • Item not in venture_paused_items → skip INDIVIDUAL_PAUSE
  • (current_occupation + guest_count) <= venture.max_capacity → skip CAPACITY_EXCEEDED
  • Venture open that day per opening_hours → skip CLOSED_THAT_DAY
  • Time within moment range (BREAKFAST/LUNCH/etc.) → skip OUTSIDE_MOMENT_HOURS

Current occupation: sum guest_count of all CONFIRMED orders with confirmed_venture_id = venture and same service_at.

2c. offerToVenture(db, order, venture, step)

  1. Create Cascade_Assignment with offer_status = WAITING_FOR_RESPONSE
  2. Set response_deadline = now + project.cascade_timeout_minutes (from projects table)
  3. Update order: status = OFFER_PENDING, current_offer_venture_id = venture.id

2d. continueCascade(db, order)

When a venture rejects or times out:

  1. Find the next venture in cascade_order (skip current one)
  2. Call filterVenture() + offerToVenture() for each
  3. If max cascade steps reached → EXPIRED

2e. processTimeouts(db)

Executed by the Timeout Processor:

  1. Find Cascade_Assignment where offer_status = WAITING_FOR_RESPONSE AND response_deadline < now()
  2. Mark as TIMEOUT
  3. Call continueCascade()

T-003: New Endpoints (routes/orders.ts)

3a. GET /orders/pending

  • Role: ENTREPRENEUR
  • Returns Cascade_Assignments with offer_status = WAITING_FOR_RESPONSE for the entrepreneur's ventures
  • Include: order data, items, customer alias, deadline, remaining_minutes
  • Scope: only assignments where the entrepreneur is a venture member

3b. POST /orders/:id/accept

  • Role: ENTREPRENEUR
  • Validate: order.status === "OFFER_PENDING"
  • Validate: current_offer_venture_id is in entrepreneur's ventures
  • Validate: order hasn't expired (check deadline)
  • Atomic transaction: UPDATE order SET status = "CONFIRMED", confirmed_venture_id = current_offer_venture_id
  • Cascade_Assignment → ACCEPTED
  • Handle race conditions: if another entrepreneur accepted first → HTTP 409

3c. POST /orders/:id/reject

  • Role: ENTREPRENEUR
  • Validate: order.status === "OFFER_PENDING"
  • Cascade_Assignment → REJECTED
  • Call continueCascade() for next venture
  • HTTP 200 with next_venture_triggered: true/false

T-004: Timeout Processor

Create apps/backend/src/services/timeout-processor.service.ts:

// MVP: setInterval every 30 seconds within the Hono process
export function startTimeoutProcessor(db: Db) {
  setInterval(async () => {
    await CascadeEngine.processTimeouts(db);
  }, 30_000);
}

Call from apps/backend/src/index.ts on server start.

Include cleanup: clearInterval on graceful shutdown (SIGTERM/SIGINT).

T-005: Modify POST /orders (existing)

When creating an order, instead of just returning { status: "SEARCHING" }, also trigger the cascade engine:

// In OrderService.create(), after insert:
const order = await tx.insert(orders).values({ ... }).returning();
// Don't await — cascade engine runs in background
CascadeEngine.startCascade(db, order, catalogTypeId).catch(logger.error);
return order;

The order is returned to the client as SEARCHING. The cascade engine runs in the background.

T-006: Seed Data

Update seed scripts to include:

  • Ventures with cascade_order, is_active, is_paused, max_capacity, opening_hours
  • Venture memberships (entrepreneur → venture relation)
  • Project config with cascade_timeout_minutes, max_cascade_steps

T-007: Tests

  • Unit: CascadeEngine — filterVenture with each skip reason, startCascade with 1/2/N ventures, continueCascade, processTimeouts
  • Integration: POST /orders → cascade starts → GET /orders/pending → accept/reject → correct final state
  • Race condition: simulate simultaneous accept → only one wins

Affected Files

File Action
apps/backend/src/db/schema/cascade-assignments.ts New — Drizzle table
apps/backend/src/db/schema/orders.ts Modify — add zzz_cascade_snapshot?
apps/backend/src/db/schema/index.ts Modify — export cascade-assignments
apps/backend/src/services/cascade-engine.service.ts New — core algorithm
apps/backend/src/services/timeout-processor.service.ts New — background job
apps/backend/src/services/order.service.ts Modify — create triggers cascade
apps/backend/src/routes/orders.ts Modify — add /pending, /accept, /reject
apps/backend/src/index.ts Modify — start timeout processor
apps/backend/seeds/ Modify — venture config, cascade config
packages/shared/src/types/ Modify — CascadeAssignment, OfferStatus, SkipReason
packages/shared/src/validators/ New — AcceptInput, RejectInput schemas

Notes

  • MVP vs POST-MVP: This covers the full MVP per spec §3.1. POST-MVP would be: BullMQ + Redis instead of setInterval, full offer/wait/timeout per venture cycle, frozen cascade_snapshot, idempotency keys
  • Order items constraint: Spec §3.3.1 says items: max 1 — cascade engine iterates ventures filtered by catalog_type_id of the item
  • Capacity unit: Always guest_count (people), not item quantity
  • Offline: MVP does not include offline handling. If the entrepreneur is offline when the timeout expires, cascade continues regardless

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    Status
    In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions