Unveil

Don’t just send it. Unveil it. Unveil is a personal celebration and surprise platform. People use it to build an interactive digital surprise (photos, videos, voice notes, letters, puzzles, games, countdowns and animated reveals) for someone they care about, then send it as a private link. The person receiving it doesn’t need an account or an app: they open the link, tap, unwrap and play through a story made just for them.


Contents

  1. Product overview (non-technical): what it does, who uses it, the main journeys
  2. Architecture: system diagram, data model, how requests are secured
  3. Setup and deployment: local setup, Supabase, Edge Functions, first-time checklist, scripts, troubleshooting
  4. Security model, Project structure and Routes
  5. Feature reference (technical detail per feature): Authentication and profiles · People · Occasions · Memory Vault · Experiences (data layer) · Experience Builder · Interactive blocks and recipient responses · Recipient experience · Scheduling and Surprise Drop · Collaborative contributions · Group Secret · Interactive scenes · Template library · Quick Create · Help me write · AI editing in the builder · AI infrastructure · Admin console and payments · About page · Installable app and link previews
  6. Design system, Testing, Adding a feature and Roadmap

Product overview

What Unveil is

A text message is read in a second and forgotten in a scroll. Unveil turns the same words into a small ceremony: a gift to unwrap, balloons to pop, a code to crack, photos that reveal one at a time, a letter that types itself out, and a final surprise at the end. The sender builds it in minutes from a template (or from scratch), adds their own memories, and shares a private link.

Who uses it

Role Needs an account? What they do
Visitor / guest No Reads the landing and About pages, and tries Quick Create at /create up to the preview. Signing up keeps their draft.
Creator (a signed-up user) Yes Keeps people, occasions and memories; builds, previews, schedules, publishes and shares experiences; sees responses; invites friends to contribute.
Collaborator (editor or viewer) Yes Is added to someone else’s experience. Viewers can see it; editors will be able to edit it (the builder currently shows them a “coming soon” screen).
Recipient No Opens their personal link and plays through the surprise. Answers, hints and completion are recorded for the creator.
Contributor (a friend) No Opens an invite or group link and adds messages, photos, videos, memories, answers or voice notes for the creator to approve.
Admin Yes, plus an app_admins row Uses the admin console: analytics, moderation, templates, gift products, payments and coupons, the About page, AI settings and the audit log.

Main features at a glance

Area What it gives people
People and occasions A private list of loved ones (photos, relationship, birthday, favourite things, notes) and the days worth celebrating, with yearly recurrence, a calendar and “coming up” reminders on the dashboard.
Memory Vault A private home for photos, videos, voice notes, stories, quotes and links, with a timeline, filters, duplicate detection, and AI memory stories that weave chosen memories into one narrative.
Quick Create Five guided steps: who it’s for → pick an experience → personalise → preview → publish. No builder needed, and guests can try it without an account.
Template library 31 ready-made interactive stories across birthdays, romance, anniversaries, friendship, family, graduation, thank-yous, farewells, apologies and “just because”.
Experience Builder A full visual editor: pages and interactive scenes, 15 block types, drag and drop, themes, animations, background music, stickers, autosave and validation before publishing.
Interactive scenes Balloons, piñatas, scratch cards, combination locks, jigsaws, hidden hunts, flip cards, “Will you…?” questions where No runs away, sign-and-stamp promises, letters and final reveals.
Sharing One private link per recipient, a device share sheet, live responses, and opens and completion tracked.
Scheduling and Surprise Drop Open at a chosen time in a chosen time zone, close automatically, or release the story in up to 10 timed stages with countdowns between them.
Friends and Group Secret Personal or group invite links for friends to contribute, owner approval, and a surprise that stays completely hidden from the recipient until the reveal.
AI help (optional) Help me write, AI edits of any block, a full experience generator, quiz and puzzle generation, and memory stories. Everything also works without AI.
Payments (optional) A price per experience with Razorpay checkout, coupons and gift products before links open.
Admin console Analytics, users and experiences moderation, reports, templates, gift products, payments, coupons, subscriptions, the About page, AI usage, system health and a tamper-resistant audit log with an audited purge option.
Look and feel A warm, premium design, light and dark mode, mobile-first layouts, reduced-motion support and keyboard-friendly interactions.

Glossary

Term Meaning
Experience The surprise itself: a titled, themed set of pages. Stored as experiences → experience_pages → experience_blocks.
Block One piece of content on a page: text, photo, video, audio, quote, message, question, quiz, puzzle, reveal, countdown, button, divider, memory or contributions.
Scene A page that plays as one interactive step of a story (for example “pop the balloons”). An experience with any scenes plays as a story.
Template A ready-made story (scenes with {placeholders}) that is copied into a new experience. Editing the copy never changes the template.
Recipient link /x/<code> (7 letters and digits): a private, hard-to-guess link for one person. Opening it counts as their visit.
Stage / Surprise Drop Groups of pages that unlock at later times, each with its own countdown.
Group Secret A group surprise that the recipient can’t see at all until its reveal time, while friends keep contributing.
Contribution Something a friend sends through an invite link; it appears only after the creator approves it.
Memory An item in the Memory Vault. Experiences reference memories by id; files are never copied.

How it works (creator’s journey)

