diff --git a/src/components/admin/SystemActivityLog.tsx b/src/components/admin/SystemActivityLog.tsx index fa24be3c..d2f059c8 100644 --- a/src/components/admin/SystemActivityLog.tsx +++ b/src/components/admin/SystemActivityLog.tsx @@ -25,7 +25,9 @@ import { UserPlus, UserX, Ban, - UserCheck + UserCheck, + MessageSquare, + MessageSquareX } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import { @@ -38,7 +40,8 @@ import { ReportResolutionDetails, ReviewModerationDetails, PhotoApprovalDetails, - AccountLifecycleDetails + AccountLifecycleDetails, + ReviewLifecycleDetails } from '@/lib/systemActivityService'; export interface SystemActivityLogRef { @@ -123,6 +126,18 @@ const activityTypeConfig = { bgColor: 'bg-green-700/10', label: 'User Unbanned', }, + review_created: { + icon: MessageSquare, + color: 'text-blue-600', + bgColor: 'bg-blue-600/10', + label: 'Review Created', + }, + review_deleted: { + icon: MessageSquareX, + color: 'text-red-600', + bgColor: 'bg-red-600/10', + label: 'Review Deleted', + }, }; export const SystemActivityLog = forwardRef( @@ -480,6 +495,76 @@ export const SystemActivityLog = forwardRef +
+ New Review + {details.rating && ( +
+ + {details.rating}/5 +
+ )} + {details.entity_name && ( + <> + for + {details.entity_name} + + )} +
+ {isExpanded && details.review_text && ( +
+

+ "{details.review_text}" +

+
+ )} + + ); + } + + case 'review_deleted': { + const details = activity.details as ReviewLifecycleDetails; + return ( +
+
+ Review Deleted + {details.was_moderated && ( + + Moderated + + )} + {details.rating && ( +
+ + {details.rating}/5 +
+ )} + {details.entity_name && ( + from {details.entity_name} + )} +
+ {isExpanded && details.review_text && ( +
+

+ "{details.review_text}" +

+
+ )} + {isExpanded && details.deletion_reason && ( +
+ + + Reason: {details.deletion_reason} + +
+ )} +
+ ); + } + default: return null; } diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index 239ad5cb..d3ee5e0d 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -1898,6 +1898,66 @@ export type Database = { } Relationships: [] } + review_deletions: { + Row: { + created_at: string + deleted_at: string | null + deleted_by: string | null + deletion_reason: string | null + id: string + park_id: string | null + rating: number + review_id: string + review_text: string | null + ride_id: string | null + user_id: string + was_moderated: boolean | null + } + Insert: { + created_at: string + deleted_at?: string | null + deleted_by?: string | null + deletion_reason?: string | null + id?: string + park_id?: string | null + rating: number + review_id: string + review_text?: string | null + ride_id?: string | null + user_id: string + was_moderated?: boolean | null + } + Update: { + created_at?: string + deleted_at?: string | null + deleted_by?: string | null + deletion_reason?: string | null + id?: string + park_id?: string | null + rating?: number + review_id?: string + review_text?: string | null + ride_id?: string | null + user_id?: string + was_moderated?: boolean | null + } + Relationships: [ + { + foreignKeyName: "review_deletions_park_id_fkey" + columns: ["park_id"] + isOneToOne: false + referencedRelation: "parks" + referencedColumns: ["id"] + }, + { + foreignKeyName: "review_deletions_ride_id_fkey" + columns: ["ride_id"] + isOneToOne: false + referencedRelation: "rides" + referencedColumns: ["id"] + }, + ] + } review_photos: { Row: { caption: string | null diff --git a/src/lib/systemActivityService.ts b/src/lib/systemActivityService.ts index d15aebee..c58cdf3b 100644 --- a/src/lib/systemActivityService.ts +++ b/src/lib/systemActivityService.ts @@ -13,7 +13,9 @@ export type ActivityType = | 'account_deletion_confirmed' | 'account_deletion_cancelled' | 'user_banned' - | 'user_unbanned'; + | 'user_unbanned' + | 'review_created' + | 'review_deleted'; export interface ActivityActor { id: string; @@ -84,6 +86,19 @@ export interface AccountLifecycleDetails { request_id?: string; } +export interface ReviewLifecycleDetails { + review_id: string; + user_id: string; + username?: string; + entity_type: 'park' | 'ride'; + entity_id: string; + entity_name?: string; + rating?: number; + review_text?: string; + deletion_reason?: string; + was_moderated?: boolean; +} + export type ActivityDetails = | EntityChangeDetails | AdminActionDetails @@ -91,7 +106,8 @@ export type ActivityDetails = | ReportResolutionDetails | ReviewModerationDetails | PhotoApprovalDetails - | AccountLifecycleDetails; + | AccountLifecycleDetails + | ReviewLifecycleDetails; export interface SystemActivity { id: string; @@ -572,6 +588,70 @@ export async function fetchSystemActivities( } } + // Fetch review lifecycle events + // 1. Review creations (recent 7 days) + const { data: newReviews, error: reviewsError } = await supabase + .from('reviews') + .select('id, user_id, park_id, ride_id, rating, review_text, created_at') + .gte('created_at', sevenDaysAgo.toISOString()) + .order('created_at', { ascending: false }) + .limit(Math.ceil(limit / 2)); + + if (!reviewsError && newReviews) { + for (const review of newReviews) { + const entityType = review.park_id ? 'park' : 'ride'; + const entityId = review.park_id || review.ride_id; + + activities.push({ + id: `review-${review.id}`, + type: 'review_created', + timestamp: review.created_at, + actor_id: review.user_id, + action: 'created review', + details: { + review_id: review.id, + user_id: review.user_id, + entity_type: entityType as 'park' | 'ride', + entity_id: entityId!, + rating: review.rating, + review_text: review.review_text, + } as ReviewLifecycleDetails, + }); + } + } + + // 2. Review deletions + const { data: deletedReviews, error: deletionsError } = await supabase + .from('review_deletions') + .select('id, review_id, user_id, park_id, ride_id, rating, review_text, deleted_by, deleted_at, deletion_reason, was_moderated') + .order('deleted_at', { ascending: false }) + .limit(limit); + + if (!deletionsError && deletedReviews) { + for (const deletion of deletedReviews) { + const entityType = deletion.park_id ? 'park' : 'ride'; + const entityId = deletion.park_id || deletion.ride_id; + + activities.push({ + id: deletion.id, + type: 'review_deleted', + timestamp: deletion.deleted_at!, + actor_id: deletion.deleted_by, + action: 'deleted review', + details: { + review_id: deletion.review_id, + user_id: deletion.user_id, + entity_type: entityType as 'park' | 'ride', + entity_id: entityId!, + rating: deletion.rating, + review_text: deletion.review_text || undefined, + deletion_reason: deletion.deletion_reason || undefined, + was_moderated: deletion.was_moderated, + } as ReviewLifecycleDetails, + }); + } + } + // Sort all activities by timestamp (newest first) activities.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); @@ -670,6 +750,86 @@ export async function fetchSystemActivities( } } } + + // Enrich review lifecycle users and entities + const reviewUserIds = filteredActivities + .filter(a => ['review_created', 'review_deleted'].includes(a.type)) + .map(a => (a.details as ReviewLifecycleDetails).user_id) + .filter(Boolean) as string[]; + + if (reviewUserIds.length > 0) { + const { data: reviewProfiles } = await supabase + .from('profiles') + .select('user_id, username') + .in('user_id', reviewUserIds); + + if (reviewProfiles) { + const reviewProfileMap = new Map(reviewProfiles.map(p => [p.user_id, p])); + + for (const activity of filteredActivities) { + if (['review_created', 'review_deleted'].includes(activity.type)) { + const details = activity.details as ReviewLifecycleDetails; + if (details.user_id && !details.username) { + const reviewProfile = reviewProfileMap.get(details.user_id); + if (reviewProfile) { + details.username = reviewProfile.username; + } + } + } + } + } + } + + // Enrich review entity names + const parkReviewIds = filteredActivities + .filter(a => ['review_created', 'review_deleted'].includes(a.type) && (a.details as ReviewLifecycleDetails).entity_type === 'park') + .map(a => (a.details as ReviewLifecycleDetails).entity_id) + .filter(Boolean) as string[]; + + const rideReviewIds = filteredActivities + .filter(a => ['review_created', 'review_deleted'].includes(a.type) && (a.details as ReviewLifecycleDetails).entity_type === 'ride') + .map(a => (a.details as ReviewLifecycleDetails).entity_id) + .filter(Boolean) as string[]; + + if (parkReviewIds.length > 0) { + const { data: parks } = await supabase + .from('parks') + .select('id, name') + .in('id', parkReviewIds); + + if (parks) { + const parkMap = new Map(parks.map(p => [p.id, p.name])); + + for (const activity of filteredActivities) { + if (['review_created', 'review_deleted'].includes(activity.type)) { + const details = activity.details as ReviewLifecycleDetails; + if (details.entity_type === 'park' && !details.entity_name) { + details.entity_name = parkMap.get(details.entity_id); + } + } + } + } + } + + if (rideReviewIds.length > 0) { + const { data: rides } = await supabase + .from('rides') + .select('id, name') + .in('id', rideReviewIds); + + if (rides) { + const rideMap = new Map(rides.map(r => [r.id, r.name])); + + for (const activity of filteredActivities) { + if (['review_created', 'review_deleted'].includes(activity.type)) { + const details = activity.details as ReviewLifecycleDetails; + if (details.entity_type === 'ride' && !details.entity_name) { + details.entity_name = rideMap.get(details.entity_id); + } + } + } + } + } } } diff --git a/supabase/migrations/20251017202711_f3db6ad1-5e46-4e16-84cd-5992f6bb5bb6.sql b/supabase/migrations/20251017202711_f3db6ad1-5e46-4e16-84cd-5992f6bb5bb6.sql new file mode 100644 index 00000000..246c1f9e --- /dev/null +++ b/supabase/migrations/20251017202711_f3db6ad1-5e46-4e16-84cd-5992f6bb5bb6.sql @@ -0,0 +1,96 @@ +-- Create review_deletions table to track deleted reviews +CREATE TABLE IF NOT EXISTS public.review_deletions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + review_id UUID NOT NULL, + user_id UUID NOT NULL REFERENCES auth.users(id), + park_id UUID REFERENCES public.parks(id), + ride_id UUID REFERENCES public.rides(id), + rating INTEGER NOT NULL, + review_text TEXT, + deleted_by UUID REFERENCES auth.users(id), + deleted_at TIMESTAMPTZ DEFAULT now(), + deletion_reason TEXT, + was_moderated BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL, + CONSTRAINT review_deletions_entity_check CHECK ( + (park_id IS NOT NULL AND ride_id IS NULL) OR + (park_id IS NULL AND ride_id IS NOT NULL) + ) +); + +-- Add indexes for performance +CREATE INDEX idx_review_deletions_deleted_at ON public.review_deletions(deleted_at DESC); +CREATE INDEX idx_review_deletions_user_id ON public.review_deletions(user_id); +CREATE INDEX idx_review_deletions_deleted_by ON public.review_deletions(deleted_by); + +-- Enable RLS +ALTER TABLE public.review_deletions ENABLE ROW LEVEL SECURITY; + +-- RLS Policies +CREATE POLICY "Moderators can view all review deletions" +ON public.review_deletions +FOR SELECT +TO authenticated +USING (is_moderator(auth.uid())); + +CREATE POLICY "Users can view their own deleted reviews" +ON public.review_deletions +FOR SELECT +TO authenticated +USING (user_id = auth.uid()); + +CREATE POLICY "System can insert review deletions" +ON public.review_deletions +FOR INSERT +TO authenticated +WITH CHECK (true); + +-- Create trigger function to log review deletions +CREATE OR REPLACE FUNCTION public.log_review_deletion() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + -- Insert into review_deletions table + INSERT INTO public.review_deletions ( + review_id, + user_id, + park_id, + ride_id, + rating, + review_text, + deleted_by, + deletion_reason, + was_moderated, + created_at + ) VALUES ( + OLD.id, + OLD.user_id, + OLD.park_id, + OLD.ride_id, + OLD.rating, + OLD.review_text, + auth.uid(), + CASE + WHEN OLD.moderation_status = 'rejected' THEN 'Rejected by moderator' + ELSE 'Deleted by user or admin' + END, + OLD.moderation_status != 'approved', + OLD.created_at + ); + + RETURN OLD; +END; +$$; + +-- Create trigger on reviews table +DROP TRIGGER IF EXISTS track_review_deletion ON public.reviews; +CREATE TRIGGER track_review_deletion +BEFORE DELETE ON public.reviews +FOR EACH ROW +EXECUTE FUNCTION public.log_review_deletion(); + +COMMENT ON TABLE public.review_deletions IS 'Tracks deleted reviews for audit purposes'; +COMMENT ON FUNCTION public.log_review_deletion() IS 'Logs review deletions before they are removed from the database'; \ No newline at end of file