feat(users): implement advanced user actions (notifications, moderation, support, audit)

This commit is contained in:
CycroftX
2026-02-09 22:15:42 +05:30
parent 6c30e3b16f
commit badde58bfa
14 changed files with 1360 additions and 151 deletions

View File

@@ -35,8 +35,9 @@ import {
interface ActionButtonsProps {
userId: string;
userName: string;
onSuspend?: () => void; // Provided by parent to open modal
onSendNotification?: () => void; // Provided by parent
onSuspend?: () => void;
onSendNotification?: () => void;
onCreateTicket?: () => void;
}
export function ActionButtons({
@@ -44,6 +45,7 @@ export function ActionButtons({
userName,
onSuspend,
onSendNotification,
onCreateTicket,
}: ActionButtonsProps) {
const [isPending, startTransition] = useTransition();
@@ -97,20 +99,9 @@ export function ActionButtons({
};
const handleCreateTicket = () => {
// Quick action: create a generic ticket for now
// In reality, this might open a dialog
startTransition(async () => {
const result = await createSupportTicket(userId, "User requires manual review");
if (result.success) {
toast.success('Support Ticket Created', {
description: result.message,
});
} else {
toast.error('Failed to create ticket', {
description: result.message,
});
}
});
if (onCreateTicket) {
onCreateTicket();
}
};
return (

View File

@@ -64,6 +64,9 @@ import { BookingsTab } from './tabs/BookingsTab';
import { SecurityTab } from './tabs/SecurityTab';
import { SupportTab } from './tabs/SupportTab';
import { AuditTab } from './tabs/AuditTab';
import { NotificationComposer } from './dialogs/NotificationComposer';
import { ModerationDialog } from './dialogs/ModerationDialog';
import { EscalationForm } from './dialogs/EscalationForm';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -84,6 +87,10 @@ export function UserInspectorSheet({
}: UserInspectorSheetProps) {
const [activeTab, setActiveTab] = useState('overview');
const [notificationOpen, setNotificationOpen] = useState(false);
const [moderationOpen, setModerationOpen] = useState(false);
const [escalationOpen, setEscalationOpen] = useState(false);
if (!user) return null;
// Fetch related data
@@ -208,8 +215,9 @@ export function UserInspectorSheet({
<ActionButtons
userId={user.id}
userName={user.name}
onSuspend={() => handleAction('Suspend')}
onSendNotification={() => onSendNotification(user)}
onSuspend={() => setModerationOpen(true)}
onSendNotification={() => setNotificationOpen(true)}
onCreateTicket={() => setEscalationOpen(true)}
/>
</div>
@@ -254,6 +262,27 @@ export function UserInspectorSheet({
</div>
</ScrollArea>
</Tabs>
{/* Dialogs */}
<NotificationComposer
open={notificationOpen}
onOpenChange={setNotificationOpen}
userId={user.id}
userName={user.name}
userEmail={user.email}
/>
<ModerationDialog
open={moderationOpen}
onOpenChange={setModerationOpen}
userId={user.id}
userName={user.name}
/>
<EscalationForm
open={escalationOpen}
onOpenChange={setEscalationOpen}
userId={user.id}
userName={user.name}
/>
</SheetContent>
</Sheet>
);

View File

@@ -0,0 +1,221 @@
'use client';
import { useState, useTransition } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2, Ticket, Phone, Clock, AlertTriangle } from 'lucide-react';
import { createEscalationTicket } from '@/lib/actions/userActions';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface EscalationFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userId: string;
userName?: string; // Optional for display
}
const TICKET_TYPES = ['Refund', 'Payment', 'Account access', 'Fraud', 'Event issue', 'Other'];
const PRIORITIES = ['Low', 'Normal', 'High', 'Urgent'];
export function EscalationForm({
open,
onOpenChange,
userId,
userName
}: EscalationFormProps) {
const [isPending, startTransition] = useTransition();
const [type, setType] = useState('');
const [priority, setPriority] = useState('Normal');
const [subject, setSubject] = useState('');
const [description, setDescription] = useState('');
const [callbackRequired, setCallbackRequired] = useState(false);
const [callbackPhone, setCallbackPhone] = useState('');
const [assigneeId, setAssigneeId] = useState('');
const handleSubmit = () => {
if (!type || !subject || !description) {
toast.error("Please fill in all required fields");
return;
}
startTransition(async () => {
const result = await createEscalationTicket({
userId,
type: type as any,
priority: priority as any,
subject,
description,
callbackRequired,
callbackPhone: callbackRequired ? callbackPhone : undefined,
assigneeId: assigneeId || undefined,
});
if (result.success) {
toast.success(result.message);
onOpenChange(false);
setSubject('');
setDescription('');
} else {
toast.error(result.message);
}
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Ticket className="h-5 w-5 text-primary" />
Create Escalation Ticket
</DialogTitle>
<DialogDescription>
Raise a new internal support ticket or escalation.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-5">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Category <span className="text-red-500">*</span></Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger>
<SelectValue placeholder="Select type..." />
</SelectTrigger>
<SelectContent>
{TICKET_TYPES.map(t => (
<SelectItem key={t} value={t}>{t}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Priority <span className="text-red-500">*</span></Label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger className={cn(
priority === 'High' ? 'text-orange-600 font-medium' :
priority === 'Urgent' ? 'text-red-600 font-bold' : ''
)}>
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
{PRIORITIES.map(p => (
<SelectItem key={p} value={p} className={
p === 'High' ? 'text-orange-600' :
p === 'Urgent' ? 'text-red-600 font-bold' : ''
}>
{p} {p === 'Urgent' && '🔥'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Subject <span className="text-red-500">*</span></Label>
<Input
placeholder="Brief summary of the issue..."
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Description <span className="text-red-500">*</span></Label>
<Textarea
placeholder="Detailed explanation, steps to reproduce, or user context..."
className="h-32 resize-none"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="bg-secondary/10 p-4 rounded-lg border border-border/50">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<Checkbox
id="callback"
checked={callbackRequired}
onCheckedChange={(c) => setCallbackRequired(c as boolean)}
/>
<Label htmlFor="callback" className="font-medium flex items-center gap-1.5 cursor-pointer">
<Phone className="h-3.5 w-3.5" /> Request Callback
</Label>
</div>
</div>
{callbackRequired && (
<div className="grid grid-cols-2 gap-4 animate-in slide-in-from-top-2">
<div className="space-y-1.5">
<Label className="text-xs">Callback Number</Label>
<Input
placeholder="+1 (555) 000-0000"
className="h-8"
value={callbackPhone}
onChange={(e) => setCallbackPhone(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Preferred Time</Label>
<Select>
<SelectTrigger className="h-8">
<SelectValue placeholder="ASAP" />
</SelectTrigger>
<SelectContent>
<SelectItem value="asap">ASAP</SelectItem>
<SelectItem value="morning">Morning (9am - 12pm)</SelectItem>
<SelectItem value="afternoon">Afternoon (1pm - 5pm)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
<div className="space-y-2">
<Label>Assign To (Optional)</Label>
<Select value={assigneeId} onValueChange={setAssigneeId}>
<SelectTrigger>
<SelectValue placeholder="Auto-assign" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Auto-assign (Roost)</SelectItem>
<SelectItem value="tier2">Tier 2 Support</SelectItem>
<SelectItem value="finance">Finance Team</SelectItem>
<SelectItem value="fraud">Fraud & Risk</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSubmit} disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Ticket
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import { useState, useTransition } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Loader2, ShieldAlert, Gavel, Calendar } from 'lucide-react';
import { moderateUser } from '@/lib/actions/userActions';
import { toast } from 'sonner';
interface ModerationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userId: string;
userName: string;
}
type Mode = 'suspend' | 'ban';
const REASON_CATEGORIES = [
"Fraudulent Activity",
"Spam / Marketing",
"Abusive Behavior",
"Chargeback / Payment Issue",
"Terms of Service Violation",
"Duplicate Account",
"Other"
];
export function ModerationDialog({
open,
onOpenChange,
userId,
userName
}: ModerationDialogProps) {
const [isPending, startTransition] = useTransition();
const [mode, setMode] = useState<Mode>('suspend');
const [reasonCategory, setReasonCategory] = useState('');
const [reasonText, setReasonText] = useState('');
const [duration, setDuration] = useState('7_days');
const [notifyUser, setNotifyUser] = useState(true);
const [revokeSessions, setRevokeSessions] = useState(true);
const handleSubmit = () => {
if (!reasonCategory) {
toast.error("Please select a reason category");
return;
}
if (reasonText.length < 10) {
toast.error("Please provide more details (min 10 chars)");
return;
}
startTransition(async () => {
const result = await moderateUser({
userId,
mode,
reasonCategory,
reasonText,
duration: mode === 'suspend' ? duration : undefined,
notifyUser,
revokeSessions
});
if (result.success) {
toast.success(result.message);
onOpenChange(false);
} else {
toast.error(result.message);
}
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Gavel className="h-5 w-5 text-destructive" />
Moderate User: {userName}
</DialogTitle>
<DialogDescription>
Restrict or block access for this account. This action is logged.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-6">
{/* Mode Selection */}
<div className="bg-secondary/10 p-3 rounded-lg border border-border/50">
<RadioGroup
defaultValue="suspend"
value={mode}
onValueChange={(v) => setMode(v as Mode)}
className="flex items-center space-x-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="suspend" id="r-suspend" className="text-amber-600 border-amber-600" />
<Label htmlFor="r-suspend" className="font-semibold cursor-pointer">Suspend Temporarily</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="ban" id="r-ban" className="text-destructive border-destructive" />
<Label htmlFor="r-ban" className="font-semibold text-destructive cursor-pointer">Permament Ban</Label>
</div>
</RadioGroup>
</div>
{/* Suspend Duration Settings */}
{mode === 'suspend' && (
<div className="space-y-3 animate-in fade-in duration-300">
<Label>Suspension Duration</Label>
<Select value={duration} onValueChange={setDuration}>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
<SelectContent>
<SelectItem value="24_hours">24 Hours (Cool down)</SelectItem>
<SelectItem value="3_days">3 Days</SelectItem>
<SelectItem value="7_days">7 Days</SelectItem>
<SelectItem value="30_days">30 Days</SelectItem>
<SelectItem value="indefinite">Indefinite (Until Reviewed)</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Ban Warning */}
{mode === 'ban' && (
<Alert variant="destructive" className="animate-in fade-in duration-300 bg-destructive/5 text-destructive border-destructive/20">
<ShieldAlert className="h-4 w-4" />
<AlertTitle>Critical Action</AlertTitle>
<AlertDescription>
Banning is permanent. The user will be unable to log in, create new accounts, or access their data.
</AlertDescription>
</Alert>
)}
{/* Common Fields */}
<div className="grid grid-cols-1 gap-4">
<div className="space-y-2">
<Label>Reason Category</Label>
<Select value={reasonCategory} onValueChange={setReasonCategory}>
<SelectTrigger>
<SelectValue placeholder="Select category..." />
</SelectTrigger>
<SelectContent>
{REASON_CATEGORIES.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Internal Note / Evidence</Label>
<Textarea
placeholder="Details about why this action is being taken..."
value={reasonText}
onChange={(e) => setReasonText(e.target.value)}
className="h-24 resize-none"
/>
<p className="text-[10px] text-muted-foreground text-right">{reasonText.length} chars (min 10)</p>
</div>
</div>
{/* Checkboxes */}
<div className="space-y-3 pt-2">
<div className="flex items-center space-x-2">
<Checkbox
id="revokeSessions"
checked={revokeSessions}
onCheckedChange={(c) => setRevokeSessions(c as boolean)}
/>
<Label htmlFor="revokeSessions" className="text-sm font-normal">
Force logout from all active sessions
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="notifyUser"
checked={notifyUser}
onCheckedChange={(c) => setNotifyUser(c as boolean)}
/>
<Label htmlFor="notifyUser" className="text-sm font-normal">
{mode === 'ban' ? 'Send ban notice via email' : 'Send suspension notice via email'}
</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button
onClick={handleSubmit}
disabled={isPending}
variant={mode === 'ban' ? 'destructive' : 'default'}
className={mode === 'suspend' ? 'bg-amber-600 hover:bg-amber-700 text-white' : ''}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode === 'ban' ? 'Confirm Ban' : 'Confirm Suspension'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,276 @@
'use client';
import { useState, useTransition } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Loader2, Send, Smartphone, Mail, Bell } from 'lucide-react';
import { sendUserNotification } from '@/lib/actions/userActions';
import { toast } from 'sonner';
interface NotificationComposerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userId: string;
userName: string;
userEmail: string;
}
type Channel = 'email' | 'in_app' | 'push';
export function NotificationComposer({
open,
onOpenChange,
userId,
userName,
userEmail
}: NotificationComposerProps) {
const [isPending, startTransition] = useTransition();
const [channel, setChannel] = useState<Channel>('email');
const [subject, setSubject] = useState('');
const [title, setTitle] = useState('');
const [message, setMessage] = useState('');
const [ctaLabel, setCtaLabel] = useState('');
const [ctaUrl, setCtaUrl] = useState('');
const [sendCopy, setSendCopy] = useState(false);
const handleSend = () => {
// Basic Validation
if (channel === 'email' && !subject) {
toast.error("Subject is required for emails");
return;
}
if ((channel === 'in_app' || channel === 'push') && !title) {
toast.error("Title is required");
return;
}
if (!message) {
toast.error("Message content is required");
return;
}
startTransition(async () => {
const result = await sendUserNotification({
userId,
channel,
subject: channel === 'email' ? subject : undefined,
title: channel !== 'email' ? title : undefined,
message,
ctaLabel,
ctaUrl,
sendCopy
});
if (result.success) {
toast.success(result.message);
onOpenChange(false);
// Reset form
setMessage('');
setSubject('');
setTitle('');
} else {
toast.error(result.message);
}
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[800px] p-0 overflow-hidden flex flex-col md:flex-row gap-0">
{/* Left Side: Form */}
<div className="flex-1 p-6 space-y-6 overflow-y-auto max-h-[85vh]">
<DialogHeader>
<DialogTitle>Notify {userName}</DialogTitle>
<DialogDescription>
Send a targeted message.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Channel</Label>
<Tabs value={channel} onValueChange={(v) => setChannel(v as Channel)} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="email">Email</TabsTrigger>
<TabsTrigger value="in_app">In-App</TabsTrigger>
<TabsTrigger value="push">Push</TabsTrigger>
</TabsList>
</Tabs>
</div>
{channel === 'email' ? (
<div className="space-y-2">
<Label>Subject Line</Label>
<Input
placeholder="Action Required: Verify your account"
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
) : (
<div className="space-y-2">
<Label>Title</Label>
<Input
placeholder="New Feature Alert"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
)}
<div className="space-y-2">
<Label>Message Body</Label>
<Textarea
placeholder="Type your message here..."
className="h-32 resize-none"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>CTA Label (Optional)</Label>
<Input
placeholder="View Details"
value={ctaLabel}
onChange={(e) => setCtaLabel(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Action URL (Optional)</Label>
<Input
placeholder="https://"
value={ctaUrl}
onChange={(e) => setCtaUrl(e.target.value)}
/>
</div>
</div>
<div className="flex items-center space-x-2 pt-2">
<Checkbox
id="sendCopy"
checked={sendCopy}
onCheckedChange={(c) => setSendCopy(c as boolean)}
/>
<Label htmlFor="sendCopy" className="text-sm font-normal text-muted-foreground">
Send a copy to support inbox
</Label>
</div>
</div>
<DialogFooter className="pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={handleSend} disabled={isPending}>
{isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
Send Notification
</Button>
</DialogFooter>
</div>
{/* Right Side: Preview */}
<div className="w-[320px] bg-secondary/20 border-l border-border/50 p-6 hidden md:flex flex-col items-center justify-center relative">
<div className="absolute top-4 left-0 w-full text-center">
<span className="text-xs uppercase tracking-wider font-semibold text-muted-foreground">Live Preview</span>
</div>
{/* Phone Frame for Push/In-App */}
{channel !== 'email' && (
<div className="w-[280px] h-[500px] bg-background border-4 border-slate-800 rounded-[2rem] shadow-xl overflow-hidden relative flex flex-col">
<div className="h-6 bg-slate-800 w-full absolute top-0 left-0 z-10 flex justify-center">
<div className="w-20 h-4 bg-black rounded-b-xl"></div>
</div>
<div className="flex-1 bg-gray-50 flex flex-col pt-10 px-4">
{channel === 'push' && (
<div className="mt-4 bg-white/80 backdrop-blur-sm p-3 rounded-xl shadow-sm border border-gray-100 mb-2 animate-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-2 mb-1">
<div className="h-5 w-5 bg-primary rounded flex items-center justify-center">
<Bell className="h-3 w-3 text-white" />
</div>
<span className="text-[10px] font-semibold text-gray-500 uppercase">EVENTIFY NOW</span>
</div>
<p className="font-semibold text-sm text-gray-900">{title || "Notification Title"}</p>
<p className="text-xs text-gray-600 line-clamp-3 leading-relaxed">
{message || "Message content will appear here..."}
</p>
</div>
)}
{channel === 'in_app' && (
<div className="mt-auto mb-4 bg-white p-4 rounded-xl shadow-lg border border-gray-100 animate-in slide-in-from-bottom-4 duration-500">
<div className="flex justify-between items-start mb-2">
<h4 className="font-bold text-gray-900">{title || "In-App Message"}</h4>
<button className="text-gray-400 hover:text-gray-600">×</button>
</div>
<p className="text-sm text-gray-600 mb-4">
{message || "Your detailed in-app message content..."}
</p>
{ctaLabel && (
<Button size="sm" className="w-full">{ctaLabel}</Button>
)}
</div>
)}
</div>
</div>
)}
{/* Email Preview Frame */}
{channel === 'email' && (
<div className="w-full h-full max-h-[500px] bg-white border border-border shadow-sm rounded-lg overflow-hidden flex flex-col">
<div className="bg-gray-100 p-2 border-b border-gray-200 text-[10px] text-gray-500 flex flex-col gap-1">
<div className="flex gap-2">
<span className="font-bold">To:</span> {userEmail}
</div>
<div className="flex gap-2">
<span className="font-bold">Subject:</span> {subject || "Subject line..."}
</div>
</div>
<div className="p-4 flex-1 overflow-y-auto">
<div className="mb-4">
<div className="h-8 w-8 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<Mail className="h-4 w-4 text-primary" />
</div>
<h3 className="text-lg font-bold text-gray-900 mb-2">Hi {userName.split(' ')[0]},</h3>
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">
{message || "Draft your email message..."}
</p>
</div>
{ctaLabel && (
<div className="py-4 text-center">
<Button className="bg-primary text-primary-foreground hover:bg-primary/90">
{ctaLabel}
</Button>
</div>
)}
<div className="mt-8 pt-4 border-t border-gray-100 text-xs text-gray-400 text-center">
© 2024 Eventify Inc.
</div>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import { useState } from 'react';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Download, FileDown, CalendarDays } from 'lucide-react';
import { toast } from 'sonner';
interface OrdersExportPopoverProps {
onExport: (options: ExportOptions) => void;
}
export interface ExportOptions {
range: string;
includeColumns: string[];
respectFilters: boolean;
}
const AVAILABLE_COLUMNS = [
{ id: 'id', label: 'Order ID' },
{ id: 'eventName', label: 'Event Name' },
{ id: 'amount', label: 'Amount' },
{ id: 'status', label: 'Status' },
{ id: 'date', label: 'Date' },
{ id: 'customer', label: 'Customer Info' },
];
export function OrdersExportPopover({ onExport }: OrdersExportPopoverProps) {
const [range, setRange] = useState('30d');
const [respectFilters, setRespectFilters] = useState(true);
const [selectedColumns, setSelectedColumns] = useState<string[]>(AVAILABLE_COLUMNS.map(c => c.id));
const [open, setOpen] = useState(false);
const handleColumnToggle = (id: string, checked: boolean) => {
if (checked) {
setSelectedColumns([...selectedColumns, id]);
} else {
setSelectedColumns(selectedColumns.filter(c => c !== id));
}
};
const handleExport = () => {
onExport({ range, includeColumns: selectedColumns, respectFilters });
setOpen(false);
toast.promise(new Promise(resolve => setTimeout(resolve, 2000)), {
loading: 'Generating CSV...',
success: 'Export downloaded successfully!',
error: 'Failed to export'
});
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 gap-2">
<FileDown className="h-3.5 w-3.5" />
Export CSV
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<div className="p-4 border-b border-border/50 bg-secondary/5">
<h4 className="font-semibold text-sm">Export Options</h4>
<p className="text-xs text-muted-foreground">Customize your data export.</p>
</div>
<div className="p-4 space-y-4">
<div className="space-y-2">
<Label className="text-xs">Date Range</Label>
<Select value={range} onValueChange={setRange}>
<SelectTrigger className="h-8">
<SelectValue placeholder="Select range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">Last 7 Days</SelectItem>
<SelectItem value="30d">Last 30 Days</SelectItem>
<SelectItem value="90d">Last 3 months</SelectItem>
<SelectItem value="all">All Time</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs">Columns</Label>
<div className="grid grid-cols-2 gap-2">
{AVAILABLE_COLUMNS.map(col => (
<div key={col.id} className="flex items-center space-x-2">
<Checkbox
id={`col-${col.id}`}
checked={selectedColumns.includes(col.id)}
onCheckedChange={(c) => handleColumnToggle(col.id, c as boolean)}
className="h-3.5 w-3.5"
/>
<Label htmlFor={`col-${col.id}`} className="text-xs font-normal cursor-pointer">
{col.label}
</Label>
</div>
))}
</div>
</div>
<div className="flex items-center space-x-2 pt-1">
<Checkbox
id="respect-filters"
checked={respectFilters}
onCheckedChange={(c) => setRespectFilters(c as boolean)}
/>
<Label htmlFor="respect-filters" className="text-xs font-normal cursor-pointer">
Apply current table filters
</Label>
</div>
</div>
<div className="p-3 bg-secondary/5 border-t border-border/50 flex justify-end">
<Button size="sm" onClick={handleExport} className="w-full">
<Download className="mr-2 h-3.5 w-3.5" /> Download .CSV
</Button>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { Download, Printer, Copy, CreditCard } from 'lucide-react';
import { ScrollArea } from '@/components/ui/scroll-area';
import { formatCurrency } from '@/data/mockData';
import { toast } from 'sonner';
interface ReceiptViewerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
booking: any; // Using any for now, ideally UserBooking
}
export function ReceiptViewer({
open,
onOpenChange,
booking
}: ReceiptViewerProps) {
if (!booking) return null;
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard");
};
// Mock calculations for the receipt
const basePrice = booking.amount * 0.9;
const tax = booking.amount * 0.1;
const fee = 2.50; // Mock processing fee
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[480px] p-0 overflow-hidden flex flex-col bg-white">
<div className="bg-slate-900 p-6 text-white text-center rounded-t-lg">
<div className="w-12 h-12 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-3">
<CreditCard className="h-6 w-6 text-white" />
</div>
<h2 className="text-xl font-bold tracking-tight">Receipt from Eventify</h2>
<p className="text-slate-400 text-sm mt-1">Thank you for your business.</p>
</div>
<ScrollArea className="max-h-[60vh]">
<div className="p-6 space-y-6">
{/* Meta Data */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wider">Order ID</p>
<div className="flex items-center gap-1 font-mono font-medium hover:text-primary cursor-pointer" onClick={() => copyToClipboard(booking.id)}>
{booking.id.substring(0, 12)}...
<Copy className="h-3 w-3 opacity-50" />
</div>
</div>
<div className="text-right">
<p className="text-muted-foreground text-xs uppercase tracking-wider">Date</p>
<p className="font-medium">{new Date(booking.createdAt).toLocaleDateString()}</p>
</div>
</div>
<Separator className="bg-slate-100" />
{/* Customer Info */}
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-2">Billed To</p>
<p className="font-semibold text-sm">User ID: {booking.userId}</p>
<p className="text-sm text-slate-500">Payment Method: Visa 4242</p>
</div>
{/* Line Items */}
<div className="bg-slate-50 p-4 rounded-lg border border-slate-100">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground text-left border-b border-slate-200">
<th className="pb-2 font-medium">Description</th>
<th className="pb-2 font-medium text-right">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<tr>
<td className="py-3">
<p className="font-medium text-slate-900">{booking.eventName}</p>
<p className="text-xs text-slate-500">{booking.ticketType} x {booking.quantity}</p>
</td>
<td className="py-3 text-right tabular-nums text-slate-900">
{formatCurrency(basePrice)}
</td>
</tr>
<tr>
<td className="py-2 text-slate-500">Processing Fees</td>
<td className="py-2 text-right tabular-nums text-slate-500">{formatCurrency(fee)}</td>
</tr>
<tr>
<td className="py-2 text-slate-500">Tax (10%)</td>
<td className="py-2 text-right tabular-nums text-slate-500">{formatCurrency(tax)}</td>
</tr>
</tbody>
<tfoot>
<tr>
<td className="pt-3 font-bold text-slate-900">Total</td>
<td className="pt-3 font-bold text-right tabular-nums text-slate-900">{formatCurrency(booking.amount + fee)}</td>
</tr>
</tfoot>
</table>
</div>
{/* Status */}
<div className="flex justify-center">
<Badge variant="outline" className="px-3 py-1 font-normal text-slate-500 border-slate-200 bg-slate-50">
Status: <span className="font-semibold text-slate-900 ml-1">{booking.status}</span>
</Badge>
</div>
</div>
</ScrollArea>
<div className="p-4 border-t border-slate-100 bg-slate-50 flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={() => window.print()}>
<Printer className="mr-2 h-4 w-4" /> Print
</Button>
<Button variant="default" size="sm">
<Download className="mr-2 h-4 w-4" /> Download PDF
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -16,23 +16,35 @@ interface AuditTabProps {
userId: string;
}
export function AuditTab({ userId }: AuditTabProps) {
// Mock Data for now
const logs = [
{ id: '1', action: 'User Suspended', actor: 'Admin (Sarah)', timestamp: '2 mins ago', icon: Shield, type: 'danger' },
{ id: '2', action: 'Note Added', actor: 'Admin (Sarah)', timestamp: '5 mins ago', icon: UserCog, type: 'blue' },
{ id: '3', action: 'Profile Updated', actor: 'User', timestamp: 'Yesterday', icon: Settings, type: 'neutral' },
{ id: '4', action: 'Failed Login Attempt', actor: 'System (IP 192.168...)', timestamp: '2 days ago', icon: AlertTriangle, type: 'warning' },
{ id: '5', action: 'Account Created', actor: 'User', timestamp: '1 month ago', icon: LogIn, type: 'success' },
];
import { formatDistanceToNow } from 'date-fns';
import { getUserAuditLogs } from '../../data/mockUserCrmData';
import type { AuditLog, AuditAction } from '@/lib/types/user';
const getIconColor = (type: string) => {
switch (type) {
case 'danger': return 'bg-red-100 text-red-600';
case 'warning': return 'bg-amber-100 text-amber-600';
case 'success': return 'bg-emerald-100 text-emerald-600';
case 'blue': return 'bg-blue-100 text-blue-600';
default: return 'bg-slate-100 text-slate-600';
interface AuditTabProps {
userId: string;
}
export function AuditTab({ userId }: AuditTabProps) {
const logs = getUserAuditLogs(userId).sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const getActionDetails = (action: AuditAction) => {
switch (action) {
case 'suspended':
case 'banned':
return { icon: Shield, color: 'bg-red-100 text-red-600', label: 'Security Action' };
case 'user_created':
case 'user_updated':
return { icon: UserCog, color: 'bg-blue-100 text-blue-600', label: 'Profile Update' };
case 'note_added':
return { icon: Flag, color: 'bg-amber-100 text-amber-600', label: 'Note Added' };
case 'notification_sent':
return { icon: Activity, color: 'bg-purple-100 text-purple-600', label: 'Communication' };
case 'receipt_viewed':
return { icon: Activity, color: 'bg-slate-100 text-slate-600', label: 'Data Access' };
case 'escalation_created':
return { icon: AlertTriangle, color: 'bg-orange-100 text-orange-600', label: 'Escalation' };
default:
return { icon: Settings, color: 'bg-gray-100 text-gray-600', label: 'System' };
}
};
@@ -44,26 +56,44 @@ export function AuditTab({ userId }: AuditTabProps) {
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-6 pl-2">
{logs.map((log, index) => (
<div key={log.id} className="relative pl-6 pb-2 border-l border-border/40 last:border-0">
{/* Connector Line */}
<div className={`absolute -left-[14px] top-0 h-7 w-7 rounded-full border-4 border-background flex items-center justify-center ${getIconColor(log.type)}`}>
<log.icon className="h-3.5 w-3.5" />
</div>
{logs.map((log) => {
const { icon: Icon, color, label } = getActionDetails(log.action);
return (
<div key={log.id} className="relative pl-6 pb-2 border-l border-border/40 last:border-0">
{/* Connector Line */}
<div className={`absolute -left-[14px] top-0 h-7 w-7 rounded-full border-4 border-background flex items-center justify-center ${color}`}>
<Icon className="h-3.5 w-3.5" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{log.action}</span>
<span className="text-xs text-muted-foreground">{log.timestamp}</span>
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="outline" className="text-[10px] h-4 px-1 font-normal text-muted-foreground">
{log.actor}
</Badge>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium capitalize">{log.action.replace('_', ' ')}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(new Date(log.timestamp), { addSuffix: true })}
</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<Badge variant="outline" className="text-[10px] h-4 px-1 font-normal text-muted-foreground">
{log.actorName} ({log.actorRole})
</Badge>
<span className="text-[10px] text-muted-foreground bg-secondary/10 px-1.5 py-0.5 rounded">
IP: {log.ipAddress}
</span>
</div>
{log.metadata && (
<div className="mt-1 bg-secondary/5 p-1.5 rounded text-[11px] font-mono text-muted-foreground break-all">
{JSON.stringify(log.metadata)}
</div>
)}
</div>
</div>
);
})}
{logs.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
No audit logs found for this user.
</div>
))}
)}
</div>
</ScrollArea>
</div>

View File

@@ -1,6 +1,8 @@
'use client';
import { useTransition, useState } from 'react';
import { ReceiptViewer } from '../dialogs/ReceiptViewer';
import { OrdersExportPopover } from '../dialogs/OrdersExportPopover';
import type { UserBooking } from '@/lib/types/user';
import {
Table,
@@ -32,11 +34,7 @@ import { formatCurrency } from '@/data/mockData';
import { processRefund } from '@/lib/actions/user-tabs';
import {
MoreHorizontal,
Download,
Ticket,
AlertCircle,
CheckCircle2,
XCircle,
Loader2
} from 'lucide-react';
import { toast } from 'sonner';
@@ -49,6 +47,7 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
const [isPending, startTransition] = useTransition();
const [refundDialogOpen, setRefundDialogOpen] = useState(false);
const [selectedBooking, setSelectedBooking] = useState<UserBooking | null>(null);
const [selectedBookingForReceipt, setSelectedBookingForReceipt] = useState<UserBooking | null>(null);
const [refundReason, setRefundReason] = useState('');
const handleRefundClick = (booking: UserBooking) => {
@@ -57,6 +56,33 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
setRefundDialogOpen(true);
};
const handleExport = (options: any) => {
// Client-side export logic
const headers = ['Order ID', 'Event', 'Date', 'Ticket', 'Quantity', 'Amount', 'Status'];
const csvContent = [
headers.join(','),
...bookings.map(b => [
b.id,
`"${b.eventName}"`,
new Date(b.eventDate).toLocaleDateString(),
b.ticketType,
b.quantity,
b.amount,
b.status
].join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `orders_export_${options.range}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const confirmRefund = () => {
if (!selectedBooking) return;
@@ -86,9 +112,7 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Transaction History</h3>
<Button variant="outline" size="sm" className="h-8 gap-2">
<Download className="h-3.5 w-3.5" /> Export CSV
</Button>
<OrdersExportPopover onExport={handleExport} />
</div>
<div className="border rounded-lg overflow-hidden">
@@ -133,7 +157,7 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>View Receipt</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSelectedBookingForReceipt(booking)}>View Receipt</DropdownMenuItem>
{booking.status === 'Confirmed' && (
<DropdownMenuItem onClick={() => handleRefundClick(booking)} className="text-red-600 focus:text-red-600">
Process Refund
@@ -155,6 +179,13 @@ export function BookingsTab({ bookings }: BookingsTabProps) {
</Table>
</div>
{/* Receipt Dialog */}
<ReceiptViewer
open={!!selectedBookingForReceipt}
onOpenChange={(open) => !open && setSelectedBookingForReceipt(null)}
booking={selectedBookingForReceipt}
/>
{/* Refund Dialog */}
<Dialog open={refundDialogOpen} onOpenChange={setRefundDialogOpen}>
<DialogContent>

View File

@@ -5,22 +5,7 @@ import type { User } from '@/lib/types/user';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { EscalationForm } from '../dialogs/EscalationForm';
import {
MessageSquare,
Mail,
@@ -29,7 +14,6 @@ import {
Headphones,
Loader2
} from 'lucide-react';
import { createSupportTicket } from '@/lib/actions/user-tabs';
import { toast } from 'sonner';
interface SupportTabProps {
@@ -37,87 +21,22 @@ interface SupportTabProps {
}
export function SupportTab({ user }: SupportTabProps) {
const [isPending, startTransition] = useTransition();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Form State
const [subject, setSubject] = useState('');
const [priority, setPriority] = useState('Normal');
const [type, setType] = useState('Inquiry');
const handleCreateTicket = () => {
startTransition(async () => {
const result = await createSupportTicket(user.id, { subject, priority, type });
if (result.success) {
toast.success(result.message);
setCreateDialogOpen(false);
setSubject('');
} else {
toast.error(result.message);
}
});
};
// Filter state could go here in future
// const [filter, setFilter] = useState('all');
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">Communication Timeline</h3>
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" className="h-8 gap-2">
<Plus className="h-3.5 w-3.5" /> Create Ticket
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New Support Ticket</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Subject</Label>
<Input
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder="Brief summary of the issue"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label>Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Inquiry">Inquiry</SelectItem>
<SelectItem value="Refund">Refund Request</SelectItem>
<SelectItem value="Technical">Technical Issue</SelectItem>
<SelectItem value="Complaint">Complaint</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Priority</Label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Low">Low</SelectItem>
<SelectItem value="Normal">Normal</SelectItem>
<SelectItem value="High">High</SelectItem>
<SelectItem value="Urgent">Urgent</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreateTicket} disabled={!subject || isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Ticket
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<EscalationForm
userId={user.id}
userName={user.name}
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</div>
{/* Timeline Stream */}

View File

@@ -613,6 +613,8 @@ export const mockAuditLogs: AuditLog[] = [
{ id: 'audit-004', actorId: 'usr-014', actorName: 'Amit Saxena', actorRole: 'Support Agent', action: 'note_added', targetUserId: 'usr-001', targetUserName: 'Priya Sharma', metadata: { noteId: 'note-002' }, ipAddress: '14.139.82.44', timestamp: '2025-08-20T11:00:00Z' },
{ id: 'audit-005', actorId: 'usr-011', actorName: 'Aisha Khan', actorRole: 'Admin', action: 'tag_added', targetUserId: 'usr-007', targetUserName: 'Sneha Kapoor', changes: { before: { tags: ['VIP'] }, after: { tags: ['VIP', 'Influencer'] } }, ipAddress: '14.98.172.55', timestamp: '2024-02-14T09:00:00Z' },
{ id: 'audit-006', actorId: 'usr-011', actorName: 'Aisha Khan', actorRole: 'Admin', action: 'notification_sent', targetUserId: 'usr-001', targetUserName: 'Priya Sharma', metadata: { notificationId: 'notif-001', title: 'VIP Early Access' }, ipAddress: '14.98.172.55', timestamp: '2026-01-20T09:55:00Z' },
{ id: 'audit-007', actorId: 'usr-014', actorName: 'Amit Saxena', actorRole: 'Support Agent', action: 'receipt_viewed', targetUserId: 'usr-001', targetUserName: 'Priya Sharma', metadata: { orderId: 'ord-001' }, ipAddress: '14.139.82.44', timestamp: '2026-02-09T10:05:00Z' },
{ id: 'audit-008', actorId: 'usr-014', actorName: 'Amit Saxena', actorRole: 'Support Agent', action: 'escalation_created', targetUserId: 'usr-001', targetUserName: 'Priya Sharma', metadata: { ticketId: 'tkt-005', type: 'Fraud', priority: 'High' }, ipAddress: '14.139.82.44', timestamp: '2026-02-09T10:10:00Z' },
];
// ============================================

View File

@@ -1,5 +1,3 @@
'use server';
import { z } from 'zod';
import { logAdminAction } from '@/lib/audit-logger';
import type { User } from '@/lib/types/user';

View File

@@ -0,0 +1,220 @@
import { z } from 'zod';
import { logAdminAction } from '@/lib/audit-logger';
import { revokeUserSession } from './user-tabs'; // Re-use existing session revocation
import type { UserStatus, SuspensionReason, SuspensionDuration, TicketPriority, TicketType } from '@/lib/types/user';
// --- Validation Schemas ---
const notificationSchema = z.object({
userId: z.string(),
channel: z.enum(['email', 'in_app', 'push']),
subject: z.string().optional(), // Required for email
title: z.string().optional(), // Required for in_app/push
message: z.string().min(1, "Message is required"),
ctaLabel: z.string().optional(),
ctaUrl: z.string().url().optional().or(z.literal('')),
sendCopy: z.boolean().optional(),
});
const moderationSchema = z.object({
userId: z.string(),
mode: z.enum(['suspend', 'ban']),
reasonCategory: z.string(),
reasonText: z.string().min(10, "Reason details must be at least 10 characters"),
duration: z.string().optional(), // For suspension
customDate: z.date().optional(), // For custom suspension
notifyUser: z.boolean().optional(),
revokeSessions: z.boolean().optional(),
});
const escalationSchema = z.object({
userId: z.string(),
type: z.enum(['Refund', 'Payment', 'Account access', 'Fraud', 'Event issue', 'Other']),
priority: z.enum(['Low', 'Normal', 'High', 'Urgent']),
subject: z.string().min(5),
description: z.string().min(10),
callbackRequired: z.boolean().optional(),
callbackPhone: z.string().optional(),
callbackTime: z.string().optional(),
assigneeId: z.string().optional(),
});
// --- Authorization Helper ---
// TODO: Replace with real auth check
async function verifyAdmin(requiredRole: 'admin' | 'manager' | 'support' = 'support') {
// Mock check
return { id: 'admin-1', role: 'admin', name: 'Sarah Admin' };
}
// --- Server Actions ---
/**
* Sends a targeted notification to a user via Email, In-App, or Push.
*/
export async function sendUserNotification(data: z.infer<typeof notificationSchema>) {
try {
const admin = await verifyAdmin();
const validated = notificationSchema.safeParse(data);
if (!validated.success) return { success: false, message: "Invalid notification data" };
const { userId, channel, subject, title, message } = validated.data;
// Validation logic for channels
if (channel === 'email' && !subject) {
return { success: false, message: "Subject is required for emails" };
}
if ((channel === 'in_app' || channel === 'push') && !title) {
return { success: false, message: "Title is required for notifications" };
}
// Simulate Provider API Call (SendGrid / Firebase)
await new Promise(resolve => setTimeout(resolve, 800));
await logAdminAction({
actorId: admin.id,
action: 'notification_sent',
targetId: userId,
details: { channel, subject, title, messageSnippet: message.substring(0, 50) }
});
// Optimistic revalidation (if we had a notifications list page)
// revalidatePath(`/admin/users/${userId}`);
return { success: true, message: `Notification sent via ${channel}` };
} catch (error) {
console.error("sendUserNotification error:", error);
return { success: false, message: "Failed to send notification" };
}
}
/**
* Unified moderation action to Suspend or Ban a user.
* Handles Mock DB updates, session revocation, and audit logging.
*/
export async function moderateUser(data: z.infer<typeof moderationSchema>) {
try {
const admin = await verifyAdmin('manager'); // Higher privilege required
const validated = moderationSchema.safeParse(data);
if (!validated.success) return { success: false, message: "Invalid moderation data" };
const { userId, mode, reasonCategory, reasonText, duration, revokeSessions, notifyUser } = validated.data;
// 1. Revoke sessions if requested
if (revokeSessions) {
// We can call the session revocation logic here
// In a real app we'd await this, but we'll let it run or await if critical
await revokeUserSession('all'); // Mocking 'all' sessionId handling
}
// 2. Simulate DB Update for Status
await new Promise(resolve => setTimeout(resolve, 1000));
const newStatus: UserStatus = mode === 'ban' ? 'Banned' : 'Suspended';
// 3. Log Action
await logAdminAction({
actorId: admin.id,
action: mode === 'ban' ? 'banned' : 'suspended',
targetId: userId,
details: {
reasonCategory,
reasonText,
duration: mode === 'suspend' ? duration : 'permanent',
notifyUser
}
});
// revalidatePath(`/admin/users`);
return { success: true, message: `User ${mode === 'ban' ? 'Banned' : 'Suspended'} successfully` };
} catch (error) {
return { success: false, message: "Moderation action failed" };
}
}
/**
* Creates a high-priority support ticket (Escalation).
* Includes routing logic and potential callback scheduling.
*/
export async function createEscalationTicket(data: z.infer<typeof escalationSchema>) {
try {
const admin = await verifyAdmin();
const validated = escalationSchema.safeParse(data);
if (!validated.success) return { success: false, message: "Invalid escalation data" };
const { userId, type, priority, subject, callbackRequired } = validated.data;
// Simulate Logic: Auto-route critical tickets
let autoAssigned = validated.data.assigneeId;
if (!autoAssigned && (priority === 'High' || priority === 'Urgent')) {
autoAssigned = 'team-lead-1'; // Mock auto-assignment
}
// Simulate DB Insert
await new Promise(resolve => setTimeout(resolve, 1200));
await logAdminAction({
actorId: admin.id,
action: 'escalation_created',
targetId: userId,
details: {
type,
priority,
subject,
callbackRequired,
assignedTo: autoAssigned
}
});
// revalidatePath(`/admin/users/${userId}?tab=support`);
return { success: true, message: isUrgent(priority) ? "Escalation routed to Team Lead" : "Support ticket created" };
} catch (error) {
return { success: false, message: "Failed to create escalation" };
}
}
/**
* Helper to determine urgency for toast messages
*/
function isUrgent(priority: string) {
return priority === 'High' || priority === 'Urgent';
}
/**
* Mock function to list user notifications for the support tab
*/
export async function listUserNotifications(userId: string) {
// Simulate fetching from DB
await new Promise(resolve => setTimeout(resolve, 500));
return [
{
id: 'notif-1',
userId,
title: 'Security Alert',
body: 'New login detected from Lisbon, Portugal',
actionUrl: '/settings/security',
priority: 'high',
status: 'Delivered',
channel: 'push',
createdAt: new Date().toISOString(),
createdBy: 'system'
},
{
id: 'notif-2',
userId,
title: 'Welcome to Eventify',
body: 'Thanks for joining! here is a quick guide.',
priority: 'normal',
status: 'Delivered',
channel: 'email',
createdAt: new Date(Date.now() - 86400000).toISOString(),
createdBy: 'system'
}
];
}

View File

@@ -252,7 +252,9 @@ export type AuditAction =
| 'ticket_assigned'
| 'ticket_resolved'
| 'data_exported'
| 'data_anonymized';
| 'data_anonymized'
| 'receipt_viewed'
| 'escalation_created';
export interface AuditLog {
id: string;