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

+21 -63
View File
@@ -1,7 +1,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { logAudit } from '../utils/audit.js';
import { adminData } from '../lib/admin-data.js';
import { getAdminApi } from '../lib/admin-api.js';
export function registerUserTools(server: McpServer) {
server.tool(
@@ -15,7 +15,7 @@ export function registerUserTools(server: McpServer) {
async ({ page, limit, search }) => {
const offset = (page - 1) * limit;
let query = supabase
let query = adminData
.from('profiles')
.select('*', { count: 'exact' });
@@ -40,7 +40,7 @@ export function registerUserTools(server: McpServer) {
'Get full user profile details',
{ user_id: z.string() },
async ({ user_id }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('profiles')
.select(`
*,
@@ -62,7 +62,7 @@ export function registerUserTools(server: McpServer) {
'Get user question responses',
{ user_id: z.string(), limit: z.number().default(50) },
async ({ user_id, limit }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('responses')
.select(`
*,
@@ -86,13 +86,13 @@ export function registerUserTools(server: McpServer) {
{ user_id: z.string() },
async ({ user_id }) => {
// First get couple_id
const { data: profile } = await supabase.from('profiles').select('couple_id').eq('id', user_id).single();
const { data: profile } = await adminData.from('profiles').select('couple_id').eq('id', user_id).single();
if (!profile?.couple_id) {
return { content: [{ type: 'text', text: 'User is not in a couple' }] };
}
const { data, error } = await supabase
const { data, error } = await adminData
.from('matches')
.select(`
*,
@@ -114,28 +114,20 @@ export function registerUserTools(server: McpServer) {
'List user uploaded media',
{ user_id: z.string() },
async ({ user_id }) => {
// This is tricky as media is in storage, but usually referenced in messages
// We'll search messages table for media_path where user_id matches
const { data, error } = await supabase
.from('messages')
.select('id, media_path, created_at, match_id')
.eq('user_id', user_id)
.not('media_path', 'is', null)
// Files remain private; the admin API issues short-lived signed URLs.
const { data, error } = await adminData
.from('media_objects')
.select('*')
.eq('owner_id', user_id)
.is('deleted_at', null)
.order('created_at', { ascending: false });
if (error) throw new Error(`Failed to get user media: ${error.message}`);
// Generate public URLs for media
const mediaWithUrls = data.map(m => {
const { data: urlData } = supabase.storage
.from('chat-media')
.getPublicUrl(m.media_path);
return {
...m,
url: urlData.publicUrl
};
});
const mediaWithUrls = await Promise.all(data.map(async (media: Record<string, any>) => ({
...media,
...(await getAdminApi().mediaUrl(media.id)),
})));
return {
content: [{ type: 'text', text: JSON.stringify(mediaWithUrls, null, 2) }]
@@ -148,7 +140,7 @@ export function registerUserTools(server: McpServer) {
'View chat messages for a match',
{ match_id: z.string(), limit: z.number().default(50) },
async ({ match_id, limit }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('messages')
.select('*')
.eq('match_id', match_id)
@@ -172,45 +164,11 @@ export function registerUserTools(server: McpServer) {
days: z.number().default(30),
reason: z.string().optional() // For audit log
},
async ({ user_id, days }) => {
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + days);
const { data: oldData } = await supabase.from('profiles').select('is_premium').eq('id', user_id).single();
// We update profiles table directly or insert into subscriptions?
// Research said `profiles.is_premium` is the flag, but also mentioned `subscriptions` table.
// Usually `subscriptions` drives `is_premium` via webhooks, but for gifting we might just set the flag
// OR insert a manual subscription.
// The admin dashboard uses `auditedSupabase.insert('subscriptions', {...})`.
const { data, error } = await supabase
.from('subscriptions')
.insert({
user_id,
product_id: 'admin_gift',
status: 'active',
expires_at: expiresAt.toISOString(),
is_sandbox: false
})
.select()
.single();
if (error) throw new Error(`Failed to gift premium: ${error.message}`);
// Also ensure profile is updated (if not handled by triggers)
await supabase.from('profiles').update({ is_premium: true }).eq('id', user_id);
await logAudit({
action: 'INSERT',
table_name: 'subscriptions',
record_id: data.id,
new_values: data,
old_values: { reason: 'Premium Gift', old_is_premium: oldData?.is_premium }
});
async ({ user_id, days, reason }) => {
const { expires_at: expiresAt } = await getAdminApi().giftPremium(user_id, days, reason);
return {
content: [{ type: 'text', text: `Premium gifted to user ${user_id} until ${expiresAt.toISOString()}` }]
content: [{ type: 'text', text: `Premium gifted to user ${user_id} until ${expiresAt}` }]
};
}
);