diff --git a/backend/src/adapters/sqlite.ts b/backend/src/adapters/sqlite.ts index 83fc577..1cbd712 100644 --- a/backend/src/adapters/sqlite.ts +++ b/backend/src/adapters/sqlite.ts @@ -131,6 +131,14 @@ export class SQLiteAdapter implements StorageAdapter { await db.delete(tickets).where(eq(tickets.id, id)); } + async countRepliesByTicket(ticketId: string): Promise { + const result = await db + .select({ count: count() }) + .from(ticketReplies) + .where(eq(ticketReplies.ticketId, ticketId)); + return result[0]?.count ?? 0; + } + async getReplies(ticketId: string): Promise { const rows = await db .select({ @@ -146,9 +154,9 @@ export class SQLiteAdapter implements StorageAdapter { .leftJoin(users, eq(ticketReplies.userId, users.id)) .where(eq(ticketReplies.ticketId, ticketId)) .orderBy(ticketReplies.createdAt); - return rows.map(r => ({ + return rows.map((r) => ({ ...r, - authorRole: r.authorRole as Reply['authorRole'], + authorRole: r.authorRole as Reply["authorRole"], username: r.username ?? null, })); } @@ -157,7 +165,7 @@ export class SQLiteAdapter implements StorageAdapter { ticketId: string; body: string; userId?: string; - authorRole: Reply['authorRole']; + authorRole: Reply["authorRole"]; }): Promise { const id = crypto.randomUUID(); const now = new Date().toISOString(); @@ -183,7 +191,11 @@ export class SQLiteAdapter implements StorageAdapter { .leftJoin(users, eq(ticketReplies.userId, users.id)) .where(eq(ticketReplies.id, id)); const row = rows[0]!; - return { ...row, authorRole: row.authorRole as Reply['authorRole'], username: row.username ?? null }; + return { + ...row, + authorRole: row.authorRole as Reply["authorRole"], + username: row.username ?? null, + }; } } diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index dec3036..e2c45a7 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1,6 +1,6 @@ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; import type { Ticket, TicketType } from "../types.ts"; -import { TICKET_LIMIT } from "../types.ts"; +import { TICKET_LIMIT, REPLY_LIMIT } from "../types.ts"; import { filterContent, filterBody } from "../middleware/contentFilter.ts"; const PAGE_SIZE = 10; @@ -142,12 +142,10 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => { if (!ticket) return reply.status(404).send({ error: "Not found" }); if (ticket.status === "closed") { - return reply - .status(409) - .send({ - error: "ticket_closed", - message: "Cannot reply to a closed ticket.", - }); + return reply.status(409).send({ + error: "ticket_closed", + message: "Cannot reply to a closed ticket.", + }); } // Determine role: @@ -159,6 +157,18 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => { if (!isOwner && ticket.userId !== null) { return reply.status(403).send({ error: "Forbidden" }); } + + // Enforce per-ticket reply limit + const replyCount = await req.storage.countRepliesByTicket(req.params.id); + if (replyCount >= REPLY_LIMIT) { + return reply.status(429).send({ + error: "reply_limit_reached", + message: `This ticket has reached the maximum of ${REPLY_LIMIT} replies. Delete the ticket from the Admin tab and open a new one.`, + limit: REPLY_LIMIT, + current: replyCount, + }); + } + const authorRole: "user" | "support" = ticket.userId === null || asSupport ? "support" : "user"; diff --git a/backend/src/types.ts b/backend/src/types.ts index ae65e0a..1ff52bf 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -26,6 +26,7 @@ export interface Ticket { } export const TICKET_LIMIT = 10 +export const REPLY_LIMIT = 20 export interface TicketFilters { status?: Ticket['status'] @@ -58,5 +59,6 @@ export interface StorageAdapter { updateTicket(id: string, patch: Partial): Promise deleteTicket(id: string): Promise getReplies(ticketId: string): Promise + countRepliesByTicket(ticketId: string): Promise createReply(data: { ticketId: string; body: string; userId?: string; authorRole: Reply['authorRole'] }): Promise } diff --git a/frontend/src/components/tickets/TicketDetail.tsx b/frontend/src/components/tickets/TicketDetail.tsx index b63108d..1dc18c8 100644 --- a/frontend/src/components/tickets/TicketDetail.tsx +++ b/frontend/src/components/tickets/TicketDetail.tsx @@ -3,7 +3,7 @@ import { Badge } from '../ui/Badge.tsx' import { Button } from '../ui/Button.tsx' import { FAKE_TRANSACTIONS } from './NewTicketForm.tsx' import { parseDescription } from '../../lib/ticket.ts' -import { storage } from '../../lib/storage.ts' +import { storage, ApiError } from '../../lib/storage.ts' import type { Ticket, Reply } from '../../lib/types.ts' import { CloseIcon } from '../icons/close.tsx' import { TrashIcon } from '../icons/trash.tsx' @@ -214,10 +214,15 @@ export function TicketDetail({ const isClosed = ticket.status === 'closed' const hasAnyAction = onCloseTicket || onReopenTicket || onDeleteTicket + const REPLY_LIMIT = 20 + const [replies, setReplies] = useState([]) const [repliesLoading, setRepliesLoading] = useState(true) + const [replyLimitHit, setReplyLimitHit] = useState(false) const threadEndRef = useRef(null) + const atReplyLimit = replies.length >= REPLY_LIMIT || replyLimitHit + useEffect(() => { setRepliesLoading(true) storage.getReplies(isAuthenticated, ticket.id) @@ -231,8 +236,16 @@ export function TicketDetail({ }, [replies.length]) const handleSendReply = async (replyBody: string) => { - const newReply = await storage.createReply(isAuthenticated, ticket.id, replyBody, replyAs === 'support') - setReplies(prev => [...prev, newReply]) + try { + const newReply = await storage.createReply(isAuthenticated, ticket.id, replyBody, replyAs === 'support') + setReplies(prev => [...prev, newReply]) + } catch (err) { + if (err instanceof ApiError && err.code === 'reply_limit_reached') { + setReplyLimitHit(true) + } else { + throw err + } + } } return ( @@ -275,7 +288,9 @@ export function TicketDetail({

Replies {!repliesLoading && replies.length > 0 && ( - ({replies.length}) + + ({replies.length} / {REPLY_LIMIT}) + )}

@@ -293,9 +308,21 @@ export function TicketDetail({
{/* Compose — hidden for read-only viewers and closed tickets */} - {canReply && !isClosed && ( + {canReply && !isClosed && !atReplyLimit && ( )} + {canReply && !isClosed && atReplyLimit && ( +
+ 📭 +
+

Reply limit reached

+

+ This ticket has hit the {REPLY_LIMIT}-reply limit. To continue, delete this ticket from the{' '} + Admin tab and open a new one. +

+
+
+ )} {canReply && isClosed && (

This ticket is closed — replies are disabled. diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 9672f32..f524411 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -1,61 +1,64 @@ -import type { Ticket, TicketType, Reply } from './types' -import { env } from '../env' - -const API = env.apiUrl +import type { Ticket, TicketType, Reply } from "./types"; +import { env } from "../env"; +const API = env.apiUrl; export class ApiError extends Error { - readonly status: number - readonly code: string + readonly status: number; + readonly code: string; constructor(status: number, code: string, message: string) { - super(message) - this.name = 'ApiError' - this.status = status - this.code = code + super(message); + this.name = "ApiError"; + this.status = status; + this.code = code; } } - async function apiFetch(path: string, init: RequestInit = {}): Promise { const res = await fetch(`${API}${path}`, { ...init, - credentials: 'include', - headers: { 'Content-Type': 'application/json', ...(init.headers ?? {}) }, - }) + credentials: "include", + headers: { "Content-Type": "application/json", ...(init.headers ?? {}) }, + }); if (!res.ok) { // Try to parse a structured error body; fall back to a generic message - let code = `http_${res.status}` - let message = `API error ${res.status}` + let code = `http_${res.status}`; + let message = `API error ${res.status}`; try { - const body = await res.json() - if (body?.error) code = body.error - if (body?.message) message = body.message - } catch { /* non-JSON body — keep defaults */ } - throw new ApiError(res.status, code, message) + const body = await res.json(); + if (body?.error) code = body.error; + if (body?.message) message = body.message; + } catch { + /* non-JSON body — keep defaults */ + } + throw new ApiError(res.status, code, message); } - return res.json() + return res.json(); } - -const LOCAL_KEY = 'support_tickets' +const LOCAL_KEY = "support_tickets"; function localGet(): Ticket[] { try { - return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]') + return JSON.parse(localStorage.getItem(LOCAL_KEY) ?? "[]"); } catch { - return [] + return []; } } function localSet(tickets: Ticket[]) { - localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets)) + localStorage.setItem(LOCAL_KEY, JSON.stringify(tickets)); } export const localAdapter = { getTickets: (): Ticket[] => localGet(), - createTicket: (data: { subject: string; description: string; type: TicketType }): Ticket => { + createTicket: (data: { + subject: string; + description: string; + type: TicketType; + }): Ticket => { const ticket: Ticket = { id: crypto.randomUUID(), userId: null, @@ -63,80 +66,81 @@ export const localAdapter = { subject: data.subject, description: data.description, type: data.type, - status: 'open', + status: "open", createdAt: new Date().toISOString(), - } - localSet([ticket, ...localGet()]) - return ticket + }; + localSet([ticket, ...localGet()]); + return ticket; }, updateTicket: (id: string, patch: Partial): Ticket | null => { - const tickets = localGet() - const idx = tickets.findIndex(t => t.id === id) - if (idx === -1) return null - tickets[idx] = { ...tickets[idx], ...patch } - localSet(tickets) - return tickets[idx] + const tickets = localGet(); + const idx = tickets.findIndex((t) => t.id === id); + if (idx === -1) return null; + tickets[idx] = { ...tickets[idx], ...patch }; + localSet(tickets); + return tickets[idx]; }, deleteTicket: (id: string): boolean => { - const before = localGet() - const after = before.filter(t => t.id !== id) - localSet(after) - return after.length < before.length + const before = localGet(); + const after = before.filter((t) => t.id !== id); + localSet(after); + return after.length < before.length; }, -} +}; - -const LOCAL_REPLIES_KEY = 'support_replies' +const LOCAL_REPLIES_KEY = "support_replies"; function repliesGet(): Reply[] { - try { return JSON.parse(localStorage.getItem(LOCAL_REPLIES_KEY) ?? '[]') } catch { return [] } + try { + return JSON.parse(localStorage.getItem(LOCAL_REPLIES_KEY) ?? "[]"); + } catch { + return []; + } } function repliesSet(replies: Reply[]) { - localStorage.setItem(LOCAL_REPLIES_KEY, JSON.stringify(replies)) + localStorage.setItem(LOCAL_REPLIES_KEY, JSON.stringify(replies)); } export const localReplyAdapter = { getReplies: (ticketId: string): Reply[] => - repliesGet().filter(r => r.ticketId === ticketId), + repliesGet().filter((r) => r.ticketId === ticketId), - createReply: (ticketId: string, body: string): Reply => { + createReply: (ticketId: string, body: string, asSupport = false): Reply => { const reply: Reply = { id: crypto.randomUUID(), ticketId, userId: null, username: null, body, - authorRole: 'user', + authorRole: asSupport ? "support" : "user", createdAt: new Date().toISOString(), - } - repliesSet([...repliesGet(), reply]) - return reply + }; + repliesSet([...repliesGet(), reply]); + return reply; }, -} - +}; export interface PaginatedResponse { - data: T[] - total: number - page: number - pageSize: number - totalPages: number + data: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; } export interface TicketFilters { - status?: Ticket['status'] - type?: TicketType - mine?: boolean // restrict to the current user's tickets + status?: Ticket["status"]; + type?: TicketType; + mine?: boolean; // restrict to the current user's tickets } - export const storage = { // User's own tickets — API when authenticated, localStorage when guest async getTickets(isAuthenticated: boolean): Promise { - if (!isAuthenticated) return localAdapter.getTickets() - return apiFetch('/api/tickets') + if (!isAuthenticated) return localAdapter.getTickets(); + return apiFetch("/api/tickets"); }, // Admin view — paginated from API when authenticated, sliced localStorage when guest @@ -147,59 +151,75 @@ export const storage = { filters: TicketFilters = {}, ): Promise> { if (!isAuthenticated) { - let all = localAdapter.getTickets() - if (filters.status) all = all.filter(t => t.status === filters.status) - if (filters.type) all = all.filter(t => t.type === filters.type) - const start = (page - 1) * pageSize + let all = localAdapter.getTickets(); + if (filters.status) all = all.filter((t) => t.status === filters.status); + if (filters.type) all = all.filter((t) => t.type === filters.type); + const start = (page - 1) * pageSize; return { data: all.slice(start, start + pageSize), total: all.length, page, pageSize, totalPages: Math.max(1, Math.ceil(all.length / pageSize)), - } + }; } - const params = new URLSearchParams({ page: String(page) }) - if (filters.status) params.set('status', filters.status) - if (filters.type) params.set('type', filters.type) - if (filters.mine) params.set('mine', 'true') + const params = new URLSearchParams({ page: String(page) }); + if (filters.status) params.set("status", filters.status); + if (filters.type) params.set("type", filters.type); + if (filters.mine) params.set("mine", "true"); - return apiFetch>(`/api/tickets/all?${params}`) + return apiFetch>(`/api/tickets/all?${params}`); }, - async createTicket(isAuthenticated: boolean, data: { subject: string; description: string; type: TicketType }): Promise { - if (!isAuthenticated) return localAdapter.createTicket(data) - return apiFetch('/api/tickets', { - method: 'POST', + async createTicket( + isAuthenticated: boolean, + data: { subject: string; description: string; type: TicketType }, + ): Promise { + if (!isAuthenticated) return localAdapter.createTicket(data); + return apiFetch("/api/tickets", { + method: "POST", body: JSON.stringify(data), - }) + }); }, - async updateTicket(isAuthenticated: boolean, id: string, patch: Partial): Promise { - if (!isAuthenticated) return localAdapter.updateTicket(id, patch) + async updateTicket( + isAuthenticated: boolean, + id: string, + patch: Partial, + ): Promise { + if (!isAuthenticated) return localAdapter.updateTicket(id, patch); return apiFetch(`/api/tickets/${id}`, { - method: 'PATCH', + method: "PATCH", body: JSON.stringify(patch), - }) + }); }, async deleteTicket(isAuthenticated: boolean, id: string): Promise { - if (!isAuthenticated) return localAdapter.deleteTicket(id) - await apiFetch(`/api/tickets/${id}`, { method: 'DELETE' }) - return true + if (!isAuthenticated) return localAdapter.deleteTicket(id); + await apiFetch(`/api/tickets/${id}`, { method: "DELETE" }); + return true; }, - async getReplies(isAuthenticated: boolean, ticketId: string): Promise { - if (!isAuthenticated) return localReplyAdapter.getReplies(ticketId) - return apiFetch(`/api/tickets/${ticketId}/replies`) + async getReplies( + isAuthenticated: boolean, + ticketId: string, + ): Promise { + if (!isAuthenticated) return localReplyAdapter.getReplies(ticketId); + return apiFetch(`/api/tickets/${ticketId}/replies`); }, - async createReply(isAuthenticated: boolean, ticketId: string, body: string, asSupport = false): Promise { - if (!isAuthenticated) return localReplyAdapter.createReply(ticketId, body) + async createReply( + isAuthenticated: boolean, + ticketId: string, + body: string, + asSupport = false, + ): Promise { + if (!isAuthenticated) + return localReplyAdapter.createReply(ticketId, body, asSupport); return apiFetch(`/api/tickets/${ticketId}/replies`, { - method: 'POST', + method: "POST", body: JSON.stringify({ body, asSupport }), - }) + }); }, -} +};