clink-co

Keeps — Supabase database

This is the real, production-target database schema for the storefront and admin dashboard: 35 tables, full Row Level Security, storage buckets, seed data, generated TypeScript types, and a data-access layer with typed error handling. It lives entirely under supabase/ and src/lib/db/ / src/lib/supabase/ and does not replace the app’s existing in-memory stores (src/lib/admin/*-store.ts, src/lib/orders/store.ts, src/lib/account/*-store.ts) — this sandbox has no live Supabase project to run migrations against, so the app keeps running on those until you connect a real project and do the swap-over (see Relationship to the in-memory stores).

Contents

supabase/
├── migrations/          11 numbered SQL migrations, applied in order
├── seed.sql              Development seed data (supabase db reset runs this automatically)
└── README.md              This file

src/lib/supabase/
├── types.ts               Database type definitions (Row/Insert/Update per table + Enums + Functions)
├── service.ts              Service-role client (bypasses RLS — server-only, narrow use)
├── server.ts / client.ts   Existing session-aware clients (unchanged)
└── safe-client.ts          Existing "null instead of throw" wrapper (unchanged)

src/lib/db/
├── errors.ts               Typed error hierarchy + Postgres error code mapping
├── client.ts                getDb() — the session-aware client every module below uses
├── products.ts, categories.ts, collections.ts, cart.ts, wishlist.ts,
│   addresses.ts, profiles.ts, orders.ts, reviews.ts, discounts.ts, audit.ts

Table relationship summary

auth.users (Supabase Auth)
  └─ profiles (1:1, id = auth.users.id)
       ├─ user_roles (1:1 today — unique(user_id); role grants, feeds profiles.role)
       ├─ addresses (1:N)
       ├─ carts (1:N, usually 1 active) ─── cart_items (1:N) ─┐
       ├─ wishlists (1:1) ─── wishlist_items (1:N) ───────────┤
       ├─ orders (1:N) ──────────────────────────────────────┤→ products
       │    ├─ order_items (1:N)                              │
       │    ├─ payments (1:N — one row per payment attempt)    │
       │    ├─ shipments (1:N)                                 │
       │    ├─ returns (1:1 per order) ── return_items (1:N)   │
       │    └─ discount_redemptions (1:N)                      │
       ├─ reviews (1:N) ── review_images (1:N)                 │
       ├─ product_questions (1:N) ── product_answers (1:1)     │
       └─ admin_audit_logs (1:N, as the acting user)           │
                                                                 │
categories ── product_categories (M:N) ────────────────────────┤
collections ── collection_products (M:N) ───────────────────────┘
products
  ├─ product_variants (1:N)
  ├─ product_images (1:N, optionally scoped to a variant)
  └─ inventory (1:N — one row per product×variant×location)

discount_codes ── discount_redemptions (1:N)

homepage_sections, hero_slides, journal_posts   — standalone, admin-managed
store_settings                                   — singleton (boolean PK, exactly one row, ever)
media_assets                                      — standalone, references storage objects
newsletter_subscribers, contact_submissions       — standalone, public-writable
role_permissions                                  — reference table (role × permission), not admin-managed

Every foreign key’s ON DELETE behavior was chosen deliberately, not defaulted:

Every RLS policy

All 35 tables (plus the internal role_permissions reference table) have ENABLE ROW LEVEL SECURITY. Grouped by pattern rather than listed 100+ times identically — the migration file after each heading has the literal CREATE POLICY statements.

Public read / staff write (categories, collections, products, product_variants, product_images, product_categories, collection_products, homepage_sections, hero_slides, journal_posts, discount_codes) — 0002_catalog.sql, 0005_promotions.sql, 0007_site_content.sql:

Owner-only (addresses, carts, cart_items, wishlists, wishlist_items) — 0001_identity_and_access.sql, 0003_carts_and_wishlists.sql:

profiles — 0001_identity_and_access.sql:

user_roles / role_permissions — 0001_identity_and_access.sql:

orders — 0004_orders_payments_shipments.sql:

order_items — 0004_orders_payments_shipments.sql:

payments — 0004_orders_payments_shipments.sql:

shipments — 0004_orders_payments_shipments.sql:

discount_redemptions — 0005_promotions.sql:

reviews — 0006_reviews_and_qa.sql:

review_images — mirrors its parent review’s visibility; insert/delete by the owning review’s author or content:write.

product_questions / product_answers — 0006_reviews_and_qa.sql:

store_settings — 0007_site_content.sql:

media_assets — 0007_site_content.sql:

newsletter_subscribers / contact_submissions — 0008_ops.sql:

returns / return_items — 0008_ops.sql:

admin_audit_logs — 0009_audit_log.sql:

Storage (storage.objects) — 0010_storage.sql:

Role → permission matrix

Role Permissions
super_admin Every permission, including team:write
store_admin Every permission except team:write
product_manager dashboard:view, products:*, categories:*, collections:*, media:*
order_fulfilment dashboard:view, orders:view, orders:fulfil, orders:export, customers:view, products:view
content_editor dashboard:view, content:*, media:*, categories:view
customer_support dashboard:view, customers:view, customers:write, orders:view (no orders:fulfil — cannot refund, cancel, or change status/tracking)

This is a byte-for-byte mirror of ROLE_PERMISSIONS in src/lib/admin/roles.ts, seeded as data into role_permissions — see 0001_identity_and_access.sql. If you ever change the TypeScript matrix, update the SQL seed insert to match (there’s no automatic sync between the two; see Security decisions).

How to run the migrations

Requires the Supabase CLI and a Supabase project (local via supabase start, or a hosted one).

# Local development database (Docker):
supabase start
supabase db reset        # applies every migration in order, then runs seed.sql

# Against a hosted project you've linked with `supabase link`:
supabase db push          # applies any migrations not yet on the remote

Migrations are plain numbered SQL files (supabase/migrations/20250101000000_extensions_and_enums.sql through ..._001000_storage.sql) — supabase db push/db reset apply them in filename order, which is also dependency order (extensions and enums before tables; identity/access before everything that references profiles; catalog before carts/orders; orders before payments/shipments; and so on). Re-running against a database that already has them applied is a no-op — the CLI tracks which migrations have already run.

If you don’t have the CLI available, every file is also just SQL: open the Supabase Dashboard’s SQL Editor and run each migration file’s contents in the same numeric order, once each.

One manual step outside the migrations: set ADMIN_BOOTSTRAP_EMAILS (see .env.local.example) before your own first sign-up, so the app grants your account super_admin on first login. Every admin role after that is granted from /admin/team (user_roles, team:write-gated), not through an env var.

How to seed the database

supabase db reset          # runs migrations + supabase/seed.sql automatically

or, against an already-migrated database:

supabase db execute -f supabase/seed.sql
# or paste the file's contents into the Dashboard's SQL Editor

seed.sql populates exactly the storefront-facing data a fresh environment needs: 6 categories, 4 collections, 22 products (with variants, images, and per-product inventory — two intentionally low-stock, one intentionally out-of-stock, so the admin dashboard’s widgets have something to show), 12 homepage sections, 3 hero slides, the store settings row, and 10 sample reviews across a handful of products. It does not seed profiles, orders, carts, or wishlists — those all key off a real auth.users.id, and fabricating one bypasses Supabase Auth entirely without producing anything you could actually sign in as. Create a real account through the app’s own sign-up flow instead. No row in the seed contains real customer personal information.

The seed script runs inside a single BEGIN/COMMIT, so a failure partway through leaves the database untouched rather than half-seeded.

Storage buckets

Created by 0010_storage.sql, not the Dashboard UI — six buckets, each with a file-size limit and a MIME-type allowlist enforced at the bucket level (independent of whatever the client-side uploader already checks):

Bucket Public Limit Allowed types
product-images Yes 5 MB jpeg, png, webp, gif, svg
category-images Yes 5 MB jpeg, png, webp, gif, svg
content-images Yes 5 MB jpeg, png, webp, gif, svg
review-images Yes 5 MB jpeg, png, webp, gif
invoices No 10 MB pdf
return-evidence No 5 MB jpeg, png, webp, gif

Path convention (enforced by storage.objects RLS, not just documented): product-images/{product_id}/{filename}, category-images/{category_id}/{filename}, content-images/{context}/{filename}, review-images/{user_id}/{review_id}/{filename}, invoices/{order_id}/{filename}, return-evidence/{user_id}/{return_id}/{filename}.

Environment variables

See .env.local.example for the full list. The three this schema adds requirements around:

Security decisions

Relationship to the in-memory stores

Every src/lib/admin/*-store.ts, src/lib/orders/store.ts, and src/lib/account/*-store.ts module already documents, in its own header comment, the real table this schema now defines for it. The swap-over path is: replace each store function’s body with the matching src/lib/db/** call (or add one following the same pattern for a resource not yet covered), leaving every call site — API routes, Server Components — unchanged, since the function signatures were written to match 1:1. This schema, its RLS, and src/lib/db/**’s error handling are the complete, ready-to-run target for that swap; only NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and running the migrations stand between this environment and actually doing it.