feat(backend): add standalone data plane

Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
jjbrunton committed 2026-08-28 14:42:11 +01:00
1 parent 01bb8225ae
commit 006761d3b0
294 files changed
+16259 -6138

No files matched your search

+10 -10
View File
@@ -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);
+11 -11
View File
@@ -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) {
+8 -8
View File
@@ -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());
+15 -15
View File
@@ -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(),
+16 -16
View File
@@ -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());
+10 -10
View File
@@ -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,