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:
jjbrunton committed 2026-08-28 14:42:11 +01:00
1 parent 01bb8225ae
commit 006761d3b0
294 files changed
+16259 -6138

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env
coverage
test
+31
View File
@@ -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
+28
View File
@@ -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"]
+14
View File
@@ -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");
+14
View File
@@ -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")
);
+71
View File
@@ -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");
+85
View File
@@ -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);
+35
View File
@@ -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();
+178
View File
@@ -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[];
+97
View File
@@ -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
}
]
}
+43
View File
@@ -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"
}
}
+128
View File
@@ -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;
}
+74
View File
@@ -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;
}
+96
View File
@@ -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,
};
}
+6
View File
@@ -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';
+25
View File
@@ -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();
}
+98
View File
@@ -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();
}
}
+32
View File
@@ -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;
+41
View File
@@ -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;
}
+29
View File
@@ -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,
}];
});
}
}
+26
View File
@@ -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);
}
}
+340
View File
@@ -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(); }
}
+122
View File
@@ -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' });
});
}
+24
View File
@@ -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);
}
}
+211
View File
@@ -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();}
}
+28
View File
@@ -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);});
}
+4
View File
@@ -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;
+29
View File
@@ -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); }
}
+193
View File
@@ -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(); }
}
+58
View File
@@ -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);
});
}
+12
View File
@@ -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)]);
+35
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
export * from './repository.js';
export * from './routes.js';
export * from './service.js';
export * from './types.js';
+212
View File
@@ -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();
}
}
+49
View File
@@ -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;
}
});
}
+61
View File
@@ -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());
}
}
+43
View File
@@ -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';
}
}
+245
View File
@@ -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();
}
}
+93
View File
@@ -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);
});
}
+49
View File
@@ -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)]);
+204
View File
@@ -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();
}
}
+53
View File
@@ -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);
});
}
+13
View File
@@ -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;
+44
View File
@@ -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 };
}
}
+31
View File
@@ -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';
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './repository.js';
export * from './janitor.js';
export * from './routes.js';
export * from './storage.js';
export * from './types.js';
+20
View File
@@ -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; }
}
+106
View File
@@ -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(); }
}
+43
View File
@@ -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'}});
});
}
+64
View File
@@ -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;
}
+21
View File
@@ -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;
}
+30
View File
@@ -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.
+5
View File
@@ -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]};
}
+19
View File
@@ -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};
}
}
+51
View File
@@ -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;
}
+220
View File
@@ -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();
}
}
+46
View File
@@ -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)));
}
+66
View File
@@ -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;
}
+116
View File
@@ -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'));
+24
View File
@@ -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);
}
+105
View File
@@ -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;
}
+34
View File
@@ -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()]); }
+30
View File
@@ -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();
}
+154
View File
@@ -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');
}
+65
View File
@@ -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 }
+22
View File
@@ -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};
}
+17
View File
@@ -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([]);
});
});
+25
View File
@@ -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/);
});
});
+143
View File
@@ -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);
});
});
+82
View File
@@ -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);
});
});
+17
View File
@@ -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});});
});
+26
View File
@@ -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');});
});
+83
View File
@@ -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