mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add MCP server and enhance AI content tools
Implement self-hosted MCP server for admin actions, update AI generation tools in admin dashboard, and refine mobile app stores and components.
This commit is contained in:
1 parent
b0f1700e40
commit
4e40b92b16
71 files changed
+10511
-1008
No files matched your search
@@ -0,0 +1,28 @@
|
||||
import { serve } from '@hono/node-server';
|
||||
import { Hono } from 'hono';
|
||||
import { logger } from 'hono/logger';
|
||||
import { authMiddleware } from './lib/auth.js';
|
||||
import { createMcpServer } from './server.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use('*', logger());
|
||||
app.use('/mcp', authMiddleware);
|
||||
|
||||
app.get('/health', (c) => c.json({ status: 'ok', service: 'sauci-mcp' }));
|
||||
|
||||
// Initialize MCP server
|
||||
const mcpServer = createMcpServer();
|
||||
|
||||
// Mount MCP server
|
||||
app.all('/mcp', async (c) => {
|
||||
return mcpServer.handleRequest(c);
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT) || 3000;
|
||||
console.log(`Server is running on port ${port}`);
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Context, Next } from 'hono';
|
||||
|
||||
export const authMiddleware = async (c: Context, next: Next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
const apiKey = process.env.SAUCI_MCP_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
// If no API key is set in env, allow access (warn in logs)
|
||||
// Ideally this should default to deny, but for dev ease we might want to warn
|
||||
console.warn('SAUCI_MCP_API_KEY not set in environment - allowing all requests');
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return c.json({ error: 'Unauthorized: Missing or invalid Authorization header' }, 401);
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
|
||||
if (token !== apiKey) {
|
||||
return c.json({ error: 'Unauthorized: Invalid API key' }, 401);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPTransport } from '@hono/mcp';
|
||||
import { registerContentTools } from './tools/content.js';
|
||||
import { registerUserTools } from './tools/users.js';
|
||||
import { registerModerationTools } from './tools/moderation.js';
|
||||
import { registerCodeTools } from './tools/codes.js';
|
||||
import { registerFeedbackTools } from './tools/feedback.js';
|
||||
import { registerAnalyticsTools } from './tools/analytics.js';
|
||||
import { registerConfigTools } from './tools/config.js';
|
||||
import { registerAdminTools } from './tools/admin.js';
|
||||
|
||||
import { Context } from 'hono';
|
||||
|
||||
export function createMcpServer() {
|
||||
const server = new McpServer({
|
||||
name: 'sauci-admin',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// Register all tool groups
|
||||
registerContentTools(server);
|
||||
registerUserTools(server);
|
||||
registerModerationTools(server);
|
||||
registerCodeTools(server);
|
||||
registerFeedbackTools(server);
|
||||
registerAnalyticsTools(server);
|
||||
registerConfigTools(server);
|
||||
registerAdminTools(server);
|
||||
|
||||
return {
|
||||
handleRequest: async (c: Context) => {
|
||||
const transport = new StreamableHTTPTransport();
|
||||
await server.connect(transport);
|
||||
return transport.handleRequest(c);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { supabase } from '../lib/supabase.js';
|
||||
import { logAudit } from '../utils/audit.js';
|
||||
|
||||
export function registerAdminTools(server: McpServer) {
|
||||
server.tool(
|
||||
'list_admins',
|
||||
'List all admin users',
|
||||
{},
|
||||
async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('admin_users')
|
||||
.select(`
|
||||
*,
|
||||
user:user_id (name, email, avatar_url)
|
||||
`);
|
||||
|
||||
if (error) throw new Error(`Failed to list admins: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'add_admin',
|
||||
'Grant admin access to a user',
|
||||
{
|
||||
user_id: z.string(),
|
||||
role: z.enum(['super_admin', 'pack_creator']).default('pack_creator'),
|
||||
permissions: z.array(z.string()).optional()
|
||||
},
|
||||
async ({ user_id, role, permissions }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('admin_users')
|
||||
.insert({
|
||||
user_id,
|
||||
role,
|
||||
permissions: permissions || []
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to add admin: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'INSERT',
|
||||
table_name: 'admin_users',
|
||||
record_id: data.id,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'update_admin_permissions',
|
||||
'Update admin permissions',
|
||||
{
|
||||
id: z.string(), // admin_users ID, not user_id
|
||||
role: z.enum(['super_admin', 'pack_creator']).optional(),
|
||||
permissions: z.array(z.string()).optional()
|
||||
},
|
||||
async ({ id, role, permissions }) => {
|
||||
const { data: oldData } = await supabase.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
|
||||
.from('admin_users')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to update admin: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'admin_users',
|
||||
record_id: id,
|
||||
old_values: oldData,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'remove_admin',
|
||||
'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 { error } = await supabase.from('admin_users').delete().eq('id', id);
|
||||
if (error) throw new Error(`Failed to remove admin: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'DELETE',
|
||||
table_name: 'admin_users',
|
||||
record_id: id,
|
||||
old_values: oldData
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Admin ${id} removed successfully` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'list_audit_logs',
|
||||
'View audit logs',
|
||||
{
|
||||
limit: z.number().default(20),
|
||||
offset: z.number().default(0),
|
||||
table_name: z.string().optional(),
|
||||
action: z.enum(['INSERT', 'UPDATE', 'DELETE']).optional()
|
||||
},
|
||||
async ({ limit, offset, table_name, action }) => {
|
||||
let query = supabase
|
||||
.from('audit_logs')
|
||||
.select('*', { count: 'exact' });
|
||||
|
||||
if (table_name) query = query.eq('table_name', table_name);
|
||||
if (action) query = query.eq('action', action);
|
||||
|
||||
const { data, error, count } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) throw new Error(`Failed to list audit logs: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { supabase } from '../lib/supabase.js';
|
||||
|
||||
export function registerAnalyticsTools(server: McpServer) {
|
||||
server.tool(
|
||||
'get_dashboard_stats',
|
||||
'Get high-level dashboard statistics',
|
||||
{},
|
||||
async () => {
|
||||
// Parallel fetch for counts
|
||||
const [
|
||||
{ count: categories },
|
||||
{ count: packs },
|
||||
{ count: questions },
|
||||
{ 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 })
|
||||
]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
counts: {
|
||||
categories,
|
||||
packs,
|
||||
questions,
|
||||
profiles,
|
||||
couples
|
||||
}
|
||||
}, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_feature_interests',
|
||||
'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) }]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_usage_insights',
|
||||
'Get usage insights (join reasons, etc)',
|
||||
{},
|
||||
async () => {
|
||||
// Aggregating onboarding data from profiles
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('usage_reason, gender');
|
||||
|
||||
if (error) throw new Error(`Failed to get usage insights: ${error.message}`);
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ reasons, genders }, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_question_analytics',
|
||||
'Get question performance stats',
|
||||
{},
|
||||
async () => {
|
||||
// Limit to top 50 most answered questions
|
||||
// This is heavy, ideally we'd have an RPC or materialized view
|
||||
// For now, we'll just return a placeholder or simple query
|
||||
|
||||
// Let's get response counts per question
|
||||
// Using .rpc if available would be best.
|
||||
// I'll assume we don't have a specific RPC for this yet and do a lightweight check.
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: "Question analytics requires complex aggregation. Please use the 'list_questions' tool to inspect specific questions." }]
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { supabase } from '../lib/supabase.js';
|
||||
import { logAudit } from '../utils/audit.js';
|
||||
|
||||
export function registerCodeTools(server: McpServer) {
|
||||
server.tool(
|
||||
'list_redemption_codes',
|
||||
'List redemption codes',
|
||||
{
|
||||
page: z.number().default(1),
|
||||
limit: z.number().default(20),
|
||||
search: z.string().optional()
|
||||
},
|
||||
async ({ page, limit, search }) => {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let query = supabase
|
||||
.from('redemption_codes')
|
||||
.select('*', { count: 'exact' });
|
||||
|
||||
if (search) {
|
||||
query = query.or(`code.ilike.%${search}%,description.ilike.%${search}%`);
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
.range(offset, offset + limit - 1)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw new Error(`Failed to list codes: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'generate_code',
|
||||
'Create a new redemption code',
|
||||
{
|
||||
code: z.string().optional(), // If not provided, should generate one
|
||||
description: z.string().optional(),
|
||||
max_uses: z.number().default(1),
|
||||
expires_at: z.string().optional(), // ISO date string
|
||||
is_active: z.boolean().default(true)
|
||||
},
|
||||
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
|
||||
.from('redemption_codes')
|
||||
.insert({
|
||||
code: finalCode,
|
||||
description,
|
||||
max_uses,
|
||||
expires_at,
|
||||
is_active,
|
||||
current_uses: 0
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to create code: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'INSERT',
|
||||
table_name: 'redemption_codes',
|
||||
record_id: data.id,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'toggle_code_active',
|
||||
'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, error } = await supabase
|
||||
.from('redemption_codes')
|
||||
.update({ is_active })
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to update code: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'redemption_codes',
|
||||
record_id: id,
|
||||
old_values: oldData,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Code ${data.code} is now ${is_active ? 'active' : 'inactive'}` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'delete_code',
|
||||
'Delete a redemption code',
|
||||
{ id: z.string() },
|
||||
async ({ id }) => {
|
||||
const { data: oldData } = await supabase.from('redemption_codes').select('*').eq('id', id).single();
|
||||
|
||||
const { error } = await supabase.from('redemption_codes').delete().eq('id', id);
|
||||
if (error) throw new Error(`Failed to delete code: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'DELETE',
|
||||
table_name: 'redemption_codes',
|
||||
record_id: id,
|
||||
old_values: oldData
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Code ${id} deleted successfully` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'list_code_redemptions',
|
||||
'See who redeemed a code',
|
||||
{ code_id: z.string() },
|
||||
async ({ code_id }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('code_redemptions')
|
||||
.select(`
|
||||
*,
|
||||
user:user_id (name, email)
|
||||
`)
|
||||
.eq('code_id', code_id)
|
||||
.order('redeemed_at', { ascending: false });
|
||||
|
||||
if (error) throw new Error(`Failed to list redemptions: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { supabase } from '../lib/supabase.js';
|
||||
import { logAudit } from '../utils/audit.js';
|
||||
|
||||
export function registerConfigTools(server: McpServer) {
|
||||
server.tool(
|
||||
'get_ai_config',
|
||||
'Get AI configuration',
|
||||
{},
|
||||
async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('ai_config')
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to get AI config: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'update_ai_config',
|
||||
'Update AI configuration',
|
||||
{
|
||||
openrouter_api_key: z.string().optional(),
|
||||
default_model: z.string().optional(),
|
||||
classifier_enabled: z.boolean().optional(),
|
||||
classifier_model: z.string().optional(),
|
||||
classifier_prompt: z.string().optional(),
|
||||
council_enabled: z.boolean().optional()
|
||||
},
|
||||
async (updates) => {
|
||||
// Get current ID (singleton table usually has 1 row, but need ID for update)
|
||||
const { data: current, error: fetchError } = await supabase
|
||||
.from('ai_config')
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('ai_config')
|
||||
.update(updates)
|
||||
.eq('id', current.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to update AI config: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'ai_config',
|
||||
record_id: current.id,
|
||||
old_values: current,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_app_config',
|
||||
'Get app configuration',
|
||||
{},
|
||||
async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('app_config')
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to get app config: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'update_app_config',
|
||||
'Update app configuration',
|
||||
{
|
||||
answer_gap_threshold: z.number().optional(),
|
||||
daily_response_limit: z.number().optional()
|
||||
},
|
||||
async (updates) => {
|
||||
const { data: current, error: fetchError } = await supabase
|
||||
.from('app_config')
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (fetchError) throw new Error(`Failed to fetch current config: ${fetchError.message}`);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('app_config')
|
||||
.update(updates)
|
||||
.eq('id', current.id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to update app config: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'app_config',
|
||||
record_id: current.id,
|
||||
old_values: current,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { supabase } from '../lib/supabase.js';
|
||||
import { logAudit } from '../utils/audit.js';
|
||||
|
||||
export function registerContentTools(server: McpServer) {
|
||||
// --- Categories ---
|
||||
|
||||
server.tool(
|
||||
'list_categories',
|
||||
'List all categories with pack counts',
|
||||
{},
|
||||
async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('categories')
|
||||
.select(`
|
||||
*,
|
||||
question_packs (count)
|
||||
`)
|
||||
.order('sort_order');
|
||||
|
||||
if (error) throw new Error(`Failed to list categories: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'create_category',
|
||||
'Create a new category',
|
||||
{
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string(),
|
||||
sort_order: z.number().optional(),
|
||||
is_public: z.boolean().default(false)
|
||||
},
|
||||
async ({ name, description, icon, sort_order, is_public }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('categories')
|
||||
.insert({ name, description, icon, sort_order: sort_order ?? 0, is_public })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to create category: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'INSERT',
|
||||
table_name: 'categories',
|
||||
record_id: data.id,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'update_category',
|
||||
'Update a category',
|
||||
{
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
is_public: z.boolean().optional()
|
||||
},
|
||||
async ({ id, ...updates }) => {
|
||||
// Get old values for audit
|
||||
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
|
||||
if (!oldData) throw new Error(`Category not found: ${id}`);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('categories')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to update category: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'categories',
|
||||
record_id: id,
|
||||
old_values: oldData,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'delete_category',
|
||||
'Delete a category',
|
||||
{ id: z.string() },
|
||||
async ({ id }) => {
|
||||
const { data: oldData } = await supabase.from('categories').select('*').eq('id', id).single();
|
||||
if (!oldData) throw new Error(`Category not found: ${id}`);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('categories')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw new Error(`Failed to delete category: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'DELETE',
|
||||
table_name: 'categories',
|
||||
record_id: id,
|
||||
old_values: oldData
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Category ${id} deleted successfully` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// --- Packs ---
|
||||
|
||||
server.tool(
|
||||
'list_packs',
|
||||
'List packs for a category or all packs',
|
||||
{ category_id: z.string().optional() },
|
||||
async ({ category_id }) => {
|
||||
let query = supabase
|
||||
.from('question_packs')
|
||||
.select(`
|
||||
*,
|
||||
questions (count)
|
||||
`)
|
||||
.order('sort_order');
|
||||
|
||||
if (category_id) {
|
||||
query = query.eq('category_id', category_id);
|
||||
}
|
||||
|
||||
const { data, error } = await query;
|
||||
if (error) throw new Error(`Failed to list packs: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'create_pack',
|
||||
'Create a new question pack',
|
||||
{
|
||||
name: z.string(),
|
||||
category_id: z.string(),
|
||||
description: z.string().optional(),
|
||||
is_premium: z.boolean().default(false),
|
||||
is_public: z.boolean().default(false),
|
||||
is_explicit: z.boolean().default(false),
|
||||
sort_order: z.number().optional()
|
||||
},
|
||||
async (input) => {
|
||||
const { data, error } = await supabase
|
||||
.from('question_packs')
|
||||
.insert({ ...input, sort_order: input.sort_order ?? 0 })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to create pack: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'INSERT',
|
||||
table_name: 'question_packs',
|
||||
record_id: data.id,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'update_pack',
|
||||
'Update a question pack',
|
||||
{
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
category_id: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
is_premium: z.boolean().optional(),
|
||||
is_public: z.boolean().optional(),
|
||||
is_explicit: z.boolean().optional(),
|
||||
sort_order: z.number().optional()
|
||||
},
|
||||
async ({ id, ...updates }) => {
|
||||
const { data: oldData } = await supabase.from('question_packs').select('*').eq('id', id).single();
|
||||
if (!oldData) throw new Error(`Pack not found: ${id}`);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('question_packs')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to update pack: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'question_packs',
|
||||
record_id: id,
|
||||
old_values: oldData,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'delete_pack',
|
||||
'Delete a question pack',
|
||||
{ id: z.string() },
|
||||
async ({ id }) => {
|
||||
const { data: oldData } = await supabase.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);
|
||||
if (error) throw new Error(`Failed to delete pack: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'DELETE',
|
||||
table_name: 'question_packs',
|
||||
record_id: id,
|
||||
old_values: oldData
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Pack ${id} deleted successfully` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// --- Questions ---
|
||||
|
||||
server.tool(
|
||||
'list_questions',
|
||||
'List questions for a pack',
|
||||
{ pack_id: z.string() },
|
||||
async ({ pack_id }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('questions')
|
||||
.select('*')
|
||||
.eq('pack_id', pack_id)
|
||||
.order('created_at'); // Questions don't strictly have a sort_order usually
|
||||
|
||||
if (error) throw new Error(`Failed to list questions: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'create_question',
|
||||
'Create a single question',
|
||||
{
|
||||
pack_id: z.string(),
|
||||
text: z.string(),
|
||||
partner_text: z.string().optional(),
|
||||
intensity: z.number().min(1).max(5).default(1)
|
||||
},
|
||||
async (input) => {
|
||||
const { data, error } = await supabase
|
||||
.from('questions')
|
||||
.insert(input)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to create question: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'INSERT',
|
||||
table_name: 'questions',
|
||||
record_id: data.id,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'update_question',
|
||||
'Update a question',
|
||||
{
|
||||
id: z.string(),
|
||||
text: z.string().optional(),
|
||||
partner_text: z.string().optional(),
|
||||
intensity: z.number().min(1).max(5).optional(),
|
||||
pack_id: z.string().optional()
|
||||
},
|
||||
async ({ id, ...updates }) => {
|
||||
const { data: oldData } = await supabase.from('questions').select('*').eq('id', id).single();
|
||||
if (!oldData) throw new Error(`Question not found: ${id}`);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('questions')
|
||||
.update(updates)
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to update question: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'questions',
|
||||
record_id: id,
|
||||
old_values: oldData,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'delete_questions',
|
||||
'Delete one or more questions',
|
||||
{ ids: z.array(z.string()) },
|
||||
async ({ ids }) => {
|
||||
const { data: oldData } = await supabase.from('questions').select('*').in('id', ids);
|
||||
|
||||
const { error } = await supabase.from('questions').delete().in('id', ids);
|
||||
if (error) throw new Error(`Failed to delete questions: ${error.message}`);
|
||||
|
||||
// Log audit for each deleted question
|
||||
if (oldData) {
|
||||
for (const record of oldData) {
|
||||
await logAudit({
|
||||
action: 'DELETE',
|
||||
table_name: 'questions',
|
||||
record_id: record.id,
|
||||
old_values: record
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `${ids.length} questions deleted successfully` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { supabase } from '../lib/supabase.js';
|
||||
import { logAudit } from '../utils/audit.js';
|
||||
|
||||
export function registerFeedbackTools(server: McpServer) {
|
||||
server.tool(
|
||||
'list_feedback',
|
||||
'List feedback with filters',
|
||||
{
|
||||
type: z.enum(['bug', 'feature_request', 'general', 'question']).optional(),
|
||||
status: z.enum(['new', 'reviewed', 'in_progress', 'resolved', 'closed']).optional(),
|
||||
limit: z.number().default(20),
|
||||
offset: z.number().default(0)
|
||||
},
|
||||
async ({ type, status, limit, offset }) => {
|
||||
let query = supabase
|
||||
.from('feedback')
|
||||
.select(`
|
||||
*,
|
||||
user:user_id (name, email)
|
||||
`, { count: 'exact' });
|
||||
|
||||
if (type) query = query.eq('type', type);
|
||||
if (status) query = query.eq('status', status);
|
||||
|
||||
const { data, error, count } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) throw new Error(`Failed to list feedback: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_feedback_detail',
|
||||
'Get full feedback details',
|
||||
{ id: z.string() },
|
||||
async ({ id }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('feedback')
|
||||
.select(`
|
||||
*,
|
||||
user:user_id (name, email),
|
||||
question:question_id (text)
|
||||
`)
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to get feedback detail: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'update_feedback_status',
|
||||
'Update feedback status',
|
||||
{
|
||||
id: z.string(),
|
||||
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, error } = await supabase
|
||||
.from('feedback')
|
||||
.update({ status })
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to update status: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'feedback',
|
||||
record_id: id,
|
||||
old_values: oldData,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Feedback ${id} status updated to ${status}` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'add_feedback_notes',
|
||||
'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, error } = await supabase
|
||||
.from('feedback')
|
||||
.update({ admin_notes: notes })
|
||||
.eq('id', id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to update notes: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'feedback',
|
||||
record_id: id,
|
||||
old_values: oldData,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Notes added to feedback ${id}` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { supabase } from '../lib/supabase.js';
|
||||
import { logAudit } from '../utils/audit.js';
|
||||
|
||||
export function registerModerationTools(server: McpServer) {
|
||||
server.tool(
|
||||
'list_flagged_messages',
|
||||
'List messages flagged by AI for review',
|
||||
{
|
||||
limit: z.number().default(20),
|
||||
offset: z.number().default(0)
|
||||
},
|
||||
async ({ limit, offset }) => {
|
||||
const { data, error, count } = await supabase
|
||||
.from('messages')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('moderation_status', 'flagged')
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) throw new Error(`Failed to list flagged messages: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'list_message_reports',
|
||||
'List user-submitted reports',
|
||||
{
|
||||
status: z.enum(['pending', 'reviewed', 'dismissed']).default('pending'),
|
||||
limit: z.number().default(20),
|
||||
offset: z.number().default(0)
|
||||
},
|
||||
async ({ status, limit, offset }) => {
|
||||
const { data, error, count } = await supabase
|
||||
.from('message_reports')
|
||||
.select(`
|
||||
*,
|
||||
message:message_id (*),
|
||||
reporter:reporter_id (name, email)
|
||||
`, { count: 'exact' })
|
||||
.eq('status', status)
|
||||
.order('created_at', { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
if (error) throw new Error(`Failed to list reports: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data, count }, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'mark_message_safe',
|
||||
'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, error } = await supabase
|
||||
.from('messages')
|
||||
.update({ moderation_status: 'safe' })
|
||||
.eq('id', message_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to update message: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'messages',
|
||||
record_id: message_id,
|
||||
old_values: oldData,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Message ${message_id} marked as safe` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'dismiss_report',
|
||||
'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, error } = await supabase
|
||||
.from('message_reports')
|
||||
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
|
||||
.eq('id', report_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to dismiss report: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'message_reports',
|
||||
record_id: report_id,
|
||||
old_values: oldData,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Report ${report_id} dismissed` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'mark_report_reviewed',
|
||||
'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, error } = await supabase
|
||||
.from('message_reports')
|
||||
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
|
||||
.eq('id', report_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to update report: ${error.message}`);
|
||||
|
||||
await logAudit({
|
||||
action: 'UPDATE',
|
||||
table_name: 'message_reports',
|
||||
record_id: report_id,
|
||||
old_values: oldData,
|
||||
new_values: data
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Report ${report_id} marked as reviewed` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { supabase } from '../lib/supabase.js';
|
||||
import { logAudit } from '../utils/audit.js';
|
||||
|
||||
export function registerUserTools(server: McpServer) {
|
||||
server.tool(
|
||||
'list_users',
|
||||
'List users with pagination and search',
|
||||
{
|
||||
page: z.number().default(1),
|
||||
limit: z.number().default(20),
|
||||
search: z.string().optional()
|
||||
},
|
||||
async ({ page, limit, search }) => {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let query = supabase
|
||||
.from('profiles')
|
||||
.select('*', { count: 'exact' });
|
||||
|
||||
if (search) {
|
||||
query = query.or(`name.ilike.%${search}%,email.ilike.%${search}%`);
|
||||
}
|
||||
|
||||
const { data, error, count } = await query
|
||||
.range(offset, offset + limit - 1)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw new Error(`Failed to list users: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data, count, page, limit }, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_user_detail',
|
||||
'Get full user profile details',
|
||||
{ user_id: z.string() },
|
||||
async ({ user_id }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select(`
|
||||
*,
|
||||
couple:couple_id (*)
|
||||
`)
|
||||
.eq('id', user_id)
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to get user details: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_user_responses',
|
||||
'Get user question responses',
|
||||
{ user_id: z.string(), limit: z.number().default(50) },
|
||||
async ({ user_id, limit }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('responses')
|
||||
.select(`
|
||||
*,
|
||||
question:question_id (text, intensity)
|
||||
`)
|
||||
.eq('user_id', user_id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) throw new Error(`Failed to get responses: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_user_matches',
|
||||
'Get user matches',
|
||||
{ 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();
|
||||
|
||||
if (!profile?.couple_id) {
|
||||
return { content: [{ type: 'text', text: 'User is not in a couple' }] };
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('matches')
|
||||
.select(`
|
||||
*,
|
||||
question:question_id (text)
|
||||
`)
|
||||
.eq('couple_id', profile.couple_id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw new Error(`Failed to get matches: ${error.message}`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_user_media',
|
||||
'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)
|
||||
.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
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(mediaWithUrls, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'get_match_chat',
|
||||
'View chat messages for a match',
|
||||
{ match_id: z.string(), limit: z.number().default(50) },
|
||||
async ({ match_id, limit }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
.eq('match_id', match_id)
|
||||
.order('created_at', { ascending: false }) // Newest first
|
||||
.limit(limit);
|
||||
|
||||
if (error) throw new Error(`Failed to get chat: ${error.message}`);
|
||||
|
||||
// Reverse to show chronological order in output if desired, but JSON is fine
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify(data, null, 2) }]
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.tool(
|
||||
'gift_premium',
|
||||
'Give user premium access',
|
||||
{
|
||||
user_id: z.string(),
|
||||
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 }
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: `Premium gifted to user ${user_id} until ${expiresAt.toISOString()}` }]
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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>;
|
||||
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
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user