# Uncut — Supabase setup

Everything to provision the backend for the mobile app (Expo) and the web pages (Next.js on Vercel). The prototype (`uncut-v5.html`) and web mock (`uncut-web.html`) model this exactly.

Order: **1) Auth → 2) Tables → 3) RLS → 4) Storage → 5) Edge Function (email) → 6) Env keys.**

---

## 1. Auth (magic link)

Dashboard → **Authentication → Providers → Email**: enable **"Email (Magic Link)"**, disable password if you only want passwordless.

- Set **Site URL** to your web domain (e.g. `https://uncut.app`) and add the Expo deep link (`uncut://auth-callback`) + Vercel preview URLs under **Redirect URLs**.
- A row in `auth.users` is created automatically on first sign-in. We mirror public profile data into our own `users` table via a trigger (below).

---

## 2. Tables

Run in the SQL editor. Note the **free-text `type`**, the renamed **`stoppers`**, and the new **todo / swipe / messaging** columns.

```sql
-- profiles (public-facing; email stays in auth.users, never exposed)
create table public.users (
  id uuid primary key references auth.users(id) on delete cascade,
  username   text unique,
  bio        text,
  avatar_url text,
  goal       text,
  created_at timestamptz default now()
);

-- gems
create table public.gems (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references public.users(id) on delete cascade,
  title text not null,
  summary text,
  type text,                          -- free text (autocomplete on the client)
  status text default 'active' check (status in ('active','archived')),
  is_public boolean default false,
  is_collab_open boolean default false,
  priority text default 'med' check (priority in ('low','med','high')),
  stoppers text[] default '{}',       -- high-level + free text, max 3 enforced client-side
  todos jsonb default '[]'::jsonb,    -- [{id,text,done,created_at}]
  board jsonb default '[]'::jsonb,    -- miro items [{id,type,x,y,w,h,text|src}]  (private)
  summary_notes text,                 -- derived for public display
  cover_url text,
  media_urls text[] default '{}',     -- storage paths, signed on read
  estimated_effort text,
  what_is_needed text,
  resources text,
  last_swiped_right_at timestamptz,
  swipe_count_right int default 0,
  swipe_count_left  int default 0,
  archived_at timestamptz,
  created_at timestamptz default now(),
  updated_at timestamptz default now()
);
create index gems_user_idx on public.gems(user_id);
create index gems_public_idx on public.gems(is_public, is_collab_open) where is_public;

-- collab signals (the "knock")
create table public.collab_signals (
  id uuid primary key default gen_random_uuid(),
  gem_id uuid not null references public.gems(id) on delete cascade,
  recipient_id uuid not null references public.users(id) on delete cascade, -- gem owner
  sender_id uuid references public.users(id) on delete set null,           -- null if logged-out web visitor
  sender_name text,                  -- for logged-out web signals
  sender_email text,                 -- PRIVATE: only readable by recipient, only for the reply
  message text not null,
  arrangement text default 'free' check (arrangement in ('free','paid','exchange')),
  status text default 'pending' check (status in ('pending','accepted','ignored','blocked')),
  created_at timestamptz default now()
);
create index signals_recipient_idx on public.collab_signals(recipient_id, status);

-- threads (the "door opens" — created only on accept)
create table public.threads (
  id uuid primary key default gen_random_uuid(),
  signal_id uuid unique not null references public.collab_signals(id) on delete cascade,
  user_a uuid not null references public.users(id) on delete cascade,  -- owner
  user_b uuid references public.users(id) on delete set null,          -- helper (if registered)
  created_at timestamptz default now()
);

-- messages (the "room")
create table public.messages (
  id uuid primary key default gen_random_uuid(),
  thread_id uuid not null references public.threads(id) on delete cascade,
  sender_id uuid not null references public.users(id) on delete cascade,
  body text not null,
  created_at timestamptz default now()
);
create index messages_thread_idx on public.messages(thread_id, created_at);

-- blocks
create table public.blocks (
  blocker_id uuid not null references public.users(id) on delete cascade,
  blocked_id uuid not null references public.users(id) on delete cascade,
  created_at timestamptz default now(),
  primary key (blocker_id, blocked_id)
);

-- reel/swipe cards (optional AI cache)
create table public.reel_cards (
  id uuid primary key default gen_random_uuid(),
  gem_id uuid unique not null references public.gems(id) on delete cascade,
  hook_line text, context_line text, cta_text text, cover_url text,
  generated_at timestamptz default now(),
  is_stale boolean default false
);

-- mirror auth.users -> public.users on signup
create function public.handle_new_user() returns trigger language plpgsql security definer as $$
begin
  insert into public.users (id, username) values (new.id, split_part(new.email,'@',1));
  return new;
end; $$;
create trigger on_auth_user_created after insert on auth.users
  for each row execute function public.handle_new_user();

-- keep updated_at fresh
create function public.touch_updated_at() returns trigger language plpgsql as $$
begin new.updated_at = now(); return new; end; $$;
create trigger gems_touch before update on public.gems
  for each row execute function public.touch_updated_at();
```

