feat: add support tickets backend and dashboard
هذا الالتزام موجود في:
470
oudelaa_dashboard/app/(dashboard)/dashboard/support/page.tsx
Normal file
470
oudelaa_dashboard/app/(dashboard)/dashboard/support/page.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { ImageIcon, RefreshCcw, Send, UserRoundCheck } from "lucide-react";
|
||||
|
||||
import { NoPermissionState } from "@/components/auth/no-permission-state";
|
||||
import { useSuperAdminSession } from "@/components/auth/session-context";
|
||||
import { PageHeader } from "@/components/dashboard/page-header";
|
||||
import { PaginationControls } from "@/components/dashboard/pagination-controls";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useToast } from "@/components/ui/toast";
|
||||
import { getItems, getPagination } from "@/lib/api/core";
|
||||
import {
|
||||
assignSupportTicket,
|
||||
getSupportTicket,
|
||||
listSupportTickets,
|
||||
replyToSupportTicket,
|
||||
updateSupportTicketStatus,
|
||||
} from "@/lib/api/support";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { SUPERADMIN_PERMISSIONS, hasPermission } from "@/lib/permissions";
|
||||
import type {
|
||||
ApiUser,
|
||||
SupportTicket,
|
||||
SupportTicketDetailResponse,
|
||||
SupportTicketStatus,
|
||||
SupportTicketsResponse,
|
||||
} from "@/types/api";
|
||||
|
||||
const statuses: SupportTicketStatus[] = ["open", "pending", "in_progress", "resolved", "closed"];
|
||||
const categories = ["account", "technical", "media", "payment", "collaboration", "other"];
|
||||
const priorities = ["low", "normal", "high", "urgent"];
|
||||
|
||||
function userLabel(user?: ApiUser | null) {
|
||||
if (!user) return "-";
|
||||
return user.stageName || user.name || user.username || user.email || user._id;
|
||||
}
|
||||
|
||||
function statusVariant(status: SupportTicketStatus): "success" | "muted" | "warning" | "danger" {
|
||||
if (status === "resolved" || status === "closed") return "success";
|
||||
if (status === "in_progress") return "warning";
|
||||
if (status === "pending") return "muted";
|
||||
return "danger";
|
||||
}
|
||||
|
||||
export default function SupportPage() {
|
||||
const { permissions } = useSuperAdminSession();
|
||||
const canManageSupport = hasPermission(permissions, SUPERADMIN_PERMISSIONS.CASES_MANAGE);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState("open");
|
||||
const [categoryFilter, setCategoryFilter] = useState("all");
|
||||
const [priorityFilter, setPriorityFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [response, setResponse] = useState<SupportTicketsResponse | null>(null);
|
||||
const [selected, setSelected] = useState<SupportTicketDetailResponse | null>(null);
|
||||
const [reply, setReply] = useState("");
|
||||
const [replyImage, setReplyImage] = useState<File | null>(null);
|
||||
const [assignAdminId, setAssignAdminId] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const filtersRef = useRef({ statusFilter, categoryFilter, priorityFilter });
|
||||
|
||||
filtersRef.current = { statusFilter, categoryFilter, priorityFilter };
|
||||
|
||||
const loadTickets = useCallback(async () => {
|
||||
if (!canManageSupport) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const current = filtersRef.current;
|
||||
const tickets = await listSupportTickets({
|
||||
page,
|
||||
limit: 15,
|
||||
status: current.statusFilter === "all" ? undefined : current.statusFilter,
|
||||
category: current.categoryFilter === "all" ? undefined : current.categoryFilter,
|
||||
priority: current.priorityFilter === "all" ? undefined : current.priorityFilter,
|
||||
sortOrder: "desc",
|
||||
});
|
||||
setResponse(tickets);
|
||||
const items = getItems(tickets) as SupportTicket[];
|
||||
const currentSelectedId = selected?.ticket._id;
|
||||
const nextTicket = currentSelectedId
|
||||
? items.find((item) => item._id === currentSelectedId) ?? items[0]
|
||||
: items[0];
|
||||
if (nextTicket) {
|
||||
const detail = await getSupportTicket(nextTicket._id);
|
||||
setSelected(detail);
|
||||
setAssignAdminId(detail.ticket.assignedAdminId ?? "");
|
||||
} else {
|
||||
setSelected(null);
|
||||
setAssignAdminId("");
|
||||
}
|
||||
} catch (error) {
|
||||
toast({ title: "Failed to load support tickets", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canManageSupport, page, selected?.ticket._id, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTickets();
|
||||
}, [loadTickets]);
|
||||
|
||||
const tickets = getItems(response) as SupportTicket[];
|
||||
const summary = useMemo(
|
||||
() => ({
|
||||
open: tickets.filter((ticket) => ticket.status === "open").length,
|
||||
inProgress: tickets.filter((ticket) => ticket.status === "in_progress").length,
|
||||
resolved: tickets.filter((ticket) => ticket.status === "resolved").length,
|
||||
closed: tickets.filter((ticket) => ticket.status === "closed").length,
|
||||
}),
|
||||
[tickets],
|
||||
);
|
||||
|
||||
const selectTicket = async (ticketId: string) => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
const detail = await getSupportTicket(ticketId);
|
||||
setSelected(detail);
|
||||
setAssignAdminId(detail.ticket.assignedAdminId ?? "");
|
||||
} catch (error) {
|
||||
toast({ title: "Failed to load ticket", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
if (page === 1) {
|
||||
void loadTickets();
|
||||
return;
|
||||
}
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const changeStatus = async (status: SupportTicketStatus) => {
|
||||
if (!selected) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
await updateSupportTicketStatus(selected.ticket._id, status);
|
||||
await selectTicket(selected.ticket._id);
|
||||
await loadTickets();
|
||||
toast({ title: "Ticket status updated", description: status, variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "Status update failed", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const assignTicket = async () => {
|
||||
if (!selected) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
await assignSupportTicket(selected.ticket._id, assignAdminId.trim());
|
||||
await selectTicket(selected.ticket._id);
|
||||
toast({ title: "Assignment updated", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "Assignment failed", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendReply = async () => {
|
||||
if (!selected || (!reply.trim() && !replyImage)) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
await replyToSupportTicket(selected.ticket._id, reply.trim(), replyImage);
|
||||
setReply("");
|
||||
setReplyImage(null);
|
||||
await selectTicket(selected.ticket._id);
|
||||
await loadTickets();
|
||||
toast({ title: "Reply sent", variant: "success" });
|
||||
} catch (error) {
|
||||
toast({ title: "Reply failed", description: String(error), variant: "danger" });
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onImageChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setReplyImage(event.target.files?.[0] ?? null);
|
||||
};
|
||||
|
||||
if (!canManageSupport) {
|
||||
return (
|
||||
<div className="space-y-5 pb-8">
|
||||
<PageHeader title="Support" subtitle="Review user support tickets and reply from the dashboard." />
|
||||
<NoPermissionState description="This page needs the cases.manage permission." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5 pb-8">
|
||||
<PageHeader
|
||||
title="Support tickets"
|
||||
subtitle="Handle user support requests separately from reports and moderation cases."
|
||||
actions={
|
||||
<Button variant="outline" onClick={() => void loadTickets()} disabled={loading}>
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Open</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{summary.open}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>In progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{summary.inProgress}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Resolved</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{summary.resolved}</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Closed</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-3xl font-bold text-foreground">{summary.closed}</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-4">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
{statuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All categories</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All priorities</SelectItem>
|
||||
{priorities.map((priority) => (
|
||||
<SelectItem key={priority} value={priority}>
|
||||
{priority}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={applyFilters} disabled={loading}>
|
||||
Apply
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-12">
|
||||
<Card className="xl:col-span-7">
|
||||
<CardHeader>
|
||||
<CardTitle>Ticket queue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!tickets.length && !loading ? (
|
||||
<EmptyState title="No support tickets" description="No tickets match the selected filters." />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead>Priority</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last message</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tickets.map((ticket) => (
|
||||
<TableRow
|
||||
key={ticket._id}
|
||||
className={selected?.ticket._id === ticket._id ? "bg-primary/10" : undefined}
|
||||
onClick={() => void selectTicket(ticket._id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-foreground">{userLabel(ticket.user)}</div>
|
||||
<div className="text-xs text-muted-foreground">{ticket.user?.email ?? ticket.userId}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[280px] truncate font-medium">{ticket.subject}</div>
|
||||
<div className="text-xs text-muted-foreground">{ticket.category}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={ticket.priority === "urgent" || ticket.priority === "high" ? "danger" : "muted"}>
|
||||
{ticket.priority}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(ticket.status)}>{ticket.status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDateTime(ticket.lastMessageAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<PaginationControls pagination={getPagination(response)} loading={loading} onPageChange={setPage} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="xl:col-span-5">
|
||||
<CardHeader>
|
||||
<CardTitle>Ticket details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!selected ? (
|
||||
<EmptyState title="Select a ticket" description="Choose a ticket to review user details and messages." />
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-xl border border-border/70 bg-secondary/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
{selected.ticket.user?.avatar ? (
|
||||
<Image
|
||||
src={selected.ticket.user.avatar}
|
||||
alt={userLabel(selected.ticket.user)}
|
||||
width={48}
|
||||
height={48}
|
||||
unoptimized
|
||||
className="h-12 w-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<UserRoundCheck className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-foreground">{userLabel(selected.ticket.user)}</div>
|
||||
<div className="text-sm text-muted-foreground">{selected.ticket.user?.username ?? "-"}</div>
|
||||
<div className="break-all text-xs text-muted-foreground">{selected.ticket.user?.email ?? "-"}</div>
|
||||
</div>
|
||||
<Badge variant={selected.ticket.user?.isDisabled ? "danger" : "success"}>
|
||||
{selected.ticket.user?.isDisabled ? "disabled" : "active"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-4 space-y-1 text-sm">
|
||||
<div className="font-semibold text-foreground">{selected.ticket.subject}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{selected.ticket.category} / {selected.ticket.priority} / {formatDateTime(selected.ticket.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<Select value={selected.ticket.status} onValueChange={(value) => void changeStatus(value as SupportTicketStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statuses.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Admin id"
|
||||
value={assignAdminId}
|
||||
onChange={(event) => setAssignAdminId(event.target.value)}
|
||||
/>
|
||||
<Button variant="outline" onClick={() => void assignTicket()} disabled={updating}>
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[420px] space-y-3 overflow-y-auto rounded-xl border border-border/70 p-3">
|
||||
{selected.messages.map((message) => (
|
||||
<div
|
||||
key={message._id}
|
||||
className={`rounded-xl border border-border/70 p-3 ${
|
||||
message.senderRole === "user" ? "bg-secondary/20" : "bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Badge variant={message.senderRole === "user" ? "muted" : "warning"}>{message.senderRole}</Badge>
|
||||
<span className="text-xs text-muted-foreground">{formatDateTime(message.createdAt)}</span>
|
||||
</div>
|
||||
{message.message ? <p className="mt-2 whitespace-pre-wrap text-sm">{message.message}</p> : null}
|
||||
{message.attachmentUrl ? (
|
||||
<a href={message.attachmentUrl} target="_blank" rel="noreferrer" className="mt-3 block">
|
||||
<Image
|
||||
src={message.attachmentUrl}
|
||||
alt="Support attachment"
|
||||
width={640}
|
||||
height={260}
|
||||
unoptimized
|
||||
className="max-h-52 w-full rounded-lg object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-border/70 p-3">
|
||||
<Textarea
|
||||
placeholder="Write a reply"
|
||||
value={reply}
|
||||
onChange={(event) => setReply(event.target.value)}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-border/70 px-3 py-2 text-sm">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{replyImage ? replyImage.name : "Attach image"}
|
||||
<input className="hidden" type="file" accept="image/*" onChange={onImageChange} />
|
||||
</label>
|
||||
<Button onClick={() => void sendReply()} disabled={updating || (!reply.trim() && !replyImage)}>
|
||||
<Send className="h-4 w-4" />
|
||||
Send reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -119,6 +119,14 @@ export const apiEndpoints = {
|
||||
`/reports/superadmin?${toQueryString(params)}`,
|
||||
updateStatus: (reportId: string) => `/reports/superadmin/${reportId}/status`,
|
||||
},
|
||||
support: {
|
||||
adminTickets: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/support/admin/tickets?${toQueryString(params)}`,
|
||||
adminTicket: (ticketId: string) => `/support/admin/tickets/${ticketId}`,
|
||||
adminReply: (ticketId: string) => `/support/admin/tickets/${ticketId}/messages`,
|
||||
adminStatus: (ticketId: string) => `/support/admin/tickets/${ticketId}/status`,
|
||||
adminAssign: (ticketId: string) => `/support/admin/tickets/${ticketId}/assign`,
|
||||
},
|
||||
marketplace: {
|
||||
home: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||
`/marketplace/home?${toQueryString(params)}`,
|
||||
|
||||
45
oudelaa_dashboard/lib/api/support.ts
Normal file
45
oudelaa_dashboard/lib/api/support.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { apiEndpoints } from "@/lib/api/endpoints";
|
||||
import { fetchWithAuth } from "@/lib/auth/client";
|
||||
import type {
|
||||
SupportTicketDetailResponse,
|
||||
SupportTicketStatus,
|
||||
SupportTicketsResponse,
|
||||
SupportTicketUpdateResponse,
|
||||
SupportMessageResponse,
|
||||
} from "@/types/api";
|
||||
|
||||
export async function listSupportTickets(
|
||||
params: Record<string, string | number | boolean | null | undefined> = {},
|
||||
) {
|
||||
return fetchWithAuth<SupportTicketsResponse>(apiEndpoints.support.adminTickets(params));
|
||||
}
|
||||
|
||||
export async function getSupportTicket(ticketId: string) {
|
||||
return fetchWithAuth<SupportTicketDetailResponse>(apiEndpoints.support.adminTicket(ticketId));
|
||||
}
|
||||
|
||||
export async function replyToSupportTicket(ticketId: string, message: string, image?: File | null) {
|
||||
const formData = new FormData();
|
||||
formData.set("message", message);
|
||||
if (image) {
|
||||
formData.set("image", image);
|
||||
}
|
||||
return fetchWithAuth<SupportMessageResponse>(apiEndpoints.support.adminReply(ticketId), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSupportTicketStatus(ticketId: string, status: SupportTicketStatus) {
|
||||
return fetchWithAuth<SupportTicketUpdateResponse>(apiEndpoints.support.adminStatus(ticketId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function assignSupportTicket(ticketId: string, adminId: string) {
|
||||
return fetchWithAuth<SupportTicketUpdateResponse>(apiEndpoints.support.adminAssign(ticketId), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ adminId: adminId || null }),
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PackageSearch,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
LifeBuoy,
|
||||
ShoppingBag,
|
||||
SquareKanban,
|
||||
Users,
|
||||
@@ -55,6 +56,12 @@ export const dashboardNav = [
|
||||
icon: Flag,
|
||||
requiredPermissions: [SUPERADMIN_PERMISSIONS.CONTENT_MODERATE],
|
||||
},
|
||||
{
|
||||
href: "/dashboard/support",
|
||||
label: "Support",
|
||||
icon: LifeBuoy,
|
||||
requiredPermissions: [SUPERADMIN_PERMISSIONS.CASES_MANAGE],
|
||||
},
|
||||
{
|
||||
href: "/marketplace",
|
||||
label: "Marketplace",
|
||||
|
||||
@@ -345,6 +345,68 @@ export type PlatformReport = {
|
||||
|
||||
export type ReportsResponse = PaginatedResponse<PlatformReport>;
|
||||
|
||||
export type SupportTicketCategory =
|
||||
| "account"
|
||||
| "technical"
|
||||
| "media"
|
||||
| "payment"
|
||||
| "collaboration"
|
||||
| "other";
|
||||
|
||||
export type SupportTicketPriority = "low" | "normal" | "high" | "urgent";
|
||||
export type SupportTicketStatus = "open" | "pending" | "in_progress" | "resolved" | "closed";
|
||||
export type SupportSenderRole = "user" | "admin" | "superadmin" | "system";
|
||||
|
||||
export type SupportTicket = {
|
||||
_id: string;
|
||||
userId: string;
|
||||
assignedAdminId?: string | null;
|
||||
user?: ApiUser | null;
|
||||
assignedAdmin?: ApiUser | null;
|
||||
subject: string;
|
||||
category: SupportTicketCategory;
|
||||
priority: SupportTicketPriority;
|
||||
status: SupportTicketStatus;
|
||||
lastMessageAt?: string;
|
||||
resolvedAt?: string | null;
|
||||
closedAt?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type SupportTicketMessage = {
|
||||
_id: string;
|
||||
ticketId: string;
|
||||
senderId?: string | null;
|
||||
senderIdentifier?: string;
|
||||
sender?: ApiUser | null;
|
||||
senderRole: SupportSenderRole;
|
||||
message?: string;
|
||||
attachmentUrl?: string;
|
||||
attachmentType?: "image" | "file" | null;
|
||||
readByUser?: boolean;
|
||||
readByAdmin?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type SupportTicketsResponse = PaginatedResponse<SupportTicket>;
|
||||
|
||||
export type SupportTicketDetailResponse = {
|
||||
ticket: SupportTicket;
|
||||
messages: SupportTicketMessage[];
|
||||
};
|
||||
|
||||
export type SupportTicketUpdateResponse = {
|
||||
message?: string;
|
||||
item: SupportTicket;
|
||||
};
|
||||
|
||||
export type SupportMessageResponse = {
|
||||
message?: string;
|
||||
item: SupportTicketMessage;
|
||||
};
|
||||
|
||||
export type Conversation = {
|
||||
_id: string;
|
||||
isGroup?: boolean;
|
||||
|
||||
@@ -30,6 +30,7 @@ import { ReportsModule } from './modules/reports/reports.module';
|
||||
import { SavesModule } from './modules/saves/saves.module';
|
||||
import { SearchModule } from './modules/search/search.module';
|
||||
import { SuperAdminModule } from './modules/superadmin/superadmin.module';
|
||||
import { SupportModule } from './modules/support/support.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { ThrottleGuard } from './common/guards/throttle.guard';
|
||||
|
||||
@@ -66,6 +67,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
|
||||
ReportsModule,
|
||||
SavesModule,
|
||||
SearchModule,
|
||||
SupportModule,
|
||||
SuperAdminModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -305,6 +305,10 @@ export class NotificationsService {
|
||||
return 'Follow request approved';
|
||||
case 'follow_request_rejected':
|
||||
return 'Follow request rejected';
|
||||
case 'support_reply':
|
||||
return 'Support reply';
|
||||
case 'support_ticket_status':
|
||||
return 'Support ticket updated';
|
||||
default:
|
||||
return 'Notification';
|
||||
}
|
||||
@@ -323,6 +327,9 @@ export class NotificationsService {
|
||||
return 'system';
|
||||
case 'collaboration_request':
|
||||
return 'post';
|
||||
case 'support_reply':
|
||||
case 'support_ticket_status':
|
||||
return 'support_ticket';
|
||||
default:
|
||||
return 'post';
|
||||
}
|
||||
@@ -345,6 +352,10 @@ export class NotificationsService {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (resourceType === 'support_ticket' && referenceId) {
|
||||
return `/support/tickets/${referenceId}`;
|
||||
}
|
||||
|
||||
if (referenceId) {
|
||||
return `/posts/${referenceId}`;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export const NOTIFICATION_TYPES = [
|
||||
'follow_request',
|
||||
'follow_request_approved',
|
||||
'follow_request_rejected',
|
||||
'support_reply',
|
||||
'support_ticket_status',
|
||||
] as const;
|
||||
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
||||
|
||||
|
||||
9
src/modules/support/dto/assign-support-ticket.dto.ts
Normal file
9
src/modules/support/dto/assign-support-ticket.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsMongoId, IsOptional } from 'class-validator';
|
||||
|
||||
export class AssignSupportTicketDto {
|
||||
@ApiPropertyOptional({ description: 'Admin user id. Omit or null to unassign.' })
|
||||
@IsOptional()
|
||||
@IsMongoId()
|
||||
adminId?: string | null;
|
||||
}
|
||||
10
src/modules/support/dto/create-support-message.dto.ts
Normal file
10
src/modules/support/dto/create-support-message.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class CreateSupportMessageDto {
|
||||
@ApiPropertyOptional({ maxLength: 5000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 5000)
|
||||
message?: string;
|
||||
}
|
||||
30
src/modules/support/dto/create-support-ticket.dto.ts
Normal file
30
src/modules/support/dto/create-support-ticket.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, Length } from 'class-validator';
|
||||
import {
|
||||
SUPPORT_TICKET_CATEGORIES,
|
||||
SUPPORT_TICKET_PRIORITIES,
|
||||
SupportTicketCategory,
|
||||
SupportTicketPriority,
|
||||
} from '../schemas/support-ticket.schema';
|
||||
|
||||
export class CreateSupportTicketDto {
|
||||
@ApiProperty({ maxLength: 150 })
|
||||
@IsString()
|
||||
@Length(1, 150)
|
||||
subject!: string;
|
||||
|
||||
@ApiProperty({ maxLength: 5000 })
|
||||
@IsString()
|
||||
@Length(1, 5000)
|
||||
message!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: SUPPORT_TICKET_CATEGORIES, default: 'technical' })
|
||||
@IsOptional()
|
||||
@IsEnum(SUPPORT_TICKET_CATEGORIES)
|
||||
category?: SupportTicketCategory = 'technical';
|
||||
|
||||
@ApiPropertyOptional({ enum: SUPPORT_TICKET_PRIORITIES, default: 'normal' })
|
||||
@IsOptional()
|
||||
@IsEnum(SUPPORT_TICKET_PRIORITIES)
|
||||
priority?: SupportTicketPriority = 'normal';
|
||||
}
|
||||
33
src/modules/support/dto/support-ticket-query.dto.ts
Normal file
33
src/modules/support/dto/support-ticket-query.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsMongoId, IsOptional } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import {
|
||||
SUPPORT_TICKET_CATEGORIES,
|
||||
SUPPORT_TICKET_PRIORITIES,
|
||||
SUPPORT_TICKET_STATUSES,
|
||||
SupportTicketCategory,
|
||||
SupportTicketPriority,
|
||||
SupportTicketStatus,
|
||||
} from '../schemas/support-ticket.schema';
|
||||
|
||||
export class SupportTicketQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ enum: SUPPORT_TICKET_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(SUPPORT_TICKET_STATUSES)
|
||||
status?: SupportTicketStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: SUPPORT_TICKET_CATEGORIES })
|
||||
@IsOptional()
|
||||
@IsEnum(SUPPORT_TICKET_CATEGORIES)
|
||||
category?: SupportTicketCategory;
|
||||
|
||||
@ApiPropertyOptional({ enum: SUPPORT_TICKET_PRIORITIES })
|
||||
@IsOptional()
|
||||
@IsEnum(SUPPORT_TICKET_PRIORITIES)
|
||||
priority?: SupportTicketPriority;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsMongoId()
|
||||
userId?: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { SUPPORT_TICKET_STATUSES, SupportTicketStatus } from '../schemas/support-ticket.schema';
|
||||
|
||||
export class UpdateSupportTicketStatusDto {
|
||||
@ApiProperty({ enum: SUPPORT_TICKET_STATUSES })
|
||||
@IsEnum(SUPPORT_TICKET_STATUSES)
|
||||
status!: SupportTicketStatus;
|
||||
}
|
||||
46
src/modules/support/schemas/support-ticket-message.schema.ts
Normal file
46
src/modules/support/schemas/support-ticket-message.schema.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
import { SupportTicket } from './support-ticket.schema';
|
||||
|
||||
export type SupportTicketMessageDocument = HydratedDocument<SupportTicketMessage>;
|
||||
|
||||
export const SUPPORT_MESSAGE_SENDER_ROLES = ['user', 'admin', 'superadmin', 'system'] as const;
|
||||
export type SupportMessageSenderRole = (typeof SUPPORT_MESSAGE_SENDER_ROLES)[number];
|
||||
|
||||
export const SUPPORT_MESSAGE_ATTACHMENT_TYPES = ['image', 'file'] as const;
|
||||
export type SupportMessageAttachmentType = (typeof SUPPORT_MESSAGE_ATTACHMENT_TYPES)[number];
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class SupportTicketMessage {
|
||||
@Prop({ type: Types.ObjectId, ref: SupportTicket.name, required: true, index: true })
|
||||
ticketId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, default: null, index: true })
|
||||
senderId!: Types.ObjectId | null;
|
||||
|
||||
@Prop({ type: String, default: '', trim: true, index: true })
|
||||
senderIdentifier!: string;
|
||||
|
||||
@Prop({ type: String, enum: SUPPORT_MESSAGE_SENDER_ROLES, required: true })
|
||||
senderRole!: SupportMessageSenderRole;
|
||||
|
||||
@Prop({ type: String, default: '', trim: true, maxlength: 5000 })
|
||||
message!: string;
|
||||
|
||||
@Prop({ type: String, default: '', trim: true })
|
||||
attachmentUrl!: string;
|
||||
|
||||
@Prop({ type: String, enum: SUPPORT_MESSAGE_ATTACHMENT_TYPES, default: null })
|
||||
attachmentType!: SupportMessageAttachmentType | null;
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
readByUser!: boolean;
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
readByAdmin!: boolean;
|
||||
}
|
||||
|
||||
export const SupportTicketMessageSchema = SchemaFactory.createForClass(SupportTicketMessage);
|
||||
SupportTicketMessageSchema.index({ ticketId: 1, createdAt: 1 });
|
||||
SupportTicketMessageSchema.index({ senderId: 1, createdAt: -1 });
|
||||
65
src/modules/support/schemas/support-ticket.schema.ts
Normal file
65
src/modules/support/schemas/support-ticket.schema.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type SupportTicketDocument = HydratedDocument<SupportTicket>;
|
||||
|
||||
export const SUPPORT_TICKET_CATEGORIES = [
|
||||
'account',
|
||||
'technical',
|
||||
'media',
|
||||
'payment',
|
||||
'collaboration',
|
||||
'other',
|
||||
] as const;
|
||||
export type SupportTicketCategory = (typeof SUPPORT_TICKET_CATEGORIES)[number];
|
||||
|
||||
export const SUPPORT_TICKET_PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const;
|
||||
export type SupportTicketPriority = (typeof SUPPORT_TICKET_PRIORITIES)[number];
|
||||
|
||||
export const SUPPORT_TICKET_STATUSES = [
|
||||
'open',
|
||||
'pending',
|
||||
'in_progress',
|
||||
'resolved',
|
||||
'closed',
|
||||
] as const;
|
||||
export type SupportTicketStatus = (typeof SUPPORT_TICKET_STATUSES)[number];
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class SupportTicket {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
userId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, default: null, index: true })
|
||||
assignedAdminId!: Types.ObjectId | null;
|
||||
|
||||
@Prop({ type: String, required: true, trim: true, maxlength: 150 })
|
||||
subject!: string;
|
||||
|
||||
@Prop({ type: String, enum: SUPPORT_TICKET_CATEGORIES, default: 'technical', index: true })
|
||||
category!: SupportTicketCategory;
|
||||
|
||||
@Prop({ type: String, enum: SUPPORT_TICKET_PRIORITIES, default: 'normal', index: true })
|
||||
priority!: SupportTicketPriority;
|
||||
|
||||
@Prop({ type: String, enum: SUPPORT_TICKET_STATUSES, default: 'open', index: true })
|
||||
status!: SupportTicketStatus;
|
||||
|
||||
@Prop({ type: Date, default: Date.now, index: true })
|
||||
lastMessageAt!: Date;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
resolvedAt!: Date | null;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
closedAt!: Date | null;
|
||||
}
|
||||
|
||||
export const SupportTicketSchema = SchemaFactory.createForClass(SupportTicket);
|
||||
SupportTicketSchema.index({ userId: 1, createdAt: -1 });
|
||||
SupportTicketSchema.index({ status: 1, updatedAt: -1 });
|
||||
SupportTicketSchema.index({ category: 1, createdAt: -1 });
|
||||
SupportTicketSchema.index({ priority: 1, createdAt: -1 });
|
||||
SupportTicketSchema.index({ assignedAdminId: 1, status: 1 });
|
||||
SupportTicketSchema.index({ lastMessageAt: -1 });
|
||||
159
src/modules/support/support.controller.ts
Normal file
159
src/modules/support/support.controller.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
import { AssignSupportTicketDto } from './dto/assign-support-ticket.dto';
|
||||
import { CreateSupportMessageDto } from './dto/create-support-message.dto';
|
||||
import { CreateSupportTicketDto } from './dto/create-support-ticket.dto';
|
||||
import { SupportTicketQueryDto } from './dto/support-ticket-query.dto';
|
||||
import { UpdateSupportTicketStatusDto } from './dto/update-support-ticket-status.dto';
|
||||
import { SupportService } from './support.service';
|
||||
|
||||
type UploadFile = { mimetype?: string; size: number; buffer: Buffer; originalname?: string };
|
||||
|
||||
const supportImageInterceptor = FileInterceptor('image', {
|
||||
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
|
||||
});
|
||||
|
||||
@ApiTags('Support')
|
||||
@Controller('support')
|
||||
export class SupportController {
|
||||
constructor(private readonly supportService: SupportService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('tickets')
|
||||
@UseInterceptors(supportImageInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['subject', 'message'],
|
||||
properties: {
|
||||
subject: { type: 'string', maxLength: 150 },
|
||||
message: { type: 'string', maxLength: 5000 },
|
||||
category: { type: 'string', enum: ['account', 'technical', 'media', 'payment', 'collaboration', 'other'] },
|
||||
priority: { type: 'string', enum: ['low', 'normal', 'high', 'urgent'] },
|
||||
image: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async createTicket(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: CreateSupportTicketDto,
|
||||
@UploadedFile() image?: UploadFile,
|
||||
) {
|
||||
return this.supportService.createTicket(user.sub, dto, image);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('tickets')
|
||||
async myTickets(@CurrentUser() user: JwtPayload, @Query() query: SupportTicketQueryDto) {
|
||||
return this.supportService.listMyTickets(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('tickets/:ticketId')
|
||||
async myTicket(@CurrentUser() user: JwtPayload, @Param('ticketId') ticketId: string) {
|
||||
return this.supportService.getMyTicket(user.sub, ticketId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('tickets/:ticketId/messages')
|
||||
@UseInterceptors(supportImageInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string', maxLength: 5000 },
|
||||
image: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async addUserMessage(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('ticketId') ticketId: string,
|
||||
@Body() dto: CreateSupportMessageDto,
|
||||
@UploadedFile() image?: UploadFile,
|
||||
) {
|
||||
return this.supportService.addUserMessage(user.sub, ticketId, dto, image);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('tickets/:ticketId/close')
|
||||
async closeMyTicket(@CurrentUser() user: JwtPayload, @Param('ticketId') ticketId: string) {
|
||||
return this.supportService.closeMyTicket(user.sub, ticketId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
|
||||
@Get('admin/tickets')
|
||||
async adminTickets(@Query() query: SupportTicketQueryDto) {
|
||||
return this.supportService.listAdminTickets(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
|
||||
@Get('admin/tickets/:ticketId')
|
||||
async adminTicket(@Param('ticketId') ticketId: string) {
|
||||
return this.supportService.getAdminTicket(ticketId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
|
||||
@Post('admin/tickets/:ticketId/messages')
|
||||
@UseInterceptors(supportImageInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async addAdminMessage(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('ticketId') ticketId: string,
|
||||
@Body() dto: CreateSupportMessageDto,
|
||||
@UploadedFile() image?: UploadFile,
|
||||
) {
|
||||
return this.supportService.addAdminMessage(user.sub, ticketId, dto, image);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
|
||||
@Patch('admin/tickets/:ticketId/status')
|
||||
async updateAdminStatus(
|
||||
@Param('ticketId') ticketId: string,
|
||||
@Body() dto: UpdateSupportTicketStatusDto,
|
||||
) {
|
||||
return this.supportService.updateAdminStatus(ticketId, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
|
||||
@Patch('admin/tickets/:ticketId/assign')
|
||||
async assignAdmin(@Param('ticketId') ticketId: string, @Body() dto: AssignSupportTicketDto) {
|
||||
return this.supportService.assignAdmin(ticketId, dto);
|
||||
}
|
||||
}
|
||||
25
src/modules/support/support.module.ts
Normal file
25
src/modules/support/support.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SupportTicket, SupportTicketSchema } from './schemas/support-ticket.schema';
|
||||
import {
|
||||
SupportTicketMessage,
|
||||
SupportTicketMessageSchema,
|
||||
} from './schemas/support-ticket-message.schema';
|
||||
import { SupportController } from './support.controller';
|
||||
import { SupportRepository } from './support.repository';
|
||||
import { SupportService } from './support.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NotificationsModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: SupportTicket.name, schema: SupportTicketSchema },
|
||||
{ name: SupportTicketMessage.name, schema: SupportTicketMessageSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService, SupportRepository],
|
||||
exports: [SupportService, SupportRepository],
|
||||
})
|
||||
export class SupportModule {}
|
||||
156
src/modules/support/support.repository.ts
Normal file
156
src/modules/support/support.repository.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model, Types } from 'mongoose';
|
||||
import { SupportTicket, SupportTicketDocument, SupportTicketStatus } from './schemas/support-ticket.schema';
|
||||
import {
|
||||
SupportMessageSenderRole,
|
||||
SupportTicketMessage,
|
||||
SupportTicketMessageDocument,
|
||||
} from './schemas/support-ticket-message.schema';
|
||||
|
||||
@Injectable()
|
||||
export class SupportRepository {
|
||||
constructor(
|
||||
@InjectModel(SupportTicket.name)
|
||||
private readonly ticketModel: Model<SupportTicketDocument>,
|
||||
@InjectModel(SupportTicketMessage.name)
|
||||
private readonly messageModel: Model<SupportTicketMessageDocument>,
|
||||
) {}
|
||||
|
||||
async createTicket(payload: {
|
||||
userId: string;
|
||||
subject: string;
|
||||
category: string;
|
||||
priority: string;
|
||||
lastMessageAt: Date;
|
||||
}) {
|
||||
return this.ticketModel.create({
|
||||
userId: new Types.ObjectId(payload.userId),
|
||||
subject: payload.subject,
|
||||
category: payload.category,
|
||||
priority: payload.priority,
|
||||
status: 'open',
|
||||
lastMessageAt: payload.lastMessageAt,
|
||||
resolvedAt: null,
|
||||
closedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
async createMessage(payload: {
|
||||
ticketId: string;
|
||||
senderId?: string | null;
|
||||
senderIdentifier: string;
|
||||
senderRole: SupportMessageSenderRole;
|
||||
message: string;
|
||||
attachmentUrl?: string;
|
||||
attachmentType?: 'image' | 'file' | null;
|
||||
readByUser: boolean;
|
||||
readByAdmin: boolean;
|
||||
}) {
|
||||
return this.messageModel.create({
|
||||
ticketId: new Types.ObjectId(payload.ticketId),
|
||||
senderId: payload.senderId && Types.ObjectId.isValid(payload.senderId) ? new Types.ObjectId(payload.senderId) : null,
|
||||
senderIdentifier: payload.senderIdentifier,
|
||||
senderRole: payload.senderRole,
|
||||
message: payload.message,
|
||||
attachmentUrl: payload.attachmentUrl ?? '',
|
||||
attachmentType: payload.attachmentType ?? null,
|
||||
readByUser: payload.readByUser,
|
||||
readByAdmin: payload.readByAdmin,
|
||||
});
|
||||
}
|
||||
|
||||
async findTicketById(ticketId: string) {
|
||||
return this.ticketModel.findById(ticketId).exec();
|
||||
}
|
||||
|
||||
async findUserTicket(ticketId: string, userId: string) {
|
||||
return this.ticketModel
|
||||
.findOne({ _id: new Types.ObjectId(ticketId), userId: new Types.ObjectId(userId) })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findUserTickets(filter: FilterQuery<SupportTicketDocument>, skip: number, limit: number, sort: Record<string, 1 | -1>) {
|
||||
return this.ticketModel.find(filter).sort(sort).skip(skip).limit(limit).exec();
|
||||
}
|
||||
|
||||
async findAdminTickets(filter: FilterQuery<SupportTicketDocument>, skip: number, limit: number, sort: Record<string, 1 | -1>) {
|
||||
return this.ticketModel
|
||||
.find(filter)
|
||||
.populate({ path: 'userId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.populate({ path: 'assignedAdminId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findAdminTicket(ticketId: string) {
|
||||
return this.ticketModel
|
||||
.findById(ticketId)
|
||||
.populate({ path: 'userId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.populate({ path: 'assignedAdminId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countTickets(filter: FilterQuery<SupportTicketDocument>) {
|
||||
return this.ticketModel.countDocuments(filter).exec();
|
||||
}
|
||||
|
||||
async findMessages(ticketId: string) {
|
||||
return this.messageModel
|
||||
.find({ ticketId: new Types.ObjectId(ticketId) })
|
||||
.populate({ path: 'senderId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.sort({ createdAt: 1 })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateTicketAfterMessage(ticketId: string, updates: Partial<{ status: SupportTicketStatus }>) {
|
||||
const now = new Date();
|
||||
return this.ticketModel
|
||||
.findByIdAndUpdate(
|
||||
ticketId,
|
||||
{
|
||||
$set: {
|
||||
lastMessageAt: now,
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
{ new: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async closeTicket(ticketId: string, userId: string) {
|
||||
return this.ticketModel
|
||||
.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(ticketId), userId: new Types.ObjectId(userId) },
|
||||
{ $set: { status: 'closed', closedAt: new Date() } },
|
||||
{ new: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateStatus(ticketId: string, status: SupportTicketStatus) {
|
||||
const update: Record<string, unknown> = { status };
|
||||
if (status === 'resolved') {
|
||||
update.resolvedAt = new Date();
|
||||
}
|
||||
if (status === 'closed') {
|
||||
update.closedAt = new Date();
|
||||
}
|
||||
return this.ticketModel.findByIdAndUpdate(ticketId, { $set: update }, { new: true }).exec();
|
||||
}
|
||||
|
||||
async assign(ticketId: string, adminId?: string | null) {
|
||||
return this.ticketModel
|
||||
.findByIdAndUpdate(
|
||||
ticketId,
|
||||
{ $set: { assignedAdminId: adminId && Types.ObjectId.isValid(adminId) ? new Types.ObjectId(adminId) : null } },
|
||||
{ new: true },
|
||||
)
|
||||
.populate({ path: 'userId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.populate({ path: 'assignedAdminId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
266
src/modules/support/support.service.spec.ts
Normal file
266
src/modules/support/support.service.spec.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { SupportService } from './support.service';
|
||||
|
||||
const userId = '507f1f77bcf86cd799439011';
|
||||
const otherUserId = '507f191e810c19729de860ea';
|
||||
const ticketId = '64f1f77bcf86cd799439011a';
|
||||
const adminId = '507f191e810c19729de860eb';
|
||||
|
||||
const asDoc = <T extends Record<string, any>>(payload: T) => ({
|
||||
id: payload._id?.toString?.() ?? payload._id,
|
||||
...payload,
|
||||
toObject: () => payload,
|
||||
});
|
||||
|
||||
describe('SupportService', () => {
|
||||
let service: SupportService;
|
||||
let repository: Record<string, jest.Mock>;
|
||||
let storage: Record<string, jest.Mock>;
|
||||
let notifications: Record<string, jest.Mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
createTicket: jest.fn(),
|
||||
createMessage: jest.fn(),
|
||||
findUserTickets: jest.fn(),
|
||||
countTickets: jest.fn(),
|
||||
findUserTicket: jest.fn(),
|
||||
findMessages: jest.fn(),
|
||||
closeTicket: jest.fn(),
|
||||
findAdminTickets: jest.fn(),
|
||||
findAdminTicket: jest.fn(),
|
||||
findTicketById: jest.fn(),
|
||||
updateTicketAfterMessage: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
assign: jest.fn(),
|
||||
};
|
||||
storage = {
|
||||
saveFile: jest.fn(),
|
||||
};
|
||||
notifications = {
|
||||
create: jest.fn(),
|
||||
};
|
||||
service = new SupportService(repository as any, storage as any, notifications as any);
|
||||
});
|
||||
|
||||
it('creates a support ticket without image and first user message', async () => {
|
||||
const ticket = asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
subject: 'Upload problem',
|
||||
category: 'technical',
|
||||
priority: 'normal',
|
||||
status: 'open',
|
||||
lastMessageAt: new Date(),
|
||||
resolvedAt: null,
|
||||
closedAt: null,
|
||||
});
|
||||
repository.createTicket.mockResolvedValue(ticket);
|
||||
repository.createMessage.mockResolvedValue(asDoc({ _id: new Types.ObjectId(), ticketId }));
|
||||
|
||||
const result = await service.createTicket(userId, {
|
||||
subject: 'Upload problem',
|
||||
message: 'File upload failed',
|
||||
category: 'technical',
|
||||
priority: 'normal',
|
||||
});
|
||||
|
||||
expect(result.message).toBe('Support ticket created successfully');
|
||||
expect(result.item.status).toBe('open');
|
||||
expect(repository.createMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ticketId,
|
||||
senderId: userId,
|
||||
senderRole: 'user',
|
||||
message: 'File upload failed',
|
||||
attachmentUrl: '',
|
||||
attachmentType: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores optional image attachment for the first message', async () => {
|
||||
storage.saveFile.mockResolvedValue('/uploads/support/images/support-test.jpg');
|
||||
repository.createTicket.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
subject: 'Image problem',
|
||||
category: 'technical',
|
||||
priority: 'normal',
|
||||
status: 'open',
|
||||
}),
|
||||
);
|
||||
repository.createMessage.mockResolvedValue(asDoc({ _id: new Types.ObjectId(), ticketId }));
|
||||
|
||||
await service.createTicket(
|
||||
userId,
|
||||
{ subject: 'Image problem', message: 'See image' },
|
||||
{ mimetype: 'image/jpeg', size: 10, buffer: Buffer.from('img'), originalname: 'a.jpg' },
|
||||
);
|
||||
|
||||
expect(storage.saveFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
folderSegments: ['support', 'images'],
|
||||
extension: '.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
}),
|
||||
);
|
||||
expect(repository.createMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attachmentUrl: '/uploads/support/images/support-test.jpg',
|
||||
attachmentType: 'image',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('lists only the current user tickets with pagination', async () => {
|
||||
const ticket = asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
subject: 'My ticket',
|
||||
category: 'technical',
|
||||
priority: 'normal',
|
||||
status: 'open',
|
||||
});
|
||||
repository.findUserTickets.mockResolvedValue([ticket]);
|
||||
repository.countTickets.mockResolvedValue(1);
|
||||
|
||||
const result = await service.listMyTickets(userId, { page: 1, limit: 20 });
|
||||
|
||||
expect(repository.findUserTickets).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: expect.any(Types.ObjectId) }),
|
||||
0,
|
||||
20,
|
||||
{ lastMessageAt: -1 },
|
||||
);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.pagination.total).toBe(1);
|
||||
});
|
||||
|
||||
it('blocks access to another user ticket', async () => {
|
||||
repository.findUserTicket.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getMyTicket(userId, ticketId)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('adds a user message to an owned open ticket', async () => {
|
||||
repository.findUserTicket.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
status: 'open',
|
||||
}),
|
||||
);
|
||||
repository.createMessage.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(),
|
||||
ticketId: new Types.ObjectId(ticketId),
|
||||
senderId: new Types.ObjectId(userId),
|
||||
senderRole: 'user',
|
||||
message: 'More details',
|
||||
attachmentUrl: '',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.addUserMessage(userId, ticketId, { message: 'More details' });
|
||||
|
||||
expect(result.item.message).toBe('More details');
|
||||
expect(repository.updateTicketAfterMessage).toHaveBeenCalledWith(ticketId, {});
|
||||
});
|
||||
|
||||
it('rejects empty user messages without image', async () => {
|
||||
repository.findUserTicket.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
status: 'open',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.addUserMessage(userId, ticketId, { message: '' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('admin list returns populated user details', async () => {
|
||||
repository.findAdminTickets.mockResolvedValue([
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: {
|
||||
_id: new Types.ObjectId(userId),
|
||||
name: 'User One',
|
||||
username: 'userone',
|
||||
email: 'user@example.com',
|
||||
avatar: '',
|
||||
},
|
||||
subject: 'Need help',
|
||||
category: 'technical',
|
||||
priority: 'high',
|
||||
status: 'open',
|
||||
}),
|
||||
]);
|
||||
repository.countTickets.mockResolvedValue(1);
|
||||
|
||||
const result = await service.listAdminTickets({ page: 1, limit: 20, status: 'open' });
|
||||
|
||||
expect(result.items[0].user?.email).toBe('user@example.com');
|
||||
expect(result.items[0].user?.username).toBe('userone');
|
||||
});
|
||||
|
||||
it('admin reply updates open ticket to in_progress and notifies when actor is a user id', async () => {
|
||||
repository.findTicketById.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(otherUserId),
|
||||
status: 'open',
|
||||
}),
|
||||
);
|
||||
repository.createMessage.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(),
|
||||
ticketId: new Types.ObjectId(ticketId),
|
||||
senderId: new Types.ObjectId(adminId),
|
||||
senderRole: 'admin',
|
||||
message: 'We are checking',
|
||||
attachmentUrl: '',
|
||||
}),
|
||||
);
|
||||
|
||||
await service.addAdminMessage(adminId, ticketId, { message: 'We are checking' });
|
||||
|
||||
expect(repository.updateTicketAfterMessage).toHaveBeenCalledWith(ticketId, { status: 'in_progress' });
|
||||
expect(notifications.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: adminId,
|
||||
recipientId: otherUserId,
|
||||
type: 'support_reply',
|
||||
resourceType: 'support_ticket',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates status and assignment', async () => {
|
||||
repository.updateStatus.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
status: 'resolved',
|
||||
}),
|
||||
);
|
||||
repository.assign.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
assignedAdminId: new Types.ObjectId(adminId),
|
||||
}),
|
||||
);
|
||||
|
||||
const status = await service.updateAdminStatus(ticketId, { status: 'resolved' });
|
||||
const assigned = await service.assignAdmin(ticketId, { adminId });
|
||||
|
||||
expect(status.item.status).toBe('resolved');
|
||||
expect(assigned.item.assignedAdminId).toBe(adminId);
|
||||
});
|
||||
});
|
||||
396
src/modules/support/support.service.ts
Normal file
396
src/modules/support/support.service.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { extname } from 'path';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AssignSupportTicketDto } from './dto/assign-support-ticket.dto';
|
||||
import { CreateSupportMessageDto } from './dto/create-support-message.dto';
|
||||
import { CreateSupportTicketDto } from './dto/create-support-ticket.dto';
|
||||
import { SupportTicketQueryDto } from './dto/support-ticket-query.dto';
|
||||
import { UpdateSupportTicketStatusDto } from './dto/update-support-ticket-status.dto';
|
||||
import { SupportRepository } from './support.repository';
|
||||
import { SupportTicketDocument, SupportTicketStatus } from './schemas/support-ticket.schema';
|
||||
import { SupportTicketMessageDocument } from './schemas/support-ticket-message.schema';
|
||||
|
||||
type UploadFile = {
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SupportService {
|
||||
private readonly logger = new Logger(SupportService.name);
|
||||
private readonly maxImageSizeBytes = 5 * 1024 * 1024;
|
||||
|
||||
constructor(
|
||||
private readonly supportRepository: SupportRepository,
|
||||
private readonly storageService: ManagedStorageService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
async createTicket(userId: string, dto: CreateSupportTicketDto, image?: UploadFile) {
|
||||
this.assertValidUserId(userId);
|
||||
const subject = dto.subject.trim();
|
||||
const message = dto.message.trim();
|
||||
const attachmentUrl = await this.saveOptionalImage(image);
|
||||
const now = new Date();
|
||||
const ticket = await this.supportRepository.createTicket({
|
||||
userId,
|
||||
subject,
|
||||
category: dto.category ?? 'technical',
|
||||
priority: dto.priority ?? 'normal',
|
||||
lastMessageAt: now,
|
||||
});
|
||||
|
||||
await this.supportRepository.createMessage({
|
||||
ticketId: ticket.id,
|
||||
senderId: userId,
|
||||
senderIdentifier: userId,
|
||||
senderRole: 'user',
|
||||
message,
|
||||
attachmentUrl,
|
||||
attachmentType: attachmentUrl ? 'image' : null,
|
||||
readByUser: true,
|
||||
readByAdmin: false,
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Support ticket created successfully',
|
||||
item: this.mapTicket(ticket),
|
||||
};
|
||||
}
|
||||
|
||||
async listMyTickets(userId: string, query: SupportTicketQueryDto) {
|
||||
this.assertValidUserId(userId);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const filter = this.buildTicketFilter(query, { userId });
|
||||
const sort = { lastMessageAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.supportRepository.findUserTickets(filter, skip, limit, sort),
|
||||
this.supportRepository.countTickets(filter),
|
||||
]);
|
||||
|
||||
return buildPaginatedResponse(
|
||||
items.map((ticket) => this.mapTicket(ticket)),
|
||||
{ page, limit, total, offset: skip },
|
||||
);
|
||||
}
|
||||
|
||||
async getMyTicket(userId: string, ticketId: string) {
|
||||
const ticket = await this.getOwnedTicketOrThrow(userId, ticketId);
|
||||
const messages = await this.supportRepository.findMessages(ticket.id);
|
||||
return {
|
||||
ticket: this.mapTicket(ticket),
|
||||
messages: messages.map((message) => this.mapMessage(message)),
|
||||
};
|
||||
}
|
||||
|
||||
async addUserMessage(userId: string, ticketId: string, dto: CreateSupportMessageDto, image?: UploadFile) {
|
||||
const ticket = await this.getOwnedTicketOrThrow(userId, ticketId);
|
||||
if (ticket.status === 'closed') {
|
||||
throw new BadRequestException('Cannot add messages to a closed support ticket');
|
||||
}
|
||||
|
||||
const attachmentUrl = await this.saveOptionalImage(image);
|
||||
const messageText = (dto.message ?? '').trim();
|
||||
this.assertMessageOrAttachment(messageText, attachmentUrl);
|
||||
|
||||
const message = await this.supportRepository.createMessage({
|
||||
ticketId: ticket.id,
|
||||
senderId: userId,
|
||||
senderIdentifier: userId,
|
||||
senderRole: 'user',
|
||||
message: messageText,
|
||||
attachmentUrl,
|
||||
attachmentType: attachmentUrl ? 'image' : null,
|
||||
readByUser: true,
|
||||
readByAdmin: false,
|
||||
});
|
||||
await this.supportRepository.updateTicketAfterMessage(ticket.id, {});
|
||||
|
||||
return {
|
||||
message: 'Support message added successfully',
|
||||
item: this.mapMessage(message),
|
||||
};
|
||||
}
|
||||
|
||||
async closeMyTicket(userId: string, ticketId: string) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
this.assertValidUserId(userId);
|
||||
const ticket = await this.supportRepository.closeTicket(ticketId, userId);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
return {
|
||||
message: 'Support ticket closed successfully',
|
||||
item: this.mapTicket(ticket),
|
||||
};
|
||||
}
|
||||
|
||||
async listAdminTickets(query: SupportTicketQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const filter = this.buildTicketFilter(query);
|
||||
const sort = { lastMessageAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.supportRepository.findAdminTickets(filter, skip, limit, sort),
|
||||
this.supportRepository.countTickets(filter),
|
||||
]);
|
||||
|
||||
return buildPaginatedResponse(
|
||||
items.map((ticket) => this.mapTicket(ticket)),
|
||||
{ page, limit, total, offset: skip },
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminTicket(ticketId: string) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
const ticket = await this.supportRepository.findAdminTicket(ticketId);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
const messages = await this.supportRepository.findMessages(ticket.id);
|
||||
return {
|
||||
ticket: this.mapTicket(ticket),
|
||||
messages: messages.map((message) => this.mapMessage(message)),
|
||||
};
|
||||
}
|
||||
|
||||
async addAdminMessage(
|
||||
adminIdentifier: string,
|
||||
ticketId: string,
|
||||
dto: CreateSupportMessageDto,
|
||||
image?: UploadFile,
|
||||
) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
const ticket = await this.supportRepository.findTicketById(ticketId);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
if (ticket.status === 'closed') {
|
||||
throw new BadRequestException('Cannot add messages to a closed support ticket');
|
||||
}
|
||||
|
||||
const attachmentUrl = await this.saveOptionalImage(image);
|
||||
const messageText = (dto.message ?? '').trim();
|
||||
this.assertMessageOrAttachment(messageText, attachmentUrl);
|
||||
const adminObjectId = Types.ObjectId.isValid(adminIdentifier) ? adminIdentifier : null;
|
||||
const message = await this.supportRepository.createMessage({
|
||||
ticketId: ticket.id,
|
||||
senderId: adminObjectId,
|
||||
senderIdentifier: adminIdentifier,
|
||||
senderRole: adminObjectId ? 'admin' : 'superadmin',
|
||||
message: messageText,
|
||||
attachmentUrl,
|
||||
attachmentType: attachmentUrl ? 'image' : null,
|
||||
readByUser: false,
|
||||
readByAdmin: true,
|
||||
});
|
||||
|
||||
const nextStatus: Partial<{ status: SupportTicketStatus }> =
|
||||
ticket.status === 'open' ? { status: 'in_progress' } : {};
|
||||
await this.supportRepository.updateTicketAfterMessage(ticket.id, nextStatus);
|
||||
await this.notifyUserAboutAdminReply(adminObjectId, ticket.userId.toString(), ticket.id, messageText);
|
||||
|
||||
return {
|
||||
message: 'Support reply sent successfully',
|
||||
item: this.mapMessage(message),
|
||||
};
|
||||
}
|
||||
|
||||
async updateAdminStatus(ticketId: string, dto: UpdateSupportTicketStatusDto) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
const ticket = await this.supportRepository.updateStatus(ticketId, dto.status);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
return {
|
||||
message: 'Support ticket status updated successfully',
|
||||
item: this.mapTicket(ticket),
|
||||
};
|
||||
}
|
||||
|
||||
async assignAdmin(ticketId: string, dto: AssignSupportTicketDto) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
if (dto.adminId) {
|
||||
this.assertValidUserId(dto.adminId);
|
||||
}
|
||||
const ticket = await this.supportRepository.assign(ticketId, dto.adminId);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
return {
|
||||
message: 'Support ticket assignment updated successfully',
|
||||
item: this.mapTicket(ticket),
|
||||
};
|
||||
}
|
||||
|
||||
private buildTicketFilter(query: SupportTicketQueryDto, base?: { userId?: string }) {
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (base?.userId) {
|
||||
filter.userId = new Types.ObjectId(base.userId);
|
||||
}
|
||||
if (query.userId) {
|
||||
filter.userId = new Types.ObjectId(query.userId);
|
||||
}
|
||||
if (query.status) {
|
||||
filter.status = query.status;
|
||||
}
|
||||
if (query.category) {
|
||||
filter.category = query.category;
|
||||
}
|
||||
if (query.priority) {
|
||||
filter.priority = query.priority;
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
private async getOwnedTicketOrThrow(userId: string, ticketId: string) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
this.assertValidUserId(userId);
|
||||
const ticket = await this.supportRepository.findUserTicket(ticketId, userId);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
return ticket;
|
||||
}
|
||||
|
||||
private async saveOptionalImage(file?: UploadFile): Promise<string> {
|
||||
if (!file) {
|
||||
return '';
|
||||
}
|
||||
if (!file.mimetype?.startsWith('image/')) {
|
||||
throw new BadRequestException('Support attachment must be an image');
|
||||
}
|
||||
if (file.size > this.maxImageSizeBytes) {
|
||||
throw new BadRequestException('Support image must be 5MB or smaller');
|
||||
}
|
||||
return this.storageService.saveFile({
|
||||
folderSegments: ['support', 'images'],
|
||||
extension: this.resolveImageExtension(file),
|
||||
buffer: file.buffer,
|
||||
contentType: file.mimetype,
|
||||
fileNamePrefix: 'support',
|
||||
});
|
||||
}
|
||||
|
||||
private resolveImageExtension(file: UploadFile): string {
|
||||
const extension = extname(file.originalname ?? '').toLowerCase();
|
||||
if (['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(extension)) {
|
||||
return extension;
|
||||
}
|
||||
if (file.mimetype === 'image/png') {
|
||||
return '.png';
|
||||
}
|
||||
if (file.mimetype === 'image/webp') {
|
||||
return '.webp';
|
||||
}
|
||||
if (file.mimetype === 'image/gif') {
|
||||
return '.gif';
|
||||
}
|
||||
return '.jpg';
|
||||
}
|
||||
|
||||
private assertMessageOrAttachment(message: string, attachmentUrl: string) {
|
||||
if (!message && !attachmentUrl) {
|
||||
throw new BadRequestException('message or image is required');
|
||||
}
|
||||
}
|
||||
|
||||
private assertValidTicketId(ticketId: string) {
|
||||
if (!Types.ObjectId.isValid(ticketId)) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
}
|
||||
|
||||
private assertValidUserId(userId: string) {
|
||||
if (!Types.ObjectId.isValid(userId)) {
|
||||
throw new BadRequestException('Invalid user id');
|
||||
}
|
||||
}
|
||||
|
||||
private mapTicket(ticket: SupportTicketDocument) {
|
||||
const object = ticket.toObject();
|
||||
const user = this.normalizePopulatedUser(object.userId);
|
||||
const assignedAdmin = this.normalizePopulatedUser(object.assignedAdminId);
|
||||
return {
|
||||
...object,
|
||||
_id: object._id?.toString(),
|
||||
userId: user?._id ?? object.userId?.toString(),
|
||||
assignedAdminId: assignedAdmin?._id ?? object.assignedAdminId?.toString?.() ?? null,
|
||||
user,
|
||||
assignedAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
private mapMessage(message: SupportTicketMessageDocument) {
|
||||
const object = message.toObject();
|
||||
const sender = this.normalizePopulatedUser(object.senderId);
|
||||
return {
|
||||
...object,
|
||||
_id: object._id?.toString(),
|
||||
ticketId: object.ticketId?.toString(),
|
||||
senderId: sender?._id ?? object.senderId?.toString?.() ?? null,
|
||||
sender,
|
||||
attachmentUrl: resolveManagedFileUrl(object.attachmentUrl),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizePopulatedUser(value: unknown) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const user = value as Record<string, any>;
|
||||
if (!user._id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
_id: user._id.toString(),
|
||||
name: user.name ?? '',
|
||||
username: user.username ?? '',
|
||||
stageName: user.stageName ?? '',
|
||||
avatar: resolveManagedFileUrl(user.avatar ?? ''),
|
||||
email: user.email ?? '',
|
||||
isDisabled: !!user.isDisabled,
|
||||
isVerified: !!user.isVerified,
|
||||
};
|
||||
}
|
||||
|
||||
private async notifyUserAboutAdminReply(
|
||||
actorId: string | null,
|
||||
recipientId: string,
|
||||
ticketId: string,
|
||||
previewText: string,
|
||||
) {
|
||||
if (!actorId || !Types.ObjectId.isValid(actorId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.notificationsService.create({
|
||||
actorId,
|
||||
recipientId,
|
||||
type: 'support_reply',
|
||||
referenceId: ticketId,
|
||||
resourceType: 'support_ticket',
|
||||
deepLink: `/support/tickets/${ticketId}`,
|
||||
previewText: previewText.slice(0, 160),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Support reply notification failed for ticket=${ticketId}: ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم