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

+91
View File
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AdminApiClient, AdminApiError } from './admin-api.js';
afterEach(() => vi.restoreAllMocks());
function response(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
describe('AdminApiClient', () => {
it('queries the standalone API with the server bearer credential', async () => {
const fetchImpl = vi.fn(async (_input: URL | RequestInfo, _init?: RequestInit) => response({ rows: [{ id: '1' }], count: 1 }));
const client = new AdminApiClient('http://127.0.0.1:3010', 'server-secret', fetchImpl as typeof fetch);
await expect(client.query('profiles', {
filters: [{ column: 'id', op: 'eq', value: '1' }],
limit: 20,
})).resolves.toEqual({ rows: [{ id: '1' }], count: 1 });
const [url, init] = fetchImpl.mock.calls[0];
expect(String(url)).toBe('http://127.0.0.1:3010/v1/admin/query/profiles');
expect(init?.headers).toMatchObject({ authorization: 'Bearer server-secret' });
expect(JSON.parse(String(init?.body))).toEqual({
filters: [{ column: 'id', op: 'eq', value: '1' }],
limit: 20,
});
});
it('maps inserts, updates, deletes, gifts and private media URLs', async () => {
const fetchImpl = vi.fn<(_input: URL | RequestInfo, _init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(response({ rows: [{ id: 'new' }] }, 201))
.mockResolvedValueOnce(response({ row: { id: 'new', name: 'changed' } }))
.mockResolvedValueOnce(response({ deleted: true }))
.mockResolvedValueOnce(response({ expires_at: '2027-01-01T00:00:00.000Z' }))
.mockResolvedValueOnce(response({ url: 'https://media.test/signed', expires_at: '2026-08-28T00:00:00.000Z' }));
const client = new AdminApiClient('https://api.sauci.test/base/', 'token', fetchImpl as typeof fetch);
await expect(client.insert('categories', [{ name: 'New' }])).resolves.toEqual([{ id: 'new' }]);
await expect(client.update('categories', 'new', { name: 'changed' })).resolves.toMatchObject({ name: 'changed' });
await expect(client.delete('categories', 'new')).resolves.toBeUndefined();
await expect(client.giftPremium('user/id', 30, 'thanks')).resolves.toEqual({ expires_at: '2027-01-01T00:00:00.000Z' });
await expect(client.mediaUrl('media/id')).resolves.toEqual({ url: 'https://media.test/signed', expires_at: '2026-08-28T00:00:00.000Z' });
expect(fetchImpl.mock.calls.map(([url, init]) => [String(url), init?.method])).toEqual([
['https://api.sauci.test/base/v1/admin/data/categories', 'POST'],
['https://api.sauci.test/base/v1/admin/data/categories/new', 'PATCH'],
['https://api.sauci.test/base/v1/admin/data/categories/new', 'DELETE'],
['https://api.sauci.test/base/v1/admin/users/user%2Fid/gift-premium', 'POST'],
['https://api.sauci.test/base/v1/admin/media/media%2Fid/url', 'GET'],
]);
});
it('paginates queryAll until the reported count is reached', async () => {
const first = Array.from({ length: 500 }, (_, id) => ({ id }));
const fetchImpl = vi.fn<(_input: URL | RequestInfo, _init?: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(response({ rows: first, count: 501 }))
.mockResolvedValueOnce(response({ rows: [{ id: 500 }], count: 501 }));
const client = new AdminApiClient('http://localhost:3010', 'token', fetchImpl as typeof fetch);
await expect(client.queryAll('questions')).resolves.toHaveLength(501);
expect(JSON.parse(String(fetchImpl.mock.calls[1][1]?.body))).toMatchObject({ limit: 500, offset: 500 });
});
it('uses the aggregate feature-interest endpoint instead of exporting user rows', async () => {
const counts = [{ feature_name: 'live_draw', opt_in_count: 4, opt_in_count_last_7_days: 2 }];
const fetchImpl = vi.fn(async (_input: URL | RequestInfo, _init?: RequestInit) => response({ counts }));
const client = new AdminApiClient('https://api.sauci.test', 'token', fetchImpl as typeof fetch);
await expect(client.featureInterestCounts()).resolves.toEqual(counts);
expect(String(fetchImpl.mock.calls[0][0])).toBe('https://api.sauci.test/v1/admin/feature-interest-counts');
expect(fetchImpl.mock.calls[0][1]?.method).toBe('GET');
});
it('returns structured API failures without exposing the credential', async () => {
const fetchImpl = vi.fn(async (_input: URL | RequestInfo, _init?: RequestInit) => response({ error: { code: 'forbidden', message: 'Missing permission' } }, 403));
const client = new AdminApiClient('https://api.sauci.test', 'never-print-me', fetchImpl as typeof fetch);
const error = await client.query('profiles').catch((cause) => cause);
expect(error).toBeInstanceOf(AdminApiError);
expect(error).toMatchObject({ status: 403, code: 'forbidden', message: 'Missing permission' });
expect(String(error)).not.toContain('never-print-me');
});
it('rejects credentials sent over non-local cleartext HTTP', () => {
expect(() => new AdminApiClient('http://api.sauci.test', 'token')).toThrow(/HTTPS/);
expect(() => new AdminApiClient('http://localhost:3010', '')).toThrow(/TOKEN/);
});
});
+137
View File
@@ -0,0 +1,137 @@
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;
};
type Fetch = typeof fetch;
export class AdminApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string,
) {
super(message);
this.name = 'AdminApiError';
}
}
function validatedBaseUrl(value: string): URL {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error('SAUCI_ADMIN_API_URL must be a valid URL');
}
const local = ['localhost', '127.0.0.1', '::1'].includes(url.hostname);
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) {
throw new Error('SAUCI_ADMIN_API_URL must use HTTPS (HTTP is allowed only for localhost)');
}
url.pathname = url.pathname.replace(/\/$/, '');
return url;
}
export class AdminApiClient {
private readonly baseUrl: URL;
constructor(
baseUrl: string,
private readonly token: string,
private readonly fetchImpl: Fetch = fetch,
) {
this.baseUrl = validatedBaseUrl(baseUrl);
if (!token.trim()) throw new Error('SAUCI_ADMIN_API_TOKEN is required');
}
async query<T extends Record<string, unknown> = Record<string, unknown>>(resource: string, query: AdminQuery = {}) {
return this.request<{ rows: T[]; count: number }>('POST', `/v1/admin/query/${resource}`, query);
}
async queryAll<T extends Record<string, unknown> = Record<string, unknown>>(resource: string, query: Omit<AdminQuery, 'limit' | 'offset'> = {}) {
const rows: T[] = [];
for (let offset = 0; ; offset += 500) {
const page = await this.query<T>(resource, { ...query, limit: 500, offset });
rows.push(...page.rows);
if (rows.length >= page.count || page.rows.length < 500) return rows;
}
}
async insert<T extends Record<string, unknown> = Record<string, unknown>>(resource: string, records: Record<string, unknown>[]) {
const result = await this.request<{ rows: T[] }>('POST', `/v1/admin/data/${resource}`, { records });
return result.rows;
}
async update<T extends Record<string, unknown> = Record<string, unknown>>(resource: string, id: string, values: Record<string, unknown>) {
const result = await this.request<{ row: T }>('PATCH', `/v1/admin/data/${resource}/${encodeURIComponent(id)}`, { values });
return result.row;
}
async delete(resource: string, id: string): Promise<void> {
await this.request('DELETE', `/v1/admin/data/${resource}/${encodeURIComponent(id)}`);
}
async dashboard(): Promise<Record<string, number>> {
return this.request('GET', '/v1/admin/dashboard');
}
async featureInterestCounts(): Promise<Array<{ feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number }>> {
const result = await this.request<{ counts: Array<{ feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number }> }>(
'GET',
'/v1/admin/feature-interest-counts',
);
return result.counts;
}
async giftPremium(userId: string, days: number, reason?: string): Promise<{ expires_at: string }> {
return this.request('POST', `/v1/admin/users/${encodeURIComponent(userId)}/gift-premium`, { days, reason });
}
async mediaUrl(mediaId: string): Promise<{ url: string; expires_at: string }> {
return this.request('GET', `/v1/admin/media/${encodeURIComponent(mediaId)}/url`);
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const response = await this.fetchImpl(this.endpoint(path), {
method,
headers: {
authorization: `Bearer ${this.token}`,
accept: 'application/json',
...(body === undefined ? {} : { 'content-type': 'application/json' }),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const payload = await response.json().catch(() => undefined) as { error?: { code?: string; message?: string } } | undefined;
if (!response.ok) {
throw new AdminApiError(payload?.error?.message ?? `Admin API request failed with status ${response.status}`, response.status, payload?.error?.code);
}
return payload as T;
}
private endpoint(path: string): URL {
const url = new URL(this.baseUrl.origin);
const prefix = this.baseUrl.pathname === '/' ? '' : this.baseUrl.pathname;
url.pathname = `${prefix}${path}`;
return url;
}
}
let sharedClient: AdminApiClient | undefined;
export function getAdminApi(): AdminApiClient {
if (sharedClient) return sharedClient;
const url = process.env.SAUCI_ADMIN_API_URL;
const token = process.env.SAUCI_ADMIN_API_TOKEN;
if (!url || !token) {
throw new Error('SAUCI_ADMIN_API_URL and SAUCI_ADMIN_API_TOKEN are required for MCP data access');
}
sharedClient = new AdminApiClient(url, token);
return sharedClient;
}
+83
View File
@@ -0,0 +1,83 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { adminData } from './admin-data.js';
const originalUrl = process.env.SAUCI_ADMIN_API_URL;
const originalToken = process.env.SAUCI_ADMIN_API_TOKEN;
beforeAll(() => {
process.env.SAUCI_ADMIN_API_URL = 'http://localhost:3010';
process.env.SAUCI_ADMIN_API_TOKEN = 'test-service-token';
vi.stubGlobal('fetch', vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => {
const url = new URL(String(input));
const body = init?.body ? JSON.parse(String(init.body)) : {};
if (url.pathname === '/v1/admin/query/feedback') {
return Response.json({ rows: [{ id: 'f1', user_id: 'u1', question_id: 'q1' }], count: 1 });
}
if (url.pathname === '/v1/admin/query/questions') {
return Response.json({ rows: [{ id: 'q1', text: 'Question', intensity: 3, private: 'not returned in relation' }], count: 1 });
}
if (url.pathname === '/v1/admin/query/profiles') {
const rows = body.filters?.length
? [{ id: 'u1', name: 'User One', email: 'one@example.test', push_token: 'private' }]
: [
{ id: 'u1', name: 'Alex', email: 'one@example.test' },
{ id: 'u2', name: 'Sam', email: 'sam@example.test' },
{ id: 'u3', name: 'No match', email: 'none@example.test' },
];
return Response.json({ rows, count: rows.length });
}
if (url.pathname === '/v1/admin/data/categories/c1' && init?.method === 'PATCH') {
return Response.json({ row: { id: 'c1', name: body.values.name } });
}
return Response.json({ error: { code: 'unexpected', message: `${init?.method} ${url.pathname}` } }, { status: 500 });
}));
});
afterAll(() => {
vi.unstubAllGlobals();
if (originalUrl === undefined) delete process.env.SAUCI_ADMIN_API_URL;
else process.env.SAUCI_ADMIN_API_URL = originalUrl;
if (originalToken === undefined) delete process.env.SAUCI_ADMIN_API_TOKEN;
else process.env.SAUCI_ADMIN_API_TOKEN = originalToken;
});
describe('adminData compatibility behavior', () => {
it('preserves requested relationship shapes without exposing unrelated profile fields', async () => {
const result = await adminData.from('feedback').select(`
*,
user:user_id (name, email),
question:question_id (text)
`).eq('id', 'f1').single();
expect(result.error).toBeNull();
expect(result.data).toMatchObject({
id: 'f1',
user: { name: 'User One', email: 'one@example.test' },
question: { text: 'Question' },
});
expect(result.data.user).not.toHaveProperty('push_token');
expect(result.data.question).not.toHaveProperty('private');
});
it('preserves OR search, pagination and union counts client-side', async () => {
const result = await adminData.from('profiles')
.select('*', { count: 'exact' })
.or('name.ilike.%sam%,email.ilike.%sam%')
.order('created_at', { ascending: false })
.range(0, 9);
expect(result.error).toBeNull();
expect(result.count).toBe(1);
expect(result.data.map((row: Record<string, unknown>) => row.id)).toEqual(['u2']);
});
it('routes mutations through the audited standalone endpoint', async () => {
const result = await adminData.from('categories').update({ name: 'Changed' }).eq('id', 'c1').select().single();
expect(result).toEqual({ data: { id: 'c1', name: 'Changed' }, error: null, count: null });
const fetchMock = vi.mocked(fetch);
const mutation = fetchMock.mock.calls.find(([input]) => String(input).endsWith('/v1/admin/data/categories/c1'));
expect(mutation?.[1]).toMatchObject({ method: 'PATCH' });
expect(mutation?.[1]?.headers).toMatchObject({ authorization: 'Bearer test-service-token' });
});
});
+172
View File
@@ -0,0 +1,172 @@
import { AdminApiError, getAdminApi, type AdminFilter, type AdminQuery } from './admin-api.js';
type Row = Record<string, any>;
type Result = { data: any; error: { message: string } | null; count: number | null };
type Action = 'select' | 'insert' | 'update' | 'delete';
class AdminQueryBuilder implements PromiseLike<Result> {
private action: Action = 'select';
private filters: AdminFilter[] = [];
private orderBy?: AdminQuery['order'];
private pageLimit?: number;
private pageOffset?: number;
private input?: Row | Row[];
private selection = '*';
private localPredicates: Array<(row: Row) => boolean> = [];
private cardinality: 'many' | 'single' | 'maybeSingle' = 'many';
constructor(private readonly resource: string) {}
select(columns = '*', _options?: { count?: 'exact'; head?: boolean }) { this.selection = columns; return this; }
insert(values: Row | Row[]) { this.action = 'insert'; this.input = values; return this; }
update(values: Row) { this.action = 'update'; this.input = values; return this; }
delete() { this.action = 'delete'; return this; }
eq(column: string, value: unknown) { this.filters.push({ column, op: 'eq', value }); return this; }
neq(column: string, value: unknown) { this.filters.push({ column, op: 'neq', value }); return this; }
in(column: string, value: unknown[]) { this.filters.push({ column, op: 'in', value }); return this; }
is(column: string, value: unknown) { this.filters.push({ column, op: 'is', value }); return this; }
not(column: string, operator: string, value: unknown) {
if (operator === 'is' && value === null) this.localPredicates.push((row) => row[column] !== null && row[column] !== undefined);
else throw new Error(`Unsupported admin query: not(${column}, ${operator})`);
return this;
}
or(expression: string) {
const conditions = expression.split(',').map((part) => {
const match = part.match(/^([a-z_][a-z0-9_]*)\.ilike\.(.*)$/i);
if (!match) throw new Error('Only ilike OR expressions are supported by the MCP admin client');
const pattern = match[2].replace(/^%|%$/g, '').toLocaleLowerCase();
return (row: Row) => String(row[match[1]] ?? '').toLocaleLowerCase().includes(pattern);
});
this.localPredicates.push((row) => conditions.some((condition) => condition(row)));
return this;
}
order(column: string, options?: { ascending?: boolean }) { this.orderBy = { column, ascending: options?.ascending }; return this; }
range(from: number, to: number) { this.pageOffset = from; this.pageLimit = Math.max(to - from + 1, 0); return this; }
limit(limit: number) { this.pageLimit = limit; return this; }
single() { this.cardinality = 'single'; return this.execute(); }
maybeSingle() { this.cardinality = 'maybeSingle'; return this.execute(); }
then<TResult1 = Result, TResult2 = never>(
onfulfilled?: ((value: Result) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): PromiseLike<TResult1 | TResult2> {
return this.execute().then(onfulfilled, onrejected);
}
private async execute(): Promise<Result> {
try {
const api = getAdminApi();
if (this.action === 'insert') {
const rows = await api.insert(this.resource, Array.isArray(this.input) ? this.input : [this.input!]);
return this.success(rows);
}
if (this.action === 'update') {
const id = this.mutationIds()[0];
const row = await api.update(this.resource, id, this.input as Row);
return this.success([row]);
}
if (this.action === 'delete') {
await Promise.all(this.mutationIds().map((id) => api.delete(this.resource, id)));
return { data: null, error: null, count: null };
}
let rows: Row[];
let count: number;
if (this.localPredicates.length) {
rows = await api.queryAll(this.resource, { filters: this.filters, order: this.orderBy });
rows = rows.filter((row) => this.localPredicates.every((predicate) => predicate(row)));
count = rows.length;
rows = rows.slice(this.pageOffset ?? 0, (this.pageOffset ?? 0) + (this.pageLimit ?? rows.length));
} else {
const result = await api.query(this.resource, {
filters: this.filters,
order: this.orderBy,
limit: this.pageLimit,
offset: this.pageOffset,
});
rows = result.rows;
count = result.count;
}
rows = await enrich(this.resource, this.selection, rows);
return this.success(rows, count);
} catch (cause) {
const message = cause instanceof Error ? cause.message : 'Unknown admin API error';
return { data: null, error: { message }, count: null };
}
}
private mutationIds(): string[] {
const idFilter = this.filters.find((filter) => filter.column === 'id' && (filter.op === 'eq' || filter.op === 'in'));
if (!idFilter) throw new Error('Admin mutations require an id filter');
return (Array.isArray(idFilter.value) ? idFilter.value : [idFilter.value]).map(String);
}
private success(rows: Row[], count: number | null = null): Result {
if (this.cardinality === 'single' && rows.length !== 1) return { data: null, error: { message: `Expected one row, received ${rows.length}` }, count };
if (this.cardinality === 'maybeSingle' && rows.length > 1) return { data: null, error: { message: `Expected at most one row, received ${rows.length}` }, count };
return { data: this.cardinality === 'many' ? rows : rows[0] ?? null, error: null, count };
}
}
async function rowsByIds(resource: string, ids: unknown[]): Promise<Map<string, Row>> {
const unique = [...new Set(ids.filter(Boolean).map(String))];
if (!unique.length) return new Map();
const rows = await getAdminApi().queryAll(resource, { filters: [{ column: 'id', op: 'in', value: unique }] });
return new Map(rows.map((row) => [String(row.id), row]));
}
async function enrich(resource: string, selection: string, rows: Row[]): Promise<Row[]> {
if (!rows.length || selection.trim() === '*') return rows;
if (resource === 'categories' && selection.includes('question_packs')) {
const packs = await getAdminApi().queryAll('question_packs');
return rows.map((row) => ({ ...row, question_packs: [{ count: packs.filter((pack) => pack.category_id === row.id).length }] }));
}
if (resource === 'question_packs' && selection.includes('questions')) {
const questions = await getAdminApi().queryAll('questions');
return rows.map((row) => ({ ...row, questions: [{ count: questions.filter((question) => question.pack_id === row.id).length }] }));
}
const relations = relationsFor(resource, selection);
let enriched = rows;
for (const relation of relations) {
const related = await rowsByIds(relation.resource, enriched.map((row) => row[relation.foreignKey]));
enriched = enriched.map((row) => {
const value = related.get(String(row[relation.foreignKey]));
const projected = value && relation.fields
? Object.fromEntries(relation.fields.map((field) => [field, value[field]]))
: value;
return { ...row, [relation.alias]: projected ?? null };
});
}
return enriched;
}
function relationsFor(resource: string, selection: string): Array<{ alias: string; resource: string; foreignKey: string; fields?: string[] }> {
const candidates: Record<string, Array<{ marker: string; alias: string; resource: string; foreignKey: string; fields?: string[] }>> = {
admin_users: [{ marker: 'user:user_id', alias: 'user', resource: 'profiles', foreignKey: 'user_id', fields: ['name', 'email', 'avatar_url'] }],
code_redemptions: [{ marker: 'user:user_id', alias: 'user', resource: 'profiles', foreignKey: 'user_id', fields: ['name', 'email'] }],
profiles: [{ marker: 'couple:couple_id', alias: 'couple', resource: 'couples', foreignKey: 'couple_id' }],
responses: [{ marker: 'question:question_id', alias: 'question', resource: 'questions', foreignKey: 'question_id', fields: ['text', 'intensity'] }],
matches: [{ marker: 'question:question_id', alias: 'question', resource: 'questions', foreignKey: 'question_id', fields: ['text'] }],
feedback: [
{ marker: 'question:question_id', alias: 'question', resource: 'questions', foreignKey: 'question_id', fields: ['text'] },
{ marker: 'user:user_id', alias: 'user', resource: 'profiles', foreignKey: 'user_id', fields: ['name', 'email'] },
],
message_reports: [
{ marker: 'message:message_id', alias: 'message', resource: 'messages', foreignKey: 'message_id' },
{ marker: 'reporter:reporter_id', alias: 'reporter', resource: 'profiles', foreignKey: 'reporter_id', fields: ['name', 'email'] },
],
};
return candidates[resource]?.filter((candidate) => selection.includes(candidate.marker)) ?? [];
}
export const adminData = {
from(resource: string) { return new AdminQueryBuilder(resource); },
async featureInterestCounts(): Promise<Result> {
try {
const counts = await getAdminApi().featureInterestCounts();
return { data: counts, error: null, count: counts.length };
} catch (cause) {
return { data: null, error: { message: cause instanceof AdminApiError ? cause.message : String(cause) }, count: null };
}
},
};
-22
View File
@@ -1,22 +0,0 @@
import { createClient } from '@supabase/supabase-js';
import { config } from 'dotenv';
config();
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
console.warn('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables');
}
export const supabase = createClient(
supabaseUrl || '',
supabaseServiceKey || '',
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
);
+8 -8
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerAdminTools(server: McpServer) {
@@ -9,7 +9,7 @@ export function registerAdminTools(server: McpServer) {
'List all admin users',
{},
async () => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('admin_users')
.select(`
*,
@@ -33,7 +33,7 @@ export function registerAdminTools(server: McpServer) {
permissions: z.array(z.string()).optional()
},
async ({ user_id, role, permissions }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('admin_users')
.insert({
user_id,
@@ -67,13 +67,13 @@ export function registerAdminTools(server: McpServer) {
permissions: z.array(z.string()).optional()
},
async ({ id, role, permissions }) => {
const { data: oldData } = await supabase.from('admin_users').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('admin_users').select('*').eq('id', id).single();
const updates: any = {};
if (role) updates.role = role;
if (permissions) updates.permissions = permissions;
const { data, error } = await supabase
const { data, error } = await adminData
.from('admin_users')
.update(updates)
.eq('id', id)
@@ -101,9 +101,9 @@ export function registerAdminTools(server: McpServer) {
'Revoke admin access',
{ id: z.string() }, // admin_users ID
async ({ id }) => {
const { data: oldData } = await supabase.from('admin_users').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('admin_users').select('*').eq('id', id).single();
const { error } = await supabase.from('admin_users').delete().eq('id', id);
const { error } = await adminData.from('admin_users').delete().eq('id', id);
if (error) throw new Error(`Failed to remove admin: ${error.message}`);
await logAudit({
@@ -129,7 +129,7 @@ export function registerAdminTools(server: McpServer) {
action: z.enum(['INSERT', 'UPDATE', 'DELETE']).optional()
},
async ({ limit, offset, table_name, action }) => {
let query = supabase
let query = adminData
.from('audit_logs')
.select('*', { count: 'exact' });
+14 -31
View File
@@ -1,5 +1,5 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
export function registerAnalyticsTools(server: McpServer) {
server.tool(
@@ -15,11 +15,11 @@ export function registerAnalyticsTools(server: McpServer) {
{ count: profiles },
{ count: couples }
] = 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 }),
supabase.from('couples').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 }),
adminData.from('couples').select('*', { count: 'exact', head: true })
]);
return {
@@ -41,27 +41,8 @@ export function registerAnalyticsTools(server: McpServer) {
'Get feature interest stats',
{},
async () => {
const { data, error } = await supabase.rpc('get_feature_interest_counts');
// Fallback if RPC doesn't exist or fails, query table directly if possible
if (error) {
// Try raw query on feature_interests table
const { data: rawData, error: rawError } = await supabase
.from('feature_interests')
.select('feature_name');
if (rawError) throw new Error(`Failed to get feature interests: ${rawError.message}`);
// Manual aggregation
const counts: Record<string, number> = {};
rawData.forEach((r: any) => {
counts[r.feature_name] = (counts[r.feature_name] || 0) + 1;
});
return {
content: [{ type: 'text', text: JSON.stringify(counts, null, 2) }]
};
}
const { data, error } = await adminData.featureInterestCounts();
if (error) throw new Error(`Failed to get feature interests: ${error.message}`);
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
@@ -75,7 +56,7 @@ export function registerAnalyticsTools(server: McpServer) {
{},
async () => {
// Aggregating onboarding data from profiles
const { data, error } = await supabase
const { data, error } = await adminData
.from('profiles')
.select('usage_reason, gender');
@@ -84,9 +65,11 @@ export function registerAnalyticsTools(server: McpServer) {
const reasons: Record<string, number> = {};
const genders: Record<string, number> = {};
data.forEach((p) => {
if (p.usage_reason) reasons[p.usage_reason] = (reasons[p.usage_reason] || 0) + 1;
if (p.gender) genders[p.gender] = (genders[p.gender] || 0) + 1;
data.forEach((p: Record<string, unknown>) => {
const usageReason = typeof p.usage_reason === 'string' ? p.usage_reason : undefined;
const gender = typeof p.gender === 'string' ? p.gender : undefined;
if (usageReason) reasons[usageReason] = (reasons[usageReason] || 0) + 1;
if (gender) genders[gender] = (genders[gender] || 0) + 1;
});
return {
+8 -8
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerCodeTools(server: McpServer) {
@@ -15,7 +15,7 @@ export function registerCodeTools(server: McpServer) {
async ({ page, limit, search }) => {
const offset = (page - 1) * limit;
let query = supabase
let query = adminData
.from('redemption_codes')
.select('*', { count: 'exact' });
@@ -48,7 +48,7 @@ export function registerCodeTools(server: McpServer) {
async ({ code, description, max_uses, expires_at, is_active }) => {
const finalCode = code || Math.random().toString(36).substring(2, 10).toUpperCase();
const { data, error } = await supabase
const { data, error } = await adminData
.from('redemption_codes')
.insert({
code: finalCode,
@@ -81,9 +81,9 @@ export function registerCodeTools(server: McpServer) {
'Enable or disable a redemption code',
{ id: z.string(), is_active: z.boolean() },
async ({ id, is_active }) => {
const { data: oldData } = await supabase.from('redemption_codes').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('redemption_codes').select('*').eq('id', id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('redemption_codes')
.update({ is_active })
.eq('id', id)
@@ -111,9 +111,9 @@ export function registerCodeTools(server: McpServer) {
'Delete a redemption code',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('redemption_codes').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('redemption_codes').select('*').eq('id', id).single();
const { error } = await supabase.from('redemption_codes').delete().eq('id', id);
const { error } = await adminData.from('redemption_codes').delete().eq('id', id);
if (error) throw new Error(`Failed to delete code: ${error.message}`);
await logAudit({
@@ -134,7 +134,7 @@ export function registerCodeTools(server: McpServer) {
'See who redeemed a code',
{ code_id: z.string() },
async ({ code_id }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('code_redemptions')
.select(`
*,
+8 -10
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerConfigTools(server: McpServer) {
@@ -9,7 +9,7 @@ export function registerConfigTools(server: McpServer) {
'Get AI configuration',
{},
async () => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('ai_config')
.select('*')
.single();
@@ -35,14 +35,14 @@ export function registerConfigTools(server: McpServer) {
},
async (updates) => {
// Get current ID (singleton table usually has 1 row, but need ID for update)
const { data: current, error: fetchError } = await supabase
const { data: current, error: fetchError } = await adminData
.from('ai_config')
.select('*')
.single();
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
const { data, error } = await supabase
const { data, error } = await adminData
.from('ai_config')
.update(updates)
.eq('id', current.id)
@@ -59,9 +59,7 @@ export function registerConfigTools(server: McpServer) {
new_values: data
});
return {
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
};
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
}
);
@@ -70,7 +68,7 @@ export function registerConfigTools(server: McpServer) {
'Get app configuration',
{},
async () => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('app_config')
.select('*')
.single();
@@ -91,14 +89,14 @@ export function registerConfigTools(server: McpServer) {
daily_response_limit: z.number().optional()
},
async (updates) => {
const { data: current, error: fetchError } = await supabase
const { data: current, error: fetchError } = await adminData
.from('app_config')
.select('*')
.single();
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
const { data, error } = await supabase
const { data, error } = await adminData
.from('app_config')
.update(updates)
.eq('id', current.id)
+19 -19
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerContentTools(server: McpServer) {
@@ -11,7 +11,7 @@ export function registerContentTools(server: McpServer) {
'List all categories with pack counts',
{},
async () => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('categories')
.select(`
*,
@@ -38,7 +38,7 @@ export function registerContentTools(server: McpServer) {
is_public: z.boolean().default(false)
},
async ({ name, description, icon, sort_order, is_public }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('categories')
.insert({ name, description, icon, sort_order: sort_order ?? 0, is_public })
.select()
@@ -72,10 +72,10 @@ export function registerContentTools(server: McpServer) {
},
async ({ id, ...updates }) => {
// Get old values for audit
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('categories').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Category not found: ${id}`);
const { data, error } = await supabase
const { data, error } = await adminData
.from('categories')
.update(updates)
.eq('id', id)
@@ -103,10 +103,10 @@ export function registerContentTools(server: McpServer) {
'Delete a category',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('categories').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Category not found: ${id}`);
const { error } = await supabase
const { error } = await adminData
.from('categories')
.delete()
.eq('id', id);
@@ -133,7 +133,7 @@ export function registerContentTools(server: McpServer) {
'List packs for a category or all packs',
{ category_id: z.string().optional() },
async ({ category_id }) => {
let query = supabase
let query = adminData
.from('question_packs')
.select(`
*,
@@ -167,7 +167,7 @@ export function registerContentTools(server: McpServer) {
sort_order: z.number().optional()
},
async (input) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('question_packs')
.insert({ ...input, sort_order: input.sort_order ?? 0 })
.select()
@@ -202,10 +202,10 @@ export function registerContentTools(server: McpServer) {
sort_order: z.number().optional()
},
async ({ id, ...updates }) => {
const { data: oldData } = await supabase.from('question_packs').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('question_packs').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Pack not found: ${id}`);
const { data, error } = await supabase
const { data, error } = await adminData
.from('question_packs')
.update(updates)
.eq('id', id)
@@ -233,10 +233,10 @@ export function registerContentTools(server: McpServer) {
'Delete a question pack',
{ id: z.string() },
async ({ id }) => {
const { data: oldData } = await supabase.from('question_packs').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('question_packs').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Pack not found: ${id}`);
const { error } = await supabase.from('question_packs').delete().eq('id', id);
const { error } = await adminData.from('question_packs').delete().eq('id', id);
if (error) throw new Error(`Failed to delete pack: ${error.message}`);
await logAudit({
@@ -259,7 +259,7 @@ export function registerContentTools(server: McpServer) {
'List questions for a pack',
{ pack_id: z.string() },
async ({ pack_id }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('questions')
.select('*')
.eq('pack_id', pack_id)
@@ -283,7 +283,7 @@ export function registerContentTools(server: McpServer) {
intensity: z.number().min(1).max(5).default(1)
},
async (input) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('questions')
.insert(input)
.select()
@@ -315,10 +315,10 @@ export function registerContentTools(server: McpServer) {
pack_id: z.string().optional()
},
async ({ id, ...updates }) => {
const { data: oldData } = await supabase.from('questions').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('questions').select('*').eq('id', id).single();
if (!oldData) throw new Error(`Question not found: ${id}`);
const { data, error } = await supabase
const { data, error } = await adminData
.from('questions')
.update(updates)
.eq('id', id)
@@ -346,9 +346,9 @@ export function registerContentTools(server: McpServer) {
'Delete one or more questions',
{ ids: z.array(z.string()) },
async ({ ids }) => {
const { data: oldData } = await supabase.from('questions').select('*').in('id', ids);
const { data: oldData } = await adminData.from('questions').select('*').in('id', ids);
const { error } = await supabase.from('questions').delete().in('id', ids);
const { error } = await adminData.from('questions').delete().in('id', ids);
if (error) throw new Error(`Failed to delete questions: ${error.message}`);
// Log audit for each deleted question
+7 -7
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerFeedbackTools(server: McpServer) {
@@ -14,7 +14,7 @@ export function registerFeedbackTools(server: McpServer) {
offset: z.number().default(0)
},
async ({ type, status, limit, offset }) => {
let query = supabase
let query = adminData
.from('feedback')
.select(`
*,
@@ -41,7 +41,7 @@ export function registerFeedbackTools(server: McpServer) {
'Get full feedback details',
{ id: z.string() },
async ({ id }) => {
const { data, error } = await supabase
const { data, error } = await adminData
.from('feedback')
.select(`
*,
@@ -67,9 +67,9 @@ export function registerFeedbackTools(server: McpServer) {
status: z.enum(['new', 'reviewed', 'in_progress', 'resolved', 'closed'])
},
async ({ id, status }) => {
const { data: oldData } = await supabase.from('feedback').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('feedback').select('*').eq('id', id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('feedback')
.update({ status })
.eq('id', id)
@@ -97,9 +97,9 @@ export function registerFeedbackTools(server: McpServer) {
'Add admin notes to feedback',
{ id: z.string(), notes: z.string() },
async ({ id, notes }) => {
const { data: oldData } = await supabase.from('feedback').select('*').eq('id', id).single();
const { data: oldData } = await adminData.from('feedback').select('*').eq('id', id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('feedback')
.update({ admin_notes: notes })
.eq('id', id)
+9 -9
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { supabase } from '../lib/supabase.js';
import { adminData } from '../lib/admin-data.js';
import { logAudit } from '../utils/audit.js';
export function registerModerationTools(server: McpServer) {
@@ -12,7 +12,7 @@ export function registerModerationTools(server: McpServer) {
offset: z.number().default(0)
},
async ({ limit, offset }) => {
const { data, error, count } = await supabase
const { data, error, count } = await adminData
.from('messages')
.select('*', { count: 'exact' })
.eq('moderation_status', 'flagged')
@@ -36,7 +36,7 @@ export function registerModerationTools(server: McpServer) {
offset: z.number().default(0)
},
async ({ status, limit, offset }) => {
const { data, error, count } = await supabase
const { data, error, count } = await adminData
.from('message_reports')
.select(`
*,
@@ -60,9 +60,9 @@ export function registerModerationTools(server: McpServer) {
'Mark a flagged message as safe',
{ message_id: z.string() },
async ({ message_id }) => {
const { data: oldData } = await supabase.from('messages').select('*').eq('id', message_id).single();
const { data: oldData } = await adminData.from('messages').select('*').eq('id', message_id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('messages')
.update({ moderation_status: 'safe' })
.eq('id', message_id)
@@ -90,9 +90,9 @@ export function registerModerationTools(server: McpServer) {
'Dismiss a user report',
{ report_id: z.string() },
async ({ report_id }) => {
const { data: oldData } = await supabase.from('message_reports').select('*').eq('id', report_id).single();
const { data: oldData } = await adminData.from('message_reports').select('*').eq('id', report_id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('message_reports')
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
.eq('id', report_id)
@@ -120,9 +120,9 @@ export function registerModerationTools(server: McpServer) {
'Mark a report as reviewed (action taken)',
{ report_id: z.string() },
async ({ report_id }) => {
const { data: oldData } = await supabase.from('message_reports').select('*').eq('id', report_id).single();
const { data: oldData } = await adminData.from('message_reports').select('*').eq('id', report_id).single();
const { data, error } = await supabase
const { data, error } = await adminData
.from('message_reports')
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
.eq('id', report_id)
+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}` }]
};
}
);
+6 -71
View File
@@ -1,81 +1,16 @@
import { supabase } from '../lib/supabase.js';
export interface AuditLogEntry {
action: 'INSERT' | 'UPDATE' | 'DELETE';
table_name: string;
record_id?: string;
old_values?: Record<string, any>;
new_values?: Record<string, any>;
old_values?: Record<string, unknown> | null;
new_values?: Record<string, unknown> | null;
admin_user_id?: string;
}
let cachedSystemAdminId: string | null = null;
async function getSystemAdminId(): Promise<string | null> {
if (cachedSystemAdminId) return cachedSystemAdminId;
// Try to find a super_admin user
const { data, error } = await supabase
.from('admin_users')
.select('user_id')
.eq('role', 'super_admin')
.limit(1)
.maybeSingle();
if (data?.user_id) {
cachedSystemAdminId = data.user_id;
return data.user_id;
}
// Fallback: any admin user
const { data: anyAdmin } = await supabase
.from('admin_users')
.select('user_id')
.limit(1)
.maybeSingle();
if (anyAdmin?.user_id) {
cachedSystemAdminId = anyAdmin.user_id;
return anyAdmin.user_id;
}
return null;
}
/**
* Log an admin action to the audit_logs table
* Standalone admin API mutations are authorized and audited atomically by the
* API. This compatibility hook deliberately does not create a duplicate row.
*/
export async function logAudit(entry: AuditLogEntry) {
try {
let adminUserId = entry.admin_user_id;
if (!adminUserId) {
adminUserId = await getSystemAdminId() || undefined;
}
if (!adminUserId) {
console.warn('Cannot log audit entry: No admin user found to attribute action to');
return;
}
// Ensure values are plain objects, not Supabase response objects
const cleanOldValues = entry.old_values ? JSON.parse(JSON.stringify(entry.old_values)) : null;
const cleanNewValues = entry.new_values ? JSON.parse(JSON.stringify(entry.new_values)) : null;
const { error } = await supabase.from('audit_logs').insert({
action: entry.action,
table_name: entry.table_name,
record_id: entry.record_id || '00000000-0000-0000-0000-000000000000', // Fallback UUID if needed
old_values: cleanOldValues,
new_values: cleanNewValues,
admin_role: 'super_admin', // MCP acts as super admin
admin_user_id: adminUserId
});
if (error) {
console.error('Failed to write audit log:', error);
}
} catch (err) {
console.error('Error in logAudit:', err);
}
export async function logAudit(_entry: AuditLogEntry): Promise<void> {
// The called admin API mutation owns the audit transaction.
}