---

## 3. Row Level Security  ← **this is the real security boundary**

UI hiding is not security. RLS is. **Enable RLS on every table**, then add policies.

```sql
alter table public.users          enable row level security;
alter table public.gems           enable row level security;
alter table public.collab_signals enable row level security;
alter table public.threads        enable row level security;
alter table public.messages       enable row level security;
alter table public.blocks         enable row level security;

-- USERS: public profile fields readable by anyone; only you edit your row
create policy users_read   on public.users for select using (true);
create policy users_update on public.users for update using (auth.uid() = id);

-- GEMS: you see your own; everyone sees only public ones
create policy gems_owner_all on public.gems
  for all using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy gems_public_read on public.gems
  for select using (is_public = true);
--  ⚠️ Never select board/todos/private columns through the public path.
--  Expose public reads via a VIEW that omits private columns (below).

-- SIGNALS: sender can insert (or anon web insert via RPC); only recipient reads
create policy signals_recipient_read on public.collab_signals
  for select using (auth.uid() = recipient_id);
create policy signals_sender_read on public.collab_signals
  for select using (auth.uid() = sender_id);
create policy signals_insert on public.collab_signals
  for insert with check (
    auth.uid() = sender_id
    or sender_id is null                       -- logged-out web signal
  )
  and not exists (                             -- can't signal someone who blocked you
    select 1 from public.blocks
    where blocker_id = recipient_id and blocked_id = auth.uid()
  );
create policy signals_recipient_update on public.collab_signals  -- accept/ignore/block
  for update using (auth.uid() = recipient_id);

-- THREADS: only the two participants
create policy threads_members on public.threads
  for select using (auth.uid() = user_a or auth.uid() = user_b);

-- MESSAGES: readable/insertable only by thread members, only after accept
create policy messages_member_read on public.messages
  for select using (exists (
    select 1 from public.threads t
    where t.id = thread_id and (auth.uid() = t.user_a or auth.uid() = t.user_b)));
create policy messages_member_send on public.messages
  for insert with check (
    auth.uid() = sender_id and exists (
      select 1 from public.threads t
      where t.id = thread_id and (auth.uid() = t.user_a or auth.uid() = t.user_b)));

create policy blocks_owner on public.blocks
  for all using (auth.uid() = blocker_id) with check (auth.uid() = blocker_id);
```

### Public view that strips private columns
The web pages should read gems through this view, **not** the table — so `board`, `todos`, `media` you didn't publish, and any owner-only field can never leak:

```sql
create view public.public_gems as
  select id, user_id, title, summary, type, priority, stoppers,
         what_is_needed, estimated_effort, cover_url, created_at
  from public.gems
  where is_public = true;
-- grant select to anon/authenticated; the view inherits the WHERE filter.
grant select on public.public_gems to anon, authenticated;
```

### Anonymous web signals (logged-out "I can help" form)
Don't let `anon` insert arbitrary rows. Wrap it in a `security definer` RPC that validates + rate-limits:

```sql
create function public.send_signal(p_gem uuid, p_name text, p_email text, p_msg text, p_arr text)
returns void language plpgsql security definer as $$
declare owner uuid;
begin
  select user_id into owner from public.gems where id = p_gem and is_public and is_collab_open;
  if owner is null then raise exception 'gem not open to collab'; end if;
  insert into public.collab_signals(gem_id, recipient_id, sender_name, sender_email, message, arrangement)
  values (p_gem, owner, p_name, p_email, p_msg, coalesce(p_arr,'free'));
end; $$;
grant execute on public.send_signal to anon, authenticated;
```
Add rate-limiting (e.g. a per-IP counter table, or pg_cron cleanup) so the form can't be abused.