flowchart TD
    A([Visitor lands on Unveil]) --> B{Has an account?}
    B -- No --> C["Try Quick Create at /create<br/>(who, experience, personalise, preview)"]
    C --> D[Sign up or sign in]
    D --> E[Onboarding: name, photo, time zone]
    E --> F["Guest draft carried over<br/>to Quick Create"]
    B -- Yes --> G[Dashboard]
    G --> H{How to create?}
    H -- Quick --> F
    H -- Template --> I[Template library]
    H -- From scratch --> J[New experience]
    H -- With AI --> K[AI experience generator]
    F --> L[Personalise and preview]
    I --> M[Experience Builder]
    J --> M
    K --> M
    L -- Customize more --> M
    M --> N{Publish now or schedule?}
    L --> N
    N --> O{Payments on?}
    O -- Yes --> P["Checkout: gifts, coupon, Razorpay"]
    O -- No --> Q[Links unlocked]
    P --> Q
    Q --> R[Copy or share each recipient's private link]
    R --> S["Watch responses, opens and completion"]
    R --> T[Optional: invite friends to contribute]

How it works (recipient’s journey)

flowchart TD
    A(["Recipient opens /x/code"]) --> B{Is the link valid and live?}
    B -- "Invalid, private, removed or unrevealed secret" --> X["Something special is on its way"]
    B -- "Scheduled for later" --> Y["Countdown: Your surprise arrives in…<br/>opens by itself"]
    B -- "Closed / expired" --> Z[This surprise has closed]
    B -- "Payment still needed" --> X
    B -- Live --> C["Welcome screen<br/>Open your surprise"]
    C --> D["Gift unwraps; pages load<br/>(opened_at recorded)"]
    D --> E["Play scenes: tap, pop, scratch,<br/>answer, solve, reveal"]
    E --> F{More stages locked?}
    F -- Yes --> G["Your next surprise arrives in…<br/>countdown, then continues"]
    G --> E
    F -- No --> H["Final reveal<br/>(completed_at recorded)"]
    H --> I[Replay or start over]

How it works (friends contributing)

flowchart LR
    A[Creator creates invite links] --> B["Personal link per friend<br/>or one group link"]
    B --> C["Friend opens /c/code<br/>(no account)"]
    C --> D["Sends message, photo, video,<br/>memory, answer or voice note"]
    D --> E[(Pending)]
    E --> F{Creator reviews}
    F -- Approve --> G["Shown in the Contributions block<br/>when that page opens"]
    F -- Reject or hide --> H[Not shown to the recipient]

Architecture

System overview

Unveil is a single-page React app talking directly to Supabase. Row Level Security in Postgres is the authorisation layer for everything a user owns. Work that needs secrets or must not be trusted to the browser (AI, admin actions, payments, recipient and contributor links) runs in Postgres security-definer functions or Supabase Edge Functions.

flowchart LR
    subgraph Browser["Browser (React 19 SPA, Vite)"]
        UI[Pages and components]
        RQ[TanStack Query cache]
        SB[supabase-js client<br/>anon key only]
    end

    subgraph Supabase
        AUTH[Auth<br/>email + password, PKCE]
        PG[(Postgres<br/>RLS, triggers, RPCs)]
        ST[(Storage<br/>avatars, memories,<br/>contributions, site-assets)]
        VAULT[(Vault<br/>AI and Razorpay secrets)]
        subgraph EF[Edge Functions - Deno]
            AIG[ai-generate]
            AIA[ai-admin]
            ADM[admin-console]
            PAY[payments]
            WH[payments-webhook]
        end
    end

    AIP[("Gemini / OpenAI /<br/>Anthropic APIs")]
    RZP[(Razorpay)]

    UI --> RQ --> SB
    SB -->|sign in, sessions| AUTH
    SB -->|tables under RLS,<br/>token-checked RPCs| PG
    SB -->|signed URLs,<br/>owner-folder uploads| ST
    SB -->|JWT| EF
    AIG --> AIP
    AIA --> AIP
    PAY --> RZP
    RZP -->|order.paid,<br/>payment.captured| WH
    EF -->|service role| PG
    EF --> VAULT

Which layer handles what

Kind of request Path Why
A user’s own data (people, occasions, memories, experiences, stories) Browser → Postgres tables RLS policies and column grants allow only the owner (and collaborators where designed).
Recipient links and contributor links (no account) Browser (anon) → security-definer RPCs The RPC checks the token, the schedule, moderation and payment status, and strips hidden answers before returning anything.
Private files Browser → Storage with short-lived signed URLs Buckets are private and policies check the owner folder or the token-issued upload ticket.
AI Browser → ai-generate / ai-admin → provider Keys stay in Vault; the server checks plan, task and usage limits and validates every reply.
Admin console Browser → admin-console Every call re-checks app_admins; admin tables have no browser policies; every change is audited.
Payments Browser → payments → Razorpay; Razorpay → payments-webhook Amounts are computed on the server; payment signatures are verified before an experience is unlocked.

Data model (core tables)

erDiagram
    AUTH_USERS ||--|| PROFILES : has
    AUTH_USERS ||--o{ PEOPLE : owns
    AUTH_USERS ||--o{ OCCASIONS : owns
    AUTH_USERS ||--o{ MEMORIES : owns
    AUTH_USERS ||--o{ MEMORY_STORIES : owns
    AUTH_USERS ||--o{ EXPERIENCES : owns
    PEOPLE ||--o{ OCCASIONS : "celebrated on"
    PEOPLE ||--o{ MEMORIES : "appears in"
    PEOPLE ||--o{ EXPERIENCES : "made for"
    OCCASIONS ||--o{ EXPERIENCES : "made for"
    EXPERIENCES ||--o{ EXPERIENCE_STAGES : "drops in"
    EXPERIENCES ||--o{ EXPERIENCE_PAGES : contains
    EXPERIENCE_STAGES ||--o{ EXPERIENCE_PAGES : groups
    EXPERIENCE_PAGES ||--o{ EXPERIENCE_BLOCKS : contains
    MEMORIES ||--o{ EXPERIENCE_BLOCKS : "referenced by id"
    EXPERIENCES ||--o{ EXPERIENCE_RECIPIENTS : "shared with"
    EXPERIENCES ||--o{ EXPERIENCE_CONTRIBUTORS : "collaborators"
    EXPERIENCE_RECIPIENTS ||--o{ EXPERIENCE_INTERACTIONS : answers
    EXPERIENCE_BLOCKS ||--o{ EXPERIENCE_INTERACTIONS : "answered on"
    EXPERIENCES ||--o{ EXPERIENCE_INVITATIONS : invites
    EXPERIENCES ||--o{ EXPERIENCE_CONTRIBUTION_PROMPTS : asks
    EXPERIENCE_INVITATIONS ||--o{ EXPERIENCE_SUBMISSIONS : sends
    EXPERIENCES ||--o{ EXPERIENCE_ORDERS : "paid by"
    EXPERIENCES ||--o| EXPERIENCE_UNLOCKS : "unlocked by"
    COUPONS ||--o{ EXPERIENCE_ORDERS : discounts

Admin, AI and site tables (app_admins, admin_audit_log, user_moderation, experience_moderation, experience_reports, template_settings, gift_products, payment_settings, ai_*, site_about) have RLS with no browser policies (except public reads where noted) and are reached through Edge Functions. Every table is described in its feature section below, and every migration is listed under Supabase.

Tech stack

Concern Choice
Build / dev Vite 7 (Node 20.19 or later)
UI React 19 + TypeScript (strict, noUncheckedIndexedAccess)
Routing React Router 7 (data router, lazily loaded pages)
Styling Tailwind CSS 4 with design tokens in src/styles/index.css
Server state TanStack Query 5
Backend Supabase (Postgres, Auth, Storage, Vault, Edge Functions) with Row Level Security
Edge Functions Deno (TypeScript), bundled with esbuild for the Dashboard editor
AI providers Gemini, OpenAI, Anthropic (optional, server-side only)
Payments Razorpay (optional)
Icons lucide-react
Animation motion (motion/react, lazy domAnimation), canvas-confetti (loaded on first use); Web Audio for sound effects and music
Tests Node’s built-in test runner (node --test) for app logic and Edge Functions; SQL verification scripts for RLS

Setup and deployment

flowchart LR
    A[Install Node 20.19+<br/>and npm install] --> B[Create a Supabase project]
    B --> C[Copy URL and anon key<br/>into .env.local]
    C --> D[Run migrations 001-022<br/>in the SQL Editor]
    D --> E[Set Auth Site URL<br/>and Redirect URLs]
    E --> F[Deploy the five<br/>Edge Functions]
    F --> G[npm run dev<br/>and sign up]
    G --> H[Make yourself an admin]
    H --> I[Optional: AI keys,<br/>Razorpay, templates,<br/>About page]

Prerequisites

Getting started

npm install
cp .env.example .env.local     # then fill in values (PowerShell: Copy-Item .env.example .env.local)
npm run dev                    # http://localhost:5173

Environment variables (.env.local). Only public values belong here; everything prefixed VITE_ is bundled into the browser.

Variable Where to find it
VITE_SUPABASE_URL Supabase Dashboard → Project Settings → API → Project URL (local: printed by npx supabase start)
VITE_SUPABASE_ANON_KEY Same page, the anon / publishable key. Never the service-role or secret key: the app refuses to start with one.
VITE_SITE_URL Optional locally, set it in production: the public address of the app, e.g. https://unveil.example.com. It makes link-preview URLs absolute (Installable app and link previews).

Server secrets (service role, AI keys, ALLOWED_ORIGINS) are set on the Edge Functions, not here. Razorpay keys are saved in Admin → Payments.

Hosting the frontend: npm run build writes a static site to dist/. The included vercel.json configures Vercel to serve /index.html for direct visits to client-side routes (for example, /x/RPCdah). Other static hosts need an equivalent SPA fallback. Set the VITE_ variables at build time (including VITE_SITE_URL), serve /sw.js and /index.html with Cache-Control: no-cache so updates reach installed apps, add the site’s origin to ALLOWED_ORIGINS, and add its URL to the Supabase Auth Redirect URLs. The site must be on HTTPS to be installable.

Supabase

Hosted project: create a project at supabase.com. Copy the Project URL and the anon / publishable key from Project Settings → API into .env.local. Then apply the migrations by running each file in supabase/migrations in numeric order (001_…, 002_…, …, 023_…) in the SQL Editor. Each file runs exactly once; never edit a file that has already been run. Schema changes always go in a new, higher-numbered file.

Script Purpose
001_foundation_profiles.sql profiles table, RLS, updated_at trigger, sign-up trigger
002_storage_avatars.sql Private avatars bucket and owner-folder storage policies
003_profiles_onboarding.sql Profile fields (username, bio, timezone, onboarding) and validation
004_people.sql people table, relationship_type enum, RLS, column grants, normalisation trigger
005_occasions.sql occasions table, occasion_type enum, owner-checked person link, RLS, column grants, normalisation trigger
006_memory_vault.sql memories table, memory_type enum, owner-checked person link, RLS, column grants, private memories bucket and storage policies (needs Postgres 15+)
007_experiences.sql experiences, experience_pages, experience_blocks, experience_recipients, experience_contributors; four enums; private schema access helpers; RLS, column grants, validation/ordering triggers, reorder RPCs; cover storage policies (needs Postgres 15+)
008_experience_interactions.sql experience_interactions table and enum; recipient RPCs (open_recipient_experience, submit_recipient_answer, record_recipient_hint, complete_recipient_experience) with server-side answer checks; storage policy so recipient links can load shared media
009_experience_status_values.sql Adds expired and cancelled to experience_status. Run it on its own, before 010: Postgres can’t use a new enum value in the transaction that adds it
010_scheduling_and_drops.sql Scheduling (expires_at, schedule_timezone, status state machine in prepare_experience, private.effective_status, release_due_experiences); experience_stages table and experience_pages.stage_id for Surprise Drop; recipient RPCs redefined so nothing is sent or answerable before its time
011_contribution_block_type.sql Adds contributions to experience_block_type. Run it on its own, before 012 (same enum rule as 009)
012_collaborative_contributions.sql experience_invitations, experience_contribution_prompts, experience_submissions; three enums; owner-only RLS and column grants; private contributions bucket with upload tickets (private.contribution_uploads); token-checked RPCs open_contribution_invitation, begin_contribution_upload, submit_contribution, recipient_contributions
013_group_secret.sql experiences.group_secret (owner-only toggle, enforced by trigger); private.is_unrevealed_secret; recipient RPCs and private.recipient_access return nothing for an unrevealed secret; viewer collaborators can’t see one until it’s revealed; open_contribution_invitation gains group progress
014_ai_infrastructure.sql AI infrastructure: app_admins, ai_provider_configs, ai_task_configs, ai_plans, ai_user_plans, ai_model_prices, ai_usage_events, ai_usage_daily and the ai_usage_monthly view. All have RLS with no browser policies. Service-role RPCs handle limits, usage and Vault-stored API keys. is_app_admin() and ai_available_tasks() are for signed-in users. Enables supabase_vault
015_ai_store_key_fix.sql Fixes ai_store_provider_key key validation (014’s regex used a repetition count above Postgres’s limit of 255, so saving a key always failed)
016_experience_generator.sql ai_available_tasks() now offers experience_generation; its default max_tokens goes from 4000 to 8000 (only if unchanged)
017_ai_editing.sql personalization (used by AI rewrites in the builder) gets a default max_tokens of 3000 instead of 800 (only if unchanged)
018_group_invite_links.sql Shared group invite links: experience_invitations.shared (one per experience), the group wall on open_contribution_invitation, higher limits for the group link, and contributor counts per person
019_memory_stories.sql memory_stories (stories written from selected memories, owner-only RLS, generated_at stamped by trigger); story_generation gets a default max_tokens of 6000 instead of 1500 (only if unchanged)
020_admin_and_payments.sql Admin console and payments: admin_audit_log (normally append-only), user_moderation, experience_moderation, experience_reports + report_experience(), template_settings, gift_products, payment_settings, coupons, experience_orders, experience_unlocks, experience_payment_status(). Recipient and contributor links now also require the experience not blocked, its owner not suspended and (while payments are on) the experience unlocked. Razorpay secrets live in Vault
021_about_page_and_guest_create.sql About page and guest create: site_about (one row of JSON content, readable by everyone, written only by admin-console), the public site-assets storage bucket (admin-only uploads under about/, 5 MB, JPEG/PNG/WebP), and read access to template_settings for signed-out visitors so guest Quick Create respects hidden and featured templates
022_short_links_and_template_expiry.sql Short links: a unique 7-character short_code on experience_recipients and experience_invitations (set by trigger, existing rows backfilled) and resolve_short_link(), which turns a code into its token and allows 30 misses per 15 minutes per IP. Adds expires_at and deleted_at to template_settings, and scheduled/end times to the admin experience list
023_admin_history_purge.sql Admin-only, audited clearing of payment orders and audit history. Order deletion preserves existing experience unlocks; audit clearing leaves a new immutable purge record

Local (needs Docker):

npx supabase start             # prints the local API URL and anon key
npx supabase db reset          # re-applies migrations + seed.sql
npm run db:types               # regenerates src/lib/supabase/database.types.ts

In the Supabase Auth settings, set Site URL and add these Redirect URLs for every environment (for example http://localhost:5173/**), so the email confirmation and password reset links work.

Edge Functions

Privileged work (AI, admin console, payments) runs in five Supabase Edge Functions. Each one imports shared code from supabase/functions/_shared/, so it must be deployed with the CLI (which uploads _shared) or as a bundle (a single self-contained file). Pasting supabase/functions/<name>/index.ts into the Dashboard editor fails with Module not found ".../_shared/env.ts".

Function Used by Verify JWT
ai-admin AI settings page (/admin/ai) On
ai-generate Help me write, experience generator, AI editing, memory stories On
admin-console Admin console (/admin/...) On
payments Checkout before sharing (quote, create order, verify) On
payments-webhook Razorpay webhook (order.paid, payment.captured) Off: Razorpay can’t send a Supabase token; the function checks Razorpay’s signature instead

Secrets (Edge Functions → Secrets, or npx supabase secrets set):

Secret Required Notes
SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY Yes Provided by Supabase automatically; don’t set them yourself
ALLOWED_ORIGINS Yes (production) Comma-separated app origins allowed to call the functions, e.g. https://www.unveilme.in,https://unveilme.in,http://localhost:5173. Origins must match exactly (scheme and hostname; no trailing slash). Empty allows any origin
GEMINI_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY No Fallback AI keys; keys saved on the AI settings page take priority

Razorpay keys are not function secrets: save them in Admin → Payments (stored in Vault).

Option A: Supabase CLI

No bundling needed. Find the project ref in the Dashboard URL (https://supabase.com/dashboard/project/<project-ref>) or Project Settings → General.

npx supabase login
npx supabase link --project-ref <project-ref>

npx supabase secrets set ALLOWED_ORIGINS=https://www.unveilme.in,https://unveilme.in,http://localhost:5173
# Optional fallback AI keys
npx supabase secrets set GEMINI_API_KEY=... OPENAI_API_KEY=... ANTHROPIC_API_KEY=...

npx supabase functions deploy ai-admin ai-generate admin-console payments
npx supabase functions deploy payments-webhook --no-verify-jwt

npx supabase functions list     # all five should be ACTIVE

If the deploy complains that Docker isn’t running, add --use-api to the deploy commands to bundle on Supabase’s side.

Option B: Dashboard editor with bundles

  1. Build the bundles in a terminal at the project root:

    npm install
    npm run bundle:functions
    

    This writes one self-contained file per function to supabase/.bundle/: ai-admin.js, ai-generate.js, admin-console.js, payments.js and payments-webhook.js.

  2. For each function, in the Dashboard open Edge Functions → Deploy a new function → Via Editor (or open the existing function and its Code tab):
    1. Name it exactly as in the table above.
    2. Delete everything in the editor’s index.ts.
    3. Paste the full contents of supabase/.bundle/<name>.js. In VS Code: open the file, Ctrl+A, Ctrl+C. In PowerShell: Get-Content supabase\.bundle\admin-console.js -Raw | Set-Clipboard.
    4. Click Deploy function.
  3. Open payments-webhook → Details and turn Verify JWT with legacy secret / Enforce JWT verification off. Leave it on for the other four.

  4. Add ALLOWED_ORIGINS (and any fallback AI keys) under Edge Functions → Secrets.

Redeploying

Checking and troubleshooting

First-time setup checklist

  1. Run migrations 001–023 in order (SQL Editor), then the scripts in supabase/verification/ if you want to confirm them (each prints PASS).
  2. Deploy the five Edge Functions and set ALLOWED_ORIGINS (Edge Functions).
  3. Sign up in the app, then make yourself an admin (SQL Editor):

    insert into public.app_admins (user_id)
    select id from auth.users where lower(email) = 'you@example.com'
    on conflict (user_id) do nothing;
    

    Reload the app; Admin console and AI settings appear in the nav.

  4. AI settings: save a provider key, Test connection, enable the provider and the tasks you want (AI infrastructure).
  5. Admin → Payments (optional):
    1. Save the Razorpay Key id (rzp_test_… or rzp_live_…), then the Key secret, and click Test connection.
    2. In the Razorpay Dashboard (Account & Settings → Webhooks), add https://<project-ref>.supabase.co/functions/v1/payments-webhook with the events order.paid and payment.captured, choose a webhook secret, and save the same secret in Admin → Payments.
    3. Set the price per experience and turn payments on. Create coupons under Coupons if you want them.
  6. Admin → Templates / Gift products: optionally hide or feature templates and add gift products.
  7. Admin → About page: add each creator’s name, photo (and its framing) and links; the platform text and a creator bio are already filled in.

Scripts

Script Purpose
npm run dev Start the dev server
npm run build Type-check (tsc -b) and build for production
npm run preview Serve the production build
npm run typecheck Type-check only
npm run lint Run ESLint
npm run db:types Regenerate DB types from the local database
npm run check:functions Type-check the Edge Functions and their tests
npm run test:functions Run the Edge Function tests (Node test runner, no network)
npm run test:app Type-check and run the app’s pure-logic tests in tests/ (scenes, Quick Create, recipient playthroughs, AI editing, About, dashboard)
npm run bundle:functions Bundle each Edge Function into supabase/.bundle/<name>.js for the Dashboard editor (Edge Functions)

Troubleshooting

Symptom Cause and fix
App shows a configuration error on start VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY missing, or a service-role/secret key was used. Fix .env.local and restart npm run dev.
Email confirmation or password reset link fails Add the app URL (e.g. http://localhost:5173/**) to Supabase Auth Redirect URLs, and open the link in the same browser (PKCE).
relation ... does not exist or a page shows a load error A migration hasn’t run. Run the missing files in order; 009 and 011 must each run on their own.
unsafe use of new value when running 010 or 012 009 / 011 ran in the same transaction. Run them alone first, then the next file.
AI buttons don’t appear The provider or task is off, there’s no key, or the user’s plan has no AI. Check AI settings.
Invalid request: ... is not allowed when saving in the admin console The deployed Edge Function is older than the app. Rebuild bundles and redeploy (all five if _shared changed).
CORS error or 403 from a function Add the app’s origin to ALLOWED_ORIGINS.
Admin nav doesn’t appear Add your user to app_admins, then reload (admin status is cached for up to 10 minutes).
Recipient link shows “on its way” The experience isn’t published or shared with recipients, is scheduled for later, is blocked, its owner is suspended, it’s a Group Secret before its reveal, or payment is still needed.
Deep links 404 in production Configure the host’s SPA fallback to /index.html.
Shared links show no picture VITE_SITE_URL wasn’t set at build time, so og:image is relative. Set it, rebuild and redeploy. WhatsApp and others cache previews for days, so test with a new URL (e.g. add ?v=2).
No Install app button Already installed, not on HTTPS, a dev build (npm run dev has no service worker), or a browser that can’t install (Firefox desktop). Try npm run build && npm run preview in Chrome or Edge.
Installed app still shows an old version The host caches sw.js. Serve it with Cache-Control: no-cache; the app updates on the next launch.

See also the Edge Function checks under Checking and troubleshooting, and Admin → System health.


Security model


Project structure

supabase/
  config.toml                 Local Supabase CLI config
  migrations/                 Numbered SQL migrations 001–023 (source of truth for schema)
  verification/               SQL scripts that assert RLS and RPC behaviour (run in the SQL Editor; they roll back and print PASS)
  functions/
    _shared/                  Shared Edge Function code: env, HTTP/CORS, auth, audit, Supabase client, AI service + adapters, payments (Razorpay)
    ai-admin/ ai-generate/    AI settings and AI generation
    admin-console/            Admin console actions (analytics, moderation, templates, gifts, payments, coupons, About page, audit)
    payments/ payments-webhook/  Checkout (options, quote, create_order, verify) and the Razorpay webhook
  tests/                      Edge Function tests (Node test runner, no network)
  .bundle/                    Build output of `npm run bundle:functions` (never edit by hand)
  seed.sql                    Local-only seed data (no real personal data)
tests/                        App pure-logic tests (scenes, Quick Create, recipient flow, AI editing, About, dashboard…)
public/                       Served as-is: favicon, app icons, manifest.webmanifest, sw.js (service worker), og-image.jpg (link previews)
src/
  main.tsx                    Entry: validates env, then loads the app
  app/
    App.tsx                   Providers + RouterProvider
    AppProviders.tsx          QueryClient + Auth context
    router.tsx                Route tree (lazy pages, guards, layouts)
    paths.ts                  Central URL map — always link via `paths.*`
    navigation.ts             Sidebar / mobile nav configuration
  config/env.ts               Typed, validated public env
  lib/
    supabase/client.ts        Single typed Supabase client
    supabase/database.types.ts  Generated DB types (regenerate after migrations)
    ai/aiClient.ts            Calls to the AI Edge Functions
    theme.ts, useTheme.ts     Light / dark / system theme
    pwa.ts, usePwaInstall.ts  Service worker registration and "Install app" state
    query-client.ts           TanStack Query defaults
    cn.ts                     className helper
  hooks/                      Cross-feature hooks (e.g. useDismiss)
  styles/index.css            Tailwind + design tokens (@theme), keyframes, dark palette
  components/
    ui/                       Design-system primitives (Button, Card, Avatar, TextField, TextAreaField, SelectField, FormField, Badge, Alert, EmptyState, ConfirmDialog, Logo, Spinner)
    layout/                   AppShell, Sidebar, TopBar, MobileNav, MobileMoreSheet, UserMenu, NotificationsMenu, PageContainer, PageHeader, RootLayout
    feedback/                 Full-page / in-page loaders, configuration error screen
    pwa/                      Install app button, dashboard banner and Settings card
  features/
    auth/                     Provider, guards, API, sign-in/up, forgot/reset password, auth layout
    profile/                  Profile API, hooks, onboarding guards, avatar/timezone pickers, image preparation
    onboarding/               Post-signup onboarding wizard
    landing/                  Public landing page
    about/                    Public About page and its content model
    dashboard/                Greeting, upcoming celebrations, drafts, scheduled and completed
    people/ occasions/        Loved ones, celebrations, recurrence and calendar
    memory-vault/             Memories, uploads, timeline and memory stories
    experiences/              Data layer, blocks, renderer and player, scenes engine, template library, themes, stages
    experience-builder/       Builder UI, Quick Create, AI generator, guest drafts, writing help and AI edits
    recipient/                Recipient link page
    contribute/               Contributor invite page
    payments/                 Checkout before sharing
    settings/ system/         Settings, appearance and not-found / system pages
    admin/                    Admin console and AI settings

Routes

Path Access Page
/ Public Landing: hero, occasion categories (each opens Create with ?occasion=), an animated how-it-works demo, features
/about Public About Unveil: the platform, the creators and links (edited in Admin → About page)
/create Public Guest Quick Create: try it without an account (optional ?occasion=); signed-in users are sent to /experiences/quick
/auth/sign-in Guest Sign in
/auth/sign-up Guest Sign up
/auth/forgot-password Guest Request reset link
/auth/reset-password Link Set new password (session from email)
/onboarding Auth, not onboarded Onboarding (name, username, photo, timezone)
/dashboard Auth Dashboard
/people Auth People list (search ?q=, filter ?rel=)
/people/new Auth Add a person
/people/:personId Auth Person detail
/people/:personId/edit Auth Edit a person
/occasions Auth Occasions: list or calendar (?view=calendar), filters ?type= ?person= (none = unlinked), ?when=past, ?month=YYYY-MM, ?day=YYYY-MM-DD
/occasions/new Auth Add an occasion (optional prefill ?person= ?date= ?type=)
/occasions/:occasionId/edit Auth Edit or delete an occasion
/memory-vault Auth Memory Vault timeline: filters ?type= ?person= (none = unlinked), search ?q=, ?sort=oldest, open a memory ?memory=<id>
/memory-vault/new Auth Add a memory (optional prefill ?person= ?type=)
/memory-vault/:memoryId/edit Auth Edit or delete a memory
/memory-vault/stories Auth Your memory stories
/memory-vault/stories/new Auth Write a story from ?memories=<id>,<id> (2–20)
/memory-vault/stories/:storyId Auth Read, edit, regenerate or delete a story
/experiences Auth My Experiences (your own and shared with you; owners can delete)
/experiences/new Auth Name a new experience (optional prefill ?occasion= ?person= ?template=), then open the builder
/experiences/quick Auth Quick Create: five guided steps from occasion to a published private link, no builder needed
/experiences/templates Auth Template library: filter ?category= ?tag=, preview any template, then create from it (keeps ?occasion= ?person=)
/experiences/:experienceId/edit Auth Experience Builder (full screen, outside the app shell; owners only for now)
/experiences/:experienceId/responses Auth Share & responses: add recipients, copy their personal links, see answers live (owner only)
/experiences/:experienceId/contributions Auth Contributions: invite friends, set questions, approve or reject what they send (owner only)
/settings Auth Profile settings (edit profile, sign out)
/admin, /admin/:section Auth, admin Admin console: analytics, users, experiences, reports, templates, gift products, payments & coupons, subscriptions, About page, AI usage, system health, audit log
/admin/ai Auth, admin AI settings: providers, keys, models, tasks, plans, prices, usage (others see “Admins only”)
/x/:code Public Recipient experience, opened with a recipient’s personal link (no account needed)
/experience/:accessToken Public Earlier long form of recipient links (64-hex token); still opens the same page
/c/:code Public Contributor page, opened with a friend’s invite link (no account needed)
/contribute/:inviteToken Public Earlier long form of invite links; still opens the same page
* Public Not found

When deploying, configure the host to serve SPA fallbacks by rewriting all paths to /index.html.

“Auth” routes also require a completed onboarding (profiles.onboarded_at set). Otherwise the user is sent to /onboarding.


Authentication and profiles

flowchart TD
    A["/auth/sign-up"] -->|signUp with full_name + browser time zone| B["on_auth_user_created trigger<br/>creates the profiles row"]
    B --> C{Email confirmation on?}
    C -- Yes --> D[Confirmation email] --> E["/onboarding"]
    C -- No --> E
    E --> F["Name and username → photo → time zone<br/>sets onboarded_at"]
    F --> G{Guest Quick Create draft waiting?}
    G -- Yes --> H["/experiences/quick<br/>draft adopted and saved"]
    G -- No --> I["/dashboard"]
    S["/auth/sign-in"] --> J{Onboarded?}
    J -- No --> E
    J -- Yes --> K[Original page, Quick Create or dashboard]
    P["/auth/forgot-password"] --> Q[Reset email] --> R["/auth/reset-password"] --> S

Flow

  1. Sign up (/auth/sign-up). This calls supabase.auth.signUp with full_name and the browser’s timezone as user metadata. The on_auth_user_created trigger (security definer) creates the profiles row and falls back to UTC if the timezone is invalid.
  2. Confirm email. When confirmations are on, the link returns to /onboarding. When they are off, the user goes straight there.
  3. Onboarding has three steps: name and optional username, optional photo, then timezone. Finishing saves the profile and sets onboarded_at, then opens /dashboard.
  4. Sessions are persisted by supabase-js in localStorage and refreshed automatically. AuthProvider listens to onAuthStateChange, and signing out clears every cached query.

Guards (src/app/router.tsx)

public.profiles

Column Type Notes
id uuid PK references auth.users(id) on delete cascade
full_name text 1–100 chars, trimmed; required once onboarded
username text optional, unique, ^[a-z0-9_]{3,30}$, lower-cased by trigger
avatar_url text object path in the private avatars bucket (<user_id>/…); the client resolves it to a 1-hour signed URL
bio text optional, ≤ 280 chars
timezone text IANA name validated against pg_timezone_names; defaults to UTC
onboarded_at timestamptz null until onboarding is finished
created_at / updated_at timestamptz updated_at is maintained by a trigger

Access rules

Avatars

Username availability

Verifying

  1. Apply the migrations by running each file in supabase/migrations in numeric order in the SQL Editor.
  2. RLS check: paste supabase/verification/profiles_rls_check.sql into the SQL Editor and run it.
  3. Manual checklist (npm run dev):

People

Users keep a private list of the people they care about (src/features/people).

public.people

Column Type Notes
id uuid PK generated by the client on create so the photo can be uploaded to its folder first
user_id uuid defaults to auth.uid(); not writable by clients; cascades on user delete
name text required, 1–100 chars, trimmed
nickname text optional, ≤ 60 chars
relationship_type enum partner, spouse, parent, child, sibling, best_friend, friend, colleague, teacher, relative, other
avatar_url text storage path avatars/<user_id>/people/<person_id>/…, enforced by a check constraint
birthday date optional, 1900 or later (the form also rejects future dates)
anniversary_date date optional, 1900 or later
notes text optional, ≤ 5000 chars
favorite_things text[] ≤ 50 items, each ≤ 80 chars; trimmed and de-duplicated (case-insensitive) by trigger
created_at / updated_at timestamptz updated_at maintained by trigger

Access rules: RLS policies for select, insert, update and delete all require user_id = auth.uid(). Column grants stop clients from setting user_id or the timestamps. anon has no access.

UI

Verifying: after running 004_people.sql, run supabase/verification/people_rls_check.sql in the SQL Editor. It should return one row: PASS: all people RLS checks passed.


Occasions

The days worth celebrating (src/features/occasions).

public.occasions

Column Type Notes
id uuid PK gen_random_uuid()
user_id uuid defaults to auth.uid(); not writable by clients; cascades on user delete
person_id uuid optional; composite FK (person_id, user_id) → people(id, user_id), so an occasion can only link to one of your people; cascades when the person is deleted
title text required, 1–120 chars, trimmed by trigger
occasion_type enum birthday, anniversary, graduation, wedding, engagement, promotion, new_job, farewell, thank_you, mothers_day, fathers_day, valentines_day, friendship, achievement, custom
date date 1900-01-01 to 2200-12-31; for recurring occasions this is the first/original date
recurring boolean default true
reminder_days_before integer 0–365 or null (no reminder); default 7
notes text optional, ≤ 2000 chars; blank becomes null
created_at / updated_at timestamptz updated_at maintained by trigger

Access rules: RLS policies for select, insert, update and delete all require user_id = auth.uid(). Column grants stop clients from writing id, user_id or the timestamps. anon has no access. The composite foreign key matters because referential checks bypass RLS; without it a user could link an occasion to someone else’s person id.

Recurrence (no duplicated rows)

Profile dates: birthdays and anniversaries saved on a person appear automatically as occasions; they are not copied into the table. If you save an occasion for the same person, type and day, it replaces the profile entry in lists (useful for a custom name, reminder or notes). Editing a profile entry opens the person’s edit page.

UI

Verifying: after running 005_occasions.sql, run supabase/verification/occasions_rls_check.sql in the SQL Editor. It should return one row: PASS: all occasions RLS checks passed.


Memory Vault

A private home for photos, videos, voice notes, stories, quotes and links (src/features/memory-vault).

public.memories

Column Type Notes
id uuid PK gen_random_uuid()
user_id uuid defaults to auth.uid(); not writable by clients; cascades on user delete
person_id uuid optional; composite FK (person_id, user_id) → people(id, user_id), so a memory can only link to one of your people. Deleting the person sets it to null (on delete set null (person_id), Postgres 15+), so the memory is kept
type enum photo, video, audio, text, quote, link; fixed once created
title text required, 1–120 chars, trimmed by trigger
description text optional (required for text and quote), ≤ 5000 chars; blank becomes null
memory_date date optional, 1900–2200 (the form also rejects future dates)
location text optional, ≤ 200 chars
metadata jsonb object, ≤ 4 KB. Media: storage_path, mime_type, size, width, height, duration. Quote: author. Link: url
created_at / updated_at timestamptz updated_at maintained by trigger

Check constraints make the metadata trustworthy: photo/video/audio rows must reference <user_id>/<sha256>.<ext> inside the owner’s own folder with a MIME type matching the memory type; other types can’t carry a storage path; links must be http(s) and ≤ 2048 chars.

Access rules: RLS policies for select, insert, update and delete all require user_id = auth.uid(). Column grants stop clients from writing id, user_id, type (after insert) or the timestamps. anon has no access.

Storage (memories bucket)

No duplicated media

UI

Verifying: after running 006_memory_vault.sql, run supabase/verification/memories_rls_check.sql in the SQL Editor. It should return one row: PASS: all memories RLS checks passed. Storage policies are checked structurally there. Check uploads by hand: add the same photo twice (the second time should say it’s reused), and delete one memory (the file should stay until the last memory using it is deleted).

Turn memories into a story

On /memory-vault, Turn memories into a story (shown when the story_generation AI task is available) lets you pick 2–20 memories. You then choose a tone (heartfelt, funny, playful, emotional) and a length (short, medium, long), and the AI connects them into a titled story with an introduction, sections (each built on one or more memories, with a caption per memory) and a closing.

public.memory_stories: title (1–120), content (jsonb: generated, introduction, sections[{id, heading, text, memory_ids, captions}], conclusion, edited; ≤ 64 KB), memory_ids (2–20), tone, length, generated_at (set by trigger when the story is written or regenerated). Owner-only RLS; clients can’t write user_id, generated_at or the timestamps.

Verifying: after 019, run supabase/verification/memory_stories_check.sql. It should return PASS: all memory story checks passed.


Experiences (data layer)

The database architecture for interactive celebrations (src/features/experiences). The visual editor is described in Experience Builder.

Tables

Table Key columns Notes
experiences user_id (owner), person_id, occasion_id, title, description, status, visibility, cover_image_url, theme_id, scheduled_at, published_at Person and occasion use composite FKs to the owner’s own rows and are unlinked (on delete set null) when those are deleted. title 1–120, description ≤ 2000, theme_id is an app theme slug. cover_image_url is a path in the owner’s memories bucket (<user_id>/<sha256>.<ext>), not a copy
experience_pages experience_id, position, title, background (jsonb) Ordered screens. background is an object ≤ 8 KB, e.g. {"color": …} or {"memory_id": …} (photo/video)
experience_blocks page_id, type, position, content (jsonb), settings (jsonb) type is one of text, image, video, audio, button, divider, countdown, reveal, quote, question, quiz, puzzle, memory, message and is fixed after insert. content (≤ 64 KB) is what’s shown, settings (≤ 16 KB) is how it behaves, including expected answers
experience_recipients experience_id, name, email, phone, access_token, opened_at, completed_at Name trimmed, email lower-cased and unique per experience, phone reduced to +digits. access_token is 64 random hex characters generated by the database; short_code is the 7-character code used in /x/<code> links
experience_contributors experience_id, user_id, role editor or viewer; one row per user; the owner can’t be added

Access (RLS + column grants)

  Owner Editor Viewer Anyone else
See the experience, pages, blocks, team ✓ ✓ ✓ –
Edit title, description, cover, theme ✓ ✓ – –
Add, edit, reorder, delete pages and blocks ✓ ✓ – –
Change status, schedule, visibility, person, occasion ✓ – – –
Delete the experience ✓ – – –
See and manage recipients (contact details, link tokens) ✓ – – –
Invite, re-role, remove contributors ✓ – – –
Leave – ✓ ✓ –

Lifecycle: new experiences start as draft. scheduled requires a future scheduled_at. Publishing sets published_at. archived hides it from active use. Migration 010 adds the full state machine and automatic opening at scheduled_at (see Scheduling and Surprise Drop).

Ordering: new pages and blocks are always appended (under an advisory lock, max 100 per experience/page). reorder_experience_pages(experience_id, page_ids[]) and reorder_experience_blocks(page_id, block_ids[]) take the complete new order and renumber positions in one statement. A stale list is rejected.

Media is referenced, never copied: blocks and page backgrounds point to Memory Vault memories by memory_id. A trigger checks that the memory belongs to the experience’s owner, and that image/video/audio blocks use a memory of that kind. A file used as a cover can’t be deleted from storage, and contributors can read covers of experiences they can see. Button links must be http(s).

TypeScript

Built on top of this layer later: recipient viewing through token-checked RPCs that strip answers (008), shared media for recipients (008), scheduling and automatic opening (010), friend contributions through invite links (012) and payments (020).

Still open: inviting collaborators (editors and viewers) by email; the API takes a user id.

Verifying: after running 007_experiences.sql, run supabase/verification/experiences_rls_check.sql in the SQL Editor. It uses an owner, an editor, a viewer and a stranger, and should return one row: PASS: all experiences RLS checks passed.


Experience Builder

A visual storytelling editor on top of experience_pages and experience_blocks (src/features/experience-builder). No new SQL is needed.

Layout

Editing

Autosave (hooks/useAutosave.ts)

One renderer for every mode (src/features/experiences/renderer)

Blocks (renderer/blocks/*, editors in experience-builder/components/BlockSettings.tsx)

Every block reads experience_blocks.content / settings defensively (renderer/values.ts), because stored JSON isn’t validated field by field. Types and defaults live in src/features/experiences/blocks.ts. In edit mode, empty blocks show a dashed placeholder; for recipients they render nothing.

Block content settings Notes
Text text size (body, lead, title), align A title renders as an h2.
Image memory_id, caption, alt fit (cover, contain), aspect (landscape, square, portrait) Photo from the vault. Intrinsic sizes reserve space.
Video memory_id, caption autoplay (muted), loop Uses the video’s own aspect ratio, capped at 70vh.
Audio memory_id, caption autoplay, loop Shows the recording’s duration.
Quote text, author align  
Button label, url action (next_page, page, link), page_id, variant, full_width Recipients don’t see buttons that can’t go anywhere.
Divider – style (line, flourish, space)  
Countdown target_at (ISO), label, ended_text show_target Updates every second, then shows ended_text.
Memory memory_id, note show_date Any memory type: photo, video, audio, story, quote or link.
Message greeting, text, from style (letter, card, note)  
Reveal teaser, text, memory_id gesture (tap, hold, scratch) The hidden content stays inert until the recipient interacts. Scratch is a canvas cover (ScratchCard.tsx) that clears at 50%. Keyboard users get a “Reveal without scratching” button.

Validation (src/features/experiences/validation.ts)

Publishing: Publish sets published, and Schedule sets scheduled with a future scheduled_at in your local time. Unpublish returns the experience to draft. Only owners can change status.

Themes (src/features/experiences/themes)

Animations (themes/motion.ts, renderer/motion, renderer/CelebrationLayer.tsx)

Not built yet

AI generation, scheduling and staged unlocks have since been built: see AI editing in the builder, AI infrastructure and Scheduling and Surprise Drop.


Interactive blocks and recipient responses

Blocks

Block Sender sets Recipient sees
Question content.prompt, placeholder; optional expected settings.answer (+ case_sensitive) An open answer box (up to 1000 characters). Without an expected answer, any reply is accepted.
Quiz content.question, options; optional settings.correct_index and explanation The choices. A wrong pick shakes and can be retried; the right one gets a success badge and the explanation.
Puzzle content.prompt (clue), optional hint and picture clue; settings.solution (required) and optional success_message A guess box, a hint button, and a solved panel with the success message.

Correct answers get a spring-in badge with a drawn tick, a ring pulse and (for puzzles) confetti; wrong answers shake. All of it honours prefers-reduced-motion.

Recipient flow

  1. The owner opens Share (on My Experiences or in the builder header), adds a recipient by name, and copies their personal link /x/<code>. Adding the first recipient switches visibility to recipients.
  2. The link works while the experience is published (or scheduled and due) and visible to recipients. Unpublishing or removing the recipient closes it.
  3. The recipient page talks only to security-definer RPCs; anon has no table access. open_recipient_experience returns the pages with expected answers, explanations and success messages stripped out, plus the recipient’s earlier progress. submit_recipient_answer checks the answer on the server and only then returns the feedback.
  4. Reaching the last page marks the recipient as finished.

What’s stored (experience_interactions: id, experience_id, page_id, block_id, recipient_id, interaction_type, response, created_at)

Responses: the Share page lists every question, quiz and puzzle in page order with each recipient’s latest answer, tries, and hint use. Each person is labelled In progress or Finished, and the page refreshes every 20 seconds while it’s open.

Verifying: after running 008_experience_interactions.sql, run supabase/verification/interactions_rls_check.sql in the SQL Editor. It should return one row: PASS: all interactions checks passed.

Not built yet: word puzzles, and email/SMS delivery of links. (AI puzzle and quiz generation are in AI editing in the builder; the photo jigsaw is an interactive scene.)

Recipient experience

Recipients open /x/<code> without signing in. The code is 7 letters and digits (about 3.5 trillion possibilities), generated by the database and unique across recipient and invite links. The page first calls resolve_short_link('recipient', code) to get the 64-hex access token and then works with the token as before. Anything that isn’t a valid code or token shows the “on its way” screen without calling the server. After 30 unknown codes in 15 minutes from one IP address the resolver refuses further lookups (HTTP 429) and the page says to try again later. Older /experience/<64-hex token> links still work.

sequenceDiagram
    actor R as Recipient
    participant P as Recipient page
    participant DB as Postgres RPCs
    participant S as Storage
    R->>P: Opens /x/code
    P-->>R: Welcome screen (nothing fetched yet)
    R->>P: Open your surprise
    P->>DB: open_recipient_experience(token)
    DB->>DB: Check token, effective status, stage unlocks,<br/>moderation, owner suspension, payment unlock
    DB-->>P: Unlocked pages and blocks (answers stripped), sets opened_at
    P->>S: Signed URLs for shared media
    P-->>R: Gift unwraps, story plays
    R->>P: Answers a question or asks for a hint
    P->>DB: submit_recipient_answer / record_recipient_hint
    DB-->>P: Correct or not, plus earned feedback
    R->>P: Reaches the end
    P->>DB: complete_recipient_experience(token)
    DB-->>P: completed_at recorded

Recipient playthroughs (tests/recipient-flow.test.ts) build one template per major category (birthday, romantic, friendship, anniversary, graduation, thank-you, farewell) as a creator would, strip the settings the way open_recipient_experience does, and walk every route (every answer of branching questions). They check each route opens with a welcome, passes the expected scenes and interactions, can be completed without the hidden answers, ends in a replayable finale, and resumes after a refresh.

Sharing (sender side): after Publish or Schedule in the builder, an “It’s live!” (or “It’s scheduled!”) dialog shows each recipient’s private link with Copy link and Share. If there’s no one yet, it asks who it’s for (pre-filled from the person) and creates their link, turning recipient links on if the experience was private. It also explains that anyone with a link can open it, that opening it yourself counts as their visit (use Preview), and that edits keep showing up after publishing. The Dashboard shows Drafts, Scheduled surprises and Recently completed (who finished what); each recipient’s progress and Copy link / Share buttons are on the Share & responses page. Share uses the device share sheet where there is one. The same buttons are on the Share & responses page. The owner can’t open a recipient’s link from the app, because opening it counts as the recipient’s visit.

Scheduling and Surprise Drop

Status state machine (enforced by the prepare_experience trigger, not the browser):

stateDiagram-v2
    [*] --> draft
    draft --> scheduled: Schedule
    draft --> published: Publish now
    draft --> archived
    scheduled --> scheduled: Change time
    scheduled --> published: Publish now
    scheduled --> cancelled: Cancel delivery
    scheduled --> draft
    scheduled --> archived
    published --> expired: Close now or expires_at passes
    published --> draft: Unpublish
    published --> archived
    expired --> draft
    expired --> scheduled
    expired --> published
    expired --> archived
    cancelled --> draft
    cancelled --> scheduled
    cancelled --> published
    cancelled --> archived
    archived --> draft
    note right of scheduled
        Recipients see it as published once
        scheduled_at passes (effective_status)
    end note
From Allowed to
draft scheduled, published, archived
scheduled scheduled (edit time), published (publish now), cancelled, draft, archived
published expired (close now), draft (unpublish), archived; end time can be edited
expired / cancelled draft, scheduled, published, archived
archived draft

Ending an experience: on My Experiences, each scheduled or live experience has Set expiry (the calendar icon): pick an end time, clear it, or (when live) End it now. Each card also has Delete. Admins can do the same for any experience in Admin → Experiences (see Admin console and payments).

Surprise Drop: Drop in the builder header adds up to 9 later stages after Stage 1, each with an optional title and an unlock date and time. The last of two or more is called “Final surprise”. Each page belongs to a stage: pick it in Page settings, or tap a stage chip in the page tabs. New pages join the stage you’re on. Removing a stage moves its pages to the stage before it.

Verifying: after running 009 and then 010, run supabase/verification/scheduling_check.sql. It should return PASS: all scheduling checks passed.

Not built yet: email and SMS delivery at the scheduled time. Links are still shared by the sender.

Collaborative contributions

A creator can invite friends to add to an experience. Open Friends in the builder header (or Contributions on the Share & responses page).

sequenceDiagram
    actor O as Creator
    actor F as Friend
    participant App as Unveil app
    participant DB as Postgres RPCs
    participant S as contributions bucket
    actor R as Recipient
    O->>App: Create personal or group invite link, set questions
    App->>DB: Insert experience_invitations (owner-only RLS)
    O-->>F: Shares /c/code
    F->>App: Opens the link (no account)
    App->>DB: open_contribution_invitation(token)
    DB-->>App: Prompts, own submissions, group wall
    F->>App: Adds a photo or voice note
    App->>DB: begin_contribution_upload(token)
    DB-->>App: Single-use upload path
    App->>S: Upload the file
    App->>DB: submit_contribution(token, ...)
    DB-->>App: Saved as pending
    O->>App: Approve on the Contributions page
    R->>DB: recipient_contributions(token) when the page opens
    DB-->>R: Approved items only

Verifying: after running 011 and then 012, run supabase/verification/contributions_check.sql. It should return PASS: all contribution checks passed.

Besides personal links, the creator can make one group link per experience (the Group link card above Invite friends) and post it in a group chat.

Verifying: after 018, run supabase/verification/group_link_check.sql. It should return PASS: all group link checks passed.

Not built yet: automatic AI compilation of contributions, and emailing invitations.

Group Secret

A collaborative surprise that stays hidden from the recipient until a reveal time, for example “Rahul’s 30th Birthday”. Turn on Group Secret when creating the experience (or later from the Contributions page). The builder header’s Secret button opens the dashboard.

Verifying: after 013, run supabase/verification/group_secret_check.sql. It should return PASS: all group secret checks passed.

Interactive scenes

Any page can become a scene: one step of an interactive story. As soon as one page of an experience is a scene, the experience plays as a story, one scene at a time. Experiences without scenes use the original page player, unchanged.

Template library

Ready-made interactive stories, played by the scene engine above. A template is configuration, not a page: an ordered list of scenes (type, settings, blocks and links) with {token} placeholders. There is no per-template code or route, and no migration.

Quick Create

A guided way to make an experience without the builder (/experiences/quick, linked from the Dashboard, My Experiences and New experience). It is a front end for the template library: the result is an ordinary experience with ordinary pages and blocks, played by the same recipient renderer. No migration.

flowchart LR
    subgraph Guest["/create (no account)"]
        G1[Who it's for] --> G2[Experience] --> G3[Personalize] --> G4[Preview]
        G4 --> G5{"Sign up free to save & share<br/>or I have an account"}
    end
    subgraph Account["/experiences/quick"]
        A1[Who it's for] --> A2[Experience] --> A3[Personalize] --> A4[Preview] --> A5[Publish]
    end
    G5 -->|"guest draft adopted<br/>after sign-in / onboarding"| A5
    A3 -->|Customize more| B[Experience Builder]
    B -->|Back to Quick Create| A3
    A5 --> C{Payment required?}
    C -- Yes --> D[Checkout] --> E[Private link + Copy / Share]
    C -- No --> E
  1. Who it’s for: their name, your name or signature (prefilled with your first name), relationship, occasion (the template categories) and an optional date and note.
  2. Experience: the occasion’s templates with animated thumbnails, what they include (puzzles and games, questions, photo memories, letters, animated reveals), interaction tags, minutes and scene count. Try it plays the template with the names already filled in; Surprise me picks one at random, which can be changed. See every occasion widens the list.
  3. Personalize: the template’s own fields (TemplateFields), the opening greeting and final title, photos and captions (MediaField: vault, upload or camera), optional rewording of questions and quiz answers, the theme (ThemePanel) and a colour preset. Every box shows what’s used if left blank. Customize more saves a draft and opens the builder.
  4. Preview: ExperiencePlayer exactly as the recipient sees it, with phone and desktop frames, a scene picker, restart and full screen. Missing required fields are listed with Fix buttons.
  5. Publish: it stays a draft until Publish and get the link. Publishing reuses the first recipient (or adds one with their name), sets visibility to recipients, publishes with no end time and shows the /x/<code> link with Copy and Share, a note that anyone with the private link can open it, and that opening it yourself counts as their visit.

How it saves (experience-builder/hooks/useQuickCreate.ts, logic in experiences/scenes/quick-create.ts):

Without an account (/create, GuestCreatePage): visitors can do the first four steps (who, experience, personalize, preview) with no sign-up.

Tests: tests/quick-create.test.ts checks draft parsing, that every template builds a complete story without photos, photo scenes, greeting, finale and colours, question edits, scratch-card photos, Surprise me, what’s missing, signatures and carrying a guest draft over.

Help me write

Optional writing help in Quick Create and the builder. AI is never required: every field can be typed, every helper offers ready-made ideas that need no AI, and without AI (no provider, task off, limit reached or an error) the AI button is replaced by a note and everything else keeps working.

AI editing in the builder

When a block is selected, Edit with AI at the top of its settings lists the actions that block offers and that the creator’s plan allows. It’s hidden when AI is off.

Block Actions Field written
Text, Message Improve writing, Make funnier, Make more emotional, Make shorter, Make more romantic, Create message content.text
Reveal the rewrites, Create reveal content.text (Create reveal also writes teaser)
Image, Video the rewrites, Create caption content.caption
Memory the rewrites, Create caption content.note
Question the rewrites content.prompt
Quiz Generate quiz question, options, settings.correct_index, settings.explanation (clears feedback)
Puzzle Generate puzzle prompt, hint, settings.kind, settings.solution

Rewrites appear only once the field has text, and not when it’s over 3000 characters.

AI infrastructure

This is a provider-agnostic foundation for AI features. AI is optional: with every provider and task off (the default), the app works exactly as before.

AI Experience Generator: /experiences/generate (linked from New experience when experience_generation is available) is a wizard: person → occasion → relationship → tone (up to two) → memories from the Memory Vault (up to 12) → length and look → optional notes → generate.

Flow: browser → ai-generate Edge Function → AiService → provider adapter → provider API. The browser never calls a provider and never receives a key.

sequenceDiagram
    participant B as Browser (aiClient)
    participant F as ai-generate
    participant DB as Postgres (service role)
    participant V as Vault
    participant P as AI provider
    B->>F: Task + input, with the user's JWT
    F->>F: Verify the user
    F->>DB: Plan allows AI? Task enabled? Per-user task limits?
    F->>DB: Provider enabled with a key?
    F->>DB: ai_begin_request (daily and monthly limits, atomic)
    F->>V: Read the provider key
    F->>P: Request (timeout, no redirects)
    alt Provider fails
        F->>P: At most one fallback provider/model
    end
    P-->>F: Reply
    F->>F: Validate against a strict schema (no HTML or scripts)
    F->>DB: Record ai_usage_events (tokens, latency, cost)
    F-->>B: Validated result or a safe error code
    B->>B: Parse again as untrusted data before saving

Setup (after running 014, 015 and 016): deploy ai-admin and ai-generate and set the secrets as described in Edge Functions (CLI or bundled files in the Dashboard editor).

-- Add admins (SQL Editor). Each email must already have an account; unknown emails and existing admins are skipped.
insert into public.app_admins (user_id)
select id from auth.users
where lower(email) in ('alice@example.com', 'bob@example.com')
on conflict (user_id) do nothing;

-- List admins
select u.email, a.created_at
from public.app_admins a
join auth.users u on u.id = a.user_id
order by u.email;

-- Remove an admin
delete from public.app_admins
where user_id in (select id from auth.users where lower(email) = 'bob@example.com');

-- Optional: prune raw usage events nightly (keeps 90 days; daily totals are kept)
select cron.schedule('ai-prune', '17 3 * * *', $$select public.ai_prune_usage_events()$$);

Admin status is cached in the browser for up to 10 minutes, so reload the app after adding or removing an admin.

Then open AI settings:

  1. Save a provider key and click Test connection.
  2. Enable the provider.
  3. Enable the tasks you want.
  4. Optionally add model prices so costs are estimated.

Users without a row in ai_user_plans are on the free plan.

Security notes:

Adding a provider:

  1. Write an adapter in _shared/ai/adapters/.
  2. Register it in _shared/ai/registry.ts.
  3. Add tests in supabase/tests/adapters.test.ts.
  4. Deploy. It then appears under “Add a provider” on the admin page.

Verifying:

Admin console and payments

sequenceDiagram
    actor O as Creator
    participant App as Unveil app
    participant PF as payments
    participant RZ as Razorpay
    participant WH as payments-webhook
    participant DB as Postgres
    O->>App: Publish (payments on, price above zero)
    App->>PF: quote(experience, gifts, coupon)
    PF->>DB: Price, gift products, coupon rules
    PF-->>App: Amount computed on the server
    App->>PF: create_order(experience, gifts, coupon)
    alt Amount is zero (100% coupon)
        PF->>DB: Free order, payments_complete_order (unlock)
    else Amount above zero
        PF->>RZ: Create order (key secret from Vault)
        RZ-->>PF: Razorpay order id
        PF-->>App: Checkout details (order, key id, amount)
        App->>RZ: Razorpay Checkout
        O->>RZ: Pays
        RZ-->>App: payment_id + signature
        App->>PF: verify(order, payment_id, signature)
        PF->>PF: Check the HMAC signature
        PF->>RZ: Fetch the payment, match order and amount, capture if needed
        PF->>DB: payments_complete_order (unlock)
        RZ-)WH: order.paid / payment.captured (backup)
        WH->>WH: Check the webhook signature
        WH->>DB: Same unlock (idempotent)
    end
    App-->>O: Recipient links now open

Setup (after running 020 and supabase/verification/admin_payments_check.sql): deploy admin-console, payments and payments-webhook (JWT verification off), and redeploy ai-admin and ai-generate because _shared changed. See Edge Functions for the CLI and Dashboard-editor steps, and step 5 of the first-time setup checklist for Razorpay.

Short links, end times and deletes: run 022_short_links_and_template_expiry.sql before deploying the frontend (the app reads short_code), then bundle and redeploy admin-console.

About page

/about shows the platform’s story (default text in features/about/about-content.ts) and, once named, one card per creator (photo, role, multi-paragraph bio), plus links and a contact email.

sequenceDiagram
    actor A as Admin
    participant F as About page form
    participant ST as site-assets bucket
    participant AC as admin-console
    participant DB as site_about + admin_audit_log
    actor V as Visitor
    A->>F: Edit text, creators, photos, frames, Show photo
    F->>F: Resize photos to 1200 px WebP
    F->>ST: Upload new photos under about/ (admins only)
    F->>AC: about_save(content)
    AC->>AC: Check admin, validate every field strictly
    AC->>DB: Save content, write about_page_updated
    alt Save fails
        F->>ST: Remove the photos just uploaded
    else Saved
        F->>ST: Remove photos no longer used
    end
    V->>DB: /about reads site_about (public)
    DB-->>V: Content, falling back to the default text

Unveil is a Progressive Web App: people can install it on Android, iPhone, iPad, Windows, macOS, Linux and ChromeOS, and it then opens from its own icon in a standalone window, with no app store.

What makes it installable

File Role
public/manifest.webmanifest Name, colours, start_url /dashboard, standalone display, icons (192, 512 and a maskable 512) and shortcuts (Create a surprise, Memory Vault, Occasions).
public/sw.js Service worker. Page loads are network-first, with the last good index.html as an offline fallback; hashed files in /assets/ are cache-first. It never handles other origins, so Supabase data, files and fonts always come from the network. Bump VERSION to drop old caches.
src/lib/pwa.ts Registers the service worker (production builds only), captures the browser’s beforeinstallprompt, and works out the install mode.
index.html Links the manifest, apple-touch-icon and the iOS web-app meta tags.

How the Install app button behaves

Browser Mode What happens
Chrome, Edge, Samsung Internet, Opera (Android and desktop) prompt The browser’s own install dialog opens.
Safari and other browsers on iPhone / iPad ios A dialog shows the steps: Share → Add to Home Screen → Add.
Safari on macOS safari A dialog shows File → Add to Dock.
Already running as the installed app installed Install UI is hidden; Settings says it’s installed.
Firefox desktop and others unsupported Install UI is hidden; Settings explains which browsers to use.

Where it appears:

There’s no install UI on recipient or contributor links, so the surprise stays uninterrupted.

Link previews. index.html carries Open Graph and Twitter card tags, so WhatsApp, iMessage, Telegram, Slack, Discord, X, LinkedIn and Facebook show the Unveil logo, title and public/og-image.jpg (1200×630) when a link is shared.

Design system

The look is warm, emotional and premium, not a generic SaaS dashboard.

Light and dark mode

Users pick Light, Dark or System from the account menu, Settings → Appearance, or the landing page header. The choice is saved in localStorage (unveil-theme) and syncs across tabs. System follows the device and updates live.

Guidelines: use generous spacing (px-5 sm:px-8 lg:px-12, py-8 lg:py-12) and mobile-first classes. Build pages from PageContainer + PageHeader, and use EmptyState for empty and placeholder states. Never show fake sample data. Show an honest empty state instead.


Testing

Layer How What it covers
Types npm run typecheck, npm run check:functions Strict TypeScript for the app, the Edge Functions and their tests
Lint npm run lint ESLint (React hooks, refresh, TypeScript rules)
App logic npm run test:app tests/*.test.ts: scenes engine, Quick Create and its sync with the builder, recipient playthroughs of every template category, AI editing and the generator, writing help, dashboard, About content
Edge Functions npm run test:functions supabase/tests/*.test.ts: AI adapters, schemas, service fallback and limits, handlers, admin authorisation, admin console validation, payments and webhook signatures (no network)
Database supabase/verification/*.sql in the SQL Editor RLS, grants, triggers and RPCs per migration; each script rolls back and prints PASS: ...
Manual Verifying Sign-up, onboarding, sign-out and redirects in the browser

Before shipping a change, run:

npm run lint
npm run test:app
npm run test:functions
npm run build

If anything under supabase/functions/ changed, also run npm run bundle:functions and redeploy the affected functions.


Adding a feature

  1. Schema first. Add a new migration with the next number (currently 024_<name>.sql). In it:
  2. Types. Run npm run db:types. Never hand-write row types in feature code; derive them with Tables<'people'>.
  3. Feature folder. Create src/features/<feature>/ containing:
  4. Routing. Add the path to app/paths.ts, register the lazy page in app/router.tsx, and add it to app/navigation.ts if it needs a nav entry.
  5. Privileged logic (AI, payments, emails, recipient-link resolution) goes in supabase/functions/<name> (Edge Functions). Never put it in the browser.

Roadmap

Built: accounts and onboarding, people, occasions, Memory Vault and memory stories, the experience data layer and builder, interactive blocks and scenes, 31 templates, Quick Create (with guest mode), recipient links, scheduling and Surprise Drop, friend contributions, group links, Group Secret, AI writing, editing and generation, the admin console, Razorpay payments, the About page, and light and dark mode.

Planned (not implemented):