Implement Review Lifecycle Tracking

This commit is contained in:
gpt-engineer-app[bot]
2025-10-17 20:28:35 +00:00
parent 7dcf8156b9
commit f36ef04a54
4 changed files with 405 additions and 4 deletions

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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);
}
}
}
}
}
}
}

View File

@@ -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';