Add inverse_of tracking and admin question management

- Add inverse_of column support for question pair linking
- Enhance admin questions page with bulk operations
- Add DB migrations for inverse_of and FK indexes
- Update pack generator docs for inverse linking
- Improve Docker configs for admin app
This commit is contained in:
jjbrunton committed 2026-01-11 11:17:46 +00:00
1 parent efe93a3db4
commit df955812fe
22 files changed
+9951 -121

No files matched your search

+18 -13
View File
@@ -1,15 +1,20 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy everything first (workspaces need full structure)
COPY . .
# Install all dependencies
RUN npm install
# Build shared package first
RUN npx turbo run build --filter=@sauci/shared
FROM node:20-bookworm-slim AS builder
WORKDIR /app
# Enable pnpm via corepack (allow non-matching packageManager)
ENV COREPACK_ENABLE_STRICT=0
ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy everything first (workspaces need full structure)
COPY . .
# Install all dependencies
RUN pnpm install --no-frozen-lockfile
# Build shared package first
RUN pnpm exec turbo run build --filter=@sauci/shared
# Build admin app with env vars
ARG VITE_SUPABASE_URL
@@ -36,7 +41,7 @@ ENV VITE_COUNCIL_ENABLED=$VITE_COUNCIL_ENABLED
ENV VITE_COUNCIL_GENERATOR_MODEL=$VITE_COUNCIL_GENERATOR_MODEL
ENV VITE_COUNCIL_REVIEWER_MODEL=$VITE_COUNCIL_REVIEWER_MODEL
RUN npx turbo run build --filter=@sauci/admin
RUN pnpm exec turbo run build --filter=@sauci/admin
FROM nginx:alpine
COPY --from=builder /app/apps/admin/dist /usr/share/nginx/html
+10 -4
View File
@@ -1,9 +1,15 @@
FROM node:20-alpine
FROM node:20-bookworm-slim
WORKDIR /app
# Enable pnpm via corepack (allow non-matching packageManager)
ENV COREPACK_ENABLE_STRICT=0
ENV PNPM_CONFIG_LINK_WORKSPACE_PACKAGES=true
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy root package files and workspace configs
COPY package*.json ./
COPY pnpm-workspace.yaml ./
COPY turbo.json ./
# Copy all workspace package.json files
@@ -14,16 +20,16 @@ COPY apps/web/package.json ./apps/web/
COPY packages/shared/package.json ./packages/shared/
# Install all dependencies at root level (workspaces)
RUN npm install
RUN pnpm install --no-frozen-lockfile
# Copy all source code
COPY . .
# Build shared package
RUN npx turbo run build --filter=@sauci/shared
RUN pnpm exec turbo run build --filter=@sauci/shared
WORKDIR /app/apps/admin
EXPOSE 3001
CMD ["npm", "run", "dev"]
CMD ["pnpm", "run", "dev"]
+1 -1
View File
@@ -25,7 +25,7 @@
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7",
"@sauci/shared": "*",
"@sauci/shared": "workspace:*",
"@supabase/supabase-js": "^2.39.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
+24 -2
View File
@@ -108,17 +108,38 @@ The card reader is the DOER. Their partner is "your partner".
</examples>
<question_types>
1. SYMMETRIC Activities (partner_text = null):
1. SYMMETRIC Activities (partner_text = null, no inverse_pair_id):
- Both partners do the same thing together
- ${symmetricExamples}
2. ASYMMETRIC Actions (requires partner_text):
2. ASYMMETRIC Actions (requires partner_text AND inverse pair):
- One partner does something TO/FOR the other, or roles differ
- text = what the INITIATOR does
- partner_text = what the RECEIVER does/experiences
- ${asymmetricExamples}
- CRITICAL: Every asymmetric question MUST have its inverse created as a separate question
</question_types>
<inverse_pairs>
For EVERY asymmetric question, you MUST create its inverse as a separate question.
Both questions in a pair should have the SAME inverse_pair_id (a unique string per pair).
Example pair (same inverse_pair_id "pair_1"):
Question 1 (primary):
text: "Spank your partner"
partner_text: "Be spanked by your partner"
inverse_pair_id: "pair_1"
Question 2 (inverse):
text: "Be spanked by your partner"
partner_text: "Spank your partner"
inverse_pair_id: "pair_1"
The first question in each pair (lower array index) becomes the "primary" and the second gets linked as its inverse.
Use simple pair IDs like "pair_1", "pair_2", etc.
Symmetric questions should have inverse_pair_id: null.
</inverse_pairs>
<output_format>
Return a JSON object with this exact structure:
{
@@ -128,6 +149,7 @@ Return a JSON object with this exact structure:
"partner_text": string|null, // REQUIRED for asymmetric, null for symmetric
"intensity": 1-5, // REQUIRED: must match the intensity level guidelines
"requires_props": string[]|null, // Optional: items needed (e.g., ["blindfold", "massage oil"])
"inverse_pair_id": string|null, // REQUIRED for asymmetric pairs, null for symmetric
"location_type": "home"|"public"|"outdoors"|"travel"|"anywhere", // Optional
"effort_level": "spontaneous"|"low"|"medium"|"planned" // Optional
}
+3
View File
@@ -43,6 +43,9 @@ export interface GeneratedQuestion {
requires_props?: string[] | null;
location_type?: 'home' | 'public' | 'outdoors' | 'travel' | 'anywhere';
effort_level?: 'spontaneous' | 'low' | 'medium' | 'planned';
// Inverse pair tracking - questions with the same inverse_pair_id are inverses of each other
// First question in pair (lower array index) is primary, second gets inverse_of set
inverse_pair_id?: string | null;
// Cherry-pick provenance (only set in cherry-pick mode)
sourceGeneratorIndex?: number;
sourceGeneratorModel?: string;
@@ -12,6 +12,7 @@ interface QuestionRow {
pack_id: string;
intensity: number | null;
partner_text: string | null;
inverse_of?: string | null;
}
interface PackRow {
@@ -124,7 +125,7 @@ export function QuestionAnalyticsPage() {
const to = from + pageSize - 1;
const { data, error } = await supabase
.from('questions')
.select('id, pack_id, intensity, partner_text')
.select('id, pack_id, intensity, partner_text, inverse_of')
.range(from, to);
if (error) throw error;
@@ -224,7 +225,9 @@ export function QuestionAnalyticsPage() {
let freeCount = 0;
let taggedCount = 0;
questions.forEach((question) => {
const primaryQuestions = questions.filter((question) => question.inverse_of == null);
primaryQuestions.forEach((question) => {
const pack = packById.get(question.pack_id);
const isPremium = pack?.is_premium ?? false;
const segmentKey: SegmentKey = isPremium ? 'premium' : 'free';
@@ -294,7 +297,7 @@ export function QuestionAnalyticsPage() {
}
});
const totalQuestions = questions.length;
const totalQuestions = primaryQuestions.length;
const premiumData: ChartDatum[] = [
{ name: 'Premium', value: premiumCount, color: '#f59e0b' },
{ name: 'Free', value: freeCount, color: '#22c55e' },
+258 -26
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
@@ -29,7 +29,14 @@ import {
DialogTrigger,
} from '@/components/ui/dialog';
import { Card } from '@/components/ui/card';
import { Plus, Pencil, Trash2, Sparkles, Loader2, Users } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus, Pencil, Trash2, Sparkles, Loader2, Users, Link2, Eye } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
@@ -53,6 +60,7 @@ interface Question {
allowed_couple_genders: string[] | null;
target_user_genders: string[] | null;
required_props?: string[] | null;
inverse_of?: string | null;
created_at: string | null;
}
@@ -75,6 +83,9 @@ export function QuestionsPage() {
const [saving, setSaving] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false);
const [peekOpen, setPeekOpen] = useState(false);
const [peekPrimary, setPeekPrimary] = useState<Question | null>(null);
const [peekInverse, setPeekInverse] = useState<Question | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
@@ -87,6 +98,7 @@ export function QuestionsPage() {
allowed_couple_genders: string[];
target_user_genders: string[];
required_props: string;
inverse_of: string;
}>({
text: '',
partner_text: '',
@@ -94,36 +106,55 @@ export function QuestionsPage() {
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
inverse_of: '',
});
// All questions in pack for inverse_of dropdown (loaded once)
const [allPackQuestions, setAllPackQuestions] = useState<Question[]>([]);
const fetchData = useCallback(async () => {
if (!packId) return;
setLoading(true);
try {
// Fetch pack info
const { data: packData } = await supabase
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
const packPromise = supabase
.from('question_packs')
.select('id, name, description, icon, is_explicit')
.eq('id', packId)
.single();
setPack(packData);
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch questions
const { data: questionData, error, count } = await supabase
const questionsPromise = supabase
.from('questions')
.select('*', { count: 'exact' })
.eq('pack_id', packId)
.order('created_at', { ascending: true })
.range(from, to);
const allPackPromise = supabase
.from('questions')
.select('id, text, inverse_of')
.eq('pack_id', packId)
.is('deleted_at', null)
.order('created_at', { ascending: true });
const [
{ data: packData, error: packError },
{ data: questionData, error, count },
{ data: allPackData, error: allPackError },
] = await Promise.all([packPromise, questionsPromise, allPackPromise]);
if (packError) throw packError;
if (error) throw error;
setTotalCount(count || 0);
setQuestions(questionData || []);
setPack(packData);
if (allPackError) {
toast.error('Failed to load inverse peek data');
console.error(allPackError);
} else {
setAllPackQuestions((allPackData as Question[]) || []);
}
} catch (error) {
toast.error('Failed to load questions');
console.error(error);
@@ -175,7 +206,23 @@ export function QuestionsPage() {
setSelectedIds(new Set());
}, [page, pageSize]);
const openCreateDialog = () => {
// Fetch all questions in pack for inverse_of dropdown
const fetchAllPackQuestions = useCallback(async () => {
if (!packId) return;
const { data } = await supabase
.from('questions')
.select('id, text, inverse_of')
.eq('pack_id', packId)
.is('deleted_at', null)
.order('created_at', { ascending: true });
setAllPackQuestions((data as Question[]) || []);
}, [packId]);
const primaryQuestionById = useMemo(() => {
return new Map(allPackQuestions.map(question => [question.id, question]));
}, [allPackQuestions]);
const openCreateDialog = async () => {
setEditingQuestion(null);
setFormData({
text: '',
@@ -184,11 +231,13 @@ export function QuestionsPage() {
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
inverse_of: '',
});
await fetchAllPackQuestions();
setDialogOpen(true);
};
const openEditDialog = (question: Question) => {
const openEditDialog = async (question: Question) => {
setEditingQuestion(question);
setFormData({
text: question.text,
@@ -197,7 +246,9 @@ export function QuestionsPage() {
allowed_couple_genders: question.allowed_couple_genders || [],
target_user_genders: question.target_user_genders || [],
required_props: question.required_props?.join(', ') ?? '',
inverse_of: question.inverse_of || '',
});
await fetchAllPackQuestions();
setDialogOpen(true);
};
@@ -223,6 +274,7 @@ export function QuestionsPage() {
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
required_props: requiredPropsValue,
inverse_of: formData.inverse_of || null,
});
if (error) throw error;
@@ -236,6 +288,7 @@ export function QuestionsPage() {
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
required_props: requiredPropsValue,
inverse_of: formData.inverse_of || null,
});
if (error) throw error;
@@ -269,21 +322,104 @@ export function QuestionsPage() {
}
};
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number }>) => {
// Bulk insert AI-generated questions
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>) => {
// Bulk insert AI-generated questions with inverse_of linking
const insertQuestions = async () => {
try {
const { error } = await auditedSupabase.insert(
'questions',
generatedQuestions.map(q => ({
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
}))
);
// Group questions by inverse_pair_id
const pairMap = new Map<string, typeof generatedQuestions>();
const standaloneQuestions: typeof generatedQuestions = [];
for (const q of generatedQuestions) {
if (q.inverse_pair_id) {
const existing = pairMap.get(q.inverse_pair_id) || [];
existing.push(q);
pairMap.set(q.inverse_pair_id, existing);
} else {
standaloneQuestions.push(q);
}
}
// Insert standalone questions first (no inverse_of)
if (standaloneQuestions.length > 0) {
const { error: standaloneError } = await auditedSupabase.insert(
'questions',
standaloneQuestions.map(q => ({
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
inverse_of: null,
}))
);
if (standaloneError) throw standaloneError;
}
// Insert paired questions: primary first, then inverse with inverse_of link
for (const [, pair] of pairMap) {
if (pair.length >= 2) {
// First question is primary (inverse_of = NULL)
const primary = pair[0];
const { data: primaryData, error: primaryError } = await auditedSupabase.insert(
'questions',
[{
pack_id: packId,
text: primary.text,
partner_text: primary.partner_text || null,
intensity: primary.intensity,
inverse_of: null,
}]
);
if (primaryError) throw primaryError;
// Second question is inverse (inverse_of = primary's id)
const primaryId = primaryData?.[0]?.id;
if (primaryId) {
const inverse = pair[1];
const { error: inverseError } = await auditedSupabase.insert(
'questions',
[{
pack_id: packId,
text: inverse.text,
partner_text: inverse.partner_text || null,
intensity: inverse.intensity,
inverse_of: primaryId,
}]
);
if (inverseError) throw inverseError;
}
// 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(
'questions',
[{
pack_id: packId,
text: extra.text,
partner_text: extra.partner_text || null,
intensity: extra.intensity,
inverse_of: null,
}]
);
if (extraError) throw extraError;
}
} else if (pair.length === 1) {
// Single question with pair_id but no pair - treat as standalone
const { error } = await auditedSupabase.insert(
'questions',
[{
pack_id: packId,
text: pair[0].text,
partner_text: pair[0].partner_text || null,
intensity: pair[0].intensity,
inverse_of: null,
}]
);
if (error) throw error;
}
}
if (error) throw error;
toast.success(`${generatedQuestions.length} questions added`);
fetchData();
} catch (error) {
@@ -361,6 +497,13 @@ export function QuestionsPage() {
}
};
const handlePeekPrimary = (question: Question) => {
if (!question.inverse_of) return;
setPeekPrimary(primaryQuestionById.get(question.inverse_of) ?? null);
setPeekInverse(question);
setPeekOpen(true);
};
if (loading) {
return (
<div className="space-y-6">
@@ -505,6 +648,40 @@ export function QuestionsPage() {
</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="inverse_of">Inverse Of (optional)</Label>
<Badge variant="outline" className="text-xs">
<Link2 className="h-3 w-3 mr-1" />
Paired
</Badge>
</div>
<Select
value={formData.inverse_of || 'none'}
onValueChange={(value) => setFormData(d => ({ ...d, inverse_of: value === 'none' ? '' : value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select primary question..." />
</SelectTrigger>
<SelectContent className="max-h-60">
<SelectItem value="none">
<span className="text-muted-foreground">None (this is a standalone question)</span>
</SelectItem>
{allPackQuestions
.filter(q => q.id !== editingQuestion?.id && !q.inverse_of)
.map(q => (
<SelectItem key={q.id} value={q.id}>
<span className="line-clamp-1">{q.text}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Link this question to its inverse (e.g., "give X" → "receive X"). The selected question is the primary; this one becomes its inverse.
Only standalone questions can be selected as primaries.
</p>
</div>
<div className="space-y-2">
<Label>Target Couples (optional)</Label>
<div className="flex flex-wrap gap-2">
@@ -647,6 +824,33 @@ export function QuestionsPage() {
onUpdated={fetchData}
/>
<Dialog open={peekOpen} onOpenChange={setPeekOpen}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Primary Question</DialogTitle>
<DialogDescription>
Quick peek for the inverse question link.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium">Primary</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{peekPrimary?.text || 'Primary question not found.'}
</p>
</div>
{peekInverse && (
<div className="space-y-1">
<p className="text-sm font-medium">Inverse</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{peekInverse.text}
</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Questions Table */}
{
questions.length === 0 ? (
@@ -681,6 +885,7 @@ export function QuestionsPage() {
</TableHead>
<TableHead className="w-12">#</TableHead>
<TableHead>Question</TableHead>
<TableHead className="w-20">Inverse</TableHead>
<TableHead className="w-32">Partner Text</TableHead>
<TableHead className="w-24">Couples</TableHead>
<TableHead className="w-24">Initiator</TableHead>
@@ -707,6 +912,33 @@ export function QuestionsPage() {
<TableCell>
<span className="line-clamp-2">{question.text}</span>
</TableCell>
<TableCell>
{question.inverse_of ? (
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs border-blue-500 text-blue-500" title="This is an inverse of another question">
<Link2 className="h-3 w-3 mr-1" />
Inverse
</Badge>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => handlePeekPrimary(question)}
aria-label="Peek primary question"
title="Peek primary question"
>
<Eye className="h-3 w-3" />
</Button>
</div>
) : questions.some(q => q.inverse_of === question.id) ? (
<Badge variant="outline" className="text-xs border-green-500 text-green-500" title="This question has an inverse">
<Link2 className="h-3 w-3 mr-1" />
Primary
</Badge>
) : (
<span className="text-muted-foreground text-xs">—</span>
)}
</TableCell>
<TableCell>
{question.partner_text ? (
<Badge variant="secondary" className="text-xs">
+77 -1
View File
@@ -145,24 +145,100 @@ export async function restoreQuestion(id: string): Promise<void> {
/**
* Bulk create questions (for AI generation)
* Handles inverse_pair_id by grouping pairs and setting inverse_of links
*/
export async function bulkCreateQuestions(
packId: string,
questions: Array<{ text: string; partner_text?: string; intensity: number }>
questions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>
): Promise<Question[]> {
const results: Question[] = [];
// Group questions by inverse_pair_id
const pairMap = new Map<string, typeof questions>();
const standaloneQuestions: typeof questions = [];
for (const q of questions) {
if (q.inverse_pair_id) {
const existing = pairMap.get(q.inverse_pair_id) || [];
existing.push(q);
pairMap.set(q.inverse_pair_id, existing);
} else {
standaloneQuestions.push(q);
}
}
// Insert standalone questions first
for (const q of standaloneQuestions) {
const { data, error } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
inverse_of: null,
});
if (error) throw error;
if (data && data.length > 0) results.push(data[0] as Question);
}
// Insert paired questions: primary first, then inverse with inverse_of link
for (const [, pair] of pairMap) {
if (pair.length >= 2) {
// First question is primary
const primary = pair[0];
const { data: primaryData, error: primaryError } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: primary.text,
partner_text: primary.partner_text || null,
intensity: primary.intensity,
inverse_of: null,
});
if (primaryError) throw primaryError;
if (primaryData && primaryData.length > 0) {
results.push(primaryData[0] as Question);
const primaryId = primaryData[0].id;
// Second question is inverse
const inverse = pair[1];
const { data: inverseData, error: inverseError } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: inverse.text,
partner_text: inverse.partner_text || null,
intensity: inverse.intensity,
inverse_of: primaryId,
});
if (inverseError) throw inverseError;
if (inverseData && inverseData.length > 0) results.push(inverseData[0] as Question);
}
// 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', {
pack_id: packId,
text: extra.text,
partner_text: extra.partner_text || null,
intensity: extra.intensity,
inverse_of: null,
});
if (error) throw error;
if (data && data.length > 0) results.push(data[0] as Question);
}
} else if (pair.length === 1) {
// Single question with pair_id but no pair
const { data, error } = await auditedSupabase.insert('questions', {
pack_id: packId,
text: pair[0].text,
partner_text: pair[0].partner_text || null,
intensity: pair[0].intensity,
inverse_of: null,
});
if (error) throw error;
if (data && data.length > 0) results.push(data[0] as Question);
}
}
return results;
}
+12 -10
View File
@@ -28,16 +28,16 @@ export interface Couple {
}
// Category for grouping packs
export interface Category {
id: string;
name: string;
description: string | null;
icon: string | null;
sort_order: number;
created_at: string;
is_public: boolean;
}
export interface Category {
id: string;
name: string;
description: string | null;
icon: string | null;
sort_order: number;
created_at: string;
is_public: boolean;
}
// Question pack
export interface QuestionPack {
@@ -67,6 +67,8 @@ export interface Question {
intensity: 1 | 2 | 3 | 4 | 5;
allowed_couple_genders?: string[] | null;
target_user_genders?: string[] | null;
/** References the primary question that this is an inverse of (e.g., give vs receive). NULL means this is a primary/standalone question. */
inverse_of?: string | null;
created_at: string;
}
@@ -0,0 +1,45 @@
-- Add inverse_of column to questions table
-- This allows tracking when questions are inverses of each other
-- (e.g., "Do you want to give X?" and "Do you want to receive X?")
-- The "primary" question has inverse_of = NULL
-- The "secondary" (inverse) question points to its primary
-- Add the column
ALTER TABLE questions
ADD COLUMN inverse_of UUID REFERENCES questions(id) ON DELETE SET NULL;
-- Add a comment to document the column
COMMENT ON COLUMN questions.inverse_of IS 'References the primary question that this question is an inverse of. Used to track question pairs (e.g., give/receive). Primary questions have inverse_of = NULL, secondary questions point to their primary.';
-- Create an index for efficient lookups
CREATE INDEX idx_questions_inverse_of ON questions(inverse_of) WHERE inverse_of IS NOT NULL;
-- Create a function to get unique question count for a pack
-- Unique count = questions that are NOT inverses of another question (i.e., inverse_of IS NULL)
CREATE OR REPLACE FUNCTION get_pack_unique_question_count(pack_uuid UUID)
RETURNS INTEGER
LANGUAGE SQL
STABLE
AS $$
SELECT COUNT(*)::INTEGER
FROM questions
WHERE pack_id = pack_uuid
AND inverse_of IS NULL
AND deleted_at IS NULL;
$$;
-- Grant execute permission to authenticated users
GRANT EXECUTE ON FUNCTION get_pack_unique_question_count(UUID) TO authenticated;
-- Create a view to get pack stats including both total and unique counts
CREATE OR REPLACE VIEW pack_question_stats AS
SELECT
pack_id,
COUNT(*) FILTER (WHERE deleted_at IS NULL) AS total_questions,
COUNT(*) FILTER (WHERE inverse_of IS NULL AND deleted_at IS NULL) AS unique_questions,
COUNT(*) FILTER (WHERE inverse_of IS NOT NULL AND deleted_at IS NULL) AS inverse_questions
FROM questions
GROUP BY pack_id;
-- Grant select on the view
GRANT SELECT ON pack_question_stats TO authenticated;
@@ -0,0 +1,42 @@
-- Add indexes for unindexed foreign keys
-- These improve JOIN and DELETE performance
CREATE INDEX IF NOT EXISTS idx_admin_users_created_by ON public.admin_users(created_by);
CREATE INDEX IF NOT EXISTS idx_ai_config_updated_by ON public.ai_config(updated_by);
CREATE INDEX IF NOT EXISTS idx_app_config_updated_by ON public.app_config(updated_by);
CREATE INDEX IF NOT EXISTS idx_code_redemptions_user_id ON public.code_redemptions(user_id);
CREATE INDEX IF NOT EXISTS idx_couple_packs_pack_id ON public.couple_packs(pack_id);
CREATE INDEX IF NOT EXISTS idx_dare_messages_sender_id ON public.dare_messages(sender_id);
CREATE INDEX IF NOT EXISTS idx_matches_question_id ON public.matches(question_id);
CREATE INDEX IF NOT EXISTS idx_message_reports_reporter_id ON public.message_reports(reporter_id);
CREATE INDEX IF NOT EXISTS idx_message_reports_reviewed_by ON public.message_reports(reviewed_by);
CREATE INDEX IF NOT EXISTS idx_messages_match_id ON public.messages(match_id);
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON public.messages(user_id);
CREATE INDEX IF NOT EXISTS idx_redemption_codes_created_by ON public.redemption_codes(created_by);
CREATE INDEX IF NOT EXISTS idx_responses_question_id ON public.responses(question_id);
CREATE INDEX IF NOT EXISTS idx_sent_dares_dare_id ON public.sent_dares(dare_id);
-- Drop unused indexes (identified by Supabase advisor as never used)
-- These are just consuming storage with no benefit
DROP INDEX IF EXISTS public.idx_feedback_type;
DROP INDEX IF EXISTS public.idx_feedback_status;
DROP INDEX IF EXISTS public.idx_subscriptions_expires_at;
DROP INDEX IF EXISTS public.idx_webhook_events_event_id;
DROP INDEX IF EXISTS public.idx_audit_logs_record_id;
DROP INDEX IF EXISTS public.idx_audit_logs_action;
DROP INDEX IF EXISTS public.idx_pack_topics_topic_id;
DROP INDEX IF EXISTS public.idx_feature_interests_feature_name;
DROP INDEX IF EXISTS public.idx_profiles_couple_id_public_key;
DROP INDEX IF EXISTS public.idx_messages_deleted_at;
DROP INDEX IF EXISTS public.idx_message_reports_created;
DROP INDEX IF EXISTS public.idx_dare_packs_visibility;
DROP INDEX IF EXISTS public.idx_dares_pack_id;
DROP INDEX IF EXISTS public.idx_dares_intensity;
DROP INDEX IF EXISTS public.idx_sent_dares_sender_id;
DROP INDEX IF EXISTS public.idx_sent_dares_recipient_id;
DROP INDEX IF EXISTS public.idx_sent_dares_status;
DROP INDEX IF EXISTS public.idx_sent_dares_expires_at;
DROP INDEX IF EXISTS public.idx_dare_messages_sent_dare_id;
DROP INDEX IF EXISTS public.idx_dare_messages_created_at;
DROP INDEX IF EXISTS public.idx_questions_deleted_at;