← Back to App

Epok Advisor Docs

Database Schema Design

This document describes the full database schema for Epok Advisor. All tables live in the public schema in Supabase (Postgres 17). Supabase Auth handles authentication. Drizzle ORM manages schema and type-safe queries. pgvector extension is enabled for embedding storage.

Entity Relationship Overview

auth.users (Supabase Auth)
  │
  └── profiles (app-specific user data + Google OAuth tokens)
        │
        ├── engagements (advisor client projects)
        │     ├── engagement_members (access control)
        │     ├── engagement_milestones (6-step progress tracker)
        │     ├── content_folders → content_documents → content_versions (scoped content)
        │     ├── questionnaire_responses (filled-out questionnaires)
        │     └── analyses → analysis_inputs (AI analysis runs)
        │
        ├── content_folders (hierarchical folder tree — KB or engagement-scoped)
        │     └── content_documents (versioned markdown docs)
        │           ├── content_versions (draft/published/archived)
        │           ├── content_visibility (client portal visibility)
        │           ├── content_document_tags (many-to-many tag assignment)
        │           └── document_embeddings (pgvector chunks for RAG)
        │
        ├── content_tags (classification labels)
        │
        ├── questionnaire_templates → questionnaire_template_versions
        │
        ├── chat_messages (AI chat history)
        │
        └── api_keys (external API authentication)

Tables

1. profiles

Extends Supabase Auth auth.users with application-specific data.

ColumnTypeNotes
iduuid PKReferences auth.users.id
emailtext NOT NULL
full_nametext
avatar_urltext
roleenum: super_admin, associate, client_primary, client_memberSystem-wide role
google_access_tokentextOAuth token for Google Docs export
google_refresh_tokentextOAuth refresh token
google_token_expirytimestamptzWhen the access token expires
created_attimestamptzDefault now()
updated_attimestamptzDefault now()

Notes:

  • Created automatically via a database trigger when a new auth user signs up
  • role defaults to client_primary (safest default — most restrictive non-member role)
  • Super admins are seeded: ben.unsworth@epokadvice.com, kendra.thompson@epokadvice.com
  • Google OAuth tokens are stored per-user for Google Docs export (not all users will have these)

2. engagements

The core business entity — one advisor going through the Epok process.

ColumnTypeNotes
iduuid PKDefault gen_random_uuid()
advisor_nametext NOT NULL
advisor_emailtext NOT NULL
firm_nametext
aum_rangetexte.g. "$500M–$1B"
statusenum: intake, active, ongoing, complete, archivedLifecycle state
notestextInternal notes (not visible to client)
created_byuuid FK → profiles.idWho created the engagement
created_attimestamptz
updated_attimestamptz

3. engagement_members

Junction table: who has access to an engagement and in what capacity.

ColumnTypeNotes
iduuid PK
engagement_iduuid FK → engagements.id
user_iduuid FK → profiles.id
roleenum: admin, associate, client_primary, client_memberRole within this engagement
invited_attimestamptzWhen the invite was sent
joined_attimestamptzWhen they accepted / first logged in
invited_byuuid FK → profiles.id
created_attimestamptz

Unique constraint: (engagement_id, user_id)


4. engagement_milestones

Tracks progress through the structured engagement process.

ColumnTypeNotes
iduuid PK
engagement_iduuid FK → engagements.id
stepinteger NOT NULL1-6
labeltext NOT NULLe.g. "Intake", "Discovery", "Workshop 1"
statusenum: upcoming, current, completed
completed_attimestamptzWhen marked done
completed_byuuid FK → profiles.idWho marked it
notestextOptional notes
created_attimestamptz

Unique constraint: (engagement_id, step)

Milestone definitions (seeded when engagement is created):

StepLabelCorresponding Tag
1IntakeMS: Intake
2DiscoveryMS: Discovery
3Workshop 1MS: Workshop 1
4Workshop 2MS: Workshop 2
5Workshop 3MS: Workshop 3
6CompleteMS: Complete

Each milestone has a corresponding "MS:" tag in content_tags. Engagement overview shows "View docs" links that filter the Documents tab by the milestone's tag.


5. content_folders

Hierarchical folder structure for organizing documents (both KB and engagement-scoped).

ColumnTypeNotes
iduuid PK
nametext NOT NULLFolder name
parent_iduuid FK → content_folders.id (self-ref)NULL = root folder. CASCADE delete
engagement_iduuid FK → engagements.idNULL = KB folder; set = engagement folder
created_byuuid FK → profiles.id
created_attimestamptz
updated_attimestamptz

Unique constraint: (name, parent_id, engagement_id) — no duplicate folder names at the same level

Notable folders:

  • "Inbox" (KB) — landing zone for auto-ingested content (Otter.ai transcripts, etc.)
  • Folders can only be deleted when empty (no docs, no sub-folders)

