Fix frontend JSONB references

This commit is contained in:
gpt-engineer-app[bot]
2025-11-03 21:19:51 +00:00
parent a4e1be8056
commit 63d9d8890c
8 changed files with 193 additions and 61 deletions

View File

@@ -10,8 +10,8 @@ interface Breadcrumb {
timestamp: string; timestamp: string;
category: string; category: string;
message: string; message: string;
level: string; level?: string;
data?: Record<string, unknown>; sequence_order?: number;
} }
interface ErrorDetails { interface ErrorDetails {
@@ -25,7 +25,7 @@ interface ErrorDetails {
status_code: number; status_code: number;
duration_ms: number; duration_ms: number;
user_id?: string; user_id?: string;
breadcrumbs?: Breadcrumb[]; request_breadcrumbs?: Breadcrumb[];
environment_context?: Record<string, unknown>; environment_context?: Record<string, unknown>;
} }
@@ -146,26 +146,26 @@ ${error.error_stack ? `Stack Trace:\n${error.error_stack}` : ''}
</TabsContent> </TabsContent>
<TabsContent value="breadcrumbs"> <TabsContent value="breadcrumbs">
{error.breadcrumbs && error.breadcrumbs.length > 0 ? ( {error.request_breadcrumbs && error.request_breadcrumbs.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{error.breadcrumbs.map((crumb, index) => ( {error.request_breadcrumbs
<div key={index} className="border-l-2 border-primary pl-4 py-2"> .sort((a, b) => (a.sequence_order || 0) - (b.sequence_order || 0))
<div className="flex items-center gap-2 mb-1"> .map((crumb, index) => (
<Badge variant="outline" className="text-xs"> <div key={index} className="border-l-2 border-primary pl-4 py-2">
{crumb.category} <div className="flex items-center gap-2 mb-1">
</Badge> <Badge variant="outline" className="text-xs">
<span className="text-xs text-muted-foreground"> {crumb.category}
{format(new Date(crumb.timestamp), 'HH:mm:ss.SSS')} </Badge>
</span> <Badge variant={crumb.level === 'error' ? 'destructive' : 'secondary'} className="text-xs">
{crumb.level || 'info'}
</Badge>
<span className="text-xs text-muted-foreground">
{format(new Date(crumb.timestamp), 'HH:mm:ss.SSS')}
</span>
</div>
<p className="text-sm">{crumb.message}</p>
</div> </div>
<p className="text-sm">{crumb.message}</p> ))}
{crumb.data && (
<pre className="text-xs text-muted-foreground mt-1">
{JSON.stringify(crumb.data, null, 2)}
</pre>
)}
</div>
))}
</div> </div>
) : ( ) : (
<p className="text-muted-foreground">No breadcrumbs recorded</p> <p className="text-muted-foreground">No breadcrumbs recorded</p>

View File

@@ -8,8 +8,18 @@ import { format } from 'date-fns';
import { handleError } from '@/lib/errorHandler'; import { handleError } from '@/lib/errorHandler';
import { AuditLogEntry } from '@/types/database'; import { AuditLogEntry } from '@/types/database';
interface ProfileChangeField {
field_name: string;
old_value: string | null;
new_value: string | null;
}
interface ProfileAuditLogWithChanges extends Omit<AuditLogEntry, 'changes'> {
profile_change_fields?: ProfileChangeField[];
}
export function ProfileAuditLog(): React.JSX.Element { export function ProfileAuditLog(): React.JSX.Element {
const [logs, setLogs] = useState<AuditLogEntry[]>([]); const [logs, setLogs] = useState<ProfileAuditLogWithChanges[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@@ -22,13 +32,18 @@ export function ProfileAuditLog(): React.JSX.Element {
.from('profile_audit_log') .from('profile_audit_log')
.select(` .select(`
*, *,
profiles!user_id(username, display_name) profiles!user_id(username, display_name),
profile_change_fields(
field_name,
old_value,
new_value
)
`) `)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(50); .limit(50);
if (error) throw error; if (error) throw error;
setLogs((data || []) as AuditLogEntry[]); setLogs((data || []) as ProfileAuditLogWithChanges[]);
} catch (error: unknown) { } catch (error: unknown) {
handleError(error, { action: 'Load audit logs' }); handleError(error, { action: 'Load audit logs' });
} finally { } finally {
@@ -71,7 +86,20 @@ export function ProfileAuditLog(): React.JSX.Element {
<Badge variant="secondary">{log.action}</Badge> <Badge variant="secondary">{log.action}</Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<pre className="text-xs">{JSON.stringify(log.changes || {}, null, 2)}</pre> {log.profile_change_fields && log.profile_change_fields.length > 0 ? (
<div className="space-y-1">
{log.profile_change_fields.map((change, idx) => (
<div key={idx} className="text-xs">
<span className="font-medium">{change.field_name}:</span>{' '}
<span className="text-muted-foreground">{change.old_value || 'null'}</span>
{' → '}
<span className="text-foreground">{change.new_value || 'null'}</span>
</div>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">No changes</span>
)}
</TableCell> </TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{format(new Date(log.created_at), 'PPpp')} {format(new Date(log.created_at), 'PPpp')}

View File

@@ -99,7 +99,15 @@ export function DataExportTab() {
try { try {
const { data, error } = await supabase const { data, error } = await supabase
.from('profile_audit_log') .from('profile_audit_log')
.select('id, action, changes, created_at, changed_by, ip_address_hash, user_agent') .select(`
id,
action,
created_at,
changed_by,
ip_address_hash,
user_agent,
profile_change_fields(field_name, old_value, new_value)
`)
.eq('user_id', user.id) .eq('user_id', user.id)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(10); .limit(10);
@@ -115,15 +123,27 @@ export function DataExportTab() {
} }
// Transform the data to match our type // Transform the data to match our type
const activityData: ActivityLogEntry[] = (data || []).map(item => ({ const activityData: ActivityLogEntry[] = (data || []).map(item => {
id: item.id, const changes: Record<string, any> = {};
action: item.action, if (item.profile_change_fields) {
changes: item.changes as Record<string, any>, for (const field of item.profile_change_fields) {
created_at: item.created_at, changes[field.field_name] = {
changed_by: item.changed_by, old: field.old_value,
ip_address_hash: item.ip_address_hash || undefined, new: field.new_value
user_agent: item.user_agent || undefined };
})); }
}
return {
id: item.id,
action: item.action,
changes,
created_at: item.created_at,
changed_by: item.changed_by,
ip_address_hash: item.ip_address_hash || undefined,
user_agent: item.user_agent || undefined
};
});
setRecentActivity(activityData); setRecentActivity(activityData);

View File

@@ -154,13 +154,18 @@ export function useRealtimeSubscriptions(
const { data: submission, error } = await supabase const { data: submission, error } = await supabase
.from('content_submissions') .from('content_submissions')
.select(` .select(`
id, submission_type, status, content, created_at, user_id, id, submission_type, status, created_at, user_id,
reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until, reviewed_at, reviewer_id, reviewer_notes, escalated, assigned_to, locked_until,
submission_items ( submission_items (
id, id,
item_type, item_type,
item_data, item_data,
status status
),
submission_metadata (
entity_id,
park_id,
ride_id
) )
`) `)
.eq('id', submissionId) .eq('id', submissionId)
@@ -177,14 +182,18 @@ export function useRealtimeSubscriptions(
/** /**
* Resolve entity names for a submission * Resolve entity names for a submission
*/ */
const resolveEntityNames = useCallback(async (submission: { submission_type: string; content: Json }) => { const resolveEntityNames = useCallback(async (submission: { submission_type: string; submission_metadata?: any[] }) => {
const content = submission.content as SubmissionContent; // Get metadata
let entityName = content?.name || 'Unknown'; const metadata = Array.isArray(submission.submission_metadata) && submission.submission_metadata.length > 0
? submission.submission_metadata[0]
: undefined;
let entityName = 'Unknown';
let parkName: string | undefined; let parkName: string | undefined;
if (submission.submission_type === 'ride' && content?.entity_id) { if (submission.submission_type === 'ride' && metadata?.entity_id) {
// Try cache first // Try cache first
const cachedRide = entityCache.getCached('rides', content.entity_id); const cachedRide = entityCache.getCached('rides', metadata.entity_id);
if (cachedRide) { if (cachedRide) {
entityName = cachedRide.name; entityName = cachedRide.name;
if (cachedRide.park_id) { if (cachedRide.park_id) {
@@ -195,12 +204,12 @@ export function useRealtimeSubscriptions(
const { data: ride } = await supabase const { data: ride } = await supabase
.from('rides') .from('rides')
.select('id, name, park_id') .select('id, name, park_id')
.eq('id', content.entity_id) .eq('id', metadata.entity_id)
.maybeSingle(); .maybeSingle();
if (ride) { if (ride) {
entityName = ride.name; entityName = ride.name;
entityCache.setCached('rides', content.entity_id, ride); entityCache.setCached('rides', metadata.entity_id, ride);
if (ride.park_id) { if (ride.park_id) {
const { data: park } = await supabase const { data: park } = await supabase
@@ -216,36 +225,36 @@ export function useRealtimeSubscriptions(
} }
} }
} }
} else if (submission.submission_type === 'park' && content?.entity_id) { } else if (submission.submission_type === 'park' && metadata?.entity_id) {
const cachedPark = entityCache.getCached('parks', content.entity_id); const cachedPark = entityCache.getCached('parks', metadata.entity_id);
if (cachedPark) { if (cachedPark) {
entityName = cachedPark.name; entityName = cachedPark.name;
} else { } else {
const { data: park } = await supabase const { data: park } = await supabase
.from('parks') .from('parks')
.select('id, name') .select('id, name')
.eq('id', content.entity_id) .eq('id', metadata.entity_id)
.maybeSingle(); .maybeSingle();
if (park) { if (park) {
entityName = park.name; entityName = park.name;
entityCache.setCached('parks', content.entity_id, park); entityCache.setCached('parks', metadata.entity_id, park);
} }
} }
} else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(submission.submission_type) && content?.entity_id) { } else if (['manufacturer', 'operator', 'designer', 'property_owner'].includes(submission.submission_type) && metadata?.entity_id) {
const cachedCompany = entityCache.getCached('companies', content.entity_id); const cachedCompany = entityCache.getCached('companies', metadata.entity_id);
if (cachedCompany) { if (cachedCompany) {
entityName = cachedCompany.name; entityName = cachedCompany.name;
} else { } else {
const { data: company } = await supabase const { data: company } = await supabase
.from('companies') .from('companies')
.select('id, name') .select('id, name')
.eq('id', content.entity_id) .eq('id', metadata.entity_id)
.maybeSingle(); .maybeSingle();
if (company) { if (company) {
entityName = company.name; entityName = company.name;
entityCache.setCached('companies', content.entity_id, company); entityCache.setCached('companies', metadata.entity_id, company);
} }
} }
} }

View File

@@ -322,14 +322,30 @@ export async function fetchSystemActivities(
} }
// Fetch admin audit log (admin actions) // Fetch admin audit log (admin actions)
// Note: Details are now in admin_audit_details table
const { data: auditLogs, error: auditError } = await supabase const { data: auditLogs, error: auditError } = await supabase
.from('admin_audit_log') .from('admin_audit_log')
.select('id, admin_user_id, target_user_id, action, details, created_at') .select(`
id,
admin_user_id,
target_user_id,
action,
created_at,
admin_audit_details(detail_key, detail_value)
`)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(limit); .limit(limit);
if (!auditError && auditLogs) { if (!auditError && auditLogs) {
for (const log of auditLogs) { for (const log of auditLogs) {
// Convert relational details back to object format
const details: Record<string, any> = {};
if (log.admin_audit_details) {
for (const detail of log.admin_audit_details as any[]) {
details[detail.detail_key] = detail.detail_value;
}
}
activities.push({ activities.push({
id: log.id, id: log.id,
type: 'admin_action', type: 'admin_action',
@@ -339,16 +355,24 @@ export async function fetchSystemActivities(
details: { details: {
action: log.action, action: log.action,
target_user_id: log.target_user_id, target_user_id: log.target_user_id,
details: log.details, details,
} as AdminActionDetails, } as AdminActionDetails,
}); });
} }
} }
// Fetch submission reviews (approved/rejected submissions) // Fetch submission reviews (approved/rejected submissions)
// Note: Content is now in submission_metadata table, but entity_name is cached in view
const { data: submissions, error: submissionsError } = await supabase const { data: submissions, error: submissionsError } = await supabase
.from('content_submissions') .from('content_submissions')
.select('id, submission_type, status, reviewer_id, reviewed_at, content') .select(`
id,
submission_type,
status,
reviewer_id,
reviewed_at,
submission_metadata(name)
`)
.not('reviewed_at', 'is', null) .not('reviewed_at', 'is', null)
.in('status', ['approved', 'rejected', 'partially_approved']) .in('status', ['approved', 'rejected', 'partially_approved'])
.order('reviewed_at', { ascending: false }) .order('reviewed_at', { ascending: false })
@@ -385,7 +409,12 @@ export async function fetchSystemActivities(
); );
for (const submission of submissions) { for (const submission of submissions) {
const contentData = submission.content as SubmissionContent; // Get name from submission_metadata
const metadata = submission.submission_metadata as any;
const entityName = Array.isArray(metadata) && metadata.length > 0
? metadata[0]?.name
: undefined;
const submissionItem = itemsMap.get(submission.id); const submissionItem = itemsMap.get(submission.id);
const itemData = submissionItem?.item_data as any; const itemData = submissionItem?.item_data as any;
@@ -394,7 +423,7 @@ export async function fetchSystemActivities(
submission_id: submission.id, submission_id: submission.id,
submission_type: submission.submission_type, submission_type: submission.submission_type,
status: submission.status, status: submission.status,
entity_name: contentData?.name, entity_name: entityName,
}; };
// Enrich with photo-specific data for photo submissions // Enrich with photo-specific data for photo submissions
@@ -600,7 +629,14 @@ export async function fetchSystemActivities(
// 3. User bans/unbans from admin audit log // 3. User bans/unbans from admin audit log
const { data: banActions, error: banError } = await supabase const { data: banActions, error: banError } = await supabase
.from('admin_audit_log') .from('admin_audit_log')
.select('id, admin_user_id, target_user_id, action, details, created_at') .select(`
id,
admin_user_id,
target_user_id,
action,
created_at,
admin_audit_details(detail_key, detail_value)
`)
.in('action', ['user_banned', 'user_unbanned']) .in('action', ['user_banned', 'user_unbanned'])
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(limit); .limit(limit);
@@ -608,6 +644,15 @@ export async function fetchSystemActivities(
if (!banError && banActions) { if (!banError && banActions) {
for (const action of banActions) { for (const action of banActions) {
const activityType = action.action === 'user_banned' ? 'user_banned' : 'user_unbanned'; const activityType = action.action === 'user_banned' ? 'user_banned' : 'user_unbanned';
// Convert relational details back to object format
const details: Record<string, any> = {};
if (action.admin_audit_details) {
for (const detail of action.admin_audit_details as any[]) {
details[detail.detail_key] = detail.detail_value;
}
}
activities.push({ activities.push({
id: action.id, id: action.id,
type: activityType, type: activityType,
@@ -617,7 +662,7 @@ export async function fetchSystemActivities(
details: { details: {
user_id: action.target_user_id, user_id: action.target_user_id,
action: action.action === 'user_banned' ? 'banned' : 'unbanned', action: action.action === 'user_banned' ? 'banned' : 'unbanned',
reason: typeof action.details === 'object' && action.details && 'reason' in action.details ? String(action.details.reason) : undefined, reason: details.reason,
} as AccountLifecycleDetails, } as AccountLifecycleDetails,
}); });
} }

View File

@@ -287,7 +287,13 @@ export default function Profile() {
// Fetch last 10 submissions with enriched data // Fetch last 10 submissions with enriched data
let submissionsQuery = supabase let submissionsQuery = supabase
.from('content_submissions') .from('content_submissions')
.select('id, submission_type, content, status, created_at') .select(`
id,
submission_type,
status,
created_at,
submission_metadata(name)
`)
.eq('user_id', userId) .eq('user_id', userId)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(10); .limit(10);
@@ -305,6 +311,12 @@ export default function Profile() {
const enrichedSubmissions = await Promise.all((submissions || []).map(async (sub) => { const enrichedSubmissions = await Promise.all((submissions || []).map(async (sub) => {
const enriched: any = { ...sub }; const enriched: any = { ...sub };
// Get name from submission_metadata
const metadata = sub.submission_metadata as any;
enriched.name = Array.isArray(metadata) && metadata.length > 0
? metadata[0]?.name
: undefined;
// For photo submissions, get photo count and preview // For photo submissions, get photo count and preview
if (sub.submission_type === 'photo') { if (sub.submission_type === 'photo') {
const { data: photoSubs } = await supabase const { data: photoSubs } = await supabase

View File

@@ -24,7 +24,16 @@ export default function ErrorLookup() {
// Search by partial or full request ID // Search by partial or full request ID
const { data, error } = await supabase const { data, error } = await supabase
.from('request_metadata') .from('request_metadata')
.select('*') .select(`
*,
request_breadcrumbs(
timestamp,
category,
message,
level,
sequence_order
)
`)
.ilike('request_id', `${errorId}%`) .ilike('request_id', `${errorId}%`)
.not('error_type', 'is', null) .not('error_type', 'is', null)
.limit(1) .limit(1)

View File

@@ -31,7 +31,16 @@ export default function ErrorMonitoring() {
let query = supabase let query = supabase
.from('request_metadata') .from('request_metadata')
.select('*') .select(`
*,
request_breadcrumbs(
timestamp,
category,
message,
level,
sequence_order
)
`)
.not('error_type', 'is', null) .not('error_type', 'is', null)
.gte('created_at', `now() - interval '${dateMap[dateRange]}'`) .gte('created_at', `now() - interval '${dateMap[dateRange]}'`)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })