mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
1 parent
01bb8225ae
commit
006761d3b0
294 files changed
+14704
-4583
No files matched your search
+23
-2
@@ -1,14 +1,35 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env*
|
||||
.DS_Store
|
||||
dist
|
||||
**/dist
|
||||
.expo
|
||||
android
|
||||
ios
|
||||
**/.expo
|
||||
**/android
|
||||
**/ios
|
||||
*.log
|
||||
.turbo
|
||||
**/.turbo
|
||||
coverage
|
||||
.nyc_output
|
||||
apps/admin
|
||||
apps/mcp
|
||||
!apps/mcp
|
||||
!apps/mcp/**
|
||||
apps/mobile
|
||||
apps/supabase
|
||||
apps/web
|
||||
e2e
|
||||
evidence
|
||||
etc
|
||||
infra
|
||||
scripts
|
||||
terraform
|
||||
.agents
|
||||
.claude
|
||||
.codex
|
||||
.ralph
|
||||
@@ -55,6 +55,7 @@ evidence/
|
||||
e2e/.auth/
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
.playwright-mcp/
|
||||
|
||||
# Misc
|
||||
*.pem
|
||||
@@ -70,3 +71,4 @@ nul
|
||||
# Agent runtime evidence (maintained lessons remain tracked)
|
||||
.ralph/activity.log
|
||||
.ralph/runs/
|
||||
.migration-state/
|
||||
@@ -1,5 +1,8 @@
|
||||
VITE_SUPABASE_URL=your_supabase_url
|
||||
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
# Hosted Supabase Auth public settings only. Admin product data and mutations go
|
||||
# through VITE_API_URL; never expose a service-role or admin service token here.
|
||||
VITE_SUPABASE_URL=your_nonproduction_supabase_auth_url
|
||||
VITE_SUPABASE_ANON_KEY=your_nonproduction_supabase_anon_key
|
||||
VITE_API_URL=http://127.0.0.1:3003
|
||||
VITE_OPENAI_API_KEY=your_openai_api_key
|
||||
VITE_OPENROUTER_API_KEY=your_openrouter_api_key
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ RUN pnpm exec turbo run build --filter=@sauci/shared
|
||||
# Build admin app with env vars
|
||||
ARG VITE_SUPABASE_URL
|
||||
ARG VITE_SUPABASE_ANON_KEY
|
||||
ARG VITE_API_URL
|
||||
ARG VITE_OPENAI_API_KEY
|
||||
ARG VITE_OPENROUTER_API_KEY
|
||||
ARG VITE_AI_MODEL
|
||||
@@ -31,6 +32,7 @@ ARG VITE_COUNCIL_REVIEWER_MODEL
|
||||
|
||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_OPENAI_API_KEY=$VITE_OPENAI_API_KEY
|
||||
ENV VITE_OPENROUTER_API_KEY=$VITE_OPENROUTER_API_KEY
|
||||
ENV VITE_AI_MODEL=$VITE_AI_MODEL
|
||||
|
||||
@@ -6,6 +6,7 @@ services:
|
||||
args:
|
||||
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
||||
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
|
||||
- VITE_API_URL=${VITE_API_URL}
|
||||
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
|
||||
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
|
||||
- VITE_AI_MODEL=${VITE_AI_MODEL}
|
||||
|
||||
@@ -8,6 +8,7 @@ services:
|
||||
environment:
|
||||
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
||||
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
|
||||
- VITE_API_URL=${VITE_API_URL:-http://127.0.0.1:3003}
|
||||
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
|
||||
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
|
||||
- VITE_AI_MODEL=${VITE_AI_MODEL}
|
||||
|
||||
@@ -195,7 +195,7 @@ function App() {
|
||||
<Route
|
||||
path="/flagged-messages"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_CHATS}>
|
||||
<FlaggedMessagesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Tags, Check, Plus, X } from 'lucide-react';
|
||||
import { extractTopicsFromPack, ExtractedTopic } from '@/lib/openai';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ExtractTopicsDialogProps {
|
||||
@@ -37,7 +37,7 @@ export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: Ext
|
||||
setAnalyzing(true);
|
||||
try {
|
||||
// Fetch questions for this pack
|
||||
const { data: questions, error: questionsError } = await supabase
|
||||
const { data: questions, error: questionsError } = await adminData
|
||||
.from('questions')
|
||||
.select('text')
|
||||
.eq('pack_id', pack.id);
|
||||
@@ -50,7 +50,7 @@ export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: Ext
|
||||
}
|
||||
|
||||
// Fetch existing topics
|
||||
const { data: existingTopics, error: topicsError } = await supabase
|
||||
const { data: existingTopics, error: topicsError } = await adminData
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -93,7 +93,7 @@ export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: Ext
|
||||
.map(t => t.existingTopicId!);
|
||||
|
||||
if (newTopics.length > 0) {
|
||||
const { data: createdTopics, error: createError } = await supabase
|
||||
const { data: createdTopics, error: createError } = await adminData
|
||||
.from('topics')
|
||||
.insert(newTopics.map(t => ({ name: t.name })))
|
||||
.select('id');
|
||||
@@ -106,14 +106,14 @@ export function ExtractTopicsDialog({ open, onOpenChange, pack, onUpdated }: Ext
|
||||
}
|
||||
|
||||
// Remove existing pack_topics for this pack
|
||||
await supabase
|
||||
await adminData
|
||||
.from('pack_topics')
|
||||
.delete()
|
||||
.eq('pack_id', pack.id);
|
||||
|
||||
// Insert new pack_topics relationships
|
||||
if (existingTopicIds.length > 0) {
|
||||
const { error: linkError } = await supabase
|
||||
const { error: linkError } = await adminData
|
||||
.from('pack_topics')
|
||||
.insert(existingTopicIds.map(topicId => ({
|
||||
pack_id: pack.id,
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Loader2, ShieldCheck, ArrowRight, Check } from 'lucide-react';
|
||||
import { analyzeQuestionTargets, TargetAnalysis } from '@/lib/openai';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Define local compatible interface
|
||||
@@ -111,7 +111,7 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
|
||||
|
||||
// Apply updates in parallel with audit logging
|
||||
await Promise.all(updates.map(update =>
|
||||
auditedSupabase.update('questions', update.id, {
|
||||
auditedAdminData.update('questions', update.id, {
|
||||
allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null,
|
||||
target_user_genders: update.suggested_initiator && update.suggested_initiator.length > 0 ? update.suggested_initiator : null,
|
||||
})
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Loader2, Wand2, ArrowRight, Check } from 'lucide-react';
|
||||
import { analyzeQuestionText, TextAnalysis } from '@/lib/openai';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Question {
|
||||
@@ -62,7 +62,7 @@ export function FixTextDialog({ open, onOpenChange, questions, isExplicit = fals
|
||||
|
||||
// Apply updates in parallel with audit logging
|
||||
await Promise.all(updates.map(update =>
|
||||
auditedSupabase.update('questions', update.id, {
|
||||
auditedAdminData.update('questions', update.id, {
|
||||
text: update.suggested_text,
|
||||
partner_text: update.suggested_partner_text,
|
||||
})
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
DeletionAnalysis,
|
||||
PropsAnalysis,
|
||||
} from '@/lib/openai';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
||||
@@ -101,7 +101,7 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, onUpdated
|
||||
if (existingProps.length > 0) return existingProps;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('questions')
|
||||
.select('required_props')
|
||||
.not('required_props', 'is', null)
|
||||
@@ -252,12 +252,12 @@ export function ReviewQuestionsDialog({ open, onOpenChange, questions, onUpdated
|
||||
|
||||
// Apply updates/deletions in parallel with audit logging
|
||||
const operations: Promise<unknown>[] = updates.map(update =>
|
||||
auditedSupabase.update('questions', update.id, update.data)
|
||||
auditedAdminData.update('questions', update.id, update.data)
|
||||
);
|
||||
|
||||
if (deleteIds.length > 0) {
|
||||
operations.push(
|
||||
auditedSupabase.deleteMany('questions', deleteIds).then(result => {
|
||||
auditedAdminData.deleteMany('questions', deleteIds).then(result => {
|
||||
if (result.error) throw result.error;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
label: string;
|
||||
@@ -32,7 +32,7 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
|
||||
if (params.packId && !params.categoryId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('question_packs')
|
||||
.select('category_id, categories(name)')
|
||||
.eq('id', params.packId)
|
||||
@@ -63,7 +63,7 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
|
||||
if (params.userId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.eq('id', params.userId)
|
||||
|
||||
@@ -108,7 +108,7 @@ const systemNav: NavItem[] = [
|
||||
label: 'Flagged Messages',
|
||||
href: '/flagged-messages',
|
||||
icon: <AlertTriangle className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_CHATS,
|
||||
},
|
||||
{
|
||||
label: 'AI Settings',
|
||||
|
||||
@@ -5,7 +5,15 @@ export const supabaseConfig = {
|
||||
anonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
|
||||
};
|
||||
|
||||
export const supabase = createClient(
|
||||
export const authClient = createClient(
|
||||
supabaseConfig.url,
|
||||
supabaseConfig.anonKey
|
||||
);
|
||||
|
||||
const configuredApiUrl = import.meta.env.VITE_API_URL;
|
||||
|
||||
if (!configuredApiUrl) {
|
||||
throw new Error('Missing required environment variable: VITE_API_URL');
|
||||
}
|
||||
|
||||
export const apiUrl = configuredApiUrl.replace(/\/$/, '');
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { supabase } from '../config';
|
||||
import { authClient } from '../config';
|
||||
import { adminRequest } from '@/lib/adminApi';
|
||||
import type { User, Session } from '@supabase/supabase-js';
|
||||
import { preloadAiConfig, clearAiConfigCache } from '@/hooks/useAiConfig';
|
||||
|
||||
@@ -80,23 +81,26 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
// Get initial session
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
authClient.auth.getSession().then(async ({ data: { session } }) => {
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
|
||||
// Restore permissions from localStorage
|
||||
const storedPermissions = localStorage.getItem(PERMISSIONS_KEY);
|
||||
if (storedPermissions) {
|
||||
setPermissions(JSON.parse(storedPermissions));
|
||||
// Preload AI config if user has an existing session (non-blocking)
|
||||
if (session) {
|
||||
try {
|
||||
const currentPermissions = await adminRequest<AdminPermissions>('/v1/admin/me');
|
||||
setPermissions(currentPermissions);
|
||||
localStorage.setItem(PERMISSIONS_KEY, JSON.stringify(currentPermissions));
|
||||
preloadAiConfig().catch(console.error);
|
||||
} catch {
|
||||
setPermissions(null);
|
||||
localStorage.removeItem(PERMISSIONS_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
// Listen for auth changes
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
const { data: { subscription } } = authClient.auth.onAuthStateChange(
|
||||
(_event, session) => {
|
||||
setSession(session);
|
||||
setUser(session?.user ?? null);
|
||||
@@ -112,7 +116,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
const { error } = await authClient.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
@@ -122,20 +126,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
// Fetch admin role and permissions
|
||||
const { data: adminData, error: adminError } = await supabase
|
||||
.from('admin_users')
|
||||
.select('role, permissions')
|
||||
.eq('user_id', data.user.id)
|
||||
.single();
|
||||
|
||||
if (adminError || !adminData) {
|
||||
await supabase.auth.signOut();
|
||||
let adminData: AdminPermissions;
|
||||
try {
|
||||
adminData = await adminRequest<AdminPermissions>('/v1/admin/me');
|
||||
} catch {
|
||||
await authClient.auth.signOut();
|
||||
throw new Error('Access denied. You are not an administrator.');
|
||||
}
|
||||
|
||||
// Store permissions from database
|
||||
const perms: AdminPermissions = {
|
||||
role: adminData.role as AdminRole,
|
||||
role: adminData.role,
|
||||
permissions: (adminData.permissions as string[]) || [],
|
||||
};
|
||||
|
||||
@@ -150,7 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
localStorage.removeItem(PERMISSIONS_KEY);
|
||||
setPermissions(null);
|
||||
clearAiConfigCache();
|
||||
await supabase.auth.signOut();
|
||||
await authClient.auth.signOut();
|
||||
};
|
||||
|
||||
const isSuperAdmin = permissions?.role === 'super_admin';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
export interface CouncilGenerator {
|
||||
model: string;
|
||||
@@ -74,7 +74,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
const { data, error: fetchError } = await adminData
|
||||
.from('ai_config')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
@@ -82,7 +82,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
||||
|
||||
if (fetchError) {
|
||||
// If user doesn't have access (not super admin), that's expected
|
||||
if (fetchError.code === 'PGRST116' || fetchError.message.includes('permission')) {
|
||||
if (fetchError.message.includes('permission')) {
|
||||
setError('Access denied. Super admin privileges required.');
|
||||
} else {
|
||||
setError(fetchError.message);
|
||||
@@ -110,7 +110,7 @@ export function useAiConfig(): UseAiConfigReturn {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: updateError } = await supabase
|
||||
const { error: updateError } = await adminData
|
||||
.from('ai_config')
|
||||
.update(updates)
|
||||
.eq('id', config.id);
|
||||
@@ -160,7 +160,7 @@ export async function preloadAiConfig(): Promise<AiConfig | null> {
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('ai_config')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
export interface AppConfig {
|
||||
id: string;
|
||||
@@ -40,7 +40,7 @@ export function useAppConfig(): UseAppConfigReturn {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
const { data, error: fetchError } = await adminData
|
||||
.from('app_config')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
@@ -71,7 +71,7 @@ export function useAppConfig(): UseAppConfigReturn {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: updateError } = await supabase
|
||||
const { error: updateError } = await adminData
|
||||
.from('app_config')
|
||||
.update(updates)
|
||||
.eq('id', config.id);
|
||||
|
||||
+18
-31
@@ -1,4 +1,4 @@
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
// Tables to audit
|
||||
const AUDITED_TABLES = [
|
||||
@@ -26,23 +26,10 @@ async function logAction(
|
||||
oldValues?: Record<string, unknown> | null,
|
||||
newValues?: Record<string, unknown> | null
|
||||
): Promise<void> {
|
||||
if (!AUDITED_TABLES.includes(tableName)) return;
|
||||
|
||||
try {
|
||||
const { error } = await supabase.rpc('log_admin_action', {
|
||||
p_table_name: tableName,
|
||||
p_action: action,
|
||||
p_record_id: recordId,
|
||||
p_old_values: oldValues || null,
|
||||
p_new_values: newValues || null,
|
||||
});
|
||||
if (error) {
|
||||
console.error('Failed to log audit action:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to log audit action:', error);
|
||||
// Don't throw - logging failure shouldn't block the operation
|
||||
}
|
||||
// The standalone admin API writes the mutation and audit record in one
|
||||
// transaction. Keeping this no-op preserves the existing call sites while
|
||||
// avoiding duplicate or client-forgeable audit entries.
|
||||
void tableName; void action; void recordId; void oldValues; void newValues;
|
||||
}
|
||||
|
||||
interface InsertResult<T> {
|
||||
@@ -56,9 +43,9 @@ interface SingleResult<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Audited Supabase operations - automatically logs INSERT/UPDATE/DELETE to audit_logs
|
||||
* Admin API mutations. The server writes each mutation and audit record atomically.
|
||||
*/
|
||||
export const auditedSupabase = {
|
||||
export const auditedAdminData = {
|
||||
/**
|
||||
* Insert records with audit logging
|
||||
*/
|
||||
@@ -68,7 +55,7 @@ export const auditedSupabase = {
|
||||
): Promise<InsertResult<T>> {
|
||||
const recordArray = Array.isArray(records) ? records : [records];
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from(table)
|
||||
.insert(recordArray)
|
||||
.select();
|
||||
@@ -94,7 +81,7 @@ export const auditedSupabase = {
|
||||
// Fetch old values before update
|
||||
let oldValues: Record<string, unknown> | null = null;
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
@@ -102,7 +89,7 @@ export const auditedSupabase = {
|
||||
oldValues = oldData;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from(table)
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
@@ -127,14 +114,14 @@ export const auditedSupabase = {
|
||||
// Fetch old values before update
|
||||
let oldRecords: Record<string, unknown>[] = [];
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq(filter.column, filter.value);
|
||||
oldRecords = oldData || [];
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from(table)
|
||||
.update(updates)
|
||||
.eq(filter.column, filter.value)
|
||||
@@ -161,7 +148,7 @@ export const auditedSupabase = {
|
||||
// Fetch old values before delete
|
||||
let oldValues: Record<string, unknown> | null = null;
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
@@ -169,7 +156,7 @@ export const auditedSupabase = {
|
||||
oldValues = oldData;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from(table)
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
@@ -191,14 +178,14 @@ export const auditedSupabase = {
|
||||
// Fetch old values before delete
|
||||
let oldRecords: Record<string, unknown>[] = [];
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.in('id', ids);
|
||||
oldRecords = oldData || [];
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from(table)
|
||||
.delete()
|
||||
.in('id', ids);
|
||||
@@ -224,7 +211,7 @@ export const auditedSupabase = {
|
||||
// Fetch old values before delete
|
||||
let oldValues: Record<string, unknown> | null = null;
|
||||
if (AUDITED_TABLES.includes(table)) {
|
||||
const { data: oldData } = await supabase
|
||||
const { data: oldData } = await adminData
|
||||
.from(table)
|
||||
.select('*')
|
||||
.eq(field, value)
|
||||
@@ -232,7 +219,7 @@ export const auditedSupabase = {
|
||||
oldValues = oldData;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from(table)
|
||||
.delete()
|
||||
.eq(field, value);
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type SupabaseFilterFn = (query: any) => any;
|
||||
type AdminFilterFn = (query: any) => any;
|
||||
|
||||
interface UseCrudOperationsOptions<T> {
|
||||
/**
|
||||
* The Supabase table name
|
||||
* The standalone admin API resource name
|
||||
*/
|
||||
table: string;
|
||||
|
||||
@@ -41,7 +41,7 @@ interface UseCrudOperationsOptions<T> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic CRUD operations hook for Supabase tables
|
||||
* Generic CRUD operations hook for standalone admin API resources
|
||||
* Reduces boilerplate for data fetching and mutations across pages.
|
||||
*
|
||||
* @example
|
||||
@@ -90,13 +90,13 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
* @param filter Optional filter function to modify the query
|
||||
*/
|
||||
const fetchAll = useCallback(async (
|
||||
filter?: SupabaseFilterFn
|
||||
filter?: AdminFilterFn
|
||||
): Promise<T[]> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let query: any = supabase.from(table).select(select);
|
||||
let query: any = adminData.from(table).select(select);
|
||||
|
||||
if (filter) {
|
||||
query = filter(query);
|
||||
@@ -131,7 +131,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
*/
|
||||
const fetchById = useCallback(async (id: string): Promise<T | null> => {
|
||||
try {
|
||||
const { data, error: fetchError } = await supabase
|
||||
const { data, error: fetchError } = await adminData
|
||||
.from(table)
|
||||
.select(select)
|
||||
.eq('id', id)
|
||||
@@ -158,7 +158,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
const { refetch = true, silent = false } = options;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const { data: created, error: createError } = await auditedSupabase.insert(table, data as any);
|
||||
const { data: created, error: createError } = await auditedAdminData.insert(table, data as any);
|
||||
|
||||
if (createError) throw createError;
|
||||
|
||||
@@ -170,7 +170,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
await fetchAll();
|
||||
}
|
||||
|
||||
// auditedSupabase.insert returns T[] | null, get first element
|
||||
// auditedAdminData.insert returns T[] | null, get first element
|
||||
return created && created.length > 0 ? (created[0] as T) : null;
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
@@ -194,7 +194,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
): Promise<boolean> => {
|
||||
const { refetch = true, silent = false } = options;
|
||||
try {
|
||||
const { error: updateError } = await auditedSupabase.update(table, id, data);
|
||||
const { error: updateError } = await auditedAdminData.update(table, id, data);
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
@@ -233,7 +233,7 @@ export function useCrudOperations<T extends { id: string }>({
|
||||
}
|
||||
|
||||
try {
|
||||
const { error: deleteError } = await auditedSupabase.delete(table, id);
|
||||
const { error: deleteError } = await auditedAdminData.delete(table, id);
|
||||
|
||||
if (deleteError) throw deleteError;
|
||||
|
||||
|
||||
@@ -1,243 +1,92 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||
import { queryAdminRows, type AdminFilter } from '@/lib/adminApi';
|
||||
|
||||
export type SubscriptionStatus = 'SUBSCRIBED' | 'SUBSCRIBING' | 'CHANNEL_ERROR' | 'TIMED_OUT' | 'CLOSED';
|
||||
|
||||
export interface UseRealtimeSubscriptionOptions<T> {
|
||||
table: string;
|
||||
schema?: string;
|
||||
event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
|
||||
filter?: string;
|
||||
onInsert?: (payload: T) => void;
|
||||
onUpdate?: (payload: { old: T; new: T }) => void;
|
||||
onDelete?: (payload: T) => void;
|
||||
insertToast?: {
|
||||
enabled: boolean;
|
||||
message: string | ((payload: T) => string);
|
||||
type?: 'success' | 'info' | 'warning';
|
||||
};
|
||||
updateToast?: {
|
||||
enabled: boolean;
|
||||
message: string | ((payload: { old: T; new: T }) => string);
|
||||
type?: 'success' | 'info' | 'warning';
|
||||
};
|
||||
deleteToast?: {
|
||||
enabled: boolean;
|
||||
message: string | ((payload: T) => string);
|
||||
type?: 'success' | 'info' | 'warning';
|
||||
};
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
table: string; schema?: string; event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*'; filter?: string;
|
||||
onInsert?: (payload: T) => void; onUpdate?: (payload: { old: T; new: T }) => void; onDelete?: (payload: T) => void;
|
||||
insertToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
|
||||
updateToast?: { enabled: boolean; message: string | ((payload: { old: T; new: T }) => string); type?: 'success' | 'info' | 'warning' };
|
||||
deleteToast?: { enabled: boolean; message: string | ((payload: T) => string); type?: 'success' | 'info' | 'warning' };
|
||||
debounceMs?: number; enabled?: boolean; pollIntervalMs?: number;
|
||||
}
|
||||
export interface UseRealtimeSubscriptionReturn { status: SubscriptionStatus; error: Error | null }
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_MS = 10_000;
|
||||
const MIN_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
function parseFilter(filter?: string): AdminFilter[] | undefined {
|
||||
if (!filter) return undefined;
|
||||
const match = /^([a-z_][a-z0-9_]*)=(eq|neq|gte|lte)\.(.+)$/.exec(filter);
|
||||
return match ? [{ column: match[1], op: match[2] as AdminFilter['op'], value: match[3] }] : undefined;
|
||||
}
|
||||
|
||||
export interface UseRealtimeSubscriptionReturn {
|
||||
status: SubscriptionStatus;
|
||||
error: Error | null;
|
||||
function rowKey(row: unknown, index: number): string {
|
||||
if (row && typeof row === 'object') {
|
||||
const record = row as Record<string, unknown>;
|
||||
const key = record.id ?? `${record.pack_id ?? ''}:${record.topic_id ?? ''}`;
|
||||
if (key) return String(key);
|
||||
}
|
||||
return String(index);
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
export function useRealtimeSubscription<T>(
|
||||
options: UseRealtimeSubscriptionOptions<T>
|
||||
): UseRealtimeSubscriptionReturn {
|
||||
const {
|
||||
table,
|
||||
schema = 'public',
|
||||
event = '*',
|
||||
filter,
|
||||
onInsert,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
insertToast,
|
||||
updateToast,
|
||||
deleteToast,
|
||||
debounceMs = 0,
|
||||
enabled = true,
|
||||
} = options;
|
||||
|
||||
export function useRealtimeSubscription<T>(options: UseRealtimeSubscriptionOptions<T>): UseRealtimeSubscriptionReturn {
|
||||
const { table, event = '*', filter, onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast,
|
||||
debounceMs = 0, enabled = true, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS } = options;
|
||||
const [status, setStatus] = useState<SubscriptionStatus>('CLOSED');
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryCountRef = useRef(0);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// Use refs for callbacks and toast options to avoid re-subscription on every render
|
||||
const onInsertRef = useRef(onInsert);
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
const onDeleteRef = useRef(onDelete);
|
||||
const insertToastRef = useRef(insertToast);
|
||||
const updateToastRef = useRef(updateToast);
|
||||
const deleteToastRef = useRef(deleteToast);
|
||||
|
||||
// Keep refs up to date
|
||||
useEffect(() => {
|
||||
onInsertRef.current = onInsert;
|
||||
onUpdateRef.current = onUpdate;
|
||||
onDeleteRef.current = onDelete;
|
||||
insertToastRef.current = insertToast;
|
||||
updateToastRef.current = updateToast;
|
||||
deleteToastRef.current = deleteToast;
|
||||
});
|
||||
|
||||
// Debounced callback wrapper
|
||||
const debouncedCallback = useCallback(
|
||||
(callback: () => void) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (debounceMs > 0) {
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
callback();
|
||||
}
|
||||
}, debounceMs);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
[debounceMs]
|
||||
);
|
||||
|
||||
// Handle incoming changes - use refs to avoid dependency changes
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleChange = useCallback(
|
||||
(payload: { eventType: string; new: any; old: any }) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const eventType = payload.eventType;
|
||||
|
||||
if (eventType === 'INSERT') {
|
||||
const newRecord = payload.new as T;
|
||||
|
||||
if (insertToastRef.current?.enabled) {
|
||||
const message =
|
||||
typeof insertToastRef.current.message === 'function'
|
||||
? insertToastRef.current.message(newRecord)
|
||||
: insertToastRef.current.message;
|
||||
toast[insertToastRef.current.type || 'info'](message);
|
||||
}
|
||||
|
||||
if (onInsertRef.current) {
|
||||
debouncedCallback(() => onInsertRef.current!(newRecord));
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'UPDATE') {
|
||||
const updatePayload = { old: payload.old as T, new: payload.new as T };
|
||||
|
||||
if (updateToastRef.current?.enabled) {
|
||||
const message =
|
||||
typeof updateToastRef.current.message === 'function'
|
||||
? updateToastRef.current.message(updatePayload)
|
||||
: updateToastRef.current.message;
|
||||
toast[updateToastRef.current.type || 'info'](message);
|
||||
}
|
||||
|
||||
if (onUpdateRef.current) {
|
||||
debouncedCallback(() => onUpdateRef.current!(updatePayload));
|
||||
}
|
||||
}
|
||||
|
||||
if (eventType === 'DELETE') {
|
||||
const deletedRecord = payload.old as T;
|
||||
|
||||
if (deleteToastRef.current?.enabled) {
|
||||
const message =
|
||||
typeof deleteToastRef.current.message === 'function'
|
||||
? deleteToastRef.current.message(deletedRecord)
|
||||
: deleteToastRef.current.message;
|
||||
toast[deleteToastRef.current.type || 'info'](message);
|
||||
}
|
||||
|
||||
if (onDeleteRef.current) {
|
||||
debouncedCallback(() => onDeleteRef.current!(deletedRecord));
|
||||
}
|
||||
}
|
||||
},
|
||||
[debouncedCallback]
|
||||
);
|
||||
const snapshotRef = useRef<Map<string, { value: T; json: string }> | null>(null);
|
||||
const callbacksRef = useRef({ onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast });
|
||||
callbacksRef.current = { onInsert, onUpdate, onDelete, insertToast, updateToast, deleteToast };
|
||||
const emit = useCallback((callback: () => void) => {
|
||||
if (debounceMs <= 0) callback(); else window.setTimeout(callback, debounceMs);
|
||||
}, [debounceMs]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
if (!enabled) {
|
||||
setStatus('CLOSED');
|
||||
return;
|
||||
if (!enabled) { setStatus('CLOSED'); return; }
|
||||
let cancelled = false;
|
||||
let timer: number | undefined;
|
||||
const interval = Math.max(MIN_POLL_INTERVAL_MS, pollIntervalMs);
|
||||
const poll = async () => {
|
||||
if (document.visibilityState === 'hidden') { timer = window.setTimeout(poll, interval); return; }
|
||||
try {
|
||||
setStatus((current) => current === 'SUBSCRIBED' ? current : 'SUBSCRIBING');
|
||||
const { rows } = await queryAdminRows<T>(table, { filters: parseFilter(filter), limit: 500 });
|
||||
if (cancelled) return;
|
||||
const next = new Map(rows.map((row, index) => [rowKey(row, index), { value: row, json: JSON.stringify(row) }]));
|
||||
const previous = snapshotRef.current;
|
||||
if (previous) {
|
||||
if (event === '*' || event === 'INSERT') for (const [key, entry] of next) if (!previous.has(key)) {
|
||||
const config = callbacksRef.current;
|
||||
if (config.insertToast?.enabled) toast[config.insertToast.type || 'info'](typeof config.insertToast.message === 'function' ? config.insertToast.message(entry.value) : config.insertToast.message);
|
||||
if (config.onInsert) emit(() => config.onInsert!(entry.value));
|
||||
}
|
||||
|
||||
const subscribe = () => {
|
||||
// Create unique channel name
|
||||
const channelName = `admin-${table}-${filter || 'all'}-${Date.now()}`;
|
||||
|
||||
// Build subscription config
|
||||
const subscriptionConfig: {
|
||||
event: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
|
||||
schema: string;
|
||||
table: string;
|
||||
filter?: string;
|
||||
} = {
|
||||
event,
|
||||
schema,
|
||||
table,
|
||||
};
|
||||
|
||||
if (filter) {
|
||||
subscriptionConfig.filter = filter;
|
||||
if (event === '*' || event === 'UPDATE') for (const [key, entry] of next) {
|
||||
const old = previous.get(key);
|
||||
if (old && old.json !== entry.json) {
|
||||
const payload = { old: old.value, new: entry.value };
|
||||
const config = callbacksRef.current;
|
||||
if (config.updateToast?.enabled) toast[config.updateToast.type || 'info'](typeof config.updateToast.message === 'function' ? config.updateToast.message(payload) : config.updateToast.message);
|
||||
if (config.onUpdate) emit(() => config.onUpdate!(payload));
|
||||
}
|
||||
|
||||
setStatus('SUBSCRIBING');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const channel = (supabase.channel(channelName) as any)
|
||||
.on('postgres_changes', subscriptionConfig, handleChange)
|
||||
.subscribe((subscriptionStatus: string) => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (subscriptionStatus === 'SUBSCRIBED') {
|
||||
setStatus('SUBSCRIBED');
|
||||
setError(null);
|
||||
retryCountRef.current = 0;
|
||||
} else if (subscriptionStatus === 'CHANNEL_ERROR') {
|
||||
setStatus('CHANNEL_ERROR');
|
||||
setError(new Error(`Failed to subscribe to ${table}`));
|
||||
|
||||
// Auto-retry with exponential backoff
|
||||
if (retryCountRef.current < MAX_RETRIES) {
|
||||
const timeout = Math.pow(2, retryCountRef.current) * 1000;
|
||||
retryCountRef.current++;
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current && channelRef.current) {
|
||||
supabase.removeChannel(channelRef.current);
|
||||
subscribe();
|
||||
}
|
||||
}, timeout);
|
||||
if (event === '*' || event === 'DELETE') for (const [key, entry] of previous) if (!next.has(key)) {
|
||||
const config = callbacksRef.current;
|
||||
if (config.deleteToast?.enabled) toast[config.deleteToast.type || 'info'](typeof config.deleteToast.message === 'function' ? config.deleteToast.message(entry.value) : config.deleteToast.message);
|
||||
if (config.onDelete) emit(() => config.onDelete!(entry.value));
|
||||
}
|
||||
} else if (subscriptionStatus === 'TIMED_OUT') {
|
||||
setStatus('TIMED_OUT');
|
||||
setError(new Error(`Subscription to ${table} timed out`));
|
||||
} else if (subscriptionStatus === 'CLOSED') {
|
||||
setStatus('CLOSED');
|
||||
}
|
||||
});
|
||||
|
||||
channelRef.current = channel;
|
||||
};
|
||||
|
||||
subscribe();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
if (channelRef.current) {
|
||||
supabase.removeChannel(channelRef.current);
|
||||
snapshotRef.current = next;
|
||||
setStatus('SUBSCRIBED'); setError(null);
|
||||
} catch (cause) {
|
||||
if (!cancelled) { setStatus('CHANNEL_ERROR'); setError(cause instanceof Error ? cause : new Error(String(cause))); }
|
||||
} finally {
|
||||
if (!cancelled) timer = window.setTimeout(poll, interval);
|
||||
}
|
||||
};
|
||||
}, [enabled, table, schema, event, filter, handleChange]);
|
||||
|
||||
void poll();
|
||||
return () => { cancelled = true; if (timer !== undefined) window.clearTimeout(timer); snapshotRef.current = null; };
|
||||
}, [emit, enabled, event, filter, pollIntervalMs, table]);
|
||||
return { status, error };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() }));
|
||||
vi.mock('@/config', () => ({
|
||||
apiUrl: 'http://127.0.0.1:3003',
|
||||
authClient: { auth: { getSession } },
|
||||
}));
|
||||
|
||||
import { AdminApiError, adminRequest, queryAdminRows } from './adminApi';
|
||||
|
||||
describe('admin API transport', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
getSession.mockResolvedValue({ data: { session: { access_token: 'hosted-auth-token' } }, error: null });
|
||||
});
|
||||
|
||||
it('sends the hosted Auth bearer token to the standalone API', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ rows: [], count: 0 }), {
|
||||
status: 200, headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
await queryAdminRows('profiles', { limit: 25 });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('http://127.0.0.1:3003/v1/admin/query/profiles');
|
||||
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer hosted-auth-token');
|
||||
expect(init?.body).toBe(JSON.stringify({ limit: 25 }));
|
||||
});
|
||||
|
||||
it('fails closed when there is no authenticated session', async () => {
|
||||
getSession.mockResolvedValue({ data: { session: null }, error: null });
|
||||
await expect(adminRequest('/v1/admin/me')).rejects.toMatchObject({ status: 401 } satisfies Partial<AdminApiError>);
|
||||
});
|
||||
|
||||
it('surfaces the API error message', async () => {
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ error: { message: 'Administrator access denied' } }), {
|
||||
status: 403, headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
await expect(adminRequest('/v1/admin/me')).rejects.toThrow('Administrator access denied');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { apiUrl, authClient } from '@/config';
|
||||
|
||||
export class AdminApiError extends Error {
|
||||
constructor(message: string, readonly status: number, readonly details?: unknown) {
|
||||
super(message);
|
||||
this.name = 'AdminApiError';
|
||||
}
|
||||
}
|
||||
|
||||
type RequestOptions = Omit<RequestInit, 'body'> & { body?: unknown };
|
||||
|
||||
async function accessToken(): Promise<string> {
|
||||
const { data, error } = await authClient.auth.getSession();
|
||||
const token = data.session?.access_token;
|
||||
if (error || !token) throw new AdminApiError('An authenticated admin session is required', 401, error);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function adminRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const token = await accessToken();
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('Accept', 'application/json');
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
|
||||
if (body !== undefined) headers.set('Content-Type', 'application/json');
|
||||
const response = await fetch(`${apiUrl}${path.startsWith('/') ? path : `/${path}`}`, { ...options, headers, body });
|
||||
const text = await response.text();
|
||||
let parsed: unknown;
|
||||
try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; }
|
||||
if (!response.ok) {
|
||||
const nested = parsed && typeof parsed === 'object' && 'error' in parsed ? (parsed as { error: unknown }).error : undefined;
|
||||
const message = nested && typeof nested === 'object' && 'message' in nested
|
||||
? String((nested as { message: unknown }).message)
|
||||
: `Admin API request failed with status ${response.status}`;
|
||||
throw new AdminApiError(message, response.status, parsed);
|
||||
}
|
||||
return parsed as T;
|
||||
}
|
||||
|
||||
export async function adminBinaryRequest(path: string, options: RequestOptions = {}): Promise<Blob> {
|
||||
const token = await accessToken();
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
const body = options.body === undefined ? undefined : JSON.stringify(options.body);
|
||||
if (body !== undefined) headers.set('Content-Type', 'application/json');
|
||||
const response = await fetch(`${apiUrl}${path.startsWith('/') ? path : `/${path}`}`, { ...options, headers, body });
|
||||
if (!response.ok) throw new AdminApiError(`Admin media request failed with status ${response.status}`, response.status);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export type AdminFilter = { column: string; op: 'eq' | 'neq' | 'in' | 'is' | 'gte' | 'lte' | 'ilike'; value: unknown };
|
||||
export type AdminQuery = {
|
||||
filters?: AdminFilter[];
|
||||
order?: { column: string; ascending?: boolean };
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function queryAdminRows<T>(resource: string, query: AdminQuery = {}): Promise<{ rows: T[]; count: number }> {
|
||||
return adminRequest(`/v1/admin/query/${encodeURIComponent(resource)}`, { method: 'POST', body: query });
|
||||
}
|
||||
|
||||
function uniqueValues(rows: Record<string, unknown>[], key: string): unknown[] {
|
||||
return [...new Set(rows.map((row) => row[key]).filter((value) => value !== null && value !== undefined))];
|
||||
}
|
||||
|
||||
async function relatedRows(resource: string, column: string, values: unknown[]): Promise<Record<string, unknown>[]> {
|
||||
if (values.length === 0) return [];
|
||||
return (await queryAdminRows<Record<string, unknown>>(resource, {
|
||||
filters: [{ column, op: 'in', value: values }], limit: 500,
|
||||
})).rows;
|
||||
}
|
||||
|
||||
async function hydrateAdminRows(resource: string, selection: string, input: any[]): Promise<any[]> {
|
||||
const rows = input as Record<string, unknown>[];
|
||||
if (rows.length === 0) return input;
|
||||
if (resource === 'question_packs' && selection.includes('categor')) {
|
||||
const categories = await relatedRows('categories', 'id', uniqueValues(rows, 'category_id'));
|
||||
const byId = new Map(categories.map((row) => [row.id, row]));
|
||||
rows.forEach((row) => { row.category = byId.get(row.category_id) ?? null; });
|
||||
}
|
||||
if (resource === 'pack_topics' && selection.includes('topics')) {
|
||||
const topics = await relatedRows('topics', 'id', uniqueValues(rows, 'topic_id'));
|
||||
const byId = new Map(topics.map((row) => [row.id, row]));
|
||||
rows.forEach((row) => { row.topics = byId.get(row.topic_id) ?? null; });
|
||||
}
|
||||
if (resource === 'feedback' && selection.includes('profile')) {
|
||||
const profiles = await relatedRows('profiles', 'id', uniqueValues(rows, 'user_id'));
|
||||
const byId = new Map(profiles.map((row) => [row.id, row]));
|
||||
rows.forEach((row) => { row.profile = byId.get(row.user_id) ?? null; });
|
||||
}
|
||||
if (resource === 'message_reports') {
|
||||
if (selection.includes('message:')) {
|
||||
const messages = await relatedRows('messages', 'id', uniqueValues(rows, 'message_id'));
|
||||
const byId = new Map(messages.map((row) => [row.id, row]));
|
||||
rows.forEach((row) => { row.message = byId.get(row.message_id) ?? null; });
|
||||
}
|
||||
if (selection.includes('reporter_profile')) {
|
||||
const profiles = await relatedRows('profiles', 'id', uniqueValues(rows, 'reporter_id'));
|
||||
const byId = new Map(profiles.map((row) => [row.id, row]));
|
||||
rows.forEach((row) => { row.reporter_profile = byId.get(row.reporter_id) ?? null; });
|
||||
}
|
||||
}
|
||||
if (resource === 'responses') {
|
||||
if (selection.includes('profile:')) {
|
||||
const profiles = await relatedRows('profiles', 'id', uniqueValues(rows, 'user_id'));
|
||||
const byId = new Map(profiles.map((row) => [row.id, row]));
|
||||
rows.forEach((row) => { row.profile = byId.get(row.user_id) ?? null; });
|
||||
}
|
||||
if (selection.includes('question:')) {
|
||||
const questions = await relatedRows('questions', 'id', uniqueValues(rows, 'question_id'));
|
||||
if (selection.includes('pack:question_packs')) {
|
||||
const packs = await relatedRows('question_packs', 'id', uniqueValues(questions, 'pack_id'));
|
||||
const packsById = new Map(packs.map((row) => [row.id, row]));
|
||||
questions.forEach((row) => { row.pack = packsById.get(row.pack_id) ?? null; });
|
||||
}
|
||||
const byId = new Map(questions.map((row) => [row.id, row]));
|
||||
rows.forEach((row) => { row.question = byId.get(row.question_id) ?? null; });
|
||||
}
|
||||
}
|
||||
if (resource === 'matches' && selection.includes('question:')) {
|
||||
const questions = await relatedRows('questions', 'id', uniqueValues(rows, 'question_id'));
|
||||
const byId = new Map(questions.map((row) => [row.id, row]));
|
||||
rows.forEach((row) => { row.question = byId.get(row.question_id) ?? null; });
|
||||
}
|
||||
if (resource === 'messages' && selection.includes('match:')) {
|
||||
const matches = await relatedRows('matches', 'id', uniqueValues(rows, 'match_id'));
|
||||
const questions = await relatedRows('questions', 'id', uniqueValues(matches, 'question_id'));
|
||||
const questionsById = new Map(questions.map((row) => [row.id, row]));
|
||||
matches.forEach((row) => { row.question = questionsById.get(row.question_id) ?? null; });
|
||||
const byId = new Map(matches.map((row) => [row.id, row]));
|
||||
rows.forEach((row) => { row.match = byId.get(row.match_id) ?? null; });
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
type QueryResult<T> = { data: T | null; error: Error | null; count: number | null };
|
||||
type QueryMode = 'many' | 'single' | 'maybeSingle';
|
||||
|
||||
class AdminQueryBuilder<T = any> implements PromiseLike<QueryResult<T[]>> {
|
||||
private readonly filters: AdminFilter[] = [];
|
||||
private ordering?: { column: string; ascending?: boolean };
|
||||
private maxRows?: number;
|
||||
private offset?: number;
|
||||
private head = false;
|
||||
private selection = '*';
|
||||
private orFilters?: AdminFilter[];
|
||||
private mode: QueryMode = 'many';
|
||||
private mutation?: { type: 'insert'; records: Record<string, unknown>[] } | { type: 'update'; values: Record<string, unknown> } | { type: 'delete' };
|
||||
|
||||
constructor(private readonly resource: string) {}
|
||||
|
||||
select(columns = '*', options?: { count?: 'exact'; head?: boolean }): this {
|
||||
this.selection = columns;
|
||||
this.head = options?.head === true;
|
||||
return this;
|
||||
}
|
||||
eq(column: string, value: unknown): this { this.filters.push({ column, op: 'eq', value }); return this; }
|
||||
neq(column: string, value: unknown): this { this.filters.push({ column, op: 'neq', value }); return this; }
|
||||
in(column: string, value: unknown[]): this { this.filters.push({ column, op: 'in', value }); return this; }
|
||||
is(column: string, value: unknown): this { this.filters.push({ column, op: 'is', value }); return this; }
|
||||
gte(column: string, value: unknown): this { this.filters.push({ column, op: 'gte', value }); return this; }
|
||||
lte(column: string, value: unknown): this { this.filters.push({ column, op: 'lte', value }); return this; }
|
||||
ilike(column: string, value: string): this { this.filters.push({ column, op: 'ilike', value }); return this; }
|
||||
or(expression: string): this {
|
||||
this.orFilters = expression.split(',').map((part) => {
|
||||
const match = /^([a-z_][a-z0-9_]*)\.ilike\.(.+)$/.exec(part);
|
||||
if (!match) throw new Error('Unsupported admin OR filter');
|
||||
return { column: match[1], op: 'ilike' as const, value: match[2] };
|
||||
});
|
||||
return this;
|
||||
}
|
||||
not(column: string, operator: string, value: unknown): this {
|
||||
if (operator !== 'is' || value !== null) throw new Error(`Unsupported admin filter: not ${operator}`);
|
||||
this.filters.push({ column, op: 'neq', value: null });
|
||||
return this;
|
||||
}
|
||||
order(column: string, options?: { ascending?: boolean }): this { this.ordering = { column, ascending: options?.ascending }; return this; }
|
||||
limit(value: number): this { this.maxRows = value; return this; }
|
||||
range(from: number, to: number): this { this.offset = from; this.maxRows = to - from + 1; return this; }
|
||||
single<TResult = T>(): PromiseLike<QueryResult<TResult>> { this.mode = 'single'; return this as unknown as PromiseLike<QueryResult<TResult>>; }
|
||||
maybeSingle<TResult = T>(): PromiseLike<QueryResult<TResult>> { this.mode = 'maybeSingle'; return this as unknown as PromiseLike<QueryResult<TResult>>; }
|
||||
insert(records: Record<string, unknown> | Record<string, unknown>[]): this {
|
||||
this.mutation = { type: 'insert', records: Array.isArray(records) ? records : [records] };
|
||||
return this;
|
||||
}
|
||||
update(values: Record<string, unknown>): this { this.mutation = { type: 'update', values }; return this; }
|
||||
delete(): this { this.mutation = { type: 'delete' }; return this; }
|
||||
|
||||
then<TResult1 = QueryResult<T[]>, TResult2 = never>(
|
||||
onfulfilled?: ((value: QueryResult<T[]>) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
||||
): PromiseLike<TResult1 | TResult2> {
|
||||
return this.execute().then(onfulfilled, onrejected);
|
||||
}
|
||||
|
||||
private async execute(): Promise<QueryResult<T[]>> {
|
||||
try {
|
||||
if (this.mutation?.type === 'insert') {
|
||||
const result = await adminRequest<{ rows: T[] }>(`/v1/admin/data/${this.resource}`, { method: 'POST', body: { records: this.mutation.records } });
|
||||
return this.shape(result.rows, result.rows.length);
|
||||
}
|
||||
if (this.mutation) {
|
||||
const matching = await queryAdminRows<Record<string, unknown>>(this.resource, { filters: this.filters, limit: 500 });
|
||||
const ids = [...new Set(matching.rows.map((row) => String(row.id ?? row.pack_id ?? '')).filter(Boolean))];
|
||||
if (this.mutation.type === 'update') {
|
||||
const rows = await Promise.all(ids.map((id) => adminRequest<{ row: T }>(`/v1/admin/data/${this.resource}/${encodeURIComponent(id)}`, { method: 'PATCH', body: { values: this.mutation!.type === 'update' ? this.mutation!.values : {} } }).then((value) => value.row)));
|
||||
return this.shape(rows, rows.length);
|
||||
}
|
||||
await Promise.all(ids.map((id) => adminRequest(`/v1/admin/data/${this.resource}/${encodeURIComponent(id)}`, { method: 'DELETE' })));
|
||||
return this.shape([], ids.length);
|
||||
}
|
||||
const result = this.orFilters?.length
|
||||
? await this.executeOrQuery()
|
||||
: await queryAdminRows<T>(this.resource, { filters: this.filters, order: this.ordering, limit: this.maxRows, offset: this.offset });
|
||||
if (!this.head) result.rows = await hydrateAdminRows(this.resource, this.selection, result.rows);
|
||||
return this.shape(result.rows, result.count);
|
||||
} catch (cause) {
|
||||
return { data: null, error: cause instanceof Error ? cause : new Error(String(cause)), count: null };
|
||||
}
|
||||
}
|
||||
|
||||
private async executeOrQuery(): Promise<{ rows: T[]; count: number }> {
|
||||
const results = await Promise.all(this.orFilters!.map((filter) => queryAdminRows<T>(this.resource, {
|
||||
filters: [...this.filters, filter], order: this.ordering, limit: 500,
|
||||
})));
|
||||
const unique = new Map<string, T>();
|
||||
for (const result of results) result.rows.forEach((row, index) => {
|
||||
const record = row as Record<string, unknown>;
|
||||
unique.set(String(record.id ?? `${record.pack_id ?? ''}:${record.topic_id ?? ''}:${index}`), row);
|
||||
});
|
||||
let rows = [...unique.values()];
|
||||
if (this.offset) rows = rows.slice(this.offset);
|
||||
if (this.maxRows) rows = rows.slice(0, this.maxRows);
|
||||
return { rows, count: unique.size };
|
||||
}
|
||||
|
||||
private shape(rows: T[], count: number): QueryResult<T[]> {
|
||||
if (this.head) return { data: null, error: null, count };
|
||||
if (this.mode === 'single') {
|
||||
if (rows.length !== 1) return { data: null, error: new Error(`Expected one ${this.resource} record, received ${rows.length}`), count };
|
||||
return { data: rows[0] as unknown as T[], error: null, count };
|
||||
}
|
||||
if (this.mode === 'maybeSingle') {
|
||||
if (rows.length > 1) return { data: null, error: new Error(`Expected at most one ${this.resource} record, received ${rows.length}`), count };
|
||||
return { data: (rows[0] ?? null) as unknown as T[], error: null, count };
|
||||
}
|
||||
return { data: rows, error: null, count };
|
||||
}
|
||||
}
|
||||
|
||||
export const adminData = {
|
||||
from<T = any>(resource: string): AdminQueryBuilder<T> { return new AdminQueryBuilder<T>(resource); },
|
||||
};
|
||||
@@ -47,7 +47,7 @@ export function getCouncilConfig(): CouncilConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize AI config by preloading from Supabase
|
||||
* Initialize AI config by preloading from the standalone admin API
|
||||
* Call this on app startup to ensure config is available
|
||||
*/
|
||||
export async function initializeAiConfig(): Promise<AiConfig | null> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
@@ -45,7 +45,7 @@ export function AuditLogsPage() {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('audit_logs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData, adminRequest } from '@/lib/adminApi';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, TrendingUp, Heart, MessageCircle, Bell } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -43,6 +43,7 @@ function formatFeatureName(featureName: string) {
|
||||
export function DashboardPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
|
||||
const canViewChats = hasPermission(PERMISSION_KEYS.VIEW_CHATS);
|
||||
const [stats, setStats] = useState({
|
||||
categories: 0,
|
||||
packs: 0,
|
||||
@@ -64,10 +65,12 @@ export function DashboardPage() {
|
||||
{ count: questionsCount },
|
||||
{ count: usersCount },
|
||||
] = await Promise.all([
|
||||
supabase.from('categories').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('questions').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('profiles').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('categories').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('questions').select('*', { count: 'exact', head: true }),
|
||||
canViewUsers
|
||||
? adminData.from('profiles').select('*', { count: 'exact', head: true })
|
||||
: Promise.resolve({ count: 0 }),
|
||||
]);
|
||||
|
||||
setStats({
|
||||
@@ -85,31 +88,24 @@ export function DashboardPage() {
|
||||
{ data: featureInterests },
|
||||
{ data: recentInterests },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from('messages')
|
||||
.select('created_at')
|
||||
.gte('created_at', startDate.toISOString())
|
||||
.lte('created_at', endDate.toISOString()),
|
||||
supabase
|
||||
.from('feature_interests')
|
||||
.select('created_at, user_id')
|
||||
.gte('created_at', startDate.toISOString())
|
||||
.lte('created_at', endDate.toISOString()),
|
||||
supabase
|
||||
.from('feature_interests')
|
||||
.select('id, user_id, feature_name, created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10),
|
||||
canViewChats
|
||||
? adminData.from('messages').select('created_at').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
||||
: Promise.resolve({ data: [] }),
|
||||
canViewUsers
|
||||
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
||||
: Promise.resolve({ data: [] }),
|
||||
canViewUsers
|
||||
? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10)
|
||||
: Promise.resolve({ data: [] }),
|
||||
]);
|
||||
|
||||
if (canViewUsers) {
|
||||
const { data: countsData, error: countsError } = await supabase.rpc('get_feature_interest_counts');
|
||||
|
||||
if (countsError) {
|
||||
try {
|
||||
const { counts } = await adminRequest<{ counts: FeatureInterestCountsRow[] }>('/v1/admin/feature-interest-counts');
|
||||
setFeatureInterestCounts(counts);
|
||||
} catch (countsError) {
|
||||
console.error('Failed to fetch feature interest counts:', countsError);
|
||||
setFeatureInterestCounts([]);
|
||||
} else {
|
||||
setFeatureInterestCounts((countsData ?? []) as FeatureInterestCountsRow[]);
|
||||
}
|
||||
} else {
|
||||
setFeatureInterestCounts([]);
|
||||
@@ -172,7 +168,7 @@ export function DashboardPage() {
|
||||
if (canViewUsers && recentActivity.length > 0) {
|
||||
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
|
||||
|
||||
const { data: profiles } = await supabase
|
||||
const { data: profiles } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, avatar_url')
|
||||
.in('id', userIds);
|
||||
@@ -193,11 +189,12 @@ export function DashboardPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canViewUsers]);
|
||||
}, [canViewChats, canViewUsers]);
|
||||
|
||||
// Real-time subscriptions for dashboard stats (with debouncing)
|
||||
const { status: profilesStatus } = useRealtimeSubscription({
|
||||
table: 'profiles',
|
||||
enabled: canViewUsers,
|
||||
onInsert: fetchStats,
|
||||
onDelete: fetchStats,
|
||||
debounceMs: 2000,
|
||||
@@ -205,6 +202,7 @@ export function DashboardPage() {
|
||||
|
||||
const { status: messagesStatus } = useRealtimeSubscription({
|
||||
table: 'messages',
|
||||
enabled: canViewChats,
|
||||
onInsert: fetchStats,
|
||||
debounceMs: 2000,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -102,7 +102,7 @@ export function FeedbackPage() {
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('feedback')
|
||||
.select(`
|
||||
*,
|
||||
@@ -161,7 +161,7 @@ export function FeedbackPage() {
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from('feedback')
|
||||
.update({
|
||||
status: editStatus,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminBinaryRequest, adminData, adminRequest } from '@/lib/adminApi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -95,7 +95,7 @@ export function FlaggedMessagesPage() {
|
||||
const from = (aiPage - 1) * aiPageSize;
|
||||
const to = from + aiPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('messages')
|
||||
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' })
|
||||
.eq('moderation_status', 'flagged')
|
||||
@@ -120,7 +120,7 @@ export function FlaggedMessagesPage() {
|
||||
const from = (reportsPage - 1) * reportsPageSize;
|
||||
const to = from + reportsPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('message_reports')
|
||||
.select(`
|
||||
id,
|
||||
@@ -166,24 +166,11 @@ export function FlaggedMessagesPage() {
|
||||
}
|
||||
}, [reportsPage, reportsPageSize, reportsTotal]);
|
||||
|
||||
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
||||
const fetchDecryptedMedia = async (messageId: string) => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) return null;
|
||||
|
||||
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ messageId })
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch media');
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -199,8 +186,8 @@ export function FlaggedMessagesPage() {
|
||||
setImageDescription(null);
|
||||
|
||||
try {
|
||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id }
|
||||
const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||
method: 'POST', body: { messageId: message.id },
|
||||
});
|
||||
|
||||
let mediaUrl = null;
|
||||
@@ -209,7 +196,7 @@ export function FlaggedMessagesPage() {
|
||||
}
|
||||
|
||||
setDecryptedContent({
|
||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
||||
text: textData.content,
|
||||
mediaUrl
|
||||
});
|
||||
|
||||
@@ -233,8 +220,8 @@ export function FlaggedMessagesPage() {
|
||||
setImageDescription(null);
|
||||
|
||||
try {
|
||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: report.message.id }
|
||||
const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||
method: 'POST', body: { messageId: report.message.id },
|
||||
});
|
||||
|
||||
let mediaUrl = null;
|
||||
@@ -243,7 +230,7 @@ export function FlaggedMessagesPage() {
|
||||
}
|
||||
|
||||
setDecryptedContent({
|
||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
||||
text: textData.content,
|
||||
mediaUrl
|
||||
});
|
||||
|
||||
@@ -274,27 +261,9 @@ export function FlaggedMessagesPage() {
|
||||
|
||||
setDescribingInline(message.id);
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
toast.error('Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ messageId: message.id })
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId: message.id },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error('Failed to decrypt media');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -319,7 +288,7 @@ export function FlaggedMessagesPage() {
|
||||
const handleMarkSafe = async (messageId: string) => {
|
||||
setProcessing(messageId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from('messages')
|
||||
.update({ moderation_status: 'safe' })
|
||||
.eq('id', messageId);
|
||||
@@ -339,7 +308,7 @@ export function FlaggedMessagesPage() {
|
||||
const handleDismissReport = async (reportId: string) => {
|
||||
setProcessing(reportId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from('message_reports')
|
||||
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
|
||||
.eq('id', reportId);
|
||||
@@ -359,7 +328,7 @@ export function FlaggedMessagesPage() {
|
||||
const handleReviewReport = async (reportId: string) => {
|
||||
setProcessing(reportId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from('message_reports')
|
||||
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
|
||||
.eq('id', reportId);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -100,7 +100,7 @@ export function RedemptionCodesPage() {
|
||||
const to = from + pageSize - 1;
|
||||
const trimmedSearch = search.trim();
|
||||
|
||||
let query = supabase
|
||||
let query = adminData
|
||||
.from('redemption_codes')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -129,7 +129,7 @@ export function RedemptionCodesPage() {
|
||||
const from = (redemptionsPage - 1) * redemptionsPageSize;
|
||||
const to = from + redemptionsPageSize - 1;
|
||||
|
||||
const { data: redemptionData, error: redemptionError, count } = await supabase
|
||||
const { data: redemptionData, error: redemptionError, count } = await adminData
|
||||
.from('code_redemptions')
|
||||
.select('id, user_id, redeemed_at', { count: 'exact' })
|
||||
.eq('code_id', codeId)
|
||||
@@ -142,7 +142,7 @@ export function RedemptionCodesPage() {
|
||||
|
||||
if (redemptionData && redemptionData.length > 0) {
|
||||
const userIds = redemptionData.map(r => r.user_id);
|
||||
const { data: profileData } = await supabase
|
||||
const { data: profileData } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email')
|
||||
.in('id', userIds);
|
||||
@@ -200,7 +200,7 @@ export function RedemptionCodesPage() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { error } = await auditedSupabase.insert('redemption_codes', {
|
||||
const { error } = await auditedAdminData.insert('redemption_codes', {
|
||||
code: formData.code.toUpperCase().trim(),
|
||||
description: formData.description.trim() || null,
|
||||
max_uses: formData.max_uses,
|
||||
@@ -229,7 +229,7 @@ export function RedemptionCodesPage() {
|
||||
|
||||
const handleToggleActive = async (code: RedemptionCode) => {
|
||||
try {
|
||||
const { error } = await auditedSupabase.update('redemption_codes', code.id, {
|
||||
const { error } = await auditedAdminData.update('redemption_codes', code.id, {
|
||||
is_active: !code.is_active,
|
||||
});
|
||||
|
||||
@@ -248,7 +248,7 @@ export function RedemptionCodesPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('redemption_codes', code.id);
|
||||
const { error } = await auditedAdminData.delete('redemption_codes', code.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react';
|
||||
@@ -65,14 +65,14 @@ export function UsageInsightsPage() {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// Get total users count
|
||||
const { count: total } = await supabase
|
||||
const { count: total } = await adminData
|
||||
.from('profiles')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
setTotalUsers(total || 0);
|
||||
|
||||
// Get onboarded users count
|
||||
const { count: onboarded } = await supabase
|
||||
const { count: onboarded } = await adminData
|
||||
.from('profiles')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('onboarding_completed', true);
|
||||
@@ -80,7 +80,7 @@ export function UsageInsightsPage() {
|
||||
setOnboardedUsers(onboarded || 0);
|
||||
|
||||
// Get usage reason breakdown
|
||||
const { data: usageData } = await supabase
|
||||
const { data: usageData } = await adminData
|
||||
.from('profiles')
|
||||
.select('usage_reason')
|
||||
.not('usage_reason', 'is', null);
|
||||
@@ -103,7 +103,7 @@ export function UsageInsightsPage() {
|
||||
setUsageStats(usageStatsArray);
|
||||
|
||||
// Get gender breakdown
|
||||
const { data: genderData } = await supabase
|
||||
const { data: genderData } = await adminData
|
||||
.from('profiles')
|
||||
.select('gender')
|
||||
.not('gender', 'is', null);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData, adminRequest } from '@/lib/adminApi';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
@@ -149,7 +149,7 @@ export function UserActivityPage() {
|
||||
const from = (responsesPage - 1) * responsesPageSize;
|
||||
const to = from + responsesPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('responses')
|
||||
.select(`
|
||||
id, user_id, answer, created_at,
|
||||
@@ -166,7 +166,7 @@ export function UserActivityPage() {
|
||||
|
||||
setResponsesTotal(count || 0);
|
||||
|
||||
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
|
||||
// Transform the API's nested relationship objects to the view model.
|
||||
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
|
||||
id: item.id,
|
||||
user_id: item.user_id,
|
||||
@@ -195,7 +195,7 @@ export function UserActivityPage() {
|
||||
const from = (matchesPage - 1) * matchesPageSize;
|
||||
const to = from + matchesPageSize - 1;
|
||||
|
||||
const { data: matchesData, error: matchesError, count } = await supabase
|
||||
const { data: matchesData, error: matchesError, count } = await adminData
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, couple_id, match_type, is_new, created_at,
|
||||
@@ -211,7 +211,7 @@ export function UserActivityPage() {
|
||||
// Get unique couple IDs and fetch profiles
|
||||
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
|
||||
if (coupleIds.length > 0) {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
const { data: profiles, error: profilesError } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, couple_id')
|
||||
.in('couple_id', coupleIds);
|
||||
@@ -257,7 +257,7 @@ export function UserActivityPage() {
|
||||
const to = from + messagesPageSize - 1;
|
||||
|
||||
// Fetch messages with match data
|
||||
const { data: messagesData, error: messagesError, count } = await supabase
|
||||
const { data: messagesData, error: messagesError, count } = await adminData
|
||||
.from('messages')
|
||||
.select(`
|
||||
id, user_id, content, media_path, media_type, created_at,
|
||||
@@ -278,7 +278,7 @@ export function UserActivityPage() {
|
||||
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
|
||||
|
||||
if (userIds.length > 0) {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
const { data: profiles, error: profilesError } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email')
|
||||
.in('id', userIds);
|
||||
@@ -319,10 +319,7 @@ export function UserActivityPage() {
|
||||
setLoading(prev => ({ ...prev, signups: true }));
|
||||
try {
|
||||
// Use RPC to get profiles with auth info (includes email from auth.users)
|
||||
const { data: allData, error } = await supabase
|
||||
.rpc('get_profiles_with_auth_info');
|
||||
|
||||
if (error) throw error;
|
||||
const { users: allData } = await adminRequest<{ users: SignupActivity[] }>('/v1/admin/users');
|
||||
|
||||
// Sort by created_at descending and apply pagination
|
||||
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -82,7 +82,7 @@ export function AdminsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// First fetch admin users with permissions
|
||||
const { data: adminData, error: adminError } = await supabase
|
||||
const { data: adminRows, error: adminError } = await adminData
|
||||
.from('admin_users')
|
||||
.select('id, user_id, role, permissions, created_at')
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -91,9 +91,9 @@ export function AdminsPage() {
|
||||
|
||||
// Then fetch profiles for these users
|
||||
// We do this manually to avoid relying on foreign key relationships if they aren't perfect
|
||||
if (adminData && adminData.length > 0) {
|
||||
const userIds = adminData.map(a => a.user_id);
|
||||
const { data: profileData, error: profileError } = await supabase
|
||||
if (adminRows && adminRows.length > 0) {
|
||||
const userIds = adminRows.map(a => a.user_id);
|
||||
const { data: profileData, error: profileError } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, avatar_url')
|
||||
.in('id', userIds);
|
||||
@@ -101,7 +101,7 @@ export function AdminsPage() {
|
||||
if (profileError) throw profileError;
|
||||
|
||||
// Merge data
|
||||
const joinedAdmins = adminData.map(admin => ({
|
||||
const joinedAdmins = adminRows.map(admin => ({
|
||||
...admin,
|
||||
permissions: (admin.permissions as string[]) || [],
|
||||
profile: profileData?.find(p => p.id === admin.user_id)
|
||||
@@ -149,7 +149,7 @@ export function AdminsPage() {
|
||||
// Determine which users are already admins to exclude them
|
||||
const adminUserIds = new Set(admins.map(a => a.user_id));
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, avatar_url')
|
||||
.or(`email.ilike.%${userSearch}%,name.ilike.%${userSearch}%`)
|
||||
@@ -179,7 +179,7 @@ export function AdminsPage() {
|
||||
const role: AdminRole = isSuperAdminChecked ? 'super_admin' : 'pack_creator';
|
||||
const permissions = isSuperAdminChecked ? [] : selectedPermissions;
|
||||
|
||||
const { error } = await auditedSupabase.insert('admin_users', {
|
||||
const { error } = await auditedAdminData.insert('admin_users', {
|
||||
user_id: selectedUser.id,
|
||||
role,
|
||||
permissions
|
||||
@@ -219,7 +219,7 @@ export function AdminsPage() {
|
||||
const role: AdminRole = editIsSuperAdmin ? 'super_admin' : 'pack_creator';
|
||||
const permissions = editIsSuperAdmin ? [] : editPermissions;
|
||||
|
||||
const { error } = await auditedSupabase.update('admin_users', editingAdmin.id, {
|
||||
const { error } = await auditedAdminData.update('admin_users', editingAdmin.id, {
|
||||
role,
|
||||
permissions
|
||||
});
|
||||
@@ -261,7 +261,7 @@ export function AdminsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.deleteBy('admin_users', 'user_id', admin.user_id);
|
||||
const { error } = await auditedAdminData.deleteBy('admin_users', 'user_id', admin.user_id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -215,7 +215,7 @@ export function CategoriesPage() {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const { data: cats, error, count } = await supabase
|
||||
const { data: cats, error, count } = await adminData
|
||||
.from('categories')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
@@ -231,11 +231,11 @@ export function CategoriesPage() {
|
||||
|
||||
if (categoryIds.length > 0) {
|
||||
const [{ data: packs }, { data: darePacks }] = await Promise.all([
|
||||
supabase
|
||||
adminData
|
||||
.from('question_packs')
|
||||
.select('category_id')
|
||||
.in('category_id', categoryIds),
|
||||
supabase
|
||||
adminData
|
||||
.from('dare_packs')
|
||||
.select('category_id')
|
||||
.in('category_id', categoryIds),
|
||||
@@ -310,11 +310,11 @@ export function CategoriesPage() {
|
||||
};
|
||||
|
||||
if (form.editingItem) {
|
||||
const { error } = await auditedSupabase.update('categories', form.editingItem.id, data);
|
||||
const { error } = await auditedAdminData.update('categories', form.editingItem.id, data);
|
||||
if (error) throw error;
|
||||
toast.success('Category updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('categories', {
|
||||
const { error } = await auditedAdminData.insert('categories', {
|
||||
...data,
|
||||
sort_order: totalCount,
|
||||
});
|
||||
@@ -338,7 +338,7 @@ export function CategoriesPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('categories', category.id);
|
||||
const { error } = await auditedAdminData.delete('categories', category.id);
|
||||
if (error) throw error;
|
||||
toast.success('Category deleted');
|
||||
fetchCategories();
|
||||
@@ -355,7 +355,7 @@ export function CategoriesPage() {
|
||||
c.id === category.id ? { ...c, is_public: !c.is_public } : c
|
||||
));
|
||||
|
||||
const { error } = await auditedSupabase.update('categories', category.id, {
|
||||
const { error } = await auditedAdminData.update('categories', category.id, {
|
||||
is_public: !category.is_public
|
||||
});
|
||||
|
||||
@@ -399,7 +399,7 @@ export function CategoriesPage() {
|
||||
// Persist new order to database
|
||||
try {
|
||||
const updates = newCategories.map((category, index) =>
|
||||
auditedSupabase.update('categories', category.id, { sort_order: index })
|
||||
auditedAdminData.update('categories', category.id, { sort_order: index })
|
||||
);
|
||||
|
||||
const results = await Promise.all(updates);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -116,7 +116,7 @@ export function DarePacksPage() {
|
||||
|
||||
// Fetch category info
|
||||
if (categoryId) {
|
||||
const { data: categoryData } = await supabase
|
||||
const { data: categoryData } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name, icon')
|
||||
.eq('id', categoryId)
|
||||
@@ -127,7 +127,7 @@ export function DarePacksPage() {
|
||||
}
|
||||
|
||||
// Fetch dare packs
|
||||
let query = supabase
|
||||
let query = adminData
|
||||
.from('dare_packs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
@@ -148,7 +148,7 @@ export function DarePacksPage() {
|
||||
const dareCounts: Record<string, number> = {};
|
||||
|
||||
if (packIds.length > 0) {
|
||||
const { data: dares } = await supabase
|
||||
const { data: dares } = await adminData
|
||||
.from('dares')
|
||||
.select('pack_id')
|
||||
.in('pack_id', packIds);
|
||||
@@ -159,7 +159,7 @@ export function DarePacksPage() {
|
||||
}
|
||||
|
||||
// Fetch categories
|
||||
const { data: categoryData } = await supabase
|
||||
const { data: categoryData } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -208,7 +208,7 @@ export function DarePacksPage() {
|
||||
form.setSaving(true);
|
||||
try {
|
||||
if (form.editingItem) {
|
||||
const { error } = await auditedSupabase.update('dare_packs', form.editingItem.id, {
|
||||
const { error } = await auditedAdminData.update('dare_packs', form.editingItem.id, {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
@@ -220,7 +220,7 @@ export function DarePacksPage() {
|
||||
if (error) throw error;
|
||||
toast.success('Dare pack updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('dare_packs', {
|
||||
const { error } = await auditedAdminData.insert('dare_packs', {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
@@ -250,7 +250,7 @@ export function DarePacksPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('dare_packs', pack.id);
|
||||
const { error } = await auditedAdminData.delete('dare_packs', pack.id);
|
||||
if (error) throw error;
|
||||
toast.success('Dare pack deleted');
|
||||
fetchData();
|
||||
@@ -273,8 +273,8 @@ export function DarePacksPage() {
|
||||
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||
|
||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
auditedAdminData.update('dare_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedAdminData.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
if (currentUpdate.error || targetUpdate.error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -110,7 +110,7 @@ export function DaresPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch pack info
|
||||
const { data: packData } = await supabase
|
||||
const { data: packData } = await adminData
|
||||
.from('dare_packs')
|
||||
.select('id, name, description, icon')
|
||||
.eq('id', packId)
|
||||
@@ -122,7 +122,7 @@ export function DaresPage() {
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch dares
|
||||
const { data: dareData, error, count } = await supabase
|
||||
const { data: dareData, error, count } = await adminData
|
||||
.from('dares')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('pack_id', packId)
|
||||
@@ -191,11 +191,11 @@ export function DaresPage() {
|
||||
};
|
||||
|
||||
if (editingDare) {
|
||||
const { error } = await auditedSupabase.update('dares', editingDare.id, dareData);
|
||||
const { error } = await auditedAdminData.update('dares', editingDare.id, dareData);
|
||||
if (error) throw error;
|
||||
toast.success('Dare updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('dares', {
|
||||
const { error } = await auditedAdminData.insert('dares', {
|
||||
...dareData,
|
||||
pack_id: packId,
|
||||
});
|
||||
@@ -219,7 +219,7 @@ export function DaresPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('dares', dare.id);
|
||||
const { error } = await auditedAdminData.delete('dares', dare.id);
|
||||
if (error) throw error;
|
||||
toast.success('Dare deleted');
|
||||
fetchData();
|
||||
@@ -259,7 +259,7 @@ export function DaresPage() {
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(selectedIds).map(id => auditedSupabase.delete('dares', id))
|
||||
Array.from(selectedIds).map(id => auditedAdminData.delete('dares', id))
|
||||
);
|
||||
toast.success(`${count} dare${count !== 1 ? 's' : ''} deleted`);
|
||||
setSelectedIds(new Set());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -103,7 +103,7 @@ export function PacksPage() {
|
||||
try {
|
||||
// Fetch category info
|
||||
if (categoryId) {
|
||||
const { data: cat } = await supabase
|
||||
const { data: cat } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name, icon')
|
||||
.eq('id', categoryId)
|
||||
@@ -111,7 +111,7 @@ export function PacksPage() {
|
||||
setCategory(cat);
|
||||
}
|
||||
|
||||
const { data: categoriesData } = await supabase
|
||||
const { data: categoriesData } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -121,7 +121,7 @@ export function PacksPage() {
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch packs
|
||||
let query = supabase
|
||||
let query = adminData
|
||||
.from('question_packs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
@@ -141,7 +141,7 @@ export function PacksPage() {
|
||||
const questionCounts: Record<string, number> = {};
|
||||
|
||||
if (packIds.length > 0) {
|
||||
const { data: questions } = await supabase
|
||||
const { data: questions } = await adminData
|
||||
.from('questions')
|
||||
.select('pack_id')
|
||||
.in('pack_id', packIds);
|
||||
@@ -152,7 +152,7 @@ export function PacksPage() {
|
||||
}
|
||||
|
||||
// Fetch all topics
|
||||
const { data: topicsData } = await supabase
|
||||
const { data: topicsData } = await adminData
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -161,7 +161,7 @@ export function PacksPage() {
|
||||
// Fetch pack_topics relationships
|
||||
let packTopicsData: any[] = [];
|
||||
if (packIds.length > 0) {
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('pack_topics')
|
||||
.select('pack_id, topic_id, topics(id, name)')
|
||||
.in('pack_id', packIds);
|
||||
@@ -236,7 +236,7 @@ export function PacksPage() {
|
||||
? true
|
||||
: (form.formData.scheduled_release_at ? false : undefined);
|
||||
|
||||
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
|
||||
const { error } = await auditedAdminData.update('question_packs', form.editingItem.id, {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
@@ -254,7 +254,7 @@ export function PacksPage() {
|
||||
if (error) throw error;
|
||||
packId = form.editingItem.id;
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('question_packs')
|
||||
.insert({
|
||||
name: form.formData.name,
|
||||
@@ -279,10 +279,10 @@ export function PacksPage() {
|
||||
}
|
||||
|
||||
// Update topics
|
||||
await supabase.from('pack_topics').delete().eq('pack_id', packId);
|
||||
await adminData.from('pack_topics').delete().eq('pack_id', packId);
|
||||
|
||||
if (selectedTopicIds.size > 0) {
|
||||
const { error: topicsError } = await supabase
|
||||
const { error: topicsError } = await adminData
|
||||
.from('pack_topics')
|
||||
.insert(
|
||||
Array.from(selectedTopicIds).map(topicId => ({
|
||||
@@ -310,7 +310,7 @@ export function PacksPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('question_packs', pack.id);
|
||||
const { error } = await auditedAdminData.delete('question_packs', pack.id);
|
||||
if (error) throw error;
|
||||
toast.success('Pack deleted');
|
||||
fetchData();
|
||||
@@ -351,8 +351,8 @@ export function PacksPage() {
|
||||
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||
|
||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||
auditedSupabase.update('question_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('question_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
auditedAdminData.update('question_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedAdminData.update('question_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
if (currentUpdate.error || targetUpdate.error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -126,7 +126,7 @@ export function QuestionAnalyticsPage() {
|
||||
while (true) {
|
||||
const from = page * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('questions')
|
||||
.select('id, pack_id, intensity, partner_text, inverse_of, question_type')
|
||||
.range(from, to);
|
||||
@@ -150,7 +150,7 @@ export function QuestionAnalyticsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Build pack query with optional is_public filter
|
||||
let packQuery = supabase
|
||||
let packQuery = adminData
|
||||
.from('question_packs')
|
||||
.select('id, name, is_premium, is_explicit, is_public, category_id, category:categories(name)');
|
||||
|
||||
@@ -162,11 +162,11 @@ export function QuestionAnalyticsPage() {
|
||||
|
||||
const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([
|
||||
packQuery,
|
||||
supabase
|
||||
adminData
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name'),
|
||||
supabase
|
||||
adminData
|
||||
.from('pack_topics')
|
||||
.select('pack_id, topic_id, topics(id, name)'),
|
||||
fetchAllQuestions(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -150,12 +150,12 @@ export function QuestionsPage() {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const packPromise = supabase
|
||||
const packPromise = adminData
|
||||
.from('question_packs')
|
||||
.select('id, name, description, icon')
|
||||
.eq('id', packId)
|
||||
.single();
|
||||
let questionsQuery = supabase
|
||||
let questionsQuery = adminData
|
||||
.from('questions')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('pack_id', packId)
|
||||
@@ -167,7 +167,7 @@ export function QuestionsPage() {
|
||||
}
|
||||
|
||||
const questionsPromise = questionsQuery;
|
||||
const allPackPromise = supabase
|
||||
const allPackPromise = adminData
|
||||
.from('questions')
|
||||
.select('id, text, inverse_of')
|
||||
.eq('pack_id', packId)
|
||||
@@ -250,7 +250,7 @@ export function QuestionsPage() {
|
||||
// Fetch all questions in pack for inverse_of dropdown
|
||||
const fetchAllPackQuestions = useCallback(async () => {
|
||||
if (!packId) return;
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('questions')
|
||||
.select('id, text, inverse_of')
|
||||
.eq('pack_id', packId)
|
||||
@@ -317,7 +317,7 @@ export function QuestionsPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingQuestion) {
|
||||
const { error } = await auditedSupabase.update('questions', editingQuestion.id, {
|
||||
const { error } = await auditedAdminData.update('questions', editingQuestion.id, {
|
||||
text: formData.text,
|
||||
partner_text: formData.partner_text || null,
|
||||
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
||||
@@ -331,7 +331,7 @@ export function QuestionsPage() {
|
||||
if (error) throw error;
|
||||
toast.success('Question updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('questions', {
|
||||
const { error } = await auditedAdminData.insert('questions', {
|
||||
pack_id: packId,
|
||||
text: formData.text,
|
||||
partner_text: formData.partner_text || null,
|
||||
@@ -363,7 +363,7 @@ export function QuestionsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('questions', question.id);
|
||||
const { error } = await auditedAdminData.delete('questions', question.id);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Question deleted');
|
||||
@@ -396,7 +396,7 @@ export function QuestionsPage() {
|
||||
|
||||
// Insert standalone questions first (no inverse_of)
|
||||
if (standaloneQuestions.length > 0) {
|
||||
const { error: standaloneError } = await auditedSupabase.insert(
|
||||
const { error: standaloneError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
standaloneQuestions.map(q => ({
|
||||
pack_id: packId,
|
||||
@@ -415,7 +415,7 @@ export function QuestionsPage() {
|
||||
if (pair.length >= 2) {
|
||||
// First question is primary (inverse_of = NULL)
|
||||
const primary = pair[0];
|
||||
const { data: primaryData, error: primaryError } = await auditedSupabase.insert(
|
||||
const { data: primaryData, error: primaryError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -432,7 +432,7 @@ export function QuestionsPage() {
|
||||
const primaryId = primaryData?.[0]?.id;
|
||||
if (primaryId) {
|
||||
const inverse = pair[1];
|
||||
const { error: inverseError } = await auditedSupabase.insert(
|
||||
const { error: inverseError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -449,7 +449,7 @@ export function QuestionsPage() {
|
||||
// Handle any additional questions in the pair (shouldn't happen, but just in case)
|
||||
for (let i = 2; i < pair.length; i++) {
|
||||
const extra = pair[i];
|
||||
const { error: extraError } = await auditedSupabase.insert(
|
||||
const { error: extraError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -464,7 +464,7 @@ export function QuestionsPage() {
|
||||
}
|
||||
} else if (pair.length === 1) {
|
||||
// Single question with pair_id but no pair - treat as standalone
|
||||
const { error } = await auditedSupabase.insert(
|
||||
const { error } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -497,7 +497,7 @@ export function QuestionsPage() {
|
||||
|
||||
setReviewLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('questions')
|
||||
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, question_type, created_at')
|
||||
.eq('pack_id', packId)
|
||||
@@ -544,7 +544,7 @@ export function QuestionsPage() {
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(selectedIds).map(id => auditedSupabase.delete('questions', id))
|
||||
Array.from(selectedIds).map(id => auditedAdminData.delete('questions', id))
|
||||
);
|
||||
toast.success(`${count} question${count !== 1 ? 's' : ''} deleted`);
|
||||
setSelectedIds(new Set());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -64,9 +64,9 @@ export function TagsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [topicsResult, packTopicsResult, packsResult] = await Promise.all([
|
||||
supabase.from('topics').select('id, name').order('name'),
|
||||
supabase.from('pack_topics').select('pack_id, topic_id'),
|
||||
supabase
|
||||
adminData.from('topics').select('id, name').order('name'),
|
||||
adminData.from('pack_topics').select('pack_id, topic_id'),
|
||||
adminData
|
||||
.from('question_packs')
|
||||
.select('id, name, description, is_public, category:categories(name)')
|
||||
.order('name'),
|
||||
@@ -106,7 +106,7 @@ export function TagsPage() {
|
||||
const questionCounts: Record<string, number> = {};
|
||||
|
||||
if (untaggedPackIds.length > 0) {
|
||||
const { data: questions, error: questionsError } = await supabase
|
||||
const { data: questions, error: questionsError } = await adminData
|
||||
.from('questions')
|
||||
.select('pack_id')
|
||||
.in('pack_id', untaggedPackIds);
|
||||
@@ -167,7 +167,7 @@ export function TagsPage() {
|
||||
setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' });
|
||||
|
||||
try {
|
||||
const { data: topicsData, error: topicsError } = await supabase
|
||||
const { data: topicsData, error: topicsError } = await adminData
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -188,7 +188,7 @@ export function TagsPage() {
|
||||
});
|
||||
|
||||
try {
|
||||
const { data: questions, error: questionsError } = await supabase
|
||||
const { data: questions, error: questionsError } = await adminData
|
||||
.from('questions')
|
||||
.select('text')
|
||||
.eq('pack_id', pack.id);
|
||||
@@ -237,7 +237,7 @@ export function TagsPage() {
|
||||
});
|
||||
|
||||
if (newTopics.length > 0) {
|
||||
const { data: createdTopics, error: createError } = await supabase
|
||||
const { data: createdTopics, error: createError } = await adminData
|
||||
.from('topics')
|
||||
.insert(newTopics)
|
||||
.select('id, name');
|
||||
@@ -250,14 +250,14 @@ export function TagsPage() {
|
||||
});
|
||||
}
|
||||
|
||||
await supabase.from('pack_topics').delete().eq('pack_id', pack.id);
|
||||
await adminData.from('pack_topics').delete().eq('pack_id', pack.id);
|
||||
|
||||
if (topicIds.length === 0) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { error: linkError } = await supabase
|
||||
const { error: linkError } = await adminData
|
||||
.from('pack_topics')
|
||||
.insert(topicIds.map((topicId) => ({
|
||||
pack_id: pack.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase, supabaseConfig } from '@/config';
|
||||
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -87,29 +87,9 @@ function ChatImage({ messageId }: { messageId: string }) {
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: supabaseConfig.anonKey,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text().catch(() => '');
|
||||
let message = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed?.error === 'string') message = parsed.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message || `Failed to load media (${res.status})`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -178,29 +158,9 @@ function ChatVideo({ messageId }: { messageId: string }) {
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: supabaseConfig.anonKey,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text().catch(() => '');
|
||||
let message = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed?.error === 'string') message = parsed.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message || `Failed to load media (${res.status})`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -287,18 +247,13 @@ function DecryptedMessageText({ message }: { message: Message }) {
|
||||
const decrypt = async () => {
|
||||
setIsDecrypting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id },
|
||||
const data = await adminRequest<{ content?: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||
method: 'POST', body: { messageId: message.id },
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (error) {
|
||||
setText('[Unable to decrypt]');
|
||||
return;
|
||||
}
|
||||
|
||||
setText((data as { content?: string | null } | null)?.content ?? null);
|
||||
setText(data.content ?? null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setText('[Unable to decrypt]');
|
||||
@@ -342,7 +297,7 @@ export function MatchChatPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch user profile
|
||||
const { data: userData } = await supabase
|
||||
const { data: userData } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url, couple_id')
|
||||
.eq('id', userId)
|
||||
@@ -352,7 +307,7 @@ export function MatchChatPage() {
|
||||
|
||||
// Fetch partner
|
||||
if (userData?.couple_id) {
|
||||
const { data: partnerData } = await supabase
|
||||
const { data: partnerData } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url')
|
||||
.eq('couple_id', userData.couple_id)
|
||||
@@ -365,7 +320,7 @@ export function MatchChatPage() {
|
||||
}
|
||||
|
||||
// Fetch match with question
|
||||
const { data: matchData } = await supabase
|
||||
const { data: matchData } = await adminData
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, match_type,
|
||||
@@ -380,7 +335,7 @@ export function MatchChatPage() {
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch messages
|
||||
const { data: messageData, error: messagesError, count } = await supabase
|
||||
const { data: messageData, error: messagesError, count } = await adminData
|
||||
.from('messages')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('match_id', matchId)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase, supabaseConfig } from '@/config';
|
||||
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
@@ -190,16 +190,9 @@ function AdminDecryptedImage({ messageId, alt }: { messageId: string; alt: strin
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
@@ -246,16 +239,9 @@ function AdminDecryptedVideo({ messageId }: { messageId: string }) {
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
@@ -308,7 +294,7 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
||||
const fetchStrokes = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { data, error: err } = await supabase
|
||||
const { data, error: err } = await adminData
|
||||
.from('live_draw_sessions')
|
||||
.select('strokes')
|
||||
.eq('couple_id', coupleId)
|
||||
@@ -322,26 +308,14 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
||||
setStrokes((data?.strokes as StrokeSegment[]) || []);
|
||||
setLoading(false);
|
||||
};
|
||||
fetchStrokes();
|
||||
|
||||
// Subscribe to real-time updates
|
||||
const channel = supabase
|
||||
.channel(`admin-livedraw-${coupleId}`)
|
||||
.on('postgres_changes', {
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'live_draw_sessions',
|
||||
filter: `couple_id=eq.${coupleId}`,
|
||||
}, (payload: any) => {
|
||||
if (payload.new?.strokes) {
|
||||
setStrokes(payload.new.strokes as StrokeSegment[]);
|
||||
}
|
||||
})
|
||||
.subscribe();
|
||||
void fetchStrokes();
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void fetchStrokes();
|
||||
}, 5_000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
supabase.removeChannel(channel);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [coupleId]);
|
||||
|
||||
@@ -482,13 +456,12 @@ export function UserDetailPage() {
|
||||
}
|
||||
expiresAt = date.toISOString();
|
||||
}
|
||||
// Use RPC function which handles permissions internally
|
||||
const { error } = await supabase.rpc('admin_gift_premium', {
|
||||
target_user_id: userId,
|
||||
expires_at_param: expiresAt,
|
||||
const days = expiresAt === null ? null : Math.max(1, Math.ceil((new Date(expiresAt).getTime() - now.getTime()) / 86_400_000));
|
||||
await adminRequest(`/v1/admin/users/${encodeURIComponent(userId)}/gift-premium`, {
|
||||
method: 'POST', body: { days, reason: 'Granted from admin user detail' },
|
||||
});
|
||||
if (error) throw error;
|
||||
const { data: updatedProfile } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
|
||||
const { users } = await adminRequest<{ users: Profile[] }>(`/v1/admin/users?userId=${encodeURIComponent(userId)}`);
|
||||
const updatedProfile = users[0] ?? null;
|
||||
if (updatedProfile) setProfile(updatedProfile);
|
||||
setUpgradeOpen(false);
|
||||
alert('User upgraded to premium successfully!');
|
||||
@@ -504,11 +477,12 @@ export function UserDetailPage() {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: profileData } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
|
||||
const { users } = await adminRequest<{ users: Profile[] }>(`/v1/admin/users?userId=${encodeURIComponent(userId)}`);
|
||||
const profileData = users[0] ?? null;
|
||||
setProfile(profileData);
|
||||
|
||||
// Fetch feature interests for the user
|
||||
const { data: featureInterestsData } = await supabase
|
||||
const { data: featureInterestsData } = await adminData
|
||||
.from('feature_interests')
|
||||
.select('id, feature_name, created_at')
|
||||
.eq('user_id', userId)
|
||||
@@ -516,11 +490,11 @@ export function UserDetailPage() {
|
||||
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
||||
|
||||
if (profileData?.couple_id) {
|
||||
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||
const { data: partnerData } = await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||
setPartner(partnerData);
|
||||
|
||||
// Fetch streak data
|
||||
const { data: streakData } = await supabase
|
||||
const { data: streakData } = await adminData
|
||||
.from('couple_streaks')
|
||||
.select('*')
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
@@ -534,7 +508,7 @@ export function UserDetailPage() {
|
||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||
const responsesTo = responsesFrom + responsesPageSize - 1;
|
||||
|
||||
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
||||
const { data: responseData, count: responsesCount, error: responsesError } = await adminData
|
||||
.from('responses')
|
||||
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' })
|
||||
.eq('user_id', userId)
|
||||
@@ -550,7 +524,7 @@ export function UserDetailPage() {
|
||||
const matchesFrom = (matchesPage - 1) * matchesPageSize;
|
||||
const matchesTo = matchesFrom + matchesPageSize - 1;
|
||||
|
||||
const { data: matchData, count: matchesCount, error: matchesError } = await supabase
|
||||
const { data: matchData, count: matchesCount, error: matchesError } = await adminData
|
||||
.from('matches')
|
||||
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
@@ -563,7 +537,7 @@ export function UserDetailPage() {
|
||||
|
||||
const matchIds = (matchData || []).map(m => m.id);
|
||||
if (matchIds.length > 0) {
|
||||
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
|
||||
const { data: messages } = await adminData.from('messages').select('match_id').in('match_id', matchIds);
|
||||
const messageCounts: Record<string, number> = {};
|
||||
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
|
||||
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
|
||||
@@ -571,7 +545,7 @@ export function UserDetailPage() {
|
||||
setMatches([]);
|
||||
}
|
||||
|
||||
const { data: allMatchIdsData } = await supabase
|
||||
const { data: allMatchIdsData } = await adminData
|
||||
.from('matches')
|
||||
.select('id')
|
||||
.eq('couple_id', profileData.couple_id);
|
||||
@@ -581,7 +555,7 @@ export function UserDetailPage() {
|
||||
const mediaFrom = (mediaPage - 1) * mediaPageSize;
|
||||
const mediaTo = mediaFrom + mediaPageSize - 1;
|
||||
|
||||
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await supabase
|
||||
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await adminData
|
||||
.from('messages')
|
||||
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
|
||||
.in('match_id', allMatchIds)
|
||||
@@ -602,7 +576,7 @@ export function UserDetailPage() {
|
||||
setMediaTotal(0);
|
||||
}
|
||||
// Fetch enabled packs for the couple
|
||||
const { data: enabledPacksData, error: packsError } = await supabase
|
||||
const { data: enabledPacksData, error: packsError } = await adminData
|
||||
.from('couple_packs')
|
||||
.select(`
|
||||
pack_id,
|
||||
@@ -631,17 +605,17 @@ export function UserDetailPage() {
|
||||
|
||||
// Fetch outstanding questions (partner answered, user hasn't)
|
||||
// Use partner from state (set earlier in the first couple_id block)
|
||||
const partnerProfile = partner || (await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data;
|
||||
const partnerProfile = partner || (await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data;
|
||||
if (partnerProfile) {
|
||||
// Get question IDs user has already answered
|
||||
const { data: userResponseIds } = await supabase
|
||||
const { data: userResponseIds } = await adminData
|
||||
.from('responses')
|
||||
.select('question_id')
|
||||
.eq('user_id', userId);
|
||||
const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id));
|
||||
|
||||
// Get all partner responses in this couple
|
||||
const { data: partnerResponses } = await supabase
|
||||
const { data: partnerResponses } = await adminData
|
||||
.from('responses')
|
||||
.select(`
|
||||
question_id,
|
||||
@@ -658,7 +632,7 @@ export function UserDetailPage() {
|
||||
|
||||
if (unansweredQuestionIds.length > 0) {
|
||||
// Fetch question details (no pack filter — admin sees all)
|
||||
const { data: questionDetails } = await supabase
|
||||
const { data: questionDetails } = await adminData
|
||||
.from('questions')
|
||||
.select('id, text, intensity, pack:question_packs(id, name)')
|
||||
.in('id', unansweredQuestionIds);
|
||||
@@ -690,7 +664,7 @@ export function UserDetailPage() {
|
||||
}
|
||||
|
||||
// Calculate gap status
|
||||
const { data: appConfig } = await supabase
|
||||
const { data: appConfig } = await adminData
|
||||
.from('app_config')
|
||||
.select('answer_gap_threshold')
|
||||
.limit(1)
|
||||
@@ -702,7 +676,7 @@ export function UserDetailPage() {
|
||||
if (threshold > 0) {
|
||||
// Count questions user answered that partner hasn't
|
||||
const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id));
|
||||
const { data: userResponses } = await supabase
|
||||
const { data: userResponses } = await adminData
|
||||
.from('responses')
|
||||
.select('question_id')
|
||||
.eq('user_id', userId)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { adminRequest } from '@/lib/adminApi';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -55,24 +55,7 @@ export function UsersPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch profiles with auth info (includes last_sign_in_at)
|
||||
const { data: profilesDataRaw, error } = await supabase
|
||||
.rpc('get_profiles_with_auth_info');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const profilesData = (profilesDataRaw || []) as Profile[];
|
||||
|
||||
// Fetch storage usage per user from storage.objects
|
||||
const { data: storageData } = await supabase
|
||||
.rpc('get_user_storage_usage');
|
||||
|
||||
// Create a map of user_id -> storage_bytes
|
||||
const storageMap: Record<string, number> = {};
|
||||
if (storageData) {
|
||||
storageData.forEach((row: { owner: string; total_bytes: number }) => {
|
||||
storageMap[row.owner] = row.total_bytes;
|
||||
});
|
||||
}
|
||||
const { users: profilesData } = await adminRequest<{ users: Profile[] }>('/v1/admin/users');
|
||||
|
||||
// Build a map of couple_id -> users with premium in that couple
|
||||
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
|
||||
@@ -100,7 +83,7 @@ export function UsersPage() {
|
||||
}
|
||||
return {
|
||||
...profile,
|
||||
storage_bytes: storageMap[profile.id] || 0,
|
||||
storage_bytes: Number(profile.storage_bytes || 0),
|
||||
partner_is_premium: partnerIsPremium,
|
||||
partner_count: partnerCount,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -32,7 +32,7 @@ export interface CategoryFormData {
|
||||
*/
|
||||
export async function fetchCategories(): Promise<Category[]> {
|
||||
// Fetch categories
|
||||
const { data: categories, error: catError } = await supabase
|
||||
const { data: categories, error: catError } = await adminData
|
||||
.from('categories')
|
||||
.select('*')
|
||||
.order('sort_order', { ascending: true });
|
||||
@@ -40,7 +40,7 @@ export async function fetchCategories(): Promise<Category[]> {
|
||||
if (catError) throw catError;
|
||||
|
||||
// Fetch pack counts per category
|
||||
const { data: packs } = await supabase
|
||||
const { data: packs } = await adminData
|
||||
.from('question_packs')
|
||||
.select('category_id');
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function fetchCategories(): Promise<Category[]> {
|
||||
* Fetch a single category by ID
|
||||
*/
|
||||
export async function fetchCategoryById(id: string): Promise<Category | null> {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('categories')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
@@ -82,7 +82,7 @@ export async function createCategory(
|
||||
data: CategoryFormData,
|
||||
sortOrder: number
|
||||
): Promise<Category> {
|
||||
const { data: created, error } = await auditedSupabase.insert('categories', {
|
||||
const { data: created, error } = await auditedAdminData.insert('categories', {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
icon: data.icon || null,
|
||||
@@ -102,7 +102,7 @@ export async function updateCategory(
|
||||
id: string,
|
||||
data: Partial<CategoryFormData>
|
||||
): Promise<void> {
|
||||
const { error } = await auditedSupabase.update('categories', id, {
|
||||
const { error } = await auditedAdminData.update('categories', id, {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
icon: data.icon || null,
|
||||
@@ -116,7 +116,7 @@ export async function updateCategory(
|
||||
* Delete a category
|
||||
*/
|
||||
export async function deleteCategory(id: string): Promise<void> {
|
||||
const { error } = await auditedSupabase.delete('categories', id);
|
||||
const { error } = await auditedAdminData.delete('categories', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function swapCategorySortOrder(
|
||||
category2Order: number
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
auditedSupabase.update('categories', category1Id, { sort_order: category2Order }),
|
||||
auditedSupabase.update('categories', category2Id, { sort_order: category1Order }),
|
||||
auditedAdminData.update('categories', category1Id, { sort_order: category2Order }),
|
||||
auditedAdminData.update('categories', category2Id, { sort_order: category1Order }),
|
||||
]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -42,7 +42,7 @@ export interface Topic {
|
||||
* Fetch all packs for a category with question counts
|
||||
*/
|
||||
export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]> {
|
||||
const { data: packs, error: packError } = await supabase
|
||||
const { data: packs, error: packError } = await adminData
|
||||
.from('question_packs')
|
||||
.select('*, category:categories(name)')
|
||||
.eq('category_id', categoryId)
|
||||
@@ -51,7 +51,7 @@ export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]>
|
||||
if (packError) throw packError;
|
||||
|
||||
// Get question counts per pack
|
||||
const { data: questions } = await supabase
|
||||
const { data: questions } = await adminData
|
||||
.from('questions')
|
||||
.select('pack_id');
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function fetchPacksForCategory(categoryId: string): Promise<Pack[]>
|
||||
* Fetch a single pack by ID
|
||||
*/
|
||||
export async function fetchPackById(id: string): Promise<Pack | null> {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('question_packs')
|
||||
.select('*, category:categories(name)')
|
||||
.eq('id', id)
|
||||
@@ -86,7 +86,7 @@ export async function fetchPackById(id: string): Promise<Pack | null> {
|
||||
* Fetch all topics
|
||||
*/
|
||||
export async function fetchAllTopics(): Promise<Topic[]> {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('topics')
|
||||
.select('*')
|
||||
.order('name');
|
||||
@@ -99,7 +99,7 @@ export async function fetchAllTopics(): Promise<Topic[]> {
|
||||
* Fetch topic IDs for a specific pack
|
||||
*/
|
||||
export async function fetchPackTopicIds(packId: string): Promise<Set<string>> {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('pack_topics')
|
||||
.select('topic_id')
|
||||
.eq('pack_id', packId);
|
||||
@@ -120,7 +120,7 @@ export async function createPack(
|
||||
data: PackFormData,
|
||||
sortOrder: number
|
||||
): Promise<Pack> {
|
||||
const { data: created, error } = await auditedSupabase.insert('question_packs', {
|
||||
const { data: created, error } = await auditedAdminData.insert('question_packs', {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
icon: data.icon || null,
|
||||
@@ -143,7 +143,7 @@ export async function updatePack(
|
||||
id: string,
|
||||
data: Partial<PackFormData>
|
||||
): Promise<void> {
|
||||
const { error } = await auditedSupabase.update('question_packs', id, {
|
||||
const { error } = await auditedAdminData.update('question_packs', id, {
|
||||
name: data.name,
|
||||
description: data.description || null,
|
||||
icon: data.icon || null,
|
||||
@@ -159,7 +159,7 @@ export async function updatePack(
|
||||
* Delete a pack
|
||||
*/
|
||||
export async function deletePack(id: string): Promise<void> {
|
||||
const { error } = await auditedSupabase.delete('question_packs', id);
|
||||
const { error } = await auditedAdminData.delete('question_packs', id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
@@ -173,8 +173,8 @@ export async function swapPackSortOrder(
|
||||
pack2Order: number
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
auditedSupabase.update('question_packs', pack1Id, { sort_order: pack2Order }),
|
||||
auditedSupabase.update('question_packs', pack2Id, { sort_order: pack1Order }),
|
||||
auditedAdminData.update('question_packs', pack1Id, { sort_order: pack2Order }),
|
||||
auditedAdminData.update('question_packs', pack2Id, { sort_order: pack1Order }),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -190,11 +190,11 @@ export async function updatePackTopics(
|
||||
topicIds: string[]
|
||||
): Promise<void> {
|
||||
// Delete existing topics
|
||||
await supabase.from('pack_topics').delete().eq('pack_id', packId);
|
||||
await adminData.from('pack_topics').delete().eq('pack_id', packId);
|
||||
|
||||
// Insert new topics
|
||||
if (topicIds.length > 0) {
|
||||
const { error } = await supabase.from('pack_topics').insert(
|
||||
const { error } = await adminData.from('pack_topics').insert(
|
||||
topicIds.map(topicId => ({ pack_id: packId, topic_id: topicId }))
|
||||
);
|
||||
if (error) throw error;
|
||||
@@ -205,7 +205,7 @@ export async function updatePackTopics(
|
||||
* Create a new topic
|
||||
*/
|
||||
export async function createTopic(name: string): Promise<Topic> {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('topics')
|
||||
.insert({ name })
|
||||
.select()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -29,7 +29,7 @@ export interface QuestionFormData {
|
||||
* Fetch all questions for a pack (excludes soft-deleted by default)
|
||||
*/
|
||||
export async function fetchQuestionsForPack(packId: string, includeDeleted = false): Promise<Question[]> {
|
||||
let query = supabase
|
||||
let query = adminData
|
||||
.from('questions')
|
||||
.select('*')
|
||||
.eq('pack_id', packId)
|
||||
@@ -49,7 +49,7 @@ export async function fetchQuestionsForPack(packId: string, includeDeleted = fal
|
||||
* Fetch a single question by ID
|
||||
*/
|
||||
export async function fetchQuestionById(id: string): Promise<Question | null> {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('questions')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
@@ -63,7 +63,7 @@ export async function fetchQuestionById(id: string): Promise<Question | null> {
|
||||
* Fetch question count for a pack (excludes soft-deleted)
|
||||
*/
|
||||
export async function fetchQuestionCount(packId: string): Promise<number> {
|
||||
const { count, error } = await supabase
|
||||
const { count, error } = await adminData
|
||||
.from('questions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('pack_id', packId)
|
||||
@@ -84,7 +84,7 @@ export async function createQuestion(
|
||||
packId: string,
|
||||
data: QuestionFormData
|
||||
): Promise<Question> {
|
||||
const { data: created, error } = await auditedSupabase.insert('questions', {
|
||||
const { data: created, error } = await auditedAdminData.insert('questions', {
|
||||
text: data.text,
|
||||
partner_text: data.partner_text || null,
|
||||
intensity: data.intensity,
|
||||
@@ -103,7 +103,7 @@ export async function updateQuestion(
|
||||
id: string,
|
||||
data: Partial<QuestionFormData>
|
||||
): Promise<void> {
|
||||
const { error } = await auditedSupabase.update('questions', id, {
|
||||
const { error } = await auditedAdminData.update('questions', id, {
|
||||
text: data.text,
|
||||
partner_text: data.partner_text || null,
|
||||
intensity: data.intensity,
|
||||
@@ -116,7 +116,7 @@ export async function updateQuestion(
|
||||
* Soft delete a question (sets deleted_at timestamp)
|
||||
*/
|
||||
export async function deleteQuestion(id: string): Promise<void> {
|
||||
const { error } = await auditedSupabase.update('questions', id, {
|
||||
const { error } = await auditedAdminData.update('questions', id, {
|
||||
deleted_at: new Date().toISOString(),
|
||||
});
|
||||
if (error) throw error;
|
||||
@@ -127,7 +127,7 @@ export async function deleteQuestion(id: string): Promise<void> {
|
||||
*/
|
||||
export async function deleteQuestions(ids: string[]): Promise<void> {
|
||||
await Promise.all(
|
||||
ids.map(id => auditedSupabase.update('questions', id, {
|
||||
ids.map(id => auditedAdminData.update('questions', id, {
|
||||
deleted_at: new Date().toISOString(),
|
||||
}))
|
||||
);
|
||||
@@ -137,7 +137,7 @@ export async function deleteQuestions(ids: string[]): Promise<void> {
|
||||
* Restore a soft-deleted question
|
||||
*/
|
||||
export async function restoreQuestion(id: string): Promise<void> {
|
||||
const { error } = await auditedSupabase.update('questions', id, {
|
||||
const { error } = await auditedAdminData.update('questions', id, {
|
||||
deleted_at: null,
|
||||
});
|
||||
if (error) throw error;
|
||||
@@ -169,7 +169,7 @@ export async function bulkCreateQuestions(
|
||||
|
||||
// Insert standalone questions first
|
||||
for (const q of standaloneQuestions) {
|
||||
const { data, error } = await auditedSupabase.insert('questions', {
|
||||
const { data, error } = await auditedAdminData.insert('questions', {
|
||||
pack_id: packId,
|
||||
text: q.text,
|
||||
partner_text: q.partner_text || null,
|
||||
@@ -186,7 +186,7 @@ export async function bulkCreateQuestions(
|
||||
if (pair.length >= 2) {
|
||||
// First question is primary
|
||||
const primary = pair[0];
|
||||
const { data: primaryData, error: primaryError } = await auditedSupabase.insert('questions', {
|
||||
const { data: primaryData, error: primaryError } = await auditedAdminData.insert('questions', {
|
||||
pack_id: packId,
|
||||
text: primary.text,
|
||||
partner_text: primary.partner_text || null,
|
||||
@@ -201,7 +201,7 @@ export async function bulkCreateQuestions(
|
||||
|
||||
// Second question is inverse
|
||||
const inverse = pair[1];
|
||||
const { data: inverseData, error: inverseError } = await auditedSupabase.insert('questions', {
|
||||
const { data: inverseData, error: inverseError } = await auditedAdminData.insert('questions', {
|
||||
pack_id: packId,
|
||||
text: inverse.text,
|
||||
partner_text: inverse.partner_text || null,
|
||||
@@ -216,7 +216,7 @@ export async function bulkCreateQuestions(
|
||||
// Handle any additional questions in the pair
|
||||
for (let i = 2; i < pair.length; i++) {
|
||||
const extra = pair[i];
|
||||
const { data, error } = await auditedSupabase.insert('questions', {
|
||||
const { data, error } = await auditedAdminData.insert('questions', {
|
||||
pack_id: packId,
|
||||
text: extra.text,
|
||||
partner_text: extra.partner_text || null,
|
||||
@@ -228,7 +228,7 @@ export async function bulkCreateQuestions(
|
||||
}
|
||||
} else if (pair.length === 1) {
|
||||
// Single question with pair_id but no pair
|
||||
const { data, error } = await auditedSupabase.insert('questions', {
|
||||
const { data, error } = await auditedAdminData.insert('questions', {
|
||||
pack_id: packId,
|
||||
text: pair[0].text,
|
||||
partner_text: pair[0].partner_text || null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
@@ -25,10 +25,10 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
|
||||
{ count: questions },
|
||||
{ count: users },
|
||||
] = await Promise.all([
|
||||
supabase.from('categories').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('questions').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('profiles').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('categories').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('questions').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('profiles').select('*', { count: 'exact', head: true }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -43,7 +43,7 @@ export async function fetchDashboardStats(): Promise<DashboardStats> {
|
||||
* Fetch count for a specific table
|
||||
*/
|
||||
export async function fetchTableCount(tableName: string): Promise<number> {
|
||||
const { count, error } = await supabase
|
||||
const { count, error } = await adminData
|
||||
.from(tableName)
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
|
||||
Vendored
+1
@@ -3,6 +3,7 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SUPABASE_URL: string;
|
||||
readonly VITE_SUPABASE_ANON_KEY: string;
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_OPENAI_API_KEY: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
coverage
|
||||
test
|
||||
@@ -0,0 +1,31 @@
|
||||
PORT=3003
|
||||
DATABASE_URL=postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
|
||||
SUPABASE_AUTH_URL=https://your-project.supabase.co
|
||||
SUPABASE_AUTH_AUDIENCE=authenticated
|
||||
|
||||
# Server-only hosted Auth Admin credential. Used for account deletion and for
|
||||
# enriching admin user lists; it is never exposed to the browser or used as a
|
||||
# Supabase database client.
|
||||
# SUPABASE_AUTH_SERVICE_ROLE_KEY=
|
||||
|
||||
# Optional overrides. The issuer defaults to <SUPABASE_AUTH_URL>/auth/v1 and
|
||||
# the JWKS endpoint defaults to <SUPABASE_AUTH_URL>/auth/v1/.well-known/jwks.json.
|
||||
# SUPABASE_AUTH_ISSUER=
|
||||
# SUPABASE_AUTH_JWKS_URL=
|
||||
|
||||
# Test-only. Startup fails if this is set outside NODE_ENV=test.
|
||||
# AUTH_TEST_JWKS={"keys":[]}
|
||||
|
||||
# Optional stable credential for non-browser admin clients such as MCP. MCP
|
||||
# receives the token as SAUCI_ADMIN_API_TOKEN; it does not receive the user ID.
|
||||
# Both values are required together. The user ID must belong to an active,
|
||||
# least-privilege admin_users row; generate a random token of at least 32 chars.
|
||||
# ADMIN_API_SERVICE_TOKEN=
|
||||
# ADMIN_API_SERVICE_USER_ID=00000000-0000-4000-8000-000000000002
|
||||
|
||||
# Optional admin review decryption. Keep the private JWK server-side only.
|
||||
# ADMIN_PRIVATE_KEY_JWK=
|
||||
|
||||
# Optional local fixture identity used by `npm run db:seed -w @sauci/api`.
|
||||
# SEED_USER_ID=00000000-0000-4000-8000-000000000001
|
||||
# SEED_USER_EMAIL=local@sauci.test
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /workspace
|
||||
COPY package.json package-lock.json ./
|
||||
COPY apps/api/package.json ./apps/api/package.json
|
||||
COPY packages/shared/package.json ./packages/shared/package.json
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --workspace @sauci/api --include-workspace-root=false --ignore-scripts
|
||||
COPY apps/api ./apps/api
|
||||
COPY packages/shared ./packages/shared
|
||||
RUN npm run build --workspace @sauci/api
|
||||
RUN npm prune --omit=dev --workspace @sauci/api --include-workspace-root=false --ignore-scripts
|
||||
|
||||
FROM node:20-bookworm-slim AS runtime
|
||||
ENV NODE_ENV=production PORT=3003
|
||||
WORKDIR /app
|
||||
RUN mkdir -p /data/media && chown node:node /data/media
|
||||
COPY --from=build /workspace/node_modules ./node_modules
|
||||
# npm may place 0.x dependency upgrades in the workspace-local tree. Overlay
|
||||
# that pruned tree so runtime resolution matches the workspace lockfile.
|
||||
COPY --from=build /workspace/apps/api/node_modules ./node_modules
|
||||
COPY --from=build --chown=node:node /workspace/apps/api/package.json ./package.json
|
||||
COPY --from=build --chown=node:node /workspace/apps/api/dist ./dist
|
||||
COPY --from=build --chown=node:node /workspace/apps/api/drizzle ./drizzle
|
||||
USER node
|
||||
EXPOSE 3003
|
||||
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:3003/health/live').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error('DATABASE_URL is required for database migrations');
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
dialect: 'postgresql',
|
||||
schema: './src/db/all-schema.ts',
|
||||
out: './drizzle',
|
||||
dbCredentials: { url: process.env.DATABASE_URL },
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE "profiles" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"name" text,
|
||||
"email" text,
|
||||
"avatar_url" text,
|
||||
"push_token" text,
|
||||
"is_premium" boolean DEFAULT false NOT NULL,
|
||||
"couple_id" uuid,
|
||||
"gender" text,
|
||||
"show_explicit_content" boolean DEFAULT true NOT NULL,
|
||||
"max_intensity" integer DEFAULT 5 NOT NULL,
|
||||
"public_key_jwk" jsonb,
|
||||
"hide_nsfw" boolean DEFAULT false NOT NULL,
|
||||
"onboarding_completed" boolean DEFAULT false NOT NULL,
|
||||
"onboarding_version" integer DEFAULT 0 NOT NULL,
|
||||
"auth_synced_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "profiles_gender_check" CHECK ("gender" IS NULL OR "gender" IN ('male', 'female', 'non-binary', 'prefer-not-to-say')),
|
||||
CONSTRAINT "profiles_max_intensity_check" CHECK ("max_intensity" BETWEEN 1 AND 5)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "profiles_couple_id_idx" ON "profiles" USING btree ("couple_id");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "feature_interests" (
|
||||
"user_id" uuid NOT NULL,
|
||||
"feature" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "feature_interests_user_id_feature_pk" PRIMARY KEY("user_id", "feature")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "feature_interests" ADD CONSTRAINT "feature_interests_user_id_profiles_id_fk"
|
||||
FOREIGN KEY ("user_id") REFERENCES "profiles"("id") ON DELETE cascade;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "feature_interests_feature_idx" ON "feature_interests" USING btree ("feature");
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE IF NOT EXISTS "couples" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"invite_code" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "couples_invite_code_unique" UNIQUE("invite_code"),
|
||||
CONSTRAINT "couples_invite_code_format" CHECK ("invite_code" ~ '^[A-Z0-9]{8}$')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "profiles" ADD CONSTRAINT "profiles_couple_id_couples_id_fk"
|
||||
FOREIGN KEY ("couple_id") REFERENCES "couples"("id") ON DELETE SET NULL;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1,60 @@
|
||||
CREATE TABLE IF NOT EXISTS "categories" (
|
||||
"id" uuid PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"icon" text,
|
||||
"color" text,
|
||||
"sort_order" integer NOT NULL DEFAULT 0,
|
||||
"is_public" boolean NOT NULL DEFAULT true,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "question_packs" (
|
||||
"id" uuid PRIMARY KEY,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"icon" text,
|
||||
"is_premium" boolean NOT NULL DEFAULT false,
|
||||
"is_public" boolean NOT NULL DEFAULT true,
|
||||
"is_explicit" boolean NOT NULL DEFAULT false,
|
||||
"min_intensity" integer,
|
||||
"max_intensity" integer,
|
||||
"avg_intensity" integer,
|
||||
"sort_order" integer NOT NULL DEFAULT 0,
|
||||
"category_id" uuid REFERENCES "categories"("id") ON DELETE SET NULL,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "question_packs_category_id_idx" ON "question_packs" ("category_id");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "questions" (
|
||||
"id" uuid PRIMARY KEY,
|
||||
"pack_id" uuid NOT NULL REFERENCES "question_packs"("id") ON DELETE CASCADE,
|
||||
"text" text NOT NULL,
|
||||
"partner_text" text,
|
||||
"intensity" integer NOT NULL DEFAULT 1 CHECK ("intensity" BETWEEN 1 AND 5),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "questions_pack_id_idx" ON "questions" ("pack_id");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "responses" (
|
||||
"id" uuid PRIMARY KEY,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"question_id" uuid NOT NULL REFERENCES "questions"("id") ON DELETE CASCADE,
|
||||
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||
"answer" text NOT NULL CHECK ("answer" IN ('yes', 'no', 'maybe')),
|
||||
"response_data" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE ("user_id", "question_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "responses_user_id_idx" ON "responses" ("user_id");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "couple_packs" (
|
||||
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||
"pack_id" uuid NOT NULL REFERENCES "question_packs"("id") ON DELETE CASCADE,
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY ("couple_id", "pack_id")
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
ALTER TABLE "questions"
|
||||
ADD COLUMN IF NOT EXISTS "allowed_couple_genders" text[],
|
||||
ADD COLUMN IF NOT EXISTS "target_user_genders" text[],
|
||||
ADD COLUMN IF NOT EXISTS "question_type" text NOT NULL DEFAULT 'swipe',
|
||||
ADD COLUMN IF NOT EXISTS "config" jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS "deleted_at" timestamptz;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "questions" ADD CONSTRAINT "questions_type_check"
|
||||
CHECK ("question_type" IN ('swipe', 'text_answer', 'audio', 'photo', 'who_likely'));
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "app_config" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"answer_gap_threshold" integer NOT NULL DEFAULT 10 CHECK ("answer_gap_threshold" >= 0),
|
||||
"daily_response_limit" integer NOT NULL DEFAULT 0 CHECK ("daily_response_limit" >= 0),
|
||||
"couple_intensity_gate_enabled" boolean NOT NULL DEFAULT false,
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "app_config_singleton_idx" ON "app_config" ((true));
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "app_config" ("id") VALUES (gen_random_uuid()) ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "matches" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"couple_id" uuid NOT NULL REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||
"question_id" uuid NOT NULL REFERENCES "questions"("id") ON DELETE CASCADE,
|
||||
"match_type" text NOT NULL CHECK ("match_type" IN ('yes_yes', 'yes_maybe', 'maybe_maybe', 'both_answered')),
|
||||
"is_new" boolean NOT NULL DEFAULT true,
|
||||
"response_summary" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE ("couple_id", "question_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "matches_couple_created_idx" ON "matches" ("couple_id", "created_at" DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"content" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"read_at" timestamptz
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "match_archives" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"archived_at" timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE ("match_id", "user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "match_archives_user_idx" ON "match_archives" ("user_id", "match_id");
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "couple_streaks" (
|
||||
"couple_id" uuid PRIMARY KEY REFERENCES "couples"("id") ON DELETE CASCADE,
|
||||
"current_streak" integer NOT NULL DEFAULT 0,
|
||||
"longest_streak" integer NOT NULL DEFAULT 0,
|
||||
"last_active_date" date,
|
||||
"last_completed_date" date,
|
||||
"user1_answered_today" boolean NOT NULL DEFAULT false,
|
||||
"user2_answered_today" boolean NOT NULL DEFAULT false,
|
||||
"streak_celebrated_at" integer NOT NULL DEFAULT 0,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "responses_couple_question_idx" ON "responses" ("couple_id", "question_id");
|
||||
@@ -0,0 +1,85 @@
|
||||
CREATE TABLE IF NOT EXISTS "messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"content" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"read_at" timestamptz,
|
||||
"delivered_at" timestamptz,
|
||||
"deleted_at" timestamptz,
|
||||
"media_path" text,
|
||||
"media_type" text CHECK ("media_type" IS NULL OR "media_type" IN ('image', 'video')),
|
||||
"media_expires_at" timestamptz,
|
||||
"media_expired" boolean NOT NULL DEFAULT false,
|
||||
"media_viewed_at" timestamptz,
|
||||
"version" integer NOT NULL DEFAULT 1,
|
||||
"encrypted_content" text,
|
||||
"encryption_iv" text,
|
||||
"keys_metadata" jsonb,
|
||||
"moderation_status" text,
|
||||
"flag_reason" text,
|
||||
"category" text,
|
||||
CONSTRAINT "messages_content_or_media" CHECK (
|
||||
"content" IS NOT NULL OR "media_path" IS NOT NULL OR "encrypted_content" IS NOT NULL
|
||||
)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "messages"
|
||||
ADD COLUMN IF NOT EXISTS "delivered_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "deleted_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "media_path" text,
|
||||
ADD COLUMN IF NOT EXISTS "media_type" text,
|
||||
ADD COLUMN IF NOT EXISTS "media_expires_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "media_expired" boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "media_viewed_at" timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS "version" integer NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS "encrypted_content" text,
|
||||
ADD COLUMN IF NOT EXISTS "encryption_iv" text,
|
||||
ADD COLUMN IF NOT EXISTS "keys_metadata" jsonb,
|
||||
ADD COLUMN IF NOT EXISTS "moderation_status" text,
|
||||
ADD COLUMN IF NOT EXISTS "flag_reason" text,
|
||||
ADD COLUMN IF NOT EXISTS "category" text;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_media_type_check"
|
||||
CHECK ("media_type" IS NULL OR "media_type" IN ('image', 'video'));
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_content_or_media"
|
||||
CHECK ("content" IS NOT NULL OR "media_path" IS NOT NULL OR "encrypted_content" IS NOT NULL);
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "messages_match_created_idx" ON "messages" ("match_id", "created_at" DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "messages_unread_idx" ON "messages" ("match_id", "user_id") WHERE "read_at" IS NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "message_deletions" (
|
||||
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"deleted_at" timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY ("message_id", "user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "message_reports" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"message_id" uuid NOT NULL REFERENCES "messages"("id") ON DELETE CASCADE,
|
||||
"reporter_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"reason" text NOT NULL CHECK ("reason" IN ('harassment', 'spam', 'inappropriate_content', 'other')),
|
||||
"status" text NOT NULL DEFAULT 'pending' CHECK ("status" IN ('pending', 'reviewed', 'dismissed')),
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE ("message_id", "reporter_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "message_reports_status_idx" ON "message_reports" ("status", "created_at" DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "chat_typing_states" (
|
||||
"match_id" uuid NOT NULL REFERENCES "matches"("id") ON DELETE CASCADE,
|
||||
"user_id" uuid NOT NULL REFERENCES "profiles"("id") ON DELETE CASCADE,
|
||||
"expires_at" timestamptz NOT NULL,
|
||||
PRIMARY KEY ("match_id", "user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "chat_typing_expires_idx" ON "chat_typing_states" ("expires_at");
|
||||
@@ -0,0 +1,35 @@
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS usage_reason text,
|
||||
ADD COLUMN IF NOT EXISTS last_active_at timestamptz;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id uuid PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
matches_enabled boolean NOT NULL DEFAULT true,
|
||||
messages_enabled boolean NOT NULL DEFAULT true,
|
||||
partner_activity_enabled boolean NOT NULL DEFAULT true,
|
||||
nudges_enabled boolean NOT NULL DEFAULT true,
|
||||
pack_changes_enabled boolean NOT NULL DEFAULT true,
|
||||
new_packs_enabled boolean NOT NULL DEFAULT true,
|
||||
streak_milestones_enabled boolean NOT NULL DEFAULT true,
|
||||
weekly_summary_enabled boolean NOT NULL DEFAULT true,
|
||||
unpaired_reminders_enabled boolean NOT NULL DEFAULT true,
|
||||
catchup_reminders_enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS feedback (
|
||||
id uuid PRIMARY KEY,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
type text NOT NULL CHECK (type IN ('bug', 'feature_request', 'general', 'question')),
|
||||
title text NOT NULL,
|
||||
description text NOT NULL,
|
||||
device_info jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'reviewed', 'in_progress', 'resolved', 'closed')),
|
||||
question_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS feedback_user_id_idx ON feedback(user_id);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
CREATE TABLE IF NOT EXISTS media_objects (
|
||||
id uuid PRIMARY KEY,
|
||||
owner_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
couple_id uuid REFERENCES couples(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL CHECK (kind IN ('avatar', 'response', 'chat', 'feedback')),
|
||||
storage_key text NOT NULL UNIQUE,
|
||||
mime_type text NOT NULL,
|
||||
byte_size integer NOT NULL CHECK (byte_size > 0),
|
||||
question_id uuid REFERENCES questions(id) ON DELETE CASCADE,
|
||||
match_id uuid REFERENCES matches(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS media_objects_owner_idx ON media_objects(owner_id, created_at DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS media_objects_couple_idx ON media_objects(couple_id) WHERE couple_id IS NOT NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS media_deletion_queue (
|
||||
storage_key text PRIMARY KEY,
|
||||
queued_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_media_blob_deletion() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
INSERT INTO media_deletion_queue(storage_key) VALUES(OLD.storage_key) ON CONFLICT DO NOTHING;
|
||||
RETURN OLD;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS media_objects_queue_blob_deletion ON media_objects;
|
||||
CREATE TRIGGER media_objects_queue_blob_deletion BEFORE DELETE ON media_objects
|
||||
FOR EACH ROW EXECUTE FUNCTION queue_media_blob_deletion();
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE feedback ADD COLUMN IF NOT EXISTS screenshot_media_id uuid REFERENCES media_objects(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
revenuecat_app_user_id text NOT NULL,
|
||||
product_id text NOT NULL,
|
||||
status text NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'cancelled', 'expired', 'billing_issue', 'paused')),
|
||||
entitlement_ids text[] NOT NULL DEFAULT '{}',
|
||||
purchased_at timestamptz NOT NULL,
|
||||
expires_at timestamptz,
|
||||
original_transaction_id text,
|
||||
store text NOT NULL DEFAULT 'APP_STORE',
|
||||
is_sandbox boolean NOT NULL DEFAULT false,
|
||||
cancel_reason text,
|
||||
grace_period_expires_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, original_transaction_id)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS subscriptions_user_id_idx ON subscriptions(user_id);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS subscriptions_status_idx ON subscriptions(status);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS revenuecat_webhook_events (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id text NOT NULL UNIQUE,
|
||||
event_type text NOT NULL,
|
||||
app_user_id text NOT NULL,
|
||||
processed_at timestamptz NOT NULL DEFAULT now(),
|
||||
payload jsonb NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS redemption_codes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code text NOT NULL,
|
||||
description text,
|
||||
max_uses integer DEFAULT 1 CHECK (max_uses IS NULL OR max_uses >= 0),
|
||||
current_uses integer NOT NULL DEFAULT 0 CHECK (current_uses >= 0),
|
||||
expires_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS redemption_codes_upper_code_idx ON redemption_codes(upper(code));
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS code_redemptions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code_id uuid NOT NULL REFERENCES redemption_codes(id) ON DELETE CASCADE,
|
||||
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
redeemed_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (code_id, user_id)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS code_redemptions_user_id_idx ON code_redemptions(user_id);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS redemption_rate_limits (
|
||||
bucket timestamptz PRIMARY KEY,
|
||||
attempts integer NOT NULL CHECK (attempts >= 0)
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS last_nudge_sent_at timestamptz;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS live_draw_sessions (
|
||||
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
|
||||
strokes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
revision bigint NOT NULL DEFAULT 1,
|
||||
updated_by uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT live_draw_sessions_strokes_array CHECK (jsonb_typeof(strokes) = 'array')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS live_draw_sessions_updated_at_idx
|
||||
ON live_draw_sessions(updated_at DESC);
|
||||
@@ -0,0 +1,161 @@
|
||||
ALTER TABLE question_packs
|
||||
ADD COLUMN IF NOT EXISTS scheduled_release_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS release_notified boolean NOT NULL DEFAULT false;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS last_unpaired_reminder_at timestamptz;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS pending_match_notifications (
|
||||
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
|
||||
active_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
match_count integer NOT NULL DEFAULT 0,
|
||||
response_count integer NOT NULL DEFAULT 1,
|
||||
latest_match_id uuid REFERENCES matches(id) ON DELETE SET NULL,
|
||||
notify_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS pending_match_notifications_due_idx ON pending_match_notifications(notify_at);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS pending_pack_notifications (
|
||||
couple_id uuid PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
|
||||
changed_by_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
notify_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS pending_pack_notifications_due_idx ON pending_pack_notifications(notify_at);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS catchup_reminder_tracking (
|
||||
user_id uuid PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
pending_since timestamptz,
|
||||
last_reminder_sent_at timestamptz,
|
||||
reminder_count integer NOT NULL DEFAULT 0 CHECK (reminder_count >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS operations_outbox (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kind text NOT NULL CHECK (kind IN ('expo', 'discord', 'classify')),
|
||||
dedupe_key text NOT NULL UNIQUE,
|
||||
recipient_id uuid REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
payload jsonb NOT NULL,
|
||||
available_at timestamptz NOT NULL DEFAULT now(),
|
||||
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
|
||||
locked_at timestamptz,
|
||||
sent_at timestamptz,
|
||||
last_error text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS operations_outbox_due_idx
|
||||
ON operations_outbox(available_at, created_at) WHERE sent_at IS NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_response_digest() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||
INSERT INTO pending_match_notifications(couple_id, active_user_id, notify_at)
|
||||
VALUES(NEW.couple_id, NEW.user_id, now() + interval '5 minutes')
|
||||
ON CONFLICT(couple_id) DO UPDATE SET
|
||||
active_user_id=excluded.active_user_id,
|
||||
response_count=pending_match_notifications.response_count+1,
|
||||
notify_at=excluded.notify_at,
|
||||
updated_at=now();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS operations_response_digest ON responses;
|
||||
CREATE TRIGGER operations_response_digest AFTER INSERT ON responses
|
||||
FOR EACH ROW EXECUTE FUNCTION queue_response_digest();
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION count_digest_match() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||
UPDATE pending_match_notifications SET match_count=match_count+1,
|
||||
latest_match_id=NEW.id, updated_at=now() WHERE couple_id=NEW.couple_id;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS operations_match_digest ON matches;
|
||||
CREATE TRIGGER operations_match_digest AFTER INSERT ON matches
|
||||
FOR EACH ROW EXECUTE FUNCTION count_digest_match();
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_message_operations() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE v_couple_id uuid; v_recipient record;
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||
SELECT couple_id INTO v_couple_id FROM matches WHERE id=NEW.match_id;
|
||||
FOR v_recipient IN
|
||||
SELECT p.id FROM profiles p
|
||||
LEFT JOIN notification_preferences np ON np.user_id=p.id
|
||||
WHERE p.couple_id=v_couple_id AND p.id<>NEW.user_id
|
||||
AND COALESCE(np.messages_enabled,true)=true
|
||||
LOOP
|
||||
INSERT INTO operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||
VALUES('expo','message:'||NEW.id||':'||v_recipient.id,v_recipient.id,
|
||||
jsonb_build_object('title','New message','body','Your partner sent you a message',
|
||||
'data',jsonb_build_object('type','message','match_id',NEW.match_id,'message_id',NEW.id)))
|
||||
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||
END LOOP;
|
||||
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||
VALUES('classify','classify:'||NEW.id,jsonb_build_object('message_id',NEW.id))
|
||||
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS operations_message_created ON messages;
|
||||
CREATE TRIGGER operations_message_created AFTER INSERT ON messages
|
||||
FOR EACH ROW EXECUTE FUNCTION queue_message_operations();
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_pack_change(p_couple_id uuid, p_user_id uuid) RETURNS void LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM profiles WHERE id=p_user_id AND couple_id=p_couple_id) THEN
|
||||
RAISE EXCEPTION 'user is not a member of couple';
|
||||
END IF;
|
||||
INSERT INTO pending_pack_notifications(couple_id,changed_by_user_id,notify_at)
|
||||
VALUES(p_couple_id,p_user_id,now()+interval '30 minutes')
|
||||
ON CONFLICT(couple_id) DO UPDATE SET changed_by_user_id=excluded.changed_by_user_id,
|
||||
notify_at=excluded.notify_at,updated_at=now();
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_discord_operations() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||
IF TG_TABLE_NAME='profiles' AND NEW.onboarding_completed=true
|
||||
AND (TG_OP='INSERT' OR OLD.onboarding_completed=false) THEN
|
||||
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||
VALUES('discord','discord:new_user:'||NEW.id,
|
||||
jsonb_build_object('event','new_user','user_id',NEW.id,'name',NEW.name,'email',NEW.email,'created_at',NEW.created_at))
|
||||
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||
END IF;
|
||||
IF TG_TABLE_NAME='profiles' AND NEW.couple_id IS NOT NULL
|
||||
AND (TG_OP='INSERT' OR OLD.couple_id IS DISTINCT FROM NEW.couple_id)
|
||||
AND EXISTS(SELECT 1 FROM profiles partner WHERE partner.couple_id=NEW.couple_id AND partner.id<>NEW.id) THEN
|
||||
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||
VALUES('discord','discord:couple_paired:'||NEW.couple_id,
|
||||
jsonb_build_object('event','couple_paired','couple_id',NEW.couple_id,'paired_at',now()))
|
||||
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS operations_profile_discord ON profiles;
|
||||
CREATE TRIGGER operations_profile_discord AFTER INSERT OR UPDATE OF onboarding_completed,couple_id ON profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION queue_discord_operations();
|
||||
--> statement-breakpoint
|
||||
CREATE OR REPLACE FUNCTION queue_feedback_discord() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||
INSERT INTO operations_outbox(kind,dedupe_key,payload)
|
||||
VALUES('discord','discord:feedback:'||NEW.id,
|
||||
jsonb_build_object('event','feedback_submitted','feedback_id',NEW.id,'user_id',NEW.user_id,
|
||||
'type',NEW.type,'status',NEW.status,'title',NEW.title,'description',NEW.description,
|
||||
'question_id',NEW.question_id,'created_at',NEW.created_at))
|
||||
ON CONFLICT(dedupe_key) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS operations_feedback_discord ON feedback;
|
||||
CREATE TRIGGER operations_feedback_discord AFTER INSERT ON feedback
|
||||
FOR EACH ROW EXECUTE FUNCTION queue_feedback_discord();
|
||||
@@ -0,0 +1,178 @@
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL UNIQUE REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
role text NOT NULL CHECK (role IN ('pack_creator', 'super_admin')),
|
||||
permissions text[] NOT NULL DEFAULT '{}',
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS admin_users_active_user_idx ON admin_users(user_id) WHERE is_active = true;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
admin_user_id uuid REFERENCES admin_users(id) ON DELETE SET NULL,
|
||||
actor_user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE RESTRICT,
|
||||
table_name text NOT NULL,
|
||||
action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE', 'ACTION')),
|
||||
record_id text,
|
||||
old_values jsonb,
|
||||
new_values jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS audit_logs_created_idx ON audit_logs(created_at DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS audit_logs_actor_idx ON audit_logs(actor_user_id, created_at DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS topics (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS pack_topics (
|
||||
pack_id uuid NOT NULL REFERENCES question_packs(id) ON DELETE CASCADE,
|
||||
topic_id uuid NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (pack_id, topic_id)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE categories ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE question_packs ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE questions ALTER COLUMN id SET DEFAULT gen_random_uuid();
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE question_packs
|
||||
ADD COLUMN IF NOT EXISTS scheduled_release_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS release_notified boolean NOT NULL DEFAULT false;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN IF NOT EXISTS inverse_of uuid REFERENCES questions(id) ON DELETE SET NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN IF NOT EXISTS moderation_status text DEFAULT 'unmoderated',
|
||||
ADD COLUMN IF NOT EXISTS flag_reason text;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE messages ADD CONSTRAINT messages_moderation_status_check
|
||||
CHECK (moderation_status IS NULL OR moderation_status IN ('safe', 'flagged', 'unmoderated'));
|
||||
EXCEPTION WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE message_reports
|
||||
ADD COLUMN IF NOT EXISTS reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS reviewed_at timestamptz;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE feedback
|
||||
ADD COLUMN IF NOT EXISTS admin_notes text;
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS ai_config (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
openrouter_api_key text,
|
||||
default_model text DEFAULT 'openai/gpt-4o-mini',
|
||||
default_temperature double precision,
|
||||
model_generate text,
|
||||
temperature_generate double precision,
|
||||
model_fix text,
|
||||
temperature_fix double precision,
|
||||
model_polish text,
|
||||
temperature_polish double precision,
|
||||
council_enabled boolean NOT NULL DEFAULT false,
|
||||
council_generator_model text DEFAULT 'anthropic/claude-3.5-sonnet',
|
||||
council_generators jsonb DEFAULT '[{"model":"anthropic/claude-3.5-sonnet"}]'::jsonb,
|
||||
council_reviewer_model text DEFAULT 'google/gemini-pro-1.5',
|
||||
council_reviewer_temperature double precision,
|
||||
council_selection_mode text DEFAULT 'whole_set' CHECK (
|
||||
council_selection_mode IS NULL OR council_selection_mode IN ('whole_set', 'cherry_pick')
|
||||
),
|
||||
cherry_pick_ensure_intensity_distribution boolean DEFAULT true,
|
||||
classifier_enabled boolean DEFAULT true,
|
||||
classifier_model text DEFAULT 'openai/gpt-4o',
|
||||
classifier_temperature double precision,
|
||||
classifier_prompt text,
|
||||
heuristics_enabled boolean NOT NULL DEFAULT false,
|
||||
heuristic_min_text_length integer NOT NULL DEFAULT 12,
|
||||
heuristic_whitelist_max_length integer NOT NULL DEFAULT 30,
|
||||
heuristic_skip_if_no_alnum boolean NOT NULL DEFAULT true,
|
||||
heuristic_skip_media_without_text boolean NOT NULL DEFAULT false,
|
||||
heuristic_record_reason boolean NOT NULL DEFAULT false,
|
||||
heuristic_use_default_whitelist boolean NOT NULL DEFAULT true,
|
||||
heuristic_use_default_keywords boolean NOT NULL DEFAULT true,
|
||||
heuristic_whitelist text,
|
||||
heuristic_keyword_triggers text,
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
updated_by uuid REFERENCES profiles(id) ON DELETE SET NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton_idx ON ai_config ((true));
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS dare_packs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), name text NOT NULL, description text, icon text,
|
||||
is_premium boolean NOT NULL DEFAULT false, is_public boolean NOT NULL DEFAULT true,
|
||||
is_explicit boolean NOT NULL DEFAULT false, sort_order integer NOT NULL DEFAULT 0,
|
||||
category_id uuid REFERENCES categories(id) ON DELETE SET NULL,
|
||||
min_intensity integer, max_intensity integer, avg_intensity numeric(3,2),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS dares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
pack_id uuid NOT NULL REFERENCES dare_packs(id) ON DELETE CASCADE,
|
||||
text text NOT NULL, intensity integer NOT NULL DEFAULT 1 CHECK (intensity BETWEEN 1 AND 5),
|
||||
suggested_duration_hours integer, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS dares_pack_idx ON dares(pack_id);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS master_keys (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key_name text NOT NULL UNIQUE,
|
||||
public_key_jwk jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
rotated_at timestamptz,
|
||||
is_active boolean NOT NULL DEFAULT true
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS sent_dares (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
couple_id uuid NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
||||
dare_id uuid REFERENCES dares(id) ON DELETE SET NULL,
|
||||
custom_dare_text text,
|
||||
custom_dare_intensity integer CHECK (
|
||||
custom_dare_intensity IS NULL OR custom_dare_intensity BETWEEN 1 AND 5
|
||||
),
|
||||
sender_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
recipient_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (
|
||||
status IN ('pending', 'active', 'completed', 'expired', 'declined', 'cancelled')
|
||||
),
|
||||
sent_at timestamptz NOT NULL DEFAULT now(),
|
||||
accepted_at timestamptz,
|
||||
expires_at timestamptz,
|
||||
completed_at timestamptz,
|
||||
sender_notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT sent_dares_dare_or_custom_check CHECK (
|
||||
dare_id IS NOT NULL OR custom_dare_text IS NOT NULL
|
||||
)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS sent_dares_couple_idx ON sent_dares(couple_id);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS sent_dares_dare_idx ON sent_dares(dare_id);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS dare_messages (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
sent_dare_id uuid NOT NULL REFERENCES sent_dares(id) ON DELETE CASCADE,
|
||||
sender_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
content text NOT NULL,
|
||||
read_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS dare_messages_sent_dare_created_idx
|
||||
ON dare_messages(sent_dare_id, created_at);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS dare_messages_sender_idx ON dare_messages(sender_id);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "question_packs"
|
||||
ALTER COLUMN "avg_intensity" TYPE numeric(3,2)
|
||||
USING "avg_intensity"::numeric(3,2);
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "topics"
|
||||
ADD COLUMN IF NOT EXISTS "description" text,
|
||||
ADD COLUMN IF NOT EXISTS "icon" text,
|
||||
ADD COLUMN IF NOT EXISTS "sort_order" integer NOT NULL DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "questions"
|
||||
ADD COLUMN IF NOT EXISTS "required_props" text[];
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1787853600000,
|
||||
"tag": "0000_identity_and_feature_interests",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1787864000000,
|
||||
"tag": "0001_couples",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1787864744000,
|
||||
"tag": "0002_packs_catalog_progress",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1787865000000,
|
||||
"tag": "0003_answers_matches",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1787865100000,
|
||||
"tag": "0004_chat",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "7",
|
||||
"when": 1787865200000,
|
||||
"tag": "0005_profile_settings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1787865300000,
|
||||
"tag": "0006_media_storage",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1787865400000,
|
||||
"tag": "0007_billing_redemption",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1787865500000,
|
||||
"tag": "0008_residual_realtime",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1787865600000,
|
||||
"tag": "0009_operations_workers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1787865700000,
|
||||
"tag": "0010_admin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1787907600000,
|
||||
"tag": "0011_question_pack_average_precision",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1787908500000,
|
||||
"tag": "0012_catalogue_field_parity",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@sauci/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20 <21"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:worker": "tsx watch src/workers/index.ts",
|
||||
"build": "tsup src/index.ts src/db/migrate.ts src/migration/run.ts src/workers/index.ts --format esm --clean",
|
||||
"start": "node dist/index.js",
|
||||
"start:worker": "node dist/workers/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:migrate:prod": "node dist/db/migrate.js",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"migration:cutover": "tsx src/migration/run.ts",
|
||||
"test": "vitest run",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@sauci/shared": "*",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hono": "^4.6.0",
|
||||
"jose": "^6.1.3",
|
||||
"pg": "^8.17.2",
|
||||
"zod": "^3.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.15.5",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"tsup": "^8.2.4",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { ApiErrorResponse, MeResponse } from '@sauci/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js';
|
||||
import type { ApiRepository } from './db/repository.js';
|
||||
import type { AppDataRepository } from './domains/app-data/repository.js';
|
||||
import { registerAppDataRoutes } from './domains/app-data/routes.js';
|
||||
import type { BillingService } from './domains/billing/service.js';
|
||||
import { registerBillingRoutes } from './domains/billing/routes.js';
|
||||
import type { AccountOperationsService } from './domains/account-operations/service.js';
|
||||
import { registerAccountOperationRoutes } from './domains/account-operations/routes.js';
|
||||
import type { AnswersRepository } from './domains/answers/repository.js';
|
||||
import { registerAnswerRoutes } from './domains/answers/routes.js';
|
||||
import type { ChatRepository } from './domains/chat/repository.js';
|
||||
import { registerChatRoutes } from './domains/chat/routes.js';
|
||||
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
||||
import type { CoupleService } from './domains/couples/service.js';
|
||||
import type { PacksRepository } from './domains/packs/repository.js';
|
||||
import { registerPackRoutes } from './domains/packs/routes.js';
|
||||
import type { ProfileSettingsRepository } from './domains/profile-settings/repository.js';
|
||||
import { registerProfileSettingsRoutes } from './domains/profile-settings/routes.js';
|
||||
import type { MediaRepository } from './domains/media/repository.js';
|
||||
import { registerMediaRoutes } from './domains/media/routes.js';
|
||||
import type { FilesystemMediaStorage } from './domains/media/storage.js';
|
||||
import type { AdminRequestAuth } from './domains/admin/auth.js';
|
||||
import type { AdminRepository } from './domains/admin/repository.js';
|
||||
import { registerAdminRoutes } from './domains/admin/routes.js';
|
||||
|
||||
type Variables = { identity: AuthIdentity };
|
||||
|
||||
export interface AppDependencies {
|
||||
auth: AuthVerifier;
|
||||
repository: ApiRepository;
|
||||
coupleService?: CoupleService;
|
||||
packsRepository?: PacksRepository;
|
||||
answersRepository?: AnswersRepository;
|
||||
chatRepository?: ChatRepository;
|
||||
profileSettingsRepository?: ProfileSettingsRepository;
|
||||
accountOperationsService?: AccountOperationsService;
|
||||
mediaRepository?: MediaRepository;
|
||||
mediaStorage?: FilesystemMediaStorage;
|
||||
appDataRepository?: AppDataRepository;
|
||||
billingService?: BillingService;
|
||||
adminRepository?: AdminRepository;
|
||||
adminAuth?: AdminRequestAuth;
|
||||
}
|
||||
|
||||
const featureSchema = z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
|
||||
const app = new Hono<{ Variables: Variables }>();
|
||||
|
||||
app.get('/health/live', (c) => c.json({ status: 'ok' }));
|
||||
app.get('/health/ready', async (c) => {
|
||||
try {
|
||||
await deps.repository.ready();
|
||||
return c.json({ status: 'ready' });
|
||||
} catch {
|
||||
return c.json(error('not_ready', 'Database is unavailable'), 503);
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/v1/*', async (c, next) => {
|
||||
// Admin routes accept either a hosted user token or the separately scoped
|
||||
// MCP service credential and therefore own their authentication middleware.
|
||||
if (c.req.path.startsWith('/v1/admin/')) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
const token = bearerToken(c.req.header('authorization'));
|
||||
if (!token) return c.json(error('unauthorized', 'A bearer token is required'), 401);
|
||||
try {
|
||||
c.set('identity', await deps.auth.verify(token));
|
||||
} catch {
|
||||
return c.json(error('unauthorized', 'The bearer token is invalid'), 401);
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
app.get('/v1/me', async (c) => {
|
||||
const profile = await deps.repository.upsertProfile(c.get('identity'));
|
||||
return c.json<MeResponse>({ profile });
|
||||
});
|
||||
|
||||
app.get('/v1/me/feature-interests/:feature', async (c) => {
|
||||
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
||||
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
||||
return c.json(await deps.repository.getFeatureInterest(c.get('identity').id, parsed.data));
|
||||
});
|
||||
|
||||
app.put('/v1/me/feature-interests/:feature', async (c) => {
|
||||
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
||||
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
||||
await deps.repository.upsertProfile(c.get('identity'));
|
||||
return c.json(await deps.repository.putFeatureInterest(c.get('identity').id, parsed.data));
|
||||
});
|
||||
|
||||
app.delete('/v1/me/feature-interests/:feature', async (c) => {
|
||||
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
||||
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
||||
return c.json(await deps.repository.deleteFeatureInterest(c.get('identity').id, parsed.data));
|
||||
});
|
||||
|
||||
if (deps.coupleService) registerCoupleRoutes(app, deps.coupleService);
|
||||
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
|
||||
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
||||
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
||||
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
||||
if (deps.appDataRepository) registerAppDataRoutes(app, deps.appDataRepository);
|
||||
if (deps.billingService) registerBillingRoutes(app, deps.billingService);
|
||||
if (deps.adminRepository && deps.adminAuth) {
|
||||
registerAdminRoutes(app, deps.adminRepository, deps.adminAuth, deps.mediaStorage);
|
||||
}
|
||||
|
||||
app.notFound((c) => c.json(error('not_found', 'Route not found'), 404));
|
||||
app.onError((cause, c) => {
|
||||
console.error(cause);
|
||||
return c.json(error('internal_error', 'An unexpected error occurred'), 500);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
createLocalJWKSet,
|
||||
createRemoteJWKSet,
|
||||
jwtVerify,
|
||||
type JSONWebKeySet,
|
||||
type JWTVerifyGetKey,
|
||||
type JWTPayload,
|
||||
} from 'jose';
|
||||
import { z } from 'zod';
|
||||
import type { AppConfig } from './config.js';
|
||||
|
||||
const uuidSchema = z.string().uuid();
|
||||
|
||||
export interface AuthIdentity {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface AuthVerifier {
|
||||
verify(token: string): Promise<AuthIdentity>;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() !== '' ? value : null;
|
||||
}
|
||||
|
||||
function identityFromClaims(claims: JWTPayload): AuthIdentity {
|
||||
const id = uuidSchema.parse(claims.sub);
|
||||
const metadata = claims.user_metadata && typeof claims.user_metadata === 'object'
|
||||
? claims.user_metadata as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
return {
|
||||
id,
|
||||
email: optionalString(claims.email),
|
||||
name: optionalString(metadata.full_name) ?? optionalString(metadata.name),
|
||||
avatarUrl: optionalString(metadata.avatar_url) ?? optionalString(metadata.picture),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAuthVerifier(config: AppConfig): AuthVerifier {
|
||||
let keySet: JWTVerifyGetKey;
|
||||
if (config.authTestJwks) {
|
||||
if (config.nodeEnv !== 'test') {
|
||||
throw new Error('Local JWKS verification is forbidden outside test');
|
||||
}
|
||||
const jwks = JSON.parse(config.authTestJwks) as JSONWebKeySet;
|
||||
keySet = createLocalJWKSet(jwks);
|
||||
} else {
|
||||
if (!config.authJwksUrl) {
|
||||
throw new Error('A remote Supabase Auth JWKS URL is required');
|
||||
}
|
||||
keySet = createRemoteJWKSet(new URL(config.authJwksUrl));
|
||||
}
|
||||
|
||||
return {
|
||||
async verify(token: string): Promise<AuthIdentity> {
|
||||
const { payload } = await jwtVerify(token, keySet, {
|
||||
issuer: config.authIssuer,
|
||||
audience: config.authAudience,
|
||||
algorithms: ['ES256', 'RS256'],
|
||||
});
|
||||
return identityFromClaims(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function bearerToken(header: string | undefined): string | null {
|
||||
if (!header) return null;
|
||||
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const baseSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
|
||||
DATABASE_URL: z.string().url(),
|
||||
SUPABASE_AUTH_URL: z.string().url().optional(),
|
||||
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
|
||||
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
|
||||
SUPABASE_AUTH_AUDIENCE: z.string().min(1).default('authenticated'),
|
||||
AUTH_TEST_JWKS: z.string().optional(),
|
||||
MEDIA_ROOT: z.string().min(1).default('/data/media'),
|
||||
MEDIA_SIGNING_SECRET: z.string().min(32).optional(),
|
||||
MEDIA_PUBLIC_BASE_URL: z.string().url().optional(),
|
||||
SUPABASE_AUTH_SERVICE_ROLE_KEY: z.string().min(1).optional(),
|
||||
REVENUECAT_API_KEY: z.string().min(1).optional(),
|
||||
REVENUECAT_ENTITLEMENT_ID: z.string().min(1).default('Sauci Pro'),
|
||||
REVENUECAT_WEBHOOK_SECRET: z.string().min(1).optional(),
|
||||
ADMIN_API_SERVICE_TOKEN: z.string().min(32).optional(),
|
||||
ADMIN_API_SERVICE_USER_ID: z.string().uuid().optional(),
|
||||
ADMIN_PRIVATE_KEY_JWK: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface AppConfig {
|
||||
nodeEnv: 'development' | 'test' | 'production';
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
authIssuer: string;
|
||||
authJwksUrl?: string;
|
||||
authAudience: string;
|
||||
authTestJwks?: string;
|
||||
mediaRoot: string;
|
||||
mediaSigningSecret: string;
|
||||
mediaPublicBaseUrl: string;
|
||||
supabaseAuthServiceRoleKey?: string;
|
||||
revenueCatApiKey?: string;
|
||||
revenueCatEntitlementId: string;
|
||||
revenueCatWebhookSecret?: string;
|
||||
adminApiServiceToken?: string;
|
||||
adminApiServiceUserId?: string;
|
||||
adminPrivateKeyJwk?: string;
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const parsed = baseSchema.parse(env);
|
||||
if (Boolean(parsed.ADMIN_API_SERVICE_TOKEN) !== Boolean(parsed.ADMIN_API_SERVICE_USER_ID)) {
|
||||
throw new Error('ADMIN_API_SERVICE_TOKEN and ADMIN_API_SERVICE_USER_ID must be configured together');
|
||||
}
|
||||
if (parsed.ADMIN_PRIVATE_KEY_JWK) {
|
||||
try { JSON.parse(parsed.ADMIN_PRIVATE_KEY_JWK); } catch { throw new Error('ADMIN_PRIVATE_KEY_JWK must be valid JSON'); }
|
||||
}
|
||||
if (parsed.AUTH_TEST_JWKS && parsed.NODE_ENV !== 'test') {
|
||||
throw new Error('AUTH_TEST_JWKS is forbidden outside NODE_ENV=test');
|
||||
}
|
||||
|
||||
const authUrl = parsed.SUPABASE_AUTH_URL?.replace(/\/$/, '');
|
||||
const authIssuer = parsed.SUPABASE_AUTH_ISSUER ?? (authUrl ? `${authUrl}/auth/v1` : undefined);
|
||||
const authJwksUrl = parsed.SUPABASE_AUTH_JWKS_URL ??
|
||||
(authUrl ? `${authUrl}/auth/v1/.well-known/jwks.json` : undefined);
|
||||
|
||||
if (!authIssuer || (!authJwksUrl && !parsed.AUTH_TEST_JWKS)) {
|
||||
throw new Error('Supabase Auth issuer and JWKS configuration are required');
|
||||
}
|
||||
if (parsed.NODE_ENV === 'production' &&
|
||||
(new URL(authIssuer).protocol !== 'https:' ||
|
||||
(authJwksUrl && new URL(authJwksUrl).protocol !== 'https:'))) {
|
||||
throw new Error('Supabase Auth issuer and JWKS URLs must use HTTPS in production');
|
||||
}
|
||||
if (parsed.NODE_ENV === 'production' && !parsed.MEDIA_SIGNING_SECRET) {
|
||||
throw new Error('MEDIA_SIGNING_SECRET is required in production');
|
||||
}
|
||||
const mediaPublicBaseUrl = parsed.MEDIA_PUBLIC_BASE_URL ?? `http://127.0.0.1:${parsed.PORT}`;
|
||||
if (parsed.NODE_ENV === 'production' && new URL(mediaPublicBaseUrl).protocol !== 'https:') {
|
||||
throw new Error('MEDIA_PUBLIC_BASE_URL must use HTTPS in production');
|
||||
}
|
||||
|
||||
return {
|
||||
nodeEnv: parsed.NODE_ENV,
|
||||
port: parsed.PORT,
|
||||
databaseUrl: parsed.DATABASE_URL,
|
||||
authIssuer,
|
||||
authJwksUrl,
|
||||
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
|
||||
authTestJwks: parsed.AUTH_TEST_JWKS,
|
||||
mediaRoot: parsed.MEDIA_ROOT,
|
||||
mediaSigningSecret: parsed.MEDIA_SIGNING_SECRET ?? 'local-media-signing-secret-change-me-123456',
|
||||
mediaPublicBaseUrl: mediaPublicBaseUrl.replace(/\/$/, ''),
|
||||
supabaseAuthServiceRoleKey: parsed.SUPABASE_AUTH_SERVICE_ROLE_KEY,
|
||||
revenueCatApiKey: parsed.REVENUECAT_API_KEY,
|
||||
revenueCatEntitlementId: parsed.REVENUECAT_ENTITLEMENT_ID,
|
||||
revenueCatWebhookSecret: parsed.REVENUECAT_WEBHOOK_SECRET,
|
||||
adminApiServiceToken: parsed.ADMIN_API_SERVICE_TOKEN,
|
||||
adminApiServiceUserId: parsed.ADMIN_API_SERVICE_USER_ID,
|
||||
adminPrivateKeyJwk: parsed.ADMIN_PRIVATE_KEY_JWK,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './schema.js';
|
||||
export * from '../domains/couples/schema.js';
|
||||
export * from '../domains/packs/schema.js';
|
||||
export * from '../domains/answers/schema.js';
|
||||
export * from '../domains/chat/schema.js';
|
||||
export * from '../domains/app-data/schema.js';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required for database migrations');
|
||||
}
|
||||
|
||||
const migrationsFolder = resolve(process.env.MIGRATIONS_DIR ?? 'drizzle');
|
||||
const pool = new Pool({ connectionString: databaseUrl, max: 2 });
|
||||
const lockClient = await pool.connect();
|
||||
|
||||
try {
|
||||
// Serializes migration containers during rolling/retried deployments.
|
||||
await lockClient.query('select pg_advisory_lock($1)', [2_024_082_701]);
|
||||
await migrate(drizzle(pool), { migrationsFolder });
|
||||
console.log(`Database migrations completed from ${migrationsFolder}`);
|
||||
} finally {
|
||||
await lockClient.query('select pg_advisory_unlock($1)', [2_024_082_701]).catch(() => undefined);
|
||||
lockClient.release();
|
||||
await pool.end();
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { FeatureInterestResponse, Profile } from '@sauci/shared';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import type { AuthIdentity } from '../auth.js';
|
||||
import { featureInterests, profiles, type ProfileRow } from './schema.js';
|
||||
|
||||
export type MobileCompatibleProfile = Profile & {
|
||||
email: string | null;
|
||||
gender: 'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null;
|
||||
show_explicit_content: boolean;
|
||||
max_intensity: 1 | 2 | 3 | 4 | 5;
|
||||
};
|
||||
|
||||
export interface ApiRepository {
|
||||
ready(): Promise<void>;
|
||||
upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile>;
|
||||
getFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
|
||||
putFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
|
||||
deleteFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function toProfile(row: ProfileRow): MobileCompatibleProfile {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
avatar_url: row.avatarUrl,
|
||||
push_token: row.pushToken,
|
||||
is_premium: row.isPremium,
|
||||
couple_id: row.coupleId,
|
||||
gender: row.gender,
|
||||
show_explicit_content: row.showExplicitContent,
|
||||
max_intensity: row.maxIntensity as 1 | 2 | 3 | 4 | 5,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
updated_at: row.updatedAt.toISOString(),
|
||||
public_key_jwk: row.publicKeyJwk,
|
||||
hide_nsfw: row.hideNsfw,
|
||||
onboarding_completed: row.onboardingCompleted,
|
||||
onboarding_version: row.onboardingVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export class PostgresRepository implements ApiRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly db: NodePgDatabase;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
this.db = drizzle(this.pool);
|
||||
}
|
||||
|
||||
async ready(): Promise<void> {
|
||||
await this.db.select({ id: profiles.id }).from(profiles).limit(1);
|
||||
}
|
||||
|
||||
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
|
||||
const [row] = await this.db.insert(profiles).values({
|
||||
id: identity.id,
|
||||
name: identity.name,
|
||||
avatarUrl: identity.avatarUrl,
|
||||
email: identity.email,
|
||||
}).onConflictDoUpdate({
|
||||
target: profiles.id,
|
||||
set: {
|
||||
email: sql`coalesce(excluded.email, ${profiles.email})`,
|
||||
authSyncedAt: new Date(),
|
||||
},
|
||||
}).returning();
|
||||
|
||||
if (!row) throw new Error('Profile upsert returned no row');
|
||||
return toProfile(row);
|
||||
}
|
||||
|
||||
async getFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
|
||||
const [row] = await this.db.select({ feature: featureInterests.feature })
|
||||
.from(featureInterests)
|
||||
.where(and(eq(featureInterests.userId, userId), eq(featureInterests.feature, feature)))
|
||||
.limit(1);
|
||||
return { feature, interested: Boolean(row) };
|
||||
}
|
||||
|
||||
async putFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
|
||||
await this.db.insert(featureInterests).values({ userId, feature }).onConflictDoNothing();
|
||||
return { feature, interested: true };
|
||||
}
|
||||
|
||||
async deleteFeatureInterest(userId: string, feature: string): Promise<FeatureInterestResponse> {
|
||||
await this.db.delete(featureInterests)
|
||||
.where(and(eq(featureInterests.userId, userId), eq(featureInterests.feature, feature)));
|
||||
return { feature, interested: false };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const profiles = pgTable('profiles', {
|
||||
id: uuid('id').primaryKey(),
|
||||
name: text('name'),
|
||||
email: text('email'),
|
||||
avatarUrl: text('avatar_url'),
|
||||
pushToken: text('push_token'),
|
||||
isPremium: boolean('is_premium').notNull().default(false),
|
||||
coupleId: uuid('couple_id'),
|
||||
gender: text('gender').$type<'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null>(),
|
||||
showExplicitContent: boolean('show_explicit_content').notNull().default(true),
|
||||
maxIntensity: integer('max_intensity').notNull().default(5),
|
||||
publicKeyJwk: jsonb('public_key_jwk').$type<Record<string, unknown> | null>(),
|
||||
hideNsfw: boolean('hide_nsfw').notNull().default(false),
|
||||
onboardingCompleted: boolean('onboarding_completed').notNull().default(false),
|
||||
onboardingVersion: integer('onboarding_version').notNull().default(0),
|
||||
authSyncedAt: timestamp('auth_synced_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [index('profiles_couple_id_idx').on(table.coupleId)]);
|
||||
|
||||
export const featureInterests = pgTable('feature_interests', {
|
||||
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
feature: text('feature').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [
|
||||
primaryKey({ columns: [table.userId, table.feature] }),
|
||||
index('feature_interests_feature_idx').on(table.feature),
|
||||
]);
|
||||
|
||||
export type ProfileRow = typeof profiles.$inferSelect;
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'dotenv/config';
|
||||
import { Pool } from 'pg';
|
||||
import { z } from 'zod';
|
||||
import { PostgresRepository } from './repository.js';
|
||||
|
||||
const schema = z.object({
|
||||
DATABASE_URL: z.string().url(),
|
||||
SEED_USER_ID: z.string().uuid().optional(),
|
||||
SEED_USER_EMAIL: z.string().email().default('local@sauci.test'),
|
||||
SEED_ADMIN_USER_ID: z.string().uuid().default('00000000-0000-4000-8000-000000000002'),
|
||||
SEED_ADMIN_USER_EMAIL: z.string().email().default('local-admin@sauci.test'),
|
||||
});
|
||||
|
||||
const env = schema.parse(process.env);
|
||||
const databaseUrl = new URL(env.DATABASE_URL);
|
||||
if (!['127.0.0.1', 'localhost', '::1'].includes(databaseUrl.hostname)) {
|
||||
throw new Error('db:seed only permits a localhost DATABASE_URL');
|
||||
}
|
||||
|
||||
const repository = new PostgresRepository(env.DATABASE_URL);
|
||||
const pool = new Pool({ connectionString: env.DATABASE_URL });
|
||||
try {
|
||||
if (env.SEED_USER_ID) await repository.upsertProfile({
|
||||
id: env.SEED_USER_ID, email: env.SEED_USER_EMAIL, name: 'Local Sauci User', avatarUrl: null,
|
||||
});
|
||||
await repository.upsertProfile({
|
||||
id: env.SEED_ADMIN_USER_ID,
|
||||
email: env.SEED_ADMIN_USER_EMAIL,
|
||||
name: 'Local Sauci Admin Service',
|
||||
avatarUrl: null,
|
||||
});
|
||||
await pool.query(`
|
||||
insert into admin_users(user_id,role,permissions,is_active)
|
||||
values($1,'super_admin','{}',true)
|
||||
on conflict(user_id) do update set role='super_admin',is_active=true,updated_at=now()
|
||||
`, [env.SEED_ADMIN_USER_ID]);
|
||||
console.log(`Seeded local admin service profile ${env.SEED_ADMIN_USER_ID}`);
|
||||
if (env.SEED_USER_ID) console.log(`Seeded local profile ${env.SEED_USER_ID}`);
|
||||
} finally {
|
||||
await Promise.all([repository.close(), pool.end()]);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Account operations and billing
|
||||
|
||||
This domain replaces the `delete-relationship`, `reset-couple-progress`,
|
||||
`delete-account`, and `sync-subscription` Supabase Edge Functions. The request
|
||||
identity always comes from the verified bearer token; no route accepts a user or
|
||||
couple identifier from the client.
|
||||
|
||||
## Server-only configuration
|
||||
|
||||
- `SUPABASE_AUTH_URL`: hosted Supabase project URL (or its `/auth/v1`
|
||||
endpoint), used only for Auth.
|
||||
- `SUPABASE_AUTH_SERVICE_ROLE_KEY`: server-side Auth Admin credential required
|
||||
for account deletion. Never expose this as an `EXPO_PUBLIC_*` value.
|
||||
- `REVENUECAT_API_KEY`: secret RevenueCat REST API key used to verify the token
|
||||
owner's entitlement.
|
||||
- `REVENUECAT_ENTITLEMENT_ID`: entitlement checked by sync; defaults to
|
||||
`Sauci Pro` to preserve the previous function behavior.
|
||||
|
||||
Missing provider secrets fail the affected operation with `503` before any
|
||||
durable local change. Account deletion holds its local transaction open until
|
||||
hosted Auth confirms deletion, and rolls the transaction back on Auth failure.
|
||||
Provider error bodies are not returned to the mobile app.
|
||||
|
||||
## Application wiring
|
||||
|
||||
Create one `PostgresAccountOperationsRepository`, then construct:
|
||||
|
||||
```ts
|
||||
const accountOperationsService = new AccountOperationsService(
|
||||
accountOperationsRepository,
|
||||
new SupabaseAuthAdminClient(process.env.SUPABASE_AUTH_URL, process.env.SUPABASE_AUTH_SERVICE_ROLE_KEY),
|
||||
new HttpRevenueCatClient(process.env.REVENUECAT_API_KEY, process.env.REVENUECAT_ENTITLEMENT_ID),
|
||||
new ExpoPartnerNotifier(),
|
||||
);
|
||||
```
|
||||
|
||||
Pass it to the app and call `registerAccountOperationRoutes`. Close the
|
||||
repository during graceful shutdown. No schema migration is required for these
|
||||
four operations: they use `profiles`, `couples`, `responses`, and `matches`.
|
||||
|
||||
Blob deletion must be handled by the self-hosted media/storage lifecycle when
|
||||
that vertical is wired. These operations intentionally never call the hosted
|
||||
Supabase database, Storage API, or Edge Functions.
|
||||
@@ -0,0 +1,139 @@
|
||||
import { z } from 'zod';
|
||||
import { AccountOperationError } from './types.js';
|
||||
|
||||
export interface AuthAdminClient {
|
||||
deleteUser(userId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RevenueCatClient {
|
||||
isEntitled(userId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface PartnerNotifier {
|
||||
relationshipDeleted(pushToken: string): Promise<void>;
|
||||
progressReset(pushToken: string): Promise<void>;
|
||||
partnerAccountDeleted(pushToken: string): Promise<void>;
|
||||
}
|
||||
|
||||
type Fetch = typeof globalThis.fetch;
|
||||
|
||||
const revenueCatResponse = z.object({
|
||||
subscriber: z.object({
|
||||
entitlements: z.record(z.object({ expires_date: z.string().datetime().nullable() }).passthrough()),
|
||||
}).passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
export class SupabaseAuthAdminClient implements AuthAdminClient {
|
||||
constructor(
|
||||
private readonly authUrl: string | undefined,
|
||||
private readonly serviceRoleKey: string | undefined,
|
||||
private readonly request: Fetch = globalThis.fetch,
|
||||
) {}
|
||||
|
||||
async deleteUser(userId: string): Promise<void> {
|
||||
if (!this.authUrl || !this.serviceRoleKey) {
|
||||
throw new AccountOperationError(
|
||||
'account_deletion_unavailable',
|
||||
'Account deletion is temporarily unavailable',
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const configuredUrl = this.authUrl.replace(/\/$/, '');
|
||||
const authApiUrl = configuredUrl.endsWith('/auth/v1') ? configuredUrl : `${configuredUrl}/auth/v1`;
|
||||
const response = await this.request(
|
||||
`${authApiUrl}/admin/users/${encodeURIComponent(userId)}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
apikey: this.serviceRoleKey,
|
||||
authorization: `Bearer ${this.serviceRoleKey}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
).catch(() => {
|
||||
throw new AccountOperationError('auth_provider_unavailable', 'Authentication provider is unavailable', 502);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AccountOperationError('auth_delete_failed', 'Failed to delete authentication record', 502);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class HttpRevenueCatClient implements RevenueCatClient {
|
||||
constructor(
|
||||
private readonly apiKey: string | undefined,
|
||||
private readonly entitlementId = 'Sauci Pro',
|
||||
private readonly request: Fetch = globalThis.fetch,
|
||||
private readonly now: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
async isEntitled(userId: string): Promise<boolean> {
|
||||
if (!this.apiKey) {
|
||||
throw new AccountOperationError(
|
||||
'subscription_sync_unavailable',
|
||||
'Subscription verification is temporarily unavailable',
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const response = await this.request(
|
||||
`https://api.revenuecat.com/v1/subscribers/${encodeURIComponent(userId)}`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${this.apiKey}`,
|
||||
accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
).catch(() => {
|
||||
throw new AccountOperationError('revenuecat_unavailable', 'Subscription provider is unavailable', 502);
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new AccountOperationError('subscription_verification_failed', 'Failed to verify subscription', 502);
|
||||
}
|
||||
|
||||
const parsed = revenueCatResponse.safeParse(await response.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
throw new AccountOperationError('invalid_subscription_response', 'Subscription provider returned invalid data', 502);
|
||||
}
|
||||
|
||||
const entitlement = parsed.data.subscriber.entitlements[this.entitlementId];
|
||||
if (!entitlement) return false;
|
||||
return entitlement.expires_date === null || new Date(entitlement.expires_date) > this.now();
|
||||
}
|
||||
}
|
||||
|
||||
export class ExpoPartnerNotifier implements PartnerNotifier {
|
||||
constructor(private readonly request: Fetch = globalThis.fetch) {}
|
||||
|
||||
relationshipDeleted(pushToken: string): Promise<void> {
|
||||
return this.send(pushToken, 'Relationship Deleted',
|
||||
'Your partner has deleted all shared data. You can start fresh with a new partner anytime.',
|
||||
'relationship_deleted');
|
||||
}
|
||||
|
||||
progressReset(pushToken: string): Promise<void> {
|
||||
return this.send(pushToken, 'Progress Reset',
|
||||
'Your partner has reset all progress. Your matches and chats have been cleared to start fresh.',
|
||||
'progress_reset');
|
||||
}
|
||||
|
||||
partnerAccountDeleted(pushToken: string): Promise<void> {
|
||||
return this.send(pushToken, 'Partner Left',
|
||||
'Your partner has deleted their account. You can start fresh with a new partner anytime.',
|
||||
'partner_account_deleted');
|
||||
}
|
||||
|
||||
private async send(to: string, title: string, body: string, type: string): Promise<void> {
|
||||
const response = await this.request('https://exp.host/--/api/v2/push/send', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ to, title, body, sound: 'default', data: { type } }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Expo push failed with status ${response.status}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './clients.js';
|
||||
export * from './repository.js';
|
||||
export * from './routes.js';
|
||||
export * from './service.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { AccountOperationError } from './types.js';
|
||||
|
||||
interface ProfileOperationRecord extends QueryResultRow {
|
||||
couple_id: string | null;
|
||||
}
|
||||
|
||||
interface PartnerRecord extends QueryResultRow {
|
||||
push_token: string | null;
|
||||
}
|
||||
|
||||
export interface DestructiveOperationResult {
|
||||
partnerPushToken: string | null;
|
||||
}
|
||||
|
||||
export interface AccountOperationsRepository {
|
||||
deleteRelationship(userId: string): Promise<DestructiveOperationResult>;
|
||||
resetProgress(userId: string): Promise<DestructiveOperationResult>;
|
||||
deleteAccount(userId: string, deleteAuthUser: () => Promise<void>): Promise<DestructiveOperationResult>;
|
||||
setPremium(userId: string, isPremium: boolean): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const result = await operation(client);
|
||||
await client.query('commit');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function lockedProfile(client: PoolClient, userId: string): Promise<ProfileOperationRecord> {
|
||||
const result = await client.query<ProfileOperationRecord>(
|
||||
'select couple_id from profiles where id = $1 for update',
|
||||
[userId],
|
||||
);
|
||||
const profile = result.rows[0];
|
||||
if (!profile) throw new AccountOperationError('profile_not_found', 'Profile not found', 404);
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function lockCoupleAndPartner(
|
||||
client: PoolClient,
|
||||
userId: string,
|
||||
coupleId: string,
|
||||
): Promise<PartnerRecord | undefined> {
|
||||
await client.query('select id from couples where id = $1 for update', [coupleId]);
|
||||
const partner = await client.query<PartnerRecord>(
|
||||
`select push_token from profiles
|
||||
where couple_id = $1 and id <> $2
|
||||
for update`,
|
||||
[coupleId, userId],
|
||||
);
|
||||
return partner.rows[0];
|
||||
}
|
||||
|
||||
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
}
|
||||
|
||||
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const profile = await lockedProfile(client, userId);
|
||||
if (!profile.couple_id) {
|
||||
throw new AccountOperationError('not_paired', 'You are not in a relationship', 400);
|
||||
}
|
||||
const partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
|
||||
await client.query(
|
||||
'update profiles set couple_id = null, updated_at = now() where couple_id = $1',
|
||||
[profile.couple_id],
|
||||
);
|
||||
await client.query('delete from couples where id = $1', [profile.couple_id]);
|
||||
return { partnerPushToken: partner?.push_token ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
async resetProgress(userId: string): Promise<DestructiveOperationResult> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const profile = await lockedProfile(client, userId);
|
||||
if (!profile.couple_id) {
|
||||
throw new AccountOperationError('not_paired', 'You are not in a relationship', 400);
|
||||
}
|
||||
const partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
|
||||
// Matches cascade to messages and per-user match archives. Responses are separate.
|
||||
await client.query('delete from matches where couple_id = $1', [profile.couple_id]);
|
||||
await client.query('delete from responses where couple_id = $1', [profile.couple_id]);
|
||||
return { partnerPushToken: partner?.push_token ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAccount(
|
||||
userId: string,
|
||||
deleteAuthUser: () => Promise<void>,
|
||||
): Promise<DestructiveOperationResult> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const profileResult = await client.query<ProfileOperationRecord>(
|
||||
'select couple_id from profiles where id = $1 for update',
|
||||
[userId],
|
||||
);
|
||||
const profile = profileResult.rows[0];
|
||||
let partner: PartnerRecord | undefined;
|
||||
if (profile?.couple_id) {
|
||||
partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
|
||||
await client.query(
|
||||
'update profiles set couple_id = null, updated_at = now() where couple_id = $1 and id <> $2',
|
||||
[profile.couple_id, userId],
|
||||
);
|
||||
await client.query('delete from couples where id = $1', [profile.couple_id]);
|
||||
}
|
||||
if (profile) await client.query('delete from profiles where id = $1', [userId]);
|
||||
|
||||
// Keep local deletion uncommitted until hosted Auth confirms deletion. A provider
|
||||
// failure therefore rolls the entire local transaction back for a safe retry.
|
||||
await deleteAuthUser();
|
||||
return { partnerPushToken: partner?.push_token ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
async setPremium(userId: string, isPremium: boolean): Promise<void> {
|
||||
const result = await this.pool.query(
|
||||
'update profiles set is_premium = $2, updated_at = now() where id = $1',
|
||||
[userId, isPremium],
|
||||
);
|
||||
if (result.rowCount === 0) throw new AccountOperationError('profile_not_found', 'Profile not found', 404);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import type { AccountOperationsService } from './service.js';
|
||||
import { AccountOperationError } from './types.js';
|
||||
|
||||
type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
async function run<T>(operation: () => Promise<T>): Promise<
|
||||
{ ok: true; value: T } |
|
||||
{ ok: false; body: ApiErrorResponse; status: 400 | 404 | 502 | 503 }
|
||||
> {
|
||||
try {
|
||||
return { ok: true, value: await operation() };
|
||||
} catch (cause) {
|
||||
if (cause instanceof AccountOperationError) {
|
||||
return { ok: false, body: error(cause.code, cause.message), status: cause.status };
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAccountOperationRoutes(app: AuthenticatedApp, service: AccountOperationsService): void {
|
||||
app.delete('/v1/couple/data', async (c) => {
|
||||
const result = await run(() => service.deleteRelationship(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
|
||||
app.delete('/v1/couple/progress', async (c) => {
|
||||
const result = await run(() => service.resetProgress(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
|
||||
app.delete('/v1/me', async (c) => {
|
||||
const result = await run(() => service.deleteAccount(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
|
||||
app.post('/v1/me/subscription/sync', async (c) => {
|
||||
const result = await run(() => service.syncSubscription(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AuthAdminClient, PartnerNotifier, RevenueCatClient } from './clients.js';
|
||||
import type { AccountOperationsRepository } from './repository.js';
|
||||
import type { OperationResult, SubscriptionSyncResult } from './types.js';
|
||||
|
||||
export class AccountOperationsService {
|
||||
constructor(
|
||||
private readonly repository: AccountOperationsRepository,
|
||||
private readonly authAdmin: AuthAdminClient,
|
||||
private readonly revenueCat: RevenueCatClient,
|
||||
private readonly notifier: PartnerNotifier,
|
||||
) {}
|
||||
|
||||
async deleteRelationship(userId: string): Promise<OperationResult> {
|
||||
const result = await this.repository.deleteRelationship(userId);
|
||||
if (result.partnerPushToken) {
|
||||
await this.notify(() => this.notifier.relationshipDeleted(result.partnerPushToken!));
|
||||
}
|
||||
return { success: true, message: 'Relationship data deleted successfully' };
|
||||
}
|
||||
|
||||
async resetProgress(userId: string): Promise<OperationResult> {
|
||||
const result = await this.repository.resetProgress(userId);
|
||||
if (result.partnerPushToken) {
|
||||
await this.notify(() => this.notifier.progressReset(result.partnerPushToken!));
|
||||
}
|
||||
return { success: true, message: 'Progress reset successfully' };
|
||||
}
|
||||
|
||||
async deleteAccount(userId: string): Promise<OperationResult> {
|
||||
const result = await this.repository.deleteAccount(userId, () => this.authAdmin.deleteUser(userId));
|
||||
if (result.partnerPushToken) {
|
||||
await this.notify(() => this.notifier.partnerAccountDeleted(result.partnerPushToken!));
|
||||
}
|
||||
return { success: true, message: 'Account deleted successfully' };
|
||||
}
|
||||
|
||||
async syncSubscription(userId: string): Promise<SubscriptionSyncResult> {
|
||||
const isPremium = await this.revenueCat.isEntitled(userId);
|
||||
await this.repository.setPremium(userId, isPremium);
|
||||
return { success: true, is_premium: isPremium };
|
||||
}
|
||||
|
||||
private async notify(operation: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
// Destructive data changes have already committed. Notification delivery is
|
||||
// deliberately best-effort, matching the previous Edge Function behavior.
|
||||
console.error('Partner notification failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export class AccountOperationError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly status: 400 | 404 | 502 | 503,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AccountOperationError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface OperationResult {
|
||||
success: true;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionSyncResult {
|
||||
success: true;
|
||||
is_premium: boolean;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Admin API
|
||||
|
||||
Browser administrators authenticate with hosted Supabase Auth. The API then
|
||||
loads the active `admin_users` row for the token subject and enforces each role
|
||||
permission again server-side. UI role state is never trusted for authorization.
|
||||
|
||||
Non-browser clients can use `ADMIN_API_SERVICE_TOKEN` with
|
||||
`ADMIN_API_SERVICE_USER_ID`. The opaque token is compared in constant time and
|
||||
maps to an explicit active `admin_users` record, so its permissions and audit
|
||||
actor are identical to a browser administrator. It does not grant Supabase
|
||||
service-role access.
|
||||
|
||||
Every generic mutation is transacted with an `audit_logs` write. Sensitive
|
||||
fields are redacted from audit payloads. Admin data routes are resource-
|
||||
whitelisted; arbitrary table names are rejected.
|
||||
|
||||
Central application wiring must exempt `/v1/admin/*` from the general hosted
|
||||
Auth middleware and pass `AdminRequestAuth` to `registerAdminRoutes`, which
|
||||
accepts either hosted JWTs or the configured service token.
|
||||
|
||||
`SupabaseAdminAuthDirectory` may use the existing server-side Auth Admin
|
||||
credential to enrich `/v1/admin/users` with email confirmation and last sign-in
|
||||
timestamps. It talks only to hosted Auth, never the Supabase data plane, and
|
||||
degrades to explicit null metadata when the credential or provider is absent.
|
||||
|
||||
`GET /v1/admin/media/:mediaId/url` requires `view_media` and issues a five-minute
|
||||
signed URL with `private, no-store` response caching. The object must exist and
|
||||
not be marked deleted. The opaque service credential can therefore request a
|
||||
URL without being accepted by customer media authorization.
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
type Fetch = typeof globalThis.fetch;
|
||||
|
||||
const authUser = z.object({
|
||||
id: z.string().uuid(),
|
||||
email: z.string().nullable().optional(),
|
||||
last_sign_in_at: z.string().nullable().optional(),
|
||||
email_confirmed_at: z.string().nullable().optional(),
|
||||
confirmed_at: z.string().nullable().optional(),
|
||||
}).passthrough();
|
||||
|
||||
export type AdminAuthUser = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
last_sign_in_at: string | null;
|
||||
email_confirmed_at: string | null;
|
||||
};
|
||||
|
||||
export interface AdminAuthDirectory {
|
||||
users(userId?: string): Promise<AdminAuthUser[]>;
|
||||
}
|
||||
|
||||
export class SupabaseAdminAuthDirectory implements AdminAuthDirectory {
|
||||
constructor(
|
||||
private readonly authUrl: string | undefined,
|
||||
private readonly serviceRoleKey: string | undefined,
|
||||
private readonly request: Fetch = globalThis.fetch,
|
||||
) {}
|
||||
|
||||
async users(userId?: string): Promise<AdminAuthUser[]> {
|
||||
if (!this.authUrl || !this.serviceRoleKey) return [];
|
||||
const base = this.authUrl.replace(/\/$/, '').replace(/\/auth\/v1$/, '');
|
||||
const url = userId
|
||||
? `${base}/auth/v1/admin/users/${encodeURIComponent(userId)}`
|
||||
: `${base}/auth/v1/admin/users?page=1&per_page=1000`;
|
||||
const response = await this.request(url, {
|
||||
headers: { apikey: this.serviceRoleKey, authorization: `Bearer ${this.serviceRoleKey}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).catch(() => null);
|
||||
if (!response?.ok) return [];
|
||||
const payload = await response.json().catch(() => null);
|
||||
const candidates = userId ? [payload] : z.object({ users: z.array(z.unknown()) }).safeParse(payload).data?.users ?? [];
|
||||
return candidates.flatMap((candidate) => {
|
||||
const parsed = authUser.safeParse(candidate); if (!parsed.success) return [];
|
||||
return [{
|
||||
id: parsed.data.id,
|
||||
email: parsed.data.email ?? null,
|
||||
last_sign_in_at: parsed.data.last_sign_in_at ?? null,
|
||||
email_confirmed_at: parsed.data.email_confirmed_at ?? parsed.data.confirmed_at ?? null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||
import type { AuthIdentity, AuthVerifier } from '../../auth.js';
|
||||
|
||||
export class AdminRequestAuth {
|
||||
private readonly serviceDigest?: Buffer;
|
||||
|
||||
constructor(
|
||||
private readonly hostedAuth: AuthVerifier,
|
||||
serviceToken?: string,
|
||||
private readonly serviceUserId?: string,
|
||||
) {
|
||||
if (Boolean(serviceToken) !== Boolean(serviceUserId)) throw new Error('Admin service token and user ID must be configured together');
|
||||
if (serviceToken && serviceToken.length < 32) throw new Error('Admin service token must contain at least 32 characters');
|
||||
this.serviceDigest = serviceToken ? createHash('sha256').update(serviceToken).digest() : undefined;
|
||||
}
|
||||
|
||||
async verify(token: string): Promise<AuthIdentity> {
|
||||
if (this.serviceDigest && this.serviceUserId) {
|
||||
const candidate = createHash('sha256').update(token).digest();
|
||||
if (timingSafeEqual(candidate, this.serviceDigest)) {
|
||||
return { id: this.serviceUserId, email: null, name: 'Admin API service', avatarUrl: null };
|
||||
}
|
||||
}
|
||||
return this.hostedAuth.verify(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
|
||||
import type { AdminAuthDirectory } from './auth-directory.js';
|
||||
|
||||
type Permission =
|
||||
| 'manage_packs' | 'manage_questions' | 'manage_categories' | 'view_users'
|
||||
| 'view_chats' | 'view_media' | 'view_matches' | 'view_responses' | 'view_activity'
|
||||
| 'manage_feedback' | 'gift_premium' | 'manage_codes' | 'manage_admins'
|
||||
| 'manage_ai_config' | 'manage_app_config' | 'view_audit_logs';
|
||||
|
||||
interface ResourceRule { read: Permission | Permission[]; write?: Permission; idColumn?: string }
|
||||
const resources: Record<string, ResourceRule> = {
|
||||
categories: { read: ['manage_categories', 'manage_packs', 'manage_questions', 'view_activity', 'view_users'], write: 'manage_categories' },
|
||||
question_packs: { read: ['manage_packs', 'manage_questions', 'view_activity', 'view_users'], write: 'manage_packs' },
|
||||
questions: { read: ['manage_questions', 'view_activity', 'view_responses', 'view_matches', 'view_users', 'manage_ai_config'], write: 'manage_questions' },
|
||||
topics: { read: 'manage_packs', write: 'manage_packs' },
|
||||
pack_topics: { read: 'manage_packs', write: 'manage_packs', idColumn: 'pack_id' },
|
||||
profiles: { read: ['view_users', 'view_activity', 'manage_admins', 'manage_feedback', 'manage_ai_config', 'manage_codes'] }, couples: { read: ['view_users', 'view_activity'] },
|
||||
responses: { read: ['view_responses', 'view_activity', 'view_users'] }, matches: { read: ['view_matches', 'view_activity', 'view_users'] },
|
||||
messages: { read: ['view_chats', 'view_activity'], write: 'view_chats' },
|
||||
message_reports: { read: 'view_chats', write: 'view_chats' },
|
||||
feedback: { read: 'manage_feedback', write: 'manage_feedback' },
|
||||
admin_users: { read: 'manage_admins', write: 'manage_admins' },
|
||||
audit_logs: { read: 'view_audit_logs' },
|
||||
redemption_codes: { read: 'manage_codes', write: 'manage_codes' },
|
||||
code_redemptions: { read: 'manage_codes' },
|
||||
ai_config: { read: 'manage_ai_config', write: 'manage_ai_config' },
|
||||
app_config: { read: 'manage_app_config', write: 'manage_app_config' },
|
||||
feature_interests: { read: 'view_users' }, couple_packs: { read: 'view_users' },
|
||||
couple_streaks: { read: 'view_users' }, live_draw_sessions: { read: 'view_users' },
|
||||
subscriptions: { read: 'view_users' },
|
||||
media_objects: { read: 'view_media' },
|
||||
dare_packs: { read: 'manage_packs', write: 'manage_packs' },
|
||||
dares: { read: 'manage_questions', write: 'manage_questions' },
|
||||
};
|
||||
|
||||
function identifier(value: string): string {
|
||||
if (!/^[a-z_][a-z0-9_]*$/.test(value)) throw new AdminError('invalid_identifier', 'Invalid field name', 400);
|
||||
return `"${value}"`;
|
||||
}
|
||||
|
||||
function rule(resource: string): ResourceRule {
|
||||
const value = resources[resource];
|
||||
if (!value) throw new AdminError('unsupported_resource', 'Resource is not available to the admin API', 404);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePermission(principal: AdminPrincipal, permission: Permission): void {
|
||||
if (principal.role !== 'super_admin' && !principal.permissions.includes(permission)) {
|
||||
throw new AdminError('forbidden', 'This administrator lacks the required permission', 403);
|
||||
}
|
||||
}
|
||||
|
||||
function requireAnyPermission(principal: AdminPrincipal, permissions: Permission | Permission[]): void {
|
||||
const choices = Array.isArray(permissions) ? permissions : [permissions];
|
||||
if (principal.role !== 'super_admin' && !choices.some((permission) => principal.permissions.includes(permission))) {
|
||||
throw new AdminError('forbidden', 'This administrator lacks the required permission', 403);
|
||||
}
|
||||
}
|
||||
|
||||
function whereParts(query: AdminQuery): { sql: string; values: unknown[] } {
|
||||
const values: unknown[] = [];
|
||||
const clauses: string[] = [];
|
||||
for (const filter of query.filters ?? []) {
|
||||
const column = identifier(filter.column);
|
||||
const position = values.length + 1;
|
||||
if (filter.op === 'is' && filter.value === null) clauses.push(`${column} is null`);
|
||||
else if (filter.op === 'neq' && filter.value === null) clauses.push(`${column} is not null`);
|
||||
else if (filter.op === 'in') {
|
||||
if (!Array.isArray(filter.value)) throw new AdminError('invalid_filter', 'in filters require an array', 400);
|
||||
values.push(filter.value); clauses.push(`${column} = any($${position})`);
|
||||
} else {
|
||||
const operators = { eq: '=', neq: '<>', gte: '>=', lte: '<=', ilike: 'ilike' } as const;
|
||||
const operator = operators[filter.op as keyof typeof operators];
|
||||
if (!operator) throw new AdminError('invalid_filter', 'Unsupported filter', 400);
|
||||
values.push(filter.value); clauses.push(`${column} ${operator} $${position}`);
|
||||
}
|
||||
}
|
||||
return { sql: clauses.length ? ` where ${clauses.join(' and ')}` : '', values };
|
||||
}
|
||||
|
||||
const restrictedReadColumns: Record<string, ReadonlySet<string>> = {
|
||||
profiles: new Set(['push_token', 'public_key_jwk']),
|
||||
messages: new Set(['encrypted_content', 'encryption_iv', 'keys_metadata']),
|
||||
media_objects: new Set(['storage_key']),
|
||||
};
|
||||
|
||||
function projection(resource: string, columns: string[] | undefined): string {
|
||||
if (!columns) return '*';
|
||||
const restricted = restrictedReadColumns[resource] ?? new Set<string>();
|
||||
if (columns.some((column) => restricted.has(column))) {
|
||||
throw new AdminError('restricted_column', 'The requested field is not available through generic admin queries', 403);
|
||||
}
|
||||
return columns.map(identifier).join(',');
|
||||
}
|
||||
|
||||
function sanitizeRows(resource: string, rows: QueryResultRow[]): QueryResultRow[] {
|
||||
const restricted = restrictedReadColumns[resource];
|
||||
if (!restricted) return rows;
|
||||
return rows.map((row) => Object.fromEntries(Object.entries(row).filter(([column]) => !restricted.has(column))));
|
||||
}
|
||||
|
||||
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try { await client.query('begin'); const value = await operation(client); await client.query('commit'); return value; }
|
||||
catch (error) { await client.query('rollback'); throw error; }
|
||||
finally { client.release(); }
|
||||
}
|
||||
|
||||
function redactAudit(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(redactAudit);
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
||||
key,
|
||||
/(key|secret|token|password|credential)/i.test(key) ? '[REDACTED]' : redactAudit(item),
|
||||
]));
|
||||
}
|
||||
|
||||
export interface AdminRepository {
|
||||
principal(userId: string): Promise<AdminPrincipal>;
|
||||
query(principal: AdminPrincipal, resource: string, query: AdminQuery): Promise<{ rows: Record<string, unknown>[]; count: number }>;
|
||||
insert(principal: AdminPrincipal, resource: string, records: Record<string, unknown>[]): Promise<Record<string, unknown>[]>;
|
||||
update(principal: AdminPrincipal, resource: string, id: string, values: Record<string, unknown>): Promise<Record<string, unknown>>;
|
||||
delete(principal: AdminPrincipal, resource: string, id: string): Promise<void>;
|
||||
dashboard(principal: AdminPrincipal): Promise<Record<string, number>>;
|
||||
giftPremium(principal: AdminPrincipal, userId: string, days: number | null, reason?: string): Promise<{ subscription_id: string; expires_at: string | null }>;
|
||||
featureInterestCounts(principal: AdminPrincipal): Promise<Array<{
|
||||
feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number;
|
||||
}>>;
|
||||
users(principal: AdminPrincipal, userId?: string): Promise<Record<string, unknown>[]>;
|
||||
authorizeMedia(principal: AdminPrincipal, mediaId: string): Promise<void>;
|
||||
decryptMessage(principal: AdminPrincipal, messageId: string): Promise<{ content: string | null }>;
|
||||
decryptMedia(principal: AdminPrincipal, messageId: string): Promise<{ bytes: Buffer; mimeType: string }>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class PostgresAdminRepository implements AdminRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string, private readonly options: {
|
||||
adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory;
|
||||
} = {}) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
|
||||
async principal(userId: string): Promise<AdminPrincipal> {
|
||||
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
|
||||
'select id, user_id, role, permissions from admin_users where user_id=$1 and is_active=true', [userId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new AdminError('admin_access_denied', 'Administrator access denied', 403);
|
||||
return { adminId: row.id, userId: row.user_id, role: row.role, permissions: row.permissions ?? [] };
|
||||
}
|
||||
|
||||
async query(principal: AdminPrincipal, resource: string, query: AdminQuery) {
|
||||
requireAnyPermission(principal, rule(resource).read);
|
||||
const where = whereParts(query);
|
||||
const table = identifier(resource);
|
||||
const selected = projection(resource, query.columns);
|
||||
let pageSql = where.sql;
|
||||
if (query.order) pageSql += ` order by ${identifier(query.order.column)} ${query.order.ascending === false ? 'desc' : 'asc'}`;
|
||||
const pageValues = [...where.values, Math.min(Math.max(query.limit ?? 100, 1), 500)];
|
||||
pageSql += ` limit $${pageValues.length}`;
|
||||
if (query.offset) { pageValues.push(Math.max(query.offset, 0)); pageSql += ` offset $${pageValues.length}`; }
|
||||
const [rows, count] = await Promise.all([
|
||||
this.pool.query<QueryResultRow>(`select ${selected} from ${table}${pageSql}`, pageValues),
|
||||
this.pool.query<{ count: string }>(`select count(*)::text count from ${table}${where.sql}`, where.values),
|
||||
]);
|
||||
return { rows: sanitizeRows(resource, rows.rows), count: Number(count.rows[0]?.count ?? 0) };
|
||||
}
|
||||
|
||||
async insert(principal: AdminPrincipal, resource: string, records: Record<string, unknown>[]) {
|
||||
const permission = rule(resource).write;
|
||||
if (!permission) throw new AdminError('forbidden', 'Resource is read-only', 403);
|
||||
requirePermission(principal, permission);
|
||||
if (!records.length) throw new AdminError('invalid_records', 'At least one record is required', 400);
|
||||
return transaction(this.pool, async (client) => {
|
||||
const inserted: Record<string, unknown>[] = [];
|
||||
for (const record of records) {
|
||||
const keys = Object.keys(record); if (!keys.length) throw new AdminError('invalid_record', 'Record cannot be empty', 400);
|
||||
const result = await client.query<QueryResultRow>(
|
||||
`insert into ${identifier(resource)} (${keys.map(identifier).join(',')}) values (${keys.map((_, index) => `$${index + 1}`).join(',')}) returning *`,
|
||||
keys.map((key) => record[key]),
|
||||
);
|
||||
const row = result.rows[0]!; inserted.push(row);
|
||||
await this.audit(client, principal, resource, 'INSERT', String(row.id ?? row[rule(resource).idColumn ?? 'id'] ?? ''), null, row);
|
||||
}
|
||||
return inserted;
|
||||
});
|
||||
}
|
||||
|
||||
async update(principal: AdminPrincipal, resource: string, id: string, values: Record<string, unknown>) {
|
||||
const resourceRule = rule(resource); if (!resourceRule.write) throw new AdminError('forbidden', 'Resource is read-only', 403);
|
||||
requirePermission(principal, resourceRule.write);
|
||||
const keys = Object.keys(values); if (!keys.length) throw new AdminError('invalid_record', 'Updates cannot be empty', 400);
|
||||
const idColumn = resourceRule.idColumn ?? 'id';
|
||||
return transaction(this.pool, async (client) => {
|
||||
const old = await client.query<QueryResultRow>(`select * from ${identifier(resource)} where ${identifier(idColumn)}=$1 for update`, [id]);
|
||||
if (!old.rows.length) throw new AdminError('not_found', 'Record not found', 404);
|
||||
const updated = await client.query<QueryResultRow>(
|
||||
`update ${identifier(resource)} set ${keys.map((key, index) => `${identifier(key)}=$${index + 2}`).join(',')} where ${identifier(idColumn)}=$1 returning *`,
|
||||
[id, ...keys.map((key) => values[key])],
|
||||
);
|
||||
for (let index = 0; index < updated.rows.length; index += 1) {
|
||||
const oldRow = old.rows[index]; const updatedRow = updated.rows[index]!;
|
||||
await this.audit(client, principal, resource, 'UPDATE', String(updatedRow.id ?? id), oldRow, updatedRow);
|
||||
}
|
||||
return updated.rows[0]!;
|
||||
});
|
||||
}
|
||||
|
||||
async delete(principal: AdminPrincipal, resource: string, id: string): Promise<void> {
|
||||
const resourceRule = rule(resource); if (!resourceRule.write) throw new AdminError('forbidden', 'Resource is read-only', 403);
|
||||
requirePermission(principal, resourceRule.write);
|
||||
const idColumn = resourceRule.idColumn ?? 'id';
|
||||
await transaction(this.pool, async (client) => {
|
||||
const deleted = await client.query<QueryResultRow>(`delete from ${identifier(resource)} where ${identifier(idColumn)}=$1 returning *`, [id]);
|
||||
if (!deleted.rows.length) throw new AdminError('not_found', 'Record not found', 404);
|
||||
for (const row of deleted.rows) {
|
||||
await this.audit(client, principal, resource, 'DELETE', String(row.id ?? id), row, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async dashboard(principal: AdminPrincipal): Promise<Record<string, number>> {
|
||||
const result = await this.pool.query<{ categories: number; packs: number; questions: number; users: number }>(
|
||||
'select (select count(*)::int from categories) categories, (select count(*)::int from question_packs) packs, (select count(*)::int from questions) questions, (select count(*)::int from profiles) users',
|
||||
);
|
||||
return result.rows[0] ?? { categories: 0, packs: 0, questions: 0, users: 0 };
|
||||
}
|
||||
|
||||
async giftPremium(principal: AdminPrincipal, userId: string, days: number | null, reason?: string) {
|
||||
requirePermission(principal, 'gift_premium');
|
||||
return transaction(this.pool, async (client) => {
|
||||
const profile = await client.query('select id from profiles where id=$1 for update', [userId]);
|
||||
if (!profile.rows[0]) throw new AdminError('user_not_found', 'User not found', 404);
|
||||
const expiresAt = days === null ? null : new Date(Date.now() + days * 86_400_000);
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`insert into subscriptions(user_id,revenuecat_app_user_id,product_id,status,entitlement_ids,purchased_at,expires_at,store)
|
||||
values($1,$2,'admin_premium','active',array['premium'],now(),$3,'manual') returning id`,
|
||||
[userId, `admin_grant:${userId}`, expiresAt],
|
||||
);
|
||||
await client.query('update profiles set is_premium=true,updated_at=now() where id=$1', [userId]);
|
||||
await client.query(
|
||||
`insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,new_values)
|
||||
values($1,$2,'subscriptions','ACTION',$3,$4)`,
|
||||
[principal.adminId, principal.userId, inserted.rows[0]!.id, { action: 'gift_premium', target_user_id: userId, days, reason: reason ?? null, expires_at: expiresAt?.toISOString() ?? null }],
|
||||
);
|
||||
return { subscription_id: inserted.rows[0]!.id, expires_at: expiresAt?.toISOString() ?? null };
|
||||
});
|
||||
}
|
||||
|
||||
async featureInterestCounts(principal: AdminPrincipal) {
|
||||
requirePermission(principal, 'view_users');
|
||||
const result = await this.pool.query<{
|
||||
feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number;
|
||||
}>(`select feature as feature_name,
|
||||
count(*)::int as opt_in_count,
|
||||
count(*) filter (where created_at >= now() - interval '7 days')::int as opt_in_count_last_7_days
|
||||
from feature_interests group by feature order by feature`);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async users(principal: AdminPrincipal, userId?: string) {
|
||||
requirePermission(principal, 'view_users');
|
||||
const result = await this.pool.query<QueryResultRow & { id: string }>(
|
||||
`select p.*,coalesce(sum(mo.byte_size) filter(where mo.deleted_at is null),0)::bigint::text as storage_bytes
|
||||
from profiles p left join media_objects mo on mo.owner_id=p.id
|
||||
${userId ? 'where p.id=$1' : ''} group by p.id order by p.created_at desc limit 500`,
|
||||
userId ? [userId] : [],
|
||||
);
|
||||
const authUsers = await this.options.authDirectory?.users(userId).catch(() => []) ?? [];
|
||||
const authById = new Map(authUsers.map((user) => [user.id, user]));
|
||||
return result.rows.map((profile) => {
|
||||
const authUser = authById.get(profile.id);
|
||||
return {
|
||||
...profile,
|
||||
email: authUser?.email ?? profile.email ?? null,
|
||||
last_sign_in_at: authUser?.last_sign_in_at ?? null,
|
||||
email_confirmed_at: authUser?.email_confirmed_at ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async authorizeMedia(principal: AdminPrincipal, mediaId: string): Promise<void> {
|
||||
requirePermission(principal, 'view_media');
|
||||
const result = await this.pool.query('select 1 from media_objects where id=$1 and deleted_at is null', [mediaId]);
|
||||
if (!result.rows[0]) throw new AdminError('media_not_found', 'Media not found', 404);
|
||||
}
|
||||
|
||||
private async messageForDecryption(messageId: string) {
|
||||
const result = await this.pool.query<{
|
||||
content: string | null; encrypted_content: string | null; encryption_iv: string | null;
|
||||
keys_metadata: { admin_wrapped_key?: string } | null; version: number; media_path: string | null;
|
||||
storage_key: string | null; mime_type: string | null;
|
||||
}>(
|
||||
`select m.content,m.encrypted_content,m.encryption_iv,m.keys_metadata,m.version,m.media_path,mo.storage_key,mo.mime_type
|
||||
from messages m left join media_objects mo on m.media_path='media:'||mo.id::text where m.id=$1`, [messageId],
|
||||
);
|
||||
const row = result.rows[0]; if (!row) throw new AdminError('message_not_found', 'Message not found', 404); return row;
|
||||
}
|
||||
|
||||
private async messageKey(row: { keys_metadata: { admin_wrapped_key?: string } | null }): Promise<CryptoKey> {
|
||||
const wrapped = row.keys_metadata?.admin_wrapped_key;
|
||||
if (!wrapped || !this.options.adminPrivateKeyJwk) throw new AdminError('decryption_unavailable', 'Admin decryption key is unavailable', 403);
|
||||
const privateKey = await crypto.subtle.importKey('jwk', JSON.parse(this.options.adminPrivateKeyJwk) as JsonWebKey, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']);
|
||||
const raw = await crypto.subtle.decrypt({ name: 'RSA-OAEP' }, privateKey, Buffer.from(wrapped, 'base64'));
|
||||
return crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['decrypt']);
|
||||
}
|
||||
|
||||
async decryptMessage(principal: AdminPrincipal, messageId: string) {
|
||||
requirePermission(principal, 'view_chats');
|
||||
const row = await this.messageForDecryption(messageId);
|
||||
if (row.version !== 2 || !row.encrypted_content) return { content: row.content };
|
||||
if (!row.encryption_iv) throw new AdminError('invalid_encrypted_message', 'Message encryption metadata is incomplete', 409);
|
||||
const key = await this.messageKey(row);
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(row.encryption_iv, 'base64') }, key, Buffer.from(row.encrypted_content, 'base64'));
|
||||
return { content: new TextDecoder().decode(plain) };
|
||||
}
|
||||
|
||||
async decryptMedia(principal: AdminPrincipal, messageId: string) {
|
||||
requirePermission(principal, 'view_media');
|
||||
const row = await this.messageForDecryption(messageId);
|
||||
if (!row.storage_key || !row.mime_type) throw new AdminError('media_not_found', 'Message media not found', 404);
|
||||
const root = resolve(this.options.mediaRoot ?? '/data/media'); const path = resolve(root, row.storage_key);
|
||||
if (!path.startsWith(`${root}${sep}`)) throw new AdminError('invalid_media_path', 'Invalid media path', 400);
|
||||
let bytes = await readFile(path);
|
||||
if (row.version === 2) {
|
||||
if (!row.encryption_iv) throw new AdminError('invalid_encrypted_message', 'Message encryption metadata is incomplete', 409);
|
||||
const key = await this.messageKey(row);
|
||||
bytes = Buffer.from(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(row.encryption_iv, 'base64') }, key, bytes));
|
||||
}
|
||||
return { bytes, mimeType: row.mime_type };
|
||||
}
|
||||
|
||||
private async audit(client: PoolClient, principal: AdminPrincipal, table: string, action: 'INSERT' | 'UPDATE' | 'DELETE', recordId: string, oldValues: unknown, newValues: unknown) {
|
||||
await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]);
|
||||
}
|
||||
|
||||
async close(): Promise<void> { await this.pool.end(); }
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { bearerToken } from '../../auth.js';
|
||||
import type { AdminRequestAuth } from './auth.js';
|
||||
import type { AdminRepository } from './repository.js';
|
||||
import { AdminError } from './types.js';
|
||||
import type { FilesystemMediaStorage } from '../media/storage.js';
|
||||
|
||||
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
const resource = z.string().regex(/^[a-z_][a-z0-9_]*$/);
|
||||
const queryBody = z.object({
|
||||
columns: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/)).min(1).max(100).optional(),
|
||||
filters: z.array(z.object({ column: z.string(), op: z.enum(['eq', 'neq', 'in', 'is', 'gte', 'lte', 'ilike']), value: z.unknown() })).optional(),
|
||||
order: z.object({ column: z.string(), ascending: z.boolean().optional() }).optional(),
|
||||
limit: z.number().int().positive().max(500).optional(), offset: z.number().int().nonnegative().optional(),
|
||||
}).strict();
|
||||
const insertBody = z.object({ records: z.array(z.record(z.unknown())).min(1) }).strict();
|
||||
const updateBody = z.object({ values: z.record(z.unknown()) }).strict();
|
||||
|
||||
function failure(code: string, message: string): ApiErrorResponse { return { error: { code, message } }; }
|
||||
async function execute<T>(operation: () => Promise<T>) {
|
||||
try { return { ok: true as const, value: await operation() }; }
|
||||
catch (cause) { if (cause instanceof AdminError) return { ok: false as const, cause }; throw cause; }
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app: App, repository: AdminRepository, auth?: AdminRequestAuth, mediaStorage?: FilesystemMediaStorage): void {
|
||||
if (auth) {
|
||||
app.use('/v1/admin/*', async (c, next) => {
|
||||
const token = bearerToken(c.req.header('authorization'));
|
||||
if (!token) return c.json(failure('unauthorized', 'A bearer token is required'), 401);
|
||||
try { c.set('identity', await auth.verify(token)); }
|
||||
catch { return c.json(failure('unauthorized', 'The bearer token is invalid'), 401); }
|
||||
await next();
|
||||
});
|
||||
}
|
||||
app.get('/v1/admin/me', async (c) => {
|
||||
const result = await execute(() => repository.principal(c.get('identity').id));
|
||||
return result.ok ? c.json({ role: result.value.role, permissions: result.value.permissions }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/admin/query/:resource', async (c) => {
|
||||
const parsedResource = resource.safeParse(c.req.param('resource'));
|
||||
const parsedBody = queryBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_query', 'Invalid admin query'), 400);
|
||||
const result = await execute(async () => repository.query(await repository.principal(c.get('identity').id), parsedResource.data, parsedBody.data));
|
||||
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/admin/data/:resource', async (c) => {
|
||||
const parsedResource = resource.safeParse(c.req.param('resource'));
|
||||
const parsedBody = insertBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
|
||||
const result = await execute(async () => repository.insert(await repository.principal(c.get('identity').id), parsedResource.data, parsedBody.data.records));
|
||||
return result.ok ? c.json({ rows: result.value }, 201) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.patch('/v1/admin/data/:resource/:id', async (c) => {
|
||||
const parsedResource = resource.safeParse(c.req.param('resource'));
|
||||
const parsedBody = updateBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
|
||||
const result = await execute(async () => repository.update(await repository.principal(c.get('identity').id), parsedResource.data, c.req.param('id'), parsedBody.data.values));
|
||||
return result.ok ? c.json({ row: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.delete('/v1/admin/data/:resource/:id', async (c) => {
|
||||
const parsedResource = resource.safeParse(c.req.param('resource'));
|
||||
if (!parsedResource.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
|
||||
const result = await execute(async () => repository.delete(await repository.principal(c.get('identity').id), parsedResource.data, c.req.param('id')));
|
||||
return result.ok ? c.json({ deleted: true }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/admin/dashboard', async (c) => {
|
||||
const result = await execute(async () => repository.dashboard(await repository.principal(c.get('identity').id)));
|
||||
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/admin/users', async (c) => {
|
||||
const result = await execute(async () => repository.users(await repository.principal(c.get('identity').id), c.req.query('userId')));
|
||||
return result.ok ? c.json({ users: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/admin/feature-interest-counts', async (c) => {
|
||||
const result = await execute(async () => repository.featureInterestCounts(await repository.principal(c.get('identity').id)));
|
||||
return result.ok ? c.json({ counts: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/admin/media/:mediaId/url', async (c) => {
|
||||
const parsed = z.string().uuid().safeParse(c.req.param('mediaId'));
|
||||
if (!parsed.success) return c.json(failure('invalid_media_id', 'A valid media ID is required'), 400);
|
||||
if (!mediaStorage) return c.json(failure('media_unavailable', 'Media storage is unavailable'), 503);
|
||||
const result = await execute(async () => {
|
||||
await repository.authorizeMedia(await repository.principal(c.get('identity').id), parsed.data);
|
||||
return mediaStorage.signedUrl(parsed.data, 300);
|
||||
});
|
||||
return result.ok ? c.json(result.value, 200, { 'cache-control': 'private, no-store' }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/admin/users/:userId/gift-premium', async (c) => {
|
||||
const parsed = z.object({ days: z.number().int().min(1).max(3650).nullable(), reason: z.string().trim().max(500).optional() }).strict()
|
||||
.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) return c.json(failure('invalid_gift', 'A valid premium duration is required'), 400);
|
||||
const result = await execute(async () => repository.giftPremium(await repository.principal(c.get('identity').id), c.req.param('userId'), parsed.data.days, parsed.data.reason));
|
||||
return result.ok ? c.json(result.value, 201) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/admin/actions/decrypt-message', async (c) => {
|
||||
const parsed = z.object({ messageId: z.string().uuid() }).strict().safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) return c.json(failure('invalid_message_id', 'A valid message ID is required'), 400);
|
||||
const result = await execute(async () => repository.decryptMessage(await repository.principal(c.get('identity').id), parsed.data.messageId));
|
||||
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/admin/actions/decrypt-media', async (c) => {
|
||||
const parsed = z.object({ messageId: z.string().uuid() }).strict().safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!parsed.success) return c.json(failure('invalid_message_id', 'A valid message ID is required'), 400);
|
||||
const result = await execute(async () => repository.decryptMedia(await repository.principal(c.get('identity').id), parsed.data.messageId));
|
||||
if (!result.ok) return c.json(failure(result.cause.code, result.cause.message), result.cause.status);
|
||||
return c.body(new Uint8Array(result.value.bytes), 200, { 'content-type': result.value.mimeType, 'cache-control': 'private, no-store' });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export type AdminRole = 'pack_creator' | 'super_admin';
|
||||
|
||||
export interface AdminPrincipal {
|
||||
adminId: string;
|
||||
userId: string;
|
||||
role: AdminRole;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export type AdminFilterOperator = 'eq' | 'neq' | 'in' | 'is' | 'gte' | 'lte' | 'ilike';
|
||||
export interface AdminFilter { column: string; op: AdminFilterOperator; value?: unknown }
|
||||
export interface AdminQuery {
|
||||
columns?: string[];
|
||||
filters?: AdminFilter[];
|
||||
order?: { column: string; ascending?: boolean };
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export class AdminError extends Error {
|
||||
constructor(public readonly code: string, message: string, public readonly status: 400 | 403 | 404 | 409) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Pool, type PoolClient } from 'pg';
|
||||
import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js';
|
||||
|
||||
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
|
||||
interface QuestionRow { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; question_type: QuestionType; config: Record<string, unknown>; created_at: string }
|
||||
interface ResponseRow { id: string; user_id: string; question_id: string; couple_id: string; answer: Answer; response_data: ResponseData; created_at: string }
|
||||
interface MatchRow { id: string; couple_id: string; question_id: string; match_type: MatchType; is_new: boolean; response_summary: Record<string, unknown> | null; created_at: string }
|
||||
|
||||
export interface SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
|
||||
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
|
||||
|
||||
export interface AnswersRepository {
|
||||
recommended(userId: string, packId?: string): Promise<QuestionRow[]>;
|
||||
pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string): Promise<Array<{ id: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } }; partnerAnsweredAt: string }>>;
|
||||
answerGap(userId: string): Promise<{ unanswered_by_partner: number; threshold: number; is_blocked: boolean }>;
|
||||
dailyLimit(userId: string): Promise<{ responses_today: number; limit_value: number; remaining: number; reset_at: string | null; is_blocked: boolean }>;
|
||||
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
|
||||
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
|
||||
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
|
||||
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
|
||||
markSeen(userId: string, ids: string[]): Promise<void>;
|
||||
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
|
||||
streak(userId: string): Promise<Record<string, unknown> | null>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class PostgresAnswersRepository implements AnswersRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
|
||||
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
|
||||
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
|
||||
const row = result.rows[0];
|
||||
if (!row?.couple_id) throw new AnswersError('no_couple', 409, 'Join a couple before answering questions');
|
||||
return row;
|
||||
}
|
||||
|
||||
async recommended(userId: string, packId?: string): Promise<QuestionRow[]> {
|
||||
const ctx = await this.context(this.pool, userId);
|
||||
const partner = await this.pool.query<ContextRow & { id: string }>('select id, couple_id, gender, max_intensity, is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1', [ctx.couple_id, userId]);
|
||||
const p = partner.rows[0];
|
||||
const config = await this.config();
|
||||
if (!packId && p && (await this.answerGap(userId)).is_blocked) return [];
|
||||
if (!packId && !ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
|
||||
const genders = ctx.gender && p?.gender ? [ctx.gender, p.gender].sort().join('+') : null;
|
||||
const maxIntensity = config.couple_intensity_gate_enabled && p ? Math.min(ctx.max_intensity, p.max_intensity) : ctx.max_intensity;
|
||||
const result = await this.pool.query<QuestionRow>(`
|
||||
with configured as (select exists(select 1 from couple_packs where couple_id=$2) value),
|
||||
active as (
|
||||
select $3::uuid pack_id where $3::uuid is not null
|
||||
union all select cp.pack_id from couple_packs cp, configured c where $3::uuid is null and cp.couple_id=$2 and cp.enabled and c.value
|
||||
union all select qp.id from question_packs qp, configured c where $3::uuid is null and qp.is_public and not c.value
|
||||
)
|
||||
select q.id,q.pack_id,
|
||||
case when q.partner_text is not null and pr.id is not null then q.partner_text else q.text end text,
|
||||
q.partner_text,q.intensity,q.allowed_couple_genders,q.target_user_genders,q.question_type,q.config,q.created_at
|
||||
from questions q join active a on a.pack_id=q.pack_id join question_packs qp on qp.id=q.pack_id
|
||||
left join responses pr on pr.question_id=q.id and pr.couple_id=$2 and pr.user_id<>$1
|
||||
where q.deleted_at is null and not exists(select 1 from responses r where r.user_id=$1 and r.question_id=q.id)
|
||||
and (not qp.is_premium or $4)
|
||||
and (q.allowed_couple_genders is null or $5::text is null or $5=any(q.allowed_couple_genders))
|
||||
and (q.target_user_genders is null or $6=any(q.target_user_genders) or pr.id is not null)
|
||||
and q.intensity <= $7
|
||||
order by (pr.id is not null) desc,q.intensity,q.id`,
|
||||
[userId, ctx.couple_id, packId ?? null, ctx.is_premium || !!p?.is_premium, genders, ctx.gender, maxIntensity]);
|
||||
return result.rows.map(this.dates);
|
||||
}
|
||||
|
||||
async pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string) {
|
||||
const ctx = await this.context(this.pool, userId);
|
||||
const result = await this.pool.query<{ id: string; created_at: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } } }>(`
|
||||
select r.id,r.created_at,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,
|
||||
'intensity',q.intensity,'allowed_couple_genders',q.allowed_couple_genders,'target_user_genders',q.target_user_genders,
|
||||
'question_type',q.question_type,'config',q.config,'created_at',q.created_at,
|
||||
'pack',json_build_object('id',qp.id,'name',qp.name,'icon',qp.icon)) question
|
||||
from responses r join questions q on q.id=r.question_id join question_packs qp on qp.id=q.pack_id
|
||||
where r.couple_id=$2 and r.user_id ${direction === 'partner' ? '<>' : '='} $1 and q.deleted_at is null
|
||||
and not exists(select 1 from responses other where other.question_id=r.question_id and other.user_id ${direction === 'partner' ? '=' : '<>'} $1 and other.couple_id=$2)
|
||||
order by case when r.question_id=$3 then 0 else 1 end,r.created_at ${direction === 'partner' ? 'asc' : 'desc'}`,
|
||||
[userId, ctx.couple_id, startQuestionId ?? null]);
|
||||
return result.rows.map(row => ({ id: row.id, question: row.question, partnerAnsweredAt: new Date(row.created_at).toISOString() }));
|
||||
}
|
||||
|
||||
private async config(client: Pool | PoolClient = this.pool) {
|
||||
const result = await client.query<{ answer_gap_threshold: number; daily_response_limit: number; couple_intensity_gate_enabled: boolean }>('select answer_gap_threshold,daily_response_limit,couple_intensity_gate_enabled from app_config limit 1');
|
||||
return result.rows[0] ?? { answer_gap_threshold: 10, daily_response_limit: 0, couple_intensity_gate_enabled: false };
|
||||
}
|
||||
|
||||
async answerGap(userId: string) {
|
||||
let ctx: ContextRow; try { ctx = await this.context(this.pool, userId); } catch { return { unanswered_by_partner: 0, threshold: 0, is_blocked: false }; }
|
||||
const cfg = await this.config();
|
||||
const partner = await this.pool.query<{ id: string; max_intensity: number }>('select id,max_intensity from profiles where couple_id=$1 and id<>$2 order by id limit 1', [ctx.couple_id, userId]);
|
||||
if (!partner.rows[0] || cfg.answer_gap_threshold === 0) return { unanswered_by_partner: 0, threshold: cfg.answer_gap_threshold, is_blocked: false };
|
||||
const gate = cfg.couple_intensity_gate_enabled ? Math.min(ctx.max_intensity, partner.rows[0].max_intensity) : 5;
|
||||
const counts = await this.pool.query<{ mine: number; theirs: number }>(`
|
||||
with enabled as(select pack_id from couple_packs where couple_id=$2 and enabled), eligible as(
|
||||
select r.* from responses r join questions q on q.id=r.question_id
|
||||
where r.couple_id=$2 and exists(select 1 from enabled) and q.pack_id in(select pack_id from enabled) and q.intensity <= $4)
|
||||
select count(*) filter(where e.user_id=$1 and not exists(select 1 from eligible x where x.question_id=e.question_id and x.user_id=$3))::int mine,
|
||||
count(*) filter(where e.user_id=$3 and not exists(select 1 from eligible x where x.question_id=e.question_id and x.user_id=$1))::int theirs from eligible e`,
|
||||
[userId, ctx.couple_id, partner.rows[0].id, gate]);
|
||||
const net = Math.max(0, (counts.rows[0]?.mine ?? 0) - (counts.rows[0]?.theirs ?? 0));
|
||||
return { unanswered_by_partner: net, threshold: cfg.answer_gap_threshold, is_blocked: net >= cfg.answer_gap_threshold };
|
||||
}
|
||||
|
||||
async dailyLimit(userId: string) {
|
||||
return this.dailyLimitWith(this.pool,userId);
|
||||
}
|
||||
|
||||
async submit(userId: string, input: SubmitInput) {
|
||||
return this.tx(async client => {
|
||||
// Profile lock serializes a user's daily-limit count across different questions.
|
||||
const ctx = await this.lockedContext(client, userId);
|
||||
// Question lock serializes both partners so the second transaction observes
|
||||
// the first committed response and creates exactly one match.
|
||||
const q = await this.question(client, input.questionId, true);
|
||||
await this.assertEligible(client,userId,ctx,q);
|
||||
const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]);
|
||||
if (!existing.rowCount) {
|
||||
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, reset_at: limit.reset_at });
|
||||
}
|
||||
const response = await client.query<ResponseRow>(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5)
|
||||
on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]);
|
||||
const match = await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,input.responseData ?? null,true);
|
||||
await this.touchStreak(client,userId,ctx.couple_id!);
|
||||
return { response: this.dates(response.rows[0]), match: match ? { ...this.dates(match), question: this.dates(q) } : null };
|
||||
});
|
||||
}
|
||||
|
||||
async update(userId: string, input: UpdateInput) {
|
||||
return this.tx(async client => {
|
||||
const ctx = await this.lockedContext(client,userId); const q=await this.question(client,input.questionId,true);
|
||||
const current=await client.query<ResponseRow>('select * from responses where user_id=$1 and question_id=$2 for update',[userId,input.questionId]);
|
||||
if (!current.rows[0]) throw new AnswersError('response_not_found',404,'No existing response found for this question');
|
||||
if (current.rows[0].answer===input.answer && typeof input.responseData==='undefined') return { success:true,message:'Answer unchanged' };
|
||||
const existing=await client.query<MatchRow>('select * from matches where couple_id=$1 and question_id=$2 for update',[ctx.couple_id,input.questionId]);
|
||||
if (input.answer==='no' && existing.rows[0] && !input.confirmDeleteMatch) {
|
||||
const count=await client.query<{ count:number }>('select count(*)::int count from messages where match_id=$1',[existing.rows[0].id]);
|
||||
return { success:false,requires_confirmation:true,match_id:existing.rows[0].id,message_count:count.rows[0]?.count ?? 0 };
|
||||
}
|
||||
const normalized=q.question_type!=='swipe' && input.answer!=='no' ? (typeof input.responseData==='undefined' ? current.rows[0].response_data : input.responseData) : null;
|
||||
const before=existing.rows[0]; const match=await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,normalized,false);
|
||||
await client.query('update responses set answer=$3,response_data=$4 where user_id=$1 and question_id=$2',[userId,input.questionId,input.answer,normalized]);
|
||||
await this.touchStreak(client,userId,ctx.couple_id!);
|
||||
return { success:true, ...(before&&!match?{match_deleted:true}:{ }), ...(!before&&match?{new_match:this.dates(match)}:{}), ...(before&&match?{match_type_updated:true}:{}) };
|
||||
});
|
||||
}
|
||||
|
||||
private async reconcileMatch(client:PoolClient,userId:string,coupleId:string,q:QuestionRow,answer:Answer,data:ResponseData,isNew:boolean) {
|
||||
const partner=await client.query<ResponseRow>('select * from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[coupleId,q.id,userId]);
|
||||
if(!partner.rows[0]) return null;
|
||||
const type=calculateMatchType(q,answer,partner.rows[0].answer);
|
||||
if(!type){ await client.query('delete from matches where couple_id=$1 and question_id=$2',[coupleId,q.id]); return null; }
|
||||
const summary=type==='both_answered'?{[userId]:data,[partner.rows[0].user_id]:partner.rows[0].response_data}:null;
|
||||
const result=await client.query<MatchRow>(`insert into matches(couple_id,question_id,match_type,is_new,response_summary) values($1,$2,$3,true,$4)
|
||||
on conflict(couple_id,question_id) do update set match_type=excluded.match_type,response_summary=excluded.response_summary,is_new=case when $5 then true else matches.is_new end returning *`,[coupleId,q.id,type,summary,isNew]);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
private async touchStreak(client:PoolClient,userId:string,coupleId:string){
|
||||
const users=await client.query<{id:string}>('select id from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
|
||||
const user1=users.rows[0].id===userId;
|
||||
await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]);
|
||||
const locked=await client.query<{current_streak:number;longest_streak:number;last_active_date:string|null;last_completed_date:string|null;user1_answered_today:boolean;user2_answered_today:boolean}>(`select current_streak,longest_streak,last_active_date::text,last_completed_date::text,user1_answered_today,user2_answered_today from couple_streaks where couple_id=$1 for update`,[coupleId]);
|
||||
const row=locked.rows[0]; const todayResult=await client.query<{today:string;yesterday:string}>('select current_date::text today,(current_date-1)::text yesterday'); const {today,yesterday}=todayResult.rows[0];
|
||||
let first=row.last_active_date===today?row.user1_answered_today:false; let second=row.last_active_date===today?row.user2_answered_today:false;
|
||||
if(user1)first=true;else second=true;
|
||||
let current=row.last_completed_date && row.last_completed_date!==yesterday && row.last_completed_date!==today?0:row.current_streak; let completed=row.last_completed_date;
|
||||
if(first&&second&&completed!==today){current=completed===yesterday?current+1:1;completed=today;}
|
||||
await client.query(`update couple_streaks set current_streak=$2,longest_streak=greatest(longest_streak,$2),last_active_date=$3,last_completed_date=$4,user1_answered_today=$5,user2_answered_today=$6,updated_at=now() where couple_id=$1`,[coupleId,current,today,completed,first,second]);
|
||||
}
|
||||
|
||||
async responses(userId:string,page:number,limit:number){
|
||||
const ctx=await this.context(this.pool,userId); const offset=page*limit;
|
||||
const [rows,count]=await Promise.all([
|
||||
this.pool.query(`select r.id,r.question_id,r.answer,r.response_data,r.created_at,
|
||||
json_build_object('id',q.id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'pack_id',q.pack_id,'question_type',q.question_type,'config',q.config,'created_at',q.created_at,'pack',json_build_object('id',qp.id,'name',qp.name,'icon',qp.icon)) question,
|
||||
m.id is not null has_match,m.id match_id,pr.id is not null partner_answered
|
||||
from responses r join questions q on q.id=r.question_id join question_packs qp on qp.id=q.pack_id left join matches m on m.couple_id=r.couple_id and m.question_id=r.question_id left join responses pr on pr.couple_id=r.couple_id and pr.question_id=r.question_id and pr.user_id<>$1 where r.user_id=$1 and r.couple_id=$2 order by r.created_at desc limit $3 offset $4`,[userId,ctx.couple_id,limit,offset]),
|
||||
this.pool.query<{count:number}>('select count(*)::int count from responses where user_id=$1 and couple_id=$2',[userId,ctx.couple_id])]);
|
||||
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
||||
}
|
||||
|
||||
async matches(userId:string,page:number,limit:number,archived:boolean){
|
||||
const ctx=await this.context(this.pool,userId); const offset=page*limit;
|
||||
const params=[userId,ctx.couple_id,archived,limit,offset];
|
||||
const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
|
||||
const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]);
|
||||
return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
||||
}
|
||||
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
|
||||
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
|
||||
async streak(userId:string){const ctx=await this.context(this.pool,userId);const r=await this.pool.query('select * from couple_streaks where couple_id=$1',[ctx.couple_id]);return r.rows[0]?this.dates(r.rows[0]):null;}
|
||||
private async question(client:PoolClient,id:string,lock=false){const r=await client.query<QuestionRow>(`select * from questions where id=$1 and deleted_at is null${lock?' for update':''}`,[id]);if(!r.rows[0])throw new AnswersError('question_not_found',404,'Question was not found');return r.rows[0];}
|
||||
private async lockedContext(client:PoolClient,userId:string){await client.query('select id from profiles where id=$1 for update',[userId]);return this.context(client,userId);}
|
||||
private async dailyLimitWith(client:Pool|PoolClient,userId:string){
|
||||
const cfg=await this.config(client);if(cfg.daily_response_limit===0)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
|
||||
const premium=await client.query<{premium:boolean}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium',[userId]);if(premium.rows[0]?.premium)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
|
||||
const r=await client.query<{count:number;reset_at:string}>("select count(*)::int count,((date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')+interval '1 day') reset_at from responses where user_id=$1 and created_at>=(date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')",[userId]);const count=r.rows[0]?.count??0;return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:new Date(r.rows[0].reset_at).toISOString(),is_blocked:count>=cfg.daily_response_limit};
|
||||
}
|
||||
private async assertEligible(client:PoolClient,userId:string,ctx:ContextRow,q:QuestionRow){
|
||||
const partner=await client.query<ContextRow&{id:string}>('select id,couple_id,gender,max_intensity,is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1',[ctx.couple_id,userId]);const p=partner.rows[0];
|
||||
const cfg=await this.config(client);const max=cfg.couple_intensity_gate_enabled&&p?Math.min(ctx.max_intensity,p.max_intensity):ctx.max_intensity;const genders=ctx.gender&&p?.gender?[ctx.gender,p.gender].sort().join('+'):null;
|
||||
const allowed=!q.allowed_couple_genders||!genders||q.allowed_couple_genders.includes(genders);const targeted=!q.target_user_genders||!!ctx.gender&&q.target_user_genders.includes(ctx.gender)||!!(await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3',[ctx.couple_id,q.id,userId])).rowCount;
|
||||
const pack=await client.query<{is_public:boolean;is_premium:boolean;enabled:boolean|null;configured:boolean}>(`select qp.is_public,qp.is_premium,cp.enabled,exists(select 1 from couple_packs where couple_id=$2) configured from question_packs qp left join couple_packs cp on cp.pack_id=qp.id and cp.couple_id=$2 where qp.id=$1`,[q.pack_id,ctx.couple_id]);const pr=pack.rows[0];const active=!!pr&&(pr.configured?pr.enabled===true:pr.is_public);const premium=!pr?.is_premium||ctx.is_premium||!!p?.is_premium;
|
||||
if(!active||!premium||q.intensity>max||!allowed||!targeted)throw new AnswersError('question_not_eligible',404,'Question was not found');
|
||||
}
|
||||
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}}
|
||||
private dates<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
|
||||
async close(){await this.pool.end();}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import type { AnswersRepository } from './repository.js';
|
||||
import { AnswersError } from './types.js';
|
||||
|
||||
type App = Hono<{Variables:{identity:AuthIdentity}}>;
|
||||
const uuid=z.string().uuid(); const page=z.coerce.number().int().min(0).default(0); const limit=z.coerce.number().int().min(1).max(100).default(20);
|
||||
const responseData=z.record(z.unknown()).nullable();
|
||||
const submit=z.object({question_id:uuid,answer:z.enum(['yes','no','maybe']),response_data:responseData.optional()}).strict();
|
||||
const update=z.object({new_answer:z.enum(['yes','no','maybe']),response_data:responseData.optional(),confirm_delete_match:z.boolean().optional().default(false)}).strict();
|
||||
const err=(code:string,message:string,details?:Record<string,unknown>):ApiErrorResponse & {details?:Record<string,unknown>}=>({error:{code,message},...(details?{details}:{})});
|
||||
async function run<T>(op:()=>Promise<T>){try{return {ok:true as const,value:await op()};}catch(e){if(e instanceof AnswersError)return {ok:false as const,status:e.status,body:err(e.code,e.message,e.details)};throw e;}}
|
||||
|
||||
export function registerAnswerRoutes(app:App,repo:AnswersRepository):void{
|
||||
app.get('/v1/questions/recommended',async c=>{const packId=c.req.query('packId');if(packId&&!uuid.safeParse(packId).success)return c.json(err('invalid_pack_id','Pack ID must be a UUID'),400);const r=await run(()=>repo.recommended(c.get('identity').id,packId));return r.ok?c.json({questions:r.value}):c.json(r.body,r.status);});
|
||||
app.get('/v1/questions/pending',async c=>{const direction=c.req.query('direction')==='mine'?'mine':'partner';const start=c.req.query('startQuestionId');if(start&&!uuid.safeParse(start).success)return c.json(err('invalid_question_id','startQuestionId must be a UUID'),400);const r=await run(()=>repo.pending(c.get('identity').id,direction,start));return r.ok?c.json({questions:r.value}):c.json(r.body,r.status);});
|
||||
app.get('/v1/me/answer-gap',async c=>c.json(await repo.answerGap(c.get('identity').id)));
|
||||
app.get('/v1/me/daily-limit',async c=>c.json(await repo.dailyLimit(c.get('identity').id)));
|
||||
app.post('/v1/responses',async c=>{const b=submit.safeParse(await c.req.json().catch(()=>null));if(!b.success)return c.json(err('invalid_request','question_id, answer, and valid response_data are required'),400);const r=await run(()=>repo.submit(c.get('identity').id,{questionId:b.data.question_id,answer:b.data.answer,responseData:b.data.response_data}));return r.ok?c.json(r.value):c.json(r.body,r.status);});
|
||||
app.patch('/v1/responses/:questionId',async c=>{const id=uuid.safeParse(c.req.param('questionId'));const b=update.safeParse(await c.req.json().catch(()=>null));if(!id.success||!b.success)return c.json(err('invalid_request','A valid question ID and answer are required'),400);const r=await run(()=>repo.update(c.get('identity').id,{questionId:id.data,answer:b.data.new_answer,responseData:b.data.response_data,confirmDeleteMatch:b.data.confirm_delete_match}));return r.ok?c.json(r.value):c.json(r.body,r.status);});
|
||||
app.get('/v1/me/responses',async c=>{const p=page.safeParse(c.req.query('page')),l=limit.safeParse(c.req.query('limit'));if(!p.success||!l.success)return c.json(err('invalid_pagination','page and limit are out of range'),400);return c.json(await repo.responses(c.get('identity').id,p.data,l.data));});
|
||||
app.get('/v1/matches',async c=>{const p=page.safeParse(c.req.query('page')),l=limit.safeParse(c.req.query('limit'));if(!p.success||!l.success)return c.json(err('invalid_pagination','page and limit are out of range'),400);return c.json(await repo.matches(c.get('identity').id,p.data,l.data,c.req.query('archived')==='true'));});
|
||||
app.patch('/v1/matches/seen',async c=>{const b=z.object({ids:z.array(uuid).min(1)}).safeParse(await c.req.json().catch(()=>null));if(!b.success)return c.json(err('invalid_request','ids must contain valid match IDs'),400);await repo.markSeen(c.get('identity').id,b.data.ids);return c.json({success:true});});
|
||||
app.put('/v1/matches/:matchId/archive',async c=>{const id=uuid.safeParse(c.req.param('matchId'));const b=z.object({archived:z.boolean()}).safeParse(await c.req.json().catch(()=>null));if(!id.success||!b.success)return c.json(err('invalid_request','A valid match ID and archived flag are required'),400);const r=await run(()=>repo.archive(c.get('identity').id,id.data,b.data.archived));return r.ok?c.json({success:true}):c.json(r.body,r.status);});
|
||||
app.get('/v1/me/streak',async c=>{const r=await run(()=>repo.streak(c.get('identity').id));return r.ok?c.json({streak:r.value}):c.json(r.body,r.status);});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Registration contract: export this module from db/all-schema.ts when the
|
||||
// application's Drizzle schema becomes authoritative. Migration 0003 is the
|
||||
// current source of truth for this domain.
|
||||
export const answersSchemaVersion = 3 as const;
|
||||
@@ -0,0 +1,29 @@
|
||||
export type Answer = 'yes' | 'no' | 'maybe';
|
||||
export type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
|
||||
export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe' | 'both_answered';
|
||||
export type ResponseData = Record<string, unknown> | null;
|
||||
|
||||
export interface QuestionRef {
|
||||
id: string;
|
||||
question_type: QuestionType;
|
||||
}
|
||||
|
||||
export function calculateMatchType(question: QuestionRef, first: Answer, second: Answer): MatchType | null {
|
||||
if (question.question_type === 'swipe') {
|
||||
if (first === 'no' || second === 'no') return null;
|
||||
if (first === 'yes' && second === 'yes') return 'yes_yes';
|
||||
if (first === 'maybe' && second === 'maybe') return 'maybe_maybe';
|
||||
return 'yes_maybe';
|
||||
}
|
||||
if (question.question_type === 'who_likely') return 'both_answered';
|
||||
return first === 'no' || second === 'no' ? null : 'both_answered';
|
||||
}
|
||||
|
||||
export class AnswersError extends Error {
|
||||
constructor(
|
||||
readonly code: 'no_couple' | 'question_not_found' | 'question_not_eligible' | 'response_not_found' | 'daily_limit' | 'match_not_found',
|
||||
readonly status: 400 | 404 | 409 | 429,
|
||||
message: string,
|
||||
readonly details?: Record<string, unknown>,
|
||||
) { super(message); }
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
|
||||
export interface PackContext { id: string; name: string; icon: string | null }
|
||||
export interface AppQuestion {
|
||||
id: string; pack_id: string; text: string; partner_text: string | null; intensity: number;
|
||||
allowed_couple_genders: string[] | null; target_user_genders: string[] | null;
|
||||
question_type: string; config: Record<string, unknown>; created_at: string;
|
||||
}
|
||||
export interface MatchContext {
|
||||
id: string; question_id: string; couple_id: string; match_type: string; created_at: string;
|
||||
response_summary: Record<string, unknown> | null;
|
||||
question: { id: string; text: string; partner_text: string | null };
|
||||
responses: Array<{ user_id: string; answer: string; created_at: string; profiles: { name: string | null } }>;
|
||||
}
|
||||
export interface StrokePoint { x: number; y: number }
|
||||
export interface StrokeSegment {
|
||||
id: string; userId: string; points: StrokePoint[]; color: string; width: number;
|
||||
timestamp: number; isEraser: boolean;
|
||||
}
|
||||
export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null }
|
||||
|
||||
export class AppDataError extends Error {
|
||||
constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record<string, unknown>) { super(message); }
|
||||
}
|
||||
|
||||
export interface AppDataRepository {
|
||||
packContext(userId: string, packId: string): Promise<PackContext>;
|
||||
packQuestions(userId: string, packId: string): Promise<AppQuestion[]>;
|
||||
packTeaser(userId: string, packId: string): Promise<Array<Pick<AppQuestion, 'id' | 'text' | 'intensity'>>>;
|
||||
matchContext(userId: string, matchId: string): Promise<MatchContext>;
|
||||
markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>;
|
||||
nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>;
|
||||
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
|
||||
getLiveDraw(userId: string): Promise<LiveDrawState>;
|
||||
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
|
||||
|
||||
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
|
||||
|
||||
export class PostgresAppDataRepository implements AppDataRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
|
||||
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
|
||||
const result = await this.pool.query<PackContext>(`
|
||||
select qp.id, qp.name, qp.icon
|
||||
from question_packs qp
|
||||
left join categories c on c.id = qp.category_id
|
||||
left join profiles p on p.id = $1
|
||||
where qp.id = $2 and qp.is_public = true
|
||||
and (qp.category_id is null or c.is_public = true)
|
||||
and (qp.is_explicit = false or coalesce(p.hide_nsfw, true) = false)
|
||||
limit 1`, [userId, packId]);
|
||||
if (!result.rows[0]) throw new AppDataError('not_found', 'Pack not found', 404);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async packContext(userId: string, packId: string): Promise<PackContext> { return this.visiblePack(userId, packId); }
|
||||
|
||||
async packQuestions(userId: string, packId: string): Promise<AppQuestion[]> {
|
||||
await this.visiblePack(userId, packId);
|
||||
const result = await this.pool.query<QuestionRow>(`
|
||||
select id, pack_id, text, partner_text, intensity, allowed_couple_genders,
|
||||
target_user_genders, question_type, config, created_at
|
||||
from questions where pack_id = $1 and deleted_at is null
|
||||
order by created_at, id`, [packId]);
|
||||
return result.rows.map(question);
|
||||
}
|
||||
|
||||
async packTeaser(userId: string, packId: string) {
|
||||
await this.visiblePack(userId, packId);
|
||||
const result = await this.pool.query<{ id: string; text: string; intensity: number }>(`
|
||||
select id, text, intensity from questions
|
||||
where pack_id = $1 and deleted_at is null
|
||||
order by intensity, md5(id::text || current_date::text) limit 3`, [packId]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async matchContext(userId: string, matchId: string): Promise<MatchContext> {
|
||||
const match = await this.pool.query<{
|
||||
id: string; question_id: string; couple_id: string; match_type: string; created_at: Date;
|
||||
response_summary: Record<string, unknown> | null; question: MatchContext['question'];
|
||||
}>(`select m.id,m.question_id,m.couple_id,m.match_type,m.created_at,m.response_summary,
|
||||
json_build_object('id',q.id,'text',q.text,'partner_text',q.partner_text) question
|
||||
from matches m join questions q on q.id=m.question_id join profiles p on p.couple_id=m.couple_id
|
||||
where p.id=$1 and m.id=$2 limit 1`, [userId, matchId]);
|
||||
if (!match.rows[0]) throw new AppDataError('not_found', 'Match not found', 404);
|
||||
const responses = await this.pool.query<{
|
||||
user_id: string; answer: string; created_at: Date; profiles: { name: string | null };
|
||||
}>(`select r.user_id,r.answer,r.created_at,json_build_object('name',p.name) profiles
|
||||
from responses r join profiles p on p.id=r.user_id
|
||||
where r.question_id=$1 and r.couple_id=$2 order by r.created_at,r.id`, [match.rows[0].question_id, match.rows[0].couple_id]);
|
||||
return { ...match.rows[0], created_at: match.rows[0].created_at.toISOString(), responses: responses.rows.map(row => ({ ...row, created_at: row.created_at.toISOString() })) };
|
||||
}
|
||||
|
||||
async markMediaViewed(userId: string, messageId: string, expiresAt: string | null) {
|
||||
const result = await this.pool.query<{ media_viewed_at: Date; media_expires_at: Date | null }>(`
|
||||
update messages m set media_viewed_at=coalesce(media_viewed_at,now()),
|
||||
media_expires_at=case when m.media_type='video' then coalesce(media_expires_at,$3::timestamptz) else null end
|
||||
from matches ma join profiles p on p.couple_id=ma.couple_id
|
||||
where m.match_id=ma.id and p.id=$1 and m.id=$2 and m.user_id<>$1
|
||||
returning m.media_viewed_at,m.media_expires_at`, [userId, messageId, expiresAt]);
|
||||
if (!result.rows[0]) throw new AppDataError('not_recipient', 'Message not found', 404);
|
||||
return { media_viewed_at: result.rows[0].media_viewed_at.toISOString(), media_expires_at: result.rows[0].media_expires_at?.toISOString() ?? null };
|
||||
}
|
||||
|
||||
async nudgeStatus(userId: string) {
|
||||
const result = await this.pool.query<{ last_nudge_sent_at: Date | null }>('select last_nudge_sent_at from profiles where id=$1', [userId]);
|
||||
return { last_nudge_sent_at: result.rows[0]?.last_nudge_sent_at?.toISOString() ?? null };
|
||||
}
|
||||
|
||||
async sendNudge(userId: string) {
|
||||
const client = await this.pool.connect();
|
||||
let senderName: string | null = null; let partnerToken: string | null = null; let nudgesEnabled = true; let nextAvailable = '';
|
||||
try {
|
||||
await client.query('begin');
|
||||
const sender = await client.query<{ name: string | null; couple_id: string | null; last_nudge_sent_at: Date | null }>(
|
||||
'select name,couple_id,last_nudge_sent_at from profiles where id=$1 for update', [userId]);
|
||||
if (!sender.rows[0]) throw new AppDataError('not_found', 'Profile not found', 404);
|
||||
if (!sender.rows[0].couple_id) throw new AppDataError('no_couple', 'Join a couple first', 409);
|
||||
const now = new Date(); const last = sender.rows[0].last_nudge_sent_at;
|
||||
if (last && now.getTime() - last.getTime() < 12 * 60 * 60 * 1000) {
|
||||
const next = new Date(last.getTime() + 12 * 60 * 60 * 1000);
|
||||
throw new AppDataError('rate_limited', 'Nudge cooldown is active', 429, {
|
||||
cooldown_remaining_seconds: Math.ceil((next.getTime() - now.getTime()) / 1000), next_nudge_available_at: next.toISOString(),
|
||||
});
|
||||
}
|
||||
const partner = await client.query<{ id: string; push_token: string | null; nudges_enabled: boolean | null }>(`
|
||||
select p.id,p.push_token,np.nudges_enabled from profiles p
|
||||
left join notification_preferences np on np.user_id=p.id
|
||||
where p.couple_id=$1 and p.id<>$2 order by p.id limit 1`, [sender.rows[0].couple_id, userId]);
|
||||
if (!partner.rows[0]) throw new AppDataError('not_found', 'Partner not found', 404);
|
||||
await client.query('update profiles set last_nudge_sent_at=$2 where id=$1', [userId, now]);
|
||||
await client.query('commit');
|
||||
senderName = sender.rows[0].name; partnerToken = partner.rows[0].push_token; nudgesEnabled = partner.rows[0].nudges_enabled !== false;
|
||||
nextAvailable = new Date(now.getTime() + 12 * 60 * 60 * 1000).toISOString();
|
||||
} catch (cause) {
|
||||
await client.query('rollback').catch(() => undefined);
|
||||
throw cause;
|
||||
} finally { client.release(); }
|
||||
if (!partnerToken) return { success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: nextAvailable };
|
||||
if (!nudgesEnabled) return { success: true as const, notification_sent: false, reason: 'nudges_disabled', next_nudge_available_at: nextAvailable };
|
||||
const response = await fetch('https://exp.host/--/api/v2/push/send', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ to: partnerToken, title: 'Partner nudge', body: `${senderName || 'Your partner'} wants you to catch up!`, sound: 'default', data: { type: 'nudge' } }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Expo push request failed with status ${response.status}`);
|
||||
return { success: true as const, notification_sent: true, next_nudge_available_at: nextAvailable };
|
||||
}
|
||||
|
||||
private async coupleId(userId: string): Promise<string> {
|
||||
const result = await this.pool.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [userId]);
|
||||
const coupleId = result.rows[0]?.couple_id;
|
||||
if (!coupleId) throw new AppDataError('no_couple', 'Join a couple first', 409);
|
||||
return coupleId;
|
||||
}
|
||||
|
||||
async getLiveDraw(userId: string): Promise<LiveDrawState> {
|
||||
const coupleId = await this.coupleId(userId);
|
||||
const result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(
|
||||
'select strokes,revision::text,updated_at,updated_by from live_draw_sessions where couple_id=$1', [coupleId]);
|
||||
const row = result.rows[0];
|
||||
return row ? { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by } : { strokes: [], revision: 0, updated_at: null, updated_by: null };
|
||||
}
|
||||
|
||||
async putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState> {
|
||||
const coupleId = await this.coupleId(userId);
|
||||
const memberIds = await this.pool.query<{ id: string }>('select id from profiles where couple_id=$1', [coupleId]);
|
||||
const allowed = new Set(memberIds.rows.map(row => row.id));
|
||||
if (strokes.some(stroke => !allowed.has(stroke.userId))) {
|
||||
throw new AppDataError('not_recipient', 'Drawing contains a stroke from outside this couple', 403);
|
||||
}
|
||||
let result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(`
|
||||
update live_draw_sessions set strokes=$2::jsonb,updated_by=$3,revision=revision+1,updated_at=now()
|
||||
where couple_id=$1 and revision=$4
|
||||
returning strokes,revision::text,updated_at,updated_by`, [coupleId, JSON.stringify(strokes), userId, baseRevision]);
|
||||
if (!result.rows[0] && baseRevision === 0) {
|
||||
result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(`
|
||||
insert into live_draw_sessions(couple_id,strokes,updated_by) values($1,$2::jsonb,$3)
|
||||
on conflict(couple_id) do nothing returning strokes,revision::text,updated_at,updated_by`, [coupleId, JSON.stringify(strokes), userId]);
|
||||
}
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
const current = await this.getLiveDraw(userId);
|
||||
throw new AppDataError('revision_conflict', 'Drawing changed on another device', 409, { current_state: current });
|
||||
}
|
||||
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
|
||||
}
|
||||
async close(): Promise<void> { await this.pool.end(); }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { AppDataError, type AppDataRepository } from './repository.js';
|
||||
|
||||
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
const uuid = z.string().uuid();
|
||||
const point = z.object({ x: z.number().finite().min(0).max(1), y: z.number().finite().min(0).max(1) }).strict();
|
||||
const stroke = z.object({
|
||||
id: z.string().min(1).max(120), userId: uuid, points: z.array(point).max(20_000),
|
||||
color: z.string().min(1).max(64), width: z.number().finite().positive().max(100),
|
||||
timestamp: z.number().finite().nonnegative(), isEraser: z.boolean(),
|
||||
}).strict();
|
||||
const liveDrawBody = z.object({ strokes: z.array(stroke).max(2_000), base_revision: z.number().int().nonnegative() }).strict();
|
||||
const viewedBody = z.object({ expires_at: z.string().datetime().nullable() }).strict();
|
||||
const error = (code: string, message: string): ApiErrorResponse => ({ error: { code, message } });
|
||||
async function run<T>(op: () => Promise<T>) { try { return { ok: true as const, value: await op() }; } catch (cause) { if (cause instanceof AppDataError) return { ok: false as const, cause }; throw cause; } }
|
||||
|
||||
export function registerAppDataRoutes(app: App, repository: AppDataRepository): void {
|
||||
app.get('/v1/packs/:packId/context', async c => {
|
||||
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
|
||||
const result = await run(() => repository.packContext(c.get('identity').id, id.data));
|
||||
return result.ok ? c.json({ pack: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.get('/v1/packs/:packId/questions', async c => {
|
||||
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
|
||||
const result = await run(() => repository.packQuestions(c.get('identity').id, id.data));
|
||||
return result.ok ? c.json({ questions: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.get('/v1/packs/:packId/teaser', async c => {
|
||||
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
|
||||
const result = await run(() => repository.packTeaser(c.get('identity').id, id.data));
|
||||
return result.ok ? c.json({ questions: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.get('/v1/matches/:matchId/context', async c => {
|
||||
const id = uuid.safeParse(c.req.param('matchId')); if (!id.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
||||
const result = await run(() => repository.matchContext(c.get('identity').id, id.data));
|
||||
return result.ok ? c.json({ match: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.patch('/v1/messages/:messageId/media-viewed', async c => {
|
||||
const id = uuid.safeParse(c.req.param('messageId')); const body = viewedBody.safeParse(await c.req.json().catch(() => null));
|
||||
if (!id.success || !body.success) return c.json(error('invalid_request', 'A valid message ID and expires_at are required'), 400);
|
||||
const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at));
|
||||
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
|
||||
app.post('/v1/me/nudge', async c => {
|
||||
const result = await run(() => repository.sendNudge(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json({ ...error(result.cause.code, result.cause.message), ...result.cause.details }, result.cause.status);
|
||||
});
|
||||
app.get('/v1/live-draw', async c => { const result = await run(() => repository.getLiveDraw(c.get('identity').id)); return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status); });
|
||||
app.put('/v1/live-draw', async c => {
|
||||
const body = liveDrawBody.safeParse(await c.req.json().catch(() => null)); if (!body.success) return c.json(error('invalid_strokes', 'A valid strokes array is required'), 400);
|
||||
const result = await run(() => repository.putLiveDraw(c.get('identity').id, body.data.strokes, body.data.base_revision));
|
||||
return result.ok ? c.json(result.value) : c.json({ ...error(result.cause.code, result.cause.message), ...result.cause.details }, result.cause.status);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { bigint, index, jsonb, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||
import { profiles } from '../../db/schema.js';
|
||||
import { couples } from '../couples/schema.js';
|
||||
|
||||
export const liveDrawSessions = pgTable('live_draw_sessions', {
|
||||
coupleId: uuid('couple_id').primaryKey().references(() => couples.id, { onDelete: 'cascade' }),
|
||||
strokes: jsonb('strokes').notNull().default([]),
|
||||
revision: bigint('revision', { mode: 'number' }).notNull().default(1),
|
||||
updatedBy: uuid('updated_by').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (table) => [index('live_draw_sessions_updated_at_idx').on(table.updatedAt)]);
|
||||
@@ -0,0 +1,35 @@
|
||||
# Billing and redemption
|
||||
|
||||
This domain owns RevenueCat webhook persistence and public promotional-code
|
||||
redemption. Neither route uses the hosted Supabase data plane.
|
||||
|
||||
- `POST /webhooks/revenuecat` requires an exact
|
||||
`Authorization: Bearer <REVENUECAT_WEBHOOK_SECRET>` header. A missing server
|
||||
secret disables the webhook with `503`; it never becomes unauthenticated.
|
||||
- `POST /public/v1/redemptions` accepts `{ email, code }` for the public web
|
||||
redemption form. Validation, remaining-use checks, redemption recording, and
|
||||
the premium grant run in one database transaction.
|
||||
|
||||
Both public endpoints have request-body limits (1 MiB for provider webhooks and
|
||||
16 KiB for redemption). Webhook authorization is checked before JSON parsing.
|
||||
Because the API cannot independently authenticate a proxy-supplied client IP,
|
||||
redemption also uses a database-backed global ceiling of 60 attempts per minute
|
||||
across all replicas. The production reverse proxy must additionally apply a
|
||||
per-client-IP limit; forwarded-IP headers must not be trusted from the public
|
||||
internet unless the proxy strips and rewrites them.
|
||||
|
||||
Webhook event insertion, subscription upsert, premium recomputation, and any
|
||||
premium-pack disabling are atomic. The unique event ID makes provider retries
|
||||
idempotent. A known event for an unknown user rolls back the event record so a
|
||||
later retry can succeed after identity reconciliation. Unknown and `TEST` events
|
||||
are recorded once and acknowledged without changing subscription state.
|
||||
|
||||
Wire a `PostgresBillingRepository`, construct `BillingService` with the
|
||||
server-only `REVENUECAT_WEBHOOK_SECRET`, call `registerBillingRoutes`, and close
|
||||
the repository during graceful shutdown. The web app should receive the API base
|
||||
URL via `NEXT_PUBLIC_API_URL`; no server secret belongs in a `NEXT_PUBLIC_*`
|
||||
variable.
|
||||
|
||||
Rate-limit the public redemption endpoint by client IP at the trusted reverse
|
||||
proxy. The API limits request size and atomically locks codes, but deliberately
|
||||
does not trust arbitrary forwarded-IP headers on its own.
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './repository.js';
|
||||
export * from './routes.js';
|
||||
export * from './service.js';
|
||||
export * from './types.js';
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
|
||||
|
||||
interface ProfileRecord extends QueryResultRow {
|
||||
id: string;
|
||||
couple_id: string | null;
|
||||
is_premium: boolean;
|
||||
}
|
||||
|
||||
interface CodeRecord extends QueryResultRow {
|
||||
id: string;
|
||||
max_uses: number | null;
|
||||
current_uses: number;
|
||||
expires_at: Date | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface WebhookResult {
|
||||
duplicate: boolean;
|
||||
handled: boolean;
|
||||
}
|
||||
|
||||
export interface BillingRepository {
|
||||
processRevenueCatEvent(payload: RevenueCatWebhook, status: SubscriptionStatus | null): Promise<WebhookResult>;
|
||||
redeem(email: string, code: string): Promise<RedemptionResult>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const result = await operation(client);
|
||||
await client.query('commit');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export class PostgresBillingRepository implements BillingRepository {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
}
|
||||
|
||||
async processRevenueCatEvent(
|
||||
payload: RevenueCatWebhook,
|
||||
status: SubscriptionStatus | null,
|
||||
): Promise<WebhookResult> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const event = payload.event;
|
||||
const inserted = await client.query(
|
||||
`insert into revenuecat_webhook_events (event_id, event_type, app_user_id, payload)
|
||||
values ($1, $2, $3, $4::jsonb)
|
||||
on conflict (event_id) do nothing`,
|
||||
[event.id, event.type, event.app_user_id, JSON.stringify(payload)],
|
||||
);
|
||||
if (inserted.rowCount === 0) return { duplicate: true, handled: status !== null };
|
||||
if (status === null) return { duplicate: false, handled: false };
|
||||
|
||||
const profileResult = await client.query<ProfileRecord>(
|
||||
'select id, couple_id, is_premium from profiles where id = $1 for update',
|
||||
[event.app_user_id],
|
||||
);
|
||||
const profile = profileResult.rows[0];
|
||||
if (!profile) throw new BillingError('user_not_found', 'User not found', 404);
|
||||
|
||||
await client.query(
|
||||
`insert into subscriptions (
|
||||
user_id, revenuecat_app_user_id, product_id, status, entitlement_ids,
|
||||
purchased_at, expires_at, original_transaction_id, store, is_sandbox,
|
||||
cancel_reason, grace_period_expires_at
|
||||
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
on conflict (user_id, original_transaction_id) do update set
|
||||
revenuecat_app_user_id = excluded.revenuecat_app_user_id,
|
||||
product_id = excluded.product_id,
|
||||
status = excluded.status,
|
||||
entitlement_ids = excluded.entitlement_ids,
|
||||
purchased_at = excluded.purchased_at,
|
||||
expires_at = excluded.expires_at,
|
||||
store = excluded.store,
|
||||
is_sandbox = excluded.is_sandbox,
|
||||
cancel_reason = excluded.cancel_reason,
|
||||
grace_period_expires_at = excluded.grace_period_expires_at,
|
||||
updated_at = now()`,
|
||||
[
|
||||
event.app_user_id,
|
||||
event.app_user_id,
|
||||
event.product_id,
|
||||
status,
|
||||
event.entitlement_ids,
|
||||
new Date(event.purchased_at_ms),
|
||||
event.expiration_at_ms === undefined ? null : new Date(event.expiration_at_ms),
|
||||
event.original_transaction_id,
|
||||
event.store,
|
||||
event.environment !== 'PRODUCTION',
|
||||
event.cancel_reason ?? null,
|
||||
event.grace_period_expiration_at_ms === undefined ? null : new Date(event.grace_period_expiration_at_ms),
|
||||
],
|
||||
);
|
||||
|
||||
const premiumResult = await client.query<{ premium: boolean }>(
|
||||
`select exists (
|
||||
select 1 from subscriptions
|
||||
where user_id = $1 and status = 'active'
|
||||
and (expires_at is null or expires_at > now())
|
||||
) as premium`,
|
||||
[event.app_user_id],
|
||||
);
|
||||
const isPremium = premiumResult.rows[0]?.premium ?? false;
|
||||
await client.query(
|
||||
'update profiles set is_premium = $2, updated_at = now() where id = $1',
|
||||
[event.app_user_id, isPremium],
|
||||
);
|
||||
|
||||
if (profile.is_premium && !isPremium && profile.couple_id) {
|
||||
const partnerPremium = await client.query<{ premium: boolean }>(
|
||||
`select exists (
|
||||
select 1 from profiles
|
||||
where couple_id = $1 and id <> $2 and is_premium = true
|
||||
) as premium`,
|
||||
[profile.couple_id, event.app_user_id],
|
||||
);
|
||||
if (!partnerPremium.rows[0]?.premium) {
|
||||
await client.query(
|
||||
`update couple_packs cp set enabled = false
|
||||
from question_packs qp
|
||||
where cp.pack_id = qp.id and qp.is_premium = true
|
||||
and cp.couple_id = $1 and cp.enabled = true`,
|
||||
[profile.couple_id],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { duplicate: false, handled: true };
|
||||
});
|
||||
}
|
||||
|
||||
async redeem(email: string, code: string): Promise<RedemptionResult> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const allowance = await client.query(
|
||||
`insert into redemption_rate_limits (bucket, attempts)
|
||||
values (date_trunc('minute', now()), 1)
|
||||
on conflict (bucket) do update
|
||||
set attempts = redemption_rate_limits.attempts + 1
|
||||
where redemption_rate_limits.attempts < 60
|
||||
returning attempts`,
|
||||
);
|
||||
if (allowance.rowCount === 0) {
|
||||
throw new BillingError('redemption_rate_limited', 'Too many redemption attempts. Please try again shortly.', 429);
|
||||
}
|
||||
await client.query(
|
||||
`delete from redemption_rate_limits where bucket < date_trunc('minute', now()) - interval '1 day'`,
|
||||
);
|
||||
|
||||
const profileResult = await client.query<ProfileRecord>(
|
||||
`select id, couple_id, is_premium from profiles
|
||||
where lower(email) = lower($1)
|
||||
order by created_at asc
|
||||
limit 1
|
||||
for update`,
|
||||
[email],
|
||||
);
|
||||
const profile = profileResult.rows[0];
|
||||
if (!profile) return { success: false, error: 'No account found with this email address' };
|
||||
|
||||
const codeResult = await client.query<CodeRecord>(
|
||||
`select id, max_uses, current_uses, expires_at, is_active
|
||||
from redemption_codes where upper(code) = upper($1) for update`,
|
||||
[code],
|
||||
);
|
||||
const redemptionCode = codeResult.rows[0];
|
||||
if (!redemptionCode) return { success: false, error: 'Invalid redemption code' };
|
||||
if (!redemptionCode.is_active) return { success: false, error: 'This code is no longer active' };
|
||||
if (redemptionCode.expires_at && redemptionCode.expires_at < new Date()) {
|
||||
return { success: false, error: 'This code has expired' };
|
||||
}
|
||||
if (redemptionCode.max_uses !== null && redemptionCode.current_uses >= redemptionCode.max_uses) {
|
||||
return { success: false, error: 'This code has reached its maximum number of uses' };
|
||||
}
|
||||
|
||||
const existing = await client.query(
|
||||
'select 1 from code_redemptions where code_id = $1 and user_id = $2',
|
||||
[redemptionCode.id, profile.id],
|
||||
);
|
||||
if (existing.rowCount) return { success: false, error: 'You have already redeemed this code' };
|
||||
|
||||
await client.query(
|
||||
'insert into code_redemptions (code_id, user_id) values ($1, $2)',
|
||||
[redemptionCode.id, profile.id],
|
||||
);
|
||||
await client.query(
|
||||
'update redemption_codes set current_uses = current_uses + 1, updated_at = now() where id = $1',
|
||||
[redemptionCode.id],
|
||||
);
|
||||
await client.query(
|
||||
'update profiles set is_premium = true, updated_at = now() where id = $1',
|
||||
[profile.id],
|
||||
);
|
||||
return { success: true, message: 'Code redeemed successfully! You now have premium access.' };
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Hono } from 'hono';
|
||||
import { bodyLimit } from 'hono/body-limit';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import type { BillingService } from './service.js';
|
||||
import { BillingError } from './types.js';
|
||||
|
||||
const redemptionSchema = z.object({
|
||||
email: z.string().trim().min(1, 'Please enter your email address').max(320),
|
||||
code: z.string().trim().min(1, 'Please enter a redemption code').max(255),
|
||||
}).strict();
|
||||
|
||||
type ApiApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
|
||||
export function registerBillingRoutes(app: ApiApp, service: BillingService): void {
|
||||
app.use('/webhooks/revenuecat', bodyLimit({
|
||||
maxSize: 1024 * 1024,
|
||||
onError: (c) => c.json({ error: 'Webhook payload is too large' }, 413),
|
||||
}));
|
||||
app.post('/webhooks/revenuecat', async (c) => {
|
||||
try {
|
||||
service.assertWebhookAuthorization(c.req.header('authorization'));
|
||||
const rawBody: unknown = await c.req.json().catch(() => null);
|
||||
return c.json(await service.processWebhook(c.req.header('authorization'), rawBody));
|
||||
} catch (cause) {
|
||||
if (cause instanceof BillingError) return c.json({ error: cause.message }, cause.status);
|
||||
throw cause;
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/public/v1/redemptions', bodyLimit({
|
||||
maxSize: 16 * 1024,
|
||||
onError: (c) => c.json({ success: false, error: 'Request is too large' }, 413),
|
||||
}));
|
||||
app.post('/public/v1/redemptions', async (c) => {
|
||||
try {
|
||||
const rawBody: unknown = await c.req.json().catch(() => null);
|
||||
const parsed = redemptionSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return c.json({ success: false, error: parsed.error.issues[0]?.message ?? 'Invalid request' }, 400);
|
||||
}
|
||||
const result = await service.redeem(parsed.data.email, parsed.data.code);
|
||||
return c.json(result, result.success ? 200 : 400);
|
||||
} catch (cause) {
|
||||
if (cause instanceof BillingError) return c.json({ success: false, error: cause.message }, cause.status);
|
||||
throw cause;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { BillingRepository } from './repository.js';
|
||||
import {
|
||||
BillingError,
|
||||
type BillingResult,
|
||||
type RedemptionResult,
|
||||
revenueCatWebhookSchema,
|
||||
type SubscriptionStatus,
|
||||
} from './types.js';
|
||||
|
||||
const statusByEvent: Record<string, SubscriptionStatus> = {
|
||||
INITIAL_PURCHASE: 'active',
|
||||
RENEWAL: 'active',
|
||||
UNCANCELLATION: 'active',
|
||||
PRODUCT_CHANGE: 'active',
|
||||
CANCELLATION: 'cancelled',
|
||||
EXPIRATION: 'expired',
|
||||
BILLING_ISSUE: 'billing_issue',
|
||||
SUBSCRIPTION_PAUSED: 'paused',
|
||||
};
|
||||
|
||||
function secureEqual(actual: string, expected: string): boolean {
|
||||
const actualBytes = Buffer.from(actual);
|
||||
const expectedBytes = Buffer.from(expected);
|
||||
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
||||
}
|
||||
|
||||
export class BillingService {
|
||||
constructor(
|
||||
private readonly repository: BillingRepository,
|
||||
private readonly revenueCatWebhookSecret: string | undefined,
|
||||
) {}
|
||||
|
||||
assertWebhookAuthorization(authorization: string | undefined): void {
|
||||
if (!this.revenueCatWebhookSecret) {
|
||||
throw new BillingError('webhook_unavailable', 'Webhook is not configured', 503);
|
||||
}
|
||||
if (!authorization || !secureEqual(authorization, `Bearer ${this.revenueCatWebhookSecret}`)) {
|
||||
throw new BillingError('unauthorized', 'Unauthorized', 401);
|
||||
}
|
||||
}
|
||||
|
||||
async processWebhook(authorization: string | undefined, rawPayload: unknown): Promise<BillingResult> {
|
||||
this.assertWebhookAuthorization(authorization);
|
||||
|
||||
const parsed = revenueCatWebhookSchema.safeParse(rawPayload);
|
||||
if (!parsed.success) throw new BillingError('invalid_payload', 'Invalid webhook payload', 400);
|
||||
const payload = parsed.data;
|
||||
|
||||
const status = statusByEvent[payload.event.type] ?? null;
|
||||
const result = await this.repository.processRevenueCatEvent(payload, status);
|
||||
if (result.duplicate) return { success: true, message: 'Duplicate event' };
|
||||
if (payload.event.type === 'TEST') return { success: true, message: 'Test event received' };
|
||||
if (!result.handled) return { success: true, message: 'Event type not handled' };
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
redeem(email: string, code: string): Promise<RedemptionResult> {
|
||||
return this.repository.redeem(email.trim(), code.trim());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const revenueCatWebhookSchema = z.object({
|
||||
api_version: z.string().min(1),
|
||||
event: z.object({
|
||||
type: z.string().min(1),
|
||||
id: z.string().min(1).max(255),
|
||||
app_user_id: z.string().uuid(),
|
||||
original_app_user_id: z.string().min(1),
|
||||
product_id: z.string().min(1),
|
||||
entitlement_ids: z.array(z.string()),
|
||||
purchased_at_ms: z.number().int().nonnegative(),
|
||||
expiration_at_ms: z.number().int().nonnegative().optional(),
|
||||
original_transaction_id: z.string().min(1),
|
||||
store: z.string().min(1),
|
||||
environment: z.string().min(1),
|
||||
cancel_reason: z.string().optional(),
|
||||
grace_period_expiration_at_ms: z.number().int().nonnegative().optional(),
|
||||
}).passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
export type RevenueCatWebhook = z.infer<typeof revenueCatWebhookSchema>;
|
||||
export type SubscriptionStatus = 'active' | 'cancelled' | 'expired' | 'billing_issue' | 'paused';
|
||||
|
||||
export interface BillingResult {
|
||||
success: true;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type RedemptionResult =
|
||||
| { success: true; message: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
export class BillingError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly status: 400 | 401 | 404 | 429 | 503,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BillingError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
|
||||
export type ChatMediaType = 'image' | 'video' | null;
|
||||
export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
match_id: string;
|
||||
user_id: string;
|
||||
content: string | null;
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
delivered_at: string | null;
|
||||
deleted_at: string | null;
|
||||
media_path: string | null;
|
||||
media_type: ChatMediaType;
|
||||
media_expires_at: string | null;
|
||||
media_expired: boolean;
|
||||
media_viewed_at: string | null;
|
||||
version: number;
|
||||
encrypted_content: string | null;
|
||||
encryption_iv: string | null;
|
||||
keys_metadata: Record<string, unknown> | null;
|
||||
moderation_status: string | null;
|
||||
flag_reason: string | null;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
interface MessageRecord extends QueryResultRow, Omit<ChatMessage, 'created_at' | 'read_at' | 'delivered_at' | 'deleted_at' | 'media_expires_at' | 'media_viewed_at'> {
|
||||
created_at: Date;
|
||||
read_at: Date | null;
|
||||
delivered_at: Date | null;
|
||||
deleted_at: Date | null;
|
||||
media_expires_at: Date | null;
|
||||
media_viewed_at: Date | null;
|
||||
}
|
||||
|
||||
export class ChatError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly status: 400 | 403 | 404 | 409,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChatRepository {
|
||||
listMessages(userId: string, matchId: string): Promise<ChatMessage[]>;
|
||||
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
|
||||
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
|
||||
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
|
||||
markDelivered(userId: string, messageId: string): Promise<ChatMessage>;
|
||||
deleteForSelf(userId: string, messageId: string): Promise<void>;
|
||||
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
|
||||
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
|
||||
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
|
||||
getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at,
|
||||
m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at,
|
||||
m.media_expired, m.media_viewed_at, m.version, m.encrypted_content,
|
||||
m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`;
|
||||
|
||||
function iso(value: Date | null): string | null {
|
||||
return value?.toISOString() ?? null;
|
||||
}
|
||||
|
||||
function toMessage(row: MessageRecord): ChatMessage {
|
||||
return {
|
||||
...row,
|
||||
created_at: row.created_at.toISOString(),
|
||||
read_at: iso(row.read_at),
|
||||
delivered_at: iso(row.delivered_at),
|
||||
deleted_at: iso(row.deleted_at),
|
||||
media_expires_at: iso(row.media_expires_at),
|
||||
media_viewed_at: iso(row.media_viewed_at),
|
||||
};
|
||||
}
|
||||
|
||||
export class PostgresChatRepository implements ChatRepository {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
}
|
||||
|
||||
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
|
||||
const result = await this.pool.query(
|
||||
`select 1
|
||||
from profiles p
|
||||
join matches ma on ma.couple_id = p.couple_id
|
||||
where p.id = $1 and ma.id = $2`,
|
||||
[userId, matchId],
|
||||
);
|
||||
if (!result.rowCount) throw new ChatError('match_not_found', 'Match not found', 404);
|
||||
}
|
||||
|
||||
private async accessibleMessage(userId: string, messageId: string): Promise<MessageRecord> {
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`select ${columns}
|
||||
from messages m
|
||||
join matches ma on ma.id = m.match_id
|
||||
join profiles p on p.couple_id = ma.couple_id
|
||||
where p.id = $1 and m.id = $2`,
|
||||
[userId, messageId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new ChatError('message_not_found', 'Message not found', 404);
|
||||
return row;
|
||||
}
|
||||
|
||||
async listMessages(userId: string, matchId: string): Promise<ChatMessage[]> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`select ${columns}
|
||||
from messages m
|
||||
where m.match_id = $1
|
||||
and not exists (
|
||||
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
|
||||
)
|
||||
order by m.created_at desc, m.id desc
|
||||
limit 500`,
|
||||
[matchId, userId],
|
||||
);
|
||||
return result.rows.map(toMessage);
|
||||
}
|
||||
|
||||
async sendText(userId: string, matchId: string, content: string): Promise<ChatMessage> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`insert into messages (match_id, user_id, content, version)
|
||||
values ($1, $2, $3, 1)
|
||||
returning id, match_id, user_id, content, created_at, read_at, delivered_at,
|
||||
deleted_at, media_path, media_type, media_expires_at, media_expired,
|
||||
media_viewed_at, version, encrypted_content, encryption_iv, keys_metadata,
|
||||
moderation_status, flag_reason, category`,
|
||||
[matchId, userId, content],
|
||||
);
|
||||
return toMessage(result.rows[0]!);
|
||||
}
|
||||
|
||||
async unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }> {
|
||||
const result = await this.pool.query<{ match_id: string; count: string }>(
|
||||
`select m.match_id, count(*)::text as count
|
||||
from messages m
|
||||
join matches ma on ma.id = m.match_id
|
||||
join profiles p on p.couple_id = ma.couple_id
|
||||
where p.id = $1 and m.user_id <> $1 and m.read_at is null
|
||||
and m.deleted_at is null
|
||||
and not exists (
|
||||
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $1
|
||||
)
|
||||
group by m.match_id`,
|
||||
[userId],
|
||||
);
|
||||
const by_match = Object.fromEntries(result.rows.map((row) => [row.match_id, Number(row.count)]));
|
||||
return { total: Object.values(by_match).reduce((sum, count) => sum + count, 0), by_match };
|
||||
}
|
||||
|
||||
async markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
const readAt = new Date();
|
||||
const result = await this.pool.query(
|
||||
`update messages
|
||||
set delivered_at = coalesce(delivered_at, $3), read_at = $3
|
||||
where match_id = $1 and user_id <> $2 and read_at is null`,
|
||||
[matchId, userId, readAt],
|
||||
);
|
||||
return { updated: result.rowCount ?? 0, read_at: readAt.toISOString() };
|
||||
}
|
||||
|
||||
async markDelivered(userId: string, messageId: string): Promise<ChatMessage> {
|
||||
const message = await this.accessibleMessage(userId, messageId);
|
||||
if (message.user_id === userId) throw new ChatError('invalid_recipient', 'Senders cannot mark their own message delivered', 403);
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`update messages m set delivered_at = coalesce(m.delivered_at, now()) where m.id = $1 returning ${columns}`,
|
||||
[messageId],
|
||||
);
|
||||
return toMessage(result.rows[0]!);
|
||||
}
|
||||
|
||||
async deleteForSelf(userId: string, messageId: string): Promise<void> {
|
||||
await this.accessibleMessage(userId, messageId);
|
||||
await this.pool.query(
|
||||
`insert into message_deletions (message_id, user_id) values ($1, $2) on conflict do nothing`,
|
||||
[messageId, userId],
|
||||
);
|
||||
}
|
||||
|
||||
async deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage> {
|
||||
const message = await this.accessibleMessage(userId, messageId);
|
||||
if (message.user_id !== userId) throw new ChatError('not_message_author', 'Only the sender can delete a message for everyone', 403);
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`update messages m set deleted_at = coalesce(m.deleted_at, now()) where m.id = $1 returning ${columns}`,
|
||||
[messageId],
|
||||
);
|
||||
return toMessage(result.rows[0]!);
|
||||
}
|
||||
|
||||
async report(userId: string, messageId: string, reason: ReportReason): Promise<void> {
|
||||
const message = await this.accessibleMessage(userId, messageId);
|
||||
if (message.user_id === userId) throw new ChatError('cannot_report_own_message', 'You cannot report your own message', 403);
|
||||
try {
|
||||
await this.pool.query(
|
||||
`insert into message_reports (message_id, reporter_id, reason) values ($1, $2, $3)`,
|
||||
[messageId, userId, reason],
|
||||
);
|
||||
} catch (cause) {
|
||||
if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === '23505') {
|
||||
throw new ChatError('already_reported', 'You have already reported this message', 409);
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
async setTyping(userId: string, matchId: string, ttlMs: number): Promise<void> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
await this.pool.query(
|
||||
`insert into chat_typing_states (match_id, user_id, expires_at)
|
||||
values ($1, $2, now() + ($3 * interval '1 millisecond'))
|
||||
on conflict (match_id, user_id) do update set expires_at = excluded.expires_at`,
|
||||
[matchId, userId, ttlMs],
|
||||
);
|
||||
}
|
||||
|
||||
async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
const result = await this.pool.query<{ expires_at: Date }>(
|
||||
`select expires_at from chat_typing_states
|
||||
where match_id = $1 and user_id <> $2 and expires_at > now()
|
||||
order by expires_at desc limit 1`,
|
||||
[matchId, userId],
|
||||
);
|
||||
const expiresAt = result.rows[0]?.expires_at ?? null;
|
||||
return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 294 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user