mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:51:13 -05:00
Implement Review Lifecycle Tracking
This commit is contained in:
@@ -25,7 +25,9 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
UserX,
|
UserX,
|
||||||
Ban,
|
Ban,
|
||||||
UserCheck
|
UserCheck,
|
||||||
|
MessageSquare,
|
||||||
|
MessageSquareX
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
@@ -38,7 +40,8 @@ import {
|
|||||||
ReportResolutionDetails,
|
ReportResolutionDetails,
|
||||||
ReviewModerationDetails,
|
ReviewModerationDetails,
|
||||||
PhotoApprovalDetails,
|
PhotoApprovalDetails,
|
||||||
AccountLifecycleDetails
|
AccountLifecycleDetails,
|
||||||
|
ReviewLifecycleDetails
|
||||||
} from '@/lib/systemActivityService';
|
} from '@/lib/systemActivityService';
|
||||||
|
|
||||||
export interface SystemActivityLogRef {
|
export interface SystemActivityLogRef {
|
||||||
@@ -123,6 +126,18 @@ const activityTypeConfig = {
|
|||||||
bgColor: 'bg-green-700/10',
|
bgColor: 'bg-green-700/10',
|
||||||
label: 'User Unbanned',
|
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>(
|
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:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1898,6 +1898,66 @@ export type Database = {
|
|||||||
}
|
}
|
||||||
Relationships: []
|
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: {
|
review_photos: {
|
||||||
Row: {
|
Row: {
|
||||||
caption: string | null
|
caption: string | null
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ export type ActivityType =
|
|||||||
| 'account_deletion_confirmed'
|
| 'account_deletion_confirmed'
|
||||||
| 'account_deletion_cancelled'
|
| 'account_deletion_cancelled'
|
||||||
| 'user_banned'
|
| 'user_banned'
|
||||||
| 'user_unbanned';
|
| 'user_unbanned'
|
||||||
|
| 'review_created'
|
||||||
|
| 'review_deleted';
|
||||||
|
|
||||||
export interface ActivityActor {
|
export interface ActivityActor {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -84,6 +86,19 @@ export interface AccountLifecycleDetails {
|
|||||||
request_id?: string;
|
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 =
|
export type ActivityDetails =
|
||||||
| EntityChangeDetails
|
| EntityChangeDetails
|
||||||
| AdminActionDetails
|
| AdminActionDetails
|
||||||
@@ -91,7 +106,8 @@ export type ActivityDetails =
|
|||||||
| ReportResolutionDetails
|
| ReportResolutionDetails
|
||||||
| ReviewModerationDetails
|
| ReviewModerationDetails
|
||||||
| PhotoApprovalDetails
|
| PhotoApprovalDetails
|
||||||
| AccountLifecycleDetails;
|
| AccountLifecycleDetails
|
||||||
|
| ReviewLifecycleDetails;
|
||||||
|
|
||||||
export interface SystemActivity {
|
export interface SystemActivity {
|
||||||
id: string;
|
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)
|
// Sort all activities by timestamp (newest first)
|
||||||
activities.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
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