mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 09:11:13 -05:00
feat: Implement Account Lifecycle Tracking
This commit is contained in:
@@ -21,7 +21,11 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Trash2
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
UserX,
|
||||||
|
Ban,
|
||||||
|
UserCheck
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +37,8 @@ import {
|
|||||||
SubmissionReviewDetails,
|
SubmissionReviewDetails,
|
||||||
ReportResolutionDetails,
|
ReportResolutionDetails,
|
||||||
ReviewModerationDetails,
|
ReviewModerationDetails,
|
||||||
PhotoApprovalDetails
|
PhotoApprovalDetails,
|
||||||
|
AccountLifecycleDetails
|
||||||
} from '@/lib/systemActivityService';
|
} from '@/lib/systemActivityService';
|
||||||
|
|
||||||
export interface SystemActivityLogRef {
|
export interface SystemActivityLogRef {
|
||||||
@@ -82,6 +87,42 @@ const activityTypeConfig = {
|
|||||||
bgColor: 'bg-teal-500/10',
|
bgColor: 'bg-teal-500/10',
|
||||||
label: 'Photo Approval',
|
label: 'Photo Approval',
|
||||||
},
|
},
|
||||||
|
account_created: {
|
||||||
|
icon: UserPlus,
|
||||||
|
color: 'text-green-600',
|
||||||
|
bgColor: 'bg-green-600/10',
|
||||||
|
label: 'Account Created',
|
||||||
|
},
|
||||||
|
account_deletion_requested: {
|
||||||
|
icon: UserX,
|
||||||
|
color: 'text-orange-600',
|
||||||
|
bgColor: 'bg-orange-600/10',
|
||||||
|
label: 'Deletion Requested',
|
||||||
|
},
|
||||||
|
account_deletion_confirmed: {
|
||||||
|
icon: UserX,
|
||||||
|
color: 'text-red-600',
|
||||||
|
bgColor: 'bg-red-600/10',
|
||||||
|
label: 'Deletion Confirmed',
|
||||||
|
},
|
||||||
|
account_deletion_cancelled: {
|
||||||
|
icon: UserCheck,
|
||||||
|
color: 'text-blue-600',
|
||||||
|
bgColor: 'bg-blue-600/10',
|
||||||
|
label: 'Deletion Cancelled',
|
||||||
|
},
|
||||||
|
user_banned: {
|
||||||
|
icon: Ban,
|
||||||
|
color: 'text-red-700',
|
||||||
|
bgColor: 'bg-red-700/10',
|
||||||
|
label: 'User Banned',
|
||||||
|
},
|
||||||
|
user_unbanned: {
|
||||||
|
icon: UserCheck,
|
||||||
|
color: 'text-green-700',
|
||||||
|
bgColor: 'bg-green-700/10',
|
||||||
|
label: 'User Unbanned',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SystemActivityLog = forwardRef<SystemActivityLogRef, SystemActivityLogProps>(
|
export const SystemActivityLog = forwardRef<SystemActivityLogRef, SystemActivityLogProps>(
|
||||||
@@ -322,6 +363,123 @@ export const SystemActivityLog = forwardRef<SystemActivityLogRef, SystemActivity
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'account_created': {
|
||||||
|
const details = activity.details as AccountLifecycleDetails;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge className="bg-green-600/10 text-green-600">New Account</Badge>
|
||||||
|
{details.username && (
|
||||||
|
<span className="font-medium">@{details.username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'account_deletion_requested': {
|
||||||
|
const details = activity.details as AccountLifecycleDetails;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||||
|
<Badge className="bg-orange-600/10 text-orange-600">Deletion Requested</Badge>
|
||||||
|
{details.scheduled_date && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Scheduled: {new Date(details.scheduled_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isExpanded && details.request_id && (
|
||||||
|
<p className="text-xs text-muted-foreground font-mono">
|
||||||
|
Request ID: {details.request_id}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'account_deletion_confirmed': {
|
||||||
|
const details = activity.details as AccountLifecycleDetails;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm flex-wrap">
|
||||||
|
<Badge className="bg-red-600/10 text-red-600">Deletion Confirmed</Badge>
|
||||||
|
{details.scheduled_date && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Will be deleted: {new Date(details.scheduled_date).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 p-2 bg-red-500/5 rounded text-sm">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Account will be permanently deleted after 7 days
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'account_deletion_cancelled': {
|
||||||
|
const details = activity.details as AccountLifecycleDetails;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge className="bg-blue-600/10 text-blue-600">Deletion Cancelled</Badge>
|
||||||
|
</div>
|
||||||
|
{isExpanded && details.reason && (
|
||||||
|
<div className="flex items-start gap-2 p-2 bg-muted rounded text-sm">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<strong>Reason:</strong> {details.reason}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'user_banned': {
|
||||||
|
const details = activity.details as AccountLifecycleDetails;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge variant="destructive">Banned</Badge>
|
||||||
|
{details.username && (
|
||||||
|
<span className="font-medium">@{details.username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isExpanded && details.reason && (
|
||||||
|
<div className="flex items-start gap-2 p-2 bg-destructive/10 rounded text-sm">
|
||||||
|
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0 text-destructive" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<strong>Reason:</strong> {details.reason}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'user_unbanned': {
|
||||||
|
const details = activity.details as AccountLifecycleDetails;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge className="bg-green-700/10 text-green-700">Unbanned</Badge>
|
||||||
|
{details.username && (
|
||||||
|
<span className="font-medium">@{details.username}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isExpanded && details.reason && (
|
||||||
|
<p className="text-sm text-muted-foreground pl-4 border-l-2">
|
||||||
|
Reason: {details.reason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ export type ActivityType =
|
|||||||
| 'submission_review'
|
| 'submission_review'
|
||||||
| 'report_resolution'
|
| 'report_resolution'
|
||||||
| 'review_moderation'
|
| 'review_moderation'
|
||||||
| 'photo_approval';
|
| 'photo_approval'
|
||||||
|
| 'account_created'
|
||||||
|
| 'account_deletion_requested'
|
||||||
|
| 'account_deletion_confirmed'
|
||||||
|
| 'account_deletion_cancelled'
|
||||||
|
| 'user_banned'
|
||||||
|
| 'user_unbanned';
|
||||||
|
|
||||||
export interface ActivityActor {
|
export interface ActivityActor {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,13 +75,23 @@ export interface PhotoApprovalDetails {
|
|||||||
entity_name?: string;
|
entity_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AccountLifecycleDetails {
|
||||||
|
user_id: string;
|
||||||
|
username?: string;
|
||||||
|
action: 'created' | 'deletion_requested' | 'deletion_confirmed' | 'deletion_cancelled' | 'banned' | 'unbanned';
|
||||||
|
reason?: string;
|
||||||
|
scheduled_date?: string;
|
||||||
|
request_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ActivityDetails =
|
export type ActivityDetails =
|
||||||
| EntityChangeDetails
|
| EntityChangeDetails
|
||||||
| AdminActionDetails
|
| AdminActionDetails
|
||||||
| SubmissionReviewDetails
|
| SubmissionReviewDetails
|
||||||
| ReportResolutionDetails
|
| ReportResolutionDetails
|
||||||
| ReviewModerationDetails
|
| ReviewModerationDetails
|
||||||
| PhotoApprovalDetails;
|
| PhotoApprovalDetails
|
||||||
|
| AccountLifecycleDetails;
|
||||||
|
|
||||||
export interface SystemActivity {
|
export interface SystemActivity {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -441,6 +457,121 @@ export async function fetchSystemActivities(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch account lifecycle events
|
||||||
|
// 1. Account deletions
|
||||||
|
const { data: deletionRequests, error: deletionsError } = await supabase
|
||||||
|
.from('account_deletion_requests')
|
||||||
|
.select('id, user_id, status, requested_at, completed_at, cancelled_at, scheduled_deletion_at, cancellation_reason')
|
||||||
|
.order('requested_at', { ascending: false })
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
if (!deletionsError && deletionRequests) {
|
||||||
|
for (const request of deletionRequests) {
|
||||||
|
// Deletion requested
|
||||||
|
activities.push({
|
||||||
|
id: `${request.id}-requested`,
|
||||||
|
type: 'account_deletion_requested',
|
||||||
|
timestamp: request.requested_at,
|
||||||
|
actor_id: request.user_id,
|
||||||
|
action: 'requested account deletion',
|
||||||
|
details: {
|
||||||
|
user_id: request.user_id,
|
||||||
|
action: 'deletion_requested',
|
||||||
|
scheduled_date: request.scheduled_deletion_at,
|
||||||
|
request_id: request.id,
|
||||||
|
} as AccountLifecycleDetails,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deletion confirmed
|
||||||
|
if (request.status === 'confirmed' || request.status === 'completed') {
|
||||||
|
activities.push({
|
||||||
|
id: `${request.id}-confirmed`,
|
||||||
|
type: 'account_deletion_confirmed',
|
||||||
|
timestamp: request.completed_at || request.requested_at,
|
||||||
|
actor_id: request.user_id,
|
||||||
|
action: 'confirmed account deletion',
|
||||||
|
details: {
|
||||||
|
user_id: request.user_id,
|
||||||
|
action: 'deletion_confirmed',
|
||||||
|
scheduled_date: request.scheduled_deletion_at,
|
||||||
|
request_id: request.id,
|
||||||
|
} as AccountLifecycleDetails,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletion cancelled
|
||||||
|
if (request.status === 'cancelled' && request.cancelled_at) {
|
||||||
|
activities.push({
|
||||||
|
id: `${request.id}-cancelled`,
|
||||||
|
type: 'account_deletion_cancelled',
|
||||||
|
timestamp: request.cancelled_at,
|
||||||
|
actor_id: request.user_id,
|
||||||
|
action: 'cancelled account deletion',
|
||||||
|
details: {
|
||||||
|
user_id: request.user_id,
|
||||||
|
action: 'deletion_cancelled',
|
||||||
|
reason: request.cancellation_reason || undefined,
|
||||||
|
request_id: request.id,
|
||||||
|
} as AccountLifecycleDetails,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. New account creations (recent 7 days)
|
||||||
|
const sevenDaysAgo = new Date();
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
|
||||||
|
const { data: newAccounts, error: accountsError } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('user_id, username, display_name, created_at')
|
||||||
|
.gte('created_at', sevenDaysAgo.toISOString())
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(Math.ceil(limit / 2));
|
||||||
|
|
||||||
|
if (!accountsError && newAccounts) {
|
||||||
|
for (const account of newAccounts) {
|
||||||
|
activities.push({
|
||||||
|
id: `account-${account.user_id}`,
|
||||||
|
type: 'account_created',
|
||||||
|
timestamp: account.created_at,
|
||||||
|
actor_id: account.user_id,
|
||||||
|
action: 'created account',
|
||||||
|
details: {
|
||||||
|
user_id: account.user_id,
|
||||||
|
username: account.username,
|
||||||
|
action: 'created',
|
||||||
|
} as AccountLifecycleDetails,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. User bans/unbans from admin audit log
|
||||||
|
const { data: banActions, error: banError } = await supabase
|
||||||
|
.from('admin_audit_log')
|
||||||
|
.select('id, admin_user_id, target_user_id, action, details, created_at')
|
||||||
|
.in('action', ['user_banned', 'user_unbanned'])
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
if (!banError && banActions) {
|
||||||
|
for (const action of banActions) {
|
||||||
|
const activityType = action.action === 'user_banned' ? 'user_banned' : 'user_unbanned';
|
||||||
|
activities.push({
|
||||||
|
id: action.id,
|
||||||
|
type: activityType,
|
||||||
|
timestamp: action.created_at,
|
||||||
|
actor_id: action.admin_user_id,
|
||||||
|
action: action.action === 'user_banned' ? 'banned user' : 'unbanned user',
|
||||||
|
details: {
|
||||||
|
user_id: action.target_user_id,
|
||||||
|
action: action.action === 'user_banned' ? 'banned' : 'unbanned',
|
||||||
|
reason: typeof action.details === 'object' && action.details && 'reason' in action.details ? String(action.details.reason) : undefined,
|
||||||
|
} as AccountLifecycleDetails,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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());
|
||||||
|
|
||||||
@@ -510,6 +641,35 @@ export async function fetchSystemActivities(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich account lifecycle target users (for bans/unbans)
|
||||||
|
const accountUserIds = filteredActivities
|
||||||
|
.filter(a => ['user_banned', 'user_unbanned', 'account_deletion_requested', 'account_deletion_confirmed', 'account_deletion_cancelled'].includes(a.type))
|
||||||
|
.map(a => (a.details as AccountLifecycleDetails).user_id)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
if (accountUserIds.length > 0) {
|
||||||
|
const { data: accountProfiles } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('user_id, username')
|
||||||
|
.in('user_id', accountUserIds);
|
||||||
|
|
||||||
|
if (accountProfiles) {
|
||||||
|
const accountProfileMap = new Map(accountProfiles.map(p => [p.user_id, p]));
|
||||||
|
|
||||||
|
for (const activity of filteredActivities) {
|
||||||
|
if (['user_banned', 'user_unbanned', 'account_deletion_requested', 'account_deletion_confirmed', 'account_deletion_cancelled'].includes(activity.type)) {
|
||||||
|
const details = activity.details as AccountLifecycleDetails;
|
||||||
|
if (details.user_id && !details.username) {
|
||||||
|
const accountProfile = accountProfileMap.get(details.user_id);
|
||||||
|
if (accountProfile) {
|
||||||
|
details.username = accountProfile.username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user