diff --git a/README.md b/README.md index 37cd7258..32d3c051 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,76 @@ Open-source release containing the Mike frontend and backend. - `backend/` - Express API, Supabase access, document processing, and migrations - `backend/migrations/000_one_shot_schema.sql` - one-shot Supabase schema for fresh databases -## Setup +## Run locally with Docker + local Supabase + +Spins up the frontend, backend, and a full local Supabase stack (Postgres, Auth, Storage, Realtime) — no cloud account required. + +Prerequisites: Docker Desktop, [Supabase CLI](https://supabase.com/docs/guides/cli) (`brew install supabase/tap/supabase`). + +```bash +# 1. Start local Supabase (Postgres, Auth, Storage, etc.) +# The migration in supabase/migrations/ is applied automatically. +supabase start + +# 2. Note the keys printed by `supabase start` (or run `supabase status`). +# Create env files from the examples and paste in the local values: +cp backend/.env.example backend/.env +cp frontend/.env.local.example frontend/.env.local + +# 3. Start the app stack +docker compose up -d +``` + +Then open http://localhost:4000. + +Default ports (set in [docker-compose.yml](docker-compose.yml)): + +| Service | URL | +|---|---| +| Frontend | http://localhost:4000 | +| Backend | http://localhost:4001 | +| Supabase API | http://localhost:54321 | +| Supabase DB | `postgresql://postgres:postgres@localhost:54322/postgres` | +| Mailpit (auth emails) | http://localhost:54324 | + +Env values for the local Supabase stack: + +``` +# backend/.env +SUPABASE_URL=http://host.docker.internal:54321 # container -> host +SUPABASE_SECRET_KEY= +R2_ENDPOINT_URL=http://host.docker.internal:54321/storage/v1/s3 +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET_NAME=mike + +# frontend/.env.local +NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321 # browser -> host +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY= +SUPABASE_SECRET_KEY= +NEXT_PUBLIC_API_BASE_URL=http://localhost:4001 +``` + +Create the `mike` storage bucket once: + +```bash +docker exec supabase_db_mike psql -U postgres -d postgres -c \ + "insert into storage.buckets (id, name, public) values ('mike','mike',false) on conflict (id) do nothing;" +``` + +Lifecycle: + +```bash +docker compose logs -f # tail app logs +docker compose restart # pick up env changes +docker compose down # stop the app +supabase stop # stop the supabase stack (volumes persist) +supabase db reset # wipe DB and re-run migrations +``` + +The compose file bind-mounts source for hot reload and runs `npm install` on container start so the deps in the named volume are guaranteed consistent (works around a Docker Desktop named-volume init quirk when bind mounts overlap). + +## Setup (manual, without Docker) Install dependencies: diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 00000000..4e8d93ec --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +.env +.env.* +!.env.example +*.log +.DS_Store diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..5453292e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libreoffice \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm install + +COPY . . + +EXPOSE 3001 + +CMD ["npm", "run", "dev"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..b285b7fd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,45 @@ +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: mike-backend + env_file: + - path: ./backend/.env + required: false + environment: + - PORT=3001 + - FRONTEND_URL=http://localhost:4000 + ports: + - "4001:3001" + volumes: + - ./backend:/app + - backend_node_modules:/app/node_modules + command: sh -c "npm install && exec npm run dev" + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: mike-frontend + env_file: + - path: ./frontend/.env.local + required: false + environment: + - NEXT_PUBLIC_API_BASE_URL=http://localhost:4001 + ports: + - "4000:3000" + volumes: + - ./frontend:/app + - frontend_node_modules:/app/node_modules + - frontend_next_cache:/app/.next + command: sh -c "npm install --legacy-peer-deps && exec npm run dev -- -H 0.0.0.0 -p 3000" + depends_on: + - backend + restart: unless-stopped + +volumes: + backend_node_modules: + frontend_node_modules: + frontend_next_cache: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..65e06413 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,11 @@ +node_modules +.next +.open-next +out +build +.env +.env.* +!.env.local.example +*.log +.DS_Store +.vercel diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 00000000..98b4ef78 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,15 @@ +FROM node:22-bookworm-slim + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm install --legacy-peer-deps + +COPY . . + +EXPOSE 3000 + +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 + +CMD ["npm", "run", "dev", "--", "-H", "0.0.0.0", "-p", "3000"] diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 00000000..ad9264f0 --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 00000000..b87d92ef --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,408 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "mike" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# [experimental.pgdelta] +# When enabled, pg-delta becomes the active engine for supported schema flows. +# enabled = false +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/20240101000000_initial_schema.sql b/supabase/migrations/20240101000000_initial_schema.sql new file mode 100644 index 00000000..80d563af --- /dev/null +++ b/supabase/migrations/20240101000000_initial_schema.sql @@ -0,0 +1,340 @@ +-- Mike one-shot Supabase schema +-- Based on supabase-migration.sql plus the later backend/migrations/*.sql files. +-- Use this for a fresh Supabase database. Existing deployments should continue +-- to apply the incremental migration files instead. + +create extension if not exists "pgcrypto"; + +-- --------------------------------------------------------------------------- +-- User profiles +-- --------------------------------------------------------------------------- + +create table if not exists public.user_profiles ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null unique references auth.users(id) on delete cascade, + display_name text, + organisation text, + tier text not null default 'Free', + message_credits_used integer not null default 0, + credits_reset_date timestamptz not null default (now() + interval '30 days'), + tabular_model text not null default 'gemini-3-flash-preview', + claude_api_key text, + gemini_api_key text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_user_profiles_user + on public.user_profiles(user_id); + +alter table public.user_profiles enable row level security; + +drop policy if exists "Users can view their own profile" on public.user_profiles; +create policy "Users can view their own profile" + on public.user_profiles for select + using (auth.uid() = user_id); + +drop policy if exists "Users can update their own profile" on public.user_profiles; +create policy "Users can update their own profile" + on public.user_profiles for update + using (auth.uid() = user_id); + +create or replace function public.handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = public +as $$ +begin + insert into public.user_profiles (user_id) + values (new.id) + on conflict (user_id) do nothing; + return new; +exception when others then + -- Never block signup if the profile insert fails. + return new; +end; +$$; + +drop trigger if exists on_auth_user_created on auth.users; +create trigger on_auth_user_created + after insert on auth.users + for each row execute procedure public.handle_new_user(); + +-- --------------------------------------------------------------------------- +-- Projects and documents +-- --------------------------------------------------------------------------- + +create table if not exists public.projects ( + id uuid primary key default gen_random_uuid(), + user_id text not null, + name text not null, + cm_number text, + visibility text not null default 'private', + shared_with jsonb not null default '[]'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_projects_user + on public.projects(user_id); + +create index if not exists projects_shared_with_idx + on public.projects using gin (shared_with); + +create table if not exists public.project_subfolders ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references public.projects(id) on delete cascade, + user_id text not null, + name text not null, + parent_folder_id uuid references public.project_subfolders(id) on delete cascade, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_project_subfolders_project + on public.project_subfolders(project_id); + +create table if not exists public.documents ( + id uuid primary key default gen_random_uuid(), + project_id uuid references public.projects(id) on delete cascade, + user_id text not null, + filename text not null, + file_type text, + size_bytes integer not null default 0, + page_count integer, + structure_tree jsonb, + status text not null default 'pending', + folder_id uuid references public.project_subfolders(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_documents_user_project + on public.documents(user_id, project_id); + +create index if not exists idx_documents_project_folder + on public.documents(project_id, folder_id); + +create table if not exists public.document_versions ( + id uuid primary key default gen_random_uuid(), + document_id uuid not null references public.documents(id) on delete cascade, + storage_path text not null, + pdf_storage_path text, + source text not null default 'upload', + version_number integer, + display_name text, + created_at timestamptz not null default now(), + constraint document_versions_source_check + check (source = any (array[ + 'upload'::text, + 'user_upload'::text, + 'assistant_edit'::text, + 'user_accept'::text, + 'user_reject'::text, + 'generated'::text + ])) +); + +create index if not exists document_versions_document_id_idx + on public.document_versions(document_id, created_at desc); + +create index if not exists document_versions_doc_vnum_idx + on public.document_versions(document_id, version_number); + +alter table public.documents + add column if not exists current_version_id uuid + references public.document_versions(id) on delete set null; + +create table if not exists public.document_edits ( + id uuid primary key default gen_random_uuid(), + document_id uuid not null references public.documents(id) on delete cascade, + chat_message_id uuid, + version_id uuid not null references public.document_versions(id) on delete cascade, + change_id text not null, + del_w_id text, + ins_w_id text, + deleted_text text not null default '', + inserted_text text not null default '', + context_before text, + context_after text, + status text not null default 'pending' + check (status = any (array[ + 'pending'::text, + 'accepted'::text, + 'rejected'::text + ])), + created_at timestamptz not null default now(), + resolved_at timestamptz +); + +create index if not exists document_edits_document_id_idx + on public.document_edits(document_id, created_at desc); + +create index if not exists document_edits_message_id_idx + on public.document_edits(chat_message_id); + +create index if not exists document_edits_version_id_idx + on public.document_edits(version_id); + +-- --------------------------------------------------------------------------- +-- Workflows +-- --------------------------------------------------------------------------- + +create table if not exists public.workflows ( + id uuid primary key default gen_random_uuid(), + user_id text, + title text not null, + type text not null, + prompt_md text, + columns_config jsonb, + practice text, + is_system boolean not null default false, + created_at timestamptz not null default now() +); + +create index if not exists idx_workflows_user + on public.workflows(user_id); + +create table if not exists public.hidden_workflows ( + id uuid primary key default gen_random_uuid(), + user_id text not null, + workflow_id text not null, + created_at timestamptz not null default now(), + unique(user_id, workflow_id) +); + +create index if not exists idx_hidden_workflows_user + on public.hidden_workflows(user_id); + +create table if not exists public.workflow_shares ( + id uuid primary key default gen_random_uuid(), + workflow_id uuid not null references public.workflows(id) on delete cascade, + shared_by_user_id text not null, + shared_with_email text not null, + allow_edit boolean not null default false, + created_at timestamptz not null default now(), + constraint workflow_shares_workflow_email_unique + unique(workflow_id, shared_with_email) +); + +create index if not exists workflow_shares_workflow_id_idx + on public.workflow_shares(workflow_id); + +create index if not exists workflow_shares_email_idx + on public.workflow_shares(shared_with_email); + +-- --------------------------------------------------------------------------- +-- Assistant chats +-- --------------------------------------------------------------------------- + +create table if not exists public.chats ( + id uuid primary key default gen_random_uuid(), + project_id uuid references public.projects(id) on delete cascade, + user_id text not null, + title text, + created_at timestamptz not null default now() +); + +create index if not exists idx_chats_user + on public.chats(user_id); + +create index if not exists idx_chats_project + on public.chats(project_id); + +create table if not exists public.chat_messages ( + id uuid primary key default gen_random_uuid(), + chat_id uuid not null references public.chats(id) on delete cascade, + role text not null, + content jsonb, + files jsonb, + annotations jsonb, + created_at timestamptz not null default now() +); + +create index if not exists idx_chat_messages_chat + on public.chat_messages(chat_id); + +do $$ +begin + if not exists ( + select 1 + from pg_constraint + where conname = 'document_edits_chat_message_id_fkey' + and conrelid = 'public.document_edits'::regclass + ) then + alter table public.document_edits + add constraint document_edits_chat_message_id_fkey + foreign key (chat_message_id) + references public.chat_messages(id) + on delete set null; + end if; +end; +$$; + +-- --------------------------------------------------------------------------- +-- Tabular reviews +-- --------------------------------------------------------------------------- + +create table if not exists public.tabular_reviews ( + id uuid primary key default gen_random_uuid(), + project_id uuid references public.projects(id) on delete cascade, + user_id text not null, + title text, + columns_config jsonb, + workflow_id uuid references public.workflows(id) on delete set null, + practice text, + shared_with jsonb not null default '[]'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_tabular_reviews_user + on public.tabular_reviews(user_id); + +create index if not exists idx_tabular_reviews_project + on public.tabular_reviews(project_id); + +create index if not exists tabular_reviews_shared_with_idx + on public.tabular_reviews using gin (shared_with); + +create table if not exists public.tabular_cells ( + id uuid primary key default gen_random_uuid(), + review_id uuid not null references public.tabular_reviews(id) on delete cascade, + document_id uuid not null references public.documents(id) on delete cascade, + column_index integer not null, + content text, + citations jsonb, + status text not null default 'pending', + created_at timestamptz not null default now() +); + +create index if not exists idx_tabular_cells_review + on public.tabular_cells(review_id, document_id, column_index); + +create table if not exists public.tabular_review_chats ( + id uuid primary key default gen_random_uuid(), + review_id uuid not null references public.tabular_reviews(id) on delete cascade, + user_id text not null, + title text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists tabular_review_chats_review_idx + on public.tabular_review_chats(review_id, updated_at desc); + +create index if not exists tabular_review_chats_user_idx + on public.tabular_review_chats(user_id); + +create table if not exists public.tabular_review_chat_messages ( + id uuid primary key default gen_random_uuid(), + chat_id uuid not null references public.tabular_review_chats(id) on delete cascade, + role text not null, + content jsonb, + annotations jsonb, + created_at timestamptz not null default now() +); + +create index if not exists tabular_review_chat_messages_chat_idx + on public.tabular_review_chat_messages(chat_id, created_at);