mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
efe93a3db4
commit
df955812fe
22 files changed
+9951
-121
No files matched your search
+18
-13
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user