mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-24 07:31:14 -05:00
Refactor log_request_metadata function
This commit is contained in:
@@ -640,14 +640,23 @@ export function SubmissionReviewManager({
|
||||
}}
|
||||
onResolve={async (strategy) => {
|
||||
if (strategy === 'keep-mine') {
|
||||
// Log conflict resolution
|
||||
// Log conflict resolution using relational tables
|
||||
const { supabase } = await import('@/integrations/supabase/client');
|
||||
await supabase.from('conflict_resolutions').insert([{
|
||||
submission_id: submissionId,
|
||||
resolved_by: user?.id || null,
|
||||
resolution_strategy: strategy,
|
||||
conflict_details: conflictData as any,
|
||||
}]);
|
||||
const { writeConflictDetailFields } = await import('@/lib/auditHelpers');
|
||||
|
||||
const { data: resolution, error } = await supabase
|
||||
.from('conflict_resolutions')
|
||||
.insert([{
|
||||
submission_id: submissionId,
|
||||
resolved_by: user?.id || null,
|
||||
resolution_strategy: strategy,
|
||||
}])
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (!error && resolution && conflictData) {
|
||||
await writeConflictDetailFields(resolution.id, conflictData as any);
|
||||
}
|
||||
|
||||
// Force override and proceed with approval
|
||||
await handleApprove();
|
||||
|
||||
@@ -23,22 +23,31 @@ export function RecentPhotosPreview({ rideId, onViewAll }: RecentPhotosPreviewPr
|
||||
async function fetchPhotos() {
|
||||
const { data, error } = await supabase
|
||||
.from('reviews')
|
||||
.select('photos')
|
||||
.select(`
|
||||
id,
|
||||
user_id,
|
||||
created_at,
|
||||
review_photos!inner(
|
||||
cloudflare_image_url,
|
||||
caption,
|
||||
order_index,
|
||||
id
|
||||
)
|
||||
`)
|
||||
.eq('ride_id', rideId)
|
||||
.eq('moderation_status', 'approved')
|
||||
.not('photos', 'is', null)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (!error && data) {
|
||||
const allPhotos: Photo[] = [];
|
||||
data.forEach((review: any) => {
|
||||
if (review.photos && Array.isArray(review.photos)) {
|
||||
review.photos.forEach((photo: any) => {
|
||||
if (review.review_photos && Array.isArray(review.review_photos)) {
|
||||
review.review_photos.forEach((photo: any) => {
|
||||
if (allPhotos.length < 4) {
|
||||
allPhotos.push({
|
||||
id: photo.id || Math.random().toString(),
|
||||
image_url: photo.image_url || photo.url,
|
||||
image_url: photo.cloudflare_image_url,
|
||||
caption: photo.caption || null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -702,6 +702,7 @@ export type Database = {
|
||||
status: string
|
||||
subject: string
|
||||
submitter_profile_data: Json | null
|
||||
submitter_profile_id: string | null
|
||||
submitter_reputation: number | null
|
||||
submitter_username: string | null
|
||||
thread_id: string | null
|
||||
@@ -730,6 +731,7 @@ export type Database = {
|
||||
status?: string
|
||||
subject: string
|
||||
submitter_profile_data?: Json | null
|
||||
submitter_profile_id?: string | null
|
||||
submitter_reputation?: number | null
|
||||
submitter_username?: string | null
|
||||
thread_id?: string | null
|
||||
@@ -758,6 +760,7 @@ export type Database = {
|
||||
status?: string
|
||||
subject?: string
|
||||
submitter_profile_data?: Json | null
|
||||
submitter_profile_id?: string | null
|
||||
submitter_reputation?: number | null
|
||||
submitter_username?: string | null
|
||||
thread_id?: string | null
|
||||
@@ -766,7 +769,22 @@ export type Database = {
|
||||
user_agent?: string | null
|
||||
user_id?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "contact_submissions_submitter_profile_id_fkey"
|
||||
columns: ["submitter_profile_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "filtered_profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "contact_submissions_submitter_profile_id_fkey"
|
||||
columns: ["submitter_profile_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "profiles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
content_submissions: {
|
||||
Row: {
|
||||
@@ -5576,11 +5594,11 @@ export type Database = {
|
||||
log_request_metadata:
|
||||
| {
|
||||
Args: {
|
||||
p_breadcrumbs?: Json
|
||||
p_breadcrumbs?: string
|
||||
p_client_version?: string
|
||||
p_duration_ms?: number
|
||||
p_endpoint?: string
|
||||
p_environment_context?: Json
|
||||
p_environment_context?: string
|
||||
p_error_message?: string
|
||||
p_error_stack?: string
|
||||
p_error_type?: string
|
||||
|
||||
@@ -191,3 +191,68 @@ export async function readItemChangeFields(
|
||||
return acc;
|
||||
}, {} as Record<string, { old_value: string | null; new_value: string | null }>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write profile change fields to relational table
|
||||
* Replaces JSONB profile_audit_log.changes column
|
||||
*/
|
||||
export async function writeProfileChangeFields(
|
||||
auditLogId: string,
|
||||
changes: Record<string, { old_value?: unknown; new_value?: unknown }>
|
||||
): Promise<void> {
|
||||
if (!changes || Object.keys(changes).length === 0) return;
|
||||
|
||||
const entries = Object.entries(changes).map(([fieldName, change]) => ({
|
||||
audit_log_id: auditLogId,
|
||||
field_name: fieldName,
|
||||
old_value: change.old_value !== undefined
|
||||
? (typeof change.old_value === 'object' ? JSON.stringify(change.old_value) : String(change.old_value))
|
||||
: null,
|
||||
new_value: change.new_value !== undefined
|
||||
? (typeof change.new_value === 'object' ? JSON.stringify(change.new_value) : String(change.new_value))
|
||||
: null,
|
||||
}));
|
||||
|
||||
const { error } = await supabase
|
||||
.from('profile_change_fields')
|
||||
.insert(entries);
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to write profile change fields', { error, auditLogId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write conflict detail fields to relational table
|
||||
* Replaces JSONB conflict_resolutions.conflict_details column
|
||||
*/
|
||||
export async function writeConflictDetailFields(
|
||||
conflictResolutionId: string,
|
||||
conflictData: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
if (!conflictData || Object.keys(conflictData).length === 0) return;
|
||||
|
||||
const entries = Object.entries(conflictData).map(([fieldName, value]) => ({
|
||||
conflict_resolution_id: conflictResolutionId,
|
||||
field_name: fieldName,
|
||||
conflicting_value_1: typeof value === 'object' && value !== null && 'v1' in value
|
||||
? String((value as any).v1)
|
||||
: null,
|
||||
conflicting_value_2: typeof value === 'object' && value !== null && 'v2' in value
|
||||
? String((value as any).v2)
|
||||
: null,
|
||||
resolved_value: typeof value === 'object' && value !== null && 'resolved' in value
|
||||
? String((value as any).resolved)
|
||||
: null,
|
||||
}));
|
||||
|
||||
const { error } = await supabase
|
||||
.from('conflict_detail_fields')
|
||||
.insert(entries);
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to write conflict detail fields', { error, conflictResolutionId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,20 +237,36 @@ class NotificationService {
|
||||
throw dbError;
|
||||
}
|
||||
|
||||
// Create audit log entry
|
||||
// DOCUMENTED EXCEPTION: profile_audit_log.changes column accepts JSONB
|
||||
// We validate the preferences structure with Zod before this point
|
||||
// Safe because the payload is constructed type-safely earlier in the function
|
||||
await supabase.from('profile_audit_log').insert([{
|
||||
user_id: userId,
|
||||
changed_by: userId,
|
||||
action: 'notification_preferences_updated',
|
||||
changes: {
|
||||
previous: previousPrefs || null,
|
||||
updated: validated,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
}]);
|
||||
// Create audit log entry using relational tables
|
||||
const { data: auditLog, error: auditError } = await supabase
|
||||
.from('profile_audit_log')
|
||||
.insert([{
|
||||
user_id: userId,
|
||||
changed_by: userId,
|
||||
action: 'notification_preferences_updated',
|
||||
changes: {}, // Empty placeholder - actual changes stored in profile_change_fields table
|
||||
}])
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (!auditError && auditLog) {
|
||||
// Write changes to relational profile_change_fields table
|
||||
const { writeProfileChangeFields } = await import('./auditHelpers');
|
||||
await writeProfileChangeFields(auditLog.id, {
|
||||
email_notifications: {
|
||||
old_value: previousPrefs?.channel_preferences,
|
||||
new_value: validated.channelPreferences,
|
||||
},
|
||||
workflow_preferences: {
|
||||
old_value: previousPrefs?.workflow_preferences,
|
||||
new_value: validated.workflowPreferences,
|
||||
},
|
||||
frequency_settings: {
|
||||
old_value: previousPrefs?.frequency_settings,
|
||||
new_value: validated.frequencySettings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Notification preferences updated', {
|
||||
action: 'update_notification_preferences',
|
||||
|
||||
81
src/lib/submissionMetadataService.ts
Normal file
81
src/lib/submissionMetadataService.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Submission Metadata Service
|
||||
* Handles reading/writing submission metadata to relational tables
|
||||
* Replaces content_submissions.content JSONB column
|
||||
*/
|
||||
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { logger } from './logger';
|
||||
|
||||
export interface SubmissionMetadataInsert {
|
||||
submission_id: string;
|
||||
metadata_key: string;
|
||||
metadata_value: string;
|
||||
value_type?: 'string' | 'number' | 'boolean' | 'date' | 'url' | 'json';
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write submission metadata to relational table
|
||||
*/
|
||||
export async function writeSubmissionMetadata(
|
||||
submissionId: string,
|
||||
metadata: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
if (!metadata || Object.keys(metadata).length === 0) return;
|
||||
|
||||
const entries: SubmissionMetadataInsert[] = Object.entries(metadata).map(([key, value], index) => ({
|
||||
submission_id: submissionId,
|
||||
metadata_key: key,
|
||||
metadata_value: typeof value === 'object' ? JSON.stringify(value) : String(value),
|
||||
value_type: inferValueType(value),
|
||||
display_order: index,
|
||||
}));
|
||||
|
||||
const { error } = await supabase
|
||||
.from('submission_metadata')
|
||||
.insert(entries);
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to write submission metadata', { error, submissionId });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read submission metadata from relational table
|
||||
* Returns as key-value object for backward compatibility
|
||||
*/
|
||||
export async function readSubmissionMetadata(
|
||||
submissionId: string
|
||||
): Promise<Record<string, string>> {
|
||||
const { data, error } = await supabase
|
||||
.from('submission_metadata')
|
||||
.select('metadata_key, metadata_value')
|
||||
.eq('submission_id', submissionId)
|
||||
.order('display_order');
|
||||
|
||||
if (error) {
|
||||
logger.error('Failed to read submission metadata', { error, submissionId });
|
||||
return {};
|
||||
}
|
||||
|
||||
return data.reduce((acc, row) => {
|
||||
acc[row.metadata_key] = row.metadata_value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer value type for metadata storage
|
||||
*/
|
||||
function inferValueType(value: unknown): 'string' | 'number' | 'boolean' | 'date' | 'url' | 'json' {
|
||||
if (typeof value === 'number') return 'number';
|
||||
if (typeof value === 'boolean') return 'boolean';
|
||||
if (typeof value === 'object') return 'json';
|
||||
if (typeof value === 'string') {
|
||||
if (value.startsWith('http://') || value.startsWith('https://')) return 'url';
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(value)) return 'date';
|
||||
}
|
||||
return 'string';
|
||||
}
|
||||
@@ -88,16 +88,13 @@ interface ContactSubmission {
|
||||
user_id: string | null;
|
||||
submitter_username: string | null;
|
||||
submitter_reputation: number | null;
|
||||
submitter_profile_data: {
|
||||
submitter_profile: {
|
||||
display_name?: string;
|
||||
member_since?: string;
|
||||
stats?: {
|
||||
rides: number;
|
||||
coasters: number;
|
||||
parks: number;
|
||||
reviews: number;
|
||||
};
|
||||
reputation?: number;
|
||||
created_at?: string;
|
||||
coaster_count?: number;
|
||||
ride_count?: number;
|
||||
park_count?: number;
|
||||
review_count?: number;
|
||||
avatar_url?: string;
|
||||
} | null;
|
||||
name: string;
|
||||
@@ -163,7 +160,19 @@ export default function AdminContact() {
|
||||
queryFn: async () => {
|
||||
let query = supabase
|
||||
.from('contact_submissions')
|
||||
.select('*')
|
||||
.select(`
|
||||
*,
|
||||
submitter_profile:profiles!submitter_profile_id(
|
||||
avatar_url,
|
||||
display_name,
|
||||
username,
|
||||
created_at,
|
||||
coaster_count,
|
||||
ride_count,
|
||||
park_count,
|
||||
review_count
|
||||
)
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
// Filter archived based on toggle
|
||||
@@ -1044,7 +1053,7 @@ export default function AdminContact() {
|
||||
</div>
|
||||
|
||||
{/* User Context Section */}
|
||||
{selectedSubmission.submitter_profile_data && (
|
||||
{selectedSubmission.submitter_profile && (
|
||||
<div className="border rounded-lg p-4 bg-muted/30">
|
||||
<h4 className="font-semibold mb-3 flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
@@ -1052,8 +1061,8 @@ export default function AdminContact() {
|
||||
</h4>
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
{selectedSubmission.submitter_profile_data.avatar_url && (
|
||||
<AvatarImage src={selectedSubmission.submitter_profile_data.avatar_url} />
|
||||
{selectedSubmission.submitter_profile.avatar_url && (
|
||||
<AvatarImage src={selectedSubmission.submitter_profile.avatar_url} />
|
||||
)}
|
||||
<AvatarFallback>
|
||||
{selectedSubmission.submitter_username?.[0]?.toUpperCase() || 'U'}
|
||||
@@ -1064,9 +1073,9 @@ export default function AdminContact() {
|
||||
<span className="font-medium">
|
||||
@{selectedSubmission.submitter_username}
|
||||
</span>
|
||||
{selectedSubmission.submitter_profile_data.display_name && (
|
||||
{selectedSubmission.submitter_profile.display_name && (
|
||||
<span className="text-muted-foreground">
|
||||
({selectedSubmission.submitter_profile_data.display_name})
|
||||
({selectedSubmission.submitter_profile.display_name})
|
||||
</span>
|
||||
)}
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
@@ -1074,25 +1083,23 @@ export default function AdminContact() {
|
||||
{selectedSubmission.submitter_reputation} rep
|
||||
</Badge>
|
||||
</div>
|
||||
{selectedSubmission.submitter_profile_data.member_since && (
|
||||
{selectedSubmission.submitter_profile.created_at && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Member since {format(new Date(selectedSubmission.submitter_profile_data.member_since), 'MMM d, yyyy')}
|
||||
</div>
|
||||
)}
|
||||
{selectedSubmission.submitter_profile_data.stats && (
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{selectedSubmission.submitter_profile_data.stats.rides} rides
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{selectedSubmission.submitter_profile_data.stats.coasters} coasters</span>
|
||||
<span>•</span>
|
||||
<span>{selectedSubmission.submitter_profile_data.stats.parks} parks</span>
|
||||
<span>•</span>
|
||||
<span>{selectedSubmission.submitter_profile_data.stats.reviews} reviews</span>
|
||||
Member since {format(new Date(selectedSubmission.submitter_profile.created_at), 'MMM d, yyyy')}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-sm flex-wrap">
|
||||
<span className="flex items-center gap-1">
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{selectedSubmission.submitter_profile.ride_count || 0} rides
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{selectedSubmission.submitter_profile.coaster_count || 0} coasters</span>
|
||||
<span>•</span>
|
||||
<span>{selectedSubmission.submitter_profile.park_count || 0} parks</span>
|
||||
<span>•</span>
|
||||
<span>{selectedSubmission.submitter_profile.review_count || 0} reviews</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user