Two data-room companions: Teams restrict a link (or future surface) to a named cohort of emails, and Due Diligence turns a space into a tracked checklist of requests.
A team is a named list of member emails owned by a user. Its only job today is to back the team link visibility gate.
teams#| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
owner_id | uuid → users.id ON DELETE CASCADE | |
name | text | Owner label |
member_emails | text[] | Lowercased emails, default [] (zod cap 1000) |
created_at, updated_at | timestamptz |
Indexed on owner_id.
links gained two columns in Phase 1:
visibility text default "public" — "public" or "team".team_id uuid? → teams.id ON DELETE SET NULL.checkTeamAccess(link, email) in lib/visitor/gate.ts runs inside the email gate step (createEmailHandler's checkTeamAccess hook):
if (link.visibility !== "team" || !link.teamId) return true; // not gated by team
const team = await db().select({ memberEmails }).from(teams).where(eq(teams.id, link.teamId));
if (!team) return false; // fail closed
return team.memberEmails?.includes(email.toLowerCase()) ?? false;
It fails closed: a team-visibility link whose team row is missing denies everyone. A visitor whose email isn't in the team gets email_not_in_team. Because it's enforced at the email step (the first gate), an unlisted email never advances.
Behind withOwner, validated by teamCreateSchema / teamUpdateSchema (lib/validation/team.ts):
| Method + path | Purpose |
|---|---|
GET/POST /api/owner/teams | List / create ({ name, memberEmails[] }) |
GET/PATCH/DELETE /api/owner/teams/[id] | Read / update / delete (partial update) |
emailLower (z.string().trim().toLowerCase().email()) normalizes every member email so the includes(email.toLowerCase()) check in the gate is exact.
A per-space checklist — the requests an investor/acquirer wants satisfied before they're done. Each item lives under a space and carries a status, optional assignee, due date, priority, and a comment thread.
diligence_items
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
space_id | uuid → spaces.id ON DELETE CASCADE | Scopes the item to a room |
title | text | |
description | text? | |
status | text default "open" | Workflow below |
category | text? | Free-form grouping (e.g. "Financial", "Legal") |
priority | int default 0 | Higher = more urgent |
assignee_email | text? | Who owns the request |
due_date | timestamptz? | |
related_file_path | text? | Optional pointer to a project file |
created_at, updated_at | timestamptz |
Indexed on space_id and status.
diligence_comments — id, item_id → diligence_items.id CASCADE, author_email, body, created_at. Indexed on item_id.
open → in_review → satisfied
→ waived
→ blocked
status is a free-text column defaulting to open; the API/UI drive it through the workflow above. New statuses are additive (readers default to a neutral state).
Behind withOwner; every item/comment is reached through its space → space.owner_id = caller so cross-tenant access is impossible:
| Method + path | Purpose |
|---|---|
GET/POST /api/owner/diligence | List items for a space / create an item |
GET/PATCH/DELETE /api/owner/diligence/[id] | Read / update (status, assignee, …) / delete one item |
GET/POST /api/owner/diligence/comments | List / add a comment on an item |
Diligence items are surfaced inside the Spaces owner area. NDA/agreement gating for the room itself is configured on the space, not per diligence item.