6. content_documents

Metadata for any piece of content — both knowledge base (global) and engagement-scoped.

ColumnTypeNotes
iduuid PK
titletext NOT NULLHuman-readable title
folder_iduuid FK → content_folders.idWhich folder it lives in (NULL = unfiled)
engagement_iduuid FK → engagements.idNULL = KB (global); set = engagement-scoped
created_byuuid FK → profiles.id
created_attimestamptz
updated_attimestamptz

Notes:

  • The old path and content_type columns have been replaced by folder_id (organization) and tags via content_document_tags (classification)
  • Documents can have 0 or more tags
  • "File to Engagement" action: sets engagement_id, clears folder_id, optionally assigns a milestone tag

7. content_versions

Every edit to a document creates a new version.

ColumnTypeNotes
iduuid PK
document_iduuid FK → content_documents.id
versioninteger NOT NULLAuto-incrementing per document
statusenum: draft, published, archivedOnly one published per document
contenttext NOT NULLThe markdown body
change_summarytextOptional — what changed and why
created_byuuid FK → profiles.id
created_attimestamptz

Unique constraint: (document_id, version)

Rules:

  • Publishing a new version automatically archives the previous published version
  • Rolling back = creating a new version with old content (never mutating history)
  • Publishing triggers auto-embedding for RAG search

8. content_visibility

Controls whether engagement-scoped content is visible to the client in their portal.

ColumnTypeNotes
iduuid PK
document_iduuid FK → content_documents.id
visible_to_clientboolean DEFAULT false
published_attimestamptzWhen it was made visible
published_byuuid FK → profiles.id

9. content_tags

Classification labels for documents. Tags replace the old hardcoded content_type enum.

ColumnTypeNotes
iduuid PK
nametext NOT NULL UNIQUETag display name
colortext NOT NULLColor key for UI (e.g. "purple", "blue", "amber")
created_byuuid FK → profiles.id
created_attimestamptz

Seeded tags: Theme, Data Point, Report Template, Prompt, System Instruction, Internal Note, Intake Notes, Workshop Note, Transcript, Report Draft, Final Report, Questionnaire Response, Summary, Auto-Ingested, MS: Intake, MS: Discovery, MS: Workshop 1, MS: Workshop 2, MS: Workshop 3, MS: Complete, Analysis Output


10. content_document_tags

Many-to-many join between documents and tags.

ColumnTypeNotes
document_iduuid FK → content_documents.idComposite PK
tag_iduuid FK → content_tags.idComposite PK

11. questionnaire_templates

Reusable questionnaire definitions.

ColumnTypeNotes
iduuid PK
titletext NOT NULLTemplate name
descriptiontext
questionsjsonb NOT NULLCurrent question set (array of question objects)
created_byuuid FK → profiles.id
created_attimestamptz
updated_attimestamptz

Question object shape: { id, type, text, required, options?, helpText?, section? } Question types: text, textarea, select, multi_select, scale, yes_no


12. questionnaire_template_versions

Versioned snapshots of questionnaire templates.

ColumnTypeNotes
iduuid PK
template_iduuid FK → questionnaire_templates.idCASCADE delete
versioninteger NOT NULL
statustext NOT NULLdraft or published
questionsjsonb NOT NULLFrozen question set for this version
change_summarytext
created_byuuid FK → profiles.id
created_attimestamptz

Unique constraint: (template_id, version)


13. questionnaire_responses

Filled-out questionnaire tied to an engagement.

ColumnTypeNotes
iduuid PK
template_iduuid FK → questionnaire_templates.idCASCADE delete
template_version_iduuid FK → questionnaire_template_versions.idFrozen version used
engagement_iduuid FK → engagements.idCASCADE delete
respondent_typeenum: interviewer, client, agentWho filled it out
statusenum: draft, complete
answersjsonb NOT NULL{ questionId: answer }
completed_attimestamptz
completed_byuuid FK → profiles.id
created_byuuid FK → profiles.id
created_attimestamptz
updated_attimestamptz

14. analyses

AI analysis runs scoped to an engagement.

ColumnTypeNotes
iduuid PK
engagement_iduuid FK → engagements.idCASCADE delete
titletext NOT NULL
prompttext NOT NULLThe prompt with @-mention document references
modeltext NOT NULLclaude-sonnet, gpt-4o, gpt-4o-mini
statusenum: draft, running, complete, error
error_messagetextIf status = error
milestone_iduuid FK → engagement_milestones.idOptional milestone association
output_document_iduuid FK → content_documents.idAuto-created output doc tagged "Analysis Output"
created_byuuid FK → profiles.id
created_attimestamptz
updated_attimestamptz

15. analysis_inputs

Join table linking analyses to their input documents (referenced via @-mentions in the prompt).

ColumnTypeNotes
iduuid PK
analysis_iduuid FK → analyses.idCASCADE delete
document_iduuid FK → content_documents.idCASCADE delete