---

## 4. Storage

Two buckets:

| bucket | public? | use |
|---|---|---|
| `covers` | **public read** | AI/cover art shown on public gem pages |
| `media`  | **private** | voice memos, photos, sketches — served via short-lived signed URLs |

Storage RLS (private bucket): users can only read/write files under their own `user_id/` prefix.

```sql
create policy media_own on storage.objects for all
  using (bucket_id = 'media' and (storage.foldername(name))[1] = auth.uid()::text)
  with check (bucket_id = 'media' and (storage.foldername(name))[1] = auth.uid()::text);
```
Generate signed URLs server-side (`createSignedUrl`, ~60 min) only for media on gems the requester owns or that are public.

---

## 5. Edge Function — email notifications (never the channel)

Email is the **doorbell**, not the room. On a new signal, notify the recipient with a deep link — **no sender email in the body**.

`supabase functions new notify-signal` → call it from a DB webhook (Database → Webhooks → on insert to `collab_signals`).

```ts
// supabase/functions/notify-signal/index.ts
import { serve } from "https://deno.land/std/http/server.ts";
serve(async (req) => {
  const { record } = await req.json();              // the new collab_signal
  // look up recipient email from auth.users via service role (server-side only)
  // send via Resend / Postmark:
  await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: { Authorization: `Bearer ${Deno.env.get("RESEND_KEY")}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      from: "Uncut <hello@uncut.app>",
      to: recipientEmail,
      subject: "Someone wants to help finish your gem",
      html: `Someone signalled on “${record.gem_title}”. Open the app to read it and decide.
             <a href="https://uncut.app/inbox">View signal →</a>`,  // NO sender email here
    }),
  });
  return new Response("ok");
});
```

Same pattern for a new `messages` row (notify the other thread member). Use **service role key only inside Edge Functions**, never in the client.

The AI calls (reel hooks, priority agent, todo generation, transcription summary) also live as Edge Functions so the **Anthropic key never ships to the client**. Voice transcription: upload audio to `media`, call Whisper from an Edge Function, write transcript back.

---

## 6. Environment keys

**Client (Expo + Next.js public):** only the anon/publishable key.
```
EXPO_PUBLIC_SUPABASE_URL=...
EXPO_PUBLIC_SUPABASE_ANON_KEY=...
NEXT_PUBLIC_SUPABASE_URL=...
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
```
**Server only (Edge Functions / Vercel server env):** never exposed.
```
SUPABASE_SERVICE_ROLE_KEY=...
ANTHROPIC_API_KEY=...
RESEND_KEY=...
```
On Vercel use `vercel env`. Never prefix secrets with `NEXT_PUBLIC_`.

---

## Security checklist
- [ ] RLS enabled on **every** table (default-deny).
- [ ] Public web reads go through `public_gems` view, not the table.
- [ ] `board`, private `media`, `sender_email` never selectable by non-owners.
- [ ] Messages gated behind `threads` (created only on `status='accepted'`) → **double opt-in**.
- [ ] Anonymous signals only via the `send_signal` RPC, rate-limited.
- [ ] Service role + Anthropic + Resend keys server-side only.
- [ ] Signed URLs (short TTL) for private media; covers bucket public.
- [ ] Blocks enforced in the signal insert policy.

## Privacy model (what the user asked)
- **In-app messaging, not email exchange.** Email only notifies; addresses never shared.
- **Double opt-in:** a signal can't become a conversation until the recipient accepts.
- **Granular publishing:** `is_public` per gem; private board items/notes never leave the device-owner's RLS scope.
- **Clean disconnect:** collaborate via threads, leave without having handed over contact info.
- **Block + rate-limit** stop cold-DM spam.

---

## Frontend pass already applied to the prototype
- **Accessibility:** removed `user-scalable=no` (pinch-zoom restored), visible `:focus-visible` rings, `prefers-reduced-motion` disables animation, `aria-label`s on all icon-only buttons, `sr-only` labels on the thread/forms, semantic `<button>`/`<a>`, alt/role on images.
- **Efficiency:** single delegated outside-click listener; pointer events (one code path for mouse+touch) on board + swipe; optimistic local writes then persist; deck renders only the top 3 cards; localStorage debounced to action points. For the real build, add list virtualization on the feed and `select` only the columns you render.
