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)}`,
|
`/reports/superadmin?${toQueryString(params)}`,
|
||||||
updateStatus: (reportId: string) => `/reports/superadmin/${reportId}/status`,
|
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: {
|
marketplace: {
|
||||||
home: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
home: (params: Record<string, string | number | boolean | null | undefined> = {}) =>
|
||||||
`/marketplace/home?${toQueryString(params)}`,
|
`/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,
|
PackageSearch,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
LifeBuoy,
|
||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
SquareKanban,
|
SquareKanban,
|
||||||
Users,
|
Users,
|
||||||
@@ -55,6 +56,12 @@ export const dashboardNav = [
|
|||||||
icon: Flag,
|
icon: Flag,
|
||||||
requiredPermissions: [SUPERADMIN_PERMISSIONS.CONTENT_MODERATE],
|
requiredPermissions: [SUPERADMIN_PERMISSIONS.CONTENT_MODERATE],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/dashboard/support",
|
||||||
|
label: "Support",
|
||||||
|
icon: LifeBuoy,
|
||||||
|
requiredPermissions: [SUPERADMIN_PERMISSIONS.CASES_MANAGE],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/marketplace",
|
href: "/marketplace",
|
||||||
label: "Marketplace",
|
label: "Marketplace",
|
||||||
|
|||||||
@@ -345,6 +345,68 @@ export type PlatformReport = {
|
|||||||
|
|
||||||
export type ReportsResponse = PaginatedResponse<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 = {
|
export type Conversation = {
|
||||||
_id: string;
|
_id: string;
|
||||||
isGroup?: boolean;
|
isGroup?: boolean;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { ReportsModule } from './modules/reports/reports.module';
|
|||||||
import { SavesModule } from './modules/saves/saves.module';
|
import { SavesModule } from './modules/saves/saves.module';
|
||||||
import { SearchModule } from './modules/search/search.module';
|
import { SearchModule } from './modules/search/search.module';
|
||||||
import { SuperAdminModule } from './modules/superadmin/superadmin.module';
|
import { SuperAdminModule } from './modules/superadmin/superadmin.module';
|
||||||
|
import { SupportModule } from './modules/support/support.module';
|
||||||
import { UsersModule } from './modules/users/users.module';
|
import { UsersModule } from './modules/users/users.module';
|
||||||
import { ThrottleGuard } from './common/guards/throttle.guard';
|
import { ThrottleGuard } from './common/guards/throttle.guard';
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
|
|||||||
ReportsModule,
|
ReportsModule,
|
||||||
SavesModule,
|
SavesModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
|
SupportModule,
|
||||||
SuperAdminModule,
|
SuperAdminModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -305,6 +305,10 @@ export class NotificationsService {
|
|||||||
return 'Follow request approved';
|
return 'Follow request approved';
|
||||||
case 'follow_request_rejected':
|
case 'follow_request_rejected':
|
||||||
return 'Follow request rejected';
|
return 'Follow request rejected';
|
||||||
|
case 'support_reply':
|
||||||
|
return 'Support reply';
|
||||||
|
case 'support_ticket_status':
|
||||||
|
return 'Support ticket updated';
|
||||||
default:
|
default:
|
||||||
return 'Notification';
|
return 'Notification';
|
||||||
}
|
}
|
||||||
@@ -323,6 +327,9 @@ export class NotificationsService {
|
|||||||
return 'system';
|
return 'system';
|
||||||
case 'collaboration_request':
|
case 'collaboration_request':
|
||||||
return 'post';
|
return 'post';
|
||||||
|
case 'support_reply':
|
||||||
|
case 'support_ticket_status':
|
||||||
|
return 'support_ticket';
|
||||||
default:
|
default:
|
||||||
return 'post';
|
return 'post';
|
||||||
}
|
}
|
||||||
@@ -345,6 +352,10 @@ export class NotificationsService {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (resourceType === 'support_ticket' && referenceId) {
|
||||||
|
return `/support/tickets/${referenceId}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (referenceId) {
|
if (referenceId) {
|
||||||
return `/posts/${referenceId}`;
|
return `/posts/${referenceId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export const NOTIFICATION_TYPES = [
|
|||||||
'follow_request',
|
'follow_request',
|
||||||
'follow_request_approved',
|
'follow_request_approved',
|
||||||
'follow_request_rejected',
|
'follow_request_rejected',
|
||||||
|
'support_reply',
|
||||||
|
'support_ticket_status',
|
||||||
] as const;
|
] as const;
|
||||||
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
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'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
المرجع في مشكلة جديدة
حظر مستخدم