mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
1 parent
01bb8225ae
commit
006761d3b0
294 files changed
+16259
-6138
No files matched your search
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
coverage
|
||||
test
|
||||
@@ -0,0 +1,31 @@
|
||||
PORT=3003
|
||||
DATABASE_URL=postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
|
||||
SUPABASE_AUTH_URL=https://your-project.supabase.co
|
||||
SUPABASE_AUTH_AUDIENCE=authenticated
|
||||
|
||||
# Server-only hosted Auth Admin credential. Used for account deletion and for
|
||||
# enriching admin user lists; it is never exposed to the browser or used as a
|
||||
# Supabase database client.
|
||||
# SUPABASE_AUTH_SERVICE_ROLE_KEY=
|
||||
|
||||
# Optional overrides. The issuer defaults to <SUPABASE_AUTH_URL>/auth/v1 and
|
||||
# the JWKS endpoint defaults to <SUPABASE_AUTH_URL>/auth/v1/.well-known/jwks.json.
|
||||
# SUPABASE_AUTH_ISSUER=
|
||||
# SUPABASE_AUTH_JWKS_URL=
|
||||
|
||||
# Test-only. Startup fails if this is set outside NODE_ENV=test.
|
||||
# AUTH_TEST_JWKS={"keys":[]}
|
||||
|
||||
# Optional stable credential for non-browser admin clients such as MCP. MCP
|
||||
# receives the token as SAUCI_ADMIN_API_TOKEN; it does not receive the user ID.
|
||||
# Both values are required together. The user ID must belong to an active,
|
||||
# least-privilege admin_users row; generate a random token of at least 32 chars.
|
||||
# ADMIN_API_SERVICE_TOKEN=
|
||||
# ADMIN_API_SERVICE_USER_ID=00000000-0000-4000-8000-000000000002
|
||||
|
||||
# Optional admin review decryption. Keep the private JWK server-side only.
|
||||
# ADMIN_PRIVATE_KEY_JWK=
|
||||
|
||||
# Optional local fixture identity used by `npm run db:seed -w @sauci/api`.
|
||||
# SEED_USER_ID=00000000-0000-4000-8000-000000000001
|
||||
# SEED_USER_EMAIL=local@sauci.test
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /workspace
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json ./apps/api/package.json
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --workspace @sauci/api --include-workspace-root=false --ignore-scripts
|
||||
COPY apps/api ./apps/api
|
||||
COPY packages/shared ./packages/shared
|
||||
RUN npm run build --workspace @sauci/api
|
||||
RUN npm prune --omit=dev --workspace @sauci/api --include-workspace-root=false --ignore-scripts
|
||||
|
||||
FROM node:20-bookworm-slim AS runtime
|
||||
ENV NODE_ENV=production PORT=3003
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /data/media && chown node:node /data/media
|
||||
COPY --from=build /workspace/node_modules ./node_modules
|
||||
# npm may place 0.x dependency upgrades in the workspace-local tree. Overlay
|
||||
# that pruned tree so runtime resolution matches the workspace lockfile.
|
||||
COPY --from=build /workspace/apps/api/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /workspace/apps/api/package.json ./package.json
|
||||
COPY --from=build --chown=node:node /workspace/apps/api/dist ./dist
|
||||
COPY --from=build --chown=node:node /workspace/apps/api/drizzle ./drizzle
|
||||
USER node
|
||||
EXPOSE 3003
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:3003/health/live').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('DATABASE_URL is required for database migrations');
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/all-schema.ts',
|
||||
out: './drizzle',
|
||||
dbCredentials: { url: process.env.DATABASE_URL },
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE "profiles" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"name" text,
|
||||
"email" text,
|
||||
"avatar_url" text,
|
||||
"push_token" text,
|
||||
"is_premium" boolean DEFAULT false NOT NULL,
|
||||
"couple_id" uuid,
|
||||
"gender" text,
|
||||
"show_explicit_content" boolean DEFAULT true NOT NULL,
|
||||
"max_intensity" integer DEFAULT 5 NOT NULL,
|
||||
"public_key_jwk" jsonb,
|
||||
"hide_nsfw" boolean DEFAULT false NOT NULL,
|
||||
"onboarding_completed" boolean DEFAULT false NOT NULL,
|
||||
"onboarding_version" integer DEFAULT 0 NOT NULL,
|
||||
"auth_synced_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "profiles_gender_check" CHECK ("gender" IS NULL OR "gender" IN ('male', 'female', 'non-binary', 'prefer-not-to-say')),
|
||||
CONSTRAINT "profiles_max_intensity_check" CHECK ("max_intensity" BETWEEN 1 AND 5)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "profiles_couple_id_idx" ON "profiles" USING btree ("couple_id");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "feature_interests" (
|
||||
"user_id" uuid NOT NULL,
|
||||
"feature" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "feature_interests_user_id_feature_pk" PRIMARY KEY("user_id", "feature")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "feature_interests" ADD CONSTRAINT "feature_interests_user_id_profiles_id_fk"
|
||||
FOREIGN KEY ("user_id") REFERENCES "profiles"("id") ON DELETE cascade;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "feature_interests_feature_idx" ON "feature_interests" USING btree ("feature");
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS "couples" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"invite_code" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "couples_invite_code_unique" UNIQUE("invite_code"),
|
||||
CONSTRAINT "couples_invite_code_format" CHECK ("invite_code" ~ '^[A-Z0-9]{8}$')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "profiles" ADD CONSTRAINT "profiles_couple_id_couples_id_fk"
|
||||
FOREIGN KEY ("couple_id") REFERENCES "couples"("id") ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1,60 @@
|
||||
CREATE TABLE IF NOT EXISTS "categories" (
|
||||
"id" uuid PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"icon" text,
|
||||
"color" text,
|
||||
"sort_order" integer NOT NULL DEFAULT 0,
|
||||
"is_public" boolean NOT NULL DEFAULT true,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "question_packs" (
|
||||
"id" uuid PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"icon" text,
|
||||
"is_premium" boolean NOT NULL DEFAULT false,
|
||||
"is_public" boolean NOT NULL DEFAULT true,
|
||||
"is_explicit" boolean NOT NULL DEFAULT false,
|
||||
"min_intensity" integer,
|
||||
"max_intensity" integer,
|
||||
"avg_intensity" integer,
|
||||
"sort_order" integer NOT NULL DEFAULT 0,
|
||||
"category_id" uuid REFERENCES "categories"("id") ON DELETE SET NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "question_packs_category_id_idx" ON "question_packs" ("category_id");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "questions" (
|
||||
"id" uuid PRIMARY KEY,
|
||||
"pack_id" uuid NOT NULL REFERENCES "question_packs"("id") ON DELETE CASCADE,
|
||||
"text" text NOT NULL,
|
||||
"partner_text" text,
|
||||
"intensity" integer NOT NULL DEFAULT 1 CHECK ("intensity" BETWEEN 1 AND 5),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "questions_pack_id_idx" ON "questions" ("pack_id");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "responses" (
|
||||
"id" uuid PRIMARY KEY,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"question_id" uuid NOT NULL REFERENCES "questions"("id") ON DELETE CASCADE,
|
||||
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||
"answer" text NOT NULL CHECK ("answer" IN ('yes', 'no', 'maybe')),
|
||||
"response_data" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE ("user_id", "question_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "responses_user_id_idx" ON "responses" ("user_id");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "couple_packs" (
|
||||
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||
"pack_id" uuid NOT NULL REFERENCES "question_packs"("id") ON DELETE CASCADE,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY ("couple_id", "pack_id")
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
ALTER TABLE "questions"
|
||||
ADD COLUMN IF NOT EXISTS "allowed_couple_genders" text[],
|
||||
ADD COLUMN IF NOT EXISTS "target_user_genders" text[],
|
||||
ADD COLUMN IF NOT EXISTS "question_type" text NOT NULL DEFAULT 'swipe',
|
||||
ADD COLUMN IF NOT EXISTS "config" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS "deleted_at" timestamptz;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "questions" ADD CONSTRAINT "questions_type_check"
|
||||
CHECK ("question_type" IN ('swipe', 'text_answer', 'audio', 'photo', 'who_likely'));
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "app_config" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"answer_gap_threshold" integer NOT NULL DEFAULT 10 CHECK ("answer_gap_threshold" >= 0),
|
||||
"daily_response_limit" integer NOT NULL DEFAULT 0 CHECK ("daily_response_limit" >= 0),
|
||||
"couple_intensity_gate_enabled" boolean NOT NULL DEFAULT false,
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "app_config_singleton_idx" ON "app_config" ((true));
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "app_config" ("id") VALUES (gen_random_uuid()) ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "matches" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||
"question_id" uuid NOT NULL REFERENCES "questions"("id") ON DELETE CASCADE,
|
||||
"match_type" text NOT NULL CHECK ("match_type" IN ('yes_yes', 'yes_maybe', 'maybe_maybe', 'both_answered')),
|
||||
"is_new" boolean NOT NULL DEFAULT true,
|
||||
"response_summary" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE ("couple_id", "question_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "matches_couple_created_idx" ON "matches" ("couple_id", "created_at" DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"content" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"read_at" timestamptz
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "match_archives" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"archived_at" timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE ("match_id", "user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "match_archives_user_idx" ON "match_archives" ("user_id", "match_id");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "couple_streaks" (
|
||||
"couple_id" uuid PRIMARY KEY REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||
"current_streak" integer NOT NULL DEFAULT 0,
|
||||
"longest_streak" integer NOT NULL DEFAULT 0,
|
||||
"last_active_date" date,
|
||||
"last_completed_date" date,
|
||||
"user1_answered_today" boolean NOT NULL DEFAULT false,
|
||||
"user2_answered_today" boolean NOT NULL DEFAULT false,
|
||||
"streak_celebrated_at" integer NOT NULL DEFAULT 0,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "responses_couple_question_idx" ON "responses" ("couple_id", "question_id");
|
||||
@@ -0,0 +1,85 @@
|
||||
CREATE TABLE IF NOT EXISTS "messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"content" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"read_at" timestamptz,
|
||||
"delivered_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
"media_path" text,
|
||||
"media_type" text CHECK ("media_type" IS NULL OR "media_type" IN ('image', 'video')),
|
||||
"media_expires_at" timestamptz,
|
||||
"media_expired" boolean NOT NULL DEFAULT false,
|
||||
"media_viewed_at" timestamptz,
|
||||
"version" integer NOT NULL DEFAULT 1,
|
||||
"encrypted_content" text,
|
||||
"encryption_iv" text,
|
||||
"keys_metadata" jsonb,
|
||||
"moderation_status" text,
|
||||
"flag_reason" text,
|
||||
"category" text,
|
||||
CONSTRAINT "messages_content_or_media" CHECK (
|
||||
"content" IS NOT NULL OR "media_path" IS NOT NULL OR "encrypted_content" IS NOT NULL
|
||||
)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "messages"
|
||||
ADD COLUMN IF NOT EXISTS "delivered_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "deleted_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "media_path" text,
|
||||
ADD COLUMN IF NOT EXISTS "media_type" text,
|
||||
ADD COLUMN IF NOT EXISTS "media_expires_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "media_expired" boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "media_viewed_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "version" integer NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS "encrypted_content" text,
|
||||
ADD COLUMN IF NOT EXISTS "encryption_iv" text,
|
||||
ADD COLUMN IF NOT EXISTS "keys_metadata" jsonb,
|
||||
ADD COLUMN IF NOT EXISTS "moderation_status" text,
|
||||
ADD COLUMN IF NOT EXISTS "flag_reason" text,
|
||||
ADD COLUMN IF NOT EXISTS "category" text;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_media_type_check"
|
||||
CHECK ("media_type" IS NULL OR "media_type" IN ('image', 'video'));
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_content_or_media"
|
||||
CHECK ("content" IS NOT NULL OR "media_path" IS NOT NULL OR "encrypted_content" IS NOT NULL);
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "messages_match_created_idx" ON "messages" ("match_id", "created_at" DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "messages_unread_idx" ON "messages" ("match_id", "user_id") WHERE "read_at" IS NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "message_deletions" (
|
||||
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"deleted_at" timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY ("message_id", "user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "message_reports" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
|
||||
"reporter_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"reason" text NOT NULL CHECK ("reason" IN ('harassment', 'spam', 'inappropriate_content', 'other')),
|
||||
"status" text NOT NULL DEFAULT 'pending' CHECK ("status" IN ('pending', 'reviewed', 'dismissed')),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE ("message_id", "reporter_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "message_reports_status_idx" ON "message_reports" ("status", "created_at" DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "chat_typing_states" (
|
||||
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"expires_at" timestamptz NOT NULL,
|
||||
PRIMARY KEY ("match_id", "user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "chat_typing_expires_idx" ON "chat_typing_states" ("expires_at");
|
||||
@@ -0,0 +1,35 @@
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS usage_reason text,
|
||||
ADD COLUMN IF NOT EXISTS last_active_at timestamptz;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id uuid PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
matches_enabled boolean NOT NULL DEFAULT true,
|
||||
messages_enabled boolean NOT NULL DEFAULT true,
|
||||
partner_activity_enabled boolean NOT NULL DEFAULT true,
|
||||
nudges_enabled boolean NOT NULL DEFAULT true,
|
||||
pack_changes_enabled boolean NOT NULL DEFAULT true,
|
||||
new_packs_enabled boolean NOT NULL DEFAULT true,
|
||||
streak_milestones_enabled boolean NOT NULL DEFAULT true,
|
||||
weekly_summary_enabled boolean NOT NULL DEFAULT true,
|
||||
unpaired_reminders_enabled boolean NOT NULL DEFAULT true,
|
||||
catchup_reminders_enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
type text NOT NULL CHECK (type IN ('bug', 'feature_request', 'general', 'question')),
|
||||
title text NOT NULL,
|
||||
description text NOT NULL,
|
||||
device_info jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'reviewed', 'in_progress', 'resolved', 'closed')),
|
||||
question_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS feedback_user_id_idx ON feedback(user_id);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE IF NOT EXISTS media_objects (
|
||||
id uuid PRIMARY KEY,
|
||||
owner_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
couple_id uuid REFERENCES couples(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL CHECK (kind IN ('avatar', 'response', 'chat', 'feedback')),
|
||||
storage_key text NOT NULL UNIQUE,
|
||||
mime_type text NOT NULL,
|
||||
byte_size integer NOT NULL CHECK (byte_size > 0),
|
||||
question_id uuid REFERENCES questions(id) ON DELETE CASCADE,
|
||||
match_id uuid REFERENCES matches(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS media_objects_owner_idx ON media_objects(owner_id, created_at DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS media_objects_couple_idx ON media_objects(couple_id) WHERE couple_id IS NOT NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS media_deletion_queue (
|
||||
storage_key text PRIMARY KEY,
|
||||
queued_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_media_blob_deletion() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
INSERT INTO media_deletion_queue(storage_key) VALUES(OLD.storage_key) ON CONFLICT DO NOTHING;
|
||||
RETURN OLD;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS media_objects_queue_blob_deletion ON media_objects;
|
||||
CREATE TRIGGER media_objects_queue_blob_deletion BEFORE DELETE ON media_objects
|
||||
FOR EACH ROW EXECUTE FUNCTION queue_media_blob_deletion();
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE feedback ADD COLUMN IF NOT EXISTS screenshot_media_id uuid REFERENCES media_objects(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
revenuecat_app_user_id text NOT NULL,
|
||||
product_id text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'cancelled', 'expired', 'billing_issue', 'paused')),
|
||||
entitlement_ids text[] NOT NULL DEFAULT '{}',
|
||||
purchased_at timestamptz NOT NULL,
|
||||
expires_at timestamptz,
|
||||
original_transaction_id text,
|
||||
store text NOT NULL DEFAULT 'APP_STORE',
|
||||
is_sandbox boolean NOT NULL DEFAULT false,
|
||||
cancel_reason text,
|
||||
grace_period_expires_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, original_transaction_id)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS subscriptions_user_id_idx ON subscriptions(user_id);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS subscriptions_status_idx ON subscriptions(status);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS revenuecat_webhook_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id text NOT NULL UNIQUE,
|
||||
event_type text NOT NULL,
|
||||
app_user_id text NOT NULL,
|
||||
processed_at timestamptz NOT NULL DEFAULT now(),
|
||||
payload jsonb NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS redemption_codes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL,
|
||||
description text,
|
||||
max_uses integer DEFAULT 1 CHECK (max_uses IS NULL OR max_uses >= 0),
|
||||
current_uses integer NOT NULL DEFAULT 0 CHECK (current_uses >= 0),
|
||||
expires_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS redemption_codes_upper_code_idx ON redemption_codes(upper(code));
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS code_redemptions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code_id uuid NOT NULL REFERENCES redemption_codes(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
redeemed_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (code_id, user_id)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS code_redemptions_user_id_idx ON code_redemptions(user_id);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS redemption_rate_limits (
|
||||
bucket timestamptz PRIMARY KEY,
|
||||
attempts integer NOT NULL CHECK (attempts >= 0)
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS last_nudge_sent_at timestamptz;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS live_draw_sessions (
|
||||
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
|
||||
strokes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
revision bigint NOT NULL DEFAULT 1,
|
||||
updated_by uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT live_draw_sessions_strokes_array CHECK (jsonb_typeof(strokes) = 'array')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS live_draw_sessions_updated_at_idx
|
||||
ON live_draw_sessions(updated_at DESC);
|
||||
@@ -0,0 +1,161 @@
|
||||
ALTER TABLE question_packs
|
||||
ADD COLUMN IF NOT EXISTS scheduled_release_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS release_notified boolean NOT NULL DEFAULT false;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS last_unpaired_reminder_at timestamptz;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS pending_match_notifications (
|
||||
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
|
||||
active_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
match_count integer NOT NULL DEFAULT 0,
|
||||
response_count integer NOT NULL DEFAULT 1,
|
||||
latest_match_id uuid REFERENCES matches(id) ON DELETE SET NULL,
|
||||
notify_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS pending_match_notifications_due_idx ON pending_match_notifications(notify_at);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS pending_pack_notifications (
|
||||
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
|
||||
changed_by_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
notify_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS pending_pack_notifications_due_idx ON pending_pack_notifications(notify_at);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS catchup_reminder_tracking (
|
||||
user_id uuid PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
pending_since timestamptz,
|
||||
last_reminder_sent_at timestamptz,
|
||||
reminder_count integer NOT NULL DEFAULT 0 CHECK (reminder_count >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS operations_outbox (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kind text NOT NULL CHECK (kind IN ('expo', 'discord', 'classify')),
|
||||
dedupe_key text NOT NULL UNIQUE,
|
||||
recipient_id uuid REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
payload jsonb NOT NULL,
|
||||
available_at timestamptz NOT NULL DEFAULT now(),
|
||||
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
locked_at timestamptz,
|
||||
sent_at timestamptz,
|
||||
last_error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS operations_outbox_due_idx
|
||||
ON operations_outbox(available_at, created_at) WHERE sent_at IS NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_response_digest() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||
INSERT INTO pending_match_notifications(couple_id, active_user_id, notify_at)
|
||||
VALUES(NEW.couple_id, NEW.user_id, now() + interval '5 minutes')
|
||||
ON CONFLICT(couple_id) DO UPDATE SET
|
||||
active_user_id=excluded.active_user_id,
|
||||
response_count=pending_match_notifications.response_count+1,
|
||||
notify_at=excluded.notify_at,
|
||||
updated_at=now();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS operations_response_digest ON responses;
|
||||
CREATE TRIGGER operations_response_digest AFTER INSERT ON responses
|
||||
FOR EACH ROW EXECUTE FUNCTION queue_response_digest();
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION count_digest_match() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||
UPDATE pending_match_notifications SET match_count=match_count+1,
|
||||
latest_match_id=NEW.id, updated_at=now() WHERE couple_id=NEW.couple_id;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS operations_match_digest ON matches;
|
||||
CREATE TRIGGER operations_match_digest AFTER INSERT ON matches
|
||||
FOR EACH ROW EXECUTE FUNCTION count_digest_match();
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_message_operations() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE v_couple_id uuid; v_recipient record;
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||
SELECT couple_id INTO v_couple_id FROM matches WHERE id=NEW.match_id;
|
||||
FOR v_recipient IN
|
||||
SELECT p.id FROM profiles p
|
||||
LEFT JOIN notification_preferences np ON np.user_id=p.id
|
||||
WHERE p.couple_id=v_couple_id AND p.id<>NEW.user_id
|
||||
AND COALESCE(np.messages_enabled,true)=true
|
||||
LOOP
|
||||
INSERT INTO operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||
VALUES('expo','message:'||NEW.id||':'||v_recipient.id,v_recipient.id,
|
||||
jsonb_build_object('title','New message','body','Your partner sent you a message',
|
||||
'data',jsonb_build_object('type','message','match_id',NEW.match_id,'message_id',NEW.id)))
|
||||
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||
END LOOP;
|
||||
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||
VALUES('classify','classify:'||NEW.id,jsonb_build_object('message_id',NEW.id))
|
||||
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS operations_message_created ON messages;
|
||||
CREATE TRIGGER operations_message_created AFTER INSERT ON messages
|
||||
FOR EACH ROW EXECUTE FUNCTION queue_message_operations();
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_pack_change(p_couple_id uuid, p_user_id uuid) RETURNS void LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM profiles WHERE id=p_user_id AND couple_id=p_couple_id) THEN
|
||||
RAISE EXCEPTION 'user is not a member of couple';
|
||||
END IF;
|
||||
INSERT INTO pending_pack_notifications(couple_id,changed_by_user_id,notify_at)
|
||||
VALUES(p_couple_id,p_user_id,now()+interval '30 minutes')
|
||||
ON CONFLICT(couple_id) DO UPDATE SET changed_by_user_id=excluded.changed_by_user_id,
|
||||
notify_at=excluded.notify_at,updated_at=now();
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_discord_operations() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||
IF TG_TABLE_NAME='profiles' AND NEW.onboarding_completed=true
|
||||
AND (TG_OP='INSERT' OR OLD.onboarding_completed=false) THEN
|
||||
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||
VALUES('discord','discord:new_user:'||NEW.id,
|
||||
jsonb_build_object('event','new_user','user_id',NEW.id,'name',NEW.name,'email',NEW.email,'created_at',NEW.created_at))
|
||||
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME='profiles' AND NEW.couple_id IS NOT NULL
|
||||
AND (TG_OP='INSERT' OR OLD.couple_id IS DISTINCT FROM NEW.couple_id)
|
||||
AND EXISTS(SELECT 1 FROM profiles partner WHERE partner.couple_id=NEW.couple_id AND partner.id<>NEW.id) THEN
|
||||
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||
VALUES('discord','discord:couple_paired:'||NEW.couple_id,
|
||||
jsonb_build_object('event','couple_paired','couple_id',NEW.couple_id,'paired_at',now()))
|
||||
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS operations_profile_discord ON profiles;
|
||||
CREATE TRIGGER operations_profile_discord AFTER INSERT OR UPDATE OF onboarding_completed,couple_id ON profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION queue_discord_operations();
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_feedback_discord() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||
VALUES('discord','discord:feedback:'||NEW.id,
|
||||
jsonb_build_object('event','feedback_submitted','feedback_id',NEW.id,'user_id',NEW.user_id,
|
||||
'type',NEW.type,'status',NEW.status,'title',NEW.title,'description',NEW.description,
|
||||
'question_id',NEW.question_id,'created_at',NEW.created_at))
|
||||
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS operations_feedback_discord ON feedback;
|
||||
CREATE TRIGGER operations_feedback_discord AFTER INSERT ON feedback
|
||||
FOR EACH ROW EXECUTE FUNCTION queue_feedback_discord();
|
||||
@@ -0,0 +1,178 @@
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL UNIQUE REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
role text NOT NULL CHECK (role IN ('pack_creator', 'super_admin')),
|
||||
permissions text[] NOT NULL DEFAULT '{}',
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS admin_users_active_user_idx ON admin_users(user_id) WHERE is_active = true;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
admin_user_id uuid REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
actor_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE RESTRICT,
|
||||
table_name text NOT NULL,
|
||||
action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE', 'ACTION')),
|
||||
record_id text,
|
||||
old_values jsonb,
|
||||
new_values jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS audit_logs_created_idx ON audit_logs(created_at DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS audit_logs_actor_idx ON audit_logs(actor_user_id, created_at DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS topics (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS pack_topics (
|
||||
pack_id uuid NOT NULL REFERENCES question_packs(id) ON DELETE CASCADE,
|
||||
topic_id uuid NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (pack_id, topic_id)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE categories ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE question_packs ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE questions ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE question_packs
|
||||
ADD COLUMN IF NOT EXISTS scheduled_release_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS release_notified boolean NOT NULL DEFAULT false;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN IF NOT EXISTS inverse_of uuid REFERENCES questions(id) ON DELETE SET NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS moderation_status text DEFAULT 'unmoderated',
|
||||
ADD COLUMN IF NOT EXISTS flag_reason text;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE messages ADD CONSTRAINT messages_moderation_status_check
|
||||
CHECK (moderation_status IS NULL OR moderation_status IN ('safe', 'flagged', 'unmoderated'));
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE message_reports
|
||||
ADD COLUMN IF NOT EXISTS reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS reviewed_at timestamptz;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE feedback
|
||||
ADD COLUMN IF NOT EXISTS admin_notes text;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS ai_config (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
openrouter_api_key text,
|
||||
default_model text DEFAULT 'openai/gpt-4o-mini',
|
||||
default_temperature double precision,
|
||||
model_generate text,
|
||||
temperature_generate double precision,
|
||||
model_fix text,
|
||||
temperature_fix double precision,
|
||||
model_polish text,
|
||||
temperature_polish double precision,
|
||||
council_enabled boolean NOT NULL DEFAULT false,
|
||||
council_generator_model text DEFAULT 'anthropic/claude-3.5-sonnet',
|
||||
council_generators jsonb DEFAULT '[{"model":"anthropic/claude-3.5-sonnet"}]'::jsonb,
|
||||
council_reviewer_model text DEFAULT 'google/gemini-pro-1.5',
|
||||
council_reviewer_temperature double precision,
|
||||
council_selection_mode text DEFAULT 'whole_set' CHECK (
|
||||
council_selection_mode IS NULL OR council_selection_mode IN ('whole_set', 'cherry_pick')
|
||||
),
|
||||
cherry_pick_ensure_intensity_distribution boolean DEFAULT true,
|
||||
classifier_enabled boolean DEFAULT true,
|
||||
classifier_model text DEFAULT 'openai/gpt-4o',
|
||||
classifier_temperature double precision,
|
||||
classifier_prompt text,
|
||||
heuristics_enabled boolean NOT NULL DEFAULT false,
|
||||
heuristic_min_text_length integer NOT NULL DEFAULT 12,
|
||||
heuristic_whitelist_max_length integer NOT NULL DEFAULT 30,
|
||||
heuristic_skip_if_no_alnum boolean NOT NULL DEFAULT true,
|
||||
heuristic_skip_media_without_text boolean NOT NULL DEFAULT false,
|
||||
heuristic_record_reason boolean NOT NULL DEFAULT false,
|
||||
heuristic_use_default_whitelist boolean NOT NULL DEFAULT true,
|
||||
heuristic_use_default_keywords boolean NOT NULL DEFAULT true,
|
||||
heuristic_whitelist text,
|
||||
heuristic_keyword_triggers text,
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
updated_by uuid REFERENCES profiles(id) ON DELETE SET NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton_idx ON ai_config ((true));
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS dare_packs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, description text, icon text,
|
||||
is_premium boolean NOT NULL DEFAULT false, is_public boolean NOT NULL DEFAULT true,
|
||||
is_explicit boolean NOT NULL DEFAULT false, sort_order integer NOT NULL DEFAULT 0,
|
||||
category_id uuid REFERENCES categories(id) ON DELETE SET NULL,
|
||||
min_intensity integer, max_intensity integer, avg_intensity numeric(3,2),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS dares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
pack_id uuid NOT NULL REFERENCES dare_packs(id) ON DELETE CASCADE,
|
||||
text text NOT NULL, intensity integer NOT NULL DEFAULT 1 CHECK (intensity BETWEEN 1 AND 5),
|
||||
suggested_duration_hours integer, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS dares_pack_idx ON dares(pack_id);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS master_keys (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key_name text NOT NULL UNIQUE,
|
||||
public_key_jwk jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
rotated_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS sent_dares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
couple_id uuid NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
||||
dare_id uuid REFERENCES dares(id) ON DELETE SET NULL,
|
||||
custom_dare_text text,
|
||||
custom_dare_intensity integer CHECK (
|
||||
custom_dare_intensity IS NULL OR custom_dare_intensity BETWEEN 1 AND 5
|
||||
),
|
||||
sender_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
recipient_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (
|
||||
status IN ('pending', 'active', 'completed', 'expired', 'declined', 'cancelled')
|
||||
),
|
||||
sent_at timestamptz NOT NULL DEFAULT now(),
|
||||
accepted_at timestamptz,
|
||||
expires_at timestamptz,
|
||||
completed_at timestamptz,
|
||||
sender_notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT sent_dares_dare_or_custom_check CHECK (
|
||||
dare_id IS NOT NULL OR custom_dare_text IS NOT NULL
|
||||
)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS sent_dares_couple_idx ON sent_dares(couple_id);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS sent_dares_dare_idx ON sent_dares(dare_id);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS dare_messages (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
sent_dare_id uuid NOT NULL REFERENCES sent_dares(id) ON DELETE CASCADE,
|
||||
sender_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
content text NOT NULL,
|
||||
read_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS dare_messages_sent_dare_created_idx
|
||||
ON dare_messages(sent_dare_id, created_at);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS dare_messages_sender_idx ON dare_messages(sender_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "question_packs"
|
||||
ALTER COLUMN "avg_intensity" TYPE numeric(3,2)
|
||||
USING "avg_intensity"::numeric(3,2);
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "topics"
|
||||
ADD COLUMN IF NOT EXISTS "description" text,
|
||||
ADD COLUMN IF NOT EXISTS "icon" text,
|
||||
ADD COLUMN IF NOT EXISTS "sort_order" integer NOT NULL DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "questions"
|
||||
ADD COLUMN IF NOT EXISTS "required_props" text[];
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1787853600000,
|
||||
"tag": "0000_identity_and_feature_interests",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1787864000000,
|
||||
"tag": "0001_couples",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1787864744000,
|
||||
"tag": "0002_packs_catalog_progress",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1787865000000,
|
||||
"tag": "0003_answers_matches",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1787865100000,
|
||||
"tag": "0004_chat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1787865200000,
|
||||
"tag": "0005_profile_settings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1787865300000,
|
||||
"tag": "0006_media_storage",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1787865400000,
|
||||
"tag": "0007_billing_redemption",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1787865500000,
|
||||
"tag": "0008_residual_realtime",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1787865600000,
|
||||
"tag": "0009_operations_workers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1787865700000,
|
||||
"tag": "0010_admin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1787907600000,
|
||||
"tag": "0011_question_pack_average_precision",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1787908500000,
|
||||
"tag": "0012_catalogue_field_parity",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@sauci/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20 <21"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:worker": "tsx watch src/workers/index.ts",
|
||||
"build": "tsup src/index.ts src/db/migrate.ts src/migration/run.ts src/workers/index.ts --format esm --clean",
|
||||
"start": "node dist/index.js",
|
||||
"start:worker": "node dist/workers/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:migrate:prod": "node dist/db/migrate.js",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"migration:cutover": "tsx src/migration/run.ts",
|
||||
"test": "vitest run",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@sauci/shared": "*",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hono": "^4.6.0",
|
||||
"jose": "^6.1.3",
|
||||
"pg": "^8.17.2",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.15.5",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"tsup": "^8.2.4",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ApiErrorResponse, MeResponse } from '@sauci/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js';
|
||||
import type { ApiRepository } from './db/repository.js';
|
||||
import type { AppDataRepository } from './domains/app-data/repository.js';
|
||||
import { registerAppDataRoutes } from './domains/app-data/routes.js';
|
||||
import type { BillingService } from './domains/billing/service.js';
|
||||
import { registerBillingRoutes } from './domains/billing/routes.js';
|
||||
import type { AccountOperationsService } from './domains/account-operations/service.js';
|
||||
import { registerAccountOperationRoutes } from './domains/account-operations/routes.js';
|
||||
import type { AnswersRepository } from './domains/answers/repository.js';
|
||||
import { registerAnswerRoutes } from './domains/answers/routes.js';
|
||||
import type { ChatRepository } from './domains/chat/repository.js';
|
||||
import { registerChatRoutes } from './domains/chat/routes.js';
|
||||
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
||||
import type { CoupleService } from './domains/couples/service.js';
|
||||
import type { PacksRepository } from './domains/packs/repository.js';
|
||||
import { registerPackRoutes } from './domains/packs/routes.js';
|
||||
import type { ProfileSettingsRepository } from './domains/profile-settings/repository.js';
|
||||
import { registerProfileSettingsRoutes } from './domains/profile-settings/routes.js';
|
||||
import type { MediaRepository } from './domains/media/repository.js';
|
||||
import { registerMediaRoutes } from './domains/media/routes.js';
|
||||
import type { FilesystemMediaStorage } from './domains/media/storage.js';
|
||||
import type { AdminRequestAuth } from './domains/admin/auth.js';
|
||||
import type { AdminRepository } from './domains/admin/repository.js';
|
||||
import { registerAdminRoutes } from './domains/admin/routes.js';
|
||||
|
||||
type Variables = { identity: AuthIdentity };
|
||||
|
||||
export interface AppDependencies {
|
||||
auth: AuthVerifier;
|
||||
repository: ApiRepository;
|
||||
coupleService?: CoupleService;
|
||||
packsRepository?: PacksRepository;
|
||||
answersRepository?: AnswersRepository;
|
||||
chatRepository?: ChatRepository;
|
||||
profileSettingsRepository?: ProfileSettingsRepository;
|
||||
accountOperationsService?: AccountOperationsService;
|
||||
mediaRepository?: MediaRepository;
|
||||
mediaStorage?: FilesystemMediaStorage;
|
||||
appDataRepository?: AppDataRepository;
|
||||
billingService?: BillingService;
|
||||
adminRepository?: AdminRepository;
|
||||
adminAuth?: AdminRequestAuth;
|
||||
}
|
||||
|
||||
const featureSchema = z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
|
||||
const app = new Hono<{ Variables: Variables }>();
|
||||
|
||||
app.get('/health/live', (c) => c.json({ status: 'ok' }));
|
||||
app.get('/health/ready', async (c) => {
|
||||
try {
|
||||
await deps.repository.ready();
|
||||
return c.json({ status: 'ready' });
|
||||
} catch {
|
||||
return c.json(error('not_ready', 'Database is unavailable'), 503);
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/v1/*', async (c, next) => {
|
||||
// Admin routes accept either a hosted user token or the separately scoped
|
||||
// MCP service credential and therefore own their authentication middleware.
|
||||
if (c.req.path.startsWith('/v1/admin/')) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const token = bearerToken(c.req.header('authorization'));
|
||||
if (!token) return c.json(error('unauthorized', 'A bearer token is required'), 401);
|
||||
try {
|
||||
c.set('identity', await deps.auth.verify(token));
|
||||
} catch {
|
||||
return c.json(error('unauthorized', 'The bearer token is invalid'), 401);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.get('/v1/me', async (c) => {
|
||||
const profile = await deps.repository.upsertProfile(c.get('identity'));
|
||||
return c.json<MeResponse>({ profile });
|
||||
});
|
||||
|
||||
app.get('/v1/me/feature-interests/:feature', async (c) => {
|
||||
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
||||
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
||||
return c.json(await deps.repository.getFeatureInterest(c.get('identity').id, parsed.data));
|
||||
});
|
||||
|
||||
app.put('/v1/me/feature-interests/:feature', async (c) => {
|
||||
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
||||
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
||||
await deps.repository.upsertProfile(c.get('identity'));
|
||||
return c.json(await deps.repository.putFeatureInterest(c.get('identity').id, parsed.data));
|
||||
});
|
||||
|
||||
app.delete('/v1/me/feature-interests/:feature', async (c) => {
|
||||
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
||||
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
||||
return c.json(await deps.repository.deleteFeatureInterest(c.get('identity').id, parsed.data));
|
||||
});
|
||||
|
||||
if (deps.coupleService) registerCoupleRoutes(app, deps.coupleService);
|
||||
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
|
||||
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
||||
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
||||
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
||||
if (deps.appDataRepository) registerAppDataRoutes(app, deps.appDataRepository);
|
||||
if (deps.billingService) registerBillingRoutes(app, deps.billingService);
|
||||
if (deps.adminRepository && deps.adminAuth) {
|
||||
registerAdminRoutes(app, deps.adminRepository, deps.adminAuth, deps.mediaStorage);
|
||||
}
|
||||
|
||||
app.notFound((c) => c.json(error('not_found', 'Route not found'), 404));
|
||||
app.onError((cause, c) => {
|
||||
console.error(cause);
|
||||
return c.json(error('internal_error', 'An unexpected error occurred'), 500);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
createLocalJWKSet,
|
||||
createRemoteJWKSet,
|
||||
jwtVerify,
|
||||
type JSONWebKeySet,
|
||||
type JWTVerifyGetKey,
|
||||
type JWTPayload,
|
||||
} from 'jose';
|
||||
import { z } from 'zod';
|
||||
import type { AppConfig } from './config.js';
|
||||
|
||||
const uuidSchema = z.string().uuid();
|
||||
|
||||
export interface AuthIdentity {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface AuthVerifier {
|
||||
verify(token: string): Promise<AuthIdentity>;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() !== '' ? value : null;
|
||||
}
|
||||
|
||||
function identityFromClaims(claims: JWTPayload): AuthIdentity {
|
||||
const id = uuidSchema.parse(claims.sub);
|
||||
const metadata = claims.user_metadata && typeof claims.user_metadata === 'object'
|
||||
? claims.user_metadata as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
return {
|
||||
id,
|
||||
email: optionalString(claims.email),
|
||||
name: optionalString(metadata.full_name) ?? optionalString(metadata.name),
|
||||
avatarUrl: optionalString(metadata.avatar_url) ?? optionalString(metadata.picture),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAuthVerifier(config: AppConfig): AuthVerifier {
|
||||
let keySet: JWTVerifyGetKey;
|
||||
if (config.authTestJwks) {
|
||||
if (config.nodeEnv !== 'test') {
|
||||
throw new Error('Local JWKS verification is forbidden outside test');
|
||||
}
|
||||
const jwks = JSON.parse(config.authTestJwks) as JSONWebKeySet;
|
||||
keySet = createLocalJWKSet(jwks);
|
||||
} else {
|
||||
if (!config.authJwksUrl) {
|
||||
throw new Error('A remote Supabase Auth JWKS URL is required');
|
||||
}
|
||||
keySet = createRemoteJWKSet(new URL(config.authJwksUrl));
|
||||
}
|
||||
|
||||
return {
|
||||
async verify(token: string): Promise<AuthIdentity> {
|
||||
const { payload } = await jwtVerify(token, keySet, {
|
||||
issuer: config.authIssuer,
|
||||
audience: config.authAudience,
|
||||
algorithms: ['ES256', 'RS256'],
|
||||
});
|
||||
return identityFromClaims(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function bearerToken(header: string | undefined): string | null {
|
||||
if (!header) return null;
|
||||
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const baseSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
|
||||
DATABASE_URL: z.string().url(),
|
||||
SUPABASE_AUTH_URL: z.string().url().optional(),
|
||||
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
|
||||
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
|
||||
SUPABASE_AUTH_AUDIENCE: z.string().min(1).default('authenticated'),
|
||||
AUTH_TEST_JWKS: z.string().optional(),
|
||||
MEDIA_ROOT: z.string().min(1).default('/data/media'),
|
||||
MEDIA_SIGNING_SECRET: z.string().min(32).optional(),
|
||||
MEDIA_PUBLIC_BASE_URL: z.string().url().optional(),
|
||||
SUPABASE_AUTH_SERVICE_ROLE_KEY: z.string().min(1).optional(),
|
||||
REVENUECAT_API_KEY: z.string().min(1).optional(),
|
||||
REVENUECAT_ENTITLEMENT_ID: z.string().min(1).default('Sauci Pro'),
|
||||
REVENUECAT_WEBHOOK_SECRET: z.string().min(1).optional(),
|
||||
ADMIN_API_SERVICE_TOKEN: z.string().min(32).optional(),
|
||||
ADMIN_API_SERVICE_USER_ID: z.string().uuid().optional(),
|
||||
ADMIN_PRIVATE_KEY_JWK: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface AppConfig {
|
||||
nodeEnv: 'development' | 'test' | 'production';
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
authIssuer: string;
|
||||
authJwksUrl?: string;
|
||||
authAudience: string;
|
||||
authTestJwks?: string;
|
||||
mediaRoot: string;
|
||||
mediaSigningSecret: string;
|
||||
mediaPublicBaseUrl: string;
|
||||
supabaseAuthServiceRoleKey?: string;
|
||||
revenueCatApiKey?: string;
|
||||
revenueCatEntitlementId: string;
|
||||
revenueCatWebhookSecret?: string;
|
||||
adminApiServiceToken?: string;
|
||||
adminApiServiceUserId?: string;
|
||||
adminPrivateKeyJwk?: string;
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const parsed = baseSchema.parse(env);
|
||||
if (Boolean(parsed.ADMIN_API_SERVICE_TOKEN) !== Boolean(parsed.ADMIN_API_SERVICE_USER_ID)) {
|
||||
throw new Error('ADMIN_API_SERVICE_TOKEN and ADMIN_API_SERVICE_USER_ID must be configured together');
|
||||
}
|
||||
if (parsed.ADMIN_PRIVATE_KEY_JWK) {
|
||||
try { JSON.parse(parsed.ADMIN_PRIVATE_KEY_JWK); } catch { throw new Error('ADMIN_PRIVATE_KEY_JWK must be valid JSON'); }
|
||||
}
|
||||
if (parsed.AUTH_TEST_JWKS && parsed.NODE_ENV !== 'test') {
|
||||
throw new Error('AUTH_TEST_JWKS is forbidden outside NODE_ENV=test');
|
||||
}
|
||||
|
||||
const authUrl = parsed.SUPABASE_AUTH_URL?.replace(/\/$/, '');
|
||||
const authIssuer = parsed.SUPABASE_AUTH_ISSUER ?? (authUrl ? `${authUrl}/auth/v1` : undefined);
|
||||
const authJwksUrl = parsed.SUPABASE_AUTH_JWKS_URL ??
|
||||
(authUrl ? `${authUrl}/auth/v1/.well-known/jwks.json` : undefined);
|
||||
|
||||
if (!authIssuer || (!authJwksUrl && !parsed.AUTH_TEST_JWKS)) {
|
||||
throw new Error('Supabase Auth issuer and JWKS configuration are required');
|
||||
}
|
||||
if (parsed.NODE_ENV === 'production' &&
|
||||
(new URL(authIssuer).protocol !== 'https:' ||
|
||||
(authJwksUrl && new URL(authJwksUrl).protocol !== 'https:'))) {
|
||||
throw new Error('Supabase Auth issuer and JWKS URLs must use HTTPS in production');
|
||||
}
|
||||
if (parsed.NODE_ENV === 'production' && !parsed.MEDIA_SIGNING_SECRET) {
|
||||
throw new Error('MEDIA_SIGNING_SECRET is required in production');
|
||||
}
|
||||
const mediaPublicBaseUrl = parsed.MEDIA_PUBLIC_BASE_URL ?? `http://127.0.0.1:${parsed.PORT}`;
|
||||
if (parsed.NODE_ENV === 'production' && new URL(mediaPublicBaseUrl).protocol !== 'https:') {
|
||||
throw new Error('MEDIA_PUBLIC_BASE_URL must use HTTPS in production');
|
||||
}
|
||||
|
||||
return {
|
||||
nodeEnv: parsed.NODE_ENV,
|
||||
port: parsed.PORT,
|
||||
databaseUrl: parsed.DATABASE_URL,
|
||||
authIssuer,
|
||||
authJwksUrl,
|
||||
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
|
||||
authTestJwks: parsed.AUTH_TEST_JWKS,
|
||||
mediaRoot: parsed.MEDIA_ROOT,
|
||||
mediaSigningSecret: parsed.MEDIA_SIGNING_SECRET ?? 'local-media-signing-secret-change-me-123456',
|
||||
mediaPublicBaseUrl: mediaPublicBaseUrl.replace(/\/$/, ''),
|
||||
supabaseAuthServiceRoleKey: parsed.SUPABASE_AUTH_SERVICE_ROLE_KEY,
|
||||
revenueCatApiKey: parsed.REVENUECAT_API_KEY,
|
||||
revenueCatEntitlementId: parsed.REVENUECAT_ENTITLEMENT_ID,
|
||||
revenueCatWebhookSecret: parsed.REVENUECAT_WEBHOOK_SECRET,
|
||||
adminApiServiceToken: parsed.ADMIN_API_SERVICE_TOKEN,
|
||||
adminApiServiceUserId: parsed.ADMIN_API_SERVICE_USER_ID,
|
||||
adminPrivateKeyJwk: parsed.ADMIN_PRIVATE_KEY_JWK,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './schema.js';
|
||||
export * from '../domains/couples/schema.js';
|
||||
export * from '../domains/packs/schema.js';
|
||||
export * from '../domains/answers/schema.js';
|
||||
export * from '../domains/chat/schema.js';
|
||||
export * from '../domains/app-data/schema.js';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required for database migrations');
|
||||
}
|
||||
|
||||
const migrationsFolder = resolve(process.env.MIGRATIONS_DIR ?? 'drizzle');
|
||||
const pool = new Pool({ connectionString: databaseUrl, max: 2 });
|
||||
const lockClient = await pool.connect();
|
||||
|
||||
try {
|
||||
// Serializes migration containers during rolling/retried deployments.
|
||||
await lockClient.query('select pg_advisory_lock($1)', [2_024_082_701]);
|
||||
await migrate(drizzle(pool), { migrationsFolder });
|
||||
console.log(`Database migrations completed from ${migrationsFolder}`);
|
||||
} finally {
|
||||
await lockClient.query('select pg_advisory_unlock($1)', [2_024_082_701]).catch(() => undefined);
|
||||
lockClient.release();
|
||||
await pool.end();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { FeatureInterestResponse, Profile } from '@sauci/shared';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import type { AuthIdentity } from '../auth.js';
|
||||
import { featureInterests, profiles, type ProfileRow } from './schema.js';
|
||||
|
||||
export type MobileCompatibleProfile = Profile & {
|
||||
email: string | null;
|
||||
gender: 'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null;
|
||||
show_explicit_content: boolean;
|
||||
max_intensity: 1 | 2 | 3 | 4 | 5;
|
||||
};
|
||||
|
||||
export interface ApiRepository {
|
||||
ready(): Promise<void>;
|
||||
upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile>;
|
||||
getFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
|
||||
putFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
|
||||
deleteFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function toProfile(row: ProfileRow): MobileCompatibleProfile {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
avatar_url: row.avatarUrl,
|
||||
push_token: row.pushToken,
|
||||
is_premium: row.isPremium,
|
||||
couple_id: row.coupleId,
|
||||
gender: row.gender,
|
||||
show_explicit_content: row.showExplicitContent,
|
||||
max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
public_key_jwk: row.publicKeyJwk,
|
||||
hide_nsfw: row.hideNsfw,
|
||||
onboarding_completed: row.onboardingCompleted,
|
||||
onboarding_version: row.onboardingVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export class PostgresRepository implements ApiRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly db: NodePgDatabase;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
this.db = drizzle(this.pool);
|
||||
}
|
||||
|
||||
async ready(): Promise<void> {
|
||||
await this.db.select({ id: profiles.id }).from(profiles).limit(1);
|
||||
}
|
||||
|
||||
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
|
||||
const [row] = await this.db.insert(profiles).values({
|
||||
id: identity.id,
|
||||
name: identity.name,
|
||||
avatarUrl: identity.avatarUrl,
|
||||
email: identity.email,
|
||||
}).onConflictDoUpdate({
|
||||
target: profiles.id,
|
||||
set: {
|
||||
email: sql`coalesce(excluded.email, ${profiles.email})`,
|
||||
authSyncedAt: new Date(),
|
||||
},
|
||||
}).returning();
|
||||
|
||||
if (!row) throw new Error('Profile upsert returned no row');
|
||||
return toProfile(row);
|
||||
}
|
||||
|
||||
async getFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
|
||||
const [row] = await this.db.select({ feature: featureInterests.feature })
|
||||
.from(featureInterests)
|
||||
.where(and(eq(featureInterests.userId, userId), eq(featureInterests.feature, feature)))
|
||||
.limit(1);
|
||||
return { feature, interested: Boolean(row) };
|
||||
}
|
||||
|
||||
async putFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
|
||||
await this.db.insert(featureInterests).values({ userId, feature }).onConflictDoNothing();
|
||||
return { feature, interested: true };
|
||||
}
|
||||
|
||||
async deleteFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
|
||||
await this.db.delete(featureInterests)
|
||||
.where(and(eq(featureInterests.userId, userId), eq(featureInterests.feature, feature)));
|
||||
return { feature, interested: false };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const profiles = pgTable('profiles', {
|
||||
id: uuid('id').primaryKey(),
|
||||
name: text('name'),
|
||||
email: text('email'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
pushToken: text('push_token'),
|
||||
isPremium: boolean('is_premium').notNull().default(false),
|
||||
coupleId: uuid('couple_id'),
|
||||
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
|
||||
showExplicitContent: boolean('show_explicit_content').notNull().default(true),
|
||||
maxIntensity: integer('max_intensity').notNull().default(5),
|
||||
publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | null>(),
|
||||
hideNsfw: boolean('hide_nsfw').notNull().default(false),
|
||||
onboardingCompleted: boolean('onboarding_completed').notNull().default(false),
|
||||
onboardingVersion: integer('onboarding_version').notNull().default(0),
|
||||
authSyncedAt: timestamp('auth_synced_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [index('profiles_couple_id_idx').on(table.coupleId)]);
|
||||
|
||||
export const featureInterests = pgTable('feature_interests', {
|
||||
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
feature: text('feature').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.feature] }),
|
||||
index('feature_interests_feature_idx').on(table.feature),
|
||||
]);
|
||||
|
||||
export type ProfileRow = typeof profiles.$inferSelect;
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'dotenv/config';
|
||||
import { Pool } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { PostgresRepository } from './repository.js';
|
||||
|
||||
const schema = z.object({
|
||||
DATABASE_URL: z.string().url(),
|
||||
SEED_USER_ID: z.string().uuid().optional(),
|
||||
SEED_USER_EMAIL: z.string().email().default('local@sauci.test'),
|
||||
SEED_ADMIN_USER_ID: z.string().uuid().default('00000000-0000-4000-8000-000000000002'),
|
||||
SEED_ADMIN_USER_EMAIL: z.string().email().default('local-admin@sauci.test'),
|
||||
});
|
||||
|
||||
const env = schema.parse(process.env);
|
||||
const databaseUrl = new URL(env.DATABASE_URL);
|
||||
if (!['127.0.0.1', 'localhost', '::1'].includes(databaseUrl.hostname)) {
|
||||
throw new Error('db:seed only permits a localhost DATABASE_URL');
|
||||
}
|
||||
|
||||
const repository = new PostgresRepository(env.DATABASE_URL);
|
||||
const pool = new Pool({ connectionString: env.DATABASE_URL });
|
||||
try {
|
||||
if (env.SEED_USER_ID) await repository.upsertProfile({
|
||||
id: env.SEED_USER_ID, email: env.SEED_USER_EMAIL, name: 'Local Sauci User', avatarUrl: null,
|
||||
});
|
||||
await repository.upsertProfile({
|
||||
id: env.SEED_ADMIN_USER_ID,
|
||||
email: env.SEED_ADMIN_USER_EMAIL,
|
||||
name: 'Local Sauci Admin Service',
|
||||
avatarUrl: null,
|
||||
});
|
||||
await pool.query(`
|
||||
insert into admin_users(user_id,role,permissions,is_active)
|
||||
values($1,'super_admin','{}',true)
|
||||
on conflict(user_id) do update set role='super_admin',is_active=true,updated_at=now()
|
||||
`, [env.SEED_ADMIN_USER_ID]);
|
||||
console.log(`Seeded local admin service profile ${env.SEED_ADMIN_USER_ID}`);
|
||||
if (env.SEED_USER_ID) console.log(`Seeded local profile ${env.SEED_USER_ID}`);
|
||||
} finally {
|
||||
await Promise.all([repository.close(), pool.end()]);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Account operations and billing
|
||||
|
||||
This domain replaces the `delete-relationship`, `reset-couple-progress`,
|
||||
`delete-account`, and `sync-subscription` Supabase Edge Functions. The request
|
||||
identity always comes from the verified bearer token; no route accepts a user or
|
||||
couple identifier from the client.
|
||||
|
||||
## Server-only configuration
|
||||
|
||||
- `SUPABASE_AUTH_URL`: hosted Supabase project URL (or its `/auth/v1`
|
||||
endpoint), used only for Auth.
|
||||
- `SUPABASE_AUTH_SERVICE_ROLE_KEY`: server-side Auth Admin credential required
|
||||
for account deletion. Never expose this as an `EXPO_PUBLIC_*` value.
|
||||
- `REVENUECAT_API_KEY`: secret RevenueCat REST API key used to verify the token
|
||||
owner's entitlement.
|
||||
- `REVENUECAT_ENTITLEMENT_ID`: entitlement checked by sync; defaults to
|
||||
`Sauci Pro` to preserve the previous function behavior.
|
||||
|
||||
Missing provider secrets fail the affected operation with `503` before any
|
||||
durable local change. Account deletion holds its local transaction open until
|
||||
hosted Auth confirms deletion, and rolls the transaction back on Auth failure.
|
||||
Provider error bodies are not returned to the mobile app.
|
||||
|
||||
## Application wiring
|
||||
|
||||
Create one `PostgresAccountOperationsRepository`, then construct:
|
||||
|
||||
```ts
|
||||
const accountOperationsService = new AccountOperationsService(
|
||||
accountOperationsRepository,
|
||||
new SupabaseAuthAdminClient(process.env.SUPABASE_AUTH_URL, process.env.SUPABASE_AUTH_SERVICE_ROLE_KEY),
|
||||
new HttpRevenueCatClient(process.env.REVENUECAT_API_KEY, process.env.REVENUECAT_ENTITLEMENT_ID),
|
||||
new ExpoPartnerNotifier(),
|
||||
);
|
||||
```
|
||||
|
||||
Pass it to the app and call `registerAccountOperationRoutes`. Close the
|
||||
repository during graceful shutdown. No schema migration is required for these
|
||||
four operations: they use `profiles`, `couples`, `responses`, and `matches`.
|
||||
|
||||
Blob deletion must be handled by the self-hosted media/storage lifecycle when
|
||||
that vertical is wired. These operations intentionally never call the hosted
|
||||
Supabase database, Storage API, or Edge Functions.
|
||||
@@ -0,0 +1,139 @@
|
||||
import { z } from 'zod';
|
||||
import { AccountOperationError } from './types.js';
|
||||
|
||||
export interface AuthAdminClient {
|
||||
deleteUser(userId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RevenueCatClient {
|
||||
isEntitled(userId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PartnerNotifier {
|
||||
relationshipDeleted(pushToken: string): Promise<void>;
|
||||
progressReset(pushToken: string): Promise<void>;
|
||||
partnerAccountDeleted(pushToken: string): Promise<void>;
|
||||
}
|
||||
|
||||
type Fetch = typeof globalThis.fetch;
|
||||
|
||||
const revenueCatResponse = z.object({
|
||||
subscriber: z.object({
|
||||
entitlements: z.record(z.object({ expires_date: z.string().datetime().nullable() }).passthrough()),
|
||||
}).passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
export class SupabaseAuthAdminClient implements AuthAdminClient {
|
||||
constructor(
|
||||
private readonly authUrl: string | undefined,
|
||||
private readonly serviceRoleKey: string | undefined,
|
||||
private readonly request: Fetch = globalThis.fetch,
|
||||
) {}
|
||||
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
if (!this.authUrl || !this.serviceRoleKey) {
|
||||
throw new AccountOperationError(
|
||||
'account_deletion_unavailable',
|
||||
'Account deletion is temporarily unavailable',
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const configuredUrl = this.authUrl.replace(/\/$/, '');
|
||||
const authApiUrl = configuredUrl.endsWith('/auth/v1') ? configuredUrl : `${configuredUrl}/auth/v1`;
|
||||
const response = await this.request(
|
||||
`${authApiUrl}/admin/users/${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
apikey: this.serviceRoleKey,
|
||||
authorization: `Bearer ${this.serviceRoleKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
).catch(() => {
|
||||
throw new AccountOperationError('auth_provider_unavailable', 'Authentication provider is unavailable', 502);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AccountOperationError('auth_delete_failed', 'Failed to delete authentication record', 502);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpRevenueCatClient implements RevenueCatClient {
|
||||
constructor(
|
||||
private readonly apiKey: string | undefined,
|
||||
private readonly entitlementId = 'Sauci Pro',
|
||||
private readonly request: Fetch = globalThis.fetch,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
async isEntitled(userId: string): Promise<boolean> {
|
||||
if (!this.apiKey) {
|
||||
throw new AccountOperationError(
|
||||
'subscription_sync_unavailable',
|
||||
'Subscription verification is temporarily unavailable',
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.request(
|
||||
`https://api.revenuecat.com/v1/subscribers/${encodeURIComponent(userId)}`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${this.apiKey}`,
|
||||
accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
).catch(() => {
|
||||
throw new AccountOperationError('revenuecat_unavailable', 'Subscription provider is unavailable', 502);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AccountOperationError('subscription_verification_failed', 'Failed to verify subscription', 502);
|
||||
}
|
||||
|
||||
const parsed = revenueCatResponse.safeParse(await response.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
throw new AccountOperationError('invalid_subscription_response', 'Subscription provider returned invalid data', 502);
|
||||
}
|
||||
|
||||
const entitlement = parsed.data.subscriber.entitlements[this.entitlementId];
|
||||
if (!entitlement) return false;
|
||||
return entitlement.expires_date === null || new Date(entitlement.expires_date) > this.now();
|
||||
}
|
||||
}
|
||||
|
||||
export class ExpoPartnerNotifier implements PartnerNotifier {
|
||||
constructor(private readonly request: Fetch = globalThis.fetch) {}
|
||||
|
||||
relationshipDeleted(pushToken: string): Promise<void> {
|
||||
return this.send(pushToken, 'Relationship Deleted',
|
||||
'Your partner has deleted all shared data. You can start fresh with a new partner anytime.',
|
||||
'relationship_deleted');
|
||||
}
|
||||
|
||||
progressReset(pushToken: string): Promise<void> {
|
||||
return this.send(pushToken, 'Progress Reset',
|
||||
'Your partner has reset all progress. Your matches and chats have been cleared to start fresh.',
|
||||
'progress_reset');
|
||||
}
|
||||
|
||||
partnerAccountDeleted(pushToken: string): Promise<void> {
|
||||
return this.send(pushToken, 'Partner Left',
|
||||
'Your partner has deleted their account. You can start fresh with a new partner anytime.',
|
||||
'partner_account_deleted');
|
||||
}
|
||||
|
||||
private async send(to: string, title: string, body: string, type: string): Promise<void> {
|
||||
const response = await this.request('https://exp.host/--/api/v2/push/send', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ to, title, body, sound: 'default', data: { type } }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Expo push failed with status ${response.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './clients.js';
|
||||
export * from './repository.js';
|
||||
export * from './routes.js';
|
||||
export * from './service.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { AccountOperationError } from './types.js';
|
||||
|
||||
interface ProfileOperationRecord extends QueryResultRow {
|
||||
couple_id: string | null;
|
||||
}
|
||||
|
||||
interface PartnerRecord extends QueryResultRow {
|
||||
push_token: string | null;
|
||||
}
|
||||
|
||||
export interface DestructiveOperationResult {
|
||||
partnerPushToken: string | null;
|
||||
}
|
||||
|
||||
export interface AccountOperationsRepository {
|
||||
deleteRelationship(userId: string): Promise<DestructiveOperationResult>;
|
||||
resetProgress(userId: string): Promise<DestructiveOperationResult>;
|
||||
deleteAccount(userId: string, deleteAuthUser: () => Promise<void>): Promise<DestructiveOperationResult>;
|
||||
setPremium(userId: string, isPremium: boolean): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const result = await operation(client);
|
||||
await client.query('commit');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function lockedProfile(client: PoolClient, userId: string): Promise<ProfileOperationRecord> {
|
||||
const result = await client.query<ProfileOperationRecord>(
|
||||
'select couple_id from profiles where id = $1 for update',
|
||||
[userId],
|
||||
);
|
||||
const profile = result.rows[0];
|
||||
if (!profile) throw new AccountOperationError('profile_not_found', 'Profile not found', 404);
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function lockCoupleAndPartner(
|
||||
client: PoolClient,
|
||||
userId: string,
|
||||
coupleId: string,
|
||||
): Promise<PartnerRecord | undefined> {
|
||||
await client.query('select id from couples where id = $1 for update', [coupleId]);
|
||||
const partner = await client.query<PartnerRecord>(
|
||||
`select push_token from profiles
|
||||
where couple_id = $1 and id <> $2
|
||||
for update`,
|
||||
[coupleId, userId],
|
||||
);
|
||||
return partner.rows[0];
|
||||
}
|
||||
|
||||
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
}
|
||||
|
||||
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const profile = await lockedProfile(client, userId);
|
||||
if (!profile.couple_id) {
|
||||
throw new AccountOperationError('not_paired', 'You are not in a relationship', 400);
|
||||
}
|
||||
const partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
|
||||
await client.query(
|
||||
'update profiles set couple_id = null, updated_at = now() where couple_id = $1',
|
||||
[profile.couple_id],
|
||||
);
|
||||
await client.query('delete from couples where id = $1', [profile.couple_id]);
|
||||
return { partnerPushToken: partner?.push_token ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
async resetProgress(userId: string): Promise<DestructiveOperationResult> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const profile = await lockedProfile(client, userId);
|
||||
if (!profile.couple_id) {
|
||||
throw new AccountOperationError('not_paired', 'You are not in a relationship', 400);
|
||||
}
|
||||
const partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
|
||||
// Matches cascade to messages and per-user match archives. Responses are separate.
|
||||
await client.query('delete from matches where couple_id = $1', [profile.couple_id]);
|
||||
await client.query('delete from responses where couple_id = $1', [profile.couple_id]);
|
||||
return { partnerPushToken: partner?.push_token ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAccount(
|
||||
userId: string,
|
||||
deleteAuthUser: () => Promise<void>,
|
||||
): Promise<DestructiveOperationResult> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const profileResult = await client.query<ProfileOperationRecord>(
|
||||
'select couple_id from profiles where id = $1 for update',
|
||||
[userId],
|
||||
);
|
||||
const profile = profileResult.rows[0];
|
||||
let partner: PartnerRecord | undefined;
|
||||
if (profile?.couple_id) {
|
||||
partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
|
||||
await client.query(
|
||||
'update profiles set couple_id = null, updated_at = now() where couple_id = $1 and id <> $2',
|
||||
[profile.couple_id, userId],
|
||||
);
|
||||
await client.query('delete from couples where id = $1', [profile.couple_id]);
|
||||
}
|
||||
if (profile) await client.query('delete from profiles where id = $1', [userId]);
|
||||
|
||||
// Keep local deletion uncommitted until hosted Auth confirms deletion. A provider
|
||||
// failure therefore rolls the entire local transaction back for a safe retry.
|
||||
await deleteAuthUser();
|
||||
return { partnerPushToken: partner?.push_token ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
async setPremium(userId: string, isPremium: boolean): Promise<void> {
|
||||
const result = await this.pool.query(
|
||||
'update profiles set is_premium = $2, updated_at = now() where id = $1',
|
||||
[userId, isPremium],
|
||||
);
|
||||
if (result.rowCount === 0) throw new AccountOperationError('profile_not_found', 'Profile not found', 404);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import type { AccountOperationsService } from './service.js';
|
||||
import { AccountOperationError } from './types.js';
|
||||
|
||||
type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
async function run<T>(operation: () => Promise<T>): Promise<
|
||||
{ ok: true; value: T } |
|
||||
{ ok: false; body: ApiErrorResponse; status: 400 | 404 | 502 | 503 }
|
||||
> {
|
||||
try {
|
||||
return { ok: true, value: await operation() };
|
||||
} catch (cause) {
|
||||
if (cause instanceof AccountOperationError) {
|
||||
return { ok: false, body: error(cause.code, cause.message), status: cause.status };
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAccountOperationRoutes(app: AuthenticatedApp, service: AccountOperationsService): void {
|
||||
app.delete('/v1/couple/data', async (c) => {
|
||||
const result = await run(() => service.deleteRelationship(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
|
||||
app.delete('/v1/couple/progress', async (c) => {
|
||||
const result = await run(() => service.resetProgress(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
|
||||
app.delete('/v1/me', async (c) => {
|
||||
const result = await run(() => service.deleteAccount(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
|
||||
app.post('/v1/me/subscription/sync', async (c) => {
|
||||
const result = await run(() => service.syncSubscription(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AuthAdminClient, PartnerNotifier, RevenueCatClient } from './clients.js';
|
||||
import type { AccountOperationsRepository } from './repository.js';
|
||||
import type { OperationResult, SubscriptionSyncResult } from './types.js';
|
||||
|
||||
export class AccountOperationsService {
|
||||
constructor(
|
||||
private readonly repository: AccountOperationsRepository,
|
||||
private readonly authAdmin: AuthAdminClient,
|
||||
private readonly revenueCat: RevenueCatClient,
|
||||
private readonly notifier: PartnerNotifier,
|
||||
) {}
|
||||
|
||||
async deleteRelationship(userId: string): Promise<OperationResult> {
|
||||
const result = await this.repository.deleteRelationship(userId);
|
||||
if (result.partnerPushToken) {
|
||||
await this.notify(() => this.notifier.relationshipDeleted(result.partnerPushToken!));
|
||||
}
|
||||
return { success: true, message: 'Relationship data deleted successfully' };
|
||||
}
|
||||
|
||||
async resetProgress(userId: string): Promise<OperationResult> {
|
||||
const result = await this.repository.resetProgress(userId);
|
||||
if (result.partnerPushToken) {
|
||||
await this.notify(() => this.notifier.progressReset(result.partnerPushToken!));
|
||||
}
|
||||
return { success: true, message: 'Progress reset successfully' };
|
||||
}
|
||||
|
||||
async deleteAccount(userId: string): Promise<OperationResult> {
|
||||
const result = await this.repository.deleteAccount(userId, () => this.authAdmin.deleteUser(userId));
|
||||
if (result.partnerPushToken) {
|
||||
await this.notify(() => this.notifier.partnerAccountDeleted(result.partnerPushToken!));
|
||||
}
|
||||
return { success: true, message: 'Account deleted successfully' };
|
||||
}
|
||||
|
||||
async syncSubscription(userId: string): Promise<SubscriptionSyncResult> {
|
||||
const isPremium = await this.revenueCat.isEntitled(userId);
|
||||
await this.repository.setPremium(userId, isPremium);
|
||||
return { success: true, is_premium: isPremium };
|
||||
}
|
||||
|
||||
private async notify(operation: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
// Destructive data changes have already committed. Notification delivery is
|
||||
// deliberately best-effort, matching the previous Edge Function behavior.
|
||||
console.error('Partner notification failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export class AccountOperationError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly status: 400 | 404 | 502 | 503,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AccountOperationError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface OperationResult {
|
||||
success: true;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionSyncResult {
|
||||
success: true;
|
||||
is_premium: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Admin API
|
||||
|
||||
Browser administrators authenticate with hosted Supabase Auth. The API then
|
||||
loads the active `admin_users` row for the token subject and enforces each role
|
||||
permission again server-side. UI role state is never trusted for authorization.
|
||||
|
||||
Non-browser clients can use `ADMIN_API_SERVICE_TOKEN` with
|
||||
`ADMIN_API_SERVICE_USER_ID`. The opaque token is compared in constant time and
|
||||
maps to an explicit active `admin_users` record, so its permissions and audit
|
||||
actor are identical to a browser administrator. It does not grant Supabase
|
||||
service-role access.
|
||||
|
||||
Every generic mutation is transacted with an `audit_logs` write. Sensitive
|
||||
fields are redacted from audit payloads. Admin data routes are resource-
|
||||
whitelisted; arbitrary table names are rejected.
|
||||
|
||||
Central application wiring must exempt `/v1/admin/*` from the general hosted
|
||||
Auth middleware and pass `AdminRequestAuth` to `registerAdminRoutes`, which
|
||||
accepts either hosted JWTs or the configured service token.
|
||||
|
||||
`SupabaseAdminAuthDirectory` may use the existing server-side Auth Admin
|
||||
credential to enrich `/v1/admin/users` with email confirmation and last sign-in
|
||||
timestamps. It talks only to hosted Auth, never the Supabase data plane, and
|
||||
degrades to explicit null metadata when the credential or provider is absent.
|
||||
|
||||
`GET /v1/admin/media/:mediaId/url` requires `view_media` and issues a five-minute
|
||||
signed URL with `private, no-store` response caching. The object must exist and
|
||||
not be marked deleted. The opaque service credential can therefore request a
|
||||
URL without being accepted by customer media authorization.
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
type Fetch = typeof globalThis.fetch;
|
||||
|
||||
const authUser = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().nullable().optional(),
|
||||
last_sign_in_at: z.string().nullable().optional(),
|
||||
email_confirmed_at: z.string().nullable().optional(),
|
||||
confirmed_at: z.string().nullable().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AdminAuthUser = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
last_sign_in_at: string | null;
|
||||
email_confirmed_at: string | null;
|
||||
};
|
||||
|
||||
export interface AdminAuthDirectory {
|
||||
users(userId?: string): Promise<AdminAuthUser[]>;
|
||||
}
|
||||
|
||||
export class SupabaseAdminAuthDirectory implements AdminAuthDirectory {
|
||||
constructor(
|
||||
private readonly authUrl: string | undefined,
|
||||
private readonly serviceRoleKey: string | undefined,
|
||||
private readonly request: Fetch = globalThis.fetch,
|
||||
) {}
|
||||
|
||||
async users(userId?: string): Promise<AdminAuthUser[]> {
|
||||
if (!this.authUrl || !this.serviceRoleKey) return [];
|
||||
const base = this.authUrl.replace(/\/$/, '').replace(/\/auth\/v1$/, '');
|
||||
const url = userId
|
||||
? `${base}/auth/v1/admin/users/${encodeURIComponent(userId)}`
|
||||
: `${base}/auth/v1/admin/users?page=1&per_page=1000`;
|
||||
const response = await this.request(url, {
|
||||
headers: { apikey: this.serviceRoleKey, authorization: `Bearer ${this.serviceRoleKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).catch(() => null);
|
||||
if (!response?.ok) return [];
|
||||
const payload = await response.json().catch(() => null);
|
||||
const candidates = userId ? [payload] : z.object({ users: z.array(z.unknown()) }).safeParse(payload).data?.users ?? [];
|
||||
return candidates.flatMap((candidate) => {
|
||||
const parsed = authUser.safeParse(candidate); if (!parsed.success) return [];
|
||||
return [{
|
||||
id: parsed.data.id,
|
||||
email: parsed.data.email ?? null,
|
||||
last_sign_in_at: parsed.data.last_sign_in_at ?? null,
|
||||
email_confirmed_at: parsed.data.email_confirmed_at ?? parsed.data.confirmed_at ?? null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import type { AuthIdentity, AuthVerifier } from '../../auth.js';
|
||||
|
||||
export class AdminRequestAuth {
|
||||
private readonly serviceDigest?: Buffer;
|
||||
|
||||
constructor(
|
||||
private readonly hostedAuth: AuthVerifier,
|
||||
serviceToken?: string,
|
||||
private readonly serviceUserId?: string,
|
||||
) {
|
||||
if (Boolean(serviceToken) !== Boolean(serviceUserId)) throw new Error('Admin service token and user ID must be configured together');
|
||||
if (serviceToken && serviceToken.length < 32) throw new Error('Admin service token must contain at least 32 characters');
|
||||
this.serviceDigest = serviceToken ? createHash('sha256').update(serviceToken).digest() : undefined;
|
||||
}
|
||||
|
||||
async verify(token: string): Promise<AuthIdentity> {
|
||||
if (this.serviceDigest && this.serviceUserId) {
|
||||
const candidate = createHash('sha256').update(token).digest();
|
||||
if (timingSafeEqual(candidate, this.serviceDigest)) {
|
||||
return { id: this.serviceUserId, email: null, name: 'Admin API service', avatarUrl: null };
|
||||
}
|
||||
}
|
||||
return this.hostedAuth.verify(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
|
||||
import type { AdminAuthDirectory } from './auth-directory.js';
|
||||
|
||||
type Permission =
|
||||
| 'manage_packs' | 'manage_questions' | 'manage_categories' | 'view_users'
|
||||
| 'view_chats' | 'view_media' | 'view_matches' | 'view_responses' | 'view_activity'
|
||||
| 'manage_feedback' | 'gift_premium' | 'manage_codes' | 'manage_admins'
|
||||
| 'manage_ai_config' | 'manage_app_config' | 'view_audit_logs';
|
||||
|
||||
interface ResourceRule { read: Permission | Permission[]; write?: Permission; idColumn?: string }
|
||||
const resources: Record<string, ResourceRule> = {
|
||||
categories: { read: ['manage_categories', 'manage_packs', 'manage_questions', 'view_activity', 'view_users'], write: 'manage_categories' },
|
||||
question_packs: { read: ['manage_packs', 'manage_questions', 'view_activity', 'view_users'], write: 'manage_packs' },
|
||||
questions: { read: ['manage_questions', 'view_activity', 'view_responses', 'view_matches', 'view_users', 'manage_ai_config'], write: 'manage_questions' },
|
||||
topics: { read: 'manage_packs', write: 'manage_packs' },
|
||||
pack_topics: { read: 'manage_packs', write: 'manage_packs', idColumn: 'pack_id' },
|
||||
profiles: { read: ['view_users', 'view_activity', 'manage_admins', 'manage_feedback', 'manage_ai_config', 'manage_codes'] }, couples: { read: ['view_users', 'view_activity'] },
|
||||
responses: { read: ['view_responses', 'view_activity', 'view_users'] }, matches: { read: ['view_matches', 'view_activity', 'view_users'] },
|
||||
messages: { read: ['view_chats', 'view_activity'], write: 'view_chats' },
|
||||
message_reports: { read: 'view_chats', write: 'view_chats' },
|
||||
feedback: { read: 'manage_feedback', write: 'manage_feedback' },
|
||||
admin_users: { read: 'manage_admins', write: 'manage_admins' },
|
||||
audit_logs: { read: 'view_audit_logs' },
|
||||
redemption_codes: { read: 'manage_codes', write: 'manage_codes' },
|
||||
code_redemptions: { read: 'manage_codes' },
|
||||
ai_config: { read: 'manage_ai_config', write: 'manage_ai_config' },
|
||||
app_config: { read: 'manage_app_config', write: 'manage_app_config' },
|
||||
feature_interests: { read: 'view_users' }, couple_packs: { read: 'view_users' },
|
||||
couple_streaks: { read: 'view_users' }, live_draw_sessions: { read: 'view_users' },
|
||||
subscriptions: { read: 'view_users' },
|
||||
media_objects: { read: 'view_media' },
|
||||
dare_packs: { read: 'manage_packs', write: 'manage_packs' },
|
||||
dares: { read: 'manage_questions', write: 'manage_questions' },
|
||||
};
|
||||
|
||||
function identifier(value: string): string {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(value)) throw new AdminError('invalid_identifier', 'Invalid field name', 400);
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
function rule(resource: string): ResourceRule {
|
||||
const value = resources[resource];
|
||||
if (!value) throw new AdminError('unsupported_resource', 'Resource is not available to the admin API', 404);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePermission(principal: AdminPrincipal, permission: Permission): void {
|
||||
if (principal.role !== 'super_admin' && !principal.permissions.includes(permission)) {
|
||||
throw new AdminError('forbidden', 'This administrator lacks the required permission', 403);
|
||||
}
|
||||
}
|
||||
|
||||
function requireAnyPermission(principal: AdminPrincipal, permissions: Permission | Permission[]): void {
|
||||
const choices = Array.isArray(permissions) ? permissions : [permissions];
|
||||
if (principal.role !== 'super_admin' && !choices.some((permission) => principal.permissions.includes(permission))) {
|
||||
throw new AdminError('forbidden', 'This administrator lacks the required permission', 403);
|
||||
}
|
||||
}
|
||||
|
||||
function whereParts(query: AdminQuery): { sql: string; values: unknown[] } {
|
||||
const values: unknown[] = [];
|
||||
const clauses: string[] = [];
|
||||
for (const filter of query.filters ?? []) {
|
||||
const column = identifier(filter.column);
|
||||
const position = values.length + 1;
|
||||
if (filter.op === 'is' && filter.value === null) clauses.push(`${column} is null`);
|
||||
else if (filter.op === 'neq' && filter.value === null) clauses.push(`${column} is not null`);
|
||||
else if (filter.op === 'in') {
|
||||
if (!Array.isArray(filter.value)) throw new AdminError('invalid_filter', 'in filters require an array', 400);
|
||||
values.push(filter.value); clauses.push(`${column} = any($${position})`);
|
||||
} else {
|
||||
const operators = { eq: '=', neq: '<>', gte: '>=', lte: '<=', ilike: 'ilike' } as const;
|
||||
const operator = operators[filter.op as keyof typeof operators];
|
||||
if (!operator) throw new AdminError('invalid_filter', 'Unsupported filter', 400);
|
||||
values.push(filter.value); clauses.push(`${column} ${operator} $${position}`);
|
||||
}
|
||||
}
|
||||
return { sql: clauses.length ? ` where ${clauses.join(' and ')}` : '', values };
|
||||
}
|
||||
|
||||
const restrictedReadColumns: Record<string, ReadonlySet<string>> = {
|
||||
profiles: new Set(['push_token', 'public_key_jwk']),
|
||||
messages: new Set(['encrypted_content', 'encryption_iv', 'keys_metadata']),
|
||||
media_objects: new Set(['storage_key']),
|
||||
};
|
||||
|
||||
function projection(resource: string, columns: string[] | undefined): string {
|
||||
if (!columns) return '*';
|
||||
const restricted = restrictedReadColumns[resource] ?? new Set<string>();
|
||||
if (columns.some((column) => restricted.has(column))) {
|
||||
throw new AdminError('restricted_column', 'The requested field is not available through generic admin queries', 403);
|
||||
}
|
||||
return columns.map(identifier).join(',');
|
||||
}
|
||||
|
||||
function sanitizeRows(resource: string, rows: QueryResultRow[]): QueryResultRow[] {
|
||||
const restricted = restrictedReadColumns[resource];
|
||||
if (!restricted) return rows;
|
||||
return rows.map((row) => Object.fromEntries(Object.entries(row).filter(([column]) => !restricted.has(column))));
|
||||
}
|
||||
|
||||
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try { await client.query('begin'); const value = await operation(client); await client.query('commit'); return value; }
|
||||
catch (error) { await client.query('rollback'); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
function redactAudit(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(redactAudit);
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
||||
key,
|
||||
/(key|secret|token|password|credential)/i.test(key) ? '[REDACTED]' : redactAudit(item),
|
||||
]));
|
||||
}
|
||||
|
||||
export interface AdminRepository {
|
||||
principal(userId: string): Promise<AdminPrincipal>;
|
||||
query(principal: AdminPrincipal, resource: string, query: AdminQuery): Promise<{ rows: Record<string, unknown>[]; count: number }>;
|
||||
insert(principal: AdminPrincipal, resource: string, records: Record<string, unknown>[]): Promise<Record<string, unknown>[]>;
|
||||
update(principal: AdminPrincipal, resource: string, id: string, values: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
delete(principal: AdminPrincipal, resource: string, id: string): Promise<void>;
|
||||
dashboard(principal: AdminPrincipal): Promise<Record<string, number>>;
|
||||
giftPremium(principal: AdminPrincipal, userId: string, days: number | null, reason?: string): Promise<{ subscription_id: string; expires_at: string | null }>;
|
||||
featureInterestCounts(principal: AdminPrincipal): Promise<Array<{
|
||||
feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number;
|
||||
}>>;
|
||||
users(principal: AdminPrincipal, userId?: string): Promise<Record<string, unknown>[]>;
|
||||
authorizeMedia(principal: AdminPrincipal, mediaId: string): Promise<void>;
|
||||
decryptMessage(principal: AdminPrincipal, messageId: string): Promise<{ content: string | null }>;
|
||||
decryptMedia(principal: AdminPrincipal, messageId: string): Promise<{ bytes: Buffer; mimeType: string }>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class PostgresAdminRepository implements AdminRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string, private readonly options: {
|
||||
adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory;
|
||||
} = {}) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
|
||||
async principal(userId: string): Promise<AdminPrincipal> {
|
||||
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
|
||||
'select id, user_id, role, permissions from admin_users where user_id=$1 and is_active=true', [userId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new AdminError('admin_access_denied', 'Administrator access denied', 403);
|
||||
return { adminId: row.id, userId: row.user_id, role: row.role, permissions: row.permissions ?? [] };
|
||||
}
|
||||
|
||||
async query(principal: AdminPrincipal, resource: string, query: AdminQuery) {
|
||||
requireAnyPermission(principal, rule(resource).read);
|
||||
const where = whereParts(query);
|
||||
const table = identifier(resource);
|
||||
const selected = projection(resource, query.columns);
|
||||
let pageSql = where.sql;
|
||||
if (query.order) pageSql += ` order by ${identifier(query.order.column)} ${query.order.ascending === false ? 'desc' : 'asc'}`;
|
||||
const pageValues = [...where.values, Math.min(Math.max(query.limit ?? 100, 1), 500)];
|
||||
pageSql += ` limit $${pageValues.length}`;
|
||||
if (query.offset) { pageValues.push(Math.max(query.offset, 0)); pageSql += ` offset $${pageValues.length}`; }
|
||||
const [rows, count] = await Promise.all([
|
||||
this.pool.query<QueryResultRow>(`select ${selected} from ${table}${pageSql}`, pageValues),
|
||||
this.pool.query<{ count: string }>(`select count(*)::text count from ${table}${where.sql}`, where.values),
|
||||
]);
|
||||
return { rows: sanitizeRows(resource, rows.rows), count: Number(count.rows[0]?.count ?? 0) };
|
||||
}
|
||||
|
||||
async insert(principal: AdminPrincipal, resource: string, records: Record<string, unknown>[]) {
|
||||
const permission = rule(resource).write;
|
||||
if (!permission) throw new AdminError('forbidden', 'Resource is read-only', 403);
|
||||
requirePermission(principal, permission);
|
||||
if (!records.length) throw new AdminError('invalid_records', 'At least one record is required', 400);
|
||||
return transaction(this.pool, async (client) => {
|
||||
const inserted: Record<string, unknown>[] = [];
|
||||
for (const record of records) {
|
||||
const keys = Object.keys(record); if (!keys.length) throw new AdminError('invalid_record', 'Record cannot be empty', 400);
|
||||
const result = await client.query<QueryResultRow>(
|
||||
`insert into ${identifier(resource)} (${keys.map(identifier).join(',')}) values (${keys.map((_, index) => `$${index + 1}`).join(',')}) returning *`,
|
||||
keys.map((key) => record[key]),
|
||||
);
|
||||
const row = result.rows[0]!; inserted.push(row);
|
||||
await this.audit(client, principal, resource, 'INSERT', String(row.id ?? row[rule(resource).idColumn ?? 'id'] ?? ''), null, row);
|
||||
}
|
||||
return inserted;
|
||||
});
|
||||
}
|
||||
|
||||
async update(principal: AdminPrincipal, resource: string, id: string, values: Record<string, unknown>) {
|
||||
const resourceRule = rule(resource); if (!resourceRule.write) throw new AdminError('forbidden', 'Resource is read-only', 403);
|
||||
requirePermission(principal, resourceRule.write);
|
||||
const keys = Object.keys(values); if (!keys.length) throw new AdminError('invalid_record', 'Updates cannot be empty', 400);
|
||||
const idColumn = resourceRule.idColumn ?? 'id';
|
||||
return transaction(this.pool, async (client) => {
|
||||
const old = await client.query<QueryResultRow>(`select * from ${identifier(resource)} where ${identifier(idColumn)}=$1 for update`, [id]);
|
||||
if (!old.rows.length) throw new AdminError('not_found', 'Record not found', 404);
|
||||
const updated = await client.query<QueryResultRow>(
|
||||
`update ${identifier(resource)} set ${keys.map((key, index) => `${identifier(key)}=$${index + 2}`).join(',')} where ${identifier(idColumn)}=$1 returning *`,
|
||||
[id, ...keys.map((key) => values[key])],
|
||||
);
|
||||
for (let index = 0; index < updated.rows.length; index += 1) {
|
||||
const oldRow = old.rows[index]; const updatedRow = updated.rows[index]!;
|
||||
await this.audit(client, principal, resource, 'UPDATE', String(updatedRow.id ?? id), oldRow, updatedRow);
|
||||
}
|
||||
return updated.rows[0]!;
|
||||
});
|
||||
}
|
||||
|
||||
async delete(principal: AdminPrincipal, resource: string, id: string): Promise<void> {
|
||||
const resourceRule = rule(resource); if (!resourceRule.write) throw new AdminError('forbidden', 'Resource is read-only', 403);
|
||||
requirePermission(principal, resourceRule.write);
|
||||
const idColumn = resourceRule.idColumn ?? 'id';
|
||||
await transaction(this.pool, async (client) => {
|
||||
const deleted = await client.query<QueryResultRow>(`delete from ${identifier(resource)} where ${identifier(idColumn)}=$1 returning *`, [id]);
|
||||
if (!deleted.rows.length) throw new AdminError('not_found', 'Record not found', 404);
|
||||
for (const row of deleted.rows) {
|
||||
await this.audit(client, principal, resource, 'DELETE', String(row.id ?? id), row, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async dashboard(principal: AdminPrincipal): Promise<Record<string, number>> {
|
||||
const result = await this.pool.query<{ categories: number; packs: number; questions: number; users: number }>(
|
||||
'select (select count(*)::int from categories) categories, (select count(*)::int from question_packs) packs, (select count(*)::int from questions) questions, (select count(*)::int from profiles) users',
|
||||
);
|
||||
return result.rows[0] ?? { categories: 0, packs: 0, questions: 0, users: 0 };
|
||||
}
|
||||
|
||||
async giftPremium(principal: AdminPrincipal, userId: string, days: number | null, reason?: string) {
|
||||
requirePermission(principal, 'gift_premium');
|
||||
return transaction(this.pool, async (client) => {
|
||||
const profile = await client.query('select id from profiles where id=$1 for update', [userId]);
|
||||
if (!profile.rows[0]) throw new AdminError('user_not_found', 'User not found', 404);
|
||||
const expiresAt = days === null ? null : new Date(Date.now() + days * 86_400_000);
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`insert into subscriptions(user_id,revenuecat_app_user_id,product_id,status,entitlement_ids,purchased_at,expires_at,store)
|
||||
values($1,$2,'admin_premium','active',array['premium'],now(),$3,'manual') returning id`,
|
||||
[userId, `admin_grant:${userId}`, expiresAt],
|
||||
);
|
||||
await client.query('update profiles set is_premium=true,updated_at=now() where id=$1', [userId]);
|
||||
await client.query(
|
||||
`insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,new_values)
|
||||
values($1,$2,'subscriptions','ACTION',$3,$4)`,
|
||||
[principal.adminId, principal.userId, inserted.rows[0]!.id, { action: 'gift_premium', target_user_id: userId, days, reason: reason ?? null, expires_at: expiresAt?.toISOString() ?? null }],
|
||||
);
|
||||
return { subscription_id: inserted.rows[0]!.id, expires_at: expiresAt?.toISOString() ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
async featureInterestCounts(principal: AdminPrincipal) {
|
||||
requirePermission(principal, 'view_users');
|
||||
const result = await this.pool.query<{
|
||||
feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number;
|
||||
}>(`select feature as feature_name,
|
||||
count(*)::int as opt_in_count,
|
||||
count(*) filter (where created_at >= now() - interval '7 days')::int as opt_in_count_last_7_days
|
||||
from feature_interests group by feature order by feature`);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async users(principal: AdminPrincipal, userId?: string) {
|
||||
requirePermission(principal, 'view_users');
|
||||
const result = await this.pool.query<QueryResultRow & { id: string }>(
|
||||
`select p.*,coalesce(sum(mo.byte_size) filter(where mo.deleted_at is null),0)::bigint::text as storage_bytes
|
||||
from profiles p left join media_objects mo on mo.owner_id=p.id
|
||||
${userId ? 'where p.id=$1' : ''} group by p.id order by p.created_at desc limit 500`,
|
||||
userId ? [userId] : [],
|
||||
);
|
||||
const authUsers = await this.options.authDirectory?.users(userId).catch(() => []) ?? [];
|
||||
const authById = new Map(authUsers.map((user) => [user.id, user]));
|
||||
return result.rows.map((profile) => {
|
||||
const authUser = authById.get(profile.id);
|
||||
return {
|
||||
...profile,
|
||||
email: authUser?.email ?? profile.email ?? null,
|
||||
last_sign_in_at: authUser?.last_sign_in_at ?? null,
|
||||
email_confirmed_at: authUser?.email_confirmed_at ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async authorizeMedia(principal: AdminPrincipal, mediaId: string): Promise<void> {
|
||||
requirePermission(principal, 'view_media');
|
||||
const result = await this.pool.query('select 1 from media_objects where id=$1 and deleted_at is null', [mediaId]);
|
||||
if (!result.rows[0]) throw new AdminError('media_not_found', 'Media not found', 404);
|
||||
}
|
||||
|
||||
private async messageForDecryption(messageId: string) {
|
||||
const result = await this.pool.query<{
|
||||
content: string | null; encrypted_content: string | null; encryption_iv: string | null;
|
||||
keys_metadata: { admin_wrapped_key?: string } | null; version: number; media_path: string | null;
|
||||
storage_key: string | null; mime_type: string | null;
|
||||
}>(
|
||||
`select m.content,m.encrypted_content,m.encryption_iv,m.keys_metadata,m.version,m.media_path,mo.storage_key,mo.mime_type
|
||||
from messages m left join media_objects mo on m.media_path='media:'||mo.id::text where m.id=$1`, [messageId],
|
||||
);
|
||||
const row = result.rows[0]; if (!row) throw new AdminError('message_not_found', 'Message not found', 404); return row;
|
||||
}
|
||||
|
||||
private async messageKey(row: { keys_metadata: { admin_wrapped_key?: string } | null }): Promise<CryptoKey> {
|
||||
const wrapped = row.keys_metadata?.admin_wrapped_key;
|
||||
if (!wrapped || !this.options.adminPrivateKeyJwk) throw new AdminError('decryption_unavailable', 'Admin decryption key is unavailable', 403);
|
||||
const privateKey = await crypto.subtle.importKey('jwk', JSON.parse(this.options.adminPrivateKeyJwk) as JsonWebKey, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']);
|
||||
const raw = await crypto.subtle.decrypt({ name: 'RSA-OAEP' }, privateKey, Buffer.from(wrapped, 'base64'));
|
||||
return crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['decrypt']);
|
||||
}
|
||||
|
||||
async decryptMessage(principal: AdminPrincipal, messageId: string) {
|
||||
requirePermission(principal, 'view_chats');
|
||||
const row = await this.messageForDecryption(messageId);
|
||||
if (row.version !== 2 || !row.encrypted_content) return { content: row.content };
|
||||
if (!row.encryption_iv) throw new AdminError('invalid_encrypted_message', 'Message encryption metadata is incomplete', 409);
|
||||
const key = await this.messageKey(row);
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(row.encryption_iv, 'base64') }, key, Buffer.from(row.encrypted_content, 'base64'));
|
||||
return { content: new TextDecoder().decode(plain) };
|
||||
}
|
||||
|
||||
async decryptMedia(principal: AdminPrincipal, messageId: string) {
|
||||
requirePermission(principal, 'view_media');
|
||||
const row = await this.messageForDecryption(messageId);
|
||||
if (!row.storage_key || !row.mime_type) throw new AdminError('media_not_found', 'Message media not found', 404);
|
||||
const root = resolve(this.options.mediaRoot ?? '/data/media'); const path = resolve(root, row.storage_key);
|
||||
if (!path.startsWith(`${root}${sep}`)) throw new AdminError('invalid_media_path', 'Invalid media path', 400);
|
||||
let bytes = await readFile(path);
|
||||
if (row.version === 2) {
|
||||
if (!row.encryption_iv) throw new AdminError('invalid_encrypted_message', 'Message encryption metadata is incomplete', 409);
|
||||
const key = await this.messageKey(row);
|
||||
bytes = Buffer.from(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(row.encryption_iv, 'base64') }, key, bytes));
|
||||
}
|
||||
return { bytes, mimeType: row.mime_type };
|
||||
}
|
||||
|
||||
private async audit(client: PoolClient, principal: AdminPrincipal, table: string, action: 'INSERT' | 'UPDATE' | 'DELETE', recordId: string, oldValues: unknown, newValues: unknown) {
|
||||
await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]);
|
||||
}
|
||||
|
||||
async close(): Promise<void> { await this.pool.end(); }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { bearerToken } from '../../auth.js';
|
||||
import type { AdminRequestAuth } from './auth.js';
|
||||
import type { AdminRepository } from './repository.js';
|
||||
import { AdminError } from './types.js';
|
||||
import type { FilesystemMediaStorage } from '../media/storage.js';
|
||||
|
||||
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
const resource = z.string().regex(/^[a-z_][a-z0-9_]*$/);
|
||||
const queryBody = z.object({
|
||||
columns: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/)).min(1).max(100).optional(),
|
||||
filters: z.array(z.object({ column: z.string(), op: z.enum(['eq', 'neq', 'in', 'is', 'gte', 'lte', 'ilike']), value: z.unknown() })).optional(),
|
||||
order: z.object({ column: z.string(), ascending: z.boolean().optional() }).optional(),
|
||||
limit: z.number().int().positive().max(500).optional(), offset: z.number().int().nonnegative().optional(),
|
||||
}).strict();
|
||||
const insertBody = z.object({ records: z.array(z.record(z.unknown())).min(1) }).strict();
|
||||
const updateBody = z.object({ values: z.record(z.unknown()) }).strict();
|
||||
|
||||
function failure(code: string, message: string): ApiErrorResponse { return { error: { code, message } }; }
|
||||
async function execute<T>(operation: () => Promise<T>) {
|
||||
try { return { ok: true as const, value: await operation() }; }
|
||||
catch (cause) { if (cause instanceof AdminError) return { ok: false as const, cause }; throw cause; }
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app: App, repository: AdminRepository, auth?: AdminRequestAuth, mediaStorage?: FilesystemMediaStorage): void {
|
||||
if (auth) {
|
||||
app.use('/v1/admin/*', async (c, next) => {
|
||||
const token = bearerToken(c.req.header('authorization'));
|
||||
if (!token) return c.json(failure('unauthorized', 'A bearer token is required'), 401);
|
||||
try { c.set('identity', await auth.verify(token)); }
|
||||
catch { return c.json(failure('unauthorized', 'The bearer token is invalid'), 401); }
|
||||
await next();
|
||||
});
|
||||
}
|
||||
app.get('/v1/admin/me', async (c) => {
|
||||
const result = await execute(() => repository.principal(c.get('identity').id));
|
||||
return result.ok ? c.json({ role: result.value.role, permissions: result.value.permissions }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/admin/query/:resource', async (c) => {
|
||||
const parsedResource = resource.safeParse(c.req.param('resource'));
|
||||
const parsedBody = queryBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_query', 'Invalid admin query'), 400);
|
||||
const result = await execute(async () => repository.query(await repository.principal(c.get('identity').id), parsedResource.data, parsedBody.data));
|
||||
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/admin/data/:resource', async (c) => {
|
||||
const parsedResource = resource.safeParse(c.req.param('resource'));
|
||||
const parsedBody = insertBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
|
||||
const result = await execute(async () => repository.insert(await repository.principal(c.get('identity').id), parsedResource.data, parsedBody.data.records));
|
||||
return result.ok ? c.json({ rows: result.value }, 201) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.patch('/v1/admin/data/:resource/:id', async (c) => {
|
||||
const parsedResource = resource.safeParse(c.req.param('resource'));
|
||||
const parsedBody = updateBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
|
||||
const result = await execute(async () => repository.update(await repository.principal(c.get('identity').id), parsedResource.data, c.req.param('id'), parsedBody.data.values));
|
||||
return result.ok ? c.json({ row: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.delete('/v1/admin/data/:resource/:id', async (c) => {
|
||||
const parsedResource = resource.safeParse(c.req.param('resource'));
|
||||
if (!parsedResource.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
|
||||
const result = await execute(async () => repository.delete(await repository.principal(c.get('identity').id), parsedResource.data, c.req.param('id')));
|
||||
return result.ok ? c.json({ deleted: true }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/admin/dashboard', async (c) => {
|
||||
const result = await execute(async () => repository.dashboard(await repository.principal(c.get('identity').id)));
|
||||
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/admin/users', async (c) => {
|
||||
const result = await execute(async () => repository.users(await repository.principal(c.get('identity').id), c.req.query('userId')));
|
||||
return result.ok ? c.json({ users: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/admin/feature-interest-counts', async (c) => {
|
||||
const result = await execute(async () => repository.featureInterestCounts(await repository.principal(c.get('identity').id)));
|
||||
return result.ok ? c.json({ counts: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/admin/media/:mediaId/url', async (c) => {
|
||||
const parsed = z.string().uuid().safeParse(c.req.param('mediaId'));
|
||||
if (!parsed.success) return c.json(failure('invalid_media_id', 'A valid media ID is required'), 400);
|
||||
if (!mediaStorage) return c.json(failure('media_unavailable', 'Media storage is unavailable'), 503);
|
||||
const result = await execute(async () => {
|
||||
await repository.authorizeMedia(await repository.principal(c.get('identity').id), parsed.data);
|
||||
return mediaStorage.signedUrl(parsed.data, 300);
|
||||
});
|
||||
return result.ok ? c.json(result.value, 200, { 'cache-control': 'private, no-store' }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/admin/users/:userId/gift-premium', async (c) => {
|
||||
const parsed = z.object({ days: z.number().int().min(1).max(3650).nullable(), reason: z.string().trim().max(500).optional() }).strict()
|
||||
.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) return c.json(failure('invalid_gift', 'A valid premium duration is required'), 400);
|
||||
const result = await execute(async () => repository.giftPremium(await repository.principal(c.get('identity').id), c.req.param('userId'), parsed.data.days, parsed.data.reason));
|
||||
return result.ok ? c.json(result.value, 201) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/admin/actions/decrypt-message', async (c) => {
|
||||
const parsed = z.object({ messageId: z.string().uuid() }).strict().safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) return c.json(failure('invalid_message_id', 'A valid message ID is required'), 400);
|
||||
const result = await execute(async () => repository.decryptMessage(await repository.principal(c.get('identity').id), parsed.data.messageId));
|
||||
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/admin/actions/decrypt-media', async (c) => {
|
||||
const parsed = z.object({ messageId: z.string().uuid() }).strict().safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) return c.json(failure('invalid_message_id', 'A valid message ID is required'), 400);
|
||||
const result = await execute(async () => repository.decryptMedia(await repository.principal(c.get('identity').id), parsed.data.messageId));
|
||||
if (!result.ok) return c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
return c.body(new Uint8Array(result.value.bytes), 200, { 'content-type': result.value.mimeType, 'cache-control': 'private, no-store' });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type AdminRole = 'pack_creator' | 'super_admin';
|
||||
|
||||
export interface AdminPrincipal {
|
||||
adminId: string;
|
||||
userId: string;
|
||||
role: AdminRole;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export type AdminFilterOperator = 'eq' | 'neq' | 'in' | 'is' | 'gte' | 'lte' | 'ilike';
|
||||
export interface AdminFilter { column: string; op: AdminFilterOperator; value?: unknown }
|
||||
export interface AdminQuery {
|
||||
columns?: string[];
|
||||
filters?: AdminFilter[];
|
||||
order?: { column: string; ascending?: boolean };
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class AdminError extends Error {
|
||||
constructor(public readonly code: string, message: string, public readonly status: 400 | 403 | 404 | 409) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Pool, type PoolClient } from 'pg';
|
||||
import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js';
|
||||
|
||||
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
|
||||
interface QuestionRow { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; question_type: QuestionType; config: Record<string, unknown>; created_at: string }
|
||||
interface ResponseRow { id: string; user_id: string; question_id: string; couple_id: string; answer: Answer; response_data: ResponseData; created_at: string }
|
||||
interface MatchRow { id: string; couple_id: string; question_id: string; match_type: MatchType; is_new: boolean; response_summary: Record<string, unknown> | null; created_at: string }
|
||||
|
||||
export interface SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
|
||||
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
|
||||
|
||||
export interface AnswersRepository {
|
||||
recommended(userId: string, packId?: string): Promise<QuestionRow[]>;
|
||||
pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string): Promise<Array<{ id: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } }; partnerAnsweredAt: string }>>;
|
||||
answerGap(userId: string): Promise<{ unanswered_by_partner: number; threshold: number; is_blocked: boolean }>;
|
||||
dailyLimit(userId: string): Promise<{ responses_today: number; limit_value: number; remaining: number; reset_at: string | null; is_blocked: boolean }>;
|
||||
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
|
||||
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
|
||||
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
|
||||
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
|
||||
markSeen(userId: string, ids: string[]): Promise<void>;
|
||||
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
|
||||
streak(userId: string): Promise<Record<string, unknown> | null>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class PostgresAnswersRepository implements AnswersRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
|
||||
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
|
||||
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
|
||||
const row = result.rows[0];
|
||||
if (!row?.couple_id) throw new AnswersError('no_couple', 409, 'Join a couple before answering questions');
|
||||
return row;
|
||||
}
|
||||
|
||||
async recommended(userId: string, packId?: string): Promise<QuestionRow[]> {
|
||||
const ctx = await this.context(this.pool, userId);
|
||||
const partner = await this.pool.query<ContextRow & { id: string }>('select id, couple_id, gender, max_intensity, is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1', [ctx.couple_id, userId]);
|
||||
const p = partner.rows[0];
|
||||
const config = await this.config();
|
||||
if (!packId && p && (await this.answerGap(userId)).is_blocked) return [];
|
||||
if (!packId && !ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
|
||||
const genders = ctx.gender && p?.gender ? [ctx.gender, p.gender].sort().join('+') : null;
|
||||
const maxIntensity = config.couple_intensity_gate_enabled && p ? Math.min(ctx.max_intensity, p.max_intensity) : ctx.max_intensity;
|
||||
const result = await this.pool.query<QuestionRow>(`
|
||||
with configured as (select exists(select 1 from couple_packs where couple_id=$2) value),
|
||||
active as (
|
||||
select $3::uuid pack_id where $3::uuid is not null
|
||||
union all select cp.pack_id from couple_packs cp, configured c where $3::uuid is null and cp.couple_id=$2 and cp.enabled and c.value
|
||||
union all select qp.id from question_packs qp, configured c where $3::uuid is null and qp.is_public and not c.value
|
||||
)
|
||||
select q.id,q.pack_id,
|
||||
case when q.partner_text is not null and pr.id is not null then q.partner_text else q.text end text,
|
||||
q.partner_text,q.intensity,q.allowed_couple_genders,q.target_user_genders,q.question_type,q.config,q.created_at
|
||||
from questions q join active a on a.pack_id=q.pack_id join question_packs qp on qp.id=q.pack_id
|
||||
left join responses pr on pr.question_id=q.id and pr.couple_id=$2 and pr.user_id<>$1
|
||||
where q.deleted_at is null and not exists(select 1 from responses r where r.user_id=$1 and r.question_id=q.id)
|
||||
and (not qp.is_premium or $4)
|
||||
and (q.allowed_couple_genders is null or $5::text is null or $5=any(q.allowed_couple_genders))
|
||||
and (q.target_user_genders is null or $6=any(q.target_user_genders) or pr.id is not null)
|
||||
and q.intensity <= $7
|
||||
order by (pr.id is not null) desc,q.intensity,q.id`,
|
||||
[userId, ctx.couple_id, packId ?? null, ctx.is_premium || !!p?.is_premium, genders, ctx.gender, maxIntensity]);
|
||||
return result.rows.map(this.dates);
|
||||
}
|
||||
|
||||
async pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string) {
|
||||
const ctx = await this.context(this.pool, userId);
|
||||
const result = await this.pool.query<{ id: string; created_at: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } } }>(`
|
||||
select r.id,r.created_at,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,
|
||||
'intensity',q.intensity,'allowed_couple_genders',q.allowed_couple_genders,'target_user_genders',q.target_user_genders,
|
||||
'question_type',q.question_type,'config',q.config,'created_at',q.created_at,
|
||||
'pack',json_build_object('id',qp.id,'name',qp.name,'icon',qp.icon)) question
|
||||
from responses r join questions q on q.id=r.question_id join question_packs qp on qp.id=q.pack_id
|
||||
where r.couple_id=$2 and r.user_id ${direction === 'partner' ? '<>' : '='} $1 and q.deleted_at is null
|
||||
and not exists(select 1 from responses other where other.question_id=r.question_id and other.user_id ${direction === 'partner' ? '=' : '<>'} $1 and other.couple_id=$2)
|
||||
order by case when r.question_id=$3 then 0 else 1 end,r.created_at ${direction === 'partner' ? 'asc' : 'desc'}`,
|
||||
[userId, ctx.couple_id, startQuestionId ?? null]);
|
||||
return result.rows.map(row => ({ id: row.id, question: row.question, partnerAnsweredAt: new Date(row.created_at).toISOString() }));
|
||||
}
|
||||
|
||||
private async config(client: Pool | PoolClient = this.pool) {
|
||||
const result = await client.query<{ answer_gap_threshold: number; daily_response_limit: number; couple_intensity_gate_enabled: boolean }>('select answer_gap_threshold,daily_response_limit,couple_intensity_gate_enabled from app_config limit 1');
|
||||
return result.rows[0] ?? { answer_gap_threshold: 10, daily_response_limit: 0, couple_intensity_gate_enabled: false };
|
||||
}
|
||||
|
||||
async answerGap(userId: string) {
|
||||
let ctx: ContextRow; try { ctx = await this.context(this.pool, userId); } catch { return { unanswered_by_partner: 0, threshold: 0, is_blocked: false }; }
|
||||
const cfg = await this.config();
|
||||
const partner = await this.pool.query<{ id: string; max_intensity: number }>('select id,max_intensity from profiles where couple_id=$1 and id<>$2 order by id limit 1', [ctx.couple_id, userId]);
|
||||
if (!partner.rows[0] || cfg.answer_gap_threshold === 0) return { unanswered_by_partner: 0, threshold: cfg.answer_gap_threshold, is_blocked: false };
|
||||
const gate = cfg.couple_intensity_gate_enabled ? Math.min(ctx.max_intensity, partner.rows[0].max_intensity) : 5;
|
||||
const counts = await this.pool.query<{ mine: number; theirs: number }>(`
|
||||
with enabled as(select pack_id from couple_packs where couple_id=$2 and enabled), eligible as(
|
||||
select r.* from responses r join questions q on q.id=r.question_id
|
||||
where r.couple_id=$2 and exists(select 1 from enabled) and q.pack_id in(select pack_id from enabled) and q.intensity <= $4)
|
||||
select count(*) filter(where e.user_id=$1 and not exists(select 1 from eligible x where x.question_id=e.question_id and x.user_id=$3))::int mine,
|
||||
count(*) filter(where e.user_id=$3 and not exists(select 1 from eligible x where x.question_id=e.question_id and x.user_id=$1))::int theirs from eligible e`,
|
||||
[userId, ctx.couple_id, partner.rows[0].id, gate]);
|
||||
const net = Math.max(0, (counts.rows[0]?.mine ?? 0) - (counts.rows[0]?.theirs ?? 0));
|
||||
return { unanswered_by_partner: net, threshold: cfg.answer_gap_threshold, is_blocked: net >= cfg.answer_gap_threshold };
|
||||
}
|
||||
|
||||
async dailyLimit(userId: string) {
|
||||
return this.dailyLimitWith(this.pool,userId);
|
||||
}
|
||||
|
||||
async submit(userId: string, input: SubmitInput) {
|
||||
return this.tx(async client => {
|
||||
// Profile lock serializes a user's daily-limit count across different questions.
|
||||
const ctx = await this.lockedContext(client, userId);
|
||||
// Question lock serializes both partners so the second transaction observes
|
||||
// the first committed response and creates exactly one match.
|
||||
const q = await this.question(client, input.questionId, true);
|
||||
await this.assertEligible(client,userId,ctx,q);
|
||||
const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]);
|
||||
if (!existing.rowCount) {
|
||||
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, reset_at: limit.reset_at });
|
||||
}
|
||||
const response = await client.query<ResponseRow>(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5)
|
||||
on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]);
|
||||
const match = await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,input.responseData ?? null,true);
|
||||
await this.touchStreak(client,userId,ctx.couple_id!);
|
||||
return { response: this.dates(response.rows[0]), match: match ? { ...this.dates(match), question: this.dates(q) } : null };
|
||||
});
|
||||
}
|
||||
|
||||
async update(userId: string, input: UpdateInput) {
|
||||
return this.tx(async client => {
|
||||
const ctx = await this.lockedContext(client,userId); const q=await this.question(client,input.questionId,true);
|
||||
const current=await client.query<ResponseRow>('select * from responses where user_id=$1 and question_id=$2 for update',[userId,input.questionId]);
|
||||
if (!current.rows[0]) throw new AnswersError('response_not_found',404,'No existing response found for this question');
|
||||
if (current.rows[0].answer===input.answer && typeof input.responseData==='undefined') return { success:true,message:'Answer unchanged' };
|
||||
const existing=await client.query<MatchRow>('select * from matches where couple_id=$1 and question_id=$2 for update',[ctx.couple_id,input.questionId]);
|
||||
if (input.answer==='no' && existing.rows[0] && !input.confirmDeleteMatch) {
|
||||
const count=await client.query<{ count:number }>('select count(*)::int count from messages where match_id=$1',[existing.rows[0].id]);
|
||||
return { success:false,requires_confirmation:true,match_id:existing.rows[0].id,message_count:count.rows[0]?.count ?? 0 };
|
||||
}
|
||||
const normalized=q.question_type!=='swipe' && input.answer!=='no' ? (typeof input.responseData==='undefined' ? current.rows[0].response_data : input.responseData) : null;
|
||||
const before=existing.rows[0]; const match=await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,normalized,false);
|
||||
await client.query('update responses set answer=$3,response_data=$4 where user_id=$1 and question_id=$2',[userId,input.questionId,input.answer,normalized]);
|
||||
await this.touchStreak(client,userId,ctx.couple_id!);
|
||||
return { success:true, ...(before&&!match?{match_deleted:true}:{ }), ...(!before&&match?{new_match:this.dates(match)}:{}), ...(before&&match?{match_type_updated:true}:{}) };
|
||||
});
|
||||
}
|
||||
|
||||
private async reconcileMatch(client:PoolClient,userId:string,coupleId:string,q:QuestionRow,answer:Answer,data:ResponseData,isNew:boolean) {
|
||||
const partner=await client.query<ResponseRow>('select * from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[coupleId,q.id,userId]);
|
||||
if(!partner.rows[0]) return null;
|
||||
const type=calculateMatchType(q,answer,partner.rows[0].answer);
|
||||
if(!type){ await client.query('delete from matches where couple_id=$1 and question_id=$2',[coupleId,q.id]); return null; }
|
||||
const summary=type==='both_answered'?{[userId]:data,[partner.rows[0].user_id]:partner.rows[0].response_data}:null;
|
||||
const result=await client.query<MatchRow>(`insert into matches(couple_id,question_id,match_type,is_new,response_summary) values($1,$2,$3,true,$4)
|
||||
on conflict(couple_id,question_id) do update set match_type=excluded.match_type,response_summary=excluded.response_summary,is_new=case when $5 then true else matches.is_new end returning *`,[coupleId,q.id,type,summary,isNew]);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
private async touchStreak(client:PoolClient,userId:string,coupleId:string){
|
||||
const users=await client.query<{id:string}>('select id from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
|
||||
const user1=users.rows[0].id===userId;
|
||||
await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]);
|
||||
const locked=await client.query<{current_streak:number;longest_streak:number;last_active_date:string|null;last_completed_date:string|null;user1_answered_today:boolean;user2_answered_today:boolean}>(`select current_streak,longest_streak,last_active_date::text,last_completed_date::text,user1_answered_today,user2_answered_today from couple_streaks where couple_id=$1 for update`,[coupleId]);
|
||||
const row=locked.rows[0]; const todayResult=await client.query<{today:string;yesterday:string}>('select current_date::text today,(current_date-1)::text yesterday'); const {today,yesterday}=todayResult.rows[0];
|
||||
let first=row.last_active_date===today?row.user1_answered_today:false; let second=row.last_active_date===today?row.user2_answered_today:false;
|
||||
if(user1)first=true;else second=true;
|
||||
let current=row.last_completed_date && row.last_completed_date!==yesterday && row.last_completed_date!==today?0:row.current_streak; let completed=row.last_completed_date;
|
||||
if(first&&second&&completed!==today){current=completed===yesterday?current+1:1;completed=today;}
|
||||
await client.query(`update couple_streaks set current_streak=$2,longest_streak=greatest(longest_streak,$2),last_active_date=$3,last_completed_date=$4,user1_answered_today=$5,user2_answered_today=$6,updated_at=now() where couple_id=$1`,[coupleId,current,today,completed,first,second]);
|
||||
}
|
||||
|
||||
async responses(userId:string,page:number,limit:number){
|
||||
const ctx=await this.context(this.pool,userId); const offset=page*limit;
|
||||
const [rows,count]=await Promise.all([
|
||||
this.pool.query(`select r.id,r.question_id,r.answer,r.response_data,r.created_at,
|
||||
json_build_object('id',q.id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'pack_id',q.pack_id,'question_type',q.question_type,'config',q.config,'created_at',q.created_at,'pack',json_build_object('id',qp.id,'name',qp.name,'icon',qp.icon)) question,
|
||||
m.id is not null has_match,m.id match_id,pr.id is not null partner_answered
|
||||
from responses r join questions q on q.id=r.question_id join question_packs qp on qp.id=q.pack_id left join matches m on m.couple_id=r.couple_id and m.question_id=r.question_id left join responses pr on pr.couple_id=r.couple_id and pr.question_id=r.question_id and pr.user_id<>$1 where r.user_id=$1 and r.couple_id=$2 order by r.created_at desc limit $3 offset $4`,[userId,ctx.couple_id,limit,offset]),
|
||||
this.pool.query<{count:number}>('select count(*)::int count from responses where user_id=$1 and couple_id=$2',[userId,ctx.couple_id])]);
|
||||
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
||||
}
|
||||
|
||||
async matches(userId:string,page:number,limit:number,archived:boolean){
|
||||
const ctx=await this.context(this.pool,userId); const offset=page*limit;
|
||||
const params=[userId,ctx.couple_id,archived,limit,offset];
|
||||
const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
|
||||
const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]);
|
||||
return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
||||
}
|
||||
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
|
||||
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
|
||||
async streak(userId:string){const ctx=await this.context(this.pool,userId);const r=await this.pool.query('select * from couple_streaks where couple_id=$1',[ctx.couple_id]);return r.rows[0]?this.dates(r.rows[0]):null;}
|
||||
private async question(client:PoolClient,id:string,lock=false){const r=await client.query<QuestionRow>(`select * from questions where id=$1 and deleted_at is null${lock?' for update':''}`,[id]);if(!r.rows[0])throw new AnswersError('question_not_found',404,'Question was not found');return r.rows[0];}
|
||||
private async lockedContext(client:PoolClient,userId:string){await client.query('select id from profiles where id=$1 for update',[userId]);return this.context(client,userId);}
|
||||
private async dailyLimitWith(client:Pool|PoolClient,userId:string){
|
||||
const cfg=await this.config(client);if(cfg.daily_response_limit===0)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
|
||||
const premium=await client.query<{premium:boolean}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium',[userId]);if(premium.rows[0]?.premium)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
|
||||
const r=await client.query<{count:number;reset_at:string}>("select count(*)::int count,((date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')+interval '1 day') reset_at from responses where user_id=$1 and created_at>=(date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')",[userId]);const count=r.rows[0]?.count??0;return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:new Date(r.rows[0].reset_at).toISOString(),is_blocked:count>=cfg.daily_response_limit};
|
||||
}
|
||||
private async assertEligible(client:PoolClient,userId:string,ctx:ContextRow,q:QuestionRow){
|
||||
const partner=await client.query<ContextRow&{id:string}>('select id,couple_id,gender,max_intensity,is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1',[ctx.couple_id,userId]);const p=partner.rows[0];
|
||||
const cfg=await this.config(client);const max=cfg.couple_intensity_gate_enabled&&p?Math.min(ctx.max_intensity,p.max_intensity):ctx.max_intensity;const genders=ctx.gender&&p?.gender?[ctx.gender,p.gender].sort().join('+'):null;
|
||||
const allowed=!q.allowed_couple_genders||!genders||q.allowed_couple_genders.includes(genders);const targeted=!q.target_user_genders||!!ctx.gender&&q.target_user_genders.includes(ctx.gender)||!!(await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3',[ctx.couple_id,q.id,userId])).rowCount;
|
||||
const pack=await client.query<{is_public:boolean;is_premium:boolean;enabled:boolean|null;configured:boolean}>(`select qp.is_public,qp.is_premium,cp.enabled,exists(select 1 from couple_packs where couple_id=$2) configured from question_packs qp left join couple_packs cp on cp.pack_id=qp.id and cp.couple_id=$2 where qp.id=$1`,[q.pack_id,ctx.couple_id]);const pr=pack.rows[0];const active=!!pr&&(pr.configured?pr.enabled===true:pr.is_public);const premium=!pr?.is_premium||ctx.is_premium||!!p?.is_premium;
|
||||
if(!active||!premium||q.intensity>max||!allowed||!targeted)throw new AnswersError('question_not_eligible',404,'Question was not found');
|
||||
}
|
||||
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}}
|
||||
private dates<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
|
||||
async close(){await this.pool.end();}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import type { AnswersRepository } from './repository.js';
|
||||
import { AnswersError } from './types.js';
|
||||
|
||||
type App = Hono<{Variables:{identity:AuthIdentity}}>;
|
||||
const uuid=z.string().uuid(); const page=z.coerce.number().int().min(0).default(0); const limit=z.coerce.number().int().min(1).max(100).default(20);
|
||||
const responseData=z.record(z.unknown()).nullable();
|
||||
const submit=z.object({question_id:uuid,answer:z.enum(['yes','no','maybe']),response_data:responseData.optional()}).strict();
|
||||
const update=z.object({new_answer:z.enum(['yes','no','maybe']),response_data:responseData.optional(),confirm_delete_match:z.boolean().optional().default(false)}).strict();
|
||||
const err=(code:string,message:string,details?:Record<string,unknown>):ApiErrorResponse & {details?:Record<string,unknown>}=>({error:{code,message},...(details?{details}:{})});
|
||||
async function run<T>(op:()=>Promise<T>){try{return {ok:true as const,value:await op()};}catch(e){if(e instanceof AnswersError)return {ok:false as const,status:e.status,body:err(e.code,e.message,e.details)};throw e;}}
|
||||
|
||||
export function registerAnswerRoutes(app:App,repo:AnswersRepository):void{
|
||||
app.get('/v1/questions/recommended',async c=>{const packId=c.req.query('packId');if(packId&&!uuid.safeParse(packId).success)return c.json(err('invalid_pack_id','Pack ID must be a UUID'),400);const r=await run(()=>repo.recommended(c.get('identity').id,packId));return r.ok?c.json({questions:r.value}):c.json(r.body,r.status);});
|
||||
app.get('/v1/questions/pending',async c=>{const direction=c.req.query('direction')==='mine'?'mine':'partner';const start=c.req.query('startQuestionId');if(start&&!uuid.safeParse(start).success)return c.json(err('invalid_question_id','startQuestionId must be a UUID'),400);const r=await run(()=>repo.pending(c.get('identity').id,direction,start));return r.ok?c.json({questions:r.value}):c.json(r.body,r.status);});
|
||||
app.get('/v1/me/answer-gap',async c=>c.json(await repo.answerGap(c.get('identity').id)));
|
||||
app.get('/v1/me/daily-limit',async c=>c.json(await repo.dailyLimit(c.get('identity').id)));
|
||||
app.post('/v1/responses',async c=>{const b=submit.safeParse(await c.req.json().catch(()=>null));if(!b.success)return c.json(err('invalid_request','question_id, answer, and valid response_data are required'),400);const r=await run(()=>repo.submit(c.get('identity').id,{questionId:b.data.question_id,answer:b.data.answer,responseData:b.data.response_data}));return r.ok?c.json(r.value):c.json(r.body,r.status);});
|
||||
app.patch('/v1/responses/:questionId',async c=>{const id=uuid.safeParse(c.req.param('questionId'));const b=update.safeParse(await c.req.json().catch(()=>null));if(!id.success||!b.success)return c.json(err('invalid_request','A valid question ID and answer are required'),400);const r=await run(()=>repo.update(c.get('identity').id,{questionId:id.data,answer:b.data.new_answer,responseData:b.data.response_data,confirmDeleteMatch:b.data.confirm_delete_match}));return r.ok?c.json(r.value):c.json(r.body,r.status);});
|
||||
app.get('/v1/me/responses',async c=>{const p=page.safeParse(c.req.query('page')),l=limit.safeParse(c.req.query('limit'));if(!p.success||!l.success)return c.json(err('invalid_pagination','page and limit are out of range'),400);return c.json(await repo.responses(c.get('identity').id,p.data,l.data));});
|
||||
app.get('/v1/matches',async c=>{const p=page.safeParse(c.req.query('page')),l=limit.safeParse(c.req.query('limit'));if(!p.success||!l.success)return c.json(err('invalid_pagination','page and limit are out of range'),400);return c.json(await repo.matches(c.get('identity').id,p.data,l.data,c.req.query('archived')==='true'));});
|
||||
app.patch('/v1/matches/seen',async c=>{const b=z.object({ids:z.array(uuid).min(1)}).safeParse(await c.req.json().catch(()=>null));if(!b.success)return c.json(err('invalid_request','ids must contain valid match IDs'),400);await repo.markSeen(c.get('identity').id,b.data.ids);return c.json({success:true});});
|
||||
app.put('/v1/matches/:matchId/archive',async c=>{const id=uuid.safeParse(c.req.param('matchId'));const b=z.object({archived:z.boolean()}).safeParse(await c.req.json().catch(()=>null));if(!id.success||!b.success)return c.json(err('invalid_request','A valid match ID and archived flag are required'),400);const r=await run(()=>repo.archive(c.get('identity').id,id.data,b.data.archived));return r.ok?c.json({success:true}):c.json(r.body,r.status);});
|
||||
app.get('/v1/me/streak',async c=>{const r=await run(()=>repo.streak(c.get('identity').id));return r.ok?c.json({streak:r.value}):c.json(r.body,r.status);});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Registration contract: export this module from db/all-schema.ts when the
|
||||
// application's Drizzle schema becomes authoritative. Migration 0003 is the
|
||||
// current source of truth for this domain.
|
||||
export const answersSchemaVersion = 3 as const;
|
||||
@@ -0,0 +1,29 @@
|
||||
export type Answer = 'yes' | 'no' | 'maybe';
|
||||
export type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
||||
export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe' | 'both_answered';
|
||||
export type ResponseData = Record<string, unknown> | null;
|
||||
|
||||
export interface QuestionRef {
|
||||
id: string;
|
||||
question_type: QuestionType;
|
||||
}
|
||||
|
||||
export function calculateMatchType(question: QuestionRef, first: Answer, second: Answer): MatchType | null {
|
||||
if (question.question_type === 'swipe') {
|
||||
if (first === 'no' || second === 'no') return null;
|
||||
if (first === 'yes' && second === 'yes') return 'yes_yes';
|
||||
if (first === 'maybe' && second === 'maybe') return 'maybe_maybe';
|
||||
return 'yes_maybe';
|
||||
}
|
||||
if (question.question_type === 'who_likely') return 'both_answered';
|
||||
return first === 'no' || second === 'no' ? null : 'both_answered';
|
||||
}
|
||||
|
||||
export class AnswersError extends Error {
|
||||
constructor(
|
||||
readonly code: 'no_couple' | 'question_not_found' | 'question_not_eligible' | 'response_not_found' | 'daily_limit' | 'match_not_found',
|
||||
readonly status: 400 | 404 | 409 | 429,
|
||||
message: string,
|
||||
readonly details?: Record<string, unknown>,
|
||||
) { super(message); }
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
|
||||
export interface PackContext { id: string; name: string; icon: string | null }
|
||||
export interface AppQuestion {
|
||||
id: string; pack_id: string; text: string; partner_text: string | null; intensity: number;
|
||||
allowed_couple_genders: string[] | null; target_user_genders: string[] | null;
|
||||
question_type: string; config: Record<string, unknown>; created_at: string;
|
||||
}
|
||||
export interface MatchContext {
|
||||
id: string; question_id: string; couple_id: string; match_type: string; created_at: string;
|
||||
response_summary: Record<string, unknown> | null;
|
||||
question: { id: string; text: string; partner_text: string | null };
|
||||
responses: Array<{ user_id: string; answer: string; created_at: string; profiles: { name: string | null } }>;
|
||||
}
|
||||
export interface StrokePoint { x: number; y: number }
|
||||
export interface StrokeSegment {
|
||||
id: string; userId: string; points: StrokePoint[]; color: string; width: number;
|
||||
timestamp: number; isEraser: boolean;
|
||||
}
|
||||
export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null }
|
||||
|
||||
export class AppDataError extends Error {
|
||||
constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record<string, unknown>) { super(message); }
|
||||
}
|
||||
|
||||
export interface AppDataRepository {
|
||||
packContext(userId: string, packId: string): Promise<PackContext>;
|
||||
packQuestions(userId: string, packId: string): Promise<AppQuestion[]>;
|
||||
packTeaser(userId: string, packId: string): Promise<Array<Pick<AppQuestion, 'id' | 'text' | 'intensity'>>>;
|
||||
matchContext(userId: string, matchId: string): Promise<MatchContext>;
|
||||
markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>;
|
||||
nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>;
|
||||
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
|
||||
getLiveDraw(userId: string): Promise<LiveDrawState>;
|
||||
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
|
||||
|
||||
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
|
||||
|
||||
export class PostgresAppDataRepository implements AppDataRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
|
||||
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
|
||||
const result = await this.pool.query<PackContext>(`
|
||||
select qp.id, qp.name, qp.icon
|
||||
from question_packs qp
|
||||
left join categories c on c.id = qp.category_id
|
||||
left join profiles p on p.id = $1
|
||||
where qp.id = $2 and qp.is_public = true
|
||||
and (qp.category_id is null or c.is_public = true)
|
||||
and (qp.is_explicit = false or coalesce(p.hide_nsfw, true) = false)
|
||||
limit 1`, [userId, packId]);
|
||||
if (!result.rows[0]) throw new AppDataError('not_found', 'Pack not found', 404);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async packContext(userId: string, packId: string): Promise<PackContext> { return this.visiblePack(userId, packId); }
|
||||
|
||||
async packQuestions(userId: string, packId: string): Promise<AppQuestion[]> {
|
||||
await this.visiblePack(userId, packId);
|
||||
const result = await this.pool.query<QuestionRow>(`
|
||||
select id, pack_id, text, partner_text, intensity, allowed_couple_genders,
|
||||
target_user_genders, question_type, config, created_at
|
||||
from questions where pack_id = $1 and deleted_at is null
|
||||
order by created_at, id`, [packId]);
|
||||
return result.rows.map(question);
|
||||
}
|
||||
|
||||
async packTeaser(userId: string, packId: string) {
|
||||
await this.visiblePack(userId, packId);
|
||||
const result = await this.pool.query<{ id: string; text: string; intensity: number }>(`
|
||||
select id, text, intensity from questions
|
||||
where pack_id = $1 and deleted_at is null
|
||||
order by intensity, md5(id::text || current_date::text) limit 3`, [packId]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async matchContext(userId: string, matchId: string): Promise<MatchContext> {
|
||||
const match = await this.pool.query<{
|
||||
id: string; question_id: string; couple_id: string; match_type: string; created_at: Date;
|
||||
response_summary: Record<string, unknown> | null; question: MatchContext['question'];
|
||||
}>(`select m.id,m.question_id,m.couple_id,m.match_type,m.created_at,m.response_summary,
|
||||
json_build_object('id',q.id,'text',q.text,'partner_text',q.partner_text) question
|
||||
from matches m join questions q on q.id=m.question_id join profiles p on p.couple_id=m.couple_id
|
||||
where p.id=$1 and m.id=$2 limit 1`, [userId, matchId]);
|
||||
if (!match.rows[0]) throw new AppDataError('not_found', 'Match not found', 404);
|
||||
const responses = await this.pool.query<{
|
||||
user_id: string; answer: string; created_at: Date; profiles: { name: string | null };
|
||||
}>(`select r.user_id,r.answer,r.created_at,json_build_object('name',p.name) profiles
|
||||
from responses r join profiles p on p.id=r.user_id
|
||||
where r.question_id=$1 and r.couple_id=$2 order by r.created_at,r.id`, [match.rows[0].question_id, match.rows[0].couple_id]);
|
||||
return { ...match.rows[0], created_at: match.rows[0].created_at.toISOString(), responses: responses.rows.map(row => ({ ...row, created_at: row.created_at.toISOString() })) };
|
||||
}
|
||||
|
||||
async markMediaViewed(userId: string, messageId: string, expiresAt: string | null) {
|
||||
const result = await this.pool.query<{ media_viewed_at: Date; media_expires_at: Date | null }>(`
|
||||
update messages m set media_viewed_at=coalesce(media_viewed_at,now()),
|
||||
media_expires_at=case when m.media_type='video' then coalesce(media_expires_at,$3::timestamptz) else null end
|
||||
from matches ma join profiles p on p.couple_id=ma.couple_id
|
||||
where m.match_id=ma.id and p.id=$1 and m.id=$2 and m.user_id<>$1
|
||||
returning m.media_viewed_at,m.media_expires_at`, [userId, messageId, expiresAt]);
|
||||
if (!result.rows[0]) throw new AppDataError('not_recipient', 'Message not found', 404);
|
||||
return { media_viewed_at: result.rows[0].media_viewed_at.toISOString(), media_expires_at: result.rows[0].media_expires_at?.toISOString() ?? null };
|
||||
}
|
||||
|
||||
async nudgeStatus(userId: string) {
|
||||
const result = await this.pool.query<{ last_nudge_sent_at: Date | null }>('select last_nudge_sent_at from profiles where id=$1', [userId]);
|
||||
return { last_nudge_sent_at: result.rows[0]?.last_nudge_sent_at?.toISOString() ?? null };
|
||||
}
|
||||
|
||||
async sendNudge(userId: string) {
|
||||
const client = await this.pool.connect();
|
||||
let senderName: string | null = null; let partnerToken: string | null = null; let nudgesEnabled = true; let nextAvailable = '';
|
||||
try {
|
||||
await client.query('begin');
|
||||
const sender = await client.query<{ name: string | null; couple_id: string | null; last_nudge_sent_at: Date | null }>(
|
||||
'select name,couple_id,last_nudge_sent_at from profiles where id=$1 for update', [userId]);
|
||||
if (!sender.rows[0]) throw new AppDataError('not_found', 'Profile not found', 404);
|
||||
if (!sender.rows[0].couple_id) throw new AppDataError('no_couple', 'Join a couple first', 409);
|
||||
const now = new Date(); const last = sender.rows[0].last_nudge_sent_at;
|
||||
if (last && now.getTime() - last.getTime() < 12 * 60 * 60 * 1000) {
|
||||
const next = new Date(last.getTime() + 12 * 60 * 60 * 1000);
|
||||
throw new AppDataError('rate_limited', 'Nudge cooldown is active', 429, {
|
||||
cooldown_remaining_seconds: Math.ceil((next.getTime() - now.getTime()) / 1000), next_nudge_available_at: next.toISOString(),
|
||||
});
|
||||
}
|
||||
const partner = await client.query<{ id: string; push_token: string | null; nudges_enabled: boolean | null }>(`
|
||||
select p.id,p.push_token,np.nudges_enabled from profiles p
|
||||
left join notification_preferences np on np.user_id=p.id
|
||||
where p.couple_id=$1 and p.id<>$2 order by p.id limit 1`, [sender.rows[0].couple_id, userId]);
|
||||
if (!partner.rows[0]) throw new AppDataError('not_found', 'Partner not found', 404);
|
||||
await client.query('update profiles set last_nudge_sent_at=$2 where id=$1', [userId, now]);
|
||||
await client.query('commit');
|
||||
senderName = sender.rows[0].name; partnerToken = partner.rows[0].push_token; nudgesEnabled = partner.rows[0].nudges_enabled !== false;
|
||||
nextAvailable = new Date(now.getTime() + 12 * 60 * 60 * 1000).toISOString();
|
||||
} catch (cause) {
|
||||
await client.query('rollback').catch(() => undefined);
|
||||
throw cause;
|
||||
} finally { client.release(); }
|
||||
if (!partnerToken) return { success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: nextAvailable };
|
||||
if (!nudgesEnabled) return { success: true as const, notification_sent: false, reason: 'nudges_disabled', next_nudge_available_at: nextAvailable };
|
||||
const response = await fetch('https://exp.host/--/api/v2/push/send', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ to: partnerToken, title: 'Partner nudge', body: `${senderName || 'Your partner'} wants you to catch up!`, sound: 'default', data: { type: 'nudge' } }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Expo push request failed with status ${response.status}`);
|
||||
return { success: true as const, notification_sent: true, next_nudge_available_at: nextAvailable };
|
||||
}
|
||||
|
||||
private async coupleId(userId: string): Promise<string> {
|
||||
const result = await this.pool.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [userId]);
|
||||
const coupleId = result.rows[0]?.couple_id;
|
||||
if (!coupleId) throw new AppDataError('no_couple', 'Join a couple first', 409);
|
||||
return coupleId;
|
||||
}
|
||||
|
||||
async getLiveDraw(userId: string): Promise<LiveDrawState> {
|
||||
const coupleId = await this.coupleId(userId);
|
||||
const result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(
|
||||
'select strokes,revision::text,updated_at,updated_by from live_draw_sessions where couple_id=$1', [coupleId]);
|
||||
const row = result.rows[0];
|
||||
return row ? { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by } : { strokes: [], revision: 0, updated_at: null, updated_by: null };
|
||||
}
|
||||
|
||||
async putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState> {
|
||||
const coupleId = await this.coupleId(userId);
|
||||
const memberIds = await this.pool.query<{ id: string }>('select id from profiles where couple_id=$1', [coupleId]);
|
||||
const allowed = new Set(memberIds.rows.map(row => row.id));
|
||||
if (strokes.some(stroke => !allowed.has(stroke.userId))) {
|
||||
throw new AppDataError('not_recipient', 'Drawing contains a stroke from outside this couple', 403);
|
||||
}
|
||||
let result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(`
|
||||
update live_draw_sessions set strokes=$2::jsonb,updated_by=$3,revision=revision+1,updated_at=now()
|
||||
where couple_id=$1 and revision=$4
|
||||
returning strokes,revision::text,updated_at,updated_by`, [coupleId, JSON.stringify(strokes), userId, baseRevision]);
|
||||
if (!result.rows[0] && baseRevision === 0) {
|
||||
result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(`
|
||||
insert into live_draw_sessions(couple_id,strokes,updated_by) values($1,$2::jsonb,$3)
|
||||
on conflict(couple_id) do nothing returning strokes,revision::text,updated_at,updated_by`, [coupleId, JSON.stringify(strokes), userId]);
|
||||
}
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
const current = await this.getLiveDraw(userId);
|
||||
throw new AppDataError('revision_conflict', 'Drawing changed on another device', 409, { current_state: current });
|
||||
}
|
||||
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
|
||||
}
|
||||
async close(): Promise<void> { await this.pool.end(); }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { AppDataError, type AppDataRepository } from './repository.js';
|
||||
|
||||
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
const uuid = z.string().uuid();
|
||||
const point = z.object({ x: z.number().finite().min(0).max(1), y: z.number().finite().min(0).max(1) }).strict();
|
||||
const stroke = z.object({
|
||||
id: z.string().min(1).max(120), userId: uuid, points: z.array(point).max(20_000),
|
||||
color: z.string().min(1).max(64), width: z.number().finite().positive().max(100),
|
||||
timestamp: z.number().finite().nonnegative(), isEraser: z.boolean(),
|
||||
}).strict();
|
||||
const liveDrawBody = z.object({ strokes: z.array(stroke).max(2_000), base_revision: z.number().int().nonnegative() }).strict();
|
||||
const viewedBody = z.object({ expires_at: z.string().datetime().nullable() }).strict();
|
||||
const error = (code: string, message: string): ApiErrorResponse => ({ error: { code, message } });
|
||||
async function run<T>(op: () => Promise<T>) { try { return { ok: true as const, value: await op() }; } catch (cause) { if (cause instanceof AppDataError) return { ok: false as const, cause }; throw cause; } }
|
||||
|
||||
export function registerAppDataRoutes(app: App, repository: AppDataRepository): void {
|
||||
app.get('/v1/packs/:packId/context', async c => {
|
||||
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
|
||||
const result = await run(() => repository.packContext(c.get('identity').id, id.data));
|
||||
return result.ok ? c.json({ pack: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.get('/v1/packs/:packId/questions', async c => {
|
||||
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
|
||||
const result = await run(() => repository.packQuestions(c.get('identity').id, id.data));
|
||||
return result.ok ? c.json({ questions: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.get('/v1/packs/:packId/teaser', async c => {
|
||||
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
|
||||
const result = await run(() => repository.packTeaser(c.get('identity').id, id.data));
|
||||
return result.ok ? c.json({ questions: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.get('/v1/matches/:matchId/context', async c => {
|
||||
const id = uuid.safeParse(c.req.param('matchId')); if (!id.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
||||
const result = await run(() => repository.matchContext(c.get('identity').id, id.data));
|
||||
return result.ok ? c.json({ match: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.patch('/v1/messages/:messageId/media-viewed', async c => {
|
||||
const id = uuid.safeParse(c.req.param('messageId')); const body = viewedBody.safeParse(await c.req.json().catch(() => null));
|
||||
if (!id.success || !body.success) return c.json(error('invalid_request', 'A valid message ID and expires_at are required'), 400);
|
||||
const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at));
|
||||
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
|
||||
app.post('/v1/me/nudge', async c => {
|
||||
const result = await run(() => repository.sendNudge(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json({ ...error(result.cause.code, result.cause.message), ...result.cause.details }, result.cause.status);
|
||||
});
|
||||
app.get('/v1/live-draw', async c => { const result = await run(() => repository.getLiveDraw(c.get('identity').id)); return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status); });
|
||||
app.put('/v1/live-draw', async c => {
|
||||
const body = liveDrawBody.safeParse(await c.req.json().catch(() => null)); if (!body.success) return c.json(error('invalid_strokes', 'A valid strokes array is required'), 400);
|
||||
const result = await run(() => repository.putLiveDraw(c.get('identity').id, body.data.strokes, body.data.base_revision));
|
||||
return result.ok ? c.json(result.value) : c.json({ ...error(result.cause.code, result.cause.message), ...result.cause.details }, result.cause.status);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { bigint, index, jsonb, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { profiles } from '../../db/schema.js';
|
||||
import { couples } from '../couples/schema.js';
|
||||
|
||||
export const liveDrawSessions = pgTable('live_draw_sessions', {
|
||||
coupleId: uuid('couple_id').primaryKey().references(() => couples.id, { onDelete: 'cascade' }),
|
||||
strokes: jsonb('strokes').notNull().default([]),
|
||||
revision: bigint('revision', { mode: 'number' }).notNull().default(1),
|
||||
updatedBy: uuid('updated_by').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [index('live_draw_sessions_updated_at_idx').on(table.updatedAt)]);
|
||||
@@ -0,0 +1,35 @@
|
||||
# Billing and redemption
|
||||
|
||||
This domain owns RevenueCat webhook persistence and public promotional-code
|
||||
redemption. Neither route uses the hosted Supabase data plane.
|
||||
|
||||
- `POST /webhooks/revenuecat` requires an exact
|
||||
`Authorization: Bearer <REVENUECAT_WEBHOOK_SECRET>` header. A missing server
|
||||
secret disables the webhook with `503`; it never becomes unauthenticated.
|
||||
- `POST /public/v1/redemptions` accepts `{ email, code }` for the public web
|
||||
redemption form. Validation, remaining-use checks, redemption recording, and
|
||||
the premium grant run in one database transaction.
|
||||
|
||||
Both public endpoints have request-body limits (1 MiB for provider webhooks and
|
||||
16 KiB for redemption). Webhook authorization is checked before JSON parsing.
|
||||
Because the API cannot independently authenticate a proxy-supplied client IP,
|
||||
redemption also uses a database-backed global ceiling of 60 attempts per minute
|
||||
across all replicas. The production reverse proxy must additionally apply a
|
||||
per-client-IP limit; forwarded-IP headers must not be trusted from the public
|
||||
internet unless the proxy strips and rewrites them.
|
||||
|
||||
Webhook event insertion, subscription upsert, premium recomputation, and any
|
||||
premium-pack disabling are atomic. The unique event ID makes provider retries
|
||||
idempotent. A known event for an unknown user rolls back the event record so a
|
||||
later retry can succeed after identity reconciliation. Unknown and `TEST` events
|
||||
are recorded once and acknowledged without changing subscription state.
|
||||
|
||||
Wire a `PostgresBillingRepository`, construct `BillingService` with the
|
||||
server-only `REVENUECAT_WEBHOOK_SECRET`, call `registerBillingRoutes`, and close
|
||||
the repository during graceful shutdown. The web app should receive the API base
|
||||
URL via `NEXT_PUBLIC_API_URL`; no server secret belongs in a `NEXT_PUBLIC_*`
|
||||
variable.
|
||||
|
||||
Rate-limit the public redemption endpoint by client IP at the trusted reverse
|
||||
proxy. The API limits request size and atomically locks codes, but deliberately
|
||||
does not trust arbitrary forwarded-IP headers on its own.
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './repository.js';
|
||||
export * from './routes.js';
|
||||
export * from './service.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
|
||||
|
||||
interface ProfileRecord extends QueryResultRow {
|
||||
id: string;
|
||||
couple_id: string | null;
|
||||
is_premium: boolean;
|
||||
}
|
||||
|
||||
interface CodeRecord extends QueryResultRow {
|
||||
id: string;
|
||||
max_uses: number | null;
|
||||
current_uses: number;
|
||||
expires_at: Date | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface WebhookResult {
|
||||
duplicate: boolean;
|
||||
handled: boolean;
|
||||
}
|
||||
|
||||
export interface BillingRepository {
|
||||
processRevenueCatEvent(payload: RevenueCatWebhook, status: SubscriptionStatus | null): Promise<WebhookResult>;
|
||||
redeem(email: string, code: string): Promise<RedemptionResult>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const result = await operation(client);
|
||||
await client.query('commit');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresBillingRepository implements BillingRepository {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
}
|
||||
|
||||
async processRevenueCatEvent(
|
||||
payload: RevenueCatWebhook,
|
||||
status: SubscriptionStatus | null,
|
||||
): Promise<WebhookResult> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const event = payload.event;
|
||||
const inserted = await client.query(
|
||||
`insert into revenuecat_webhook_events (event_id, event_type, app_user_id, payload)
|
||||
values ($1, $2, $3, $4::jsonb)
|
||||
on conflict (event_id) do nothing`,
|
||||
[event.id, event.type, event.app_user_id, JSON.stringify(payload)],
|
||||
);
|
||||
if (inserted.rowCount === 0) return { duplicate: true, handled: status !== null };
|
||||
if (status === null) return { duplicate: false, handled: false };
|
||||
|
||||
const profileResult = await client.query<ProfileRecord>(
|
||||
'select id, couple_id, is_premium from profiles where id = $1 for update',
|
||||
[event.app_user_id],
|
||||
);
|
||||
const profile = profileResult.rows[0];
|
||||
if (!profile) throw new BillingError('user_not_found', 'User not found', 404);
|
||||
|
||||
await client.query(
|
||||
`insert into subscriptions (
|
||||
user_id, revenuecat_app_user_id, product_id, status, entitlement_ids,
|
||||
purchased_at, expires_at, original_transaction_id, store, is_sandbox,
|
||||
cancel_reason, grace_period_expires_at
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
on conflict (user_id, original_transaction_id) do update set
|
||||
revenuecat_app_user_id = excluded.revenuecat_app_user_id,
|
||||
product_id = excluded.product_id,
|
||||
status = excluded.status,
|
||||
entitlement_ids = excluded.entitlement_ids,
|
||||
purchased_at = excluded.purchased_at,
|
||||
expires_at = excluded.expires_at,
|
||||
store = excluded.store,
|
||||
is_sandbox = excluded.is_sandbox,
|
||||
cancel_reason = excluded.cancel_reason,
|
||||
grace_period_expires_at = excluded.grace_period_expires_at,
|
||||
updated_at = now()`,
|
||||
[
|
||||
event.app_user_id,
|
||||
event.app_user_id,
|
||||
event.product_id,
|
||||
status,
|
||||
event.entitlement_ids,
|
||||
new Date(event.purchased_at_ms),
|
||||
event.expiration_at_ms === undefined ? null : new Date(event.expiration_at_ms),
|
||||
event.original_transaction_id,
|
||||
event.store,
|
||||
event.environment !== 'PRODUCTION',
|
||||
event.cancel_reason ?? null,
|
||||
event.grace_period_expiration_at_ms === undefined ? null : new Date(event.grace_period_expiration_at_ms),
|
||||
],
|
||||
);
|
||||
|
||||
const premiumResult = await client.query<{ premium: boolean }>(
|
||||
`select exists (
|
||||
select 1 from subscriptions
|
||||
where user_id = $1 and status = 'active'
|
||||
and (expires_at is null or expires_at > now())
|
||||
) as premium`,
|
||||
[event.app_user_id],
|
||||
);
|
||||
const isPremium = premiumResult.rows[0]?.premium ?? false;
|
||||
await client.query(
|
||||
'update profiles set is_premium = $2, updated_at = now() where id = $1',
|
||||
[event.app_user_id, isPremium],
|
||||
);
|
||||
|
||||
if (profile.is_premium && !isPremium && profile.couple_id) {
|
||||
const partnerPremium = await client.query<{ premium: boolean }>(
|
||||
`select exists (
|
||||
select 1 from profiles
|
||||
where couple_id = $1 and id <> $2 and is_premium = true
|
||||
) as premium`,
|
||||
[profile.couple_id, event.app_user_id],
|
||||
);
|
||||
if (!partnerPremium.rows[0]?.premium) {
|
||||
await client.query(
|
||||
`update couple_packs cp set enabled = false
|
||||
from question_packs qp
|
||||
where cp.pack_id = qp.id and qp.is_premium = true
|
||||
and cp.couple_id = $1 and cp.enabled = true`,
|
||||
[profile.couple_id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { duplicate: false, handled: true };
|
||||
});
|
||||
}
|
||||
|
||||
async redeem(email: string, code: string): Promise<RedemptionResult> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const allowance = await client.query(
|
||||
`insert into redemption_rate_limits (bucket, attempts)
|
||||
values (date_trunc('minute', now()), 1)
|
||||
on conflict (bucket) do update
|
||||
set attempts = redemption_rate_limits.attempts + 1
|
||||
where redemption_rate_limits.attempts < 60
|
||||
returning attempts`,
|
||||
);
|
||||
if (allowance.rowCount === 0) {
|
||||
throw new BillingError('redemption_rate_limited', 'Too many redemption attempts. Please try again shortly.', 429);
|
||||
}
|
||||
await client.query(
|
||||
`delete from redemption_rate_limits where bucket < date_trunc('minute', now()) - interval '1 day'`,
|
||||
);
|
||||
|
||||
const profileResult = await client.query<ProfileRecord>(
|
||||
`select id, couple_id, is_premium from profiles
|
||||
where lower(email) = lower($1)
|
||||
order by created_at asc
|
||||
limit 1
|
||||
for update`,
|
||||
[email],
|
||||
);
|
||||
const profile = profileResult.rows[0];
|
||||
if (!profile) return { success: false, error: 'No account found with this email address' };
|
||||
|
||||
const codeResult = await client.query<CodeRecord>(
|
||||
`select id, max_uses, current_uses, expires_at, is_active
|
||||
from redemption_codes where upper(code) = upper($1) for update`,
|
||||
[code],
|
||||
);
|
||||
const redemptionCode = codeResult.rows[0];
|
||||
if (!redemptionCode) return { success: false, error: 'Invalid redemption code' };
|
||||
if (!redemptionCode.is_active) return { success: false, error: 'This code is no longer active' };
|
||||
if (redemptionCode.expires_at && redemptionCode.expires_at < new Date()) {
|
||||
return { success: false, error: 'This code has expired' };
|
||||
}
|
||||
if (redemptionCode.max_uses !== null && redemptionCode.current_uses >= redemptionCode.max_uses) {
|
||||
return { success: false, error: 'This code has reached its maximum number of uses' };
|
||||
}
|
||||
|
||||
const existing = await client.query(
|
||||
'select 1 from code_redemptions where code_id = $1 and user_id = $2',
|
||||
[redemptionCode.id, profile.id],
|
||||
);
|
||||
if (existing.rowCount) return { success: false, error: 'You have already redeemed this code' };
|
||||
|
||||
await client.query(
|
||||
'insert into code_redemptions (code_id, user_id) values ($1, $2)',
|
||||
[redemptionCode.id, profile.id],
|
||||
);
|
||||
await client.query(
|
||||
'update redemption_codes set current_uses = current_uses + 1, updated_at = now() where id = $1',
|
||||
[redemptionCode.id],
|
||||
);
|
||||
await client.query(
|
||||
'update profiles set is_premium = true, updated_at = now() where id = $1',
|
||||
[profile.id],
|
||||
);
|
||||
return { success: true, message: 'Code redeemed successfully! You now have premium access.' };
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Hono } from 'hono';
|
||||
import { bodyLimit } from 'hono/body-limit';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import type { BillingService } from './service.js';
|
||||
import { BillingError } from './types.js';
|
||||
|
||||
const redemptionSchema = z.object({
|
||||
email: z.string().trim().min(1, 'Please enter your email address').max(320),
|
||||
code: z.string().trim().min(1, 'Please enter a redemption code').max(255),
|
||||
}).strict();
|
||||
|
||||
type ApiApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
|
||||
export function registerBillingRoutes(app: ApiApp, service: BillingService): void {
|
||||
app.use('/webhooks/revenuecat', bodyLimit({
|
||||
maxSize: 1024 * 1024,
|
||||
onError: (c) => c.json({ error: 'Webhook payload is too large' }, 413),
|
||||
}));
|
||||
app.post('/webhooks/revenuecat', async (c) => {
|
||||
try {
|
||||
service.assertWebhookAuthorization(c.req.header('authorization'));
|
||||
const rawBody: unknown = await c.req.json().catch(() => null);
|
||||
return c.json(await service.processWebhook(c.req.header('authorization'), rawBody));
|
||||
} catch (cause) {
|
||||
if (cause instanceof BillingError) return c.json({ error: cause.message }, cause.status);
|
||||
throw cause;
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/public/v1/redemptions', bodyLimit({
|
||||
maxSize: 16 * 1024,
|
||||
onError: (c) => c.json({ success: false, error: 'Request is too large' }, 413),
|
||||
}));
|
||||
app.post('/public/v1/redemptions', async (c) => {
|
||||
try {
|
||||
const rawBody: unknown = await c.req.json().catch(() => null);
|
||||
const parsed = redemptionSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return c.json({ success: false, error: parsed.error.issues[0]?.message ?? 'Invalid request' }, 400);
|
||||
}
|
||||
const result = await service.redeem(parsed.data.email, parsed.data.code);
|
||||
return c.json(result, result.success ? 200 : 400);
|
||||
} catch (cause) {
|
||||
if (cause instanceof BillingError) return c.json({ success: false, error: cause.message }, cause.status);
|
||||
throw cause;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { BillingRepository } from './repository.js';
|
||||
import {
|
||||
BillingError,
|
||||
type BillingResult,
|
||||
type RedemptionResult,
|
||||
revenueCatWebhookSchema,
|
||||
type SubscriptionStatus,
|
||||
} from './types.js';
|
||||
|
||||
const statusByEvent: Record<string, SubscriptionStatus> = {
|
||||
INITIAL_PURCHASE: 'active',
|
||||
RENEWAL: 'active',
|
||||
UNCANCELLATION: 'active',
|
||||
PRODUCT_CHANGE: 'active',
|
||||
CANCELLATION: 'cancelled',
|
||||
EXPIRATION: 'expired',
|
||||
BILLING_ISSUE: 'billing_issue',
|
||||
SUBSCRIPTION_PAUSED: 'paused',
|
||||
};
|
||||
|
||||
function secureEqual(actual: string, expected: string): boolean {
|
||||
const actualBytes = Buffer.from(actual);
|
||||
const expectedBytes = Buffer.from(expected);
|
||||
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
||||
}
|
||||
|
||||
export class BillingService {
|
||||
constructor(
|
||||
private readonly repository: BillingRepository,
|
||||
private readonly revenueCatWebhookSecret: string | undefined,
|
||||
) {}
|
||||
|
||||
assertWebhookAuthorization(authorization: string | undefined): void {
|
||||
if (!this.revenueCatWebhookSecret) {
|
||||
throw new BillingError('webhook_unavailable', 'Webhook is not configured', 503);
|
||||
}
|
||||
if (!authorization || !secureEqual(authorization, `Bearer ${this.revenueCatWebhookSecret}`)) {
|
||||
throw new BillingError('unauthorized', 'Unauthorized', 401);
|
||||
}
|
||||
}
|
||||
|
||||
async processWebhook(authorization: string | undefined, rawPayload: unknown): Promise<BillingResult> {
|
||||
this.assertWebhookAuthorization(authorization);
|
||||
|
||||
const parsed = revenueCatWebhookSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) throw new BillingError('invalid_payload', 'Invalid webhook payload', 400);
|
||||
const payload = parsed.data;
|
||||
|
||||
const status = statusByEvent[payload.event.type] ?? null;
|
||||
const result = await this.repository.processRevenueCatEvent(payload, status);
|
||||
if (result.duplicate) return { success: true, message: 'Duplicate event' };
|
||||
if (payload.event.type === 'TEST') return { success: true, message: 'Test event received' };
|
||||
if (!result.handled) return { success: true, message: 'Event type not handled' };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
redeem(email: string, code: string): Promise<RedemptionResult> {
|
||||
return this.repository.redeem(email.trim(), code.trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const revenueCatWebhookSchema = z.object({
|
||||
api_version: z.string().min(1),
|
||||
event: z.object({
|
||||
type: z.string().min(1),
|
||||
id: z.string().min(1).max(255),
|
||||
app_user_id: z.string().uuid(),
|
||||
original_app_user_id: z.string().min(1),
|
||||
product_id: z.string().min(1),
|
||||
entitlement_ids: z.array(z.string()),
|
||||
purchased_at_ms: z.number().int().nonnegative(),
|
||||
expiration_at_ms: z.number().int().nonnegative().optional(),
|
||||
original_transaction_id: z.string().min(1),
|
||||
store: z.string().min(1),
|
||||
environment: z.string().min(1),
|
||||
cancel_reason: z.string().optional(),
|
||||
grace_period_expiration_at_ms: z.number().int().nonnegative().optional(),
|
||||
}).passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
export type RevenueCatWebhook = z.infer<typeof revenueCatWebhookSchema>;
|
||||
export type SubscriptionStatus = 'active' | 'cancelled' | 'expired' | 'billing_issue' | 'paused';
|
||||
|
||||
export interface BillingResult {
|
||||
success: true;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type RedemptionResult =
|
||||
| { success: true; message: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export class BillingError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly status: 400 | 401 | 404 | 429 | 503,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BillingError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
|
||||
export type ChatMediaType = 'image' | 'video' | null;
|
||||
export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
match_id: string;
|
||||
user_id: string;
|
||||
content: string | null;
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
delivered_at: string | null;
|
||||
deleted_at: string | null;
|
||||
media_path: string | null;
|
||||
media_type: ChatMediaType;
|
||||
media_expires_at: string | null;
|
||||
media_expired: boolean;
|
||||
media_viewed_at: string | null;
|
||||
version: number;
|
||||
encrypted_content: string | null;
|
||||
encryption_iv: string | null;
|
||||
keys_metadata: Record<string, unknown> | null;
|
||||
moderation_status: string | null;
|
||||
flag_reason: string | null;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
interface MessageRecord extends QueryResultRow, Omit<ChatMessage, 'created_at' | 'read_at' | 'delivered_at' | 'deleted_at' | 'media_expires_at' | 'media_viewed_at'> {
|
||||
created_at: Date;
|
||||
read_at: Date | null;
|
||||
delivered_at: Date | null;
|
||||
deleted_at: Date | null;
|
||||
media_expires_at: Date | null;
|
||||
media_viewed_at: Date | null;
|
||||
}
|
||||
|
||||
export class ChatError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly status: 400 | 403 | 404 | 409,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatRepository {
|
||||
listMessages(userId: string, matchId: string): Promise<ChatMessage[]>;
|
||||
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
|
||||
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
|
||||
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
|
||||
markDelivered(userId: string, messageId: string): Promise<ChatMessage>;
|
||||
deleteForSelf(userId: string, messageId: string): Promise<void>;
|
||||
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
|
||||
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
|
||||
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
|
||||
getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at,
|
||||
m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at,
|
||||
m.media_expired, m.media_viewed_at, m.version, m.encrypted_content,
|
||||
m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`;
|
||||
|
||||
function iso(value: Date | null): string | null {
|
||||
return value?.toISOString() ?? null;
|
||||
}
|
||||
|
||||
function toMessage(row: MessageRecord): ChatMessage {
|
||||
return {
|
||||
...row,
|
||||
created_at: row.created_at.toISOString(),
|
||||
read_at: iso(row.read_at),
|
||||
delivered_at: iso(row.delivered_at),
|
||||
deleted_at: iso(row.deleted_at),
|
||||
media_expires_at: iso(row.media_expires_at),
|
||||
media_viewed_at: iso(row.media_viewed_at),
|
||||
};
|
||||
}
|
||||
|
||||
export class PostgresChatRepository implements ChatRepository {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
}
|
||||
|
||||
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
|
||||
const result = await this.pool.query(
|
||||
`select 1
|
||||
from profiles p
|
||||
join matches ma on ma.couple_id = p.couple_id
|
||||
where p.id = $1 and ma.id = $2`,
|
||||
[userId, matchId],
|
||||
);
|
||||
if (!result.rowCount) throw new ChatError('match_not_found', 'Match not found', 404);
|
||||
}
|
||||
|
||||
private async accessibleMessage(userId: string, messageId: string): Promise<MessageRecord> {
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`select ${columns}
|
||||
from messages m
|
||||
join matches ma on ma.id = m.match_id
|
||||
join profiles p on p.couple_id = ma.couple_id
|
||||
where p.id = $1 and m.id = $2`,
|
||||
[userId, messageId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new ChatError('message_not_found', 'Message not found', 404);
|
||||
return row;
|
||||
}
|
||||
|
||||
async listMessages(userId: string, matchId: string): Promise<ChatMessage[]> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`select ${columns}
|
||||
from messages m
|
||||
where m.match_id = $1
|
||||
and not exists (
|
||||
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
|
||||
)
|
||||
order by m.created_at desc, m.id desc
|
||||
limit 500`,
|
||||
[matchId, userId],
|
||||
);
|
||||
return result.rows.map(toMessage);
|
||||
}
|
||||
|
||||
async sendText(userId: string, matchId: string, content: string): Promise<ChatMessage> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`insert into messages (match_id, user_id, content, version)
|
||||
values ($1, $2, $3, 1)
|
||||
returning id, match_id, user_id, content, created_at, read_at, delivered_at,
|
||||
deleted_at, media_path, media_type, media_expires_at, media_expired,
|
||||
media_viewed_at, version, encrypted_content, encryption_iv, keys_metadata,
|
||||
moderation_status, flag_reason, category`,
|
||||
[matchId, userId, content],
|
||||
);
|
||||
return toMessage(result.rows[0]!);
|
||||
}
|
||||
|
||||
async unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }> {
|
||||
const result = await this.pool.query<{ match_id: string; count: string }>(
|
||||
`select m.match_id, count(*)::text as count
|
||||
from messages m
|
||||
join matches ma on ma.id = m.match_id
|
||||
join profiles p on p.couple_id = ma.couple_id
|
||||
where p.id = $1 and m.user_id <> $1 and m.read_at is null
|
||||
and m.deleted_at is null
|
||||
and not exists (
|
||||
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $1
|
||||
)
|
||||
group by m.match_id`,
|
||||
[userId],
|
||||
);
|
||||
const by_match = Object.fromEntries(result.rows.map((row) => [row.match_id, Number(row.count)]));
|
||||
return { total: Object.values(by_match).reduce((sum, count) => sum + count, 0), by_match };
|
||||
}
|
||||
|
||||
async markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
const readAt = new Date();
|
||||
const result = await this.pool.query(
|
||||
`update messages
|
||||
set delivered_at = coalesce(delivered_at, $3), read_at = $3
|
||||
where match_id = $1 and user_id <> $2 and read_at is null`,
|
||||
[matchId, userId, readAt],
|
||||
);
|
||||
return { updated: result.rowCount ?? 0, read_at: readAt.toISOString() };
|
||||
}
|
||||
|
||||
async markDelivered(userId: string, messageId: string): Promise<ChatMessage> {
|
||||
const message = await this.accessibleMessage(userId, messageId);
|
||||
if (message.user_id === userId) throw new ChatError('invalid_recipient', 'Senders cannot mark their own message delivered', 403);
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`update messages m set delivered_at = coalesce(m.delivered_at, now()) where m.id = $1 returning ${columns}`,
|
||||
[messageId],
|
||||
);
|
||||
return toMessage(result.rows[0]!);
|
||||
}
|
||||
|
||||
async deleteForSelf(userId: string, messageId: string): Promise<void> {
|
||||
await this.accessibleMessage(userId, messageId);
|
||||
await this.pool.query(
|
||||
`insert into message_deletions (message_id, user_id) values ($1, $2) on conflict do nothing`,
|
||||
[messageId, userId],
|
||||
);
|
||||
}
|
||||
|
||||
async deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage> {
|
||||
const message = await this.accessibleMessage(userId, messageId);
|
||||
if (message.user_id !== userId) throw new ChatError('not_message_author', 'Only the sender can delete a message for everyone', 403);
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`update messages m set deleted_at = coalesce(m.deleted_at, now()) where m.id = $1 returning ${columns}`,
|
||||
[messageId],
|
||||
);
|
||||
return toMessage(result.rows[0]!);
|
||||
}
|
||||
|
||||
async report(userId: string, messageId: string, reason: ReportReason): Promise<void> {
|
||||
const message = await this.accessibleMessage(userId, messageId);
|
||||
if (message.user_id === userId) throw new ChatError('cannot_report_own_message', 'You cannot report your own message', 403);
|
||||
try {
|
||||
await this.pool.query(
|
||||
`insert into message_reports (message_id, reporter_id, reason) values ($1, $2, $3)`,
|
||||
[messageId, userId, reason],
|
||||
);
|
||||
} catch (cause) {
|
||||
if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === '23505') {
|
||||
throw new ChatError('already_reported', 'You have already reported this message', 409);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
async setTyping(userId: string, matchId: string, ttlMs: number): Promise<void> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
await this.pool.query(
|
||||
`insert into chat_typing_states (match_id, user_id, expires_at)
|
||||
values ($1, $2, now() + ($3 * interval '1 millisecond'))
|
||||
on conflict (match_id, user_id) do update set expires_at = excluded.expires_at`,
|
||||
[matchId, userId, ttlMs],
|
||||
);
|
||||
}
|
||||
|
||||
async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
const result = await this.pool.query<{ expires_at: Date }>(
|
||||
`select expires_at from chat_typing_states
|
||||
where match_id = $1 and user_id <> $2 and expires_at > now()
|
||||
order by expires_at desc limit 1`,
|
||||
[matchId, userId],
|
||||
);
|
||||
const expiresAt = result.rows[0]?.expires_at ?? null;
|
||||
return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { ChatError, type ChatRepository } from './repository.js';
|
||||
|
||||
type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
const id = z.string().uuid();
|
||||
const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict();
|
||||
const reportBody = z.object({ reason: z.enum(['harassment', 'spam', 'inappropriate_content', 'other']) }).strict();
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
async function result<T>(operation: () => Promise<T>) {
|
||||
try {
|
||||
return { ok: true as const, value: await operation() };
|
||||
} catch (cause) {
|
||||
if (cause instanceof ChatError) return { ok: false as const, cause };
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
|
||||
app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id)));
|
||||
|
||||
app.get('/v1/matches/:matchId/messages', async (c) => {
|
||||
const parsedId = id.safeParse(c.req.param('matchId'));
|
||||
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
||||
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data));
|
||||
return response.ok ? c.json({ messages: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/matches/:matchId/messages', async (c) => {
|
||||
const parsedId = id.safeParse(c.req.param('matchId'));
|
||||
const parsedBody = sendBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
||||
if (!parsedBody.success) return c.json(error('invalid_message', 'Message content is required'), 400);
|
||||
const response = await result(() => repository.sendText(c.get('identity').id, parsedId.data, parsedBody.data.content));
|
||||
return response.ok ? c.json({ message: response.value }, 201) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/matches/:matchId/read', async (c) => {
|
||||
const parsedId = id.safeParse(c.req.param('matchId'));
|
||||
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
||||
const response = await result(() => repository.markMatchRead(c.get('identity').id, parsedId.data));
|
||||
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.put('/v1/matches/:matchId/typing', async (c) => {
|
||||
const parsedId = id.safeParse(c.req.param('matchId'));
|
||||
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
||||
const response = await result(() => repository.setTyping(c.get('identity').id, parsedId.data, 4_000));
|
||||
return response.ok ? c.json({ typing: true }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/matches/:matchId/typing', async (c) => {
|
||||
const parsedId = id.safeParse(c.req.param('matchId'));
|
||||
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
||||
const response = await result(() => repository.getPartnerTyping(c.get('identity').id, parsedId.data));
|
||||
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/messages/:messageId/delivered', async (c) => {
|
||||
const parsedId = id.safeParse(c.req.param('messageId'));
|
||||
if (!parsedId.success) return c.json(error('invalid_message_id', 'A valid message ID is required'), 400);
|
||||
const response = await result(() => repository.markDelivered(c.get('identity').id, parsedId.data));
|
||||
return response.ok ? c.json({ message: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.delete('/v1/messages/:messageId', async (c) => {
|
||||
const parsedId = id.safeParse(c.req.param('messageId'));
|
||||
const scope = c.req.query('scope');
|
||||
if (!parsedId.success) return c.json(error('invalid_message_id', 'A valid message ID is required'), 400);
|
||||
if (scope !== 'self' && scope !== 'everyone') return c.json(error('invalid_delete_scope', 'Delete scope must be self or everyone'), 400);
|
||||
if (scope === 'self') {
|
||||
const response = await result(() => repository.deleteForSelf(c.get('identity').id, parsedId.data));
|
||||
return response.ok ? c.json({ deleted: true }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
}
|
||||
const response = await result(() => repository.deleteForEveryone(c.get('identity').id, parsedId.data));
|
||||
return response.ok ? c.json({ deleted: true, message: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/messages/:messageId/reports', async (c) => {
|
||||
const parsedId = id.safeParse(c.req.param('messageId'));
|
||||
const parsedBody = reportBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsedId.success) return c.json(error('invalid_message_id', 'A valid message ID is required'), 400);
|
||||
if (!parsedBody.success) return c.json(error('invalid_report_reason', 'A valid report reason is required'), 400);
|
||||
const response = await result(() => repository.report(c.get('identity').id, parsedId.data, parsedBody.data.reason));
|
||||
return response.ok ? c.json({ reported: true }, 201) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||
import { profiles } from '../../db/schema.js';
|
||||
|
||||
// `matches` is owned by the matches vertical (0003). Keeping the FK in SQL
|
||||
// avoids importing another domain's schema fragment here.
|
||||
export const messages = pgTable('messages', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
matchId: uuid('match_id').notNull(),
|
||||
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
content: text('content'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
readAt: timestamp('read_at', { withTimezone: true }),
|
||||
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
mediaPath: text('media_path'),
|
||||
mediaType: text('media_type'),
|
||||
mediaExpiresAt: timestamp('media_expires_at', { withTimezone: true }),
|
||||
mediaExpired: boolean('media_expired').notNull().default(false),
|
||||
mediaViewedAt: timestamp('media_viewed_at', { withTimezone: true }),
|
||||
version: integer('version').notNull().default(1),
|
||||
encryptedContent: text('encrypted_content'),
|
||||
encryptionIv: text('encryption_iv'),
|
||||
keysMetadata: jsonb('keys_metadata'),
|
||||
moderationStatus: text('moderation_status'),
|
||||
flagReason: text('flag_reason'),
|
||||
category: text('category'),
|
||||
}, (table) => [index('messages_match_created_idx').on(table.matchId, table.createdAt)]);
|
||||
|
||||
export const messageDeletions = pgTable('message_deletions', {
|
||||
messageId: uuid('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
|
||||
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [primaryKey({ columns: [table.messageId, table.userId] })]);
|
||||
|
||||
export const messageReports = pgTable('message_reports', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
messageId: uuid('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
|
||||
reporterId: uuid('reporter_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
reason: text('reason').notNull(),
|
||||
status: text('status').notNull().default('pending'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [uniqueIndex('message_reports_message_reporter_idx').on(table.messageId, table.reporterId)]);
|
||||
|
||||
export const chatTypingStates = pgTable('chat_typing_states', {
|
||||
matchId: uuid('match_id').notNull(),
|
||||
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
}, (table) => [primaryKey({ columns: [table.matchId, table.userId] }), index('chat_typing_expires_idx').on(table.expiresAt)]);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { Couple, Profile } from '@sauci/shared';
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { CoupleError, type CoupleStateResponse } from './types.js';
|
||||
|
||||
interface ProfileRecord extends QueryResultRow {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatar_url: string | null;
|
||||
push_token: string | null;
|
||||
is_premium: boolean;
|
||||
couple_id: string | null;
|
||||
gender: Profile['gender'];
|
||||
show_explicit_content: boolean;
|
||||
max_intensity: Profile['max_intensity'];
|
||||
public_key_jwk: Record<string, unknown> | null;
|
||||
hide_nsfw: boolean;
|
||||
onboarding_completed: boolean;
|
||||
onboarding_version: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
interface CoupleRecord extends QueryResultRow {
|
||||
id: string;
|
||||
invite_code: string;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface CoupleRepository {
|
||||
getState(userId: string): Promise<CoupleStateResponse>;
|
||||
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
|
||||
join(userId: string, inviteCode: string): Promise<Couple>;
|
||||
cancel(userId: string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function toCouple(row: CoupleRecord): Couple {
|
||||
return {
|
||||
id: row.id,
|
||||
invite_code: row.invite_code,
|
||||
created_at: row.created_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function toProfile(row: ProfileRecord): Profile {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
avatar_url: row.avatar_url,
|
||||
push_token: row.push_token,
|
||||
is_premium: row.is_premium,
|
||||
couple_id: row.couple_id,
|
||||
gender: row.gender,
|
||||
show_explicit_content: row.show_explicit_content,
|
||||
max_intensity: row.max_intensity,
|
||||
public_key_jwk: row.public_key_jwk,
|
||||
hide_nsfw: row.hide_nsfw,
|
||||
onboarding_completed: row.onboarding_completed,
|
||||
onboarding_version: row.onboarding_version,
|
||||
created_at: row.created_at.toISOString(),
|
||||
updated_at: row.updated_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function profileForUpdate(client: PoolClient, userId: string): Promise<{ couple_id: string | null }> {
|
||||
const result = await client.query<{ couple_id: string | null }>(
|
||||
'select couple_id from profiles where id = $1 for update',
|
||||
[userId],
|
||||
);
|
||||
const profile = result.rows[0];
|
||||
if (!profile) {
|
||||
throw new CoupleError('profile_not_found', 'Profile not found. Please complete signup first.', 404);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const result = await work(client);
|
||||
await client.query('commit');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresCoupleRepository implements CoupleRepository {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
}
|
||||
|
||||
async getState(userId: string): Promise<CoupleStateResponse> {
|
||||
const profileResult = await this.pool.query<{ couple_id: string | null }>(
|
||||
'select couple_id from profiles where id = $1',
|
||||
[userId],
|
||||
);
|
||||
const profile = profileResult.rows[0];
|
||||
if (!profile) {
|
||||
throw new CoupleError('profile_not_found', 'Profile not found. Please complete signup first.', 404);
|
||||
}
|
||||
if (!profile.couple_id) return { couple: null, partner: null };
|
||||
|
||||
const [coupleResult, partnerResult] = await Promise.all([
|
||||
this.pool.query<CoupleRecord>(
|
||||
'select id, invite_code, created_at from couples where id = $1',
|
||||
[profile.couple_id],
|
||||
),
|
||||
this.pool.query<ProfileRecord>(
|
||||
`select id, name, email, avatar_url, push_token, is_premium, couple_id, gender,
|
||||
show_explicit_content, max_intensity, public_key_jwk, hide_nsfw,
|
||||
onboarding_completed, onboarding_version, created_at, updated_at
|
||||
from profiles
|
||||
where couple_id = $1 and id <> $2
|
||||
limit 1`,
|
||||
[profile.couple_id, userId],
|
||||
),
|
||||
]);
|
||||
const couple = coupleResult.rows[0];
|
||||
if (!couple) return { couple: null, partner: null };
|
||||
return {
|
||||
couple: toCouple(couple),
|
||||
partner: partnerResult.rows[0] ? toProfile(partnerResult.rows[0]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async create(userId: string, coupleId: string, inviteCode: string): Promise<Couple> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const profile = await profileForUpdate(client, userId);
|
||||
if (profile.couple_id) {
|
||||
throw new CoupleError('already_paired', 'You are already in a couple', 409);
|
||||
}
|
||||
try {
|
||||
const result = await client.query<CoupleRecord>(
|
||||
'insert into couples (id, invite_code) values ($1, $2) returning id, invite_code, created_at',
|
||||
[coupleId, inviteCode],
|
||||
);
|
||||
await client.query('update profiles set couple_id = $1, updated_at = now() where id = $2', [coupleId, userId]);
|
||||
return toCouple(result.rows[0]!);
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') {
|
||||
throw new CoupleError('invite_code_collision', 'Invite code collision', 409);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async join(userId: string, inviteCode: string): Promise<Couple> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const profile = await profileForUpdate(client, userId);
|
||||
if (profile.couple_id) {
|
||||
throw new CoupleError('already_paired', 'You are already in a couple', 409);
|
||||
}
|
||||
|
||||
const coupleResult = await client.query<CoupleRecord>(
|
||||
'select id, invite_code, created_at from couples where invite_code = $1 for update',
|
||||
[inviteCode],
|
||||
);
|
||||
const couple = coupleResult.rows[0];
|
||||
if (!couple) {
|
||||
throw new CoupleError('invalid_invite_code', 'Invalid invite code', 404);
|
||||
}
|
||||
|
||||
const memberResult = await client.query<{ count: string }>(
|
||||
'select count(*)::text as count from profiles where couple_id = $1',
|
||||
[couple.id],
|
||||
);
|
||||
if (Number(memberResult.rows[0]?.count ?? 0) >= 2) {
|
||||
throw new CoupleError('couple_full', 'This couple already has two partners', 409);
|
||||
}
|
||||
|
||||
await client.query('update profiles set couple_id = $1, updated_at = now() where id = $2', [couple.id, userId]);
|
||||
return toCouple(couple);
|
||||
});
|
||||
}
|
||||
|
||||
async cancel(userId: string): Promise<void> {
|
||||
await transaction(this.pool, async (client) => {
|
||||
const profile = await profileForUpdate(client, userId);
|
||||
if (!profile.couple_id) {
|
||||
throw new CoupleError('not_paired', 'You are not in a couple', 400);
|
||||
}
|
||||
await client.query('select id from couples where id = $1 for update', [profile.couple_id]);
|
||||
await client.query(
|
||||
'update profiles set couple_id = null, updated_at = now() where couple_id = $1',
|
||||
[profile.couple_id],
|
||||
);
|
||||
await client.query('delete from couples where id = $1', [profile.couple_id]);
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { CoupleService } from './service.js';
|
||||
import { CoupleError } from './types.js';
|
||||
|
||||
type Variables = { identity: AuthIdentity };
|
||||
type AuthenticatedApp = Hono<{ Variables: Variables }>;
|
||||
|
||||
const joinBody = z.object({ invite_code: z.string() }).strict();
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
async function route<T>(operation: () => Promise<T>): Promise<
|
||||
{ ok: true; value: T } | { ok: false; body: ApiErrorResponse; status: 400 | 404 | 409 }
|
||||
> {
|
||||
try {
|
||||
return { ok: true, value: await operation() };
|
||||
} catch (cause) {
|
||||
if (cause instanceof CoupleError) {
|
||||
return { ok: false, body: error(cause.code, cause.message), status: cause.status };
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerCoupleRoutes(app: AuthenticatedApp, service: CoupleService): void {
|
||||
app.get('/v1/couple', async (c) => {
|
||||
const result = await route(() => service.getState(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
|
||||
app.post('/v1/couple', async (c) => {
|
||||
const rawBody: unknown = await c.req.json().catch(() => ({}));
|
||||
if (typeof rawBody === 'object' && rawBody !== null && Object.keys(rawBody).length === 0) {
|
||||
const result = await route(() => service.create(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value, 201) : c.json(result.body, result.status);
|
||||
}
|
||||
|
||||
const parsed = joinBody.safeParse(rawBody);
|
||||
if (!parsed.success) return c.json(error('invalid_invite_code', 'A valid invite_code is required'), 400);
|
||||
const result = await route(() => service.join(c.get('identity').id, parsed.data.invite_code));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
|
||||
app.delete('/v1/couple', async (c) => {
|
||||
const result = await route(() => service.cancel(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { text, timestamp, uuid, pgTable } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* Domain-owned schema fragment. The root API schema can re-export this table
|
||||
* when the couples vertical is registered with the application.
|
||||
*/
|
||||
export const couples = pgTable('couples', {
|
||||
id: uuid('id').primaryKey(),
|
||||
inviteCode: text('invite_code').notNull().unique(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export type CoupleRow = typeof couples.$inferSelect;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import type { CoupleRepository } from './repository.js';
|
||||
import { CoupleError, type CoupleMutationResponse, type CoupleStateResponse } from './types.js';
|
||||
|
||||
const inviteAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
|
||||
export function generateInviteCode(bytes: Uint8Array = randomBytes(8)): string {
|
||||
return Array.from(bytes, (byte) => inviteAlphabet[byte % inviteAlphabet.length]).join('').slice(0, 8);
|
||||
}
|
||||
|
||||
export class CoupleService {
|
||||
constructor(private readonly repository: CoupleRepository) {}
|
||||
|
||||
getState(userId: string): Promise<CoupleStateResponse> {
|
||||
return this.repository.getState(userId);
|
||||
}
|
||||
|
||||
async create(userId: string): Promise<CoupleMutationResponse> {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const inviteCode = generateInviteCode();
|
||||
try {
|
||||
const couple = await this.repository.create(userId, randomUUID(), inviteCode);
|
||||
return { success: true, couple_id: couple.id, invite_code: couple.invite_code };
|
||||
} catch (error) {
|
||||
if (!(error instanceof CoupleError) || error.code !== 'invite_code_collision') throw error;
|
||||
}
|
||||
}
|
||||
throw new Error('Unable to allocate a unique invite code');
|
||||
}
|
||||
|
||||
async join(userId: string, inviteCode: string): Promise<CoupleMutationResponse> {
|
||||
const canonicalCode = inviteCode.trim().toUpperCase();
|
||||
if (!/^[A-Z0-9]{8}$/.test(canonicalCode)) {
|
||||
throw new CoupleError('invalid_invite_code', 'Invalid invite code format', 400);
|
||||
}
|
||||
const couple = await this.repository.join(userId, canonicalCode);
|
||||
return { success: true, couple_id: couple.id };
|
||||
}
|
||||
|
||||
async cancel(userId: string): Promise<CoupleMutationResponse> {
|
||||
await this.repository.cancel(userId);
|
||||
return { success: true, couple_id: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Couple, Profile } from '@sauci/shared';
|
||||
|
||||
export interface CoupleStateResponse {
|
||||
couple: Couple | null;
|
||||
partner: Profile | null;
|
||||
}
|
||||
|
||||
export interface CoupleMutationResponse {
|
||||
success: true;
|
||||
couple_id: string | null;
|
||||
invite_code?: string;
|
||||
}
|
||||
|
||||
export type CoupleErrorCode =
|
||||
| 'profile_not_found'
|
||||
| 'already_paired'
|
||||
| 'invalid_invite_code'
|
||||
| 'couple_full'
|
||||
| 'not_paired'
|
||||
| 'invite_code_collision';
|
||||
|
||||
export class CoupleError extends Error {
|
||||
constructor(
|
||||
readonly code: CoupleErrorCode,
|
||||
message: string,
|
||||
readonly status: 400 | 404 | 409,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CoupleError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './repository.js';
|
||||
export * from './janitor.js';
|
||||
export * from './routes.js';
|
||||
export * from './storage.js';
|
||||
export * from './types.js';
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MediaRepository } from './repository.js';
|
||||
import type { FilesystemMediaStorage } from './storage.js';
|
||||
|
||||
export class MediaJanitor {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
constructor(private readonly repository: MediaRepository, private readonly storage: FilesystemMediaStorage) {}
|
||||
async runOnce(limit=100): Promise<number> {
|
||||
const keys=await this.repository.queuedDeletions(limit); let removed=0;
|
||||
for(const key of keys) {
|
||||
try { await this.storage.remove(key); await this.repository.acknowledgeDeletion(key); removed++; }
|
||||
catch(cause) { console.error('Media cleanup failed', { storageKey:key, cause }); }
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
start(intervalMs=60_000) {
|
||||
if(this.timer) return; void this.runOnce();
|
||||
this.timer=setInterval(()=>void this.runOnce(),intervalMs); this.timer.unref();
|
||||
}
|
||||
stop() { if(this.timer) clearInterval(this.timer); this.timer=null; }
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import type { MediaKind, MediaObject, MediaUploadContext } from './types.js';
|
||||
|
||||
interface MediaRow extends QueryResultRow {
|
||||
id: string; owner_id: string; couple_id: string | null; kind: MediaKind;
|
||||
storage_key: string; mime_type: string; byte_size: number; question_id: string | null;
|
||||
match_id: string | null; expires_at: Date | null; deleted_at: Date | null;
|
||||
}
|
||||
|
||||
export class MediaError extends Error {
|
||||
constructor(readonly code: string, message: string, readonly status: 400 | 403 | 404 | 409) { super(message); }
|
||||
}
|
||||
|
||||
export interface MediaRepository {
|
||||
create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext): Promise<{ media: MediaObject; message?: Record<string, unknown> }>;
|
||||
accessible(userId: string, mediaId: string): Promise<MediaObject>;
|
||||
byId(mediaId: string): Promise<MediaObject | null>;
|
||||
queuedDeletions(limit: number): Promise<string[]>;
|
||||
acknowledgeDeletion(storageKey: string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const columns = 'id, owner_id, couple_id, kind, storage_key, mime_type, byte_size, question_id, match_id, expires_at, deleted_at';
|
||||
function object(row: MediaRow): MediaObject {
|
||||
return { ...row, expires_at: row.expires_at?.toISOString() ?? null };
|
||||
}
|
||||
|
||||
export class PostgresMediaRepository implements MediaRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
|
||||
async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const profile = await client.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1 for update', [userId]);
|
||||
if (!profile.rows[0]) throw new MediaError('profile_not_found', 'Profile not found', 404);
|
||||
const coupleId = profile.rows[0].couple_id;
|
||||
if ((kind === 'response' || kind === 'chat') && !coupleId) throw new MediaError('couple_required', 'Join a couple before sharing media', 409);
|
||||
if (kind === 'response') {
|
||||
if (!context.questionId) throw new MediaError('question_required', 'A question ID is required', 400);
|
||||
const question = await client.query('select 1 from questions where id=$1 and deleted_at is null', [context.questionId]);
|
||||
if (!question.rowCount) throw new MediaError('question_not_found', 'Question not found', 404);
|
||||
}
|
||||
if (kind === 'chat') {
|
||||
if (!context.matchId) throw new MediaError('match_required', 'A match ID is required', 400);
|
||||
const match = await client.query('select 1 from matches where id=$1 and couple_id=$2', [context.matchId, coupleId]);
|
||||
if (!match.rowCount) throw new MediaError('match_not_found', 'Match not found', 404);
|
||||
}
|
||||
const id = randomUUID();
|
||||
const inserted = await client.query<MediaRow>(
|
||||
`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id)
|
||||
values($1,$2,$3,$4,$5,$6,$7,$8,$9) returning ${columns}`,
|
||||
[id, userId, coupleId, kind, storageKey, mimeType, byteSize, context.questionId ?? null, context.matchId ?? null],
|
||||
);
|
||||
let message: Record<string, unknown> | undefined;
|
||||
if (kind === 'chat') {
|
||||
const mediaType = mimeType.startsWith('video/') ? 'video' : 'image';
|
||||
const result = await client.query<Record<string, unknown> & QueryResultRow>(
|
||||
`insert into messages(match_id,user_id,content,media_path,media_type,media_expired,version)
|
||||
values($1,$2,$3,$4,$5,false,1) returning *`,
|
||||
[context.matchId, userId, mediaType === 'video' ? 'Sent a video' : 'Sent an image', `media:${id}`, mediaType],
|
||||
);
|
||||
message = result.rows[0];
|
||||
}
|
||||
if (kind === 'avatar') {
|
||||
await client.query('update profiles set avatar_url=$2, updated_at=now() where id=$1', [userId, `media:${id}`]);
|
||||
await client.query(`delete from media_objects where owner_id=$1 and kind='avatar' and id<>$2`, [userId, id]);
|
||||
}
|
||||
await client.query('commit');
|
||||
return { media: object(inserted.rows[0]!), message };
|
||||
} catch (cause) {
|
||||
await client.query('rollback');
|
||||
throw cause;
|
||||
} finally { client.release(); }
|
||||
}
|
||||
|
||||
async accessible(userId: string, mediaId: string): Promise<MediaObject> {
|
||||
const result = await this.pool.query<MediaRow>(
|
||||
`select mo.${columns.split(', ').join(', mo.')}
|
||||
from media_objects mo join profiles viewer on viewer.id=$1
|
||||
where mo.id=$2 and mo.deleted_at is null and (mo.expires_at is null or mo.expires_at>now())
|
||||
and not exists(select 1 from messages m where m.media_path='media:'||mo.id::text and (m.media_expired or m.media_expires_at<=now()))
|
||||
and (mo.owner_id=$1 or (mo.kind in ('avatar','response','chat') and mo.couple_id is not null and mo.couple_id=viewer.couple_id))`,
|
||||
[userId, mediaId],
|
||||
);
|
||||
if (!result.rows[0]) throw new MediaError('media_not_found', 'Media not found', 404);
|
||||
return object(result.rows[0]);
|
||||
}
|
||||
|
||||
async byId(mediaId: string): Promise<MediaObject | null> {
|
||||
const result = await this.pool.query<MediaRow>(`select ${columns} from media_objects mo where id=$1 and deleted_at is null and (expires_at is null or expires_at>now())
|
||||
and not exists(select 1 from messages m where m.media_path='media:'||mo.id::text and (m.media_expired or m.media_expires_at<=now()))`, [mediaId]);
|
||||
return result.rows[0] ? object(result.rows[0]) : null;
|
||||
}
|
||||
async queuedDeletions(limit: number): Promise<string[]> {
|
||||
await this.pool.query(`delete from media_objects where expires_at<=now()`);
|
||||
const result=await this.pool.query<{storage_key:string}>('select storage_key from media_deletion_queue order by queued_at limit $1',[limit]);
|
||||
return result.rows.map(row=>row.storage_key);
|
||||
}
|
||||
async acknowledgeDeletion(storageKey: string): Promise<void> {
|
||||
await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]);
|
||||
}
|
||||
async close() { await this.pool.end(); }
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { bodyLimit } from 'hono/body-limit';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { MediaError, type MediaRepository } from './repository.js';
|
||||
import { FilesystemMediaStorage } from './storage.js';
|
||||
import { mediaKinds } from './types.js';
|
||||
|
||||
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
const uuid=z.string().uuid(); const kind=z.enum(mediaKinds);
|
||||
const err=(code:string,message:string):ApiErrorResponse=>({error:{code,message}});
|
||||
export function registerMediaRoutes(app: App, repository: MediaRepository, storage: FilesystemMediaStorage) {
|
||||
app.use('/v1/media/*', bodyLimit({maxSize:50*1024*1024,onError:c=>c.json(err('upload_too_large','Upload exceeds the size limit'),413)}));
|
||||
app.post('/v1/media/:kind', async c => {
|
||||
const parsedKind=kind.safeParse(c.req.param('kind')); if(!parsedKind.success) return c.json(err('invalid_media_kind','Unsupported media kind'),400);
|
||||
const mime=(c.req.header('content-type')??'').split(';')[0]!.trim().toLowerCase();
|
||||
const lengthHeader=c.req.header('content-length'); const length=lengthHeader ? Number(lengthHeader) : null;
|
||||
try { storage.validate(parsedKind.data,mime,length); } catch(e) { const code=e instanceof Error?e.message:'invalid_upload'; return c.json(err(code,code==='upload_too_large'?'Upload exceeds the size limit':'Unsupported or invalid media'),code==='upload_too_large'?413:415); }
|
||||
const questionId=c.req.query('questionId'); const matchId=c.req.query('matchId');
|
||||
if(questionId&&!uuid.safeParse(questionId).success) return c.json(err('invalid_question_id','A valid question ID is required'),400);
|
||||
if(matchId&&!uuid.safeParse(matchId).success) return c.json(err('invalid_match_id','A valid match ID is required'),400);
|
||||
const bytes=new Uint8Array(await c.req.arrayBuffer());
|
||||
let stored: {key:string;size:number};
|
||||
try { stored=await storage.put(parsedKind.data,mime,bytes); } catch(e) { const code=e instanceof Error?e.message:'invalid_upload'; return c.json(err(code,code==='upload_too_large'?'Upload exceeds the size limit':'File contents do not match the declared media type'),code==='upload_too_large'?413:415); }
|
||||
try {
|
||||
const result=await repository.create(c.get('identity').id,parsedKind.data,stored.key,mime,stored.size,{questionId,matchId});
|
||||
return c.json({...result, reference:`media:${result.media.id}`},201);
|
||||
} catch(e) { await storage.remove(stored.key); if(e instanceof MediaError) return c.json(err(e.code,e.message),e.status); throw e; }
|
||||
});
|
||||
app.get('/v1/media/:mediaId/url', async c => {
|
||||
const parsed=uuid.safeParse(c.req.param('mediaId')); if(!parsed.success) return c.json(err('invalid_media_id','A valid media ID is required'),400);
|
||||
try { await repository.accessible(c.get('identity').id,parsed.data); return c.json(storage.signedUrl(parsed.data)); }
|
||||
catch(e) { if(e instanceof MediaError) return c.json(err(e.code,e.message),e.status); throw e; }
|
||||
});
|
||||
app.get('/media/:mediaId/content', async c => {
|
||||
const parsed=uuid.safeParse(c.req.param('mediaId')); const expires=Number(c.req.query('expires')); const signature=c.req.query('signature')??'';
|
||||
if(!parsed.success||!storage.verify(parsed.data,expires,signature)) return c.json(err('invalid_media_signature','Media URL is invalid or expired'),403);
|
||||
const media=await repository.byId(parsed.data); if(!media) return c.json(err('media_not_found','Media not found'),404);
|
||||
const bytes=await storage.read(media.storage_key).catch(()=>null); if(!bytes) return c.json(err('media_not_found','Media not found'),404);
|
||||
return new Response(bytes,{headers:{'Content-Type':media.mime_type,'Content-Length':String(bytes.byteLength),'Cache-Control':'private, max-age=300','X-Content-Type-Options':'nosniff'}});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
||||
import { extname, resolve, sep } from 'node:path';
|
||||
import type { MediaKind } from './types.js';
|
||||
|
||||
const allowed: Record<MediaKind, ReadonlySet<string>> = {
|
||||
avatar: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
||||
response: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
|
||||
chat: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/webm']),
|
||||
feedback: new Set(['image/jpeg', 'image/png', 'image/webp']),
|
||||
};
|
||||
const limits: Record<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024 };
|
||||
const extensions: Record<string, string> = { 'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','video/mp4':'.mp4','video/quicktime':'.mov','video/webm':'.webm','audio/mp4':'.m4a','audio/m4a':'.m4a','audio/x-m4a':'.m4a','audio/mpeg':'.mp3','audio/wav':'.wav','audio/webm':'.webm','audio/aac':'.aac','audio/x-caf':'.caf' };
|
||||
|
||||
export class FilesystemMediaStorage {
|
||||
constructor(readonly root: string, private readonly signingSecret: string, private readonly publicBaseUrl: string) {}
|
||||
validate(kind: MediaKind, mime: string, length: number | null) {
|
||||
if (!allowed[kind].has(mime)) throw new Error('unsupported_media_type');
|
||||
if (length !== null && (length < 1 || length > limits[kind])) throw new Error('upload_too_large');
|
||||
}
|
||||
private path(key: string) {
|
||||
if (key.includes('..') || key.includes('\\') || key.startsWith('/')) throw new Error('invalid_storage_key');
|
||||
const root = resolve(this.root); const candidate = resolve(root, key);
|
||||
if (!candidate.startsWith(`${root}${sep}`)) throw new Error('invalid_storage_key');
|
||||
return candidate;
|
||||
}
|
||||
async put(kind: MediaKind, mime: string, bytes: Uint8Array): Promise<{ key: string; size: number }> {
|
||||
this.validate(kind, mime, bytes.byteLength);
|
||||
if (!matchesMagic(mime, bytes)) throw new Error('media_signature_mismatch');
|
||||
const date = new Date().toISOString().slice(0,10); const id = randomUUID();
|
||||
const key = `${kind}/${date}/${id}${extensions[mime]}`; const target = this.path(key); const temp = this.path(`tmp/${id}.upload`);
|
||||
await mkdir(resolve(this.root, 'tmp'), { recursive: true }); await mkdir(resolve(this.root, kind, date), { recursive: true });
|
||||
await writeFile(temp, bytes, { flag: 'wx' }); await rename(temp, target);
|
||||
return { key, size: bytes.byteLength };
|
||||
}
|
||||
async remove(key: string) {
|
||||
try { await unlink(this.path(key)); }
|
||||
catch (cause) { if (!(typeof cause==='object'&&cause!==null&&'code' in cause&&cause.code==='ENOENT')) throw cause; }
|
||||
}
|
||||
async read(key: string) { return readFile(this.path(key)); }
|
||||
signedUrl(id: string, ttlSeconds=3600) {
|
||||
const expires = Math.floor(Date.now()/1000)+ttlSeconds; const signature = this.sign(id, expires);
|
||||
return { url: `${this.publicBaseUrl}/media/${id}/content?expires=${expires}&signature=${signature}`, expires_at: new Date(expires*1000).toISOString() };
|
||||
}
|
||||
verify(id: string, expires: number, signature: string) {
|
||||
if (!Number.isSafeInteger(expires) || expires < Math.floor(Date.now()/1000)) return false;
|
||||
const expected = Buffer.from(this.sign(id, expires)); const actual = Buffer.from(signature);
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
}
|
||||
private sign(id: string, expires: number) { return createHmac('sha256', this.signingSecret).update(`${id}.${expires}`).digest('base64url'); }
|
||||
}
|
||||
|
||||
function matchesMagic(mime: string, bytes: Uint8Array) {
|
||||
const b=Buffer.from(bytes); if (mime==='image/jpeg') return b[0]===0xff&&b[1]===0xd8&&b[2]===0xff;
|
||||
if (mime==='image/png') return b.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]));
|
||||
if (mime==='image/gif') return b.subarray(0,4).toString()==='GIF8';
|
||||
if (mime==='image/webp'||mime==='video/webm'||mime==='audio/webm') return mime==='image/webp' ? b.subarray(0,4).toString()==='RIFF'&&b.subarray(8,12).toString()==='WEBP' : b.subarray(0,4).equals(Buffer.from([0x1a,0x45,0xdf,0xa3]));
|
||||
if (mime.startsWith('video/')||mime.includes('mp4')||mime.includes('m4a')) return b.subarray(4,8).toString()==='ftyp';
|
||||
if (mime==='audio/mpeg') return (b[0]===0x49&&b[1]===0x44&&b[2]===0x33)||(b[0]===0xff&&(b[1]&0xe0)===0xe0);
|
||||
if (mime==='audio/wav') return b.subarray(0,4).toString()==='RIFF'&&b.subarray(8,12).toString()==='WAVE';
|
||||
if (mime==='audio/aac') return b[0]===0xff&&(b[1]&0xf6)===0xf0;
|
||||
if (mime==='audio/x-caf') return b.subarray(0,4).toString()==='caff';
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const mediaKinds = ['avatar', 'response', 'chat', 'feedback'] as const;
|
||||
export type MediaKind = typeof mediaKinds[number];
|
||||
|
||||
export interface MediaObject {
|
||||
id: string;
|
||||
owner_id: string;
|
||||
couple_id: string | null;
|
||||
kind: MediaKind;
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
byte_size: number;
|
||||
question_id: string | null;
|
||||
match_id: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface MediaUploadContext {
|
||||
questionId?: string;
|
||||
matchId?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Operations worker
|
||||
|
||||
`src/workers/index.ts` is the runtime for scheduled and asynchronous operations.
|
||||
It polls every 30 seconds by default, bounds each claim, and uses a Postgres
|
||||
advisory lock for producers plus `FOR UPDATE SKIP LOCKED` for delivery. Provider
|
||||
work is represented by an `operations_outbox` row with a unique dedupe key.
|
||||
Failed work retries with exponential backoff up to five attempts.
|
||||
|
||||
The worker replaces scheduled releases, streak milestones, session/match
|
||||
digests, message notifications, pack-change notifications, weekly summaries,
|
||||
unpaired and catch-up reminders, message classification, and product-used
|
||||
Discord events. The older one-hour partner-activity sender and generic match
|
||||
sender are consolidated into the five-minute session digest because the legacy
|
||||
pair produced duplicate notifications for the same response session.
|
||||
|
||||
Pack writes must call the local SQL function
|
||||
`queue_pack_change(couple_id, authenticated_user_id)` after enabling a pack.
|
||||
The function verifies couple membership and applies the 30-minute debounce.
|
||||
|
||||
Classification defaults on. Production startup fails closed without
|
||||
`OPENROUTER_API_KEY`; encrypted messages additionally require
|
||||
`ADMIN_PRIVATE_KEY_JWK`. Provider calls are bounded and have timeouts. Expo
|
||||
ticket-level errors are failures, and each notification includes the stable
|
||||
outbox ID as `notification_id` for client deduplication.
|
||||
|
||||
The worker invokes the existing filesystem `MediaJanitor`; it does not recreate
|
||||
the legacy Supabase Storage cleanup. Run only one janitor owner in production.
|
||||
When the worker service is enabled, disable the API-process janitor to avoid
|
||||
redundant scans. Deletion remains idempotent if both briefly overlap at rollout.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './providers.js';
|
||||
export * from './repository.js';
|
||||
export * from './runner.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import type { OperationsRepository } from './repository.js';
|
||||
import type { OperationItem } from './types.js';
|
||||
|
||||
type Fetch = typeof globalThis.fetch;
|
||||
|
||||
export class ExpoPushProvider {
|
||||
constructor(private readonly request:Fetch=globalThis.fetch) {}
|
||||
async send(item:OperationItem):Promise<void> {
|
||||
if (!item.pushToken) return;
|
||||
const title=typeof item.payload.title==='string'?item.payload.title:null;
|
||||
const body=typeof item.payload.body==='string'?item.payload.body:null;
|
||||
if (!title||!body) throw new Error('invalid_expo_payload');
|
||||
const data=typeof item.payload.data==='object'&&item.payload.data?item.payload.data:{};
|
||||
const response=await this.request('https://exp.host/--/api/v2/push/send',{method:'POST',headers:{'content-type':'application/json',accept:'application/json'},
|
||||
body:JSON.stringify({to:item.pushToken,title,body,sound:'default',data:{...data,notification_id:item.id}}),signal:AbortSignal.timeout(10_000)});
|
||||
if(!response.ok) throw new Error(`expo_http_${response.status}`);
|
||||
const result=await response.json().catch(()=>null) as {data?:{status?:string;message?:string}}|null;
|
||||
if(result?.data?.status==='error') throw new Error(`expo_ticket_${result.data.message??'error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class DiscordProvider {
|
||||
constructor(private readonly webhookUrl:string|undefined,private readonly request:Fetch=globalThis.fetch) {}
|
||||
async send(item:OperationItem):Promise<void> {
|
||||
if(!this.webhookUrl) return;
|
||||
const event=typeof item.payload.event==='string'?item.payload.event:'operation';
|
||||
const response=await this.request(this.webhookUrl,{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({username:'Sauci Notifications',content:event.replaceAll('_',' '),embeds:[{title:event.replaceAll('_',' '),description:formatPayload(item.payload),timestamp:new Date().toISOString()}]}),
|
||||
signal:AbortSignal.timeout(10_000)});
|
||||
if(!response.ok) throw new Error(`discord_http_${response.status}`);
|
||||
}
|
||||
}
|
||||
function formatPayload(payload:Record<string,unknown>):string {
|
||||
return Object.entries(payload).filter(([key])=>key!=='event').map(([key,value])=>`${key}: ${String(value??'')}`).join('\n').slice(0,1800);
|
||||
}
|
||||
|
||||
const whitelist=new Set(['ok','okay','yes','no','maybe','thanks','thank you','lol','lmao','good night','goodnight','good morning','love you']);
|
||||
const keywords=['suicide','kill myself','self harm','kill you','hurt you','rape','stab','shoot'];
|
||||
|
||||
export class MessageClassifier {
|
||||
constructor(private readonly repository:OperationsRepository,private readonly options:{enabled:boolean;apiKey?:string;model:string;prompt:string;adminPrivateKeyJwk?:string;mediaRoot:string},private readonly request:Fetch=globalThis.fetch) {}
|
||||
async classify(item:OperationItem):Promise<void> {
|
||||
const saved=await this.repository.classifierConfig();
|
||||
if(!(saved?.enabled ?? this.options.enabled)) return;
|
||||
const messageId=typeof item.payload.message_id==='string'?item.payload.message_id:null;
|
||||
if(!messageId) throw new Error('missing_message_id');
|
||||
const message=await this.repository.message(messageId); if(!message) return;
|
||||
let text=message.content??''; let aesKey:CryptoKey|null=null;
|
||||
const getKey=async()=>{
|
||||
if(aesKey)return aesKey;
|
||||
const wrapped=message.keysMetadata?.admin_wrapped_key;
|
||||
if(!wrapped||!this.options.adminPrivateKeyJwk) throw new Error('admin_decryption_key_unavailable');
|
||||
const privateKey=await crypto.subtle.importKey('jwk',JSON.parse(this.options.adminPrivateKeyJwk) as JsonWebKey,{name:'RSA-OAEP',hash:'SHA-256'},false,['decrypt']);
|
||||
const raw=await crypto.subtle.decrypt({name:'RSA-OAEP'},privateKey,Buffer.from(wrapped,'base64'));
|
||||
aesKey=await crypto.subtle.importKey('raw',raw,{name:'AES-GCM'},false,['decrypt']); return aesKey;
|
||||
};
|
||||
if(message.version===2&&message.encryptedContent) {
|
||||
if(!message.encryptionIv)throw new Error('missing_encryption_iv'); const key=await getKey();
|
||||
const plain=await crypto.subtle.decrypt({name:'AES-GCM',iv:Buffer.from(message.encryptionIv,'base64')},key,Buffer.from(message.encryptedContent,'base64'));
|
||||
text=new TextDecoder().decode(plain);
|
||||
}
|
||||
let media:string|null=null;
|
||||
if(message.mediaStorageKey&&message.mediaMimeType?.startsWith('image/')) {
|
||||
const root=resolve(this.options.mediaRoot);const path=resolve(root,message.mediaStorageKey);
|
||||
if(!path.startsWith(`${root}${sep}`))throw new Error('invalid_media_path'); let bytes=await readFile(path);
|
||||
if(message.version===2){if(!message.encryptionIv)throw new Error('missing_encryption_iv');const key=await getKey();bytes=Buffer.from(await crypto.subtle.decrypt({name:'AES-GCM',iv:Buffer.from(message.encryptionIv,'base64')},key,bytes));}
|
||||
media=`data:${message.mediaMimeType};base64,${bytes.toString('base64')}`;
|
||||
}
|
||||
const normalized=text.trim().toLowerCase();
|
||||
const customWhitelist=(saved?.heuristicWhitelist??'').split(/[\n,]/).map(value=>value.trim().toLowerCase()).filter(Boolean);
|
||||
const customKeywords=(saved?.heuristicKeywordTriggers??'').split(/[\n,]/).map(value=>value.trim().toLowerCase()).filter(Boolean);
|
||||
const allowed=new Set([...(saved?.heuristicUseDefaultWhitelist===false?[]:whitelist),...customWhitelist]);
|
||||
const triggers=[...(saved?.heuristicUseDefaultKeywords===false?[]:keywords),...customKeywords];
|
||||
const minLength=saved?.heuristicMinTextLength??12;
|
||||
const whitelistMax=saved?.heuristicWhitelistMaxLength??30;
|
||||
const noAlphaNumeric=!/[a-z0-9]/i.test(normalized);
|
||||
const heuristicSafe=!media&&(
|
||||
(allowed.has(normalized)&&normalized.length<=whitelistMax)||
|
||||
(saved?.heuristicSkipIfNoAlnum!==false&&noAlphaNumeric)||
|
||||
(!triggers.some(k=>normalized.includes(k))&&normalized.length<minLength)
|
||||
)||(Boolean(media)&&!normalized&&saved?.heuristicSkipMediaWithoutText===true);
|
||||
if((saved?.heuristicsEnabled??true)&&heuristicSafe) {
|
||||
await this.repository.classify(messageId,'safe',null,'Neutral'); return;
|
||||
}
|
||||
const apiKey=saved?.apiKey??this.options.apiKey;
|
||||
if(!apiKey)throw new Error('openrouter_api_key_unavailable');
|
||||
const content:Array<Record<string,unknown>>=[];if(text.trim())content.push({type:'text',text});if(media)content.push({type:'image_url',image_url:{url:media}});
|
||||
if(!content.length){await this.repository.classify(messageId,'safe','No content','Neutral');return;}
|
||||
const response=await this.request('https://openrouter.ai/api/v1/chat/completions',{method:'POST',headers:{authorization:`Bearer ${apiKey}`,'content-type':'application/json','http-referer':'https://sauci.app','x-title':'Sauci'},
|
||||
body:JSON.stringify({model:saved?.model??this.options.model,temperature:saved?.temperature??undefined,messages:[{role:'system',content:saved?.prompt??this.options.prompt},{role:'user',content}],response_format:{type:'json_object'}}),signal:AbortSignal.timeout(30_000)});
|
||||
if(!response.ok)throw new Error(`openrouter_http_${response.status}`);
|
||||
const result=await response.json() as {choices?:Array<{message?:{content?:string}}>};const raw=result.choices?.[0]?.message?.content;if(!raw)throw new Error('empty_classifier_response');
|
||||
let parsed:{status?:string;reason?:string;category?:string};try{parsed=JSON.parse(raw) as typeof parsed;}catch{parsed={status:raw.toLowerCase().includes('flagged')?'flagged':'safe',reason:'Classifier returned invalid JSON'};}
|
||||
await this.repository.classify(messageId,parsed.status==='flagged'?'flagged':'safe',parsed.reason??null,parsed.category??'Neutral');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js';
|
||||
|
||||
interface OutboxRow extends QueryResultRow {
|
||||
id: string; kind: OperationItem['kind']; dedupe_key: string; recipient_id: string | null;
|
||||
push_token: string | null; payload: Record<string, unknown>; attempts: number;
|
||||
}
|
||||
|
||||
export interface OperationsRepository {
|
||||
produce(now: Date, limit?: number): Promise<ProducerSummary>;
|
||||
claim(limit?: number): Promise<OperationItem[]>;
|
||||
complete(id: string): Promise<void>;
|
||||
fail(id: string, message: string): Promise<void>;
|
||||
message(id: string): Promise<MessageForClassification | null>;
|
||||
classifierConfig(): Promise<ClassifierRuntimeConfig | null>;
|
||||
classify(id: string, status: 'safe' | 'flagged', reason: string | null, category: string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function item(row: OutboxRow): OperationItem {
|
||||
return { id: row.id, kind: row.kind, dedupeKey: row.dedupe_key, recipientId: row.recipient_id,
|
||||
pushToken: row.push_token, payload: row.payload, attempts: row.attempts };
|
||||
}
|
||||
|
||||
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try { await client.query('begin'); const value=await work(client); await client.query('commit'); return value; }
|
||||
catch(cause) { await client.query('rollback'); throw cause; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
export class PostgresOperationsRepository implements OperationsRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
|
||||
async produce(now: Date, limit=100): Promise<ProducerSummary> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked");
|
||||
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
|
||||
if (!lock.rows[0]?.locked) return empty;
|
||||
|
||||
const released=await client.query<{id:string;name:string}>(
|
||||
`with candidates as (
|
||||
select id from question_packs where is_public=false and release_notified=false
|
||||
and scheduled_release_at is not null and scheduled_release_at<=$1
|
||||
order by scheduled_release_at for update skip locked limit $2
|
||||
) update question_packs qp set is_public=true,release_notified=true
|
||||
from candidates c where qp.id=c.id returning qp.id,qp.name`, [now,limit]);
|
||||
for (const pack of released.rows) {
|
||||
await client.query(
|
||||
`insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||
select 'expo','new_pack:'||$1||':'||p.id,p.id,
|
||||
jsonb_build_object('title','New pack available','body',$2||' is now available to play!',
|
||||
'data',jsonb_build_object('type','new_pack','packId',$1))
|
||||
from profiles p left join notification_preferences np on np.user_id=p.id
|
||||
where p.push_token is not null and coalesce(np.new_packs_enabled,true)
|
||||
on conflict(dedupe_key) do nothing`, [pack.id,pack.name]);
|
||||
}
|
||||
|
||||
const milestones=await client.query<{couple_id:string;current_streak:number}>(
|
||||
`with due as (
|
||||
select couple_id,current_streak from couple_streaks
|
||||
where current_streak=any(array[7,14,30,60,100]) and current_streak>streak_celebrated_at
|
||||
order by couple_id for update skip locked limit $1
|
||||
) update couple_streaks cs set streak_celebrated_at=d.current_streak,updated_at=$2
|
||||
from due d where cs.couple_id=d.couple_id returning cs.couple_id,cs.current_streak`, [limit,now]);
|
||||
for (const milestone of milestones.rows) {
|
||||
const copy=streakCopy(milestone.current_streak);
|
||||
await client.query(
|
||||
`insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||
select 'expo','streak:'||$1::uuid||':'||$2||':'||p.id,p.id,
|
||||
jsonb_build_object('title',$3::text,'body',$4::text,'data',jsonb_build_object('type','streak_milestone','streak',$2::int))
|
||||
from profiles p left join notification_preferences np on np.user_id=p.id
|
||||
where p.couple_id=$1::uuid and p.push_token is not null and coalesce(np.streak_milestones_enabled,true)
|
||||
on conflict(dedupe_key) do nothing`, [milestone.couple_id,milestone.current_streak,copy.title,copy.body]);
|
||||
}
|
||||
|
||||
const digests=await client.query<{couple_id:string;active_user_id:string;match_count:number;latest_match_id:string|null}>(
|
||||
`select couple_id,active_user_id,match_count,latest_match_id from pending_match_notifications
|
||||
where notify_at<=$1 order by notify_at for update skip locked limit $2`, [now,limit]);
|
||||
for (const digest of digests.rows) {
|
||||
const active=await client.query<{name:string|null}>('select name from profiles where id=$1',[digest.active_user_id]);
|
||||
const name=active.rows[0]?.name||'Your partner';
|
||||
const body=digest.match_count===1?'You have a new match! Tap to see what you both said':digest.match_count>1?`You have ${digest.match_count} new matches! Tap to see what you both said`:'Tap to see what you both said';
|
||||
await client.query(
|
||||
`insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||
select 'expo','digest:'||$1::uuid||':'||p.id||':'||extract(epoch from $5::timestamptz)::bigint,p.id,
|
||||
jsonb_build_object('title',$2::text,'body',$3::text,'data',jsonb_build_object('type','match_digest','count',$4::int,'match_id',$6::text))
|
||||
from profiles p left join notification_preferences np on np.user_id=p.id
|
||||
where p.couple_id=$1::uuid and p.id<>$7::uuid and p.push_token is not null
|
||||
and coalesce(np.partner_activity_enabled,true) and (p.last_active_at is null or p.last_active_at<$5::timestamptz-interval '5 minutes')
|
||||
on conflict(dedupe_key) do nothing`,
|
||||
[digest.couple_id,`${name} has been answering questions! 💕`,body,digest.match_count,now,digest.latest_match_id,digest.active_user_id]);
|
||||
await client.query('delete from pending_match_notifications where couple_id=$1',[digest.couple_id]);
|
||||
}
|
||||
|
||||
const packChanges=await client.query<{couple_id:string;changed_by_user_id:string}>(
|
||||
`delete from pending_pack_notifications where couple_id in (
|
||||
select couple_id from pending_pack_notifications where notify_at<=$1 order by notify_at for update skip locked limit $2
|
||||
) returning couple_id,changed_by_user_id`, [now,limit]);
|
||||
for (const change of packChanges.rows) await client.query(
|
||||
`insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||
select 'expo','pack_change:'||$1::uuid||':'||p.id||':'||to_char($3::timestamptz,'YYYYMMDDHH24MI'),p.id,
|
||||
'{"title":"Question packs updated","body":"Your partner updated the question packs","data":{"type":"pack_change"}}'::jsonb
|
||||
from profiles p left join notification_preferences np on np.user_id=p.id
|
||||
where p.couple_id=$1::uuid and p.id<>$2::uuid and p.push_token is not null and coalesce(np.pack_changes_enabled,true)
|
||||
on conflict(dedupe_key) do nothing`, [change.couple_id,change.changed_by_user_id,now]);
|
||||
|
||||
const weekly=await client.query(
|
||||
`insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||
select 'expo','weekly:'||to_char($1::timestamptz,'IYYY-IW')||':'||p.id,p.id,
|
||||
jsonb_build_object('title','Your weekly recap','body',case when count(m.id)=0 then 'No new matches this week. Answer some questions to discover what you have in common!'
|
||||
when count(m.id)<=3 then 'You matched on '||count(m.id)||' topics this week! Tap to start a conversation.'
|
||||
else count(m.id)||' matches this week — you two are on a roll!' end,
|
||||
'data',jsonb_build_object('type','weekly_summary'))
|
||||
from profiles p left join notification_preferences np on np.user_id=p.id
|
||||
left join matches m on m.couple_id=p.couple_id and m.created_at>$1::timestamptz-interval '7 days'
|
||||
where extract(dow from $1::timestamptz)=0 and extract(hour from $1::timestamptz)>=10
|
||||
and p.couple_id is not null and p.push_token is not null and coalesce(np.weekly_summary_enabled,true)
|
||||
and exists(select 1 from responses r where r.couple_id=p.couple_id and r.created_at>$1::timestamptz-interval '7 days')
|
||||
group by p.id on conflict(dedupe_key) do nothing`, [now]);
|
||||
|
||||
const unpaired=await client.query(
|
||||
`with eligible as (
|
||||
select p.id,p.couple_id is not null has_invite from profiles p left join notification_preferences np on np.user_id=p.id
|
||||
where extract(hour from $1::timestamptz)>=18 and p.push_token is not null
|
||||
and (p.couple_id is null or (select count(*) from profiles member where member.couple_id=p.couple_id)<2)
|
||||
and coalesce(np.unpaired_reminders_enabled,true)
|
||||
and (p.last_unpaired_reminder_at is null or p.last_unpaired_reminder_at<$1::timestamptz-interval '3 days')
|
||||
order by p.id for update of p skip locked limit $2
|
||||
), queued as (
|
||||
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||
select 'expo','unpaired:'||to_char($1::timestamptz,'YYYY-MM-DD')||':'||e.id,e.id,
|
||||
jsonb_build_object('title','Sauci','body',case when e.has_invite
|
||||
then 'Your invite code is waiting! Share it with your partner to start discovering what you have in common.'
|
||||
else 'Ready to get started? Create an invite code and share it with your partner!' end,
|
||||
'data',jsonb_build_object('type','unpaired_reminder'))
|
||||
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
|
||||
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
|
||||
|
||||
const catchup=await this.produceCatchups(client,now,limit);
|
||||
return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0,
|
||||
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,catchupReminders:catchup};
|
||||
});
|
||||
}
|
||||
|
||||
private async produceCatchups(client: PoolClient, now: Date, limit: number): Promise<number> {
|
||||
await client.query(
|
||||
`with behind as (
|
||||
select me.id user_id,min(partner.name) partner_name,count(distinct pr.question_id)::int pending_count
|
||||
from profiles me join profiles partner on partner.couple_id=me.couple_id and partner.id<>me.id
|
||||
join responses pr on pr.user_id=partner.id
|
||||
left join responses mine on mine.user_id=me.id and mine.question_id=pr.question_id
|
||||
where mine.id is null group by me.id
|
||||
) insert into catchup_reminder_tracking(user_id,pending_since)
|
||||
select user_id,$1 from behind on conflict(user_id) do nothing`, [now]);
|
||||
await client.query(
|
||||
`delete from catchup_reminder_tracking t where not exists(
|
||||
select 1 from profiles me join profiles partner on partner.couple_id=me.couple_id and partner.id<>me.id
|
||||
join responses pr on pr.user_id=partner.id left join responses mine on mine.user_id=me.id and mine.question_id=pr.question_id
|
||||
where me.id=t.user_id and mine.id is null)`, []);
|
||||
const result=await client.query(
|
||||
`with due as (
|
||||
select me.id,min(partner.name) partner_name,count(distinct pr.question_id)::int pending_count,t.reminder_count
|
||||
from catchup_reminder_tracking t join profiles me on me.id=t.user_id
|
||||
join profiles partner on partner.couple_id=me.couple_id and partner.id<>me.id
|
||||
join responses pr on pr.user_id=partner.id left join responses mine on mine.user_id=me.id and mine.question_id=pr.question_id
|
||||
left join notification_preferences np on np.user_id=me.id
|
||||
where extract(hour from $1::timestamptz)>=17 and mine.id is null and me.push_token is not null and coalesce(np.catchup_reminders_enabled,true)
|
||||
and (me.last_active_at is null or me.last_active_at<$1::timestamptz-interval '6 hours')
|
||||
and t.pending_since<=$1::timestamptz-interval '24 hours'
|
||||
and (t.reminder_count=0 or t.last_reminder_sent_at<=$1::timestamptz-interval '3 days')
|
||||
group by me.id,t.reminder_count order by me.id limit $2
|
||||
), queued as (
|
||||
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||
select 'expo','catchup:'||to_char($1::timestamptz,'YYYY-MM-DD')||':'||d.id,d.id,
|
||||
jsonb_build_object('title',case when d.reminder_count=0 then 'Questions waiting' else 'Still waiting on you' end,
|
||||
'body',case when d.reminder_count=0 and d.pending_count>=6
|
||||
then coalesce(d.partner_name,'Your partner')||' has answered '||d.pending_count||' questions you haven''t seen yet'
|
||||
when d.reminder_count=0 then coalesce(d.partner_name,'Your partner')||' answered some questions — your turn!'
|
||||
else 'You have '||d.pending_count||' questions waiting. Don''t leave '||coalesce(d.partner_name,'your partner')||' hanging!' end,
|
||||
'data',jsonb_build_object('type','catchup_reminder'))
|
||||
from due d on conflict(dedupe_key) do nothing returning recipient_id
|
||||
) update catchup_reminder_tracking t set last_reminder_sent_at=$1,reminder_count=reminder_count+1
|
||||
from queued q where t.user_id=q.recipient_id returning t.user_id`, [now,limit]);
|
||||
return result.rowCount??0;
|
||||
}
|
||||
|
||||
async claim(limit=25): Promise<OperationItem[]> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const result=await client.query<OutboxRow>(
|
||||
`with selected as (
|
||||
select id from operations_outbox where sent_at is null and attempts<5 and available_at<=now()
|
||||
and (locked_at is null or locked_at<now()-interval '5 minutes')
|
||||
order by available_at,created_at for update skip locked limit $1
|
||||
), claimed as (
|
||||
update operations_outbox o set locked_at=now(),attempts=attempts+1 from selected s where o.id=s.id returning o.*
|
||||
) select c.id,c.kind,c.dedupe_key,c.recipient_id,
|
||||
case when c.kind<>'expo' then null else case c.payload->'data'->>'type'
|
||||
when 'message' then case when coalesce(np.messages_enabled,true) then p.push_token end
|
||||
when 'match_digest' then case when coalesce(np.partner_activity_enabled,true) then p.push_token end
|
||||
when 'pack_change' then case when coalesce(np.pack_changes_enabled,true) then p.push_token end
|
||||
when 'new_pack' then case when coalesce(np.new_packs_enabled,true) then p.push_token end
|
||||
when 'streak_milestone' then case when coalesce(np.streak_milestones_enabled,true) then p.push_token end
|
||||
when 'weekly_summary' then case when coalesce(np.weekly_summary_enabled,true) then p.push_token end
|
||||
when 'unpaired_reminder' then case when coalesce(np.unpaired_reminders_enabled,true) then p.push_token end
|
||||
when 'catchup_reminder' then case when coalesce(np.catchup_reminders_enabled,true) then p.push_token end
|
||||
else p.push_token end end push_token,
|
||||
c.payload,c.attempts
|
||||
from claimed c left join profiles p on p.id=c.recipient_id
|
||||
left join notification_preferences np on np.user_id=c.recipient_id`, [limit]);
|
||||
return result.rows.map(item);
|
||||
});
|
||||
}
|
||||
async complete(id:string) { await this.pool.query('update operations_outbox set sent_at=now(),locked_at=null,last_error=null where id=$1',[id]); }
|
||||
async fail(id:string,message:string) { await this.pool.query(
|
||||
`update operations_outbox set locked_at=null,last_error=$2,available_at=now()+make_interval(secs=>least(3600,power(2,attempts)::int*30)) where id=$1`,[id,message.slice(0,500)]); }
|
||||
async message(id:string): Promise<MessageForClassification|null> {
|
||||
const r=await this.pool.query<{id:string;version:number;content:string|null;encrypted_content:string|null;encryption_iv:string|null;keys_metadata:{admin_wrapped_key?:string}|null;media_path:string|null;media_type:string|null;storage_key:string|null;mime_type:string|null}>(
|
||||
`select m.id,m.version,m.content,m.encrypted_content,m.encryption_iv,m.keys_metadata,m.media_path,m.media_type,
|
||||
mo.storage_key,mo.mime_type from messages m left join media_objects mo on m.media_path='media:'||mo.id::text where m.id=$1`,[id]);
|
||||
const m=r.rows[0]; return m?{id:m.id,version:m.version,content:m.content,encryptedContent:m.encrypted_content,encryptionIv:m.encryption_iv,keysMetadata:m.keys_metadata,mediaPath:m.media_path,mediaType:m.media_type,mediaStorageKey:m.storage_key,mediaMimeType:m.mime_type}:null;
|
||||
}
|
||||
async classifierConfig(): Promise<ClassifierRuntimeConfig|null> {
|
||||
const result=await this.pool.query<{
|
||||
classifier_enabled:boolean|null;openrouter_api_key:string|null;classifier_model:string|null;
|
||||
classifier_temperature:number|null;classifier_prompt:string|null;heuristics_enabled:boolean|null;
|
||||
heuristic_min_text_length:number|null;heuristic_whitelist_max_length:number|null;
|
||||
heuristic_skip_if_no_alnum:boolean|null;heuristic_skip_media_without_text:boolean|null;
|
||||
heuristic_use_default_whitelist:boolean|null;heuristic_use_default_keywords:boolean|null;
|
||||
heuristic_whitelist:string|null;heuristic_keyword_triggers:string|null;
|
||||
}>(`select classifier_enabled,openrouter_api_key,classifier_model,classifier_temperature,classifier_prompt,
|
||||
heuristics_enabled,heuristic_min_text_length,heuristic_whitelist_max_length,
|
||||
heuristic_skip_if_no_alnum,heuristic_skip_media_without_text,
|
||||
heuristic_use_default_whitelist,heuristic_use_default_keywords,
|
||||
heuristic_whitelist,heuristic_keyword_triggers
|
||||
from ai_config limit 1`);
|
||||
const row=result.rows[0];if(!row)return null;
|
||||
return {enabled:row.classifier_enabled,apiKey:row.openrouter_api_key,model:row.classifier_model,
|
||||
temperature:row.classifier_temperature,prompt:row.classifier_prompt,heuristicsEnabled:row.heuristics_enabled,
|
||||
heuristicMinTextLength:row.heuristic_min_text_length,heuristicWhitelistMaxLength:row.heuristic_whitelist_max_length,
|
||||
heuristicSkipIfNoAlnum:row.heuristic_skip_if_no_alnum,heuristicSkipMediaWithoutText:row.heuristic_skip_media_without_text,
|
||||
heuristicUseDefaultWhitelist:row.heuristic_use_default_whitelist,heuristicUseDefaultKeywords:row.heuristic_use_default_keywords,
|
||||
heuristicWhitelist:row.heuristic_whitelist,heuristicKeywordTriggers:row.heuristic_keyword_triggers};
|
||||
}
|
||||
async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) {
|
||||
await this.pool.query('update messages set moderation_status=$2,flag_reason=$3,category=$4 where id=$1',[id,status,reason,category]);
|
||||
}
|
||||
async close() { await this.pool.end(); }
|
||||
}
|
||||
|
||||
function streakCopy(days:number) {
|
||||
const copy:Record<number,[string,string]>={7:['1 week streak!','You and your partner have been connecting for 7 days straight. Keep it up!'],14:['2 week streak!','Two weeks of daily connection. You two are on fire!'],30:['1 month streak!',"A whole month of daily connection! You're building something special."],60:['2 month streak!','60 days of daily connection. Your commitment is inspiring!'],100:['100 day streak!',"100 days! You've made connection a habit. Incredible!"]};
|
||||
const value=copy[days]??[`${days} day streak!`,`${days} days of connecting with your partner. Amazing!`]; return {title:value[0],body:value[1]};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { MediaJanitor } from '../media/janitor.js';
|
||||
import type { OperationsRepository } from './repository.js';
|
||||
import type { DiscordProvider, ExpoPushProvider, MessageClassifier } from './providers.js';
|
||||
|
||||
export class OperationsRunner {
|
||||
constructor(private readonly repository:OperationsRepository,private readonly expo:ExpoPushProvider,private readonly discord:DiscordProvider,private readonly classifier:MessageClassifier,private readonly mediaJanitor?:MediaJanitor) {}
|
||||
async runOnce(now=new Date(),limit=25) {
|
||||
const produced=await this.repository.produce(now,100); if(this.mediaJanitor)await this.mediaJanitor.runOnce(100);
|
||||
const items=await this.repository.claim(limit);let completed=0,failed=0;
|
||||
for(const item of items) {
|
||||
try {
|
||||
if(item.kind==='expo')await this.expo.send(item);else if(item.kind==='discord')await this.discord.send(item);else await this.classifier.classify(item);
|
||||
await this.repository.complete(item.id);completed++;
|
||||
} catch(cause) { await this.repository.fail(item.id,cause instanceof Error?cause.message:'unknown error');failed++; }
|
||||
}
|
||||
return {produced,claimed:items.length,completed,failed};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export type OperationKind = 'expo' | 'discord' | 'classify';
|
||||
|
||||
export interface OperationItem {
|
||||
id: string;
|
||||
kind: OperationKind;
|
||||
dedupeKey: string;
|
||||
recipientId: string | null;
|
||||
pushToken: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface MessageForClassification {
|
||||
id: string;
|
||||
version: number;
|
||||
content: string | null;
|
||||
encryptedContent: string | null;
|
||||
encryptionIv: string | null;
|
||||
keysMetadata: { admin_wrapped_key?: string } | null;
|
||||
mediaPath: string | null;
|
||||
mediaType: string | null;
|
||||
mediaStorageKey: string | null;
|
||||
mediaMimeType: string | null;
|
||||
}
|
||||
|
||||
export interface ClassifierRuntimeConfig {
|
||||
enabled: boolean | null;
|
||||
apiKey: string | null;
|
||||
model: string | null;
|
||||
temperature: number | null;
|
||||
prompt: string | null;
|
||||
heuristicsEnabled: boolean | null;
|
||||
heuristicMinTextLength: number | null;
|
||||
heuristicWhitelistMaxLength: number | null;
|
||||
heuristicSkipIfNoAlnum: boolean | null;
|
||||
heuristicSkipMediaWithoutText: boolean | null;
|
||||
heuristicUseDefaultWhitelist: boolean | null;
|
||||
heuristicUseDefaultKeywords: boolean | null;
|
||||
heuristicWhitelist: string | null;
|
||||
heuristicKeywordTriggers: string | null;
|
||||
}
|
||||
|
||||
export interface ProducerSummary {
|
||||
releasedPacks: number;
|
||||
streakMilestones: number;
|
||||
digests: number;
|
||||
packChanges: number;
|
||||
weeklySummaries: number;
|
||||
unpairedReminders: number;
|
||||
catchupReminders: number;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
export interface PackCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
color: string | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
export interface PublicQuestionPack {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_premium: boolean;
|
||||
is_public: boolean;
|
||||
is_explicit: boolean;
|
||||
min_intensity: number | null;
|
||||
max_intensity: number | null;
|
||||
avg_intensity: number | null;
|
||||
sort_order: number;
|
||||
category_id: string | null;
|
||||
category: PackCategory | null;
|
||||
questions: Array<{ count: number }>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PacksCatalogResponse {
|
||||
categories: PackCategory[];
|
||||
packs: PublicQuestionPack[];
|
||||
}
|
||||
|
||||
export interface EnabledPacksResponse {
|
||||
enabledPackIds: string[];
|
||||
}
|
||||
|
||||
export interface PackProgressItem {
|
||||
packId: string;
|
||||
totalQuestions: number;
|
||||
answeredQuestions: number;
|
||||
}
|
||||
|
||||
export interface PackProgressResponse {
|
||||
progress: PackProgressItem[];
|
||||
}
|
||||
|
||||
export class PacksDomainError extends Error {
|
||||
constructor(readonly code: 'no_couple' | 'pack_not_found') {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
export interface PacksRepository {
|
||||
getCatalog(userId: string, showAllIntensities: boolean): Promise<PacksCatalogResponse>;
|
||||
getEnabledPacks(userId: string): Promise<EnabledPacksResponse>;
|
||||
setPackEnabled(userId: string, packId: string, enabled: boolean): Promise<EnabledPacksResponse>;
|
||||
getPackProgress(userId: string): Promise<PackProgressResponse>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface ProfilePreferencesRow {
|
||||
couple_id: string | null;
|
||||
hide_nsfw: boolean;
|
||||
}
|
||||
|
||||
export class PostgresPacksRepository implements PacksRepository {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
}
|
||||
|
||||
private async profile(userId: string): Promise<ProfilePreferencesRow> {
|
||||
const result = await this.pool.query<ProfilePreferencesRow>(
|
||||
'select couple_id, hide_nsfw from profiles where id = $1 limit 1',
|
||||
[userId],
|
||||
);
|
||||
// An identity that has not completed /v1/me bootstrapping gets the safest content defaults.
|
||||
return result.rows[0] ?? { couple_id: null, hide_nsfw: true };
|
||||
}
|
||||
|
||||
async getCatalog(userId: string, showAllIntensities: boolean): Promise<PacksCatalogResponse> {
|
||||
const profile = await this.profile(userId);
|
||||
const maxIntensity = profile.hide_nsfw ? 2 : 5;
|
||||
const [categoryResult, packResult] = await Promise.all([
|
||||
this.pool.query<PackCategory>(`
|
||||
select id, name, description, icon, color, sort_order, created_at, is_public
|
||||
from categories
|
||||
where is_public = true
|
||||
order by sort_order, name, id
|
||||
`),
|
||||
this.pool.query<PublicQuestionPack>(`
|
||||
select
|
||||
qp.id, qp.name, qp.description, qp.icon, qp.is_premium, qp.is_public,
|
||||
qp.is_explicit, qp.min_intensity, qp.max_intensity,
|
||||
qp.avg_intensity::double precision as avg_intensity,
|
||||
qp.sort_order, qp.category_id, qp.created_at,
|
||||
case when c.id is null then null else json_build_object(
|
||||
'id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon,
|
||||
'color', c.color, 'sort_order', c.sort_order, 'created_at', c.created_at,
|
||||
'is_public', c.is_public
|
||||
) end as category,
|
||||
json_build_array(json_build_object('count', count(q.id)::integer)) as questions
|
||||
from question_packs qp
|
||||
left join categories c on c.id = qp.category_id
|
||||
left join questions q on q.pack_id = qp.id
|
||||
where qp.is_public = true
|
||||
and (qp.category_id is null or c.is_public = true)
|
||||
and ($1::boolean = false or qp.is_explicit = false)
|
||||
and ($2::boolean = true or qp.max_intensity is null or qp.max_intensity <= $3::integer)
|
||||
group by qp.id, c.id
|
||||
order by qp.sort_order, qp.name, qp.id
|
||||
`, [profile.hide_nsfw, showAllIntensities, maxIntensity]),
|
||||
]);
|
||||
|
||||
return {
|
||||
categories: categoryResult.rows.map((category) => ({
|
||||
...category,
|
||||
created_at: new Date(category.created_at).toISOString(),
|
||||
})),
|
||||
packs: packResult.rows.map((pack) => ({
|
||||
...pack,
|
||||
created_at: new Date(pack.created_at).toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getEnabledPacks(userId: string): Promise<EnabledPacksResponse> {
|
||||
const profile = await this.profile(userId);
|
||||
if (!profile.couple_id) return { enabledPackIds: [] };
|
||||
const result = await this.pool.query<{ pack_id: string }>(`
|
||||
select cp.pack_id
|
||||
from couple_packs cp
|
||||
join question_packs qp on qp.id = cp.pack_id
|
||||
left join categories c on c.id = qp.category_id
|
||||
where cp.couple_id = $1 and cp.enabled = true and qp.is_public = true
|
||||
and (qp.category_id is null or c.is_public = true)
|
||||
order by qp.sort_order, cp.pack_id
|
||||
`, [profile.couple_id]);
|
||||
return { enabledPackIds: result.rows.map((row) => row.pack_id) };
|
||||
}
|
||||
|
||||
async setPackEnabled(userId: string, packId: string, enabled: boolean): Promise<EnabledPacksResponse> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const profile = await client.query<ProfilePreferencesRow>(
|
||||
'select couple_id, hide_nsfw from profiles where id = $1 limit 1 for share',
|
||||
[userId],
|
||||
);
|
||||
const coupleId = profile.rows[0]?.couple_id;
|
||||
if (!coupleId) throw new PacksDomainError('no_couple');
|
||||
const pack = await client.query(`
|
||||
select qp.id
|
||||
from question_packs qp
|
||||
left join categories c on c.id = qp.category_id
|
||||
where qp.id = $1 and qp.is_public = true
|
||||
and (qp.category_id is null or c.is_public = true)
|
||||
limit 1
|
||||
`, [packId]);
|
||||
if (pack.rowCount === 0) throw new PacksDomainError('pack_not_found');
|
||||
|
||||
const previous = await client.query<{ enabled: boolean }>(`
|
||||
select enabled from couple_packs
|
||||
where couple_id = $1 and pack_id = $2
|
||||
for update
|
||||
`, [coupleId, packId]);
|
||||
await client.query(`
|
||||
insert into couple_packs (couple_id, pack_id, enabled)
|
||||
values ($1, $2, $3)
|
||||
on conflict (couple_id, pack_id)
|
||||
do update set enabled = excluded.enabled
|
||||
`, [coupleId, packId, enabled]);
|
||||
if (enabled && previous.rows[0]?.enabled !== true) {
|
||||
await client.query('select queue_pack_change($1, $2)', [coupleId, userId]);
|
||||
}
|
||||
await client.query('commit');
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
return this.getEnabledPacks(userId);
|
||||
}
|
||||
|
||||
async getPackProgress(userId: string): Promise<PackProgressResponse> {
|
||||
const result = await this.pool.query<{
|
||||
pack_id: string;
|
||||
total_questions: number;
|
||||
answered_questions: number;
|
||||
}>(`
|
||||
select qp.id as pack_id,
|
||||
count(distinct q.id)::integer as total_questions,
|
||||
count(distinct r.question_id)::integer as answered_questions
|
||||
from question_packs qp
|
||||
left join categories c on c.id = qp.category_id
|
||||
left join questions q on q.pack_id = qp.id
|
||||
left join responses r on r.question_id = q.id and r.user_id = $1
|
||||
where qp.is_public = true and (qp.category_id is null or c.is_public = true)
|
||||
group by qp.id
|
||||
order by qp.sort_order, qp.id
|
||||
`, [userId]);
|
||||
return {
|
||||
progress: result.rows.map((row) => ({
|
||||
packId: row.pack_id,
|
||||
totalQuestions: row.total_questions,
|
||||
answeredQuestions: row.answered_questions,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { PacksDomainError, type PacksRepository } from './repository.js';
|
||||
|
||||
type PacksApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
|
||||
const uuidSchema = z.string().uuid();
|
||||
const toggleSchema = z.object({ enabled: z.boolean() }).strict();
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
export function registerPackRoutes(app: PacksApp, repository: PacksRepository): void {
|
||||
app.get('/v1/packs', async (c) => {
|
||||
const showAll = c.req.query('showAllIntensities') === 'true';
|
||||
return c.json(await repository.getCatalog(c.get('identity').id, showAll));
|
||||
});
|
||||
|
||||
app.get('/v1/me/enabled-packs', async (c) =>
|
||||
c.json(await repository.getEnabledPacks(c.get('identity').id)));
|
||||
|
||||
app.put('/v1/me/enabled-packs/:packId', async (c) => {
|
||||
const packId = uuidSchema.safeParse(c.req.param('packId'));
|
||||
if (!packId.success) return c.json(error('invalid_pack_id', 'Pack ID must be a UUID'), 400);
|
||||
const body = toggleSchema.safeParse(await c.req.json().catch(() => null));
|
||||
if (!body.success) return c.json(error('invalid_request', 'enabled must be a boolean'), 400);
|
||||
try {
|
||||
return c.json(await repository.setPackEnabled(c.get('identity').id, packId.data, body.data.enabled));
|
||||
} catch (cause) {
|
||||
if (cause instanceof PacksDomainError && cause.code === 'no_couple') {
|
||||
return c.json(error('no_couple', 'Join a couple before changing packs'), 409);
|
||||
}
|
||||
if (cause instanceof PacksDomainError && cause.code === 'pack_not_found') {
|
||||
return c.json(error('pack_not_found', 'Pack was not found'), 404);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/v1/me/pack-progress', async (c) =>
|
||||
c.json(await repository.getPackProgress(c.get('identity').id)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { boolean, index, integer, jsonb, numeric, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||
import { profiles } from '../../db/schema.js';
|
||||
import { couples } from '../couples/schema.js';
|
||||
|
||||
export const categories = pgTable('categories', {
|
||||
id: uuid('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
icon: text('icon'),
|
||||
color: text('color'),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
isPublic: boolean('is_public').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const questionPacks = pgTable('question_packs', {
|
||||
id: uuid('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
description: text('description'),
|
||||
icon: text('icon'),
|
||||
isPremium: boolean('is_premium').notNull().default(false),
|
||||
isPublic: boolean('is_public').notNull().default(true),
|
||||
isExplicit: boolean('is_explicit').notNull().default(false),
|
||||
minIntensity: integer('min_intensity'),
|
||||
maxIntensity: integer('max_intensity'),
|
||||
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2, mode: 'number' }),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
|
||||
|
||||
export const questions = pgTable('questions', {
|
||||
id: uuid('id').primaryKey(),
|
||||
packId: uuid('pack_id').notNull().references(() => questionPacks.id, { onDelete: 'cascade' }),
|
||||
text: text('text').notNull(),
|
||||
partnerText: text('partner_text'),
|
||||
intensity: integer('intensity').notNull().default(1),
|
||||
allowedCoupleGenders: text('allowed_couple_genders').array(),
|
||||
targetUserGenders: text('target_user_genders').array(),
|
||||
requiredProps: text('required_props').array(),
|
||||
questionType: text('question_type').notNull().default('swipe'),
|
||||
config: jsonb('config').notNull().default({}),
|
||||
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||
inverseOf: uuid('inverse_of'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [index('questions_pack_id_idx').on(table.packId)]);
|
||||
|
||||
export const responses = pgTable('responses', {
|
||||
id: uuid('id').primaryKey(),
|
||||
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
questionId: uuid('question_id').notNull().references(() => questions.id, { onDelete: 'cascade' }),
|
||||
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
|
||||
answer: text('answer').notNull(),
|
||||
responseData: jsonb('response_data'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
uniqueIndex('responses_user_question_idx').on(table.userId, table.questionId),
|
||||
index('responses_user_id_idx').on(table.userId),
|
||||
]);
|
||||
|
||||
export const couplePacks = pgTable('couple_packs', {
|
||||
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
|
||||
packId: uuid('pack_id').notNull().references(() => questionPacks.id, { onDelete: 'cascade' }),
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [primaryKey({ columns: [table.coupleId, table.packId] })]);
|
||||
@@ -0,0 +1,10 @@
|
||||
export { PostgresProfileSettingsRepository } from './repository.js';
|
||||
export type { ProfileSettingsRepository } from './repository.js';
|
||||
export { registerProfileSettingsRoutes } from './routes.js';
|
||||
export type {
|
||||
FeedbackSubmission,
|
||||
NotificationPreferenceKey,
|
||||
NotificationPreferences,
|
||||
ProfileUpdate,
|
||||
} from './types.js';
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
import type {
|
||||
FeedbackSubmission,
|
||||
NotificationPreferenceKey,
|
||||
NotificationPreferences,
|
||||
ProfileUpdate,
|
||||
} from './types.js';
|
||||
|
||||
interface PreferencesRow extends QueryResultRow {
|
||||
user_id: string;
|
||||
matches_enabled: boolean;
|
||||
messages_enabled: boolean;
|
||||
partner_activity_enabled: boolean;
|
||||
nudges_enabled: boolean;
|
||||
pack_changes_enabled: boolean;
|
||||
new_packs_enabled: boolean;
|
||||
streak_milestones_enabled: boolean;
|
||||
weekly_summary_enabled: boolean;
|
||||
unpaired_reminders_enabled: boolean;
|
||||
catchup_reminders_enabled: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export class ProfileSettingsError extends Error {
|
||||
constructor(readonly code: 'profile_not_found') {
|
||||
super(code === 'profile_not_found' ? 'Profile not found. Please complete signup first.' : code);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProfileSettingsRepository {
|
||||
updateProfile(userId: string, update: ProfileUpdate): Promise<void>;
|
||||
updateLastActive(userId: string): Promise<void>;
|
||||
getNotificationPreferences(userId: string): Promise<NotificationPreferences>;
|
||||
updateNotificationPreference(userId: string, key: NotificationPreferenceKey, value: boolean): Promise<NotificationPreferences>;
|
||||
submitFeedback(userId: string, submission: FeedbackSubmission): Promise<{ id: string; created_at: string }>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const profileColumns: Record<keyof ProfileUpdate, string> = {
|
||||
name: 'name',
|
||||
gender: 'gender',
|
||||
usage_reason: 'usage_reason',
|
||||
max_intensity: 'max_intensity',
|
||||
show_explicit_content: 'show_explicit_content',
|
||||
hide_nsfw: 'hide_nsfw',
|
||||
onboarding_completed: 'onboarding_completed',
|
||||
onboarding_version: 'onboarding_version',
|
||||
public_key_jwk: 'public_key_jwk',
|
||||
push_token: 'push_token',
|
||||
};
|
||||
|
||||
function toPreferences(row: PreferencesRow): NotificationPreferences {
|
||||
return {
|
||||
user_id: row.user_id,
|
||||
matches_enabled: row.matches_enabled,
|
||||
messages_enabled: row.messages_enabled,
|
||||
partner_activity_enabled: row.partner_activity_enabled,
|
||||
nudges_enabled: row.nudges_enabled,
|
||||
pack_changes_enabled: row.pack_changes_enabled,
|
||||
new_packs_enabled: row.new_packs_enabled,
|
||||
streak_milestones_enabled: row.streak_milestones_enabled,
|
||||
weekly_summary_enabled: row.weekly_summary_enabled,
|
||||
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
|
||||
catchup_reminders_enabled: row.catchup_reminders_enabled,
|
||||
created_at: row.created_at.toISOString(),
|
||||
updated_at: row.updated_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function assertChanged(rowCount: number | null): void {
|
||||
if (rowCount === 0) throw new ProfileSettingsError('profile_not_found');
|
||||
}
|
||||
|
||||
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
|
||||
const entries = Object.entries(update) as [keyof ProfileUpdate, ProfileUpdate[keyof ProfileUpdate]][];
|
||||
if (entries.length === 0) return;
|
||||
const assignments = entries.map(([key], index) => `${profileColumns[key]} = $${index + 2}`);
|
||||
const values = entries.map(([, value]) => value);
|
||||
const result = await this.pool.query(
|
||||
`update profiles set ${assignments.join(', ')}, updated_at = now() where id = $1`,
|
||||
[userId, ...values],
|
||||
);
|
||||
assertChanged(result.rowCount);
|
||||
}
|
||||
|
||||
async updateLastActive(userId: string): Promise<void> {
|
||||
const result = await this.pool.query(
|
||||
'update profiles set last_active_at = now(), updated_at = now() where id = $1',
|
||||
[userId],
|
||||
);
|
||||
assertChanged(result.rowCount);
|
||||
}
|
||||
|
||||
async getNotificationPreferences(userId: string): Promise<NotificationPreferences> {
|
||||
const result = await this.pool.query<PreferencesRow>(
|
||||
`insert into notification_preferences (user_id) values ($1)
|
||||
on conflict (user_id) do update set user_id = excluded.user_id
|
||||
returning *`,
|
||||
[userId],
|
||||
);
|
||||
return toPreferences(result.rows[0]!);
|
||||
}
|
||||
|
||||
async updateNotificationPreference(
|
||||
userId: string,
|
||||
key: NotificationPreferenceKey,
|
||||
value: boolean,
|
||||
): Promise<NotificationPreferences> {
|
||||
await this.getNotificationPreferences(userId);
|
||||
const result = await this.pool.query<PreferencesRow>(
|
||||
`update notification_preferences set ${key} = $2, updated_at = now() where user_id = $1 returning *`,
|
||||
[userId, value],
|
||||
);
|
||||
return toPreferences(result.rows[0]!);
|
||||
}
|
||||
|
||||
async submitFeedback(
|
||||
userId: string,
|
||||
submission: FeedbackSubmission,
|
||||
): Promise<{ id: string; created_at: string }> {
|
||||
const id = randomUUID();
|
||||
const result = await this.pool.query<{ id: string; created_at: Date }>(
|
||||
`insert into feedback (id, user_id, type, title, description, question_id, device_info, screenshot_media_id)
|
||||
select $1, $2, $3, $4, $5, $6, $7, mo.id from (select $8::uuid id) requested
|
||||
left join media_objects mo on mo.id=requested.id and mo.owner_id=$2 and mo.kind='feedback' and mo.deleted_at is null
|
||||
where requested.id is null or mo.id is not null returning id, created_at`,
|
||||
[id, userId, submission.type, submission.title, submission.description,
|
||||
submission.question_id ?? null, submission.device_info ?? {}, submission.screenshot_media_id ?? null],
|
||||
);
|
||||
if (!result.rows[0]) throw new Error('Feedback screenshot is not accessible');
|
||||
return { id: result.rows[0]!.id, created_at: result.rows[0]!.created_at.toISOString() };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { ProfileSettingsError, type ProfileSettingsRepository } from './repository.js';
|
||||
import { notificationPreferenceKeys } from './types.js';
|
||||
|
||||
type ProfileSettingsApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
|
||||
const profileUpdateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
gender: z.enum(['male', 'female', 'non-binary', 'prefer-not-to-say']).nullable().optional(),
|
||||
usage_reason: z.enum(['improve_communication', 'spice_up_intimacy', 'deeper_connection', 'have_fun', 'strengthen_relationship']).nullable().optional(),
|
||||
max_intensity: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]).optional(),
|
||||
show_explicit_content: z.boolean().optional(),
|
||||
hide_nsfw: z.boolean().optional(),
|
||||
onboarding_completed: z.boolean().optional(),
|
||||
onboarding_version: z.number().int().min(0).optional(),
|
||||
public_key_jwk: z.record(z.unknown()).nullable().optional(),
|
||||
push_token: z.string().trim().min(1).max(512).nullable().optional(),
|
||||
}).strict().refine((body) => Object.keys(body).length > 0, 'At least one profile field is required');
|
||||
|
||||
const preferenceSchema = z.object({
|
||||
key: z.enum(notificationPreferenceKeys),
|
||||
value: z.boolean(),
|
||||
}).strict();
|
||||
|
||||
const feedbackSchema = z.object({
|
||||
type: z.enum(['bug', 'feature_request', 'general', 'question']),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
description: z.string().trim().min(1).max(10_000),
|
||||
question_id: z.string().uuid().nullable().optional(),
|
||||
device_info: z.record(z.unknown()).optional(),
|
||||
screenshot_media_id: z.string().uuid().nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
async function parseJson(c: { req: { json(): Promise<unknown> } }): Promise<unknown> {
|
||||
return c.req.json().catch(() => null);
|
||||
}
|
||||
|
||||
async function handleProfileError<T>(operation: () => Promise<T>): Promise<T | null> {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (cause) {
|
||||
if (cause instanceof ProfileSettingsError) return null;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerProfileSettingsRoutes(app: ProfileSettingsApp, repository: ProfileSettingsRepository): void {
|
||||
app.patch('/v1/me/profile', async (c) => {
|
||||
const parsed = profileUpdateSchema.safeParse(await parseJson(c));
|
||||
if (!parsed.success) return c.json(error('invalid_profile_update', 'Profile update contains invalid or unsupported fields'), 400);
|
||||
const result = await handleProfileError(() => repository.updateProfile(c.get('identity').id, parsed.data));
|
||||
return result === null
|
||||
? c.json(error('profile_not_found', 'Profile not found. Please complete signup first.'), 404)
|
||||
: c.json({ updated: true });
|
||||
});
|
||||
|
||||
app.post('/v1/me/activity', async (c) => {
|
||||
const result = await handleProfileError(() => repository.updateLastActive(c.get('identity').id));
|
||||
return result === null
|
||||
? c.json(error('profile_not_found', 'Profile not found. Please complete signup first.'), 404)
|
||||
: c.body(null, 204);
|
||||
});
|
||||
|
||||
app.get('/v1/me/notification-preferences', async (c) =>
|
||||
c.json(await repository.getNotificationPreferences(c.get('identity').id)));
|
||||
|
||||
app.patch('/v1/me/notification-preferences', async (c) => {
|
||||
const parsed = preferenceSchema.safeParse(await parseJson(c));
|
||||
if (!parsed.success) return c.json(error('invalid_preference', 'A supported preference key and boolean value are required'), 400);
|
||||
return c.json(await repository.updateNotificationPreference(
|
||||
c.get('identity').id,
|
||||
parsed.data.key,
|
||||
parsed.data.value,
|
||||
));
|
||||
});
|
||||
|
||||
app.post('/v1/feedback', async (c) => {
|
||||
const parsed = feedbackSchema.safeParse(await parseJson(c));
|
||||
if (!parsed.success) return c.json(error('invalid_feedback', 'Valid feedback fields are required'), 400);
|
||||
return c.json(await repository.submitFeedback(c.get('identity').id, parsed.data), 201);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export const notificationPreferenceKeys = [
|
||||
'matches_enabled',
|
||||
'messages_enabled',
|
||||
'partner_activity_enabled',
|
||||
'nudges_enabled',
|
||||
'pack_changes_enabled',
|
||||
'new_packs_enabled',
|
||||
'streak_milestones_enabled',
|
||||
'weekly_summary_enabled',
|
||||
'unpaired_reminders_enabled',
|
||||
'catchup_reminders_enabled',
|
||||
] as const;
|
||||
|
||||
export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number];
|
||||
|
||||
export interface NotificationPreferences extends Record<NotificationPreferenceKey, boolean> {
|
||||
user_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProfileUpdate {
|
||||
name?: string;
|
||||
gender?: 'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null;
|
||||
usage_reason?: 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null;
|
||||
max_intensity?: 1 | 2 | 3 | 4 | 5;
|
||||
show_explicit_content?: boolean;
|
||||
hide_nsfw?: boolean;
|
||||
onboarding_completed?: boolean;
|
||||
onboarding_version?: number;
|
||||
public_key_jwk?: Record<string, unknown> | null;
|
||||
push_token?: string | null;
|
||||
}
|
||||
|
||||
export interface FeedbackSubmission {
|
||||
type: 'bug' | 'feature_request' | 'general' | 'question';
|
||||
title: string;
|
||||
description: string;
|
||||
question_id?: string | null;
|
||||
device_info?: Record<string, unknown>;
|
||||
screenshot_media_id?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { createApp } from './app.js';
|
||||
import { createAuthVerifier } from './auth.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { PostgresRepository } from './db/repository.js';
|
||||
import { PostgresAppDataRepository } from './domains/app-data/repository.js';
|
||||
import { AdminRequestAuth } from './domains/admin/auth.js';
|
||||
import { SupabaseAdminAuthDirectory } from './domains/admin/auth-directory.js';
|
||||
import { PostgresAdminRepository } from './domains/admin/repository.js';
|
||||
import { BillingService, PostgresBillingRepository } from './domains/billing/index.js';
|
||||
import {
|
||||
AccountOperationsService,
|
||||
ExpoPartnerNotifier,
|
||||
HttpRevenueCatClient,
|
||||
PostgresAccountOperationsRepository,
|
||||
SupabaseAuthAdminClient,
|
||||
} from './domains/account-operations/index.js';
|
||||
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
||||
import { PostgresChatRepository } from './domains/chat/repository.js';
|
||||
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
||||
import { CoupleService } from './domains/couples/service.js';
|
||||
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
||||
import { PostgresProfileSettingsRepository } from './domains/profile-settings/repository.js';
|
||||
import { FilesystemMediaStorage, PostgresMediaRepository } from './domains/media/index.js';
|
||||
|
||||
const config = loadConfig();
|
||||
const authVerifier = createAuthVerifier(config);
|
||||
const repository = new PostgresRepository(config.databaseUrl);
|
||||
const coupleRepository = new PostgresCoupleRepository(config.databaseUrl);
|
||||
const packsRepository = new PostgresPacksRepository(config.databaseUrl);
|
||||
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
|
||||
const chatRepository = new PostgresChatRepository(config.databaseUrl);
|
||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
|
||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
|
||||
const accountOperationsService = new AccountOperationsService(
|
||||
accountOperationsRepository,
|
||||
new SupabaseAuthAdminClient(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
||||
new HttpRevenueCatClient(config.revenueCatApiKey, config.revenueCatEntitlementId),
|
||||
new ExpoPartnerNotifier(),
|
||||
);
|
||||
const mediaRepository = new PostgresMediaRepository(config.databaseUrl);
|
||||
const mediaStorage = new FilesystemMediaStorage(
|
||||
config.mediaRoot,
|
||||
config.mediaSigningSecret,
|
||||
config.mediaPublicBaseUrl,
|
||||
);
|
||||
const appDataRepository = new PostgresAppDataRepository(config.databaseUrl);
|
||||
const billingRepository = new PostgresBillingRepository(config.databaseUrl);
|
||||
const billingService = new BillingService(billingRepository, config.revenueCatWebhookSecret);
|
||||
const adminRepository = new PostgresAdminRepository(config.databaseUrl, {
|
||||
adminPrivateKeyJwk: config.adminPrivateKeyJwk,
|
||||
mediaRoot: config.mediaRoot,
|
||||
authDirectory: new SupabaseAdminAuthDirectory(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
||||
});
|
||||
const app = createApp({
|
||||
auth: authVerifier,
|
||||
repository,
|
||||
coupleService: new CoupleService(coupleRepository),
|
||||
packsRepository,
|
||||
answersRepository,
|
||||
chatRepository,
|
||||
profileSettingsRepository,
|
||||
accountOperationsService,
|
||||
mediaRepository,
|
||||
mediaStorage,
|
||||
appDataRepository,
|
||||
billingService,
|
||||
adminRepository,
|
||||
adminAuth: new AdminRequestAuth(
|
||||
authVerifier,
|
||||
config.adminApiServiceToken,
|
||||
config.adminApiServiceUserId,
|
||||
),
|
||||
});
|
||||
|
||||
const server = serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||
console.log(`Sauci API listening on http://127.0.0.1:${info.port}`);
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
|
||||
async function shutdown(signal: string): Promise<void> {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`Received ${signal}; shutting down`);
|
||||
const forcedExit = setTimeout(() => {
|
||||
console.error('Graceful shutdown timed out');
|
||||
process.exit(1);
|
||||
}, 10_000);
|
||||
forcedExit.unref();
|
||||
|
||||
server.close(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
repository.close(),
|
||||
coupleRepository.close(),
|
||||
packsRepository.close(),
|
||||
answersRepository.close(),
|
||||
chatRepository.close(),
|
||||
profileSettingsRepository.close(),
|
||||
accountOperationsRepository.close(),
|
||||
mediaRepository.close(),
|
||||
appDataRepository.close(),
|
||||
billingRepository.close(),
|
||||
adminRepository.close(),
|
||||
]);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Failed to close database connections', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
@@ -0,0 +1,24 @@
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { MigrationCheckpoint } from './types.js';
|
||||
|
||||
export async function loadCheckpoint(path: string, finalSync: boolean): Promise<MigrationCheckpoint> {
|
||||
if (!finalSync) {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(path, 'utf8')) as MigrationCheckpoint;
|
||||
if (parsed.version === 1 && parsed.mode === 'initial') return parsed;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
return { version: 1, runId: randomUUID(), mode: finalSync ? 'final' : 'initial', completedTables: [], storage: {}, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
export async function saveCheckpoint(path: string, checkpoint: MigrationCheckpoint): Promise<void> {
|
||||
checkpoint.updatedAt = new Date().toISOString();
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const temporary = `${path}.${process.pid}.tmp`;
|
||||
await writeFile(temporary, `${JSON.stringify(checkpoint, null, 2)}\n`, { mode: 0o600 });
|
||||
await rename(temporary, path);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { DatabaseMigrationOptions, TableResult } from './types.js';
|
||||
|
||||
interface TableSpec { name: string; key: string[]; replace?: boolean }
|
||||
export const TABLES: TableSpec[] = [
|
||||
{ name: 'couples', key: ['id'] }, { name: 'profiles', key: ['id'] },
|
||||
{ name: 'admin_users', key: ['id'] }, { name: 'master_keys', key: ['id'] },
|
||||
{ name: 'categories', key: ['id'] }, { name: 'question_packs', key: ['id'] },
|
||||
{ name: 'topics', key: ['id'] }, { name: 'pack_topics', key: ['pack_id', 'topic_id'] },
|
||||
{ name: 'questions', key: ['id'] }, { name: 'app_config', key: ['id'], replace: true },
|
||||
{ name: 'ai_config', key: ['id'], replace: true },
|
||||
{ name: 'dare_packs', key: ['id'] }, { name: 'dares', key: ['id'] },
|
||||
{ name: 'sent_dares', key: ['id'] }, { name: 'dare_messages', key: ['id'] },
|
||||
{ name: 'couple_packs', key: ['couple_id', 'pack_id'] }, { name: 'responses', key: ['id'] },
|
||||
{ name: 'matches', key: ['id'] }, { name: 'couple_streaks', key: ['couple_id'] },
|
||||
{ name: 'messages', key: ['id'] }, { name: 'match_archives', key: ['id'] },
|
||||
{ name: 'message_deletions', key: ['message_id', 'user_id'] }, { name: 'message_reports', key: ['id'] },
|
||||
{ name: 'notification_preferences', key: ['user_id'] }, { name: 'feedback', key: ['id'] },
|
||||
{ name: 'subscriptions', key: ['id'] }, { name: 'revenuecat_webhook_events', key: ['id'] },
|
||||
{ name: 'redemption_codes', key: ['id'] }, { name: 'code_redemptions', key: ['id'] },
|
||||
{ name: 'feature_interests', key: ['user_id', 'feature'] }, { name: 'live_draw_sessions', key: ['couple_id'] },
|
||||
{ name: 'catchup_reminder_tracking', key: ['user_id'] }, { name: 'audit_logs', key: ['id'] },
|
||||
];
|
||||
|
||||
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
|
||||
async function columns(pool: Pool | PoolClient, table: string): Promise<string[]> {
|
||||
const result = await pool.query<{ column_name: string }>(`select column_name from information_schema.columns where table_schema=current_schema() and table_name=$1 order by ordinal_position`, [table]);
|
||||
return result.rows.map((row) => row.column_name);
|
||||
}
|
||||
async function count(pool: Pool | PoolClient, table: string): Promise<number> {
|
||||
return Number((await pool.query<{ count: string }>(`select count(*) count from ${quote(table)}`)).rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
async function upsertRows(client: PoolClient, spec: TableSpec, names: string[], rows: Record<string, unknown>[]): Promise<number> {
|
||||
if (spec.replace) await client.query(`delete from ${quote(spec.name)}`);
|
||||
if (!rows.length) return 0;
|
||||
const update = names.filter((name) => !spec.key.includes(name));
|
||||
const conflict = spec.key.map(quote).join(',');
|
||||
const action = update.length ? `do update set ${update.map((name) => `${quote(name)}=excluded.${quote(name)}`).join(',')}` : 'do nothing';
|
||||
for (const row of rows) {
|
||||
await client.query(`insert into ${quote(spec.name)} (${names.map(quote).join(',')}) values (${names.map((_, index) => `$${index + 1}`).join(',')}) on conflict (${conflict}) ${action}`, names.map((name) => row[name]));
|
||||
}
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function pruneRows(client: PoolClient, spec: TableSpec, sourceRows: Record<string, unknown>[]): Promise<number> {
|
||||
if (spec.replace || !spec.key.length) return 0;
|
||||
const before = await count(client, spec.name);
|
||||
if (!sourceRows.length) await client.query(`delete from ${quote(spec.name)}`);
|
||||
else {
|
||||
const values: unknown[] = [];
|
||||
const tuples = sourceRows.map((row) => `(${spec.key.map((key) => { values.push(row[key]); return `$${values.length}`; }).join(',')})`).join(',');
|
||||
const keyTuple = `(${spec.key.map(quote).join(',')})`;
|
||||
await client.query(`delete from ${quote(spec.name)} where ${keyTuple} not in (${tuples})`, values);
|
||||
}
|
||||
return before - await count(client, spec.name);
|
||||
}
|
||||
|
||||
export async function migrateDatabase(source: Pool, target: Pool, options: DatabaseMigrationOptions): Promise<TableResult[]> {
|
||||
const results: TableResult[] = [];
|
||||
for (const spec of TABLES) {
|
||||
const [sourceColumns, targetColumns] = await Promise.all([columns(source, spec.name), columns(target, spec.name)]);
|
||||
if (!sourceColumns.length || !targetColumns.length) {
|
||||
results.push({ table: spec.name, sourceRows: 0, targetRows: targetColumns.length ? await count(target, spec.name) : 0, importedRows: 0, prunedRows: 0, status: 'skipped', note: !sourceColumns.length ? 'source table absent' : 'target table absent' });
|
||||
continue;
|
||||
}
|
||||
const common = targetColumns.filter((name) => sourceColumns.includes(name));
|
||||
const sourceOnly = sourceColumns.filter((name) => !targetColumns.includes(name));
|
||||
if (!spec.key.every((key) => common.includes(key))) throw new Error(`${spec.name} is missing required migration key columns`);
|
||||
const sourceRows = (await source.query<Record<string, unknown>>(`select ${common.map(quote).join(',')} from ${quote(spec.name)}`)).rows;
|
||||
let importedRows = 0; let prunedRows = 0;
|
||||
const skip = !options.finalSync && options.checkpoint.completedTables.includes(spec.name);
|
||||
if (!options.dryRun && !skip) {
|
||||
const client = await target.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
// Historical imports must not enqueue user notifications, classification,
|
||||
// Discord events, or other operational side effects.
|
||||
await client.query("set local sauci.suppress_operations = 'on'");
|
||||
importedRows = await upsertRows(client, spec, common, sourceRows);
|
||||
if (options.finalSync && options.prune) prunedRows = await pruneRows(client, spec, sourceRows);
|
||||
await client.query('commit');
|
||||
} catch (error) { await client.query('rollback'); throw error; } finally { client.release(); }
|
||||
options.checkpoint.completedTables = [...new Set([...options.checkpoint.completedTables, spec.name])];
|
||||
await options.saveCheckpoint?.(options.checkpoint);
|
||||
}
|
||||
const targetRows = await count(target, spec.name);
|
||||
const notes = [skip ? 'resumed from checkpoint' : '', sourceOnly.length ? `legacy-only columns intentionally omitted: ${sourceOnly.join(', ')}` : ''].filter(Boolean);
|
||||
results.push({ table: spec.name, sourceRows: sourceRows.length, targetRows, importedRows, prunedRows, status: sourceRows.length === targetRows ? 'equal' : 'different', ...(notes.length ? { note: notes.join('; ') } : {}) });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function relationalParity(target: Pool): Promise<Record<string, number>> {
|
||||
const checks: Record<string, string> = {
|
||||
profiles_without_couple: 'select count(*) count from profiles p left join couples c on c.id=p.couple_id where p.couple_id is not null and c.id is null',
|
||||
responses_with_missing_owner: 'select count(*) count from responses r left join profiles p on p.id=r.user_id left join couples c on c.id=r.couple_id left join questions q on q.id=r.question_id where p.id is null or c.id is null or q.id is null',
|
||||
matches_with_missing_relation: 'select count(*) count from matches m left join couples c on c.id=m.couple_id left join questions q on q.id=m.question_id where c.id is null or q.id is null',
|
||||
messages_with_missing_relation: 'select count(*) count from messages m left join matches x on x.id=m.match_id left join profiles p on p.id=m.user_id where x.id is null or p.id is null',
|
||||
couple_mismatch_responses: 'select count(*) count from responses r join profiles p on p.id=r.user_id where p.couple_id is distinct from r.couple_id',
|
||||
};
|
||||
const result: Record<string, number> = {};
|
||||
for (const [name, sql] of Object.entries(checks)) result[name] = Number((await target.query<{ count: string }>(sql)).rows[0]?.count ?? 0);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { Pool } from 'pg';
|
||||
import { loadCheckpoint, saveCheckpoint } from './checkpoint.js';
|
||||
import { migrateDatabase, relationalParity } from './database.js';
|
||||
import { validateEndpoints, redactDatabaseUrl } from './safety.js';
|
||||
import { createSupabaseDownloader, migrateStorage } from './storage.js';
|
||||
import type { ParityReport } from './types.js';
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const dryRun = args.has('--dry-run'); const finalSync = args.has('--final-sync'); const prune = args.has('--prune'); const includeStorage = args.has('--storage');
|
||||
if (prune && (!finalSync || dryRun)) throw new Error('--prune requires a non-dry-run --final-sync with the legacy source stopped');
|
||||
if (includeStorage && !process.env.MEDIA_ROOT) throw new Error('MEDIA_ROOT is required with --storage');
|
||||
const endpoints = validateEndpoints(process.env.SOURCE_DATABASE_URL, process.env.TARGET_DATABASE_URL, process.env.MIGRATION_TARGET_HOST_ALLOWLIST);
|
||||
const checkpointPath = resolve(process.env.MIGRATION_CHECKPOINT_FILE ?? '.migration-state/backend-cutover.json');
|
||||
const reportPath = resolve(process.env.MIGRATION_REPORT_FILE ?? '.migration-state/backend-parity.json');
|
||||
const checkpoint = await loadCheckpoint(checkpointPath, finalSync);
|
||||
const source = new Pool({ connectionString: endpoints.source, max: 2 }); const target = new Pool({ connectionString: endpoints.target, max: 2 });
|
||||
const startedAt = new Date().toISOString();
|
||||
console.log(`Migration ${checkpoint.runId}: ${redactDatabaseUrl(endpoints.source)} -> ${redactDatabaseUrl(endpoints.target)} (${dryRun ? 'dry-run' : finalSync ? 'final-sync' : 'initial'})`);
|
||||
try {
|
||||
const save = (value: typeof checkpoint) => saveCheckpoint(checkpointPath, value);
|
||||
const tables = await migrateDatabase(source, target, { dryRun, finalSync, prune, checkpoint, saveCheckpoint: save });
|
||||
const storage = includeStorage ? await migrateStorage(source, target, {
|
||||
dryRun, prune: finalSync && prune, mediaRoot: process.env.MEDIA_ROOT ?? '', checkpoint, saveCheckpoint: save,
|
||||
download: createSupabaseDownloader(process.env.SOURCE_STORAGE_URL ?? '', process.env.SOURCE_STORAGE_SERVICE_ROLE_KEY ?? ''),
|
||||
}) : null;
|
||||
const relational = await relationalParity(target);
|
||||
const parity = tables.filter((row) => row.status !== 'skipped').every((row) => row.status === 'equal') && Object.values(relational).every((count) => count === 0) && (!storage || (storage.sourceFiles === storage.targetFiles && storage.failedFiles === 0 && storage.missingOnDisk.length === 0));
|
||||
const report: ParityReport = { runId: checkpoint.runId, dryRun, finalSync, startedAt, finishedAt: new Date().toISOString(), tables, storage, relational, parity };
|
||||
await mkdir(dirname(reportPath), { recursive: true }); await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 });
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (!parity && finalSync) process.exitCode = 2;
|
||||
} finally { await Promise.all([source.end(), target.end()]); }
|
||||
@@ -0,0 +1,30 @@
|
||||
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', 'host.docker.internal']);
|
||||
|
||||
function parseDatabaseUrl(label: string, value: string | undefined): URL {
|
||||
if (!value) throw new Error(`${label} is required`);
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(value); } catch { throw new Error(`${label} must be a valid PostgreSQL URL`); }
|
||||
if (!['postgres:', 'postgresql:'].includes(parsed.protocol)) throw new Error(`${label} must use postgres:// or postgresql://`);
|
||||
if (!parsed.hostname || !parsed.pathname.slice(1)) throw new Error(`${label} must identify a host and database`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function databaseIdentity(url: URL): string {
|
||||
return `${url.hostname.toLowerCase()}:${url.port || '5432'}/${decodeURIComponent(url.pathname.slice(1))}`;
|
||||
}
|
||||
|
||||
export function validateEndpoints(sourceValue: string | undefined, targetValue: string | undefined, allowlistValue?: string): { source: string; target: string } {
|
||||
const source = parseDatabaseUrl('SOURCE_DATABASE_URL', sourceValue);
|
||||
const target = parseDatabaseUrl('TARGET_DATABASE_URL', targetValue);
|
||||
if (databaseIdentity(source) === databaseIdentity(target)) throw new Error('Source and target must be different PostgreSQL databases');
|
||||
const allowlist = new Set((allowlistValue ?? '').split(',').map((item) => item.trim().toLowerCase()).filter(Boolean));
|
||||
if (!LOCAL_HOSTS.has(target.hostname.toLowerCase()) && !allowlist.has(target.hostname.toLowerCase())) {
|
||||
throw new Error(`Target host is not allowlisted; add it explicitly to MIGRATION_TARGET_HOST_ALLOWLIST`);
|
||||
}
|
||||
return { source: source.toString(), target: target.toString() };
|
||||
}
|
||||
|
||||
export function redactDatabaseUrl(value: string): string {
|
||||
const parsed = new URL(value); parsed.username = parsed.username ? '***' : ''; parsed.password = parsed.password ? '***' : '';
|
||||
return parsed.toString();
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve, sep } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { Pool } from 'pg';
|
||||
import type { StorageMigrationOptions, StorageResult } from './types.js';
|
||||
|
||||
interface StorageObject {
|
||||
id: string;
|
||||
bucket_id: string;
|
||||
name: string;
|
||||
owner_id: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
const BUCKET_KIND: Record<string, 'avatar' | 'response' | 'chat' | 'feedback'> = {
|
||||
avatars: 'avatar', 'response-media': 'response', 'chat-media': 'chat', 'feedback-screenshots': 'feedback',
|
||||
};
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const quote = (value: string) => `"${value.replaceAll('"', '""')}"`;
|
||||
|
||||
function safeStorageKey(bucket: string, name: string): string {
|
||||
const normalized = `${bucket}/${name}`.replaceAll('\\', '/');
|
||||
if (!BUCKET_KIND[bucket] || normalized.startsWith('/') || normalized.split('/').some((part) => !part || part === '.' || part === '..')) throw new Error(`Unsafe storage object path: ${bucket}/${name}`);
|
||||
return normalized;
|
||||
}
|
||||
function destination(rootValue: string, key: string): string {
|
||||
const root = resolve(rootValue); const path = resolve(root, key);
|
||||
if (!path.startsWith(`${root}${sep}`)) throw new Error(`Storage path escapes MEDIA_ROOT: ${key}`);
|
||||
return path;
|
||||
}
|
||||
function metadataString(metadata: Record<string, unknown> | null, ...keys: string[]): string | null {
|
||||
for (const key of keys) if (typeof metadata?.[key] === 'string') return metadata[key] as string;
|
||||
return null;
|
||||
}
|
||||
function metadataSize(metadata: Record<string, unknown> | null): number {
|
||||
const value = metadata?.size ?? metadata?.contentLength;
|
||||
return typeof value === 'number' && value >= 0 ? value : Number(value ?? 0) || 0;
|
||||
}
|
||||
|
||||
export function createSupabaseDownloader(baseUrl: string, serviceRoleKey: string): (bucket: string, name: string) => Promise<Uint8Array> {
|
||||
const base = baseUrl.replace(/\/$/, '');
|
||||
if (!/^https:\/\//.test(base) && !/^http:\/\/(localhost|127\.0\.0\.1)(:|\/)/.test(base)) throw new Error('SOURCE_STORAGE_URL must use HTTPS unless it is localhost');
|
||||
if (!serviceRoleKey) throw new Error('SOURCE_STORAGE_SERVICE_ROLE_KEY is required for storage copy');
|
||||
return async (bucket, name) => {
|
||||
const path = [bucket, ...name.split('/')].map(encodeURIComponent).join('/');
|
||||
const response = await fetch(`${base}/storage/v1/object/authenticated/${path}`, { headers: { authorization: `Bearer ${serviceRoleKey}`, apikey: serviceRoleKey } });
|
||||
if (!response.ok) throw new Error(`Storage download failed for ${bucket}/${name}: HTTP ${response.status}`);
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
};
|
||||
}
|
||||
|
||||
async function mediaRelation(target: Pool, object: StorageObject, key: string) {
|
||||
const first = object.name.split('/')[0];
|
||||
const owner = object.owner_id && uuidPattern.test(object.owner_id) ? object.owner_id : (uuidPattern.test(first) && object.bucket_id !== 'chat-media' ? first : null);
|
||||
if (!owner) throw new Error(`Cannot determine owner for ${key}`);
|
||||
const profile = await target.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [owner]);
|
||||
if (!profile.rows[0]) throw new Error(`Storage owner does not exist in target for ${key}`);
|
||||
let matchId: string | null = null; let questionId: string | null = null;
|
||||
if (object.bucket_id === 'chat-media' && uuidPattern.test(first)) matchId = first;
|
||||
if (object.bucket_id === 'response-media') {
|
||||
const candidate = object.name.split('/').at(-1)?.split('_')[0] ?? '';
|
||||
if (uuidPattern.test(candidate)) questionId = candidate;
|
||||
}
|
||||
return { ownerId: owner, coupleId: profile.rows[0].couple_id, matchId, questionId };
|
||||
}
|
||||
|
||||
async function rewriteLegacyReference(target: Pool, object: StorageObject): Promise<void> {
|
||||
const reference = `media:${object.id}`;
|
||||
const legacyName = object.name;
|
||||
const legacyKey = `${object.bucket_id}/${legacyName}`;
|
||||
const matchesLegacy = (column: string) => `(
|
||||
${column}=$2 or ${column}=$3 or
|
||||
(right(${column},length($2))=$2 and position('/${object.bucket_id}/' in ${column})>0)
|
||||
)`;
|
||||
if (object.bucket_id === 'avatars') {
|
||||
await target.query(`update profiles set avatar_url=$1 where ${matchesLegacy('avatar_url')}`, [reference, legacyName, legacyKey]);
|
||||
} else if (object.bucket_id === 'chat-media') {
|
||||
await target.query(`update messages set media_path=$1 where ${matchesLegacy('media_path')}`, [reference, legacyName, legacyKey]);
|
||||
} else if (object.bucket_id === 'feedback-screenshots') {
|
||||
await target.query(`update feedback set screenshot_url=$1 where ${matchesLegacy('screenshot_url')}`, [reference, legacyName, legacyKey]);
|
||||
} else if (object.bucket_id === 'response-media') {
|
||||
await target.query(`
|
||||
update responses
|
||||
set response_data=jsonb_set(response_data,'{media_path}',to_jsonb($1::text),true)
|
||||
where response_data is not null and ${matchesLegacy("response_data->>'media_path'")}
|
||||
`, [reference, legacyName, legacyKey]);
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateStorage(source: Pool, target: Pool, options: StorageMigrationOptions): Promise<StorageResult> {
|
||||
const schema = options.storageSchema ?? 'storage';
|
||||
if (!/^[a-z_][a-z0-9_]*$/i.test(schema)) throw new Error('Invalid storage schema');
|
||||
const objects = await source.query<StorageObject>(`select id,bucket_id,name,owner_id,metadata,created_at,updated_at from ${quote(schema)}.objects where bucket_id=any($1::text[]) order by bucket_id,name`, [Object.keys(BUCKET_KIND)]);
|
||||
const result: StorageResult = { sourceFiles: objects.rowCount ?? objects.rows.length, targetFiles: 0, copiedFiles: 0, skippedFiles: 0, prunedFiles: 0, failedFiles: 0, sourceBytes: 0, copiedBytes: 0, missingOnDisk: [], failures: [] };
|
||||
const sourceKeys = new Set<string>();
|
||||
for (const object of objects.rows) {
|
||||
const key = safeStorageKey(object.bucket_id, object.name); const expectedSize = metadataSize(object.metadata); result.sourceBytes += expectedSize;
|
||||
sourceKeys.add(key);
|
||||
const updatedAt = object.updated_at ? new Date(object.updated_at).toISOString() : null;
|
||||
const marker = options.checkpoint.storage[key];
|
||||
if (marker && marker.updatedAt === updatedAt && (!expectedSize || marker.byteSize === expectedSize)) {
|
||||
if (options.dryRun) { result.skippedFiles += 1; continue; }
|
||||
const registered = await target.query('select 1 from media_objects where id=$1 and storage_key=$2 and byte_size=$3', [object.id, key, marker.byteSize]);
|
||||
try {
|
||||
if (registered.rowCount && (await stat(destination(options.mediaRoot, key))).size === marker.byteSize) {
|
||||
await rewriteLegacyReference(target, object);
|
||||
result.skippedFiles += 1;
|
||||
continue;
|
||||
}
|
||||
} catch { /* checkpoint is incomplete; recopy below */ }
|
||||
}
|
||||
if (options.dryRun) continue;
|
||||
try {
|
||||
const bytes = await options.download(object.bucket_id, object.name);
|
||||
if (expectedSize && bytes.byteLength !== expectedSize) throw new Error(`Size mismatch for ${key}: expected ${expectedSize}, received ${bytes.byteLength}`);
|
||||
const relation = await mediaRelation(target, object, key);
|
||||
const path = destination(options.mediaRoot, key); await mkdir(dirname(path), { recursive: true });
|
||||
const temporary = `${path}.${process.pid}.tmp`; await writeFile(temporary, bytes, { mode: 0o600 }); await rename(temporary, path);
|
||||
const mime = metadataString(object.metadata, 'mimetype', 'contentType') ?? 'application/octet-stream';
|
||||
await target.query(`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id,created_at)
|
||||
values($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) on conflict(id) do update set owner_id=excluded.owner_id,couple_id=excluded.couple_id,kind=excluded.kind,storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size,question_id=excluded.question_id,match_id=excluded.match_id,created_at=excluded.created_at`,
|
||||
[object.id, relation.ownerId, relation.coupleId, BUCKET_KIND[object.bucket_id], key, mime, bytes.byteLength, relation.questionId, relation.matchId, object.created_at]);
|
||||
await rewriteLegacyReference(target, object);
|
||||
options.checkpoint.storage[key] = { updatedAt, byteSize: bytes.byteLength };
|
||||
await options.saveCheckpoint?.(options.checkpoint); result.copiedFiles += 1; result.copiedBytes += bytes.byteLength;
|
||||
} catch (error) {
|
||||
result.failedFiles += 1;
|
||||
result.failures.push({ storageKey: key, error: error instanceof Error ? error.message : 'Unknown storage migration failure' });
|
||||
}
|
||||
}
|
||||
if (!options.dryRun) {
|
||||
if (options.prune) {
|
||||
const existing = await target.query<{ id: string; storage_key: string }>("select id,storage_key from media_objects where kind=any($1::text[])", [Object.values(BUCKET_KIND)]);
|
||||
for (const row of existing.rows) if (!sourceKeys.has(row.storage_key)) {
|
||||
await rm(destination(options.mediaRoot, row.storage_key), { force: true });
|
||||
await target.query('delete from media_objects where id=$1', [row.id]);
|
||||
delete options.checkpoint.storage[row.storage_key]; result.prunedFiles += 1;
|
||||
}
|
||||
await options.saveCheckpoint?.(options.checkpoint);
|
||||
}
|
||||
const registered = await target.query<{ storage_key: string; byte_size: number }>("select storage_key,byte_size from media_objects where deleted_at is null and kind=any($1::text[])", [Object.values(BUCKET_KIND)]);
|
||||
result.targetFiles = registered.rows.length;
|
||||
for (const row of registered.rows) {
|
||||
try { if ((await stat(destination(options.mediaRoot, row.storage_key))).size !== row.byte_size) result.missingOnDisk.push(row.storage_key); }
|
||||
catch { result.missingOnDisk.push(row.storage_key); }
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function fileDigest(path: string): Promise<string> {
|
||||
return createHash('sha256').update(await readFile(path)).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Pool } from 'pg';
|
||||
|
||||
export interface MigrationCheckpoint {
|
||||
version: 1;
|
||||
runId: string;
|
||||
mode: 'initial' | 'final';
|
||||
completedTables: string[];
|
||||
storage: Record<string, { updatedAt: string | null; byteSize: number }>;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TableResult {
|
||||
table: string;
|
||||
sourceRows: number;
|
||||
targetRows: number;
|
||||
importedRows: number;
|
||||
prunedRows: number;
|
||||
status: 'equal' | 'different' | 'skipped';
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface StorageResult {
|
||||
sourceFiles: number;
|
||||
targetFiles: number;
|
||||
copiedFiles: number;
|
||||
skippedFiles: number;
|
||||
prunedFiles: number;
|
||||
failedFiles: number;
|
||||
sourceBytes: number;
|
||||
copiedBytes: number;
|
||||
missingOnDisk: string[];
|
||||
failures: Array<{ storageKey: string; error: string }>;
|
||||
}
|
||||
|
||||
export interface ParityReport {
|
||||
runId: string;
|
||||
dryRun: boolean;
|
||||
finalSync: boolean;
|
||||
startedAt: string;
|
||||
finishedAt: string;
|
||||
tables: TableResult[];
|
||||
storage: StorageResult | null;
|
||||
relational: Record<string, number>;
|
||||
parity: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseMigrationOptions {
|
||||
dryRun: boolean;
|
||||
finalSync: boolean;
|
||||
prune: boolean;
|
||||
checkpoint: MigrationCheckpoint;
|
||||
saveCheckpoint?: (value: MigrationCheckpoint) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface StorageMigrationOptions {
|
||||
dryRun: boolean;
|
||||
prune?: boolean;
|
||||
mediaRoot: string;
|
||||
checkpoint: MigrationCheckpoint;
|
||||
storageSchema?: string;
|
||||
download: (bucket: string, name: string) => Promise<Uint8Array>;
|
||||
saveCheckpoint?: (value: MigrationCheckpoint) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface MigrationPools { source: Pool; target: Pool }
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const optionalString=z.preprocess(value=>value===''?undefined:value,z.string().min(1).optional());
|
||||
const optionalUrl=z.preprocess(value=>value===''?undefined:value,z.string().url().optional());
|
||||
const schema=z.object({
|
||||
NODE_ENV:z.enum(['development','test','production']).default('development'), DATABASE_URL:z.string().url(),
|
||||
WORKER_POLL_INTERVAL_MS:z.coerce.number().int().min(1000).max(300000).default(30000), WORKER_BATCH_SIZE:z.coerce.number().int().min(1).max(100).default(25),
|
||||
MEDIA_ROOT:z.string().min(1).default('/data/media'), MEDIA_SIGNING_SECRET:z.string().min(32).default('local-media-signing-secret-change-me-123456'),
|
||||
MEDIA_PUBLIC_BASE_URL:z.string().url().default('http://127.0.0.1:3003'), CLASSIFIER_ENABLED:z.enum(['true','false']).default('true').transform(v=>v==='true'),
|
||||
OPENROUTER_API_KEY:optionalString, CLASSIFIER_MODEL:z.string().min(1).default('openai/gpt-4o'), ADMIN_PRIVATE_KEY_JWK:optionalString,
|
||||
DISCORD_WEBHOOK_URL:optionalUrl,
|
||||
});
|
||||
const defaultPrompt='Classify couples-app content. Consensual adult sexual content is allowed. Flag only CSAM or sexual content involving minors, non-consensual sexual content, self-harm, trafficking, terrorism, extreme violence, or illegal weapons or drug trafficking. Return JSON with status safe or flagged and a brief reason.';
|
||||
export function loadWorkerConfig(env:NodeJS.ProcessEnv=process.env) {
|
||||
const value=schema.parse(env);
|
||||
if(value.NODE_ENV==='production'&&value.CLASSIFIER_ENABLED&&!value.OPENROUTER_API_KEY)throw new Error('OPENROUTER_API_KEY is required when classification is enabled');
|
||||
if(value.ADMIN_PRIVATE_KEY_JWK){try{JSON.parse(value.ADMIN_PRIVATE_KEY_JWK);}catch{throw new Error('ADMIN_PRIVATE_KEY_JWK must be valid JSON');}}
|
||||
return {nodeEnv:value.NODE_ENV,databaseUrl:value.DATABASE_URL,pollIntervalMs:value.WORKER_POLL_INTERVAL_MS,batchSize:value.WORKER_BATCH_SIZE,
|
||||
mediaRoot:value.MEDIA_ROOT,mediaSigningSecret:value.MEDIA_SIGNING_SECRET,mediaPublicBaseUrl:value.MEDIA_PUBLIC_BASE_URL,classifierEnabled:value.CLASSIFIER_ENABLED,
|
||||
openRouterApiKey:value.OPENROUTER_API_KEY,classifierModel:value.CLASSIFIER_MODEL,classifierPrompt:env.CLASSIFIER_PROMPT||defaultPrompt,
|
||||
adminPrivateKeyJwk:value.ADMIN_PRIVATE_KEY_JWK,discordWebhookUrl:value.DISCORD_WEBHOOK_URL};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MediaJanitor } from '../domains/media/janitor.js';
|
||||
import { PostgresMediaRepository } from '../domains/media/repository.js';
|
||||
import { FilesystemMediaStorage } from '../domains/media/storage.js';
|
||||
import { DiscordProvider, ExpoPushProvider, MessageClassifier, OperationsRunner, PostgresOperationsRepository } from '../domains/operations/index.js';
|
||||
import { loadWorkerConfig } from './config.js';
|
||||
|
||||
const config=loadWorkerConfig(); const operations=new PostgresOperationsRepository(config.databaseUrl); const mediaRepository=new PostgresMediaRepository(config.databaseUrl);
|
||||
const mediaStorage=new FilesystemMediaStorage(config.mediaRoot,config.mediaSigningSecret,config.mediaPublicBaseUrl);
|
||||
const runner=new OperationsRunner(operations,new ExpoPushProvider(),new DiscordProvider(config.discordWebhookUrl),new MessageClassifier(operations,
|
||||
{enabled:config.classifierEnabled,apiKey:config.openRouterApiKey,model:config.classifierModel,prompt:config.classifierPrompt,adminPrivateKeyJwk:config.adminPrivateKeyJwk,mediaRoot:config.mediaRoot}),
|
||||
new MediaJanitor(mediaRepository,mediaStorage));
|
||||
let stopped=false;let timer:NodeJS.Timeout|undefined;
|
||||
async function tick(){if(stopped)return;try{const result=await runner.runOnce(new Date(),config.batchSize);if(result.claimed||result.failed)console.log('Operations worker tick',result);}catch(cause){console.error('Operations worker tick failed',cause);}finally{if(!stopped){timer=setTimeout(()=>void tick(),config.pollIntervalMs);timer.unref();}}}
|
||||
async function shutdown(signal:string){if(stopped)return;stopped=true;if(timer)clearTimeout(timer);console.log(`Received ${signal}; stopping operations worker`);await Promise.all([operations.close(),mediaRepository.close()]);}
|
||||
process.on('SIGINT',()=>void shutdown('SIGINT').then(()=>process.exit(0))); process.on('SIGTERM',()=>void shutdown('SIGTERM').then(()=>process.exit(0)));
|
||||
console.log('Sauci operations worker started');void tick();
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { HttpRevenueCatClient, SupabaseAuthAdminClient } from '../src/domains/account-operations/clients.js';
|
||||
|
||||
describe('account operation provider clients', () => {
|
||||
it('fails closed before account deletion when Auth Admin credentials are absent', async () => {
|
||||
const request = vi.fn();
|
||||
const client = new SupabaseAuthAdminClient(undefined, undefined, request);
|
||||
await expect(client.deleteUser('11111111-1111-4111-8111-111111111111'))
|
||||
.rejects.toMatchObject({ code: 'account_deletion_unavailable', status: 503 });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses only the server-side service role against the hosted Auth admin endpoint', async () => {
|
||||
const request = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
const client = new SupabaseAuthAdminClient('https://auth.example.test', 'server-secret', request);
|
||||
await client.deleteUser('11111111-1111-4111-8111-111111111111');
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'https://auth.example.test/auth/v1/admin/users/11111111-1111-4111-8111-111111111111',
|
||||
expect.objectContaining({
|
||||
method: 'DELETE',
|
||||
headers: { apikey: 'server-secret', authorization: 'Bearer server-secret' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{}, false],
|
||||
[{ expires_date: '2026-08-26T12:00:00.000Z' }, false],
|
||||
[{ expires_date: '2026-08-28T12:00:00.000Z' }, true],
|
||||
[{ expires_date: null }, true],
|
||||
])('derives premium from the configured RevenueCat entitlement', async (entitlement, expected) => {
|
||||
const request = vi.fn(async () => Response.json({
|
||||
subscriber: { entitlements: Object.keys(entitlement).length ? { pro: entitlement } : {} },
|
||||
}));
|
||||
const client = new HttpRevenueCatClient(
|
||||
'rc-secret',
|
||||
'pro',
|
||||
request,
|
||||
() => new Date('2026-08-27T12:00:00.000Z'),
|
||||
);
|
||||
await expect(client.isEntitled('user/id')).resolves.toBe(expected);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'https://api.revenuecat.com/v1/subscribers/user%2Fid',
|
||||
expect.objectContaining({ headers: expect.objectContaining({ authorization: 'Bearer rc-secret' }) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not leak provider response content when RevenueCat rejects verification', async () => {
|
||||
const client = new HttpRevenueCatClient('rc-secret', 'pro', async () =>
|
||||
new Response('provider secret detail', { status: 401 }));
|
||||
await expect(client.isEntitled('user')).rejects.toEqual(expect.objectContaining({
|
||||
code: 'subscription_verification_failed',
|
||||
message: 'Failed to verify subscription',
|
||||
status: 502,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { Pool } from 'pg';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PostgresAccountOperationsRepository } from '../src/domains/account-operations/repository.js';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const localDatabase = databaseUrl ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(databaseUrl).hostname) : false;
|
||||
if (databaseUrl && !localDatabase) throw new Error('Account operations integration tests only permit a localhost DATABASE_URL');
|
||||
|
||||
describe.skipIf(!databaseUrl || !localDatabase)('PostgresAccountOperationsRepository', () => {
|
||||
const adminPool = new Pool({ connectionString: databaseUrl });
|
||||
const schema = `account_operations_test_${randomUUID().replaceAll('-', '')}`;
|
||||
const aliceId = '11111111-1111-4111-8111-111111111111';
|
||||
const bobId = '22222222-2222-4222-8222-222222222222';
|
||||
const coupleId = '33333333-3333-4333-8333-333333333333';
|
||||
const packId = '44444444-4444-4444-8444-444444444444';
|
||||
const questionId = '55555555-5555-4555-8555-555555555555';
|
||||
let pool: Pool;
|
||||
let repository: PostgresAccountOperationsRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
await adminPool.query(`CREATE SCHEMA "${schema}"`);
|
||||
const isolatedUrl = new URL(databaseUrl!);
|
||||
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
|
||||
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
||||
repository = new PostgresAccountOperationsRepository(isolatedUrl.toString());
|
||||
for (const migrationName of [
|
||||
'0000_identity_and_feature_interests.sql',
|
||||
'0001_couples.sql',
|
||||
'0002_packs_catalog_progress.sql',
|
||||
'0003_answers_matches.sql',
|
||||
]) {
|
||||
const migration = await readFile(new URL(`../drizzle/${migrationName}`, import.meta.url), 'utf8');
|
||||
for (const statement of migration.split('--> statement-breakpoint')) {
|
||||
if (statement.trim()) await pool.query(statement);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await pool.query('truncate profiles, couples, question_packs, questions cascade');
|
||||
await pool.query('insert into couples (id, invite_code) values ($1, $2)', [coupleId, 'ABCD2345']);
|
||||
await pool.query(
|
||||
`insert into profiles (id, name, couple_id, push_token) values
|
||||
($1, 'Alice', $3, null), ($2, 'Bob', $3, 'ExponentPushToken[bob]')`,
|
||||
[aliceId, bobId, coupleId],
|
||||
);
|
||||
await pool.query('insert into question_packs (id, name) values ($1, $2)', [packId, 'Core']);
|
||||
await pool.query('insert into questions (id, pack_id, text) values ($1, $2, $3)', [questionId, packId, 'Question']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await repository.close();
|
||||
await pool.end();
|
||||
await adminPool.query(`DROP SCHEMA "${schema}" CASCADE`);
|
||||
await adminPool.end();
|
||||
});
|
||||
|
||||
async function addProgress(): Promise<void> {
|
||||
await pool.query(
|
||||
`insert into responses (id, user_id, question_id, couple_id, answer)
|
||||
values ($1, $2, $3, $4, 'yes')`,
|
||||
[randomUUID(), aliceId, questionId, coupleId],
|
||||
);
|
||||
await pool.query(
|
||||
`insert into matches (id, couple_id, question_id, match_type)
|
||||
values ($1, $2, $3, 'yes_yes')`,
|
||||
[randomUUID(), coupleId, questionId],
|
||||
);
|
||||
}
|
||||
|
||||
it('resets only the authenticated couple progress and retains the relationship', async () => {
|
||||
await addProgress();
|
||||
await expect(repository.resetProgress(aliceId)).resolves.toEqual({ partnerPushToken: 'ExponentPushToken[bob]' });
|
||||
expect((await pool.query('select count(*)::int as count from responses')).rows[0].count).toBe(0);
|
||||
expect((await pool.query('select count(*)::int as count from matches')).rows[0].count).toBe(0);
|
||||
expect((await pool.query('select count(*)::int as count from profiles where couple_id = $1', [coupleId])).rows[0].count).toBe(2);
|
||||
});
|
||||
|
||||
it('deletes shared couple data while retaining both independent profiles', async () => {
|
||||
await addProgress();
|
||||
await repository.deleteRelationship(aliceId);
|
||||
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
|
||||
expect((await pool.query('select count(*)::int as count from profiles')).rows[0].count).toBe(2);
|
||||
expect((await pool.query('select count(*)::int as count from profiles where couple_id is not null')).rows[0].count).toBe(0);
|
||||
});
|
||||
|
||||
it('rolls back every local account change when hosted Auth deletion fails', async () => {
|
||||
const deleteAuth = vi.fn(async () => { throw new Error('provider unavailable'); });
|
||||
await expect(repository.deleteAccount(aliceId, deleteAuth)).rejects.toThrow('provider unavailable');
|
||||
expect(deleteAuth).toHaveBeenCalledOnce();
|
||||
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(1);
|
||||
expect((await pool.query('select count(*)::int as count from profiles')).rows[0].count).toBe(2);
|
||||
expect((await pool.query('select count(*)::int as count from profiles where couple_id = $1', [coupleId])).rows[0].count).toBe(2);
|
||||
});
|
||||
|
||||
it('deletes the local profile and relationship only after Auth confirms', async () => {
|
||||
const deleteAuth = vi.fn(async () => undefined);
|
||||
await repository.deleteAccount(aliceId, deleteAuth);
|
||||
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
|
||||
expect((await pool.query('select id, couple_id from profiles')).rows).toEqual([{ id: bobId, couple_id: null }]);
|
||||
});
|
||||
|
||||
it('updates premium status only for the authenticated profile', async () => {
|
||||
await repository.setPremium(aliceId, true);
|
||||
expect((await pool.query('select id, is_premium from profiles order by id')).rows).toEqual([
|
||||
{ id: aliceId, is_premium: true },
|
||||
{ id: bobId, is_premium: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Hono } from 'hono';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthIdentity } from '../src/auth.js';
|
||||
import { registerAccountOperationRoutes } from '../src/domains/account-operations/routes.js';
|
||||
import type { AccountOperationsService } from '../src/domains/account-operations/service.js';
|
||||
import { AccountOperationError } from '../src/domains/account-operations/types.js';
|
||||
|
||||
const identity: AuthIdentity = {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
email: 'alice@sauci.test',
|
||||
name: 'Alice',
|
||||
avatarUrl: null,
|
||||
};
|
||||
|
||||
function service(): AccountOperationsService {
|
||||
return {
|
||||
deleteRelationship: vi.fn(async () => ({ success: true, message: 'deleted' })),
|
||||
resetProgress: vi.fn(async () => ({ success: true, message: 'reset' })),
|
||||
deleteAccount: vi.fn(async () => ({ success: true, message: 'deleted' })),
|
||||
syncSubscription: vi.fn(async () => ({ success: true, is_premium: true })),
|
||||
} as unknown as AccountOperationsService;
|
||||
}
|
||||
|
||||
function appWith(operations: AccountOperationsService) {
|
||||
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
|
||||
app.use('/v1/*', async (c, next) => {
|
||||
c.set('identity', identity);
|
||||
await next();
|
||||
});
|
||||
registerAccountOperationRoutes(app, operations);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('account operation routes', () => {
|
||||
it('derives ownership exclusively from the authenticated identity', async () => {
|
||||
const operations = service();
|
||||
const app = appWith(operations);
|
||||
expect((await app.request('/v1/couple/data', { method: 'DELETE' })).status).toBe(200);
|
||||
expect((await app.request('/v1/couple/progress', { method: 'DELETE' })).status).toBe(200);
|
||||
expect((await app.request('/v1/me', { method: 'DELETE' })).status).toBe(200);
|
||||
expect((await app.request('/v1/me/subscription/sync', { method: 'POST' })).status).toBe(200);
|
||||
expect(operations.deleteRelationship).toHaveBeenCalledWith(identity.id);
|
||||
expect(operations.resetProgress).toHaveBeenCalledWith(identity.id);
|
||||
expect(operations.deleteAccount).toHaveBeenCalledWith(identity.id);
|
||||
expect(operations.syncSubscription).toHaveBeenCalledWith(identity.id);
|
||||
});
|
||||
|
||||
it('returns a safe structured provider error', async () => {
|
||||
const operations = service();
|
||||
vi.mocked(operations.deleteAccount).mockRejectedValueOnce(
|
||||
new AccountOperationError('auth_delete_failed', 'Failed to delete authentication record', 502),
|
||||
);
|
||||
const response = await appWith(operations).request('/v1/me', { method: 'DELETE' });
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: { code: 'auth_delete_failed', message: 'Failed to delete authentication record' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SupabaseAdminAuthDirectory } from '../src/domains/admin/auth-directory.js';
|
||||
|
||||
describe('admin hosted-auth directory', () => {
|
||||
it('returns explicit empty enrichment when server credentials are absent', async () => {
|
||||
const request = vi.fn();
|
||||
await expect(new SupabaseAdminAuthDirectory(undefined, undefined, request).users()).resolves.toEqual([]);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads hosted Auth metadata with server-only credentials', async () => {
|
||||
const request = vi.fn(async () => Response.json({ users: [{
|
||||
id: '11111111-1111-4111-8111-111111111111', email: 'admin@example.test',
|
||||
last_sign_in_at: '2026-08-27T12:00:00.000Z', confirmed_at: '2026-08-26T12:00:00.000Z',
|
||||
}] }));
|
||||
const directory = new SupabaseAdminAuthDirectory('https://auth.example.test/auth/v1', 'server-secret', request);
|
||||
await expect(directory.users()).resolves.toEqual([{
|
||||
id: '11111111-1111-4111-8111-111111111111', email: 'admin@example.test',
|
||||
last_sign_in_at: '2026-08-27T12:00:00.000Z', email_confirmed_at: '2026-08-26T12:00:00.000Z',
|
||||
}]);
|
||||
expect(request).toHaveBeenCalledWith(
|
||||
'https://auth.example.test/auth/v1/admin/users?page=1&per_page=1000',
|
||||
expect.objectContaining({ headers: { apikey: 'server-secret', authorization: 'Bearer server-secret' } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose hosted Auth provider failures', async () => {
|
||||
const directory = new SupabaseAdminAuthDirectory('https://auth.example.test', 'server-secret', async () =>
|
||||
new Response('sensitive provider detail', { status: 401 }));
|
||||
await expect(directory.users()).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthVerifier } from '../src/auth.js';
|
||||
import { AdminRequestAuth } from '../src/domains/admin/auth.js';
|
||||
|
||||
const hosted: AuthVerifier = { verify: vi.fn(async (token) => {
|
||||
if (token !== 'hosted') throw new Error('invalid');
|
||||
return { id: '11111111-1111-4111-8111-111111111111', email: 'admin@test', name: null, avatarUrl: null };
|
||||
}) };
|
||||
|
||||
describe('AdminRequestAuth', () => {
|
||||
it('accepts hosted Supabase bearer identities', async () => {
|
||||
await expect(new AdminRequestAuth(hosted).verify('hosted')).resolves.toMatchObject({ id: '11111111-1111-4111-8111-111111111111' });
|
||||
});
|
||||
|
||||
it('maps a stable service token to its explicit least-privilege admin actor', async () => {
|
||||
const auth = new AdminRequestAuth(hosted, 'a'.repeat(32), '22222222-2222-4222-8222-222222222222');
|
||||
await expect(auth.verify('a'.repeat(32))).resolves.toMatchObject({ id: '22222222-2222-4222-8222-222222222222' });
|
||||
await expect(auth.verify('wrong')).rejects.toThrow('invalid');
|
||||
});
|
||||
|
||||
it('rejects partial or weak service-token configuration', () => {
|
||||
expect(() => new AdminRequestAuth(hosted, 'a'.repeat(32))).toThrow(/configured together/);
|
||||
expect(() => new AdminRequestAuth(hosted, 'short', '22222222-2222-4222-8222-222222222222')).toThrow(/32/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { Pool } from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { PostgresAdminRepository } from '../src/domains/admin/repository.js';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const isLocal = databaseUrl ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(databaseUrl).hostname) : false;
|
||||
if (databaseUrl && !isLocal) throw new Error('Admin integration tests only permit a localhost DATABASE_URL');
|
||||
|
||||
describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audit', () => {
|
||||
const adminPool = new Pool({ connectionString: databaseUrl });
|
||||
const schema = `admin_test_${randomUUID().replaceAll('-', '')}`;
|
||||
const creatorUser = '11111111-1111-4111-8111-111111111111';
|
||||
const superUser = '22222222-2222-4222-8222-222222222222';
|
||||
const ordinaryUser = '33333333-3333-4333-8333-333333333333';
|
||||
const moderatorUser = '44444444-4444-4444-8444-444444444444';
|
||||
let pool: Pool; let repository: PostgresAdminRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
await adminPool.query(`create schema "${schema}"`);
|
||||
const isolated = new URL(databaseUrl!); isolated.searchParams.set('options', `-c search_path=${schema}`);
|
||||
pool = new Pool({ connectionString: isolated.toString() }); repository = new PostgresAdminRepository(isolated.toString());
|
||||
for (const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0007_billing_redemption.sql','0008_residual_realtime.sql','0009_operations_workers.sql','0010_admin.sql']) {
|
||||
const migration = await readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8');
|
||||
for (const statement of migration.split('--> statement-breakpoint')) if (statement.trim()) await pool.query(statement);
|
||||
}
|
||||
await pool.query('insert into profiles(id,name) values($1,$5),($2,$6),($3,$7),($4,$8)', [creatorUser, superUser, ordinaryUser, moderatorUser, 'Creator', 'Super', 'User', 'Moderator']);
|
||||
await pool.query("insert into admin_users(user_id,role,permissions) values($1,'pack_creator',array['manage_packs']),($2,'super_admin','{}'),($3,'pack_creator',array['view_chats','view_media'])", [creatorUser, superUser, moderatorUser]);
|
||||
});
|
||||
|
||||
afterAll(async () => { await repository.close(); await pool.end(); await adminPool.query(`drop schema "${schema}" cascade`); await adminPool.end(); });
|
||||
|
||||
it('denies ordinary authenticated users and permission escalation', async () => {
|
||||
await expect(repository.principal(ordinaryUser)).rejects.toMatchObject({ code: 'admin_access_denied' });
|
||||
const creator = await repository.principal(creatorUser);
|
||||
await expect(repository.query(creator, 'profiles', {})).rejects.toMatchObject({ code: 'forbidden' });
|
||||
await expect(repository.query(creator, 'audit_logs', {})).rejects.toMatchObject({ code: 'forbidden' });
|
||||
});
|
||||
|
||||
it('allows content permissions but isolates privileged resources', async () => {
|
||||
const creator = await repository.principal(creatorUser);
|
||||
const inserted = await repository.insert(creator, 'question_packs', [{ name: 'Creator pack' }]);
|
||||
const result = await repository.query(creator, 'question_packs', { filters: [{ column: 'id', op: 'eq', value: inserted[0]!.id }] });
|
||||
expect(result).toMatchObject({ count: 1, rows: [expect.objectContaining({ name: 'Creator pack' })] });
|
||||
});
|
||||
|
||||
it('writes immutable audit evidence in the same transaction as mutations', async () => {
|
||||
const creator = await repository.principal(creatorUser);
|
||||
const [pack] = await repository.insert(creator, 'question_packs', [{ name: 'Audited pack' }]);
|
||||
await repository.update(creator, 'question_packs', String(pack!.id), { name: 'Updated pack' });
|
||||
await repository.delete(creator, 'question_packs', String(pack!.id));
|
||||
const audit = await pool.query('select action,actor_user_id,old_values,new_values from audit_logs where record_id=$1 order by created_at', [pack!.id]);
|
||||
expect(audit.rows.map(row => row.action)).toEqual(['INSERT', 'UPDATE', 'DELETE']);
|
||||
expect(audit.rows.every(row => row.actor_user_id === creatorUser)).toBe(true);
|
||||
expect(audit.rows[1]).toMatchObject({ old_values: expect.objectContaining({ name: 'Audited pack' }), new_values: expect.objectContaining({ name: 'Updated pack' }) });
|
||||
});
|
||||
|
||||
it('preserves legacy AI config columns while redacting provider credentials from audit logs', async () => {
|
||||
const principal = await repository.principal(superUser);
|
||||
const [config] = await repository.insert(principal, 'ai_config', [{
|
||||
openrouter_api_key: 'sk-sensitive', default_model: 'openai/gpt-4o-mini',
|
||||
model_generate: 'anthropic/claude-3.5-sonnet', classifier_enabled: true,
|
||||
heuristics_enabled: true, heuristic_min_text_length: 18,
|
||||
}]);
|
||||
expect(config).toMatchObject({
|
||||
openrouter_api_key: 'sk-sensitive', model_generate: 'anthropic/claude-3.5-sonnet',
|
||||
classifier_enabled: true, heuristics_enabled: true, heuristic_min_text_length: 18,
|
||||
});
|
||||
const audit = await pool.query("select new_values from audit_logs where table_name='ai_config' and record_id=$1", [config!.id]);
|
||||
expect(audit.rows[0]?.new_values).toMatchObject({ openrouter_api_key: '[REDACTED]', default_model: 'openai/gpt-4o-mini' });
|
||||
});
|
||||
|
||||
it('grants super admins read access without trusting client role claims', async () => {
|
||||
const principal = await repository.principal(superUser);
|
||||
expect((await repository.query(principal, 'profiles', {})).count).toBe(4);
|
||||
expect((await repository.query(principal, 'audit_logs', {})).count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('honors validated projections and never returns generic secret-bearing fields', async () => {
|
||||
await pool.query("update profiles set push_token='ExponentPushToken[secret]',public_key_jwk='{\"kty\":\"RSA\"}' where id=$1", [ordinaryUser]);
|
||||
const principal = await repository.principal(superUser);
|
||||
const projected = await repository.query(principal, 'profiles', { columns: ['id', 'name'], filters: [{ column: 'id', op: 'eq', value: ordinaryUser }] });
|
||||
expect(projected.rows).toEqual([{ id: ordinaryUser, name: 'User' }]);
|
||||
const defaultRow = (await repository.query(principal, 'profiles', { filters: [{ column: 'id', op: 'eq', value: ordinaryUser }] })).rows[0]!;
|
||||
expect(defaultRow).not.toHaveProperty('push_token');
|
||||
expect(defaultRow).not.toHaveProperty('public_key_jwk');
|
||||
await expect(repository.query(principal, 'profiles', { columns: ['id', 'push_token'] })).rejects.toMatchObject({ code: 'restricted_column' });
|
||||
});
|
||||
|
||||
it('supports null-safe filters and scoped feature-interest aggregates', async () => {
|
||||
await pool.query("insert into feature_interests(user_id,feature) values($1,'better-chat'),($2,'better-chat')", [creatorUser, ordinaryUser]);
|
||||
const principal = await repository.principal(superUser);
|
||||
expect((await repository.query(principal, 'profiles', { filters: [{ column: 'email', op: 'neq', value: null }] })).count).toBe(0);
|
||||
expect(await repository.featureInterestCounts(principal)).toEqual([{
|
||||
feature_name: 'better-chat', opt_in_count: 2, opt_in_count_last_7_days: 2,
|
||||
}]);
|
||||
});
|
||||
|
||||
it('gifts premium atomically and audits the explicit service action', async () => {
|
||||
const principal = await repository.principal(superUser);
|
||||
const result = await repository.giftPremium(principal, ordinaryUser, 30, 'support grant');
|
||||
expect(result.subscription_id).toEqual(expect.any(String));
|
||||
expect((await pool.query('select is_premium from profiles where id=$1', [ordinaryUser])).rows[0]?.is_premium).toBe(true);
|
||||
expect((await pool.query("select new_values from audit_logs where record_id=$1 and action='ACTION'", [result.subscription_id])).rows[0]?.new_values).toMatchObject({ action: 'gift_premium', target_user_id: ordinaryUser, days: 30 });
|
||||
});
|
||||
|
||||
it('supports lifetime gifts without inventing an expiry', async () => {
|
||||
const principal = await repository.principal(superUser);
|
||||
const result = await repository.giftPremium(principal, ordinaryUser, null, 'lifetime support grant');
|
||||
expect(result.expires_at).toBeNull();
|
||||
expect((await pool.query('select expires_at from subscriptions where id=$1', [result.subscription_id])).rows[0]?.expires_at).toBeNull();
|
||||
});
|
||||
|
||||
it('gates message moderation and media access independently from AI configuration', async () => {
|
||||
const creator = await repository.principal(creatorUser);
|
||||
const moderator = await repository.principal(moderatorUser);
|
||||
await expect(repository.query(creator, 'messages', {})).rejects.toMatchObject({ code: 'forbidden' });
|
||||
await expect(repository.query(creator, 'message_reports', {})).rejects.toMatchObject({ code: 'forbidden' });
|
||||
expect((await repository.query(moderator, 'messages', {})).count).toBe(0);
|
||||
expect((await repository.query(moderator, 'message_reports', {})).count).toBe(0);
|
||||
await expect(repository.authorizeMedia(creator, randomUUID())).rejects.toMatchObject({ code: 'forbidden' });
|
||||
await expect(repository.authorizeMedia(moderator, randomUUID())).rejects.toMatchObject({ code: 'media_not_found' });
|
||||
});
|
||||
|
||||
it('creates all legacy admin and dare cutover tables with preserved foreign keys', async () => {
|
||||
const tables = await pool.query<{ table_name: string }>(
|
||||
`select table_name from information_schema.tables where table_schema=current_schema()
|
||||
and table_name=any($1::text[]) order by table_name`,
|
||||
[['admin_users','audit_logs','topics','pack_topics','ai_config','dare_packs','dares','master_keys','sent_dares','dare_messages']],
|
||||
);
|
||||
expect(tables.rows.map(row => row.table_name)).toEqual([
|
||||
'admin_users','ai_config','audit_logs','dare_messages','dare_packs','dares','master_keys','pack_topics','sent_dares','topics',
|
||||
]);
|
||||
const fks = await pool.query<{ constraint_name: string }>(
|
||||
`select constraint_name from information_schema.table_constraints
|
||||
where table_schema=current_schema() and constraint_type='FOREIGN KEY'
|
||||
and table_name in ('sent_dares','dare_messages')`,
|
||||
);
|
||||
expect(fks.rows).toHaveLength(6);
|
||||
expect((await pool.query(`select column_name from information_schema.columns where table_schema=current_schema() and table_name='feedback' and column_name='admin_notes'`)).rowCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Hono } from 'hono';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthIdentity } from '../src/auth.js';
|
||||
import type { AdminRepository } from '../src/domains/admin/repository.js';
|
||||
import { registerAdminRoutes } from '../src/domains/admin/routes.js';
|
||||
import { AdminError, type AdminPrincipal } from '../src/domains/admin/types.js';
|
||||
import { FilesystemMediaStorage } from '../src/domains/media/storage.js';
|
||||
|
||||
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: 'admin@test', name: 'Admin', avatarUrl: null };
|
||||
const principal: AdminPrincipal = { adminId: '22222222-2222-4222-8222-222222222222', userId: identity.id, role: 'pack_creator', permissions: ['manage_packs'] };
|
||||
|
||||
function setup(denied = false) {
|
||||
const repository: AdminRepository = {
|
||||
principal: vi.fn(async (userId) => { if (denied) throw new AdminError('admin_access_denied', 'denied', 403); return { ...principal, userId }; }),
|
||||
query: vi.fn(async () => ({ rows: [{ id: 'pack' }], count: 1 })),
|
||||
insert: vi.fn(async () => [{ id: 'pack' }]), update: vi.fn(async () => ({ id: 'pack' })),
|
||||
delete: vi.fn(async () => undefined), dashboard: vi.fn(async () => ({ packs: 1 })),
|
||||
giftPremium: vi.fn(async () => ({ subscription_id: 'sub', expires_at: 'later' })),
|
||||
featureInterestCounts: vi.fn(async () => []), users: vi.fn(async () => []), close: vi.fn(async () => undefined),
|
||||
authorizeMedia: vi.fn(async () => undefined),
|
||||
decryptMessage: vi.fn(async () => ({ content: 'plain' })),
|
||||
decryptMedia: vi.fn(async () => ({ bytes: Buffer.from('media'), mimeType: 'image/png' })),
|
||||
};
|
||||
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
|
||||
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
|
||||
registerAdminRoutes(app, repository, undefined, new FilesystemMediaStorage('/tmp/admin-route-media', 'test-signing-secret', 'http://localhost:8787'));
|
||||
return { app, repository };
|
||||
}
|
||||
|
||||
describe('admin routes', () => {
|
||||
it('returns role claims only after server-side admin lookup', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await app.request('/v1/admin/me');
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ role: 'pack_creator', permissions: ['manage_packs'] });
|
||||
expect(repository.principal).toHaveBeenCalledWith(identity.id);
|
||||
});
|
||||
|
||||
it('denies authenticated users absent from active admin membership', async () => {
|
||||
const response = await setup(true).app.request('/v1/admin/me');
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('never accepts actor identity or role from mutation bodies', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await app.request('/v1/admin/data/question_packs', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ records: [{ name: 'Pack' }], role: 'super_admin', actor_user_id: 'attacker' }),
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(repository.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes only validated query and mutation data to the authorized repository', async () => {
|
||||
const { app, repository } = setup();
|
||||
const queried = await app.request('/v1/admin/query/question_packs', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ columns: ['id', 'name'], filters: [{ column: 'is_public', op: 'eq', value: true }], limit: 20 }),
|
||||
});
|
||||
expect(queried.status).toBe(200);
|
||||
expect(repository.query).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), 'question_packs', expect.objectContaining({ columns: ['id', 'name'], limit: 20 }));
|
||||
});
|
||||
|
||||
it('issues short-lived admin-authorized media URLs without exposing storage paths', async () => {
|
||||
const { app, repository } = setup();
|
||||
const mediaId = '33333333-3333-4333-8333-333333333333';
|
||||
const response = await app.request(`/v1/admin/media/${mediaId}/url`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('cache-control')).toBe('private, no-store');
|
||||
expect(await response.json()).toMatchObject({ url: expect.stringContaining(`/media/${mediaId}/content?`) });
|
||||
expect(repository.authorizeMedia).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), mediaId);
|
||||
});
|
||||
|
||||
it('accepts explicit lifetime premium grants', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await app.request(`/v1/admin/users/${identity.id}/gift-premium`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ days: null }),
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(repository.giftPremium).toHaveBeenCalledWith(expect.objectContaining({ userId: identity.id }), identity.id, null, undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { Pool } from 'pg';
|
||||
import { afterAll,beforeAll,describe,expect,it } from 'vitest';
|
||||
import { PostgresAnswersRepository } from '../src/domains/answers/repository.js';
|
||||
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
|
||||
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
|
||||
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999';
|
||||
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female'])",[q,pack,concurrentQ,femaleOnlyQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
||||
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
|
||||
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
|
||||
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
|
||||
it('is idempotent and creates one match only after both partners answer',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.submit(u1,{questionId:q,answer:'maybe'})).match).toBeNull();const second=await repo.submit(u2,{questionId:q,answer:'yes'});expect(second.match?.match_type).toBe('yes_maybe');await repo.submit(u2,{questionId:q,answer:'yes'});const counts=await pool.query('select (select count(*) from responses where question_id=$1) responses,(select count(*) from matches where question_id=$1) matches',[q]);expect(counts.rows[0]).toMatchObject({responses:'2',matches:'1'});});
|
||||
it('requires confirmation before destroying a match and isolates outsiders',async()=>{const pending=await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:false});expect(pending).toMatchObject({success:false,requires_confirmation:true,message_count:0});expect(await repo.update(u1,{questionId:q,answer:'no',confirmDeleteMatch:true})).toMatchObject({success:true,match_deleted:true});await expect(repo.archive(outsider,'88888888-8888-4888-8888-888888888888',true)).rejects.toMatchObject({code:'match_not_found'});});
|
||||
it('records both partners once per day and increments on a consecutive day',async()=>{await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:1,longest_streak:1,user1_answered_today:true,user2_answered_today:true});await pool.query("update couple_streaks set current_streak=1,longest_streak=1,last_active_date=current_date-1,last_completed_date=current_date-1,user1_answered_today=false,user2_answered_today=false where couple_id=$1",[couple]);await repo.submit(u1,{questionId:q,answer:'yes'});await repo.submit(u2,{questionId:q,answer:'yes'});expect(await repo.streak(u1)).toMatchObject({current_streak:2,longest_streak:2});});
|
||||
it('serializes the daily limit across different questions',async()=>{await pool.query('delete from responses where user_id=$1',[u1]);await pool.query('update app_config set daily_response_limit=1');const results=await Promise.allSettled([repo.submit(u1,{questionId:q,answer:'yes'}),repo.submit(u1,{questionId:femaleOnlyQ,answer:'yes'})]);expect(results.filter(r=>r.status==='fulfilled')).toHaveLength(1);expect(results.filter(r=>r.status==='rejected')).toHaveLength(1);expect(await repo.dailyLimit(u1)).toMatchObject({responses_today:1,is_blocked:true});});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Hono } from 'hono';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthIdentity } from '../src/auth.js';
|
||||
import type { AnswersRepository } from '../src/domains/answers/repository.js';
|
||||
import { registerAnswerRoutes } from '../src/domains/answers/routes.js';
|
||||
import { AnswersError, calculateMatchType } from '../src/domains/answers/types.js';
|
||||
|
||||
const identity: AuthIdentity={id:'11111111-1111-4111-8111-111111111111',email:null,name:null,avatarUrl:null};
|
||||
const questionId='22222222-2222-4222-8222-222222222222';
|
||||
function repo():AnswersRepository{return {
|
||||
recommended:vi.fn(async()=>[]),pending:vi.fn(async()=>[]),answerGap:vi.fn(async()=>({unanswered_by_partner:0,threshold:10,is_blocked:false})),
|
||||
dailyLimit:vi.fn(async()=>({responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false})),
|
||||
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
|
||||
function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthIdentity}}>();app.use('/v1/*',async(c,n)=>{c.set('identity',identity);await n();});registerAnswerRoutes(app,r);return app;}
|
||||
|
||||
describe('answer routes',()=>{
|
||||
it('takes ownership only from the bearer identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes',user_id:'attacker'})});expect(r.submit).not.toHaveBeenCalled();const ok=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(ok.status).toBe(200);expect(r.submit).toHaveBeenCalledWith(identity.id,{questionId,answer:'yes',responseData:undefined});});
|
||||
it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached'));expect((await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})})).status).toBe(429);});
|
||||
it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);});
|
||||
});
|
||||
|
||||
describe('match semantics',()=>{
|
||||
const q=(question_type:'swipe'|'text_answer'|'audio'|'photo'|'who_likely')=>({id:questionId,question_type});
|
||||
it('preserves swipe combinations',()=>{expect(calculateMatchType(q('swipe'),'yes','yes')).toBe('yes_yes');expect(calculateMatchType(q('swipe'),'yes','maybe')).toBe('yes_maybe');expect(calculateMatchType(q('swipe'),'maybe','maybe')).toBe('maybe_maybe');expect(calculateMatchType(q('swipe'),'no','yes')).toBeNull();});
|
||||
it('matches non-swipe positive answers and always matches who-likely',()=>{expect(calculateMatchType(q('text_answer'),'yes','maybe')).toBe('both_answered');expect(calculateMatchType(q('photo'),'no','yes')).toBeNull();expect(calculateMatchType(q('who_likely'),'no','no')).toBe('both_answered');});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { exportJWK, generateKeyPair, SignJWT } from 'jose';
|
||||
import { Pool } from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createApp } from '../src/app.js';
|
||||
import { createAuthVerifier } from '../src/auth.js';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { PostgresRepository } from '../src/db/repository.js';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const localDatabase = databaseUrl ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(databaseUrl).hostname) : false;
|
||||
if (databaseUrl && !localDatabase) {
|
||||
throw new Error('API integration tests only permit a localhost DATABASE_URL');
|
||||
}
|
||||
|
||||
describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration', () => {
|
||||
const adminPool = new Pool({ connectionString: databaseUrl });
|
||||
const schema = `api_test_${randomUUID().replaceAll('-', '')}`;
|
||||
let pool: Pool;
|
||||
let repository: PostgresRepository;
|
||||
let token: string;
|
||||
let app: ReturnType<typeof createApp>;
|
||||
|
||||
beforeAll(async () => {
|
||||
await adminPool.query(`CREATE SCHEMA "${schema}"`);
|
||||
const isolatedUrl = new URL(databaseUrl!);
|
||||
isolatedUrl.searchParams.set('options', `-c search_path=${schema}`);
|
||||
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
||||
repository = new PostgresRepository(isolatedUrl.toString());
|
||||
const migration = await readFile(new URL('../drizzle/0000_identity_and_feature_interests.sql', import.meta.url), 'utf8');
|
||||
for (const statement of migration.split('--> statement-breakpoint')) {
|
||||
if (statement.trim()) await pool.query(statement);
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = await generateKeyPair('ES256');
|
||||
const publicJwk = await exportJWK(publicKey);
|
||||
publicJwk.kid = 'integration-key';
|
||||
const issuer = 'https://auth.sauci.test/auth/v1';
|
||||
token = await new SignJWT({ email: 'integration@sauci.test', user_metadata: { name: 'Integration' } })
|
||||
.setProtectedHeader({ alg: 'ES256', kid: 'integration-key' })
|
||||
.setSubject('33333333-3333-4333-8333-333333333333')
|
||||
.setIssuer(issuer)
|
||||
.setAudience('authenticated')
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('5m')
|
||||
.sign(privateKey);
|
||||
const config = loadConfig({
|
||||
NODE_ENV: 'test',
|
||||
DATABASE_URL: databaseUrl,
|
||||
SUPABASE_AUTH_ISSUER: issuer,
|
||||
AUTH_TEST_JWKS: JSON.stringify({ keys: [publicJwk] }),
|
||||
});
|
||||
app = createApp({ auth: createAuthVerifier(config), repository });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await repository.close();
|
||||
await pool.end();
|
||||
await adminPool.query(`DROP SCHEMA "${schema}" CASCADE`);
|
||||
await adminPool.end();
|
||||
});
|
||||
|
||||
it('bootstraps identity and persists an isolated interest round-trip', async () => {
|
||||
const headers = { authorization: `Bearer ${token}` };
|
||||
const me = await app.request('/v1/me', { headers });
|
||||
expect(me.status).toBe(200);
|
||||
expect(await me.json()).toMatchObject({
|
||||
profile: {
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
email: 'integration@sauci.test',
|
||||
name: 'Integration',
|
||||
max_intensity: 5,
|
||||
},
|
||||
});
|
||||
|
||||
expect((await app.request('/v1/me/feature-interests/better-chat', { method: 'PUT', headers })).status).toBe(200);
|
||||
expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json())
|
||||
.toEqual({ feature: 'better-chat', interested: true });
|
||||
expect(await (await app.request('/v1/me/feature-interests/better-chat', { method: 'DELETE', headers })).json())
|
||||
.toEqual({ feature: 'better-chat', interested: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { Pool } from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { PostgresAppDataRepository } from '../src/domains/app-data/repository.js';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const local = databaseUrl ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(databaseUrl).hostname) : false;
|
||||
if (databaseUrl && !local) throw new Error('App-data integration tests only permit a localhost DATABASE_URL');
|
||||
|
||||
describe.skipIf(!databaseUrl || !local)('app-data repository + PostgreSQL', () => {
|
||||
const admin = new Pool({ connectionString: databaseUrl }); const schema = `app_data_${randomUUID().replaceAll('-', '')}`;
|
||||
const alice='11111111-1111-4111-8111-111111111111', bob='22222222-2222-4222-8222-222222222222', outsider='33333333-3333-4333-8333-333333333333';
|
||||
const couple='44444444-4444-4444-8444-444444444444', otherCouple='55555555-5555-4555-8555-555555555555', category='66666666-6666-4666-8666-666666666666';
|
||||
const pack='77777777-7777-4777-8777-777777777777', question='88888888-8888-4888-8888-888888888888', match='99999999-9999-4999-8999-999999999999', message='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
let pool: Pool; let repo: PostgresAppDataRepository;
|
||||
beforeAll(async () => {
|
||||
await admin.query(`create schema "${schema}"`); const url=new URL(databaseUrl!); url.searchParams.set('options',`-c search_path=${schema}`);
|
||||
pool=new Pool({connectionString:url.toString()}); repo=new PostgresAppDataRepository(url.toString());
|
||||
for (const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0008_residual_realtime.sql']) {
|
||||
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8'); for(const statement of sql.split('--> statement-breakpoint')) if(statement.trim()) await pool.query(statement);
|
||||
}
|
||||
await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'APDATA01',otherCouple,'APDATA02']);
|
||||
await pool.query('insert into profiles(id,couple_id,name,hide_nsfw) values($1,$4,$5,false),($2,$4,$6,false),($3,$7,$8,false)',[alice,bob,outsider,couple,'Alice','Bob',otherCouple,'Out']);
|
||||
await pool.query("insert into categories(id,name) values($1,'Visible')",[category]);
|
||||
await pool.query("insert into question_packs(id,name,category_id) values($1,'Pack',$2)",[pack,category]);
|
||||
await pool.query("insert into questions(id,pack_id,text) values($1,$2,'Question')",[question,pack]);
|
||||
await pool.query("insert into responses(id,user_id,question_id,couple_id,answer) values(gen_random_uuid(),$1,$3,$4,'yes'),(gen_random_uuid(),$2,$3,$4,'yes')",[alice,bob,question,couple]);
|
||||
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[match,couple,question]);
|
||||
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'x.jpg','image')",[message,match,alice]);
|
||||
});
|
||||
afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
||||
it('returns only public catalog data and couple-owned match context',async()=>{
|
||||
expect((await repo.packQuestions(alice,pack))[0]?.text).toBe('Question');
|
||||
expect((await repo.matchContext(bob,match)).responses).toHaveLength(2);
|
||||
await expect(repo.matchContext(outsider,match)).rejects.toMatchObject({status:404});
|
||||
});
|
||||
it('allows only the recipient in the owning couple to mark media viewed',async()=>{
|
||||
expect((await repo.markMediaViewed(bob,message,null)).media_viewed_at).toBeTruthy();
|
||||
await expect(repo.markMediaViewed(alice,message,null)).rejects.toMatchObject({status:404});
|
||||
await expect(repo.markMediaViewed(outsider,message,null)).rejects.toMatchObject({status:404});
|
||||
});
|
||||
it('persists drawing state per couple and rejects foreign stroke owners',async()=>{
|
||||
const stroke={id:'s1',userId:alice,points:[{x:.2,y:.3}],color:'#fff',width:2,timestamp:1,isEraser:false};
|
||||
expect((await repo.putLiveDraw(alice,[stroke],0)).revision).toBe(1);
|
||||
expect((await repo.getLiveDraw(bob)).strokes).toEqual([stroke]);
|
||||
expect((await repo.getLiveDraw(outsider)).strokes).toEqual([]);
|
||||
await expect(repo.putLiveDraw(alice,[{...stroke,userId:outsider}],1)).rejects.toMatchObject({status:403});
|
||||
});
|
||||
it('rejects one of two concurrent writes from the same base revision with current state',async()=>{
|
||||
const current=await repo.getLiveDraw(alice);
|
||||
const a={id:'concurrent-a',userId:alice,points:[{x:.1,y:.1}],color:'#fff',width:2,timestamp:2,isEraser:false};
|
||||
const b={...a,id:'concurrent-b',userId:bob};
|
||||
const results=await Promise.allSettled([
|
||||
repo.putLiveDraw(alice,[...current.strokes,a],current.revision),
|
||||
repo.putLiveDraw(bob,[...current.strokes,b],current.revision),
|
||||
]);
|
||||
expect(results.filter(result=>result.status==='fulfilled')).toHaveLength(1);
|
||||
const rejected=results.find(result=>result.status==='rejected');
|
||||
expect(rejected).toMatchObject({status:'rejected',reason:{status:409,code:'revision_conflict',details:{current_state:{revision:current.revision+1}}}});
|
||||
});
|
||||
it('atomically records nudge cooldown before reporting a missing push token',async()=>{
|
||||
await expect(repo.sendNudge(alice)).resolves.toMatchObject({success:true,notification_sent:false,reason:'no_push_token'});
|
||||
await expect(repo.sendNudge(alice)).rejects.toMatchObject({status:429,code:'rate_limited'});
|
||||
});
|
||||
});
|
||||
Loaded 100 of 294 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user