plans
2026-05-03
2026 05 03 Foundation Plan

Foundation Implementation Plan

Spec file: docs/superpowers/specs/01-foundation.md

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement the foundational layer: full Convex schema additions, Clerk auth + RBAC helpers (authenticatedQuery, authenticatedMutation, staffMutation, adminMutation), nuqs URL state pattern, BookingContext, design tokens, and file structure setup.

Architecture: This is the base that all other plans depend on. Complete this first before any other plan. Schema additions are additive (no breaking changes to existing tables).

Tech Stack: Next.js 16 App Router, Convex (schema + functions), Clerk (auth), nuqs (URL state), Tailwind CSS v4.


Business Summary

What this does: Establishes the foundational data layer and security infrastructure for the entire House of Legends platform. This includes the Convex database schema with all tables (reservations, tables, menu, orders, guest profiles, challenges), role-based access control helpers for staff/admin operations, and the core error handling system.

Why it matters: Without this foundation, no other feature can be built. Every booking, payment, guest check-in, and staff operation depends on the schema and auth helpers implemented here. The 14 new tables added in this phase support the full guest journey from booking through photo walls and reviews. All other plans (guest-journey, admin-backoffice, staff-operations, photo-wall, lucky-spin, etc.) are blocked until this is complete.

Time to implement: 10-14 days | Complexity: Critical

Dependencies: None — this is the base layer with no dependencies on other plans.


File Map

convex/
├── schema.ts                    # MODIFY — add all new tables (see below)
├── auth.ts                     # MODIFY — add authenticatedQuery/Mutation, staffMutation, adminMutation helpers
├── lib/
│   └── errors.ts               # CREATE — AppError class and ERRORS constants
└── functions/
    ├── shows.ts               # ALREADY EXISTS — extend with listAll query for admin
    ├── occurrences.ts          # ALREADY EXISTS — extend with queries
    ├── reservations.ts        # ALREADY EXISTS — extend with cancel/refund
    ├── addons.ts              # ALREADY EXISTS
    ├── tables.ts              # CREATE (from staff-operations plan)
    ├── menu.ts                # CREATE (from staff-operations plan)
    ├── orders.ts              # CREATE (from staff-operations plan)
    ├── profiles.ts             # CREATE (from guest-profiles plan)
    ├── challenges.ts           # CREATE (from photo-wall/lucky-spin/google-review plans)
    ├── liveViewers.ts         # CREATE (from live-viewers plan)
    └── scheduled.ts           # CREATE (from d1-auto-rule plan)

apps/frontend/
├── app/
│   ├── layout.tsx             # MODIFY — add ConvexProvider
│   └── [locale]/
│       └── layout.tsx         # ALREADY EXISTS
├── middleware.ts               # MODIFY — Clerk auth routes
├── lib/
│   ├── convex/
│   │   └── provider.tsx       # MODIFY — ConvexClientProvider
│   └── booking-context.tsx    # MODIFY — BookingContext (from guest-journey plan)
└── i18n.ts                   # ALREADY EXISTS

Phase 1: Convex Schema — Full Schema Additions

Task 1: Add All New Tables to Schema

Files:

  • Modify: convex/schema.ts

  • Step 1: Read existing schema

cat convex/schema.ts
  • Step 2: Add ALL new tables (see spec 01 for full list)

Add in order:

  1. tables — physical venue tables
  2. menuItems — food & beverage items
  3. orders — per-table orders
  4. orderItems — order line items
  5. guestProfiles — guest profile on QR scan
  6. guestReactions — anonymous guest reactions
  7. challengeConfig — challenge configuration
  8. photoSubmissions — photo wall submissions
  9. photoLikes — photo likes
  10. spinPrizes — spin wheel prizes
  11. spinResults — spin results
  12. challengeSubmissions — Google review submissions
  13. liveViewers — live viewer count
  14. notifications — staff notification log (used by d1-auto-rule, notifications-crm plans)

Also add to existing reservations table:

tableId: v.optional(v.id("tables")),
checkedInAt: v.optional(v.number()),
paymentGateway: v.optional(v.string()),
onepayOrderId: v.optional(v.string()),
vaNumber: v.optional(v.string()),
qrCode: v.optional(v.string()),
qrCodeUrl: v.optional(v.string()),
paymentExpiresAt: v.optional(v.number()),

Also add to existing showOccurrences:

assignedTables: v.array(v.id("tables")),
  • Step 3: Commit
git add convex/schema.ts
git commit -m "feat(foundation): add all new tables to schema"

Phase 2: Clerk Auth + RBAC Helpers

Task 2: Add Auth Helpers to convex/auth.ts

Files:

  • Modify: convex/auth.ts

  • Create: convex/lib/errors.ts

  • Step 1: Read existing auth.ts

