Implement planned features

This commit is contained in:
gpt-engineer-app[bot]
2025-11-03 00:38:16 +00:00
parent ecca11a475
commit 061c06be29
10 changed files with 771 additions and 123 deletions

View File

@@ -0,0 +1,134 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchEditHistory } from '@/lib/submissionItemsService';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { EditHistoryEntry } from './EditHistoryEntry';
import { History, Loader2, AlertCircle } from 'lucide-react';
interface EditHistoryAccordionProps {
submissionId: string;
}
const INITIAL_LOAD = 20;
const LOAD_MORE_INCREMENT = 10;
export function EditHistoryAccordion({ submissionId }: EditHistoryAccordionProps) {
const [limit, setLimit] = useState(INITIAL_LOAD);
const { data: editHistory, isLoading, error } = useQuery({
queryKey: ['edit-history', submissionId, limit],
queryFn: async () => {
const { supabase } = await import('@/integrations/supabase/client');
// Fetch edit history with user profiles
const { data, error } = await supabase
.from('item_edit_history')
.select(`
id,
item_id,
edited_at,
edited_by,
previous_data,
new_data,
edit_reason,
changed_fields,
profiles:edited_by (
username,
avatar_url
)
`)
.eq('item_id', submissionId)
.order('edited_at', { ascending: false })
.limit(limit);
if (error) throw error;
return data || [];
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
const loadMore = () => {
setLimit(prev => prev + LOAD_MORE_INCREMENT);
};
const hasMore = editHistory && editHistory.length === limit;
return (
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="edit-history">
<AccordionTrigger className="hover:no-underline">
<div className="flex items-center gap-2">
<History className="h-4 w-4" />
<span>Edit History</span>
{editHistory && editHistory.length > 0 && (
<span className="text-xs text-muted-foreground">
({editHistory.length} edit{editHistory.length !== 1 ? 's' : ''})
</span>
)}
</div>
</AccordionTrigger>
<AccordionContent>
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Failed to load edit history: {error instanceof Error ? error.message : 'Unknown error'}
</AlertDescription>
</Alert>
)}
{!isLoading && !error && editHistory && editHistory.length === 0 && (
<Alert>
<AlertDescription>
No edit history found for this submission.
</AlertDescription>
</Alert>
)}
{!isLoading && !error && editHistory && editHistory.length > 0 && (
<div className="space-y-4">
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-3">
{editHistory.map((entry: any) => (
<EditHistoryEntry
key={entry.id}
editId={entry.id}
editorName={entry.profiles?.username || 'Unknown User'}
editorAvatar={entry.profiles?.avatar_url}
timestamp={entry.edited_at}
changedFields={entry.changed_fields || []}
editReason={entry.edit_reason}
beforeData={entry.previous_data}
afterData={entry.new_data}
/>
))}
</div>
</ScrollArea>
{hasMore && (
<div className="flex justify-center pt-2">
<Button
variant="outline"
size="sm"
onClick={loadMore}
>
Load More
</Button>
</div>
)}
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
);
}

View File

@@ -0,0 +1,131 @@
import { formatDistanceToNow } from 'date-fns';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ChevronDown, Edit, User } from 'lucide-react';
import { useState } from 'react';
interface EditHistoryEntryProps {
editId: string;
editorName: string;
editorAvatar?: string;
timestamp: string;
changedFields: string[];
editReason?: string;
beforeData?: Record<string, any>;
afterData?: Record<string, any>;
}
export function EditHistoryEntry({
editId,
editorName,
editorAvatar,
timestamp,
changedFields,
editReason,
beforeData,
afterData,
}: EditHistoryEntryProps) {
const [isExpanded, setIsExpanded] = useState(false);
const getFieldValue = (data: Record<string, any> | undefined, field: string): string => {
if (!data || !(field in data)) return '—';
const value = data[field];
if (value === null || value === undefined) return '—';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
};
return (
<Card className="p-4">
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<div className="flex items-start gap-3">
{/* Editor Avatar */}
<Avatar className="h-8 w-8">
<AvatarImage src={editorAvatar} alt={editorName} />
<AvatarFallback>
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
{/* Edit Info */}
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{editorName}</span>
<Badge variant="secondary" className="text-xs">
<Edit className="h-3 w-3 mr-1" />
{changedFields.length} field{changedFields.length !== 1 ? 's' : ''}
</Badge>
</div>
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(new Date(timestamp), { addSuffix: true })}
</span>
</div>
{/* Changed Fields Summary */}
<div className="flex flex-wrap gap-1">
{changedFields.slice(0, 3).map((field) => (
<Badge key={field} variant="outline" className="text-xs">
{field}
</Badge>
))}
{changedFields.length > 3 && (
<Badge variant="outline" className="text-xs">
+{changedFields.length - 3} more
</Badge>
)}
</div>
{/* Edit Reason */}
{editReason && (
<p className="text-sm text-muted-foreground italic">
"{editReason}"
</p>
)}
{/* Expand/Collapse Button */}
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2">
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`} />
<span className="ml-1">{isExpanded ? 'Hide' : 'Show'} Changes</span>
</Button>
</CollapsibleTrigger>
</div>
</div>
{/* Detailed Changes */}
<CollapsibleContent className="mt-3 space-y-3">
{changedFields.map((field) => {
const beforeValue = getFieldValue(beforeData, field);
const afterValue = getFieldValue(afterData, field);
return (
<div key={field} className="border-l-2 border-muted pl-3 space-y-1">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{field}
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Before</div>
<div className="bg-destructive/10 text-destructive rounded p-2 font-mono text-xs break-all">
{beforeValue}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">After</div>
<div className="bg-success/10 text-success rounded p-2 font-mono text-xs break-all">
{afterValue}
</div>
</div>
</div>
</div>
);
})}
</CollapsibleContent>
</Collapsible>
</Card>
);
}

View File

@@ -1,8 +1,11 @@
import { Filter, MessageSquare, FileText, Image, X } from 'lucide-react';
import { Filter, MessageSquare, FileText, Image, X, ChevronDown } from 'lucide-react';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { QueueSortControls } from './QueueSortControls';
import { useFilterPanelState } from '@/hooks/useFilterPanelState';
import type { EntityFilter, StatusFilter, SortConfig } from '@/types/moderation';
interface QueueFiltersProps {
@@ -39,12 +42,38 @@ export const QueueFilters = ({
onClearFilters,
showClearButton
}: QueueFiltersProps) => {
const { isCollapsed, toggle } = useFilterPanelState();
// Count active filters
const activeFilterCount = [
activeEntityFilter !== 'all' ? 1 : 0,
activeStatusFilter !== 'all' ? 1 : 0,
].reduce((sum, val) => sum + val, 0);
return (
<div className={`flex flex-col gap-4 bg-muted/50 rounded-lg ${isMobile ? 'p-3' : 'p-4 sm:flex-row'}`}>
<div className="flex items-center justify-between w-full mb-2 pb-2 border-b border-border">
<div className={`bg-muted/50 rounded-lg transition-all duration-250 ${isMobile ? 'p-3' : 'p-4'}`}>
<Collapsible open={!isCollapsed} onOpenChange={() => toggle()}>
{/* Header with collapse trigger on mobile */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-muted-foreground">Moderation Queue</h3>
{isCollapsed && activeFilterCount > 0 && (
<Badge variant="secondary" className="text-xs">
{activeFilterCount} active
</Badge>
)}
</div>
{isMobile && (
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<ChevronDown className={`h-4 w-4 transition-transform duration-250 ${isCollapsed ? '' : 'rotate-180'}`} />
<span className="sr-only">{isCollapsed ? 'Expand filters' : 'Collapse filters'}</span>
</Button>
</CollapsibleTrigger>
)}
</div>
<CollapsibleContent className="space-y-4">
<div className={`flex gap-4 flex-1 ${isMobile ? 'flex-col' : 'flex-col sm:flex-row'}`}>
{/* Entity Type Filter */}
<div className={`space-y-2 ${isMobile ? 'w-full' : 'min-w-[140px]'}`}>
@@ -55,7 +84,7 @@ export const QueueFilters = ({
>
<SelectTrigger
id="entity-filter"
className={isMobile ? "h-10" : ""}
className={isMobile ? "h-11 min-h-[44px]" : ""}
aria-label="Filter by entity type"
>
<SelectValue>
@@ -103,7 +132,7 @@ export const QueueFilters = ({
>
<SelectTrigger
id="status-filter"
className={isMobile ? "h-10" : ""}
className={isMobile ? "h-11 min-h-[44px]" : ""}
aria-label="Filter by submission status"
>
<SelectValue>
@@ -132,14 +161,42 @@ export const QueueFilters = ({
/>
</div>
{/* Clear Filters Button */}
{/* Clear Filters & Apply Buttons (mobile only) */}
{isMobile && (
<div className="flex gap-2 pt-2 border-t border-border">
{showClearButton && (
<div className={isMobile ? "" : "flex items-end"}>
<Button
variant="outline"
size={isMobile ? "default" : "sm"}
size="default"
onClick={onClearFilters}
className={`flex items-center gap-2 ${isMobile ? 'w-full h-10' : ''}`}
className="flex-1 h-11 min-h-[44px]"
aria-label="Clear all filters"
>
<X className="w-4 h-4 mr-2" />
Clear All
</Button>
)}
<Button
variant="default"
size="default"
onClick={() => toggle()}
className="flex-1 h-11 min-h-[44px]"
>
Apply
</Button>
</div>
)}
</CollapsibleContent>
</Collapsible>
{/* Clear Filters Button (desktop only) */}
{!isMobile && showClearButton && (
<div className="flex items-end pt-2">
<Button
variant="outline"
size="sm"
onClick={onClearFilters}
className="flex items-center gap-2"
aria-label="Clear all filters"
>
<X className="w-4 h-4" />

View File

@@ -13,8 +13,10 @@ import {
approveSubmissionItems,
rejectSubmissionItems,
escalateSubmission,
checkSubmissionConflict,
type SubmissionItemWithDeps,
type DependencyConflict
type DependencyConflict,
type ConflictCheckResult
} from '@/lib/submissionItemsService';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
@@ -22,7 +24,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AlertCircle, CheckCircle2, XCircle, Edit, Network, ArrowUp } from 'lucide-react';
import { AlertCircle, CheckCircle2, XCircle, Edit, Network, ArrowUp, History } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useIsMobile } from '@/hooks/use-mobile';
@@ -34,6 +36,8 @@ import { RejectionDialog } from './RejectionDialog';
import { ItemEditDialog } from './ItemEditDialog';
import { ValidationBlockerDialog } from './ValidationBlockerDialog';
import { WarningConfirmDialog } from './WarningConfirmDialog';
import { ConflictResolutionModal } from './ConflictResolutionModal';
import { EditHistoryAccordion } from './EditHistoryAccordion';
import { validateMultipleItems, ValidationResult } from '@/lib/entityValidationSchemas';
import { logger } from '@/lib/logger';
@@ -70,6 +74,9 @@ export function SubmissionReviewManager({
const [userConfirmedWarnings, setUserConfirmedWarnings] = useState(false);
const [hasBlockingErrors, setHasBlockingErrors] = useState(false);
const [globalValidationKey, setGlobalValidationKey] = useState(0);
const [conflictData, setConflictData] = useState<ConflictCheckResult | null>(null);
const [showConflictResolutionModal, setShowConflictResolutionModal] = useState(false);
const [lastModifiedTimestamp, setLastModifiedTimestamp] = useState<string | null>(null);
const { toast } = useToast();
const { isAdmin, isSuperuser } = useUserRole();
@@ -113,15 +120,16 @@ export function SubmissionReviewManager({
try {
const { supabase } = await import('@/integrations/supabase/client');
// Fetch submission type
// Fetch submission type and last_modified_at
const { data: submission } = await supabase
.from('content_submissions')
.select('submission_type')
.select('submission_type, last_modified_at')
.eq('id', submissionId)
.single();
if (submission) {
setSubmissionType(submission.submission_type || 'submission');
setLastModifiedTimestamp(submission.last_modified_at);
}
const fetchedItems = await fetchSubmissionItems(submissionId);
@@ -211,6 +219,18 @@ export function SubmissionReviewManager({
dispatch({ type: 'START_APPROVAL' });
try {
// Check for conflicts first (optimistic locking)
if (lastModifiedTimestamp) {
const conflictCheck = await checkSubmissionConflict(submissionId, lastModifiedTimestamp);
if (conflictCheck.hasConflict) {
setConflictData(conflictCheck);
setShowConflictResolutionModal(true);
dispatch({ type: 'RESET' }); // Return to reviewing state
return; // Block approval until conflict resolved
}
}
// Run validation on all selected items
const validationResultsMap = await validateMultipleItems(
selectedItems.map(item => ({
@@ -603,6 +623,43 @@ export function SubmissionReviewManager({
i.item_data?.name || i.item_type.replace('_', ' ')
)}
/>
<ConflictResolutionModal
open={showConflictResolutionModal}
onOpenChange={setShowConflictResolutionModal}
conflictData={conflictData}
onResolve={async (strategy) => {
if (strategy === 'keep-mine') {
// Log conflict resolution
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,
}]);
// Force override and proceed with approval
await handleApprove();
} else if (strategy === 'keep-theirs') {
// Reload data and discard local changes
await loadSubmissionItems();
toast({
title: 'Changes Discarded',
description: 'Loaded the latest version from the server',
});
} else if (strategy === 'reload') {
// Just reload without approving
await loadSubmissionItems();
toast({
title: 'Reloaded',
description: 'Viewing the latest version',
});
}
setShowConflictResolutionModal(false);
setConflictData(null);
}}
/>
</>
);
@@ -724,13 +781,13 @@ export function SubmissionReviewManager({
<Tabs
value={activeTab}
onValueChange={(v) => {
if (v === 'items' || v === 'dependencies') {
setActiveTab(v);
if (v === 'items' || v === 'dependencies' || v === 'history') {
setActiveTab(v as 'items' | 'dependencies');
}
}}
className="flex-1 flex flex-col"
>
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="items">
<CheckCircle2 className="w-4 h-4 mr-2" />
Items ({items.length})
@@ -739,6 +796,10 @@ export function SubmissionReviewManager({
<Network className="w-4 h-4 mr-2" />
Dependencies
</TabsTrigger>
<TabsTrigger value="history">
<History className="w-4 h-4 mr-2" />
History
</TabsTrigger>
</TabsList>
<TabsContent value="items" className="flex-1 overflow-hidden">
@@ -778,6 +839,12 @@ export function SubmissionReviewManager({
<TabsContent value="dependencies" className="flex-1 overflow-hidden">
<DependencyVisualizer items={items} selectedIds={selectedItemIds} />
</TabsContent>
<TabsContent value="history" className="flex-1 overflow-hidden">
<ScrollArea className="h-full pr-4">
<EditHistoryAccordion submissionId={submissionId} />
</ScrollArea>
</TabsContent>
</Tabs>
{/* Blocking error alert */}

View File

@@ -0,0 +1,47 @@
import { useState, useEffect } from 'react';
const STORAGE_KEY = 'queue-filter-panel-collapsed';
interface UseFilterPanelStateReturn {
isCollapsed: boolean;
toggle: () => void;
setCollapsed: (value: boolean) => void;
}
/**
* Hook to manage filter panel collapsed/expanded state
* Syncs with localStorage for persistence across sessions
*/
export function useFilterPanelState(): UseFilterPanelStateReturn {
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
// Initialize from localStorage on mount
try {
const stored = localStorage.getItem(STORAGE_KEY);
// Default to collapsed on mobile (width < 768px)
const isMobile = window.innerWidth < 768;
return stored ? JSON.parse(stored) : isMobile;
} catch (error) {
console.error('Error reading filter panel state from localStorage:', error);
return window.innerWidth < 768;
}
});
// Sync to localStorage when state changes
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(isCollapsed));
} catch (error) {
console.error('Error saving filter panel state to localStorage:', error);
}
}, [isCollapsed]);
const toggle = () => setIsCollapsed(prev => !prev);
const setCollapsed = (value: boolean) => setIsCollapsed(value);
return {
isCollapsed,
toggle,
setCollapsed,
};
}

View File

@@ -450,6 +450,44 @@ export type Database = {
},
]
}
conflict_resolutions: {
Row: {
conflict_details: Json | null
created_at: string
detected_at: string
id: string
resolution_strategy: string
resolved_by: string | null
submission_id: string
}
Insert: {
conflict_details?: Json | null
created_at?: string
detected_at?: string
id?: string
resolution_strategy: string
resolved_by?: string | null
submission_id: string
}
Update: {
conflict_details?: Json | null
created_at?: string
detected_at?: string
id?: string
resolution_strategy?: string
resolved_by?: string | null
submission_id?: string
}
Relationships: [
{
foreignKeyName: "conflict_resolutions_submission_id_fkey"
columns: ["submission_id"]
isOneToOne: false
referencedRelation: "content_submissions"
referencedColumns: ["id"]
},
]
}
contact_email_threads: {
Row: {
body_html: string | null

View File

@@ -35,6 +35,18 @@ export interface DependencyConflict {
}>;
}
export interface ConflictCheckResult {
hasConflict: boolean;
clientVersion: {
last_modified_at: string;
};
serverVersion?: {
last_modified_at: string;
last_modified_by: string;
modified_by_profile?: any;
} | null;
}
/**
* Fetch all items for a submission with their dependencies
*/
@@ -1369,25 +1381,6 @@ export async function fetchEditHistory(itemId: string) {
}
}
/**
* Conflict detection interfaces and functions
*/
export interface ConflictCheckResult {
hasConflict: boolean;
serverVersion?: {
last_modified_at: string;
last_modified_by: string;
modified_by_profile?: {
username: string;
display_name: string;
avatar_url: string;
};
};
clientVersion?: {
last_modified_at: string;
};
}
/**
* Check if a submission has been modified since the client last loaded it
* Used for optimistic locking to prevent concurrent edit conflicts
@@ -1425,14 +1418,14 @@ export async function checkSubmissionConflict(
return {
hasConflict: serverTimestamp > clientTimestamp,
clientVersion: {
last_modified_at: clientLastModified,
},
serverVersion: {
last_modified_at: data.last_modified_at,
last_modified_by: data.last_modified_by,
modified_by_profile: data.profiles as any,
},
clientVersion: {
last_modified_at: clientLastModified,
},
};
} catch (error: unknown) {
logger.error('Error checking submission conflict', {

View File

@@ -112,6 +112,56 @@ serve(async (req) => {
);
}
// Generate idempotency key for duplicate prevention
const { data: keyData, error: keyError } = await supabase
.rpc('generate_notification_idempotency_key', {
p_notification_type: 'moderation_submission',
p_entity_id: submission_id,
p_recipient_id: '00000000-0000-0000-0000-000000000000', // Topic-based, use placeholder
p_event_data: { submission_type, action }
});
const idempotencyKey = keyData || `mod_sub_${submission_id}_${Date.now()}`;
// Check for duplicate within 24h window
const { data: existingLog, error: logCheckError } = await supabase
.from('notification_logs')
.select('id')
.eq('idempotency_key', idempotencyKey)
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
.maybeSingle();
if (existingLog) {
// Duplicate detected - log and skip
await supabase.from('notification_logs').update({
is_duplicate: true
}).eq('id', existingLog.id);
edgeLogger.info('Duplicate notification prevented', {
action: 'notify_moderators',
requestId: tracking.requestId,
idempotencyKey,
submission_id
});
return new Response(
JSON.stringify({
success: true,
message: 'Duplicate notification prevented',
idempotencyKey,
requestId: tracking.requestId,
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
status: 200,
}
);
}
// Prepare enhanced notification payload
const notificationPayload = {
baseUrl: 'https://www.thrillwiki.com',
@@ -146,6 +196,19 @@ serve(async (req) => {
},
});
// Log notification in notification_logs with idempotency key
await supabase.from('notification_logs').insert({
user_id: '00000000-0000-0000-0000-000000000000', // Topic-based
notification_type: 'moderation_submission',
idempotency_key: idempotencyKey,
is_duplicate: false,
metadata: {
submission_id,
submission_type,
transaction_id: data?.transactionId
}
});
if (error) {
const duration = endRequest(tracking);
edgeLogger.error('Failed to notify moderators via topic', {

View File

@@ -151,11 +151,64 @@ serve(async (req) => {
};
}
// Generate idempotency key for duplicate prevention
const { data: keyData, error: keyError } = await supabase
.rpc('generate_notification_idempotency_key', {
p_notification_type: `submission_${status}`,
p_entity_id: submission_id,
p_recipient_id: user_id,
});
const idempotencyKey = keyData || `user_sub_${submission_id}_${user_id}_${status}_${Date.now()}`;
// Check for duplicate within 24h window
const { data: existingLog, error: logCheckError } = await supabase
.from('notification_logs')
.select('id')
.eq('user_id', user_id)
.eq('idempotency_key', idempotencyKey)
.gte('created_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString())
.maybeSingle();
if (existingLog) {
// Duplicate detected - log and skip
await supabase.from('notification_logs').update({
is_duplicate: true
}).eq('id', existingLog.id);
console.log('Duplicate notification prevented:', {
userId: user_id,
idempotencyKey,
submissionId: submission_id,
requestId: tracking.requestId
});
endRequest(tracking, 200);
return new Response(
JSON.stringify({
success: true,
message: 'Duplicate notification prevented',
idempotencyKey,
requestId: tracking.requestId
}),
{
headers: {
...corsHeaders,
'Content-Type': 'application/json',
'X-Request-ID': tracking.requestId
},
status: 200,
}
);
}
console.log('Sending notification to user:', {
userId: user_id,
workflowId,
entityName,
status,
idempotencyKey,
requestId: tracking.requestId
});
@@ -175,6 +228,19 @@ serve(async (req) => {
throw new Error(`Failed to trigger notification: ${notificationError.message}`);
}
// Log notification in notification_logs with idempotency key
await supabase.from('notification_logs').insert({
user_id,
notification_type: `submission_${status}`,
idempotency_key: idempotencyKey,
is_duplicate: false,
metadata: {
submission_id,
submission_type,
transaction_id: notificationResult?.transactionId
}
});
console.log('User notification sent successfully:', notificationResult);
endRequest(tracking, 200);

View File

@@ -0,0 +1,52 @@
-- Phase 1: Add conflict resolution tracking
CREATE TABLE IF NOT EXISTS public.conflict_resolutions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
submission_id UUID NOT NULL REFERENCES public.content_submissions(id) ON DELETE CASCADE,
detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resolved_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
resolution_strategy TEXT NOT NULL CHECK (resolution_strategy IN ('keep-mine', 'keep-theirs', 'reload', 'merge')),
conflict_details JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Add index for performance
CREATE INDEX IF NOT EXISTS idx_conflict_resolutions_submission
ON public.conflict_resolutions(submission_id);
CREATE INDEX IF NOT EXISTS idx_conflict_resolutions_detected_at
ON public.conflict_resolutions(detected_at DESC);
-- Enable RLS
ALTER TABLE public.conflict_resolutions ENABLE ROW LEVEL SECURITY;
-- Policy: Moderators can view all conflict resolutions
CREATE POLICY "Moderators can view conflict resolutions"
ON public.conflict_resolutions
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.user_roles
WHERE user_id = auth.uid()
AND role IN ('moderator', 'admin', 'superuser')
)
);
-- Policy: Moderators can insert conflict resolutions
CREATE POLICY "Moderators can insert conflict resolutions"
ON public.conflict_resolutions
FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM public.user_roles
WHERE user_id = auth.uid()
AND role IN ('moderator', 'admin', 'superuser')
)
AND resolved_by = auth.uid()
);
-- Add index for notification deduplication performance (Phase 3)
CREATE INDEX IF NOT EXISTS idx_notification_logs_dedup
ON public.notification_logs(user_id, idempotency_key, created_at);
-- Add comment
COMMENT ON TABLE public.conflict_resolutions IS 'Tracks resolution of concurrent edit conflicts in moderation system';