Unique constraint: (analysis_id, document_id)


16. document_embeddings

Vector embeddings for RAG search. Created by api.embed.ts on document publish.

ColumnTypeNotes
iduuid PK
document_iduuid FK → content_documents.idCASCADE delete
chunk_indexintegerPosition within the document
contenttextThe text chunk (~2000 chars with overlap)
embeddingvector(1536)OpenAI text-embedding-3-small
created_attimestamptz

Index: HNSW index on embedding column for cosine similarity search RPC: match_documents(query_embedding, match_threshold, match_count) returns top-N similar chunks


17. chat_messages

Persistent chat history for the AI chat panel.

ColumnTypeNotes
iduuid PK
session_idtextGroups messages into conversations
roletextuser, assistant, system
contenttextMessage content
modeltextWhich LLM was used
user_iduuid FK → profiles.id
created_attimestamptz

18. api_keys

API keys for external access (ChatGPT Actions, Claude MCP, Zapier).

ColumnTypeNotes
iduuid PK
user_iduuid FK → profiles.idKey owner
nametextDisplay name (e.g. "OAuth: client_abc123")
key_hashtext NOT NULLSHA-256 hash of the full key
key_prefixtext NOT NULLFirst 8 chars of key for identification
is_activeboolean DEFAULT true
last_used_attimestamptz
created_attimestamptz

Key format: epok_sk_... (base64url random), shown once on creation Lookup: validate_api_key() SECURITY DEFINER function for efficient hash-based lookup


RLS Policies Summary

All tables have Row Level Security enabled. Key policies:

Core tables

  • profiles: Users read own profile; super admins read all; users update own (except role)
  • engagements: Super admins full CRUD; members read their own
  • engagement_members: Super admins full CRUD; members read own engagement's list
  • engagement_milestones: Super admins full CRUD; members read

Content tables

  • content_folders: Super admins full CRUD; engagement-scoped folders visible to members
  • content_documents + content_versions:
    • KB (engagement_id IS NULL): super admins full CRUD; associates read-only; clients no access
    • Engagement-scoped: super admins full CRUD; associates CRUD on assigned; clients read published+visible only
  • content_visibility: Super admins full CRUD; members read for their engagement
  • content_tags / content_document_tags: Super admins full CRUD; all authenticated users can read

RLS recursion fix

content_documents ↔ content_visibility ↔ content_versions have circular policy dependencies. Broken with SECURITY DEFINER helper functions:

  • is_doc_visible_to_client(doc_id)
  • can_view_document(doc_id)
  • can_access_document_versions(doc_id)
  • is_doc_published_and_visible(doc_id)

Other tables

  • questionnaire_templates/versions: Super admins full CRUD; all authenticated read
  • questionnaire_responses: Super admins full CRUD; engagement members read/write their own
  • analyses / analysis_inputs: Super admins full CRUD; engagement members read
  • document_embeddings: Super admins full CRUD (used server-side via service role)
  • chat_messages: Users read/write their own messages
  • api_keys: Users manage their own keys

Enums

Defined in app/db/schema/enums.ts:

EnumValues
user_rolesuper_admin, associate, client_primary, client_member
engagement_statusintake, active, ongoing, complete, archived
milestone_statusupcoming, current, completed
engagement_member_roleadmin, associate, client_primary, client_member
content_typetheme, theme_data_point, advisor_intake, workshop_note, workshop_transcript, report_draft, report_final, report_template, prompt, system_instruction, questionnaire_template, questionnaire_response, internal_note
content_version_statusdraft, published, archived
analysis_statusdraft, running, complete, error
respondent_typeinterviewer, client, agent
questionnaire_statusdraft, complete

Note: content_type enum still exists in the database but is no longer used as a column on content_documents. Classification is now handled entirely by the content_tags / content_document_tags tables.


Seed Data

On initial setup:

  1. Profiles: Super admin roles for ben.unsworth@epokadvice.com and kendra.thompson@epokadvice.com (set via trigger on first login)
  2. Milestones: 6-step structure seeded per engagement on creation
  3. Content tags: ~20 tags seeded (Theme, Data Point, Report Template, Prompt, System Instruction, Internal Note, Intake Notes, Workshop Note, Transcript, Report Draft, Final Report, Questionnaire Response, Summary, Auto-Ingested, MS: Intake, MS: Discovery, MS: Workshop 1-3, MS: Complete, Analysis Output)
  4. KB folders: "Inbox" folder for auto-ingested content

Drizzle ORM Notes

  • Schema defined in app/db/schema/ with one file per table, re-exported from index.ts
  • Schema changes applied via drizzle-kit push (no migration files)
  • Type-safe queries in loaders, actions, and API routes
  • Connection via direct Postgres connection string (IPv4 add-on, not pooler)