diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..f42ecd5 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,55 @@ +FROM node:20-bookworm + +# Install Postgres 17 +RUN apt-get update && \ + apt-get install -y gnupg2 lsb-release wget curl && \ + echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \ + > /etc/apt/sources.list.d/pgdg.list && \ + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \ + apt-get update && \ + apt-get install -y postgresql-17 && \ + rm -rf /var/lib/apt/lists/* + +# Install PostgREST +RUN ARCH=$(dpkg --print-architecture) && \ + if [ "$ARCH" = "arm64" ]; then \ + PGRST_URL="https://github.com/PostgREST/postgrest/releases/download/v12.2.3/postgrest-v12.2.3-ubuntu-aarch64.tar.xz"; \ + else \ + PGRST_URL="https://github.com/PostgREST/postgrest/releases/download/v12.2.3/postgrest-v12.2.3-linux-static-x64.tar.xz"; \ + fi && \ + wget -qO /tmp/postgrest.tar.xz "$PGRST_URL" && \ + tar xJf /tmp/postgrest.tar.xz -C /usr/local/bin && \ + rm /tmp/postgrest.tar.xz + +# Install GoTrue (Supabase Auth) +RUN ARCH=$(dpkg --print-architecture) && \ + if [ "$ARCH" = "arm64" ]; then \ + GOTRUE_URL="https://github.com/supabase/auth/releases/download/v2.186.0/auth-v2.186.0-arm64.tar.gz"; \ + else \ + GOTRUE_URL="https://github.com/supabase/auth/releases/download/v2.186.0/auth-v2.186.0-x86.tar.gz"; \ + fi && \ + wget -qO /tmp/gotrue.tar.gz "$GOTRUE_URL" && \ + tar xzf /tmp/gotrue.tar.gz -C /usr/local/bin auth && \ + rm /tmp/gotrue.tar.gz + +# Allow postgres to run without being the postgres user +RUN sed -i "s/peer/trust/g" /etc/postgresql/17/main/pg_hba.conf && \ + sed -i "s/scram-sha-256/trust/g" /etc/postgresql/17/main/pg_hba.conf && \ + sed -i "s/#listen_addresses.*/listen_addresses = '*'/" /etc/postgresql/17/main/postgresql.conf && \ + chown -R postgres:postgres /var/run/postgresql + +WORKDIR /app + +# Install dependencies first (layer caching) +COPY package.json package-lock.json ./ +RUN npm ci + +# Copy source +COPY . . + +RUN chmod +x scripts/dev-entrypoint.sh && \ + chown -R postgres:postgres /app + +USER postgres +EXPOSE 3000 +ENTRYPOINT ["scripts/dev-entrypoint.sh"] diff --git a/Makefile b/Makefile index 0f15ab1..1084dd2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: test test-up test-down +.PHONY: test test-up test-down dev dev-down # Run all tests or a specific test file/pattern in Docker # Usage: @@ -19,3 +19,12 @@ test-up: # Remove test containers and images test-down: docker compose -f docker-compose.test.yml down --rmi local --volumes + +# Start dev environment (Postgres + Auth + Next.js) +# App: http://localhost:3456 | Login: evrhet@postimp.com / password123 +dev: + docker compose -f docker-compose.dev.yml up --build + +# Stop dev environment +dev-down: + docker compose -f docker-compose.dev.yml down --volumes diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..33d840c --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,17 @@ +services: + dev: + build: + context: . + dockerfile: Dockerfile.dev + ports: + - "3456:3000" + - "54399:54321" + volumes: + - ./src:/app/src + - ./supabase:/app/supabase + - ./scripts:/app/scripts + tty: true + stdin_open: true + # Safety: all env vars inside the container use fake API keys/secrets. + # The container has no production credentials, so even with network access + # it cannot affect production systems. diff --git a/package-lock.json b/package-lock.json index 20927f9..68314cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", "daisyui": "^5.5.19", + "jose": "^6.2.1", "next": "^15.5.12", "openai": "^6.25.0", "react": "^18.3.1", @@ -5753,6 +5754,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.1.tgz", + "integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 53ada78..5aad871 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:docker": "docker compose -f docker-compose.test.yml up --build --abort-on-container-exit", + "dev:docker": "docker compose -f docker-compose.dev.yml up --build", + "dev:docker:down": "docker compose -f docker-compose.dev.yml down --volumes", "format": "biome format --write .", "format:check": "biome format .", "prepare": "husky", @@ -33,6 +35,7 @@ "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", "daisyui": "^5.5.19", + "jose": "^6.2.1", "next": "^15.5.12", "openai": "^6.25.0", "react": "^18.3.1", diff --git a/scripts/backfill-permalinks.ts b/scripts/backfill-permalinks.ts index c0e6587..a9bc4da 100644 --- a/scripts/backfill-permalinks.ts +++ b/scripts/backfill-permalinks.ts @@ -24,7 +24,7 @@ async function main() { // Find published posts with an instagram_post_id but no permalink const { data: posts, error } = await db .from("posts") - .select("id, profile_id, instagram_post_id") + .select("id, profile_id, organization_id, instagram_post_id") .eq("status", "published") .not("instagram_post_id", "is", null) .is("instagram_permalink", null); @@ -41,25 +41,25 @@ async function main() { console.log(`Found ${posts.length} post(s) to backfill.\n`); - // Get unique profile IDs and fetch their access tokens - const profileIds = [...new Set(posts.map((p) => p.profile_id))]; + // Get unique org IDs from posts and fetch their access tokens + const orgIds = [...new Set(posts.map((p) => p.organization_id).filter(Boolean))]; const { data: connections } = await db .from("instagram_connections") - .select("profile_id, access_token") - .in("profile_id", profileIds); + .select("organization_id, access_token") + .in("organization_id", orgIds); const tokenMap = new Map(); for (const conn of connections || []) { - tokenMap.set(conn.profile_id, conn.access_token); + tokenMap.set(conn.organization_id, conn.access_token); } let updated = 0; let failed = 0; for (const post of posts) { - const accessToken = tokenMap.get(post.profile_id); + const accessToken = post.organization_id ? tokenMap.get(post.organization_id) : null; if (!accessToken) { - console.log(` SKIP ${post.id} — no Instagram connection for profile ${post.profile_id}`); + console.log(` SKIP ${post.id} — no Instagram connection for org ${post.organization_id}`); failed++; continue; } diff --git a/scripts/dev-entrypoint.sh b/scripts/dev-entrypoint.sh new file mode 100755 index 0000000..5baff3b --- /dev/null +++ b/scripts/dev-entrypoint.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +set -euo pipefail + +JWT_SECRET="super-secret-jwt-token-with-at-least-32-characters-long" + +# JWTs signed with the secret above +ANON_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InRlc3QiLCJpYXQiOjE3NzI2NzcxMjEsImV4cCI6MjA4ODAzNzEyMX0.T_xhqWx3_n9TCCf4r_zsn4EKTDweMHZ-HOahs9qJiEw" +SERVICE_ROLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoidGVzdCIsImlhdCI6MTc3MjY3NzEyMSwiZXhwIjoyMDg4MDM3MTIxfQ.2OKwB_fc6OYTm1bl54bPDVAbcgKQBQeyjZtzMpefafI" + +echo "==> Starting Postgres..." +pg_ctlcluster 17 main start + +# Step 1: Create roles, extensions, storage schema (no auth tables — GoTrue handles those) +echo "==> Setting up roles and extensions..." +psql -U postgres -v ON_ERROR_STOP=1 <<'SQL' +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- Storage schema +CREATE SCHEMA IF NOT EXISTS storage; +CREATE TABLE IF NOT EXISTS storage.buckets ( + id text PRIMARY KEY, name text, public boolean, + file_size_limit bigint, allowed_mime_types text[] +); +CREATE TABLE IF NOT EXISTS storage.objects ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + bucket_id text REFERENCES storage.buckets(id), + name text, owner uuid, created_at timestamptz DEFAULT now() +); + +-- Publication for Realtime +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'supabase_realtime') THEN + CREATE PUBLICATION supabase_realtime; + END IF; +END $$; + +-- PostgREST roles +DO $$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon') THEN + CREATE ROLE anon NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN + CREATE ROLE authenticated NOLOGIN; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'service_role') THEN + CREATE ROLE service_role NOLOGIN BYPASSRLS; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticator') THEN + CREATE ROLE authenticator NOINHERIT LOGIN PASSWORD 'postgres'; + END IF; + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'supabase_auth_admin') THEN + CREATE ROLE supabase_auth_admin LOGIN BYPASSRLS CREATEROLE; + END IF; +END $$; + +GRANT anon TO authenticator; +GRANT authenticated TO authenticator; +GRANT service_role TO authenticator; +GRANT ALL ON SCHEMA public TO anon, authenticated, service_role; + +ALTER TABLE storage.objects ENABLE ROW LEVEL SECURITY; +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE policyname = 'allow_all' AND tablename = 'objects') THEN + CREATE POLICY "allow_all" ON storage.objects USING (true) WITH CHECK (true); + END IF; +END $$; + +-- Auth schema — GoTrue will create tables and auth.uid() via its own migrations +CREATE SCHEMA IF NOT EXISTS auth; +ALTER SCHEMA auth OWNER TO supabase_auth_admin; +GRANT ALL ON SCHEMA auth TO supabase_auth_admin; +SQL + +# Step 2: Start GoTrue — it creates the full auth schema with its own migrations +echo "==> Starting GoTrue (auth)..." +export GOTRUE_DB_DATABASE_URL="postgres://supabase_auth_admin:postgres@localhost:5432/postgres?sslmode=disable&search_path=auth" +export GOTRUE_DB_DRIVER="postgres" +export GOTRUE_DB_NAMESPACE="auth" +export GOTRUE_API_HOST="0.0.0.0" +export GOTRUE_API_PORT="9999" +export GOTRUE_SITE_URL="http://localhost:3456" +export GOTRUE_URI_ALLOW_LIST="http://localhost:3456" +export GOTRUE_JWT_SECRET="${JWT_SECRET}" +export GOTRUE_JWT_EXP="3600" +export GOTRUE_JWT_AUD="authenticated" +export GOTRUE_JWT_DEFAULT_GROUP_NAME="authenticated" +export GOTRUE_EXTERNAL_EMAIL_ENABLED="true" +export GOTRUE_MAILER_AUTOCONFIRM="true" +export GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false" +export GOTRUE_DISABLE_SIGNUP="false" +export GOTRUE_MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify" +export GOTRUE_MAILER_URLPATHS_INVITE="/auth/v1/verify" +export GOTRUE_MAILER_URLPATHS_RECOVERY="/auth/v1/verify" +export GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify" +export API_EXTERNAL_URL="http://localhost:54399" +auth & +AUTH_PID=$! + +# Wait for GoTrue to be ready (it runs migrations on startup) +echo " Waiting for GoTrue to be ready..." +for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:9999/health > /dev/null 2>&1; then + echo " GoTrue is ready." + break + fi + if ! kill -0 $AUTH_PID 2>/dev/null; then + echo " ERROR: GoTrue exited unexpectedly. Check logs above." + exit 1 + fi + sleep 1 +done + +# Step 3: Run app migrations (auth.users now exists from GoTrue) +# Track applied migrations so container restarts don't re-run them +psql -U postgres -v ON_ERROR_STOP=1 <<'SQL' +CREATE TABLE IF NOT EXISTS public._dev_migrations ( + name text PRIMARY KEY, + applied_at timestamptz DEFAULT now() +); +SQL + +echo "==> Running migrations..." +for f in /app/supabase/migrations/*.sql; do + fname="$(basename "$f")" + already=$(psql -U postgres -tAc "SELECT 1 FROM public._dev_migrations WHERE name = '${fname}'" 2>/dev/null || true) + if [ "$already" = "1" ]; then + echo " -> ${fname} (already applied, skipping)" + continue + fi + echo " -> ${fname}" + psql -U postgres -v ON_ERROR_STOP=1 -f "$f" + psql -U postgres -c "INSERT INTO public._dev_migrations (name) VALUES ('${fname}')" +done + +echo "==> Granting permissions..." +psql -U postgres <<'SQL' +GRANT ALL ON ALL TABLES IN SCHEMA public TO anon, authenticated, service_role; +GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO anon, authenticated, service_role; +GRANT USAGE ON SCHEMA public TO anon, authenticated, service_role, authenticator; +GRANT ALL ON ALL TABLES IN SCHEMA public TO authenticator; +GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO authenticator; +GRANT ALL ON ALL TABLES IN SCHEMA auth TO service_role; +GRANT ALL ON ALL SEQUENCES IN SCHEMA auth TO service_role; +GRANT USAGE ON SCHEMA auth TO service_role; +SQL + +echo "==> Creating auth users via GoTrue API..." +ANON_HDR="apikey: ${ANON_KEY}" + +# Create evrhet user (or verify exists) +EVRHET_RESP=$(curl -sf -X POST "http://127.0.0.1:9999/admin/users" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${SERVICE_ROLE_KEY}" \ + -d '{ + "id": "aaaaaaaa-0000-0000-0000-000000000001", + "email": "evrhet@postimp.com", + "password": "password123", + "email_confirm": true, + "app_metadata": {"provider":"email","providers":["email"]} + }' 2>&1) || echo " (evrhet user may already exist)" +echo " evrhet@postimp.com: done" + +# Create ryan user (or verify exists) +RYAN_RESP=$(curl -sf -X POST "http://127.0.0.1:9999/admin/users" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${SERVICE_ROLE_KEY}" \ + -d '{ + "id": "bbbbbbbb-0000-0000-0000-000000000002", + "email": "ryan@postimp.com", + "password": "password123", + "email_confirm": true, + "app_metadata": {"provider":"email","providers":["email"]} + }' 2>&1) || echo " (ryan user may already exist)" +echo " ryan@postimp.com: done" + +echo "==> Seeding app data..." +psql -U postgres -v ON_ERROR_STOP=1 -f /app/supabase/seed.sql + +echo "==> Starting PostgREST..." +cat > /tmp/postgrest.conf < Starting dev proxy..." +node /app/scripts/dev-proxy.mjs & +sleep 1 + +echo "" +echo " ================================================" +echo " Dev environment ready!" +echo " ================================================" +echo "" +echo " App: http://localhost:3456" +echo " API: http://localhost:54399" +echo "" +echo " Accounts:" +echo " evrhet@postimp.com / password123" +echo " ryan@postimp.com / password123" +echo "" +echo " Both users belong to D&D Labs + Pizza Planet" +echo " ================================================" +echo "" + +echo "==> Starting Next.js dev server..." +cd /app +# Browser uses host-mapped port; server-side uses SUPABASE_URL override (internal) +export NEXT_PUBLIC_SUPABASE_URL="http://localhost:54399" +export SUPABASE_URL="http://localhost:54321" +export NEXT_PUBLIC_SUPABASE_ANON_KEY="${ANON_KEY}" +export SUPABASE_SERVICE_ROLE_KEY="${SERVICE_ROLE_KEY}" +export NEXT_PUBLIC_BASE_URL="http://localhost:3456" +export OPENAI_API_KEY="fake-key-dev-only" +export INSTAGRAM_APP_ID="fake-id" +export INSTAGRAM_APP_SECRET="fake-secret" +export FACEBOOK_APP_ID="fake-id" +export FACEBOOK_APP_SECRET="fake-secret" +export TWILIO_ACCOUNT_SID="ACfake" +export TWILIO_AUTH_TOKEN="fake-token" +export TWILIO_PHONE_NUMBER="+15551234567" +export CRON_SECRET="dev-cron-secret" + +node_modules/.bin/next dev --hostname 0.0.0.0 & +NEXT_PID=$! +# Keep container alive — wait on Next.js and restart if it exits +while true; do + wait $NEXT_PID || true + echo "==> Next.js exited (code $?), restarting in 2s..." + sleep 2 + node_modules/.bin/next dev --hostname 0.0.0.0 & + NEXT_PID=$! +done diff --git a/scripts/dev-proxy.mjs b/scripts/dev-proxy.mjs new file mode 100644 index 0000000..189e47f --- /dev/null +++ b/scripts/dev-proxy.mjs @@ -0,0 +1,137 @@ +import http from "node:http"; + +const POSTGREST_PORT = 3001; +const GOTRUE_PORT = 9999; +const PROXY_PORT = 54321; +const uploadedKeys = new Set(); + +const CORS_HEADERS = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "access-control-allow-headers": "*", + "access-control-expose-headers": "x-supabase-api-version", + "access-control-max-age": "86400", +}; + +// Strip any existing CORS headers from upstream, then apply ours +function withCors(upstreamHeaders) { + const cleaned = {}; + for (const [k, v] of Object.entries(upstreamHeaders)) { + if (!k.toLowerCase().startsWith("access-control-")) { + cleaned[k] = v; + } + } + return { ...cleaned, ...CORS_HEADERS }; +} + +const server = http.createServer((req, res) => { + // Handle CORS preflight for all routes + if (req.method === "OPTIONS") { + res.writeHead(204, CORS_HEADERS); + res.end(); + return; + } + + // Auth proxy: forward to GoTrue + if (req.url?.startsWith("/auth/v1/")) { + const target = req.url.replace("/auth/v1/", "/"); + const opts = { + hostname: "127.0.0.1", + port: GOTRUE_PORT, + path: target, + method: req.method, + headers: { ...req.headers, host: `127.0.0.1:${GOTRUE_PORT}` }, + }; + const proxy = http.request(opts, (upstream) => { + const headers = withCors(upstream.headers); + res.writeHead(upstream.statusCode ?? 500, headers); + upstream.pipe(res); + }); + proxy.on("error", (err) => { + res.writeHead(502, { "Content-Type": "text/plain", ...CORS_HEADERS }); + res.end("GoTrue proxy error: " + err.message); + }); + req.pipe(proxy); + return; + } + + // Storage mock: respond with success for uploads, 409 for duplicates + if (req.url?.startsWith("/storage/v1/object/")) { + const key = req.url.replace("/storage/v1/object/", ""); + + if (req.method === "DELETE") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + try { + const parsed = JSON.parse(body); + const paths = Array.isArray(parsed) ? parsed : parsed?.prefixes || []; + for (const p of paths) uploadedKeys.delete(p); + } catch {} + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify([])); + }); + return; + } + + if (req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ publicUrl: `http://localhost:54321/storage/v1/object/public/${key}` }), + ); + return; + } + + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + if (uploadedKeys.has(key)) { + res.writeHead(409, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + statusCode: "409", + error: "Duplicate", + message: "The resource already exists", + }), + ); + return; + } + uploadedKeys.add(key); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ Key: key })); + }); + return; + } + + // REST proxy: forward to PostgREST + if (req.url?.startsWith("/rest/v1/")) { + const target = req.url.replace("/rest/v1/", "/"); + const opts = { + hostname: "127.0.0.1", + port: POSTGREST_PORT, + path: target, + method: req.method, + headers: { ...req.headers, host: `127.0.0.1:${POSTGREST_PORT}` }, + }; + const proxy = http.request(opts, (upstream) => { + const headers = withCors(upstream.headers); + res.writeHead(upstream.statusCode ?? 500, headers); + upstream.pipe(res); + }); + proxy.on("error", (err) => { + res.writeHead(502, { "Content-Type": "text/plain", ...CORS_HEADERS }); + res.end("PostgREST proxy error: " + err.message); + }); + req.pipe(proxy); + return; + } + + res.writeHead(404); + res.end("Not found"); +}); + +server.listen(PROXY_PORT, "0.0.0.0", () => { + console.log( + `Dev proxy listening on :${PROXY_PORT} (auth → :${GOTRUE_PORT}, rest → :${POSTGREST_PORT})`, + ); +}); diff --git a/scripts/test-init.sql b/scripts/test-init.sql index 4ae4039..ec17956 100644 --- a/scripts/test-init.sql +++ b/scripts/test-init.sql @@ -5,11 +5,37 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; -- Auth schema (profiles FK references auth.users) +-- Includes columns needed by both tests (minimal) and dev seed (GoTrue-compatible). CREATE SCHEMA IF NOT EXISTS auth; CREATE TABLE IF NOT EXISTS auth.users ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - email text + instance_id uuid, + email text, + encrypted_password text, + email_confirmed_at timestamptz, + aud text DEFAULT 'authenticated', + role text DEFAULT 'authenticated', + raw_app_meta_data jsonb DEFAULT '{}'::jsonb, + raw_user_meta_data jsonb DEFAULT '{}'::jsonb, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + confirmation_token text DEFAULT '', + recovery_token text DEFAULT '', + email_change_token_new text DEFAULT '', + email_change text DEFAULT '' ); +CREATE TABLE IF NOT EXISTS auth.identities ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + provider_id text NOT NULL, + provider text NOT NULL DEFAULT 'email', + identity_data jsonb DEFAULT '{}'::jsonb, + last_sign_in_at timestamptz, + created_at timestamptz DEFAULT now(), + updated_at timestamptz DEFAULT now(), + UNIQUE (provider_id, provider) +); + CREATE OR REPLACE FUNCTION auth.uid() RETURNS uuid AS $$ SELECT (current_setting('request.jwt.claims', true)::json->>'sub')::uuid $$ LANGUAGE sql STABLE; diff --git a/src/__tests__/helpers/seed.ts b/src/__tests__/helpers/seed.ts index 5b9e86b..3740348 100644 --- a/src/__tests__/helpers/seed.ts +++ b/src/__tests__/helpers/seed.ts @@ -2,6 +2,9 @@ import { vi } from "vitest"; import { createDbClient } from "@/lib/db/client"; import type { DeliverFn } from "@/lib/core/types"; +/** 60-day token lifetime used by Instagram long-lived tokens */ +export const TOKEN_LIFETIME_MS = 60 * 24 * 60 * 60 * 1000; + // Track seeded user IDs so cleanAll only deletes what this test suite created const seededUserIds: string[] = []; @@ -59,18 +62,48 @@ export async function seedPost( return data!; } +export async function seedOrganization( + userId: string, + overrides: Record = {}, +): Promise<{ id: string }> { + const db = createDbClient(); + const { data, error } = await db + .from("organizations") + .insert({ + name: "Test Organization", + creator_user_id: userId, + ...overrides, + }) + .select("id") + .single(); + + if (error) throw new Error(`seedOrganization: ${error.message || JSON.stringify(error)}`); + + const { error: memberError } = await db.from("organization_members").insert({ + organization_id: data!.id, + user_id: userId, + role: "owner", + }); + if (memberError) + throw new Error( + `seedOrganization membership: ${memberError.message || JSON.stringify(memberError)}`, + ); + + return data!; +} + export async function seedInstagramConnection( - profileId: string, + orgId: string, overrides: Record = {}, ): Promise<{ id: string }> { const db = createDbClient(); const { data, error } = await db .from("instagram_connections") .insert({ - profile_id: profileId, + organization_id: orgId, instagram_user_id: "ig_user_123", access_token: "test_access_token", - token_expires_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + token_expires_at: new Date(Date.now() + TOKEN_LIFETIME_MS).toISOString(), instagram_username: "testuser", ...overrides, }) @@ -82,14 +115,14 @@ export async function seedInstagramConnection( } export async function seedFacebookConnection( - profileId: string, + orgId: string, overrides: Record = {}, ): Promise<{ id: string }> { const db = createDbClient(); const { data, error } = await db .from("facebook_connections") .insert({ - profile_id: profileId, + organization_id: orgId, facebook_user_id: "fb_user_123", facebook_page_id: "fb_page_123", page_name: "Test Page", @@ -128,6 +161,15 @@ export async function cleanAll() { const { error: pendingFbErr } = await db.from("pending_facebook_tokens").delete().neq("id", NIL); if (pendingFbErr) console.error("cleanAll pending_facebook_tokens:", pendingFbErr.message); + const { error: pendingRegErr } = await db.from("pending_registrations").delete().neq("id", NIL); + if (pendingRegErr) console.error("cleanAll pending_registrations:", pendingRegErr.message); + + const { error: memberErr } = await db.from("organization_members").delete().neq("id", NIL); + if (memberErr) console.error("cleanAll organization_members:", memberErr.message); + + const { error: orgErr } = await db.from("organizations").delete().neq("id", NIL); + if (orgErr) console.error("cleanAll organizations:", orgErr.message); + const { error: profErr } = await db.from("profiles").delete().neq("id", NIL); if (profErr) console.error("cleanAll profiles:", profErr.message); diff --git a/src/__tests__/lib/core/handle-approve.test.ts b/src/__tests__/lib/core/handle-approve.test.ts index 6cbf8d7..28dc907 100644 --- a/src/__tests__/lib/core/handle-approve.test.ts +++ b/src/__tests__/lib/core/handle-approve.test.ts @@ -8,10 +8,12 @@ import { getInstagramConnection } from "@/lib/db/instagram"; import type { Post } from "@/lib/db/posts"; import { seedProfile, + seedOrganization, seedPost, seedInstagramConnection, seedFacebookConnection, cleanAll, + TOKEN_LIFETIME_MS, } from "../../helpers/seed"; const mockIgPublish = vi.mocked(publishToInstagram); @@ -34,7 +36,7 @@ describe("executePublish", () => { mockIsExpiring.mockReturnValue(false); mockRefresh.mockResolvedValue({ accessToken: "refreshed_tok", - expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000), + expiresAt: new Date(Date.now() + TOKEN_LIFETIME_MS), }); }); @@ -46,7 +48,8 @@ describe("executePublish", () => { it("returns error when no connections", async () => { const { id } = await seedProfile(); - const post = await seedPost(id); + const org = await seedOrganization(id); + const post = await seedPost(id, { organization_id: org.id }); const fullPost = await getPost(post.id); const result = await executePublish(id, fullPost); @@ -56,10 +59,13 @@ describe("executePublish", () => { }); it("returns error when IG token expired and no FB", async () => { + mockIsExpiring.mockReturnValue(true); + const { id } = await seedProfile(); - const post = await seedPost(id); + const org = await seedOrganization(id); + const post = await seedPost(id, { organization_id: org.id }); const fullPost = await getPost(post.id); - await seedInstagramConnection(id, { + await seedInstagramConnection(org.id, { token_expires_at: new Date(Date.now() - 86400000).toISOString(), }); @@ -71,9 +77,10 @@ describe("executePublish", () => { it("publishes to Instagram successfully", async () => { const { id } = await seedProfile(); - const post = await seedPost(id); + const org = await seedOrganization(id); + const post = await seedPost(id, { organization_id: org.id }); const fullPost = await getPost(post.id); - await seedInstagramConnection(id); + await seedInstagramConnection(org.id); const result = await executePublish(id, fullPost); @@ -88,13 +95,14 @@ describe("executePublish", () => { it("publishes to Facebook only when only FB connected", async () => { const db = createDbClient(); const { id } = await seedProfile(); + const org = await seedOrganization(id); await db .from("profiles") .update({ publish_platforms: ["facebook"] }) .eq("id", id); - const post = await seedPost(id); + const post = await seedPost(id, { organization_id: org.id }); const fullPost = await getPost(post.id); - await seedFacebookConnection(id); + await seedFacebookConnection(org.id); const result = await executePublish(id, fullPost); @@ -109,14 +117,15 @@ describe("executePublish", () => { it("publishes to both platforms when both connected", async () => { const db = createDbClient(); const { id } = await seedProfile(); + const org = await seedOrganization(id); await db .from("profiles") .update({ publish_platforms: ["instagram", "facebook"] }) .eq("id", id); - const post = await seedPost(id); + const post = await seedPost(id, { organization_id: org.id }); const fullPost = await getPost(post.id); - await seedInstagramConnection(id); - await seedFacebookConnection(id); + await seedInstagramConnection(org.id); + await seedFacebookConnection(org.id); const result = await executePublish(id, fullPost); @@ -138,14 +147,15 @@ describe("executePublish", () => { const db = createDbClient(); const { id } = await seedProfile(); + const org = await seedOrganization(id); await db .from("profiles") .update({ publish_platforms: ["instagram", "facebook"] }) .eq("id", id); - const post = await seedPost(id); + const post = await seedPost(id, { organization_id: org.id }); const fullPost = await getPost(post.id); - await seedInstagramConnection(id); - await seedFacebookConnection(id); + await seedInstagramConnection(org.id); + await seedFacebookConnection(org.id); const result = await executePublish(id, fullPost); @@ -166,9 +176,10 @@ describe("executePublish", () => { }); const { id } = await seedProfile(); - const post = await seedPost(id); + const org = await seedOrganization(id); + const post = await seedPost(id, { organization_id: org.id }); const fullPost = await getPost(post.id); - await seedInstagramConnection(id); + await seedInstagramConnection(org.id); const result = await executePublish(id, fullPost); @@ -180,30 +191,32 @@ describe("executePublish", () => { }); it("refreshes token after publish when expiring soon", async () => { - mockIsExpiring.mockReturnValue(true); + mockIsExpiring.mockImplementation((_token: string | null, windowMs?: number) => windowMs !== 0); const { id } = await seedProfile(); - const post = await seedPost(id); + const org = await seedOrganization(id); + const post = await seedPost(id, { organization_id: org.id }); const fullPost = await getPost(post.id); - await seedInstagramConnection(id); + await seedInstagramConnection(org.id); await executePublish(id, fullPost); expect(mockRefresh).toHaveBeenCalledWith("test_access_token"); const db = createDbClient(); - const connection = await getInstagramConnection(db, id); + const connection = await getInstagramConnection(db, org.id); expect(connection!.access_token).toBe("refreshed_tok"); }); it("still succeeds when opportunistic refresh fails", async () => { - mockIsExpiring.mockReturnValue(true); + mockIsExpiring.mockImplementation((_token: string | null, windowMs?: number) => windowMs !== 0); mockRefresh.mockRejectedValueOnce(new Error("refresh failed")); const { id } = await seedProfile(); - const post = await seedPost(id); + const org = await seedOrganization(id); + const post = await seedPost(id, { organization_id: org.id }); const fullPost = await getPost(post.id); - await seedInstagramConnection(id); + await seedInstagramConnection(org.id); const result = await executePublish(id, fullPost); @@ -211,4 +224,15 @@ describe("executePublish", () => { const updated = await getPost(post.id); expect(updated.status).toBe("published"); }); + + it("returns error when post has no organization_id", async () => { + const { id } = await seedProfile(); + const post = await seedPost(id); + const fullPost = await getPost(post.id); + + const result = await executePublish(id, fullPost); + + expect(result.success).toBe(false); + expect(result.error).toContain("not associated with an organization"); + }); }); diff --git a/src/__tests__/lib/core/orchestrate.test.ts b/src/__tests__/lib/core/orchestrate.test.ts index 6b12a2d..553e376 100644 --- a/src/__tests__/lib/core/orchestrate.test.ts +++ b/src/__tests__/lib/core/orchestrate.test.ts @@ -8,6 +8,7 @@ import { createDbClient } from "@/lib/db/client"; import { seedProfile, seedPost, + seedOrganization, seedInstagramConnection, cleanAll, makeTestDeliver, @@ -90,8 +91,9 @@ describe("orchestrate", () => { it("handles publish_post tool call", async () => { const { id } = await seedProfile(); - await seedPost(id); - await seedInstagramConnection(id); + const org = await seedOrganization(id); + await seedPost(id, { organization_id: org.id }); + await seedInstagramConnection(org.id); mockSendMessage.mockResolvedValueOnce({ responseId: "resp_pub_1", diff --git a/src/__tests__/lib/core/router.test.ts b/src/__tests__/lib/core/router.test.ts deleted file mode 100644 index 1e02836..0000000 --- a/src/__tests__/lib/core/router.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { routeMessage } from "@/lib/core/router"; -import { sendMessage, sendToolResults } from "@/lib/openai/conversation"; -import type { MessageContext } from "@/lib/core/types"; -import { seedProfile, seedPost, cleanAll, makeTestDeliver } from "../../helpers/seed"; -import { createDbClient } from "@/lib/db/client"; - -const mockSendMessage = vi.mocked(sendMessage); -const mockSendToolResults = vi.mocked(sendToolResults); - -describe("routeMessage", () => { - let deliver: ReturnType["deliver"]; - let messages: ReturnType["messages"]; - - beforeEach(() => { - ({ deliver, messages } = makeTestDeliver()); - // Default: AI returns update_caption tool call - mockSendMessage.mockResolvedValue({ - responseId: "resp_test_123", - textResponse: "Here's a caption for your post!", - toolCalls: [ - { - name: "update_caption", - callId: "call_test_1", - args: { caption: "Test caption #test #vitest" }, - }, - ], - }); - mockSendToolResults.mockResolvedValue({ - responseId: "resp_test_456", - textResponse: "Caption updated!", - toolCalls: [], - }); - }); - - afterEach(async () => { - await cleanAll(); - mockSendMessage.mockReset(); - mockSendToolResults.mockReset(); - }); - - function ctx(profileId: string, overrides: Partial = {}): MessageContext { - return { - profileId, - body: "", - mediaUrl: null, - channel: "web", - ...overrides, - }; - } - - it("sends onboarding prompt when profile not onboarded", async () => { - const { id } = await seedProfile({ onboarding_completed: false }); - await routeMessage(ctx(id, { body: "hello" }), deliver); - - expect(deliver).toHaveBeenCalledOnce(); - expect(messages[0].text).toContain("onboarding"); - // AI should NOT be called - expect(mockSendMessage).not.toHaveBeenCalled(); - }); - - it("sends no-draft prompt when no media and no active draft", async () => { - const { id } = await seedProfile(); - await routeMessage(ctx(id, { body: "random text" }), deliver); - - expect(deliver).toHaveBeenCalledOnce(); - expect(messages[0].text).toContain("photo"); - expect(mockSendMessage).not.toHaveBeenCalled(); - }); - - it("creates post and calls AI when media buffer is present", async () => { - const { id } = await seedProfile(); - const buffer = new ArrayBuffer(8); - const result = await routeMessage( - ctx(id, { - body: "my new post", - imageBuffer: buffer, - contentType: "image/jpeg", - }), - deliver, - ); - - expect(result.postId).toBeDefined(); - expect(mockSendMessage).toHaveBeenCalledOnce(); - // Should deliver caption message + AI text response - expect(deliver).toHaveBeenCalled(); - }); - - it("sends message to AI with existing draft", async () => { - const { id } = await seedProfile(); - await seedPost(id); - - // AI returns just text, no tool calls (e.g. answering a question) - mockSendMessage.mockResolvedValueOnce({ - responseId: "resp_question", - textResponse: "The caption looks great as-is!", - toolCalls: [], - }); - - await routeMessage(ctx(id, { body: "is this caption too long?" }), deliver); - - expect(mockSendMessage).toHaveBeenCalledOnce(); - expect(deliver).toHaveBeenCalledOnce(); - expect(messages[0].text).toContain("looks great"); - }); - - it("handles update_caption tool call from AI", async () => { - const { id } = await seedProfile(); - const post = await seedPost(id); - - await routeMessage(ctx(id, { body: "make it more casual" }), deliver); - - // Should have delivered caption message and AI follow-up - expect(mockSendMessage).toHaveBeenCalledOnce(); - expect(mockSendToolResults).toHaveBeenCalledOnce(); - - // Verify caption was updated in DB - const db = createDbClient(); - const { data } = await db.from("posts").select("caption").eq("id", post.id).single(); - expect(data?.caption).toBe("Test caption #test #vitest"); - }); - - it("keeps existing draft when new media is sent", async () => { - const { id } = await seedProfile(); - const oldPost = await seedPost(id); - - const buffer = new ArrayBuffer(8); - await routeMessage( - ctx(id, { - body: "new photo", - imageBuffer: buffer, - contentType: "image/jpeg", - }), - deliver, - ); - - // Old post should still be a draft - const db = createDbClient(); - const { data } = await db.from("posts").select("status").eq("id", oldPost.id).single(); - expect(data?.status).toBe("draft"); - }); - - it("saves conversation ID on post after first AI call", async () => { - const { id } = await seedProfile(); - const post = await seedPost(id); - - await routeMessage(ctx(id, { body: "hello" }), deliver); - - const db = createDbClient(); - const { data } = await db - .from("posts") - .select("openai_conversation_id") - .eq("id", post.id) - .single(); - expect(data?.openai_conversation_id).toBeDefined(); - }); -}); diff --git a/src/__tests__/lib/db/facebook.test.ts b/src/__tests__/lib/db/facebook.test.ts index 162203f..719431c 100644 --- a/src/__tests__/lib/db/facebook.test.ts +++ b/src/__tests__/lib/db/facebook.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, afterEach } from "vitest"; import { createDbClient } from "@/lib/db/client"; -import { seedProfile, seedFacebookConnection, cleanAll } from "../../helpers/seed"; +import { + seedProfile, + seedOrganization, + seedFacebookConnection, + cleanAll, +} from "../../helpers/seed"; import { getFacebookConnection, upsertFacebookConnection } from "@/lib/db/facebook"; const db = createDbClient(); @@ -13,17 +18,19 @@ describe("facebook connections", () => { describe("getFacebookConnection", () => { it("returns connection when it exists", async () => { const { id } = await seedProfile(); - await seedFacebookConnection(id); + const org = await seedOrganization(id); + await seedFacebookConnection(org.id); - const connection = await getFacebookConnection(db, id); + const connection = await getFacebookConnection(db, org.id); expect(connection).not.toBeNull(); - expect(connection!.profile_id).toBe(id); + expect(connection!.organization_id).toBe(org.id); expect(connection!.page_name).toBe("Test Page"); }); it("returns null when no connection", async () => { const { id } = await seedProfile(); - const connection = await getFacebookConnection(db, id); + const org = await seedOrganization(id); + const connection = await getFacebookConnection(db, org.id); expect(connection).toBeNull(); }); }); @@ -31,32 +38,34 @@ describe("facebook connections", () => { describe("upsertFacebookConnection", () => { it("inserts new connection", async () => { const { id } = await seedProfile(); + const org = await seedOrganization(id); await upsertFacebookConnection(db, { - profile_id: id, + organization_id: org.id, facebook_user_id: "fb_456", facebook_page_id: "page_456", page_name: "New Page", page_access_token: "token_abc", }); - const connection = await getFacebookConnection(db, id); + const connection = await getFacebookConnection(db, org.id); expect(connection!.facebook_user_id).toBe("fb_456"); expect(connection!.page_name).toBe("New Page"); }); it("updates existing connection on conflict", async () => { const { id } = await seedProfile(); - await seedFacebookConnection(id); + const org = await seedOrganization(id); + await seedFacebookConnection(org.id); await upsertFacebookConnection(db, { - profile_id: id, + organization_id: org.id, facebook_user_id: "fb_updated", facebook_page_id: "page_updated", page_name: "Updated Page", page_access_token: "new_token", }); - const connection = await getFacebookConnection(db, id); + const connection = await getFacebookConnection(db, org.id); expect(connection!.facebook_user_id).toBe("fb_updated"); expect(connection!.page_name).toBe("Updated Page"); }); diff --git a/src/__tests__/lib/db/instagram.test.ts b/src/__tests__/lib/db/instagram.test.ts index f6a433b..df42a2c 100644 --- a/src/__tests__/lib/db/instagram.test.ts +++ b/src/__tests__/lib/db/instagram.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, afterEach } from "vitest"; import { createDbClient } from "@/lib/db/client"; -import { seedProfile, seedInstagramConnection, cleanAll } from "../../helpers/seed"; +import { + TOKEN_LIFETIME_MS, + seedProfile, + seedOrganization, + seedInstagramConnection, + cleanAll, +} from "../../helpers/seed"; import { getInstagramConnection, updateInstagramToken, @@ -17,17 +23,19 @@ describe("instagram connections", () => { describe("getInstagramConnection", () => { it("returns connection when it exists", async () => { const { id } = await seedProfile(); - await seedInstagramConnection(id); + const org = await seedOrganization(id); + await seedInstagramConnection(org.id); - const connection = await getInstagramConnection(db, id); + const connection = await getInstagramConnection(db, org.id); expect(connection).not.toBeNull(); - expect(connection!.profile_id).toBe(id); + expect(connection!.organization_id).toBe(org.id); expect(connection!.instagram_username).toBe("testuser"); }); it("returns null when no connection", async () => { const { id } = await seedProfile(); - const connection = await getInstagramConnection(db, id); + const org = await seedOrganization(id); + const connection = await getInstagramConnection(db, org.id); expect(connection).toBeNull(); }); }); @@ -35,14 +43,15 @@ describe("instagram connections", () => { describe("updateInstagramToken", () => { it("updates only token fields", async () => { const { id } = await seedProfile(); - await seedInstagramConnection(id); + const org = await seedOrganization(id); + await seedInstagramConnection(org.id); - const newExpiry = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(); - await updateInstagramToken(db, id, "refreshed_token", newExpiry); + const newExpiry = new Date(Date.now() + TOKEN_LIFETIME_MS).toISOString(); + await updateInstagramToken(db, org.id, "refreshed_token", newExpiry); - const connection = await getInstagramConnection(db, id); + const connection = await getInstagramConnection(db, org.id); expect(connection!.access_token).toBe("refreshed_token"); - expect(connection!.token_expires_at).toBe(newExpiry); + expect(new Date(connection!.token_expires_at!).getTime()).toBe(new Date(newExpiry).getTime()); // Other fields unchanged expect(connection!.instagram_user_id).toBe("ig_user_123"); expect(connection!.instagram_username).toBe("testuser"); @@ -52,32 +61,34 @@ describe("instagram connections", () => { describe("upsertInstagramConnection", () => { it("inserts new connection", async () => { const { id } = await seedProfile(); + const org = await seedOrganization(id); await upsertInstagramConnection(db, { - profile_id: id, + organization_id: org.id, instagram_user_id: "ig_456", access_token: "token_abc", - token_expires_at: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(), + token_expires_at: new Date(Date.now() + TOKEN_LIFETIME_MS).toISOString(), instagram_username: "newuser", }); - const connection = await getInstagramConnection(db, id); + const connection = await getInstagramConnection(db, org.id); expect(connection!.instagram_user_id).toBe("ig_456"); expect(connection!.instagram_username).toBe("newuser"); }); it("updates existing connection on conflict", async () => { const { id } = await seedProfile(); - await seedInstagramConnection(id); + const org = await seedOrganization(id); + await seedInstagramConnection(org.id); await upsertInstagramConnection(db, { - profile_id: id, + organization_id: org.id, instagram_user_id: "ig_updated", access_token: "new_token", - token_expires_at: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(), + token_expires_at: new Date(Date.now() + TOKEN_LIFETIME_MS).toISOString(), instagram_username: "updateduser", }); - const connection = await getInstagramConnection(db, id); + const connection = await getInstagramConnection(db, org.id); expect(connection!.instagram_user_id).toBe("ig_updated"); }); }); diff --git a/src/__tests__/lib/db/messages.test.ts b/src/__tests__/lib/db/messages.test.ts index bd88645..9f5dcee 100644 --- a/src/__tests__/lib/db/messages.test.ts +++ b/src/__tests__/lib/db/messages.test.ts @@ -181,8 +181,6 @@ describe("messages", () => { channel: "web", }); - // Small delay to ensure distinct timestamps - await new Promise((r) => setTimeout(r, 50)); const recent = await insertMessage(db, { profile_id: profileId, direction: "inbound", diff --git a/src/__tests__/lib/db/organizations.test.ts b/src/__tests__/lib/db/organizations.test.ts new file mode 100644 index 0000000..44dd688 --- /dev/null +++ b/src/__tests__/lib/db/organizations.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { createDbClient } from "@/lib/db/client"; +import { seedProfile, cleanAll } from "../../helpers/seed"; +import { + createOrganization, + getOrganizationForUser, + getOrganizationsForUser, +} from "@/lib/db/organizations"; + +const db = createDbClient(); + +describe("organizations", () => { + afterEach(async () => { + await cleanAll(); + }); + + describe("createOrganization", () => { + it("creates org and adds user as owner", async () => { + const { id } = await seedProfile(); + const org = await createOrganization(db, id, "Test Org"); + + expect(org.name).toBe("Test Org"); + expect(org.creator_user_id).toBe(id); + + // Verify membership was created + const { data: membership } = await db + .from("organization_members") + .select("*") + .eq("organization_id", org.id) + .eq("user_id", id) + .single(); + + expect(membership).not.toBeNull(); + expect(membership!.role).toBe("owner"); + }); + + it("allows creating multiple orgs for same user", async () => { + const { id } = await seedProfile(); + const org1 = await createOrganization(db, id, "Org One"); + const org2 = await createOrganization(db, id, "Org Two"); + + expect(org1.id).not.toBe(org2.id); + expect(org1.name).toBe("Org One"); + expect(org2.name).toBe("Org Two"); + + const orgs = await getOrganizationsForUser(db, id); + expect(orgs).toHaveLength(2); + }); + + it("sets default values for optional fields", async () => { + const { id } = await seedProfile(); + const org = await createOrganization(db, id, "Defaults Org"); + + expect(org.caption_style).toBe("polished"); + expect(org.publish_platforms).toEqual(["instagram"]); + expect(org.brand_name).toBeNull(); + }); + }); + + describe("getOrganizationForUser", () => { + it("returns the first org when user has one", async () => { + const { id } = await seedProfile(); + const created = await createOrganization(db, id, "Solo Org"); + + const org = await getOrganizationForUser(db, id); + expect(org).not.toBeNull(); + expect(org!.id).toBe(created.id); + expect(org!.name).toBe("Solo Org"); + }); + + it("returns the earliest-joined org when user has multiple", async () => { + const { id } = await seedProfile(); + const first = await createOrganization(db, id, "First Org"); + await createOrganization(db, id, "Second Org"); + + const org = await getOrganizationForUser(db, id); + expect(org!.id).toBe(first.id); + }); + + it("returns null when user has no orgs", async () => { + const { id } = await seedProfile(); + const org = await getOrganizationForUser(db, id); + expect(org).toBeNull(); + }); + }); + + describe("getOrganizationsForUser", () => { + it("returns empty array for user with no orgs", async () => { + const { id } = await seedProfile(); + const orgs = await getOrganizationsForUser(db, id); + expect(orgs).toEqual([]); + }); + + it("returns all orgs in join-date order", async () => { + const { id } = await seedProfile(); + const org1 = await createOrganization(db, id, "Alpha"); + const org2 = await createOrganization(db, id, "Beta"); + const org3 = await createOrganization(db, id, "Gamma"); + + const orgs = await getOrganizationsForUser(db, id); + expect(orgs).toHaveLength(3); + expect(orgs[0].id).toBe(org1.id); + expect(orgs[1].id).toBe(org2.id); + expect(orgs[2].id).toBe(org3.id); + }); + + it("only returns orgs the user is a member of", async () => { + const user1 = await seedProfile(); + const user2 = await seedProfile(); + + await createOrganization(db, user1.id, "User1 Org"); + await createOrganization(db, user2.id, "User2 Org"); + + const user1Orgs = await getOrganizationsForUser(db, user1.id); + expect(user1Orgs).toHaveLength(1); + expect(user1Orgs[0].name).toBe("User1 Org"); + + const user2Orgs = await getOrganizationsForUser(db, user2.id); + expect(user2Orgs).toHaveLength(1); + expect(user2Orgs[0].name).toBe("User2 Org"); + }); + + it("includes orgs where user is added as member (not creator)", async () => { + const owner = await seedProfile(); + const member = await seedProfile(); + + const org = await createOrganization(db, owner.id, "Shared Org"); + + // Manually add second user as member + await db.from("organization_members").insert({ + organization_id: org.id, + user_id: member.id, + role: "member", + }); + + const memberOrgs = await getOrganizationsForUser(db, member.id); + expect(memberOrgs).toHaveLength(1); + expect(memberOrgs[0].id).toBe(org.id); + expect(memberOrgs[0].name).toBe("Shared Org"); + }); + }); +}); diff --git a/src/__tests__/lib/db/posts.test.ts b/src/__tests__/lib/db/posts.test.ts index 98bee36..d4a73de 100644 --- a/src/__tests__/lib/db/posts.test.ts +++ b/src/__tests__/lib/db/posts.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect, afterEach } from "vitest"; import { createDbClient } from "@/lib/db/client"; -import { seedProfile, seedPost, cleanAll } from "../../helpers/seed"; +import { seedProfile, seedPost, seedOrganization, cleanAll } from "../../helpers/seed"; import { getActiveDraft, getPostById, getPostByPreviewToken, - getPostsByProfile, + getPostsByOrganization, getRecentCaptions, insertPost, updatePost, @@ -45,7 +45,18 @@ describe("posts", () => { const { id: profileId } = await seedProfile(); const { id: postId } = await seedPost(profileId); - const post = await getPostById(db, postId, profileId); + const post = await getPostById(db, postId, { profileId }); + expect(post).not.toBeNull(); + expect(post!.id).toBe(postId); + }); + + it("returns post when id and organizationId match", async () => { + const { id: profileId } = await seedProfile(); + const { id: orgId } = await seedOrganization(profileId); + const { id: postId } = await seedPost(profileId, { organization_id: orgId }); + + // Another user in the same org can look up by orgId + const post = await getPostById(db, postId, { organizationId: orgId }); expect(post).not.toBeNull(); expect(post!.id).toBe(postId); }); @@ -55,13 +66,23 @@ describe("posts", () => { const { id: postId } = await seedPost(profileId); const { id: otherId } = await seedProfile(); - const post = await getPostById(db, postId, otherId); + const post = await getPostById(db, postId, { profileId: otherId }); + expect(post).toBeNull(); + }); + + it("returns null when organizationId does not match", async () => { + const { id: profileId } = await seedProfile(); + const { id: orgId } = await seedOrganization(profileId); + const { id: otherOrgId } = await seedOrganization(profileId, { name: "Other Org" }); + const { id: postId } = await seedPost(profileId, { organization_id: orgId }); + + const post = await getPostById(db, postId, { organizationId: otherOrgId }); expect(post).toBeNull(); }); it("returns null for nonexistent post", async () => { const { id: profileId } = await seedProfile(); - const post = await getPostById(db, crypto.randomUUID(), profileId); + const post = await getPostById(db, crypto.randomUUID(), { profileId }); expect(post).toBeNull(); }); }); @@ -82,14 +103,15 @@ describe("posts", () => { }); }); - describe("getPostsByProfile", () => { + describe("getPostsByOrganization", () => { it("returns non-cancelled posts in desc order", async () => { const { id } = await seedProfile(); - await seedPost(id, { caption: "draft one", status: "draft" }); - await seedPost(id, { caption: "published one", status: "published" }); - await seedPost(id, { caption: "cancelled one", status: "cancelled" }); + const { id: orgId } = await seedOrganization(id); + await seedPost(id, { caption: "draft one", status: "draft", organization_id: orgId }); + await seedPost(id, { caption: "published one", status: "published", organization_id: orgId }); + await seedPost(id, { caption: "cancelled one", status: "cancelled", organization_id: orgId }); - const posts = await getPostsByProfile(db, id); + const posts = await getPostsByOrganization(db, orgId); expect(posts).toHaveLength(2); // Most recent first expect(posts[0].caption).toBe("published one"); @@ -98,11 +120,27 @@ describe("posts", () => { expect(posts.every((p) => p.status !== "cancelled")).toBe(true); }); - it("returns empty array for user with no posts", async () => { + it("returns empty array for org with no posts", async () => { const { id } = await seedProfile(); - const posts = await getPostsByProfile(db, id); + const { id: orgId } = await seedOrganization(id); + const posts = await getPostsByOrganization(db, orgId); expect(posts).toEqual([]); }); + + it("filters by profileId when provided", async () => { + const { id: user1 } = await seedProfile(); + const { id: user2 } = await seedProfile(); + const { id: orgId } = await seedOrganization(user1); + await seedPost(user1, { caption: "user1 post", organization_id: orgId }); + await seedPost(user2, { caption: "user2 post", organization_id: orgId }); + + const myPosts = await getPostsByOrganization(db, orgId, user1); + expect(myPosts).toHaveLength(1); + expect(myPosts[0].caption).toBe("user1 post"); + + const allPosts = await getPostsByOrganization(db, orgId); + expect(allPosts).toHaveLength(2); + }); }); describe("getRecentCaptions", () => { @@ -139,7 +177,7 @@ describe("posts", () => { expect(result.id).toBeDefined(); expect(result.preview_token).toBeDefined(); - const post = await getPostById(db, result.id, profileId); + const post = await getPostById(db, result.id, { profileId }); expect(post!.caption).toBe("test caption"); }); }); @@ -154,28 +192,47 @@ describe("posts", () => { status: "published", }); - const post = await getPostById(db, postId, profileId); + const post = await getPostById(db, postId, { profileId }); expect(post!.caption).toBe("updated caption"); expect(post!.status).toBe("published"); }); }); describe("cancelDrafts", () => { - it("cancels all draft posts for a profile", async () => { + it("cancels all draft posts for a profile in an org", async () => { const { id } = await seedProfile(); - await seedPost(id, { status: "draft" }); - await seedPost(id, { status: "draft" }); - await seedPost(id, { status: "published" }); + const { id: orgId } = await seedOrganization(id); + await seedPost(id, { status: "draft", organization_id: orgId }); + await seedPost(id, { status: "draft", organization_id: orgId }); + await seedPost(id, { status: "published", organization_id: orgId }); - await cancelDrafts(db, id); + await cancelDrafts(db, id, orgId); const draft = await getActiveDraft(db, id); expect(draft).toBeNull(); // Published post should be unaffected - const posts = await getPostsByProfile(db, id); + const posts = await getPostsByOrganization(db, orgId); expect(posts).toHaveLength(1); expect(posts[0].status).toBe("published"); }); + + it("only cancels drafts in the specified org", async () => { + const { id } = await seedProfile(); + const { id: org1 } = await seedOrganization(id, { name: "Org 1" }); + const { id: org2 } = await seedOrganization(id, { name: "Org 2" }); + await seedPost(id, { status: "draft", organization_id: org1 }); + await seedPost(id, { status: "draft", organization_id: org2 }); + + await cancelDrafts(db, id, org1); + + const org1Posts = await getPostsByOrganization(db, org1); + expect(org1Posts).toHaveLength(0); + + // Org2 draft should be untouched + const org2Posts = await getPostsByOrganization(db, org2); + expect(org2Posts).toHaveLength(1); + expect(org2Posts[0].status).toBe("draft"); + }); }); }); diff --git a/src/__tests__/lib/db/registrations.test.ts b/src/__tests__/lib/db/registrations.test.ts index 1fd0f41..37aece4 100644 --- a/src/__tests__/lib/db/registrations.test.ts +++ b/src/__tests__/lib/db/registrations.test.ts @@ -13,8 +13,6 @@ const db = createDbClient(); describe("pending registrations", () => { afterEach(async () => { - const NIL = "00000000-0000-0000-0000-000000000000"; - await db.from("pending_registrations").delete().neq("id", NIL); await cleanAll(); }); diff --git a/src/__tests__/lib/instagram/auth.test.ts b/src/__tests__/lib/instagram/auth.test.ts index c933b1a..d5d6c72 100644 --- a/src/__tests__/lib/instagram/auth.test.ts +++ b/src/__tests__/lib/instagram/auth.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; + +vi.unmock("@/lib/instagram/auth"); + import { isTokenExpiringSoon } from "@/lib/instagram/auth"; describe("isTokenExpiringSoon", () => { diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 1ffa75f..0fdda78 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -72,7 +72,7 @@ vi.mock("@/lib/instagram/auth", () => ({ getInstagramUsername: vi.fn().mockResolvedValue("testuser"), refreshInstagramToken: vi.fn().mockResolvedValue({ accessToken: "refreshed_tok", - expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000), + expiresAt: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000), // 60 days }), isTokenExpiringSoon: vi.fn().mockReturnValue(false), })); diff --git a/src/app/account/account-view.tsx b/src/app/account/account-view.tsx index 7af6f80..bfd167d 100644 --- a/src/app/account/account-view.tsx +++ b/src/app/account/account-view.tsx @@ -12,8 +12,15 @@ import { REQUIRED_FACEBOOK_SCOPES, needsReauth, } from "@/lib/core/scopes"; +import OrgSwitcher from "@/app/posts/org-switcher"; -export default function AccountView() { +export default function AccountView({ + activeOrgId, + activeOrgName, +}: { + activeOrgId: string | null; + activeOrgName: string | null; +}) { return ( } > - + ); } -function AccountContent() { +function AccountContent({ + activeOrgId, + activeOrgName, +}: { + activeOrgId: string | null; + activeOrgName: string | null; +}) { const [profile, setProfile] = useState(null); const [instagram, setInstagram] = useState(null); const [facebook, setFacebook] = useState(null); @@ -90,19 +103,29 @@ function AccountContent() { setCaptionStyle(p.caption_style || "polished"); setTargetAudience(p.target_audience || ""); - const [igResult, fbResult, pendingFbResult] = await Promise.all([ - supabase.from("instagram_connections").select("*").eq("profile_id", user.id).maybeSingle(), - supabase.from("facebook_connections").select("*").eq("profile_id", user.id).maybeSingle(), - supabase - .from("pending_facebook_tokens") - .select("facebook_user_id") - .eq("profile_id", user.id) - .maybeSingle(), - ]); - - setInstagram(igResult.data); - setFacebook(fbResult.data); - setHasPendingFb(!!pendingFbResult.data); + // Use the active org (resolved server-side from cookie) to find connections + if (activeOrgId) { + const [igResult, fbResult, pendingFbResult] = await Promise.all([ + supabase + .from("instagram_connections") + .select("*") + .eq("organization_id", activeOrgId) + .maybeSingle(), + supabase + .from("facebook_connections") + .select("*") + .eq("organization_id", activeOrgId) + .maybeSingle(), + supabase + .from("pending_facebook_tokens") + .select("facebook_user_id") + .eq("profile_id", user.id) + .maybeSingle(), + ]); + setInstagram(igResult.data); + setFacebook(fbResult.data); + setHasPendingFb(!!pendingFbResult.data); + } setChecking(false); } load(); @@ -136,6 +159,7 @@ function AccountContent() { async function handleLogout() { await supabase.auth.signOut(); + document.cookie = "active_org=; path=/; max-age=0"; router.push("/login"); } @@ -326,111 +350,127 @@ function AccountContent() { )} + {/* Organization connections card */}
-

