This commit is contained in:
Jamie Brunton committed 2025-12-30 18:01:47 +00:00
1 parent 88543c7775
commit a3b88395ad
12 files changed
+6 -623

No files matched your search

+4 -8
View File
@@ -1,17 +1,13 @@
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./
COPY turbo.json ./
COPY apps/admin/package.json ./apps/admin/
COPY packages/shared/package.json ./packages/shared/
# Install dependencies (only what's needed for admin) # Copy everything first (workspaces need full structure)
RUN npm install
# Copy source
COPY . . COPY . .
# Install all dependencies
RUN npm install
# Build shared package first # Build shared package first
RUN npx turbo run build --filter=@sauci/shared RUN npx turbo run build --filter=@sauci/shared
-2
View File
@@ -12,6 +12,4 @@ services:
- VITE_AI_MODEL_GENERATE=${VITE_AI_MODEL_GENERATE} - VITE_AI_MODEL_GENERATE=${VITE_AI_MODEL_GENERATE}
- VITE_AI_MODEL_FIX=${VITE_AI_MODEL_FIX} - VITE_AI_MODEL_FIX=${VITE_AI_MODEL_FIX}
- VITE_AI_MODEL_POLISH=${VITE_AI_MODEL_POLISH} - VITE_AI_MODEL_POLISH=${VITE_AI_MODEL_POLISH}
ports:
- "3000:80"
restart: unless-stopped restart: unless-stopped
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -1,122 +0,0 @@
import { DataProvider, CreateParams, UpdateParams, DeleteParams, DeleteManyParams } from 'react-admin';
import { dataProvider as baseDataProvider, supabase } from './supabaseProvider';
// Tables to audit
const AUDITED_TABLES = [
'question_packs',
'questions',
'profiles',
'couples',
'responses',
'matches',
'messages',
'subscriptions',
'feedback',
];
const logAction = async (
tableName: string,
recordId: string,
action: 'INSERT' | 'UPDATE' | 'DELETE',
oldValues?: Record<string, unknown> | null,
newValues?: Record<string, unknown> | null
) => {
try {
await supabase.rpc('log_admin_action', {
p_table_name: tableName,
p_record_id: recordId,
p_action: action,
p_old_values: oldValues || null,
p_new_values: newValues || null,
});
} catch (error) {
console.error('Failed to log audit action:', error);
// Don't throw - logging failure shouldn't block the operation
}
};
export const auditDataProvider: DataProvider = {
...baseDataProvider,
create: async <RecordType extends { id: string } = { id: string }>(
resource: string,
params: CreateParams
) => {
const result = await baseDataProvider.create<RecordType>(resource, params);
if (AUDITED_TABLES.includes(resource)) {
await logAction(resource, result.data.id, 'INSERT', null, result.data as Record<string, unknown>);
}
return result;
},
update: async <RecordType extends { id: string } = { id: string }>(
resource: string,
params: UpdateParams
) => {
// Fetch old values before update
let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(resource)) {
const { data } = await supabase
.from(resource)
.select('*')
.eq('id', params.id)
.single();
oldValues = data;
}
const result = await baseDataProvider.update<RecordType>(resource, params);
if (AUDITED_TABLES.includes(resource)) {
await logAction(resource, params.id as string, 'UPDATE', oldValues, result.data as Record<string, unknown>);
}
return result;
},
delete: async <RecordType extends { id: string } = { id: string }>(
resource: string,
params: DeleteParams
) => {
// Fetch old values before delete
let oldValues: Record<string, unknown> | null = null;
if (AUDITED_TABLES.includes(resource)) {
const { data } = await supabase
.from(resource)
.select('*')
.eq('id', params.id)
.single();
oldValues = data;
}
const result = await baseDataProvider.delete<RecordType>(resource, params);
if (AUDITED_TABLES.includes(resource)) {
await logAction(resource, params.id as string, 'DELETE', oldValues, null);
}
return result;
},
deleteMany: async (resource: string, params: DeleteManyParams) => {
// Fetch old values before delete
if (AUDITED_TABLES.includes(resource)) {
const { data: oldRecords } = await supabase
.from(resource)
.select('*')
.in('id', params.ids);
const result = await baseDataProvider.deleteMany(resource, params);
// Log each deletion
for (const record of oldRecords || []) {
await logAction(resource, record.id, 'DELETE', record, null);
}
return result;
}
return baseDataProvider.deleteMany(resource, params);
},
};
@@ -1,21 +0,0 @@
import { supabaseDataProvider } from 'ra-supabase';
import { supabase } from '../config';
export { supabase };
const resources = {
categories: ['id', 'name', 'description', 'icon', 'sort_order', 'created_at'],
question_packs: ['id', 'name', 'description', 'icon', 'is_premium', 'is_public', 'is_explicit', 'sort_order', 'created_at'],
questions: ['id', 'pack_id', 'text', 'partner_text', 'intensity', 'allowed_couple_genders', 'target_user_genders', 'created_at'],
profiles: ['id', 'name', 'avatar_url', 'is_premium', 'couple_id', 'created_at'],
couples: ['id', 'invite_code', 'created_at'],
responses: ['id', 'user_id', 'question_id', 'couple_id', 'answer', 'created_at'],
matches: ['id', 'couple_id', 'question_id', 'match_type', 'is_new', 'created_at'],
messages: ['id', 'match_id', 'user_id', 'content', 'read_at', 'created_at'],
subscriptions: ['id', 'user_id', 'status', 'product_id', 'expires_at', 'created_at'],
feedback: ['id', 'user_id', 'type', 'title', 'description', 'status', 'created_at'],
audit_logs: ['id', 'table_name', 'record_id', 'action', 'old_values', 'new_values', 'changed_fields', 'admin_user_id', 'admin_role', 'created_at'],
admin_users: ['id', 'user_id', 'role', 'created_at'],
};
export const dataProvider = supabaseDataProvider(supabase, resources);
-64
View File
@@ -1,64 +0,0 @@
import {
List,
Datagrid,
TextField,
DateField,
FunctionField,
SelectInput,
TextInput,
DateInput,
} from 'react-admin';
const auditLogFilters = [
<TextInput key="table" label="Table" source="table_name" alwaysOn />,
<SelectInput
key="action"
source="action"
choices={[
{ id: 'INSERT', name: 'Create' },
{ id: 'UPDATE', name: 'Update' },
{ id: 'DELETE', name: 'Delete' },
]}
/>,
<DateInput key="from" source="created_at@gte" label="From Date" />,
<DateInput key="to" source="created_at@lte" label="To Date" />,
];
export const AuditLogList = () => (
<List
filters={auditLogFilters}
sort={{ field: 'created_at', order: 'DESC' }}
>
<Datagrid bulkActionButtons={false}>
<DateField source="created_at" showTime label="Timestamp" />
<TextField source="table_name" label="Table" />
<FunctionField
label="Action"
render={(record: { action: string }) => {
const actionMap: Record<string, string> = {
INSERT: 'Create',
UPDATE: 'Update',
DELETE: 'Delete',
};
return actionMap[record.action] || record.action;
}}
/>
<TextField source="record_id" label="Record ID" />
<TextField source="admin_role" label="Role" />
<FunctionField
label="Changed Fields"
render={(record: { changed_fields?: string[] }) =>
record.changed_fields?.join(', ') || '-'
}
/>
<FunctionField
label="Summary"
render={(record: { action: string; changed_fields?: string[] }) => {
if (record.action === 'INSERT') return 'New record created';
if (record.action === 'DELETE') return 'Record deleted';
return `${record.changed_fields?.length || 0} fields changed`;
}}
/>
</Datagrid>
</List>
);
-21
View File
@@ -1,21 +0,0 @@
import {
List,
Datagrid,
TextField,
DateField,
TextInput,
} from 'react-admin';
const coupleFilters = [
<TextInput key="invite" label="Invite Code" source="invite_code" alwaysOn />,
];
export const CoupleList = () => (
<List filters={coupleFilters}>
<Datagrid bulkActionButtons={false}>
<TextField source="id" label="Couple ID" />
<TextField source="invite_code" label="Invite Code" />
<DateField source="created_at" label="Created" />
</Datagrid>
</List>
);
-59
View File
@@ -1,59 +0,0 @@
import {
Show,
SimpleShowLayout,
TextField,
DateField,
ReferenceField,
ReferenceManyField,
List,
Datagrid,
BooleanField,
Labeled,
} from 'react-admin';
export const MatchShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="id" label="Match ID" />
<ReferenceField source="question_id" reference="questions" label="Question">
<TextField source="text" />
</ReferenceField>
<TextField source="match_type" />
<BooleanField source="is_new" label="Unread" />
<DateField source="created_at" />
<Labeled label="Chat Messages" fullWidth>
<ReferenceManyField
reference="messages"
target="match_id"
sort={{ field: 'created_at', order: 'ASC' }}
>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="user_id" reference="profiles" link={false}>
<TextField source="name" />
</ReferenceField>
<TextField source="content" label="Message" />
<DateField source="created_at" showTime label="Sent" />
<DateField source="read_at" showTime label="Read" emptyText="-" />
</Datagrid>
</ReferenceManyField>
</Labeled>
</SimpleShowLayout>
</Show>
);
export const MatchList = () => (
<List>
<Datagrid rowClick="show" bulkActionButtons={false}>
<ReferenceField source="couple_id" reference="couples" link={false}>
<TextField source="id" />
</ReferenceField>
<ReferenceField source="question_id" reference="questions">
<TextField source="text" />
</ReferenceField>
<TextField source="match_type" />
<BooleanField source="is_new" label="Unread" />
<DateField source="created_at" />
</Datagrid>
</List>
);
-25
View File
@@ -1,25 +0,0 @@
import {
List,
Datagrid,
TextField,
DateField,
ReferenceField,
TextInput,
} from 'react-admin';
const messageFilters = [
<TextInput key="content" label="Search content" source="content@ilike" alwaysOn />,
];
export const MessageList = () => (
<List filters={messageFilters} sort={{ field: 'created_at', order: 'DESC' }}>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="user_id" reference="profiles" label="Sender">
<TextField source="name" />
</ReferenceField>
<TextField source="content" />
<DateField source="created_at" showTime label="Sent At" />
<DateField source="read_at" showTime label="Read At" />
</Datagrid>
</List>
);
-39
View File
@@ -1,39 +0,0 @@
import { List, Datagrid, TextField, BooleanField, EditButton, Create, SimpleForm, TextInput, BooleanInput, Edit } from 'react-admin';
export const PackList = () => (
<List>
<Datagrid rowClick="edit">
<TextField source="name" />
<TextField source="description" />
<BooleanField source="is_premium" />
<BooleanField source="is_public" />
<EditButton />
</Datagrid>
</List>
);
export const PackEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="name" />
<TextInput source="description" multiline />
<TextInput source="icon" />
<BooleanInput source="is_premium" />
<BooleanInput source="is_public" />
<TextInput source="sort_order" />
</SimpleForm>
</Edit>
);
export const PackCreate = () => (
<Create>
<SimpleForm>
<TextInput source="name" />
<TextInput source="description" multiline />
<TextInput source="icon" />
<BooleanInput source="is_premium" />
<BooleanInput source="is_public" />
<TextInput source="sort_order" />
</SimpleForm>
</Create>
);
-152
View File
@@ -1,152 +0,0 @@
import {
List,
Datagrid,
TextField,
ReferenceField,
NumberField,
EditButton,
Create,
SimpleForm,
TextInput,
NumberInput,
ReferenceInput,
SelectInput,
Edit,
CheckboxGroupInput,
ArrayField,
ChipField,
SingleFieldList,
BooleanField,
FunctionField,
} from 'react-admin';
import { Typography, Box, Chip, Tooltip, IconButton } from '@mui/material';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
const COUPLE_GENDER_CHOICES = [
{ id: 'male+female', name: 'Male + Female' },
{ id: 'male+male', name: 'Male + Male' },
{ id: 'female+female', name: 'Female + Female' },
];
const TARGET_USER_GENDER_CHOICES = [
{ id: 'male', name: 'Male' },
{ id: 'female', name: 'Female' },
{ id: 'non-binary', name: 'Non-binary' },
{ id: 'prefer-not-to-say', name: 'Prefer not to say' },
];
const LabelWithTooltip = ({ label, tooltip }: { label: string; tooltip: string }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography component="span">{label}</Typography>
<Tooltip title={tooltip} arrow placement="right">
<IconButton size="small" sx={{ p: 0.25 }}>
<HelpOutlineIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
</IconButton>
</Tooltip>
</Box>
);
const GenderChips = ({ record, source }: { record?: any; source: string }) => {
const values = record?.[source];
if (!values || values.length === 0) {
return <Chip label="All" size="small" variant="outlined" />;
}
return (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{values.map((v: string) => (
<Chip key={v} label={v} size="small" />
))}
</Box>
);
};
export const QuestionList = () => (
<List>
<Datagrid rowClick="edit">
<TextField source="text" />
<ReferenceField source="pack_id" reference="question_packs">
<TextField source="name" />
</ReferenceField>
<NumberField source="intensity" />
<FunctionField
label="Two-part"
render={(record: any) => record?.partner_text ? 'Yes' : 'No'}
/>
<FunctionField
label="Target Users"
render={(record: any) => {
const values = record?.target_user_genders;
if (!values || values.length === 0) return 'All';
return values.join(', ');
}}
/>
<EditButton />
</Datagrid>
</List>
);
const QuestionForm = () => (
<SimpleForm>
<ReferenceInput source="pack_id" reference="question_packs">
<SelectInput optionText="name" />
</ReferenceInput>
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>
Question Text
</Typography>
<TextInput
source="text"
multiline
fullWidth
helperText="Primary text shown to the initiator (first person to see this question)"
/>
<TextInput
source="partner_text"
multiline
fullWidth
helperText="Optional: Text shown to the partner after the initiator has answered. Leave blank for single-text questions."
/>
<NumberInput source="intensity" min={1} max={5} />
<Typography variant="subtitle2" sx={{ mt: 3, mb: 1 }}>
Targeting
</Typography>
<CheckboxGroupInput
source="target_user_genders"
label={
<LabelWithTooltip
label="Show to (as initiator)"
tooltip="The 'initiator' is the first person in the couple to see and answer a question. This filter controls which genders can be the initiator. Their partner will always see the question after the initiator answers, regardless of this setting."
/>
}
choices={TARGET_USER_GENDER_CHOICES}
helperText="Leave empty to show to all genders as initiator."
/>
<CheckboxGroupInput
source="allowed_couple_genders"
label={
<LabelWithTooltip
label="Couple types"
tooltip="Filters which couple compositions can see this question. A 'male+female' couple has one male and one female partner. This ensures questions are only shown to couples where the content is relevant to their relationship dynamic."
/>
}
choices={COUPLE_GENDER_CHOICES}
helperText="Leave empty to show to all couple types."
/>
</SimpleForm>
);
export const QuestionEdit = () => (
<Edit>
<QuestionForm />
</Edit>
);
export const QuestionCreate = () => (
<Create>
<QuestionForm />
</Create>
);
-109
View File
@@ -1,109 +0,0 @@
import {
List,
Datagrid,
TextField,
BooleanField,
DateField,
ReferenceField,
Edit,
SimpleForm,
TextInput,
BooleanInput,
ReferenceInput,
SelectInput,
Show,
SimpleShowLayout,
ReferenceManyField,
useRecordContext,
TabbedShowLayout,
} from 'react-admin';
export const ProfileList = () => (
<List>
<Datagrid rowClick="show">
<TextField source="name" />
<BooleanField source="is_premium" />
<ReferenceField source="couple_id" reference="couples" link={false}>
<TextField source="id" />
</ReferenceField>
<DateField source="created_at" />
</Datagrid>
</List>
);
export const ProfileShow = () => (
<Show>
<TabbedShowLayout>
<TabbedShowLayout.Tab label="Profile">
<TextField source="id" />
<TextField source="name" />
<BooleanField source="is_premium" />
<ReferenceField source="couple_id" reference="couples">
<TextField source="invite_code" />
</ReferenceField>
<DateField source="created_at" />
</TabbedShowLayout.Tab>
<TabbedShowLayout.Tab label="Matches & Chats">
<ReferenceManyField
label="Matches"
reference="matches"
target="couple_id"
source="couple_id"
>
<Datagrid rowClick="show" bulkActionButtons={false}>
<ReferenceField source="question_id" reference="questions">
<TextField source="text" />
</ReferenceField>
<TextField source="match_type" />
<BooleanField source="is_new" label="Unread" />
<DateField source="created_at" />
</Datagrid>
</ReferenceManyField>
</TabbedShowLayout.Tab>
<TabbedShowLayout.Tab label="Responses">
<ReferenceManyField
label="Responses"
reference="responses"
target="user_id"
>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="question_id" reference="questions">
<TextField source="text" />
</ReferenceField>
<TextField source="answer" />
<DateField source="created_at" />
</Datagrid>
</ReferenceManyField>
</TabbedShowLayout.Tab>
</TabbedShowLayout>
</Show>
);
export const ProfileEdit = () => (
<Edit>
<SimpleForm>
<TextInput source="name" />
<BooleanInput source="is_premium" label="Premium Status" />
<ReferenceInput source="couple_id" reference="couples">
<SelectInput optionText="id" />
</ReferenceInput>
</SimpleForm>
</Edit>
);
export const ResponseList = () => (
<List>
<Datagrid bulkActionButtons={false}>
<ReferenceField source="user_id" reference="profiles">
<TextField source="name" />
</ReferenceField>
<ReferenceField source="question_id" reference="questions">
<TextField source="text" />
</ReferenceField>
<TextField source="answer" />
<DateField source="created_at" />
</Datagrid>
</List>
);