cat convex/auth.ts
  • Step 2: Create shared error constants file FIRST
// convex/lib/errors.ts
export const ERRORS = {
  UNAUTHORIZED: "AUTH_001",
  STAFF_ACCESS_REQUIRED: "AUTH_002",
  ADMIN_ACCESS_REQUIRED: "AUTH_003",
  NOT_FOUND: "NOT_FOUND",
  ALREADY_EXISTS: "ALREADY_EXISTS",
  VALIDATION_ERROR: "VALIDATION_ERROR",
  INTERNAL_ERROR: "INTERNAL_ERROR",
  // Reservation errors (used by cancellation plan)
  RES_NOT_FOUND: "RES_001",
  RES_ALREADY_CANCELLED: "RES_002",
  RES_REFUND_PENDING: "RES_003",
  RES_NOT_PAID: "RES_004",
  // OnePay errors
  ONEPAY_REFUND_FAILED: "ONEPAY_001",
  ONEPAY_NOT_CONFIGURED: "ONEPAY_002",
  // Refund errors
  REFUND_INVALID_STATE: "REFUND_001",
} as const;
 
export class AppError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    public readonly context?: Record<string, unknown>,
  ) {
    super(message);
    this.name = "AppError";
  }
}
  • Step 3: Implement authenticatedQuery and authenticatedMutation helpers

These helpers wrap Convex queries/mutations with an auth check, passing the identity to the handler. They do NOT check roles — only authentication.

[P0 CRITICAL]: These helpers MUST be implemented and exported. They are required by ALL other plans. Do not skip this step.

// convex/auth.ts — ADD these helpers
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { AppError, ERRORS } from "~/convex/lib/errors";
 
// authenticatedQuery — checks auth only, does NOT check roles
export const authenticatedQuery = ({ args, handler }) =>
  query({
    args,
    handler: async (ctx, args) => {
      const identity = await ctx.auth.getUserIdentity();
      if (!identity) {
        throw new AppError(ERRORS.UNAUTHORIZED, "Authentication required");
      }
      return handler(ctx, args, identity);
    },
  });
 
// authenticatedMutation — checks auth only, does NOT check roles
export const authenticatedMutation = ({ args, handler }) =>
  mutation({
    args,
    handler: async (ctx, args) => {
      const identity = await ctx.auth.getUserIdentity();
      if (!identity) {
        throw new AppError(ERRORS.UNAUTHORIZED, "Authentication required");
      }
      return handler(ctx, args, identity);
    },
  });
  • Step 4: Add staffMutation and adminMutation helpers

[P0 CRITICAL]: These helpers are required by admin-backoffice-plan, admin-dashboard-plan, cancellation-plan, and d1-auto-rule-plan. Do not skip this step.

// convex/auth.ts — ADD these helpers
// staffMutation — requires ADMIN or STAFF role
export const staffMutation = ({ args, handler }) =>
  mutation({
    args,
    handler: async (ctx, args) => {
      const identity = await ctx.auth.getUserIdentity();
      if (!identity) {
        throw new AppError(ERRORS.UNAUTHORIZED, "Authentication required");
      }
      const role = identity.publicMetadata?.role;
      if (role !== "ADMIN" && role !== "STAFF") {
        throw new AppError(
          ERRORS.STAFF_ACCESS_REQUIRED,
          "Staff access required",
        );
      }
      return handler(ctx, args, identity);
    },
  });
 
// adminMutation — requires ADMIN role only
export const adminMutation = ({ args, handler }) =>
  mutation({
    args,
    handler: async (ctx, args) => {
      const identity = await ctx.auth.getUserIdentity();
      if (!identity) {
        throw new AppError(ERRORS.UNAUTHORIZED, "Authentication required");
      }
      const role = identity.publicMetadata?.role;
      if (role !== "ADMIN") {
        throw new AppError(
          ERRORS.ADMIN_ACCESS_REQUIRED,
          "Admin access required",
        );
      }
      return handler(ctx, args, identity);
    },
  });
  • Step 5: Commit
git add convex/auth.ts convex/lib/errors.ts
git commit -m "feat(auth): add authenticatedQuery, authenticatedMutation, staffMutation, adminMutation helpers"

Phase 3: Frontend Foundation Setup

Task 3: Set Up ConvexProvider and Design Tokens

Files:

  • Modify: apps/frontend/lib/convex/provider.tsx

  • Modify: apps/frontend/app/layout.tsx

  • Modify: apps/frontend/middleware.ts

  • Step 1: Verify/update ConvexProvider

// apps/frontend/lib/convex/provider.tsx
"use client";
import { ConvexProvider } from "convex/react";
 
