{
  "slug": "convex",
  "name": "Convex",
  "summary": "A TypeScript-first backend with live queries: tables, server functions and auth in one place, with every check written in code.",
  "audience": [
    "developers"
  ],
  "bestFor": "All-TypeScript projects that want screens to update live (orders and bookings appearing without refresh).",
  "cost": "Free tier for small projects; paid plans per team.",
  "rank": 4,
  "covers": "full",
  "docs": [
    {
      "label": "Convex Auth",
      "href": "https://labs.convex.dev/auth"
    },
    {
      "label": "Authorization in Convex",
      "href": "https://docs.convex.dev/auth/functions-auth"
    }
  ],
  "body": "## 1. Schema\n\nDefine the data-model tables in `convex/schema.ts` with `defineSchema`/`defineTable`, using the same field names (money as whole pence, times as numbers in ms). Add `.index(\"by_email\", [\"email\"])` on `staffMembers`, and `.index(\"by_starts\", [\"startsAt\"])` on `bookings`.\n\n## 2. One helper for every check\n\nConvex has no separate rules file: each query and mutation checks access itself. Put this in `convex/access.ts` and call it at the top of every back-office function.\n\n```ts\nimport type { QueryCtx, MutationCtx } from \"./_generated/server\";\n\nexport type Role = \"owner\" | \"manager\" | \"staff\";\n\nexport async function requireRole(ctx: QueryCtx | MutationCtx, allowed: Role[]) {\n  const identity = await ctx.auth.getUserIdentity();\n  if (!identity?.email) throw new Error(\"Not signed in\");\n  const member = await ctx.db\n    .query(\"staffMembers\")\n    .withIndex(\"by_email\", (q) => q.eq(\"email\", identity.email!))\n    .unique();\n  if (!member || member.status !== \"active\" || !allowed.includes(member.role)) throw new Error(\"Not allowed\");\n  return member;\n}\n```\n\nThen follow the access table exactly, for example:\n\n```ts\nexport const listBookings = query({ args: { day: v.number() }, handler: async (ctx, { day }) => {\n  await requireRole(ctx, [\"owner\", \"manager\", \"staff\"]);\n  return ctx.db.query(\"bookings\").withIndex(\"by_starts\", (q) => q.gte(\"startsAt\", day).lt(\"startsAt\", day + 86_400_000)).collect();\n} });\n\nexport const setBookingStatus = mutation({ args: { id: v.id(\"bookings\"), status: v.union(v.literal(\"confirmed\"), v.literal(\"arrived\"), v.literal(\"no-show\"), v.literal(\"cancelled\")) },\n  handler: async (ctx, { id, status }) => {\n    await requireRole(ctx, [\"owner\", \"manager\", \"staff\"]);\n    await ctx.db.patch(id, { status }); // staff may only change status, so this mutation touches nothing else\n  } });\n\n// Public booking form: no role check, but only the fields the form sends, and status forced to \"confirmed\".\nexport const createBooking = mutation({ args: { startsAt: v.number(), name: v.string(), party: v.number(), phone: v.string(), note: v.optional(v.string()) },\n  handler: (ctx, args) => ctx.db.insert(\"bookings\", { ...args, status: \"confirmed\", createdAt: Date.now() }) });\n```\n\nPublic reads (menu, hours, closures, settings) are plain queries without `requireRole`. Never write a back-office query without it.\n\n## 3. Sign-in and the first owner\n\nUse Convex Auth (password and magic-link providers) or Clerk. Insert the first `staffMembers` row by hand in the Convex dashboard with the owner's email and `role: \"owner\"`.\n\n## 4. Protect /admin on the server\n\nWith Convex Auth in Next.js, use `convexAuthNextjsMiddleware` and redirect unauthenticated requests for `/admin/*` (except `/admin/sign-in`) to the sign-in page. The functions' `requireRole` checks are what actually protect the data.\n\n## 5. Wire the screens\n\nPass `useQuery(api.bookings.listBookings, …)` results into the screens and call mutations from their callbacks. Lists update live without polling.",
  "url": "https://copypasteui.dev/r/recipes/convex.json",
  "prompt": "# Back office with Convex\n\nSet up the back office for this site with this backend, following every step. The back-office screens are the CopyPasteUI \"admin\" components (admin-sign-in, admin-overview, admin-bookings …); each takes its data and save actions as props. Best for: All-TypeScript projects that want screens to update live (orders and bookings appearing without refresh). Cost: Free tier for small projects; paid plans per team.\n\n## The back-office data model\n\nEvery recipe stores the same data with the same names, so the back-office screens (category \"admin\") work with any backend. Money is stored in pence/cents as whole numbers. Times are stored in UTC.\n\n| Table | Fields | Used by |\n| --- | --- | --- |\n| `staff_members` | user_id (the auth user), email, name, role (`owner` \\| `manager` \\| `staff`), status (`active` \\| `invited`) | admin-staff, every access check |\n| `settings` | one row: name, phone, email, address, instagram, booking_link, notify_bookings, notify_enquiries, notify_reviews, daily_summary | admin-settings, the public header/footer/contact |\n| `bookings` | id, starts_at, name, party, phone, email, table_label, note, status (`confirmed` \\| `arrived` \\| `no-show` \\| `cancelled`), created_at | admin-bookings, admin-overview |\n| `orders` | id, number, customer_name, placed_at, pickup_at, items (list of {name, qty}), total_pence, paid, stage (`new` \\| `preparing` \\| `ready` \\| `collected`) | admin-orders |\n| `enquiries` | id, name, email, phone, subject, message, received_at, status (`new` \\| `replied` \\| `archived`) | admin-enquiries |\n| `menu_sections` | id, name, position | admin-menu-editor, the public menu |\n| `menu_items` | id, section_id, name, description, price_pence, tags (list), available, position | admin-menu-editor, the public menu |\n| `customers` | id, name, email, phone, visits, last_visit, spend_pence, notes, marketing_opt_in | admin-customers |\n| `reviews` | id, source, author, rating (1–5), text, posted_at, reply | admin-reviews |\n| `opening_hours` | day (0 = Monday … 6 = Sunday), open, opens, closes | admin-hours, the public footer/contact |\n| `closures` | id, starts_on, ends_on, note | admin-hours, the closed-notice banner |\n\n## Who may do what\n\nThis is the security contract. Implement it in the database's own rules, not only in the interface — hiding a button doesn't stop anyone.\n\n| Data | Public visitors | Staff | Manager | Owner |\n| --- | --- | --- | --- | --- |\n| menu_sections, menu_items, opening_hours, closures | read | read | read, write | read, write |\n| settings | read (public fields only) | read | read | read, write |\n| bookings | create only (from the booking form), never read | read, change status | read, write | read, write |\n| orders | create only (from checkout), never read | read, change stage | read, write | read, write |\n| enquiries | create only (from the contact form), never read | — | read, write | read, write |\n| customers, reviews | — | — | read, write | read, write |\n| staff_members | — | read own row | read | read, write |\n\nRules that apply to every backend:\n- Every `/admin` page checks the session **on the server** and sends signed-out people to `/admin/sign-in`.\n- Secret keys (service-role keys, admin SDK credentials, API secrets) live only in server code and environment variables, never in the browser bundle.\n- Public forms may only insert, with fields limited to what the form sends; they can never read other people's rows.\n- `/admin` is left out of the navigation and the sitemap, and disallowed in robots.txt.\n- The first owner account is created by hand once (instructions in each recipe); everyone else is invited from admin-staff.\n\n# Convex: steps\n\n## 1. Schema\n\nDefine the data-model tables in `convex/schema.ts` with `defineSchema`/`defineTable`, using the same field names (money as whole pence, times as numbers in ms). Add `.index(\"by_email\", [\"email\"])` on `staffMembers`, and `.index(\"by_starts\", [\"startsAt\"])` on `bookings`.\n\n## 2. One helper for every check\n\nConvex has no separate rules file: each query and mutation checks access itself. Put this in `convex/access.ts` and call it at the top of every back-office function.\n\n```ts\nimport type { QueryCtx, MutationCtx } from \"./_generated/server\";\n\nexport type Role = \"owner\" | \"manager\" | \"staff\";\n\nexport async function requireRole(ctx: QueryCtx | MutationCtx, allowed: Role[]) {\n  const identity = await ctx.auth.getUserIdentity();\n  if (!identity?.email) throw new Error(\"Not signed in\");\n  const member = await ctx.db\n    .query(\"staffMembers\")\n    .withIndex(\"by_email\", (q) => q.eq(\"email\", identity.email!))\n    .unique();\n  if (!member || member.status !== \"active\" || !allowed.includes(member.role)) throw new Error(\"Not allowed\");\n  return member;\n}\n```\n\nThen follow the access table exactly, for example:\n\n```ts\nexport const listBookings = query({ args: { day: v.number() }, handler: async (ctx, { day }) => {\n  await requireRole(ctx, [\"owner\", \"manager\", \"staff\"]);\n  return ctx.db.query(\"bookings\").withIndex(\"by_starts\", (q) => q.gte(\"startsAt\", day).lt(\"startsAt\", day + 86_400_000)).collect();\n} });\n\nexport const setBookingStatus = mutation({ args: { id: v.id(\"bookings\"), status: v.union(v.literal(\"confirmed\"), v.literal(\"arrived\"), v.literal(\"no-show\"), v.literal(\"cancelled\")) },\n  handler: async (ctx, { id, status }) => {\n    await requireRole(ctx, [\"owner\", \"manager\", \"staff\"]);\n    await ctx.db.patch(id, { status }); // staff may only change status, so this mutation touches nothing else\n  } });\n\n// Public booking form: no role check, but only the fields the form sends, and status forced to \"confirmed\".\nexport const createBooking = mutation({ args: { startsAt: v.number(), name: v.string(), party: v.number(), phone: v.string(), note: v.optional(v.string()) },\n  handler: (ctx, args) => ctx.db.insert(\"bookings\", { ...args, status: \"confirmed\", createdAt: Date.now() }) });\n```\n\nPublic reads (menu, hours, closures, settings) are plain queries without `requireRole`. Never write a back-office query without it.\n\n## 3. Sign-in and the first owner\n\nUse Convex Auth (password and magic-link providers) or Clerk. Insert the first `staffMembers` row by hand in the Convex dashboard with the owner's email and `role: \"owner\"`.\n\n## 4. Protect /admin on the server\n\nWith Convex Auth in Next.js, use `convexAuthNextjsMiddleware` and redirect unauthenticated requests for `/admin/*` (except `/admin/sign-in`) to the sign-in page. The functions' `requireRole` checks are what actually protect the data.\n\n## 5. Wire the screens\n\nPass `useQuery(api.bookings.listBookings, …)` results into the screens and call mutations from their callbacks. Lists update live without polling.\n\nDocs to check before you start, since providers change their APIs: Convex Auth (https://labs.convex.dev/auth); Authorization in Convex (https://docs.convex.dev/auth/functions-auth).\n"
}
