mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 10:11:13 -05:00
Implement Review Lifecycle Tracking
This commit is contained in:
@@ -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<SystemActivityLogRef, SystemActivityLogProps>(
|
||||
@@ -480,6 +495,76 @@ export const SystemActivityLog = forwardRef<SystemActivityLogRef, SystemActivity
|
||||
);
|
||||
}
|
||||
|
||||
case 'review_created': {
|
||||
const details = activity.details as ReviewLifecycleDetails;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<Badge className="bg-blue-600/10 text-blue-600">New Review</Badge>
|
||||
{details.rating && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
|
||||
<span className="font-medium">{details.rating}/5</span>
|
||||
</div>
|
||||
)}
|
||||
{details.entity_name && (
|
||||
<>
|
||||
<span className="text-muted-foreground">for</span>
|
||||
<span className="font-medium">{details.entity_name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && details.review_text && (
|
||||
<div className="p-3 bg-muted rounded text-sm">
|
||||
<p className="text-muted-foreground line-clamp-3">
|
||||
"{details.review_text}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case 'review_deleted': {
|
||||
const details = activity.details as ReviewLifecycleDetails;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||
<Badge variant="destructive">Review Deleted</Badge>
|
||||
{details.was_moderated && (
|
||||
<Badge variant="outline" className="text-orange-600 border-orange-600">
|
||||
Moderated
|
||||
</Badge>
|
||||
)}
|
||||
{details.rating && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{details.rating}/5</span>
|
||||
</div>
|
||||
)}
|
||||
{details.entity_name && (
|
||||
<span className="text-muted-foreground">from {details.entity_name}</span>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && details.review_text && (
|
||||
<div className="p-3 bg-muted rounded text-sm">
|
||||
<p className="text-muted-foreground line-clamp-3 opacity-60">
|
||||
"{details.review_text}"
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isExpanded && details.deletion_reason && (
|
||||
<div className="flex items-start gap-2 p-2 bg-red-500/5 rounded text-sm">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0 text-red-600" />
|
||||
<span className="text-muted-foreground">
|
||||
<strong>Reason:</strong> {details.deletion_reason}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user