Foundation Implementation Plan
Spec file:
docs/superpowers/specs/01-foundation.mdFor 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 EXISTSPhase 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:
tables— physical venue tablesmenuItems— food & beverage itemsorders— per-table ordersorderItems— order line itemsguestProfiles— guest profile on QR scanguestReactions— anonymous guest reactionschallengeConfig— challenge configurationphotoSubmissions— photo wall submissionsphotoLikes— photo likesspinPrizes— spin wheel prizesspinResults— spin resultschallengeSubmissions— Google review submissionsliveViewers— live viewer countnotifications— 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
authenticatedQueryandauthenticatedMutationhelpers
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
staffMutationandadminMutationhelpers
[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)
| Token | Hex | Tailwind Class |
|---|---|---|
background | #1a1a1a | bg-[#1a1a1a] |
accent | #C5A059 | text-[#C5A059] / bg-[#C5A059] |
accent-light | #DEC89E | text-[#DEC89E] |
text | #e6e6e6 | text-[#e6e6e6] |
muted | #808080 | text-[#808080] |
border | #333333 | border-[#333333] |
Add these to tailwind.config.ts as CSS variables for consistency.
Acceptance Criteria
- All 14 new tables exist in Convex schema with correct indexes
authenticatedQuery,authenticatedMutationhelpers implemented and exported (REQUIRED for all other plans)staffMutation,adminMutationhelpers implemented and exported (REQUIRED for cancellation, admin-backoffice, admin-dashboard)AppErrorclass andERRORSconstants inconvex/lib/errors.tsConvexClientProviderwraps the app- Clerk middleware protects
/admin/*routes and allows public routes - 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.
| Plan | Depends On |
|---|---|
| 02-guest-journey | 01-foundation |
| 03-admin-backoffice | 01-foundation |
| 04-staff-operations | 01-foundation |
| 05-guest-profiles | 01-foundation |
| 06-photo-wall | 01-foundation, 04-staff-operations |
| 07-lucky-spin | 01-foundation, 04-staff-operations |
| 08-google-review | 01-foundation, 04-staff-operations |
| 09-confirmation-exp | 01-foundation |
| 10-cancellation-refund | 01-foundation |
| 11-live-viewers | 01-foundation |
| 12-d1-auto-rule | 01-foundation |
| 13-trust-signals | 01-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 useuseQueryin 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.0Clerk 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
| Plan | Depends On | Schema Shares |
|---|---|---|
| 01-foundation | (none — base) | Adds all new tables |
| 02-guest-journey | 01-foundation | Uses reservations, showOccurrences |
| 03-admin-backoffice | 01-foundation | Uses all tables |
| 04-staff-operations | 01-foundation | Uses tables, menuItems, orders, orderItems |
| 05-guest-profiles | 01-foundation | Uses guestProfiles, guestReactions |
| 06-photo-wall | 01-foundation, 04-staff-operations | Uses photoSubmissions, photoLikes |
| 07-lucky-spin | 01-foundation, 04-staff-operations | Uses spinPrizes, spinResults |
| 08-google-review | 01-foundation, 04-staff-operations | Uses challengeSubmissions |
| 09-confirmation-exp | 01-foundation | Uses reservations |
| 10-cancellation-refund | 01-foundation | Uses reservations |
| 11-live-viewers | 01-foundation | Uses liveViewers |
| 12-d1-auto-rule | 01-foundation | Uses showOccurrences, notifications |
| 13-trust-signals | 01-foundation | Uses guestProfiles |
10. Performance Considerations
-
Schema indexes: Ensure all
.index()calls in schema match query patterns. Common query patterns that need indexes:reservationsbyoccurrenceId,paymentStatus,customerEmailshowOccurrencesbydate,status,templateIdordersbytableId,status,reservationIdguestProfilesbyshowDate,tokennotificationsbychannel,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 (onlyroleneeded). Convex handles identity resolution efficiently within its own runtime — do not cache identity in module scope. -
Real-time subscriptions: Each
useQuerycall creates a WebSocket subscription. Bundle related queries into aggregation queries (e.g.,analytics.dashboardSummary) rather than many individual subscriptions. -
Batch operations: For
createBatchin 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)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P0-1 | All mutation handlers | Using raw throw new Error instead of AppError | [FIXED] All mutations now use throw new AppError(ERRORS.XXX, "message") with typed error codes |
| P0-2 | Schema definitions | Missing v.id() validators for ID fields | [FIXED] All ID fields use v.id("tableName") validators |
| P0-3 | Auth helpers in convex/auth.ts | staffMutation/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)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P1-1 | All Convex functions | console.log usage | [FIXED] Changed to consola.info/warn/error |
| P1-2 | UI components | Hardcoded strings | [FIXED] All use useTranslations/getTranslations |
| P1-3 | Client components | Missing useTransition | [ADDED] Added to all async state update flows in admin components |
| P1-4 | Pages with async data | Missing Suspense boundary | [ADDED] <Suspense> wrappers added to admin dashboard |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| GAP-1 | Convex schema indexes not yet verified | Verify all .index() calls match query patterns after implementation |
| GAP-2 | Clerk publicMetadata.role not yet set during user provisioning | Requires Clerk webhook or admin panel to set role |
| GAP-3 | notifications table required by d1-auto-rule and notifications-crm plans | Added notifications table to schema additions list in Phase 1 Task 1 Step 2 |
| GAP-4 | liveViewers table required by live-viewers plan | Added liveViewers table to schema additions list in Phase 1 Task 1 Step 2 |
i18n Compliance
- All user-facing strings use
getTranslations(server) oruseTranslations(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
astype assertions used anywhere in plan code v.id()validators used for all Convex ID fields
Security
- All mutations use
staffMutationoradminMutationwrappers AppErrorclass used for all error throwing (not rawthrow new Error)- Clerk middleware configured for public/protected route separation
Design Tokens
| Token | Hex | Tailwind Class | Usage |
|---|---|---|---|
background | #1a1a1a | bg-[#1a1a1a] | Body background |
accent | #C5A059 | text-[#C5A059] / bg-[#C5A059] | Gold primary |
accent-light | #DEC89E | text-[#DEC89E] | Gold secondary |
text | #e6e6e6 | text-[#e6e6e6] | Body text |
muted | #808080 | text-[#808080] | Secondary text |
border | #333333 | border-[#333333] | Borders |