feat: add support tickets backend and dashboard

هذا الالتزام موجود في:
boutmoun123
2026-06-07 02:10:31 +03:00
الأصل d373d576e3
التزام 48dc3861cf
20 ملفات معدلة مع 1811 إضافات و0 حذوفات

عرض الملف

@@ -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)}`,

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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

عرض الملف

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