Instagram Connection

- {igError && ( -
{igError}
- )} - {instagram ? ( -
-
-
-

@{instagram.instagram_username || "Connected"}

-

- Connected {new Date(instagram.created_at).toLocaleDateString()} -

-
- - Reconnect - -
- {needsReauth(instagram.granted_scopes, REQUIRED_INSTAGRAM_SCOPES) && ( -
-

- New permissions required. Please re-authorize to continue using all features. -

+
+

{activeOrgName || "Organization"}

+ +
+

+ Social accounts linked to this organization. +

+ + {/* Instagram */} +
+

Instagram

+ {igError && ( +
{igError}
+ )} + {instagram ? ( +
+
+
+

@{instagram.instagram_username || "Connected"}

+

+ Connected {new Date(instagram.created_at).toLocaleDateString()} +

+
- Re-authorize + Reconnect
- )} -
- ) : ( - - Connect Instagram - - )} -
+ {needsReauth(instagram.granted_scopes, REQUIRED_INSTAGRAM_SCOPES) && ( +
+

+ New permissions required. Please re-authorize to continue using all features. +

+ + Re-authorize + +
+ )} +
+ ) : ( + + Connect Instagram + + )} +
-
-

Facebook Connection

- {fbError && ( -
{fbError}
- )} - {facebook ? ( -
-
-
-

{facebook.page_name || "Connected"}

-

- Connected {new Date(facebook.created_at).toLocaleDateString()} -

+ {/* Facebook */} +
+

Facebook

+ {fbError && ( +
{fbError}
+ )} + {facebook ? ( +
+
+
+

{facebook.page_name || "Connected"}

+

+ Connected {new Date(facebook.created_at).toLocaleDateString()} +

+
+ + Reconnect +
- - Reconnect - + {needsReauth(facebook.granted_scopes, REQUIRED_FACEBOOK_SCOPES) && ( +
+

+ New permissions required. Please re-authorize to continue using all features. +

+ + Re-authorize + +
+ )}
- {needsReauth(facebook.granted_scopes, REQUIRED_FACEBOOK_SCOPES) && ( -
-

- New permissions required. Please re-authorize to continue using all features. -

+ ) : hasPendingFb ? ( +
+
+
+

Connected

+

No Facebook Page selected

+
- Re-authorize + Reconnect
- )} -
- ) : hasPendingFb ? ( -
-
-
-

Connected

-

No Facebook Page selected

-
- - Reconnect -
-
- ) : ( - - Connect Facebook - - )} + ) : ( + + Connect Facebook + + )} +
+ {/* New Organization */} + + {profile?.phone && (

Phone

@@ -441,3 +481,93 @@ function AccountContent() {
); } + +function NewOrgForm() { + const [showForm, setShowForm] = useState(false); + const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(""); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim()) return; + setCreating(true); + setCreateError(""); + + try { + const res = await fetch("/api/org/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name.trim() }), + }); + if (res.ok) { + // API auto-switches to the new org via cookie — reload to show it + window.location.reload(); + } else { + const data = await res.json(); + setCreateError(data.error || "Failed to create organization."); + setCreating(false); + } + } catch { + setCreateError("Something went wrong. Please try again."); + setCreating(false); + } + } + + if (!showForm) { + return ( + + ); + } + + return ( +
+

New Organization

+
+
+ + setName(e.target.value)} + required + maxLength={100} + autoFocus + placeholder="e.g. Pizza Planet" + className="w-full rounded-lg border border-base-300 px-4 py-2.5 text-base-content focus:ring-2 focus:ring-primary focus:border-transparent outline-none" + /> +
+ {createError && ( +
{createError}
+ )} +
+ + +
+
+
+ ); +} diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index adb9baa..11c60b7 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -1,7 +1,23 @@ +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import { createDbClient } from "@/lib/db/client"; +import { getActiveOrganization } from "@/lib/db/organizations"; import AccountView from "./account-view"; export const dynamic = "force-dynamic"; -export default function AccountPage() { - return ; +export default async function AccountPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect("/login"); + } + + const db = createDbClient(); + const org = await getActiveOrganization(db, user.id); + + return ; } diff --git a/src/app/api/auth/create-profile/route.ts b/src/app/api/auth/create-profile/route.ts index b46d338..2a4c92e 100644 --- a/src/app/api/auth/create-profile/route.ts +++ b/src/app/api/auth/create-profile/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createDbClient } from "@/lib/db/client"; import { createClient } from "@/lib/supabase/server"; import { insertProfile } from "@/lib/db/profiles"; +import { createOrganization } from "@/lib/db/organizations"; import { getUnusedRegistrationByToken, markRegistrationUsed } from "@/lib/db/registrations"; import { log, serializeError } from "@/lib/logger"; @@ -40,9 +41,12 @@ export async function POST(request: NextRequest) { try { await insertProfile(db, { id: user.id, phone }); + // Create a default organization for the user + await createOrganization(db, user.id, "My Organization"); + log.info({ operation: "api.auth.createProfile", - message: "Profile created", + message: "Profile and default organization created", profileId: user.id, hasPhone: !!phone, }); diff --git a/src/app/api/cron/refresh-tokens/route.ts b/src/app/api/cron/refresh-tokens/route.ts index b3f075c..cfe5bfc 100644 --- a/src/app/api/cron/refresh-tokens/route.ts +++ b/src/app/api/cron/refresh-tokens/route.ts @@ -28,7 +28,7 @@ export async function GET(request: NextRequest) { const { data: connections, error } = await db .from("instagram_connections") - .select("profile_id, access_token, token_expires_at") + .select("organization_id, access_token, token_expires_at") .not("token_expires_at", "is", null) .gt("token_expires_at", now) .lt("token_expires_at", sevenDaysFromNow); @@ -59,7 +59,7 @@ export async function GET(request: NextRequest) { const result = await refreshInstagramToken(conn.access_token); await updateInstagramToken( db, - conn.profile_id, + conn.organization_id, result.accessToken, result.expiresAt.toISOString(), ); @@ -69,7 +69,7 @@ export async function GET(request: NextRequest) { log.error({ operation: "api.cron.refreshTokens", message: "Failed to refresh token", - profileId: conn.profile_id, + orgId: conn.organization_id, error: serializeError(err), }); } diff --git a/src/app/api/facebook/callback/route.ts b/src/app/api/facebook/callback/route.ts index 94d3110..3dec726 100644 --- a/src/app/api/facebook/callback/route.ts +++ b/src/app/api/facebook/callback/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createDbClient } from "@/lib/db/client"; import { exchangeCodeForToken, getGrantedScopes } from "@/lib/facebook/auth"; import { savePendingFacebookToken } from "@/lib/db/facebook"; +import { getActiveOrganization } from "@/lib/db/organizations"; import { getBaseUrl } from "@/lib/core/url"; import { log, timed, serializeError } from "@/lib/logger"; @@ -33,8 +34,14 @@ export async function GET(request: NextRequest) { const db = createDbClient(); + // Look up the user's organization + const org = await getActiveOrganization(db, userId); + if (!org) { + return NextResponse.redirect(`${baseUrl}/account?error=no_organization`); + } + // Save token to pending table (not in URL) for page selection step - await savePendingFacebookToken(db, userId, fbUserId, accessToken, grantedScopes ?? undefined); + await savePendingFacebookToken(db, org.id, fbUserId, accessToken, grantedScopes ?? undefined); log.info({ operation: "api.facebook.callback", diff --git a/src/app/api/facebook/pages/route.ts b/src/app/api/facebook/pages/route.ts index f621748..80e6bec 100644 --- a/src/app/api/facebook/pages/route.ts +++ b/src/app/api/facebook/pages/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { createDbClient } from "@/lib/db/client"; import { getPendingFacebookToken } from "@/lib/db/facebook"; +import { getActiveOrganization } from "@/lib/db/organizations"; import { listPages } from "@/lib/facebook/auth"; import { log, timed, serializeError } from "@/lib/logger"; @@ -17,7 +18,11 @@ export async function GET() { } const db = createDbClient(); - const pending = await getPendingFacebookToken(db, user.id); + const org = await getActiveOrganization(db, user.id); + if (!org) { + return NextResponse.json({ error: "No organization found" }, { status: 404 }); + } + const pending = await getPendingFacebookToken(db, org.id); if (!pending) { return NextResponse.json({ error: "No pending Facebook token found" }, { status: 404 }); diff --git a/src/app/api/facebook/select-page/route.ts b/src/app/api/facebook/select-page/route.ts index c2ab986..4d4fc7d 100644 --- a/src/app/api/facebook/select-page/route.ts +++ b/src/app/api/facebook/select-page/route.ts @@ -6,6 +6,7 @@ import { deletePendingFacebookToken, upsertFacebookConnection, } from "@/lib/db/facebook"; +import { getActiveOrganization } from "@/lib/db/organizations"; import { log, timed, serializeError } from "@/lib/logger"; export async function POST(request: NextRequest) { @@ -28,7 +29,11 @@ export async function POST(request: NextRequest) { } const db = createDbClient(); - const pending = await getPendingFacebookToken(db, user.id); + const org = await getActiveOrganization(db, user.id); + if (!org) { + return NextResponse.json({ error: "No organization found" }, { status: 404 }); + } + const pending = await getPendingFacebookToken(db, org.id); if (!pending) { return NextResponse.json({ error: "No pending Facebook token found" }, { status: 404 }); @@ -37,7 +42,8 @@ export async function POST(request: NextRequest) { try { // Save the Facebook page connection await upsertFacebookConnection(db, { - profile_id: user.id, + organization_id: org.id, + user_id: user.id, facebook_user_id: pending.facebook_user_id, facebook_page_id: page_id, page_name: page_name || null, @@ -61,7 +67,7 @@ export async function POST(request: NextRequest) { } // Clean up pending token - await deletePendingFacebookToken(db, user.id); + await deletePendingFacebookToken(db, org.id); log.info({ operation: "api.facebook.selectPage", diff --git a/src/app/api/instagram/callback/route.ts b/src/app/api/instagram/callback/route.ts index 87d85ae..1a066e6 100644 --- a/src/app/api/instagram/callback/route.ts +++ b/src/app/api/instagram/callback/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createDbClient } from "@/lib/db/client"; import { exchangeCodeForToken, getInstagramUsername } from "@/lib/instagram/auth"; import { upsertInstagramConnection } from "@/lib/db/instagram"; +import { getActiveOrganization } from "@/lib/db/organizations"; import { getBaseUrl } from "@/lib/core/url"; import { log, timed, serializeError } from "@/lib/logger"; @@ -37,9 +38,16 @@ export async function GET(request: NextRequest) { const db = createDbClient(); + // Look up the user's organization + const org = await getActiveOrganization(db, userId); + if (!org) { + return NextResponse.redirect(`${baseUrl}/account?error=no_organization`); + } + // Upsert the connection await upsertInstagramConnection(db, { - profile_id: userId, + organization_id: org.id, + user_id: userId, instagram_user_id: igUserId, access_token: accessToken, token_expires_at: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000).toISOString(), // 60 days diff --git a/src/app/api/org/create/route.ts b/src/app/api/org/create/route.ts new file mode 100644 index 0000000..b0f5ca4 --- /dev/null +++ b/src/app/api/org/create/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { createDbClient } from "@/lib/db/client"; +import { createOrganization } from "@/lib/db/organizations"; +import { createOrgToken, COOKIE_NAME, buildOrgCookieOptions } from "@/lib/org-context"; +import { log, serializeError } from "@/lib/logger"; + +export async function POST(request: NextRequest) { + log.info({ operation: "api.org.create", message: "POST /api/org/create" }); + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { name } = await request.json(); + if (!name || typeof name !== "string" || name.trim().length === 0) { + return NextResponse.json({ error: "Organization name is required" }, { status: 400 }); + } + if (name.trim().length > 100) { + return NextResponse.json( + { error: "Organization name must be 100 characters or less" }, + { status: 400 }, + ); + } + + const db = createDbClient(); + + try { + const org = await createOrganization(db, user.id, name.trim()); + + // Auto-switch to the new org + const token = await createOrgToken({ + orgId: org.id, + orgName: org.name, + role: "owner", + userId: user.id, + }); + + log.info({ + operation: "api.org.create", + message: "Organization created", + orgId: org.id, + orgName: org.name, + }); + + const response = NextResponse.json({ + success: true, + organization: { id: org.id, name: org.name }, + }); + response.cookies.set(COOKIE_NAME, token, buildOrgCookieOptions()); + return response; + } catch (err) { + log.error({ + operation: "api.org.create", + message: "Failed to create organization", + error: serializeError(err), + }); + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/org/list/route.ts b/src/app/api/org/list/route.ts new file mode 100644 index 0000000..e387577 --- /dev/null +++ b/src/app/api/org/list/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { createDbClient } from "@/lib/db/client"; +import { log, serializeError } from "@/lib/logger"; + +export async function GET() { + log.info({ operation: "api.org.list", message: "GET /api/org/list" }); + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const db = createDbClient(); + const { data: memberships, error } = await db + .from("organization_members") + .select("organization_id, role, organizations(id, name)") + .eq("user_id", user.id); + + if (error) { + log.error({ + operation: "api.org.list", + message: "Failed to fetch organizations", + error: serializeError(error), + }); + return NextResponse.json({ error: "Failed to fetch organizations" }, { status: 500 }); + } + + const orgs = (memberships || []) + .filter((m) => m.organizations != null) + .map((m) => { + const org = m.organizations as unknown as { id: string; name: string }; + return { id: org.id, name: org.name, role: m.role }; + }); + + return NextResponse.json({ organizations: orgs }); +} diff --git a/src/app/api/org/switch/route.ts b/src/app/api/org/switch/route.ts new file mode 100644 index 0000000..3971147 --- /dev/null +++ b/src/app/api/org/switch/route.ts @@ -0,0 +1,63 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { createDbClient } from "@/lib/db/client"; +import { createOrgToken, COOKIE_NAME, buildOrgCookieOptions } from "@/lib/org-context"; +import type { OrgRole } from "@/lib/supabase/types"; +import { log } from "@/lib/logger"; + +export async function POST(request: NextRequest) { + log.info({ operation: "api.org.switch", message: "POST /api/org/switch" }); + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { organizationId } = await request.json(); + if (!organizationId) { + return NextResponse.json({ error: "Missing organizationId" }, { status: 400 }); + } + + const db = createDbClient(); + + // Validate membership and get org name in one query + const { data: membership, error } = await db + .from("organization_members") + .select("role, organizations(id, name)") + .eq("organization_id", organizationId) + .eq("user_id", user.id) + .single(); + + if (error || !membership) { + log.warn({ + operation: "api.org.switch", + message: "Non-member attempted org switch", + orgId: organizationId, + }); + return NextResponse.json({ error: "Not a member of this organization" }, { status: 403 }); + } + + const org = membership.organizations as unknown as { id: string; name: string } | null; + + const token = await createOrgToken({ + orgId: organizationId, + orgName: org?.name || "Organization", + role: membership.role as OrgRole, + userId: user.id, + }); + + log.info({ + operation: "api.org.switch", + message: "Org switched", + orgId: organizationId, + orgName: org?.name, + }); + + const response = NextResponse.json({ success: true }); + response.cookies.set(COOKIE_NAME, token, buildOrgCookieOptions()); + return response; +} diff --git a/src/app/api/posts/[postId]/delete/route.ts b/src/app/api/posts/[postId]/delete/route.ts index ea015f5..2b60f63 100644 --- a/src/app/api/posts/[postId]/delete/route.ts +++ b/src/app/api/posts/[postId]/delete/route.ts @@ -23,7 +23,7 @@ export async function POST( const db = createDbClient(); // Verify post belongs to user - const post = await getPostById(db, postId, user.id); + const post = await getPostById(db, postId, { profileId: user.id }); if (!post) { return NextResponse.json({ error: "Not found" }, { status: 404 }); diff --git a/src/app/api/posts/[postId]/stats/route.ts b/src/app/api/posts/[postId]/stats/route.ts index d4cedda..79af172 100644 --- a/src/app/api/posts/[postId]/stats/route.ts +++ b/src/app/api/posts/[postId]/stats/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createClient } from "@/lib/supabase/server"; import { createAdminClient } from "@/lib/supabase/admin"; +import { getActiveOrganization } from "@/lib/db/organizations"; import { log, timed, serializeError } from "@/lib/logger"; const STALE_AFTER_MS = 10 * 60 * 1000; // 10 minutes @@ -49,11 +50,14 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ post } // Fetch fresh stats from Instagram - const { data: ig } = await admin - .from("instagram_connections") - .select("access_token") - .eq("profile_id", user.id) - .single(); + const org = await getActiveOrganization(admin, user.id); + const { data: ig } = org + ? await admin + .from("instagram_connections") + .select("access_token") + .eq("organization_id", org.id) + .single() + : { data: null }; if (!ig) { return NextResponse.json({ stats: cached?.data || null, fetched_at: cached?.fetched_at }); diff --git a/src/app/onboarding/onboarding-view.tsx b/src/app/onboarding/onboarding-view.tsx index e331130..b1a1537 100644 --- a/src/app/onboarding/onboarding-view.tsx +++ b/src/app/onboarding/onboarding-view.tsx @@ -81,6 +81,29 @@ export default function OnboardingView() { return; } + // Rename the default org to match the brand name (best-effort, non-blocking) + if (brandName) { + try { + const { data: membership } = await supabase + .from("organization_members") + .select("organization_id") + .eq("user_id", user.id) + .limit(1) + .single(); + if (membership) { + const { error: renameError } = await supabase + .from("organizations") + .update({ name: brandName }) + .eq("id", membership.organization_id); + if (renameError) { + console.warn("Failed to rename default organization:", renameError.message); + } + } + } catch (err) { + console.warn("Failed to rename default organization:", err); + } + } + router.push("/posts"); } diff --git a/src/app/posts/[postId]/page.tsx b/src/app/posts/[postId]/page.tsx index 999ce10..7e96351 100644 --- a/src/app/posts/[postId]/page.tsx +++ b/src/app/posts/[postId]/page.tsx @@ -4,6 +4,7 @@ import { createDbClient } from "@/lib/db/client"; import { getPostById } from "@/lib/db/posts"; import { getMessages } from "@/lib/db/messages"; import { getInstagramConnection } from "@/lib/db/instagram"; +import { getActiveOrganization } from "@/lib/db/organizations"; import ThreadView from "./thread-view"; async function fetchInstagramProfile( @@ -40,16 +41,20 @@ export default async function ThreadPage({ params }: { params: Promise<{ postId: const db = createDbClient(); - // Fetch post and Instagram connection in parallel - const [post, igConnection] = await Promise.all([ - getPostById(db, postId, user.id), - getInstagramConnection(db, user.id), - ]); + const org = await getActiveOrganization(db, user.id); + + // Look up post by org membership (allows viewing other members' posts) + const post = org + ? await getPostById(db, postId, { organizationId: org.id }) + : await getPostById(db, postId, { profileId: user.id }); if (!post) { notFound(); } + // Fetch Instagram connection if org exists + const igConnection = org ? await getInstagramConnection(db, org.id) : null; + // Fetch messages and Instagram profile in parallel const [messages, igProfile] = await Promise.all([ getMessages(db, user.id, { channel: "web", postId, ascending: true }), diff --git a/src/app/posts/org-switcher.tsx b/src/app/posts/org-switcher.tsx new file mode 100644 index 0000000..f65e5da --- /dev/null +++ b/src/app/posts/org-switcher.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; + +interface OrgItem { + id: string; + name: string; + role: string; +} + +export default function OrgSwitcher({ + currentOrgId, + compact, +}: { + currentOrgId?: string | null; + compact?: boolean; +}) { + const [orgs, setOrgs] = useState([]); + const [expanded, setExpanded] = useState(false); + const [switching, setSwitching] = useState(false); + const [switchError, setSwitchError] = useState(false); + const containerRef = useRef(null); + + // Close dropdown on outside click + useEffect(() => { + if (!expanded) return; + function handleClick(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setExpanded(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [expanded]); + + useEffect(() => { + fetch("/api/org/list") + .then((r) => r.json()) + .then((data) => { + if (data.organizations) setOrgs(data.organizations); + }) + .catch(() => {}); + }, []); + + if (orgs.length === 0) return null; + // In compact mode, only render if there are multiple orgs to switch between + if (compact && orgs.length <= 1) return null; + + const activeOrg = orgs.find((o) => o.id === currentOrgId) || orgs[0]; + + async function handleSwitch(orgId: string) { + if (orgId === activeOrg.id) { + setExpanded(false); + return; + } + setSwitching(true); + setSwitchError(false); + try { + const res = await fetch("/api/org/switch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ organizationId: orgId }), + }); + if (res.ok) { + window.location.reload(); + } else { + setSwitchError(true); + setSwitching(false); + } + } catch { + setSwitchError(true); + setSwitching(false); + } + } + + return ( +
+ + {switchError && ( +

Failed to switch. Please try again.

+ )} + {expanded && ( +
+ {orgs.map((org) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/app/posts/page.tsx b/src/app/posts/page.tsx index b89f3ab..fba91a5 100644 --- a/src/app/posts/page.tsx +++ b/src/app/posts/page.tsx @@ -2,7 +2,8 @@ import { redirect } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; import { createDbClient } from "@/lib/db/client"; import { getProfile } from "@/lib/db/profiles"; -import { getPostsByProfile } from "@/lib/db/posts"; +import { getPostsByOrganization } from "@/lib/db/posts"; +import { getActiveOrganization } from "@/lib/db/organizations"; import PostsList from "./posts-list"; export default async function ChatPage() { @@ -17,15 +18,31 @@ export default async function ChatPage() { const db = createDbClient(); - // Check onboarding - const profile = await getProfile(db, user.id); + const [profile, org] = await Promise.all([ + getProfile(db, user.id), + getActiveOrganization(db, user.id), + ]); if (!profile?.onboarding_completed) { redirect("/onboarding"); } - // Fetch all posts for the user - const posts = await getPostsByProfile(db, user.id); + if (!org) { + redirect("/onboarding"); + } + + // Fetch both views in parallel: my posts and all org posts + const [myPosts, allPosts] = await Promise.all([ + getPostsByOrganization(db, org.id, user.id), + getPostsByOrganization(db, org.id), + ]); - return ; + return ( + + ); } diff --git a/src/app/posts/posts-list.tsx b/src/app/posts/posts-list.tsx index a4e6736..3911c05 100644 --- a/src/app/posts/posts-list.tsx +++ b/src/app/posts/posts-list.tsx @@ -5,6 +5,9 @@ import { useRouter } from "next/navigation"; import { createClient } from "@/lib/supabase/client"; import type { Post } from "@/lib/db/posts"; import Image from "next/image"; +import OrgSwitcher from "./org-switcher"; + +type Filter = "mine" | "all"; const statusColors: Record = { draft: "bg-warning/10 text-warning", @@ -12,16 +15,31 @@ const statusColors: Record = { cancelled: "bg-base-200 text-base-content/50", }; -export default function PostsList({ posts: initialPosts }: { posts: Post[] }) { +export default function PostsList({ + myPosts, + allPosts, + activeOrgId, + activeOrgName, +}: { + myPosts: Post[]; + allPosts: Post[]; + activeOrgId: string; + activeOrgName: string; +}) { const router = useRouter(); const [menuOpen, setMenuOpen] = useState(false); - const [posts, setPosts] = useState(initialPosts); + const [filter, setFilter] = useState("mine"); + const [deletedIds, setDeletedIds] = useState>(new Set()); const [deletingId, setDeletingId] = useState(null); const [deleteError, setDeleteError] = useState(null); + const hasOtherPosts = allPosts.length > myPosts.length; + const posts = (filter === "mine" ? myPosts : allPosts).filter((p) => !deletedIds.has(p.id)); + async function handleLogout() { const supabase = createClient(); await supabase.auth.signOut(); + document.cookie = "active_org=; path=/; max-age=0"; router.push("/login"); } @@ -32,7 +50,7 @@ export default function PostsList({ posts: initialPosts }: { posts: Post[] }) { try { const res = await fetch(`/api/posts/${postId}/delete`, { method: "POST" }); if (res.ok) { - setPosts((prev) => prev.filter((p) => p.id !== postId)); + setDeletedIds((prev) => new Set(prev).add(postId)); } else { setDeleteError("Could not delete post. Please try again."); } @@ -72,6 +90,24 @@ export default function PostsList({ posts: initialPosts }: { posts: Post[] }) {
+ {/* Filter bar */} +
+ {activeOrgName} + My Posts + {hasOtherPosts && ( + + )} +
+ {/* Slide-in menu backdrop */}
+
)} {posts.map((post) => { diff --git a/src/lib/core/handle-approve.ts b/src/lib/core/handle-approve.ts index 8e5326c..076f2f7 100644 --- a/src/lib/core/handle-approve.ts +++ b/src/lib/core/handle-approve.ts @@ -24,20 +24,27 @@ export async function executePublish(profileId: string, post: Post): Promise { const db = createDbClient(); @@ -62,8 +67,10 @@ export async function uploadAndCreatePost( const publicUrl = getPostImageUrl(db, fileName); + const org = await resolveOrganization(db, profileId, messageBody); const post = await insertPost(db, { profile_id: profileId, + organization_id: org?.id ?? null, image_url: publicUrl, caption: "", status: "draft", @@ -89,3 +96,70 @@ export async function uploadAndCreatePost( return null; } } + +/** + * Resolve which organization a new post belongs to. + * - 0 orgs: returns null + * - 1 org: returns it directly + * - 2+ orgs: uses an LLM to infer from the message body, falls back to first org + */ +async function resolveOrganization( + client: DbClient, + userId: string, + messageBody?: string, +): Promise { + const orgs = await getOrganizationsForUser(client, userId); + if (orgs.length === 0) return null; + if (orgs.length === 1) return orgs[0]; + + // Multiple orgs — try LLM disambiguation if we have a message + if (messageBody) { + try { + const orgId = await inferOrganization(orgs, messageBody); + if (orgId) { + const match = orgs.find((o) => o.id === orgId); + if (match) { + log.info({ + operation: "handleNewPost.resolveOrg", + message: "LLM resolved organization", + orgId: match.id, + orgName: match.name, + }); + return match; + } + } + } catch (err) { + log.warn({ + operation: "handleNewPost.resolveOrg", + message: "LLM org inference failed, using default", + error: serializeError(err), + }); + } + } + + return orgs[0]; +} + +async function inferOrganization( + orgs: Organization[], + messageBody: string, +): Promise { + const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + const orgList = orgs.map((o) => `- "${o.name}" (id: ${o.id})`).join("\n"); + + const response = await openai.responses.create({ + model: "gpt-4o-mini", + instructions: `You help route social media posts to the right organization. +Given a user's message and a list of organizations they belong to, determine which organization this post is most likely for. +Respond with ONLY the organization id, nothing else. If you cannot determine the organization, respond with "unknown".`, + input: `Organizations:\n${orgList}\n\nUser message: "${messageBody}"`, + max_output_tokens: 100, + }); + + const answer = response.output_text?.trim(); + if (!answer || answer === "unknown") return null; + + // Validate it's one of the actual org IDs + const validIds = new Set(orgs.map((o) => o.id)); + return validIds.has(answer) ? answer : null; +} diff --git a/src/lib/core/instagram-data.ts b/src/lib/core/instagram-data.ts index f61f090..2ce3c82 100644 --- a/src/lib/core/instagram-data.ts +++ b/src/lib/core/instagram-data.ts @@ -2,12 +2,12 @@ import { createDbClient } from "@/lib/db/client"; import type { Post } from "@/lib/supabase/types"; import { log, timed } from "@/lib/logger"; -async function getAccessToken(profileId: string): Promise { +async function getAccessToken(organizationId: string): Promise { const db = createDbClient(); const { data } = await db .from("instagram_connections") .select("access_token") - .eq("profile_id", profileId) + .eq("organization_id", organizationId) .single(); return data?.access_token ?? null; } @@ -20,7 +20,11 @@ export async function fetchPostStats( return { error: "No Instagram post ID found." }; } - const accessToken = await getAccessToken(profileId); + if (!post.organization_id) { + return { error: "Post is not associated with an organization." }; + } + + const accessToken = await getAccessToken(post.organization_id); if (!accessToken) { return { error: "No Instagram connection found. Connect Instagram in account settings." }; } @@ -78,7 +82,11 @@ export async function fetchPostComments( return { error: "No Instagram post ID found." }; } - const accessToken = await getAccessToken(profileId); + if (!post.organization_id) { + return { error: "Post is not associated with an organization." }; + } + + const accessToken = await getAccessToken(post.organization_id); if (!accessToken) { return { error: "No Instagram connection found. Connect Instagram in account settings." }; } diff --git a/src/lib/core/orchestrate.ts b/src/lib/core/orchestrate.ts index 2a1f920..4354634 100644 --- a/src/lib/core/orchestrate.ts +++ b/src/lib/core/orchestrate.ts @@ -38,7 +38,13 @@ export async function orchestrate( ? { kind: "buffer", imageBuffer: ctx.imageBuffer, contentType: ctx.contentType } : { kind: "url", mediaUrl: ctx.mediaUrl! }; - newPostResult = await uploadAndCreatePost(ctx.profileId, source, ctx.channel, deliver); + newPostResult = await uploadAndCreatePost( + ctx.profileId, + source, + ctx.channel, + deliver, + ctx.body, + ); if (!newPostResult) return {}; } diff --git a/src/lib/db/facebook.ts b/src/lib/db/facebook.ts index 68dbfae..59ab30d 100644 --- a/src/lib/db/facebook.ts +++ b/src/lib/db/facebook.ts @@ -5,12 +5,12 @@ export type { FacebookConnection } from "@/lib/supabase/types"; export async function getFacebookConnection( client: DbClient, - profileId: string, + orgId: string, ): Promise { const { data, error } = await client .from("facebook_connections") .select("*") - .eq("profile_id", profileId) + .eq("organization_id", orgId) .single(); if (error) return null; return data; @@ -19,7 +19,8 @@ export async function getFacebookConnection( export async function upsertFacebookConnection( client: DbClient, fields: { - profile_id: string; + organization_id: string; + user_id?: string | null; facebook_user_id: string; facebook_page_id: string; page_name: string | null; @@ -29,32 +30,32 @@ export async function upsertFacebookConnection( ): Promise { const { error } = await client .from("facebook_connections") - .upsert(fields, { onConflict: "profile_id" }); + .upsert(fields, { onConflict: "organization_id" }); if (error) throw error; } export async function savePendingFacebookToken( client: DbClient, - profileId: string, + orgId: string, facebookUserId: string, userAccessToken: string, grantedScopes?: string[], ): Promise { const { error } = await client.from("pending_facebook_tokens").upsert( { - profile_id: profileId, + organization_id: orgId, facebook_user_id: facebookUserId, user_access_token: userAccessToken, granted_scopes: grantedScopes || null, }, - { onConflict: "profile_id" }, + { onConflict: "organization_id" }, ); if (error) throw error; } export async function getPendingFacebookToken( client: DbClient, - profileId: string, + orgId: string, ): Promise<{ facebook_user_id: string; user_access_token: string; @@ -63,19 +64,16 @@ export async function getPendingFacebookToken( const { data, error } = await client .from("pending_facebook_tokens") .select("facebook_user_id, user_access_token, granted_scopes") - .eq("profile_id", profileId) + .eq("organization_id", orgId) .single(); if (error) return null; return data; } -export async function deletePendingFacebookToken( - client: DbClient, - profileId: string, -): Promise { +export async function deletePendingFacebookToken(client: DbClient, orgId: string): Promise { const { error } = await client .from("pending_facebook_tokens") .delete() - .eq("profile_id", profileId); + .eq("organization_id", orgId); if (error) throw error; } diff --git a/src/lib/db/instagram.ts b/src/lib/db/instagram.ts index 39b8257..d557edc 100644 --- a/src/lib/db/instagram.ts +++ b/src/lib/db/instagram.ts @@ -5,12 +5,12 @@ export type { InstagramConnection } from "@/lib/supabase/types"; export async function getInstagramConnection( client: DbClient, - profileId: string, + orgId: string, ): Promise { const { data, error } = await client .from("instagram_connections") .select("*") - .eq("profile_id", profileId) + .eq("organization_id", orgId) .single(); if (error) return null; return data; @@ -18,7 +18,7 @@ export async function getInstagramConnection( export async function updateInstagramToken( client: DbClient, - profileId: string, + orgId: string, accessToken: string, tokenExpiresAt: string, ): Promise { @@ -28,14 +28,15 @@ export async function updateInstagramToken( access_token: accessToken, token_expires_at: tokenExpiresAt, }) - .eq("profile_id", profileId); + .eq("organization_id", orgId); if (error) throw error; } export async function upsertInstagramConnection( client: DbClient, fields: { - profile_id: string; + organization_id: string; + user_id?: string | null; instagram_user_id: string; access_token: string; token_expires_at: string; @@ -45,6 +46,6 @@ export async function upsertInstagramConnection( ): Promise { const { error } = await client .from("instagram_connections") - .upsert(fields, { onConflict: "profile_id" }); + .upsert(fields, { onConflict: "organization_id" }); if (error) throw error; } diff --git a/src/lib/db/organizations.ts b/src/lib/db/organizations.ts new file mode 100644 index 0000000..7890fac --- /dev/null +++ b/src/lib/db/organizations.ts @@ -0,0 +1,90 @@ +import type { DbClient } from "./client"; +import type { Organization, OrganizationMember } from "@/lib/supabase/types"; +import { getActiveOrgContext } from "@/lib/org-context"; + +export type { Organization, OrganizationMember } from "@/lib/supabase/types"; + +/** + * Get the active organization for a user. + * Reads the active_org cookie first; falls back to first membership. + * The cookie is only available in Next.js request contexts (server components, API routes). + */ +export async function getActiveOrganization( + client: DbClient, + userId: string, +): Promise { + // Try the cookie first + try { + const ctx = await getActiveOrgContext(); + if (ctx && ctx.userId === userId) { + const { data: org } = await client + .from("organizations") + .select("*") + .eq("id", ctx.orgId) + .single(); + if (org) return org; + } + } catch { + // cookies() throws outside Next.js request context (e.g. tests, cron) + } + + // Fall back to first membership + return getOrganizationForUser(client, userId); +} + +/** + * Get the first organization for a user (no cookie awareness). + * Use this in contexts without cookies (cron jobs, tests, etc). + */ +export async function getOrganizationForUser( + client: DbClient, + userId: string, +): Promise { + const orgs = await getOrganizationsForUser(client, userId); + return orgs[0] ?? null; +} + +/** + * Create an organization and add the user as owner (atomic via RPC). + */ +export async function createOrganization( + client: DbClient, + userId: string, + name: string, +): Promise { + const { data, error } = await client + .rpc("create_organization", { + p_user_id: userId, + p_name: name, + }) + .single(); + if (error || !data) throw error ?? new Error("Failed to create organization"); + + return data as Organization; +} + +/** + * Get all organizations a user belongs to, ordered by join date. + */ +export async function getOrganizationsForUser( + client: DbClient, + userId: string, +): Promise { + const { data: memberships, error } = await client + .from("organization_members") + .select("organization_id") + .eq("user_id", userId) + .order("created_at", { ascending: true }); + if (error || !memberships?.length) return []; + + const orgIds = memberships.map((m) => m.organization_id); + const { data: orgs, error: orgError } = await client + .from("organizations") + .select("*") + .in("id", orgIds); + if (orgError || !orgs) return []; + + // Preserve join-date order + const orgMap = new Map(orgs.map((o) => [o.id, o])); + return orgIds.map((id) => orgMap.get(id)).filter((o): o is Organization => !!o); +} diff --git a/src/lib/db/posts.ts b/src/lib/db/posts.ts index 6629fd6..410e39e 100644 --- a/src/lib/db/posts.ts +++ b/src/lib/db/posts.ts @@ -19,14 +19,16 @@ export async function getActiveDraft(client: DbClient, profileId: string): Promi export async function getPostById( client: DbClient, postId: string, - profileId: string, + options: { profileId?: string; organizationId?: string }, ): Promise { - const { data, error } = await client - .from("posts") - .select("*") - .eq("id", postId) - .eq("profile_id", profileId) - .single(); + let query = client.from("posts").select("*").eq("id", postId); + if (options.organizationId) { + query = query.eq("organization_id", options.organizationId); + } + if (options.profileId) { + query = query.eq("profile_id", options.profileId); + } + const { data, error } = await query.single(); if (error) return null; return data; } @@ -41,13 +43,21 @@ export async function getPostByPreviewToken(client: DbClient, token: string): Pr return data; } -export async function getPostsByProfile(client: DbClient, profileId: string): Promise { - const { data, error } = await client +export async function getPostsByOrganization( + client: DbClient, + organizationId: string, + profileId?: string, +): Promise { + let query = client .from("posts") .select("*") - .eq("profile_id", profileId) + .eq("organization_id", organizationId) .neq("status", "cancelled") .order("created_at", { ascending: false }); + if (profileId) { + query = query.eq("profile_id", profileId); + } + const { data, error } = await query; if (error) throw error; return data || []; } @@ -71,6 +81,7 @@ export async function insertPost( client: DbClient, fields: { profile_id: string; + organization_id?: string | null; image_url: string; caption: string; status: string; @@ -94,11 +105,19 @@ export async function updatePost( if (error) throw error; } -export async function cancelDrafts(client: DbClient, profileId: string): Promise { - const { error } = await client +export async function cancelDrafts( + client: DbClient, + profileId: string, + organizationId?: string, +): Promise { + let query = client .from("posts") .update({ status: "cancelled" }) .eq("profile_id", profileId) .eq("status", "draft"); + if (organizationId) { + query = query.eq("organization_id", organizationId); + } + const { error } = await query; if (error) throw error; } diff --git a/src/lib/org-context.ts b/src/lib/org-context.ts new file mode 100644 index 0000000..9b3ef3b --- /dev/null +++ b/src/lib/org-context.ts @@ -0,0 +1,69 @@ +import { SignJWT, jwtVerify } from "jose"; +import { cookies } from "next/headers"; +import type { OrgRole } from "@/lib/supabase/types"; + +const COOKIE_NAME = "active_org"; +const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days + +export interface OrgContext { + orgId: string; + orgName: string; + role: OrgRole; + userId: string; +} + +let cachedSecret: Uint8Array | null = null; + +function getSecret(): Uint8Array { + if (cachedSecret) return cachedSecret; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!key) throw new Error("SUPABASE_SERVICE_ROLE_KEY is required for org context"); + cachedSecret = new TextEncoder().encode(key); + return cachedSecret; +} + +export async function createOrgToken(ctx: OrgContext): Promise { + return new SignJWT({ + orgId: ctx.orgId, + orgName: ctx.orgName, + role: ctx.role, + userId: ctx.userId, + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("30d") + .sign(getSecret()); +} + +export async function verifyOrgToken(token: string): Promise { + try { + const { payload } = await jwtVerify(token, getSecret()); + return { + orgId: payload.orgId as string, + orgName: payload.orgName as string, + role: payload.role as OrgRole, + userId: payload.userId as string, + }; + } catch { + return null; + } +} + +export async function getActiveOrgContext(): Promise { + const cookieStore = await cookies(); + const token = cookieStore.get(COOKIE_NAME)?.value; + if (!token) return null; + return verifyOrgToken(token); +} + +export function buildOrgCookieOptions() { + return { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax" as const, + path: "/", + maxAge: COOKIE_MAX_AGE, + }; +} + +export { COOKIE_NAME }; diff --git a/src/lib/supabase/admin.ts b/src/lib/supabase/admin.ts index d00ff75..ff257a2 100644 --- a/src/lib/supabase/admin.ts +++ b/src/lib/supabase/admin.ts @@ -5,7 +5,7 @@ export type { SupabaseClient }; export function createAdminClient() { return createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!, { auth: { diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts index 166bd25..78a9136 100644 --- a/src/lib/supabase/server.ts +++ b/src/lib/supabase/server.ts @@ -5,7 +5,7 @@ export async function createClient() { const cookieStore = await cookies(); return createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index affac58..bc28148 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -14,7 +14,8 @@ export interface Profile { export interface InstagramConnection { id: string; - profile_id: string; + organization_id: string; + user_id: string | null; instagram_user_id: string; access_token: string; token_expires_at: string | null; @@ -26,7 +27,8 @@ export interface InstagramConnection { export interface FacebookConnection { id: string; - profile_id: string; + organization_id: string; + user_id: string | null; facebook_user_id: string; facebook_page_id: string; page_name: string | null; @@ -41,6 +43,7 @@ export type PostStatus = "draft" | "published" | "cancelled"; export interface Post { id: string; profile_id: string; + organization_id: string | null; image_url: string; caption: string | null; status: PostStatus; @@ -90,3 +93,27 @@ export interface PendingRegistration { used: boolean; created_at: string; } + +export type OrgRole = "owner" | "manager" | "member"; + +export interface Organization { + id: string; + name: string; + creator_user_id: string | null; + brand_name: string | null; + brand_description: string | null; + tone: string | null; + target_audience: string | null; + caption_style: string; + publish_platforms: string[]; + created_at: string; + updated_at: string; +} + +export interface OrganizationMember { + id: string; + organization_id: string; + user_id: string; + role: OrgRole; + created_at: string; +} diff --git a/src/middleware.ts b/src/middleware.ts index b503dd2..a3dc1d8 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -7,7 +7,7 @@ export async function middleware(request: NextRequest) { }); const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_URL || process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { diff --git a/supabase/migrations/015_add_organizations.sql b/supabase/migrations/015_add_organizations.sql new file mode 100644 index 0000000..9902144 --- /dev/null +++ b/supabase/migrations/015_add_organizations.sql @@ -0,0 +1,357 @@ +-- Migration: Add organizations data model +-- Organizations own social accounts; users belong to orgs via memberships with roles. + +-- ============================================================================= +-- 1. Enum +-- ============================================================================= + +CREATE TYPE org_role AS ENUM ('owner', 'manager', 'member'); + +-- ============================================================================= +-- 2. New tables +-- ============================================================================= + +CREATE TABLE organizations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + creator_user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + brand_name text, + brand_description text, + tone text, + target_audience text, + caption_style text DEFAULT 'polished', + publish_platforms text[] DEFAULT ARRAY['instagram'], + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TRIGGER handle_organizations_updated_at + BEFORE UPDATE ON organizations + FOR EACH ROW EXECUTE FUNCTION handle_updated_at(); + +CREATE TABLE organization_members ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + role org_role NOT NULL DEFAULT 'member', + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (organization_id, user_id) +); + +-- ============================================================================= +-- 3. RLS helper functions +-- ============================================================================= + +CREATE OR REPLACE FUNCTION is_org_member(org_id uuid) +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +STABLE +AS $$ + SELECT EXISTS ( + SELECT 1 FROM organization_members + WHERE organization_id = org_id AND user_id = auth.uid() + ); +$$; + +CREATE OR REPLACE FUNCTION is_org_owner(org_id uuid) +RETURNS boolean +LANGUAGE sql +SECURITY DEFINER +STABLE +AS $$ + SELECT EXISTS ( + SELECT 1 FROM organization_members + WHERE organization_id = org_id AND user_id = auth.uid() AND role = 'owner' + ); +$$; + +-- ============================================================================= +-- 4. RLS on organizations +-- ============================================================================= + +ALTER TABLE organizations ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Members can view their organizations" + ON organizations FOR SELECT + USING (is_org_member(id)); + +CREATE POLICY "Authenticated users can create organizations" + ON organizations FOR INSERT + WITH CHECK (auth.uid() IS NOT NULL); + +CREATE POLICY "Owners can update their organizations" + ON organizations FOR UPDATE + USING (is_org_owner(id)); + +CREATE POLICY "Owners can delete their organizations" + ON organizations FOR DELETE + USING (is_org_owner(id)); + +-- ============================================================================= +-- 5. RLS on organization_members +-- ============================================================================= + +ALTER TABLE organization_members ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Members can view org memberships" + ON organization_members FOR SELECT + USING (is_org_member(organization_id)); + +CREATE POLICY "Owners can add members" + ON organization_members FOR INSERT + WITH CHECK (is_org_owner(organization_id)); + +CREATE POLICY "Owners can update members" + ON organization_members FOR UPDATE + USING (is_org_owner(organization_id)); + +CREATE POLICY "Owners can remove members" + ON organization_members FOR DELETE + USING (is_org_owner(organization_id)); + +-- ============================================================================= +-- 5b. Atomic org creation RPC +-- ============================================================================= + +CREATE OR REPLACE FUNCTION create_organization(p_user_id uuid, p_name text) +RETURNS organizations +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + new_org organizations; +BEGIN + INSERT INTO organizations (name, creator_user_id) + VALUES (p_name, p_user_id) + RETURNING * INTO new_org; + + INSERT INTO organization_members (organization_id, user_id, role) + VALUES (new_org.id, p_user_id, 'owner'); + + RETURN new_org; +END; +$$; + +-- ============================================================================= +-- 6. Alter instagram_connections +-- ============================================================================= + +-- Add new columns +ALTER TABLE instagram_connections + ADD COLUMN organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE, + ADD COLUMN user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL; + +-- ============================================================================= +-- 7. Alter facebook_connections +-- ============================================================================= + +ALTER TABLE facebook_connections + ADD COLUMN organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE, + ADD COLUMN user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL; + +-- ============================================================================= +-- 8. Alter pending_facebook_tokens +-- ============================================================================= + +ALTER TABLE pending_facebook_tokens + ADD COLUMN organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE; + +-- ============================================================================= +-- 9. Alter posts +-- ============================================================================= + +ALTER TABLE posts + ADD COLUMN organization_id uuid REFERENCES organizations(id) ON DELETE SET NULL; + +-- ============================================================================= +-- 10. Data migration — backfill from existing profiles +-- ============================================================================= + +DO $$ +DECLARE + rec RECORD; + new_org_id uuid; +BEGIN + FOR rec IN + SELECT id, brand_name FROM profiles + LOOP + -- Create an org for each existing profile + INSERT INTO organizations (name, creator_user_id, brand_name) + VALUES (COALESCE(rec.brand_name, 'My Organization'), rec.id, rec.brand_name) + RETURNING id INTO new_org_id; + + -- Add profile owner as org owner + INSERT INTO organization_members (organization_id, user_id, role) + VALUES (new_org_id, rec.id, 'owner'); + + -- Backfill connections + UPDATE instagram_connections + SET organization_id = new_org_id, user_id = rec.id + WHERE profile_id = rec.id; + + UPDATE facebook_connections + SET organization_id = new_org_id, user_id = rec.id + WHERE profile_id = rec.id; + + UPDATE pending_facebook_tokens + SET organization_id = new_org_id + WHERE profile_id = rec.id; + + -- Backfill posts + UPDATE posts + SET organization_id = new_org_id + WHERE profile_id = rec.id; + END LOOP; +END $$; + +-- ============================================================================= +-- 11. Enforce NOT NULL on organization_id after backfill +-- ============================================================================= + +ALTER TABLE instagram_connections + ALTER COLUMN organization_id SET NOT NULL; + +ALTER TABLE facebook_connections + ALTER COLUMN organization_id SET NOT NULL; + +ALTER TABLE pending_facebook_tokens + ALTER COLUMN organization_id SET NOT NULL; + +-- posts.organization_id stays nullable per plan + +-- Add unique constraints on organization_id for connection upserts +ALTER TABLE instagram_connections + ADD CONSTRAINT instagram_connections_organization_id_key UNIQUE (organization_id); + +ALTER TABLE facebook_connections + ADD CONSTRAINT facebook_connections_organization_id_key UNIQUE (organization_id); + +ALTER TABLE pending_facebook_tokens + ADD CONSTRAINT pending_facebook_tokens_organization_id_key UNIQUE (organization_id); + +-- ============================================================================= +-- 12. Drop old profile-based RLS policies (must happen before dropping columns) +-- ============================================================================= + +DROP POLICY IF EXISTS "Users can view own instagram connection" ON instagram_connections; +DROP POLICY IF EXISTS "Users can insert own instagram connection" ON instagram_connections; +DROP POLICY IF EXISTS "Users can update own instagram connection" ON instagram_connections; +DROP POLICY IF EXISTS "Users can delete own instagram connection" ON instagram_connections; + +DROP POLICY IF EXISTS "Users can view own facebook connection" ON facebook_connections; +DROP POLICY IF EXISTS "Users can insert own facebook connection" ON facebook_connections; +DROP POLICY IF EXISTS "Users can update own facebook connection" ON facebook_connections; +DROP POLICY IF EXISTS "Users can delete own facebook connection" ON facebook_connections; + +DROP POLICY IF EXISTS "Users can read own pending tokens" ON pending_facebook_tokens; + +-- ============================================================================= +-- 13. Drop old profile_id columns and constraints from connection tables +-- ============================================================================= + +-- instagram_connections: drop unique constraint on profile_id, then the column +ALTER TABLE instagram_connections + DROP CONSTRAINT IF EXISTS instagram_connections_profile_id_key; +ALTER TABLE instagram_connections + DROP COLUMN profile_id; + +-- facebook_connections: drop unique constraint on profile_id, then the column +ALTER TABLE facebook_connections + DROP CONSTRAINT IF EXISTS facebook_connections_profile_id_key; +ALTER TABLE facebook_connections + DROP COLUMN profile_id; + +-- pending_facebook_tokens: drop unique constraint on profile_id, then the column +ALTER TABLE pending_facebook_tokens + DROP CONSTRAINT IF EXISTS pending_facebook_tokens_profile_id_key; +ALTER TABLE pending_facebook_tokens + DROP COLUMN profile_id; + +-- ============================================================================= +-- 14. New org-based RLS policies on instagram_connections +-- ============================================================================= + +-- New org-based policies +CREATE POLICY "Org members can view instagram connections" + ON instagram_connections FOR SELECT + USING (is_org_member(organization_id)); + +CREATE POLICY "Org owners can insert instagram connections" + ON instagram_connections FOR INSERT + WITH CHECK (is_org_owner(organization_id)); + +CREATE POLICY "Org owners can update instagram connections" + ON instagram_connections FOR UPDATE + USING (is_org_owner(organization_id)); + +CREATE POLICY "Org owners can delete instagram connections" + ON instagram_connections FOR DELETE + USING (is_org_owner(organization_id)); + +-- ============================================================================= +-- 15. New org-based RLS policies on facebook_connections +-- ============================================================================= + +CREATE POLICY "Org members can view facebook connections" + ON facebook_connections FOR SELECT + USING (is_org_member(organization_id)); + +CREATE POLICY "Org owners can insert facebook connections" + ON facebook_connections FOR INSERT + WITH CHECK (is_org_owner(organization_id)); + +CREATE POLICY "Org owners can update facebook connections" + ON facebook_connections FOR UPDATE + USING (is_org_owner(organization_id)); + +CREATE POLICY "Org owners can delete facebook connections" + ON facebook_connections FOR DELETE + USING (is_org_owner(organization_id)); + +-- ============================================================================= +-- 15b. New org-based RLS policies on pending_facebook_tokens +-- ============================================================================= + +CREATE POLICY "Org members can view pending facebook tokens" + ON pending_facebook_tokens FOR SELECT + USING (is_org_member(organization_id)); + +CREATE POLICY "Org owners can insert pending facebook tokens" + ON pending_facebook_tokens FOR INSERT + WITH CHECK (is_org_owner(organization_id)); + +CREATE POLICY "Org owners can update pending facebook tokens" + ON pending_facebook_tokens FOR UPDATE + USING (is_org_owner(organization_id)); + +CREATE POLICY "Org owners can delete pending facebook tokens" + ON pending_facebook_tokens FOR DELETE + USING (is_org_owner(organization_id)); + +-- ============================================================================= +-- 16. Add org-member policies on posts (alongside existing user policies) +-- ============================================================================= + +CREATE POLICY "Org members can view org posts" + ON posts FOR SELECT + USING (organization_id IS NOT NULL AND is_org_member(organization_id)); + +CREATE POLICY "Org members can insert org posts" + ON posts FOR INSERT + WITH CHECK (organization_id IS NOT NULL AND is_org_member(organization_id)); + +CREATE POLICY "Org members can update org posts" + ON posts FOR UPDATE + USING (organization_id IS NOT NULL AND is_org_member(organization_id)); + +-- ============================================================================= +-- 16. Indexes for common lookups +-- ============================================================================= + +CREATE INDEX idx_organization_members_user_id ON organization_members(user_id); +CREATE INDEX idx_organization_members_org_id ON organization_members(organization_id); +CREATE INDEX idx_instagram_connections_org_id ON instagram_connections(organization_id); +CREATE INDEX idx_facebook_connections_org_id ON facebook_connections(organization_id); +CREATE INDEX idx_pending_facebook_tokens_org_id ON pending_facebook_tokens(organization_id); +CREATE INDEX idx_posts_org_id ON posts(organization_id); diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..5fabc17 --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,102 @@ +-- Dev seed data: runs automatically on `supabase db reset` +-- Creates two users, two orgs, memberships, and fake Instagram connections. + +-- Fixed UUIDs for idempotency +DO $$ +DECLARE + evrhet_id uuid := 'aaaaaaaa-0000-0000-0000-000000000001'; + ryan_id uuid := 'bbbbbbbb-0000-0000-0000-000000000002'; + dnd_org uuid := '11111111-0000-0000-0000-000000000001'; + pizza_org uuid := '22222222-0000-0000-0000-000000000002'; +BEGIN + + -- Auth users are created via GoTrue API in dev-entrypoint.sh + -- (GoTrue handles password hashing and identity records properly) + + -- 1. Profiles + INSERT INTO profiles (id, phone, brand_name, brand_description, tone, target_audience, + onboarding_completed, publish_platforms) VALUES + (evrhet_id, '+15550000001', 'D&D Labs', + 'Tabletop gaming content and custom miniature painting studio', + 'nerdy and enthusiastic', 'D&D players, tabletop gamers, miniature hobbyists', + true, ARRAY['instagram', 'facebook']), + (ryan_id, '+15550000002', 'Pizza Planet', + 'Authentic wood-fired pizza with locally sourced ingredients', + 'fun and appetizing', 'pizza lovers, local foodies, families', + true, ARRAY['instagram']) + ON CONFLICT (id) DO NOTHING; + + -- 3. Organizations + INSERT INTO organizations (id, name, creator_user_id, brand_name, brand_description, tone, target_audience) VALUES + (dnd_org, 'D&D Labs', evrhet_id, 'D&D Labs', + 'Tabletop gaming content and custom miniature painting studio', + 'nerdy and enthusiastic', 'D&D players, tabletop gamers'), + (pizza_org, 'Pizza Planet', ryan_id, 'Pizza Planet', + 'Authentic wood-fired pizza with locally sourced ingredients', + 'fun and appetizing', 'pizza lovers, local foodies') + ON CONFLICT (id) DO NOTHING; + + -- 4. Memberships (both users in both orgs) + INSERT INTO organization_members (organization_id, user_id, role) VALUES + (dnd_org, evrhet_id, 'owner'), + (dnd_org, ryan_id, 'manager'), + (pizza_org, ryan_id, 'owner'), + (pizza_org, evrhet_id, 'manager') + ON CONFLICT (organization_id, user_id) DO NOTHING; + + -- 5. Fake Instagram connections + INSERT INTO instagram_connections (id, organization_id, connected_by_user_id, + instagram_user_id, access_token, token_expires_at, instagram_username) VALUES + ('cccccccc-0000-0000-0000-000000000001'::uuid, dnd_org, evrhet_id, + 'ig_dnd_labs', 'fake_ig_token_dnd', + now() + interval '55 days', 'dnd.labs'), + ('cccccccc-0000-0000-0000-000000000002'::uuid, pizza_org, ryan_id, + 'ig_pizza_planet', 'fake_ig_token_pizza', + now() + interval '45 days', 'pizza.planet') + ON CONFLICT (id) DO NOTHING; + + -- 6. Sample posts (4 per org, 2 per user per org) + + -- D&D Labs: evrhet's posts + INSERT INTO posts (id, profile_id, organization_id, image_url, caption, status, published_at, instagram_post_id) VALUES + ('eeeeeeee-0000-0000-0000-000000000001'::uuid, evrhet_id, dnd_org, + 'https://placehold.co/1080x1080/7c3aed/white?text=D%26D+Labs', + 'Just finished painting this ancient red dragon mini. 40 hours of work but worth every second. #dnd #minipainting #ttrpg', + 'published', now() - interval '2 days', 'ig_post_001'), + ('eeeeeeee-0000-0000-0000-000000000002'::uuid, evrhet_id, dnd_org, + 'https://placehold.co/1080x1080/7c3aed/white?text=D%26D+Labs', + 'New campaign starting this weekend! Building a homebrew world with some wild plot twists. #dungeonsanddragons', + 'draft', null, null), + + -- D&D Labs: ryan's posts + ('eeeeeeee-0000-0000-0000-000000000005'::uuid, ryan_id, dnd_org, + 'https://placehold.co/1080x1080/7c3aed/white?text=D%26D+Labs', + 'Our party just hit level 20. Time to face Tiamat. Wish us luck. #dnd5e #ttrpg #epiclevel', + 'published', now() - interval '1 day', 'ig_post_005'), + ('eeeeeeee-0000-0000-0000-000000000006'::uuid, ryan_id, dnd_org, + 'https://placehold.co/1080x1080/7c3aed/white?text=D%26D+Labs', + 'Sneak peek at the terrain I built for this week''s session. Modular dungeon tiles are a game changer.', + 'draft', null, null), + + -- Pizza Planet: ryan's posts + ('eeeeeeee-0000-0000-0000-000000000003'::uuid, ryan_id, pizza_org, + 'https://placehold.co/1080x1080/dc2626/white?text=Pizza+Planet', + 'Fresh out of the wood-fired oven. Our new truffle mushroom pizza is here for a limited time. #pizza #woodfired', + 'published', now() - interval '5 days', 'ig_post_002'), + ('eeeeeeee-0000-0000-0000-000000000004'::uuid, ryan_id, pizza_org, + 'https://placehold.co/1080x1080/dc2626/white?text=Pizza+Planet', + 'Happy hour starts at 4. Half-price slices and $5 craft beers. See you there!', + 'draft', null, null), + + -- Pizza Planet: evrhet's posts + ('eeeeeeee-0000-0000-0000-000000000007'::uuid, evrhet_id, pizza_org, + 'https://placehold.co/1080x1080/dc2626/white?text=Pizza+Planet', + 'Behind the scenes: our dough ferments for 72 hours before it ever sees the oven. That''s the secret. #pizzacraft', + 'published', now() - interval '3 days', 'ig_post_007'), + ('eeeeeeee-0000-0000-0000-000000000008'::uuid, evrhet_id, pizza_org, + 'https://placehold.co/1080x1080/dc2626/white?text=Pizza+Planet', + 'New seasonal menu dropping next week. Heirloom tomato + burrata is going to be a hit.', + 'draft', null, null) + ON CONFLICT (id) DO NOTHING; + +END $$;