diff --git a/.gitignore b/.gitignore index ce9161cee..ad48b10ba 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,11 @@ build !.env.example !.env.local.example +docker/.env.local +docker/backend.env.local +docker/frontend.env.local +docker/seed/*.local + *.log *.tsbuildinfo next-env.d.ts diff --git a/backend/src/lib/storage.ts b/backend/src/lib/storage.ts index 82e7f2369..05c42ed4a 100644 --- a/backend/src/lib/storage.ts +++ b/backend/src/lib/storage.ts @@ -25,6 +25,9 @@ function getClient(): S3Client { accessKeyId: process.env.R2_ACCESS_KEY_ID!, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, }, + // MinIO (and some other S3-compatible servers) require path-style URLs. + // Real R2 works fine with path-style too, so it's safe to leave on. + forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true", }); } diff --git a/docker/.env.local.example b/docker/.env.local.example new file mode 100644 index 000000000..c6049b053 --- /dev/null +++ b/docker/.env.local.example @@ -0,0 +1,29 @@ +# ============================================================= +# backend.env.local (used by the backend container) +# ============================================================= +PORT=3001 +FRONTEND_URL=http://localhost:3000 +SUPABASE_URL=http://host.docker.internal:54321 +SUPABASE_SECRET_KEY= + +# MinIO — container-internal URL (backend talks to minio via Docker DNS) +R2_ENDPOINT_URL=http://minio:9000 +R2_ACCESS_KEY_ID=minioadmin +R2_SECRET_ACCESS_KEY=minioadmin +R2_BUCKET_NAME=mike +S3_FORCE_PATH_STYLE=true + +# ============================================================= +# frontend.env.local (used by the frontend container) +# ============================================================= +NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321 +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY= +SUPABASE_SECRET_KEY= +NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 + +# MinIO — host-accessible URL (browser fetches from host, not container) +R2_ENDPOINT_URL=http://localhost:9000 +R2_ACCESS_KEY_ID=minioadmin +R2_SECRET_ACCESS_KEY=minioadmin +R2_BUCKET_NAME=mike +S3_FORCE_PATH_STYLE=true diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 000000000..c8cb90ee7 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,75 @@ +# Mike – Docker Setup + +## Local Development + +`setup.sh` provisions the local infrastructure (Supabase + MinIO + schema + seed user) and writes env files. The backend and frontend are run separately on the host. + +### Prerequisites + +| Tool | Install | +|------|---------| +| Docker (with Compose v2) | https://docs.docker.com/get-docker/ | +| Supabase CLI | https://supabase.com/docs/guides/cli | +| `psql` | https://www.postgresql.org/download/ | +| Node.js 20+ | https://nodejs.org/ | + +### Quick Start + +```bash +./docker/setup.sh +``` + +This will: +1. Start Supabase locally via the Supabase CLI +2. Start MinIO via Docker Compose +3. Apply the database schema (`backend/migrations/000_one_shot_schema.sql`) +4. Create a default test user +5. Write `backend/.env` and `frontend/.env.local` with auto-detected keys + +Then start the apps in two terminals: + +```bash +cd backend && npm install && npm run dev # http://localhost:3001 +cd frontend && npm install --legacy-peer-deps && npm run dev # http://localhost:3000 +``` + +Open **http://localhost:3000** and log in with: + +``` +Email: dev@mike.local +Password: password123 +``` + +### Service URLs + +| Service | URL | +|---------|-----| +| App (run locally) | http://localhost:3000 | +| Backend API (run locally) | http://localhost:3001 | +| MinIO console | http://localhost:9001 (minioadmin / minioadmin) | +| Supabase Studio | http://localhost:54323 | + +### Re-running setup.sh + +`setup.sh` is idempotent — re-running it is safe: +- Supabase `start` is a no-op if already running +- Schema SQL uses `IF NOT EXISTS` guards +- User creation tolerates a 422 (already exists) response +- MinIO bucket creation uses `--ignore-existing` +- Env files are overwritten with fresh values + +### Env files + +`backend/.env` and `frontend/.env.local` are git-ignored and generated by `setup.sh`. See `backend/.env.example` and `frontend/.env.local.example` for the expected shape. + +--- + +## Production Deployment + +Production deployment via Docker Compose is not currently supported. The frontend is intended to be deployed to Cloudflare Workers via [`@opennextjs/cloudflare`](https://opennext.js.org/cloudflare): + +```bash +cd frontend && npm run deploy +``` + +The backend can be deployed to any Node.js host (Fly.io, Railway, a VPS, etc.) — see `backend/.env.example` for required env vars. It expects a hosted Supabase project and S3-compatible storage (e.g. Cloudflare R2). diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 000000000..605c79ec7 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,31 @@ +services: + minio: + image: minio/minio + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 5s + retries: 10 + + minio-init: + image: minio/mc + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 minioadmin minioadmin; + mc mb --ignore-existing local/mike; + echo 'Bucket ready'; + " + +volumes: + minio_data: diff --git a/docker/seed/apply-schema.sh b/docker/seed/apply-schema.sh new file mode 100755 index 000000000..44d3a1437 --- /dev/null +++ b/docker/seed/apply-schema.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +DB_URL="${1:-postgresql://postgres:postgres@localhost:54322/postgres}" +echo "Applying schema..." +psql "$DB_URL" -f "$(dirname "$0")/../../backend/migrations/000_one_shot_schema.sql" +echo "Schema applied." diff --git a/docker/seed/create-user.sh b/docker/seed/create-user.sh new file mode 100755 index 000000000..c6cbf8fb6 --- /dev/null +++ b/docker/seed/create-user.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +SUPABASE_URL="${1:-http://localhost:54321}" +SERVICE_KEY="${2:?SERVICE_KEY required}" +HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "$SUPABASE_URL/auth/v1/admin/users" \ + -H "Authorization: Bearer $SERVICE_KEY" \ + -H "apikey: $SERVICE_KEY" \ + -H "Content-Type: application/json" \ + -d '{"email":"dev@mike.local","password":"password123","email_confirm":true}') +if [ "$HTTP_STATUS" = "200" ] || [ "$HTTP_STATUS" = "201" ] || [ "$HTTP_STATUS" = "422" ]; then + echo "Default user ready (dev@mike.local / password123)" +else + echo "Unexpected status $HTTP_STATUS creating default user" >&2 + exit 1 +fi diff --git a/docker/setup.sh b/docker/setup.sh new file mode 100755 index 000000000..e6d9b5477 --- /dev/null +++ b/docker/setup.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +check_dep() { + command -v "$1" &>/dev/null || { echo "ERROR: '$1' not found. $2"; exit 1; } +} + +echo "[1/5] Checking dependencies..." +check_dep docker "Install Docker: https://docs.docker.com/get-docker/" +check_dep supabase "Install Supabase CLI: https://supabase.com/docs/guides/cli" +check_dep psql "Install psql: https://www.postgresql.org/download/" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "[2/5] Starting Supabase..." +cd "$ROOT_DIR" +supabase start + +# Parse keys from `supabase status -o env` (machine-readable key=value) +SUPABASE_ENV=$(supabase status -o env 2>/dev/null | grep -E '^[A-Z_]+=') +eval "$(echo "$SUPABASE_ENV" | sed 's/^/SB_/')" + +ANON_KEY="${SB_ANON_KEY:-}" +SERVICE_KEY="${SB_SERVICE_ROLE_KEY:-}" +DB_URL="${SB_DB_URL:-}" + +if [ -z "$ANON_KEY" ] || [ -z "$SERVICE_KEY" ] || [ -z "$DB_URL" ]; then + echo "ERROR: Failed to parse Supabase status. Run 'supabase status -o env' to debug." >&2 + exit 1 +fi + +echo "[3/5] Starting MinIO (object storage)..." +docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d minio minio-init + +echo "[4/5] Applying database schema..." +bash "$SCRIPT_DIR/seed/apply-schema.sh" "$DB_URL" + +echo "[5/5] Creating default user..." +bash "$SCRIPT_DIR/seed/create-user.sh" "http://localhost:54321" "$SERVICE_KEY" + +echo "" +echo "Writing env files..." +cat > "$ROOT_DIR/backend/.env" << ENVEOF +PORT=3001 +FRONTEND_URL=http://localhost:3000 +SUPABASE_URL=http://localhost:54321 +SUPABASE_SECRET_KEY=$SERVICE_KEY +R2_ENDPOINT_URL=http://localhost:9000 +R2_ACCESS_KEY_ID=minioadmin +R2_SECRET_ACCESS_KEY=minioadmin +R2_BUCKET_NAME=mike +S3_FORCE_PATH_STYLE=true +ENVEOF + +cat > "$ROOT_DIR/frontend/.env.local" << ENVEOF +NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321 +NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=$ANON_KEY +SUPABASE_SECRET_KEY=$SERVICE_KEY +NEXT_PUBLIC_API_BASE_URL=http://localhost:3001 +R2_ENDPOINT_URL=http://localhost:9000 +R2_ACCESS_KEY_ID=minioadmin +R2_SECRET_ACCESS_KEY=minioadmin +R2_BUCKET_NAME=mike +S3_FORCE_PATH_STYLE=true +ENVEOF + +echo " wrote backend/.env" +echo " wrote frontend/.env.local" +echo "" +echo "Infrastructure ready." +echo "" +echo " Supabase API: http://localhost:54321" +echo " Supabase Studio: http://localhost:54323" +echo " MinIO API: http://localhost:9000" +echo " MinIO console: http://localhost:9001 (minioadmin / minioadmin)" +echo "" +echo " Login: dev@mike.local" +echo " Passw: password123" +echo "" +echo "Next steps - run the apps in separate terminals:" +echo " cd backend && npm install && npm run dev # http://localhost:3001" +echo " cd frontend && npm install && npm run dev # http://localhost:3000" +echo "" diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 000000000..ad9264f0b --- /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 000000000..81d6b48bb --- /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 = "mikeoss" + +[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/20000101000000_schema.sql b/supabase/migrations/20000101000000_schema.sql new file mode 100644 index 000000000..80d563afe --- /dev/null +++ b/supabase/migrations/20000101000000_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);