add:reply count enforcer
This commit is contained in:
@@ -131,6 +131,14 @@ export class SQLiteAdapter implements StorageAdapter {
|
||||
await db.delete(tickets).where(eq(tickets.id, id));
|
||||
}
|
||||
|
||||
async countRepliesByTicket(ticketId: string): Promise<number> {
|
||||
const result = await db
|
||||
.select({ count: count() })
|
||||
.from(ticketReplies)
|
||||
.where(eq(ticketReplies.ticketId, ticketId));
|
||||
return result[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
async getReplies(ticketId: string): Promise<Reply[]> {
|
||||
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<Reply> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,9 +142,7 @@ 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({
|
||||
return reply.status(409).send({
|
||||
error: "ticket_closed",
|
||||
message: "Cannot reply to a closed ticket.",
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<Ticket>): Promise<Ticket | null>
|
||||
deleteTicket(id: string): Promise<void>
|
||||
getReplies(ticketId: string): Promise<Reply[]>
|
||||
countRepliesByTicket(ticketId: string): Promise<number>
|
||||
createReply(data: { ticketId: string; body: string; userId?: string; authorRole: Reply['authorRole'] }): Promise<Reply>
|
||||
}
|
||||
|
||||
@@ -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<Reply[]>([])
|
||||
const [repliesLoading, setRepliesLoading] = useState(true)
|
||||
const [replyLimitHit, setReplyLimitHit] = useState(false)
|
||||
const threadEndRef = useRef<HTMLDivElement>(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) => {
|
||||
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({
|
||||
<div className="flex flex-col gap-1.5 min-h-0 flex-1">
|
||||
<p className="text-xs font-medium text-fg-200 shrink-0">
|
||||
Replies {!repliesLoading && replies.length > 0 && (
|
||||
<span className="text-fg-300 font-normal">({replies.length})</span>
|
||||
<span className={`font-normal ${atReplyLimit ? 'text-amber-400' : 'text-fg-300'}`}>
|
||||
({replies.length} / {REPLY_LIMIT})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="overflow-y-auto flex-1 min-h-[80px]">
|
||||
@@ -293,9 +308,21 @@ export function TicketDetail({
|
||||
</div>
|
||||
|
||||
{/* Compose — hidden for read-only viewers and closed tickets */}
|
||||
{canReply && !isClosed && (
|
||||
{canReply && !isClosed && !atReplyLimit && (
|
||||
<ReplyComposer onSend={handleSendReply} />
|
||||
)}
|
||||
{canReply && !isClosed && atReplyLimit && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-amber-900/40 bg-amber-950/30 px-4 py-3 shrink-0">
|
||||
<span className="mt-0.5 text-base leading-none">📭</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-xs font-medium text-amber-300">Reply limit reached</p>
|
||||
<p className="text-xs leading-relaxed text-amber-400/80">
|
||||
This ticket has hit the {REPLY_LIMIT}-reply limit. To continue, delete this ticket from the{' '}
|
||||
<span className="font-medium text-amber-300">Admin tab</span> and open a new one.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canReply && isClosed && (
|
||||
<p className="text-xs text-fg-300 italic border-t border-border-100 pt-3 shrink-0">
|
||||
This ticket is closed — replies are disabled.
|
||||
|
||||
@@ -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<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
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 */
|
||||
}
|
||||
return res.json()
|
||||
throw new ApiError(res.status, code, message);
|
||||
}
|
||||
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>): 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<T> {
|
||||
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<Ticket[]> {
|
||||
if (!isAuthenticated) return localAdapter.getTickets()
|
||||
return apiFetch<Ticket[]>('/api/tickets')
|
||||
if (!isAuthenticated) return localAdapter.getTickets();
|
||||
return apiFetch<Ticket[]>("/api/tickets");
|
||||
},
|
||||
|
||||
// Admin view — paginated from API when authenticated, sliced localStorage when guest
|
||||
@@ -147,59 +151,75 @@ export const storage = {
|
||||
filters: TicketFilters = {},
|
||||
): Promise<PaginatedResponse<Ticket>> {
|
||||
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<PaginatedResponse<Ticket>>(`/api/tickets/all?${params}`)
|
||||
return apiFetch<PaginatedResponse<Ticket>>(`/api/tickets/all?${params}`);
|
||||
},
|
||||
|
||||
async createTicket(isAuthenticated: boolean, data: { subject: string; description: string; type: TicketType }): Promise<Ticket> {
|
||||
if (!isAuthenticated) return localAdapter.createTicket(data)
|
||||
return apiFetch<Ticket>('/api/tickets', {
|
||||
method: 'POST',
|
||||
async createTicket(
|
||||
isAuthenticated: boolean,
|
||||
data: { subject: string; description: string; type: TicketType },
|
||||
): Promise<Ticket> {
|
||||
if (!isAuthenticated) return localAdapter.createTicket(data);
|
||||
return apiFetch<Ticket>("/api/tickets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async updateTicket(isAuthenticated: boolean, id: string, patch: Partial<Ticket>): Promise<Ticket | null> {
|
||||
if (!isAuthenticated) return localAdapter.updateTicket(id, patch)
|
||||
async updateTicket(
|
||||
isAuthenticated: boolean,
|
||||
id: string,
|
||||
patch: Partial<Ticket>,
|
||||
): Promise<Ticket | null> {
|
||||
if (!isAuthenticated) return localAdapter.updateTicket(id, patch);
|
||||
return apiFetch<Ticket>(`/api/tickets/${id}`, {
|
||||
method: 'PATCH',
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
async deleteTicket(isAuthenticated: boolean, id: string): Promise<boolean> {
|
||||
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<Reply[]> {
|
||||
if (!isAuthenticated) return localReplyAdapter.getReplies(ticketId)
|
||||
return apiFetch<Reply[]>(`/api/tickets/${ticketId}/replies`)
|
||||
async getReplies(
|
||||
isAuthenticated: boolean,
|
||||
ticketId: string,
|
||||
): Promise<Reply[]> {
|
||||
if (!isAuthenticated) return localReplyAdapter.getReplies(ticketId);
|
||||
return apiFetch<Reply[]>(`/api/tickets/${ticketId}/replies`);
|
||||
},
|
||||
|
||||
async createReply(isAuthenticated: boolean, ticketId: string, body: string, asSupport = false): Promise<Reply> {
|
||||
if (!isAuthenticated) return localReplyAdapter.createReply(ticketId, body)
|
||||
async createReply(
|
||||
isAuthenticated: boolean,
|
||||
ticketId: string,
|
||||
body: string,
|
||||
asSupport = false,
|
||||
): Promise<Reply> {
|
||||
if (!isAuthenticated)
|
||||
return localReplyAdapter.createReply(ticketId, body, asSupport);
|
||||
return apiFetch<Reply>(`/api/tickets/${ticketId}/replies`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body, asSupport }),
|
||||
})
|
||||
});
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user