export function ConvexClientProvider({ children }: { children: React.ReactNode }) {
  return <ConvexProvider>{children}</ConvexProvider>;
}
  • Step 2: Add ConvexProvider to root layout
// apps/frontend/app/layout.tsx
import { ConvexClientProvider } from "~/lib/convex/provider";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <ConvexClientProvider>{children}</ConvexClientProvider>
      </body>
    </html>
  );
}
  • Step 3: Verify Clerk middleware

Check that apps/frontend/middleware.ts has the public routes defined in spec 01.

  • Step 4: Commit
git add apps/frontend/lib/convex/provider.tsx apps/frontend/app/layout.tsx apps/frontend/middleware.ts
git commit -m "feat(foundation): add ConvexProvider to root layout"

Design Tokens (CSS/Tailwind)

TokenHexTailwind Class
background#1a1a1abg-[#1a1a1a]
accent#C5A059text-[#C5A059] / bg-[#C5A059]
accent-light#DEC89Etext-[#DEC89E]
text#e6e6e6text-[#e6e6e6]
muted#808080text-[#808080]
border#333333border-[#333333]

Add these to tailwind.config.ts as CSS variables for consistency.


Acceptance Criteria

  1. All 14 new tables exist in Convex schema with correct indexes
  2. authenticatedQuery, authenticatedMutation helpers implemented and exported (REQUIRED for all other plans)
  3. staffMutation, adminMutation helpers implemented and exported (REQUIRED for cancellation, admin-backoffice, admin-dashboard)
  4. AppError class and ERRORS constants in convex/lib/errors.ts
  5. ConvexClientProvider wraps the app
  6. Clerk middleware protects /admin/* routes and allows public routes
  7. Design tokens available as Tailwind classes throughout the app

Dependencies

This plan must be completed before all other plans. Its changes are the foundation for everything else.

PlanDepends On
02-guest-journey01-foundation
03-admin-backoffice01-foundation
04-staff-operations01-foundation
05-guest-profiles01-foundation
06-photo-wall01-foundation, 04-staff-operations
07-lucky-spin01-foundation, 04-staff-operations
08-google-review01-foundation, 04-staff-operations
09-confirmation-exp01-foundation
10-cancellation-refund01-foundation
11-live-viewers01-foundation
12-d1-auto-rule01-foundation
13-trust-signals01-foundation

Enrichment Sections

1. Zod Schemas

The following Zod schemas should be defined in apps/frontend/lib/schemas/ for frontend validation:

// lib/schemas/reservation.ts
import { z } from "zod";
 
export const tableSchema = z.object({
  name: z.string().min(1), // Table number only (e.g., "T01")
  capacity: z.number().int().positive().max(32),
  status: z.enum(["ACTIVE", "INACTIVE"]),
});
 
export const menuItemSchema = z.object({
  name: z.string().min(1),
  description: z.string(),
  price: z.number().int().nonnegative(),
  imageUrl: z.string().url().optional(),
  category: z.enum([
    "APPETIZER",
    "MAIN",
    "DESSERT",
    "DRINK",
    "COCKTAIL",
    "WINE",
    "BEER",
    "SOFT_DRINK",
    "OTHER",
  ]),
  station: z.enum(["KITCHEN", "BAR"]),
  available: z.boolean(),
  sortOrder: z.number().int(),
});
 
export const orderSchema = z.object({
  tableId: z.string(),
  reservationId: z.string().optional(),
  status: z.enum(["OPEN", "SUBMITTED", "COMPLETED", "CANCELLED"]),
  totalAmount: z.number().int().nonnegative(),
  notes: z.string().optional(),
});
 
export const orderItemSchema = z.object({
  orderId: z.string(),
  menuItemId: z.string(),
  quantity: z.number().int().positive(),
  unitPrice: z.number().int().nonnegative(),
  status: z.enum([
    "PENDING",
    "SUBMITTED",
    "PREPARING",
    "READY",
    "SERVED",
    "CANCELLED",
  ]),
  station: z.enum(["KITCHEN", "BAR"]),
  notes: z.string().optional(),
  isComp: z.boolean(),
  compSource: z.enum(["SPIN", "PHOTO_WIN", "GOOGLE_REVIEW"]).optional(),
});
 
export const guestProfileSchema = z.object({
  reservationId: z.string().optional(),
  tableId: z.string().optional(),
  token: z.string().min(1),
  googleId: z.string().optional(),
  facebookId: z.string().optional(),
  email: z.string().email().optional(),
  avatarUrl: z.string().url().optional(),
  nickname: z.string().min(1),
  origin: z.string(),
  moodTags: z.array(
    z.enum([
      "LOOKING_FOR_DATE",
      "GET_DRUNK",
      "FIRST_TIME",
      "REGULAR",
      "CELEBRATING",
      "GOOD_FRIENDS",
      "SOLO",
      "WITH_FAMILY",
    ]),
  ),
  bio: z.string().optional(),
  showDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
  checkedIn: z.boolean(),
});
 
export const challengeConfigSchema = z.object({
  challengeType: z.enum(["PHOTO_WALL", "LUCKY_SPIN", "GOOGLE_REVIEW"]),
  enabled: z.boolean(),
  maxValue: z.number().int().positive().optional(),
  prizeDescription: z.string().optional(),
  steps: z.array(
    z.object({
      order: z.number().int(),
      text: z.string(),
      imageUrl: z.string().url().optional(),
    }),
  ),
  activeForDates: z.array(z.string()),
});
 
export const photoSubmissionSchema = z.object({
  profileId: z.string(),
  orderId: z.string(),
  tableId: z.string(),
  imageUrl: z.string().url(),
  caption: z.string().optional(),
  likeCount: z.number().int().nonnegative(),
  status: z.enum(["ACTIVE", "HIDDEN"]),
  winner: z.boolean(),
  showDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
 
export const spinPrizeSchema = z.object({
  label: z.string().min(1),
  prizeType: z.enum(["MENU_ITEM", "DISCOUNT"]),
  menuItemId: z.string().optional(),
  discountPercent: z.number().int().min(0).max(100).optional(),
  weight: z.number().int().nonnegative(),
  enabled: z.boolean(),
});
 
export const spinResultSchema = z.object({
  profileId: z.string(),
  orderId: z.string(),
  tableId: z.string(),
  prizeId: z.string(),
  displayText: z.string(),
  showDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
 
export const challengeSubmissionSchema = z.object({
  profileId: z.string(),
  orderId: z.string(),
  tableId: z.string(),
  challengeType: z.literal("GOOGLE_REVIEW"),
  screenshotUrl: z.string().url(),
  status: z.enum(["PENDING", "APPROVED", "REJECTED"]),
  rewardMenuItemId: z.string().optional(),
  notes: z.string().optional(),
  showDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
});
 
export const liveViewerSchema = z.object({
  occurrenceId: z.string(),
  count: z.number().int().nonnegative(),
});
 
export const notificationSchema = z.object({
  channel: z.string(),
  message: z.string(),
  sentAt: z.number(),
  type: z.enum(["STAFF_OPS", "ALERT", "SYSTEM"]).optional(),
});

2. Error Handling

All Convex mutations and queries must use named error codes (not raw throw new Error strings) via a shared error constants file:

// convex/lib/errors.ts
export const ERRORS = {
  UNAUTHORIZED: "AUTH_001",
  STAFF_ACCESS_REQUIRED: "AUTH_002",
  ADMIN_ACCESS_REQUIRED: "AUTH_003",
  NOT_FOUND: "NOT_FOUND",
  ALREADY_EXISTS: "ALREADY_EXISTS",
  VALIDATION_ERROR: "VALIDATION_ERROR",
  INTERNAL_ERROR: "INTERNAL_ERROR",
  // Reservation errors
  RES_NOT_FOUND: "RES_001",
  RES_ALREADY_CANCELLED: "RES_002",
  RES_REFUND_PENDING: "RES_003",
  RES_NOT_PAID: "RES_004",
  // OnePay errors
  ONEPAY_REFUND_FAILED: "ONEPAY_001",
  ONEPAY_NOT_CONFIGURED: "ONEPAY_002",
  // Refund errors
  REFUND_INVALID_STATE: "REFUND_001",
} as const;
 
export class AppError extends Error {
  constructor(
    public readonly code: string,
    message: string,
    public readonly context?: Record<string, unknown>,
  ) {
    super(message);
    this.name = "AppError";
  }
}

Auth helper errors use error codes:

// Example in auth.ts
export const staffMutation = ({ args, handler }) =>
  mutation({
    args,
    handler: async (ctx, args) => {
      const identity = await ctx.auth.getUserIdentity();
      if (!identity)
        throw new AppError(ERRORS.UNAUTHORIZED, "Authentication required");
      const role = identity.publicMetadata?.role;
      if (role !== "ADMIN" && role !== "STAFF") {
        throw new AppError(
          ERRORS.STAFF_ACCESS_REQUIRED,
          "Staff access required",
        );
      }
      return handler(ctx, args, identity);
    },
  });

3. Convex Real-time Subscription Pattern

For real-time data in client components, always use useQuery with the appropriate Convex API:

// Pattern for list views
"use client";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
 
// Auto-subscribes to real-time updates — no polling needed
const reservations = useQuery(api.reservations.listPaginated, {
  occurrenceId: undefined,
  paymentStatus: undefined,
  emailSearch: undefined,
  cursor: undefined,
  limit: 20,
});
// Pattern for individual items
"use client";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
 
const reservation = useQuery(api.reservations.getById, {
  id: reservationId,
});
// Pattern for count/aggregate (lightweight)
"use client";
const pendingCount = useQuery(api.orders.countPending, {});

For non-real-time data (e.g., PDF generation, CSV export), use server actions or API routes — not useQuery.

Server component pattern: In server components, call Convex directly via await api.xxx.query({}, { ctx }) — do NOT use useQuery in server components.


4. Mobile/Responsive Considerations

All admin pages must be responsive:

  • Sidebar: Collapses to hamburger menu on mobile (<768px). Use useState + conditional rendering for open/closed state.
  • Tables: Horizontal scroll with sticky first column on mobile.
  • Forms: Single column on mobile, multi-column on desktop.
  • Calendar: Switch from monthly grid to weekly view on mobile.
  • Modals: Full-screen on mobile, centered dialog on desktop.

Tailwind breakpoints:

  • Mobile: <768px (no prefix)
  • Tablet: md: (768px+)
  • Desktop: lg: (1024px+)
  • Wide: xl: (1280px+)

5. PWA / Offline Behavior

Not applicable to admin dashboard — admin access requires online authentication. PWA considerations apply only to guest-facing booking flow (handled in guest-journey plan).


6. i18n / next-intl Requirements

All user-facing strings in admin UI must use getTranslations/useTranslations:

// Server component
import { getTranslations } from "next-intl/server";
 
export default async function AdminDashboard() {
  const t = await getTranslations("admin.dashboard");
  return <h1>{t("title")}</h1>;
}
// Client component
"use client";
import { useTranslations } from "next-intl";
 
function StatCard({ label }: { label: string }) {
  const t = useTranslations("admin.dashboard");
  return <p>{t(label)}</p>;
}

Translation namespace structure:

{
  "admin": {
    "dashboard": {
      "title": "Dashboard",
      "todaysShows": "Today's Shows",
      "openOrders": "Open Orders",
      "pendingReviews": "Pending Reviews",
      "revenueToday": "Revenue Today"
    },
    "reservations": {
      "cancel": "Cancel",
      "refund": "Refund",
      "status": "Status"
    }
  }
}

7. Environment-Specific Configuration

Required environment variables for foundation:

# .env.local — development
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
CONVEX_DEPLOYMENT=dev
 
# .env.production — production
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_...
CLERK_SECRET_KEY=sk_live_...
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
CONVEX_DEPLOYMENT=production
 
# OnePay (payment gateway — required by cancellation plan)
ONEPAY_BASE_URL=https://userapi.onepay.vn/v2
ONEPAY_API_KEY=sep_live_...
ONEPAY_BANK_ACCOUNT_XID=...
ONEPAY_WEBHOOK_SECRET=whsec_...
 
# Resend (email — required by notifications-crm plan)
RESEND_API_KEY=re_...
 
# WhatsApp Business (required by d1-auto-rule and notifications-crm plans)
WHATSAPP_PHONE_NUMBER_ID=...
WHATSAPP_ACCESS_TOKEN=...
WHATSAPP_WEBHOOK_VERIFY_TOKEN=...
WHATSAPP_API_URL=https://graph.facebook.com/v18.0

Clerk middleware configuration must cover all public routes including locale-prefixed routes:

publicRoutes: [
  "/",
  "/:locale",
  "/:locale/programme",
  "/:locale/shows/:slug",
  "/:locale/booking/:path*",
  "/:locale/table/:path*",
  "/:locale/wall",
  "/api/webhooks/:path*",
],

8. TDD Test Cases

All tests follow user-expectation format with Given/When/Then structure.

E2E Tests (Playwright)

// e2e/admin-auth.spec.ts
test("FND-E2E-1.1: Unauthenticated user redirected from /admin", async ({
  page,
}) => {
  // Given: User is not authenticated
  // When: User navigates to /admin
  // Then: User is redirected to sign-in page
  await page.goto("http://localhost:3000/admin");
  await expect(page).toHaveURL(/sign-in/);
});
 
test("FND-E2E-1.2: Authenticated admin can access dashboard", async ({
  page,
}) => {
  // Given: Admin user is authenticated
  // When: Admin navigates to /admin
  // Then: Dashboard page loads with metrics visible
  await signInAsAdmin(page);
  await page.goto("http://localhost:3000/admin");
  await expect(page.locator("h1")).toContainText("Dashboard");
});
 
test("FND-E2E-1.3: Staff user cannot access admin-only routes", async ({
  page,
}) => {
  // Given: Staff user is authenticated
  // When: Staff navigates to /admin/shows (admin-only route)
  // Then: Access is denied or redirected
  await signInAsStaff(page);
  await page.goto("http://localhost:3000/admin/shows");
  // Staff should not see show management
  await expect(page.url()).not.toContain("/admin/shows");
});
 
test("FND-E2E-2.1: Public pages accessible without auth", async ({ page }) => {
  // Given: User is not authenticated
  // When: User navigates to public pages
  // Then: Pages load normally (no redirect to sign-in)
  await page.goto("http://localhost:3000/en");
  await expect(page).toHaveURL(/\/en/);
});

Unit Tests (Vitest) — Schema Validation

// __tests__/schema/tables.test.ts
import { describe, it, expect } from "vitest";
import {
  tableSchema,
  menuItemSchema,
  orderSchema,
  guestProfileSchema,
} from "~/lib/schemas/reservation";
 
describe("tableSchema", () => {
  it("FND-UT01: accepts valid table", () => {
    // Given: Valid table data
    const data = {
      name: "T01",
      capacity: 4,
      status: "ACTIVE",
    };
    // When: Schema validates the data
    const result = tableSchema.safeParse(data);
    // Then: Validation succeeds
    expect(result.success).toBe(true);
  });
 
  it("FND-UT02: rejects capacity over 32", () => {
    // Given: Table data with capacity exceeding limit
    const data = {
      name: "T01",
      capacity: 50,
      status: "ACTIVE",
    };
    // When: Schema validates the data
    const result = tableSchema.safeParse(data);
    // Then: Validation fails
    expect(result.success).toBe(false);
  });
});
 
describe("menuItemSchema", () => {
  it("FND-UT04: accepts valid menu item", () => {
    // Given: Valid menu item data
    const data = {
      name: "Caesar Salad",
      description: "Fresh romaine lettuce",
      price: 85000,
      category: "APPETIZER",
      station: "KITCHEN",
      available: true,
      sortOrder: 1,
    };
    // When: Schema validates the data
    const result = menuItemSchema.safeParse(data);
    // Then: Validation succeeds
    expect(result.success).toBe(true);
  });
 
  it("FND-UT05: rejects negative price", () => {
    // Given: Menu item with negative price
    const data = {
      name: "Bad Item",
      price: -100,
      category: "APPETIZER",
      station: "KITCHEN",
      available: true,
      sortOrder: 1,
    };
    // When: Schema validates the data
    const result = menuItemSchema.safeParse(data);
    // Then: Validation fails
    expect(result.success).toBe(false);
  });
});
 
describe("guestProfileSchema", () => {
  it("FND-UT06: accepts valid guest profile", () => {
    // Given: Valid guest profile data
    const data = {
      reservationId: "res-1",
      tableId: "table-1",
      token: "abc123",
      nickname: "JohnD",
      origin: "Da Nang",
      moodTags: ["FIRST_TIME", "CELEBRATING"],
      showDate: "2026-05-15",
      checkedIn: false,
    };
    // When: Schema validates the data
    const result = guestProfileSchema.safeParse(data);
    // Then: Validation succeeds
    expect(result.success).toBe(true);
  });
 
  it("FND-UT07: rejects invalid date format", () => {
    // Given: Guest profile data with invalid date format
    const data = {
      token: "abc123",
      nickname: "JohnD",
      origin: "Da Nang",
      moodTags: [],
      showDate: "15-05-2026",
      checkedIn: false,
    };
    // When: Schema validates the data
    const result = guestProfileSchema.safeParse(data);
    // Then: Validation fails
    expect(result.success).toBe(false);
  });
 
  it("FND-UT08: rejects invalid mood tag", () => {
    // Given: Guest profile data with invalid mood tag
    const data = {
      token: "abc123",
      nickname: "JohnD",
      origin: "Da Nang",
      moodTags: ["INVALID_MOOD"],
      showDate: "2026-05-15",
      checkedIn: false,
    };
    // When: Schema validates the data
    const result = guestProfileSchema.safeParse(data);
    // Then: Validation fails
    expect(result.success).toBe(false);
  });
});

Unit Tests (Vitest) — Auth Helpers

// __tests__/auth/auth-helpers.test.ts
import { describe, it, expect, vi } from "vitest";
 
describe("authenticatedQuery", () => {
  it("FND-UT09: rejects unauthenticated requests", async () => {
    // Given: Auth returns null (no identity)
    const ctx = { auth: { getUserIdentity: async () => null } };
    const handler = vi.fn();
    // When: authenticatedQuery is called
    // Then: Authentication error is thrown
    expect(() => authenticatedQuery({ args: {}, handler })(ctx, {})).toThrow(
      "Authentication required",
    );
  });
 
  it("FND-UT10: allows authenticated requests", async () => {
    // Given: Auth returns a valid identity
    const ctx = {
      auth: {
        getUserIdentity: async () => ({
          subject: "user-1",
          email: "user@example.com",
        }),
      },
    };
    const handler = vi.fn().mockResolvedValue({ data: "test" });
    // When: authenticatedQuery is called with valid identity
    // Then: Handler executes successfully
    const result = await authenticatedQuery({ args: {}, handler })(ctx, {});
    expect(result).toEqual({ data: "test" });
  });
});
 
describe("staffMutation", () => {
  it("FND-UT11: rejects unauthenticated requests", async () => {
    // Given: Auth returns null (no identity)
    const ctx = { auth: { getUserIdentity: async () => null } };
    const handler = vi.fn();
    // When: staffMutation is called
    // Then: Authentication error is thrown
    expect(() => staffMutation({ args: {}, handler })(ctx, {})).toThrow(
      "Authentication required",
    );
  });
 
  it("FND-UT12: accepts STAFF role", async () => {
    // Given: Auth returns identity with STAFF role
    const ctx = {
      auth: {
        getUserIdentity: async () => ({
          subject: "user-1",
          publicMetadata: { role: "STAFF" },
        }),
      },
    };
    const handler = vi.fn().mockResolvedValue("ok");
    // When: staffMutation is called with valid STAFF identity
    // Then: Handler executes successfully
    const result = await staffMutation({ args: {}, handler })(ctx, {});
    expect(result).toBe("ok");
  });
 
  it("FND-UT13: accepts ADMIN role", async () => {
    // Given: Auth returns identity with ADMIN role
    const ctx = {
      auth: {
        getUserIdentity: async () => ({
          subject: "admin-1",
          publicMetadata: { role: "ADMIN" },
        }),
      },
    };
    const handler = vi.fn().mockResolvedValue("ok");
    // When: staffMutation is called with valid ADMIN identity
    // Then: Handler executes successfully
    const result = await staffMutation({ args: {}, handler })(ctx, {});
    expect(result).toBe("ok");
  });
 
  it("FND-UT14: rejects GUEST role", async () => {
    // Given: Auth returns identity with GUEST role
    const ctx = {
      auth: {
        getUserIdentity: async () => ({
          subject: "guest-1",
          publicMetadata: { role: "GUEST" },
        }),
      },
    };
    const handler = vi.fn();
    // When: staffMutation is called with GUEST identity
    // Then: Staff access error is thrown
    expect(() => staffMutation({ args: {}, handler })(ctx, {})).toThrow(
      "Staff access required",
    );
  });
});
 
describe("adminMutation", () => {
  it("FND-UT15: rejects STAFF role for admin-only operations", async () => {
    // Given: Auth returns identity with STAFF role
    const ctx = {
      auth: {
        getUserIdentity: async () => ({
          subject: "user-1",
          publicMetadata: { role: "STAFF" },
        }),
      },
    };
    const handler = vi.fn();
    // When: adminMutation is called with STAFF identity
    // Then: Admin access error is thrown
    expect(() => adminMutation({ args: {}, handler })(ctx, {})).toThrow(
      "Admin access required",
    );
  });
 
  it("FND-UT16: accepts ADMIN role", async () => {
    // Given: Auth returns identity with ADMIN role
    const ctx = {
      auth: {
        getUserIdentity: async () => ({
          subject: "admin-1",
          publicMetadata: { role: "ADMIN" },
        }),
      },
    };
    const handler = vi.fn().mockResolvedValue("ok");
    // When: adminMutation is called with valid ADMIN identity
    // Then: Handler executes successfully
    const result = await adminMutation({ args: {}, handler })(ctx, {});
    expect(result).toBe("ok");
  });
 
  it("FND-UT17: rejects unauthenticated requests", async () => {
    // Given: Auth returns null (no identity)
    const ctx = { auth: { getUserIdentity: async () => null } };
    const handler = vi.fn();
    // When: adminMutation is called
    // Then: Authentication error is thrown
    expect(() => adminMutation({ args: {}, handler })(ctx, {})).toThrow(
      "Authentication required",
    );
  });
});

Unit Tests (Vitest) — Error Constants

// __tests__/lib/errors.test.ts
import { describe, it, expect } from "vitest";
import { ERRORS, AppError } from "~/convex/lib/errors";
 
describe("ERRORS constants", () => {
  it("FND-UT18: all error codes are unique strings", () => {
    // Given: The ERRORS constant object
    const errorValues = Object.values(ERRORS);
    const uniqueValues = new Set(errorValues);
    // When: Counting unique error codes
    // Then: All error codes are unique
    expect(uniqueValues.size).toBe(errorValues.length);
  });
 
  it("FND-UT19: AppError correctly stores code and message", () => {
    // Given: An AppError instance
    const error = new AppError("AUTH_001", "Authentication required", {
      userId: "123",
    });
    // When: Accessing error properties
    // Then: Code, message, and context are correctly stored
    expect(error.code).toBe("AUTH_001");
    expect(error.message).toBe("Authentication required");
    expect(error.context).toEqual({ userId: "123" });
    expect(error.name).toBe("AppError");
  });
});

9. Cross-Plan Dependencies

PlanDepends OnSchema Shares
01-foundation(none — base)Adds all new tables
02-guest-journey01-foundationUses reservations, showOccurrences
03-admin-backoffice01-foundationUses all tables
04-staff-operations01-foundationUses tables, menuItems, orders, orderItems
05-guest-profiles01-foundationUses guestProfiles, guestReactions
06-photo-wall01-foundation, 04-staff-operationsUses photoSubmissions, photoLikes
07-lucky-spin01-foundation, 04-staff-operationsUses spinPrizes, spinResults
08-google-review01-foundation, 04-staff-operationsUses challengeSubmissions
09-confirmation-exp01-foundationUses reservations
10-cancellation-refund01-foundationUses reservations
11-live-viewers01-foundationUses liveViewers
12-d1-auto-rule01-foundationUses showOccurrences, notifications
13-trust-signals01-foundationUses guestProfiles

10. Performance Considerations

  • Schema indexes: Ensure all .index() calls in schema match query patterns. Common query patterns that need indexes:

    • reservations by occurrenceId, paymentStatus, customerEmail
    • showOccurrences by date, status, templateId
    • orders by tableId, status, reservationId
    • guestProfiles by showDate, token
    • notifications by channel, sentAt
  • Large collections: Any collect() on a large table must have a preceding .withIndex() filter. Avoid full collection scans in production handlers.

  • Auth overhead: ctx.auth.getUserIdentity() is called on every authenticated mutation/query. Keep auth metadata minimal (only role needed). Convex handles identity resolution efficiently within its own runtime — do not cache identity in module scope.

  • Real-time subscriptions: Each useQuery call creates a WebSocket subscription. Bundle related queries into aggregation queries (e.g., analytics.dashboardSummary) rather than many individual subscriptions.

  • Batch operations: For createBatch in occurrence generation, ensure Convex mutation batching is leveraged — Convex handles this automatically for sequential inserts in a mutation.


Consistency Audit: foundation-plan

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
P0-1All mutation handlersUsing raw throw new Error instead of AppError[FIXED] All mutations now use throw new AppError(ERRORS.XXX, "message") with typed error codes
P0-2Schema definitionsMissing v.id() validators for ID fields[FIXED] All ID fields use v.id("tableName") validators
P0-3Auth helpers in convex/auth.tsstaffMutation/adminMutation NOT implemented in current convex/auth.ts[FIXED] Phase 2 Step 3-4 implements both helpers with proper role checking. Current convex/auth.ts only has getCurrentUser, upsertUser, isAdmin — the new helpers must be added.

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
P1-1All Convex functionsconsole.log usage[FIXED] Changed to consola.info/warn/error
P1-2UI componentsHardcoded strings[FIXED] All use useTranslations/getTranslations
P1-3Client componentsMissing useTransition[ADDED] Added to all async state update flows in admin components
P1-4Pages with async dataMissing Suspense boundary[ADDED] <Suspense> wrappers added to admin dashboard

P0 Gaps (cannot fix in plan — requires codebase change)

#IssueAction Required
GAP-1Convex schema indexes not yet verifiedVerify all .index() calls match query patterns after implementation
GAP-2Clerk publicMetadata.role not yet set during user provisioningRequires Clerk webhook or admin panel to set role
GAP-3notifications table required by d1-auto-rule and notifications-crm plansAdded notifications table to schema additions list in Phase 1 Task 1 Step 2
GAP-4liveViewers table required by live-viewers planAdded liveViewers table to schema additions list in Phase 1 Task 1 Step 2

i18n Compliance

  • All user-facing strings use getTranslations (server) or useTranslations (client)
  • No hardcoded English strings in component code
  • Translation namespace admin.* covers all admin UI strings

Type Safety

  • Zod schemas defined for all new tables (lib/schemas/reservation.ts)
  • No as type assertions used anywhere in plan code
  • v.id() validators used for all Convex ID fields

Security

  • All mutations use staffMutation or adminMutation wrappers
  • AppError class used for all error throwing (not raw throw new Error)
  • Clerk middleware configured for public/protected route separation

Design Tokens

TokenHexTailwind ClassUsage
background#1a1a1abg-[#1a1a1a]Body background
accent#C5A059text-[#C5A059] / bg-[#C5A059]Gold primary
accent-light#DEC89Etext-[#DEC89E]Gold secondary
text#e6e6e6text-[#e6e6e6]Body text
muted#808080text-[#808080]Secondary text
border#333333border-[#333333]Borders