add:reply count enforcer

This commit is contained in:
2026-03-09 23:24:44 +09:00
parent 2a81ede504
commit 5264b81466
5 changed files with 183 additions and 112 deletions

View File

@@ -131,6 +131,14 @@ export class SQLiteAdapter implements StorageAdapter {
await db.delete(tickets).where(eq(tickets.id, id)); 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[]> { async getReplies(ticketId: string): Promise<Reply[]> {
const rows = await db const rows = await db
.select({ .select({
@@ -146,9 +154,9 @@ export class SQLiteAdapter implements StorageAdapter {
.leftJoin(users, eq(ticketReplies.userId, users.id)) .leftJoin(users, eq(ticketReplies.userId, users.id))
.where(eq(ticketReplies.ticketId, ticketId)) .where(eq(ticketReplies.ticketId, ticketId))
.orderBy(ticketReplies.createdAt); .orderBy(ticketReplies.createdAt);
return rows.map(r => ({ return rows.map((r) => ({
...r, ...r,
authorRole: r.authorRole as Reply['authorRole'], authorRole: r.authorRole as Reply["authorRole"],
username: r.username ?? null, username: r.username ?? null,
})); }));
} }
@@ -157,7 +165,7 @@ export class SQLiteAdapter implements StorageAdapter {
ticketId: string; ticketId: string;
body: string; body: string;
userId?: string; userId?: string;
authorRole: Reply['authorRole']; authorRole: Reply["authorRole"];
}): Promise<Reply> { }): Promise<Reply> {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -183,7 +191,11 @@ export class SQLiteAdapter implements StorageAdapter {
.leftJoin(users, eq(ticketReplies.userId, users.id)) .leftJoin(users, eq(ticketReplies.userId, users.id))
.where(eq(ticketReplies.id, id)); .where(eq(ticketReplies.id, id));
const row = rows[0]!; 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,
};
} }
} }

View File

@@ -1,6 +1,6 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
import type { Ticket, TicketType } from "../types.ts"; 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"; import { filterContent, filterBody } from "../middleware/contentFilter.ts";
const PAGE_SIZE = 10; 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) return reply.status(404).send({ error: "Not found" });
if (ticket.status === "closed") { if (ticket.status === "closed") {
return reply return reply.status(409).send({
.status(409)
.send({
error: "ticket_closed", error: "ticket_closed",
message: "Cannot reply to a closed ticket.", message: "Cannot reply to a closed ticket.",
}); });
@@ -159,6 +157,18 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
if (!isOwner && ticket.userId !== null) { if (!isOwner && ticket.userId !== null) {
return reply.status(403).send({ error: "Forbidden" }); 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" = const authorRole: "user" | "support" =
ticket.userId === null || asSupport ? "support" : "user"; ticket.userId === null || asSupport ? "support" : "user";

View File

@@ -26,6 +26,7 @@ export interface Ticket {
} }
export const TICKET_LIMIT = 10 export const TICKET_LIMIT = 10
export const REPLY_LIMIT = 20
export interface TicketFilters { export interface TicketFilters {
status?: Ticket['status'] status?: Ticket['status']
@@ -58,5 +59,6 @@ export interface StorageAdapter {
updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null>
deleteTicket(id: string): Promise<void> deleteTicket(id: string): Promise<void>
getReplies(ticketId: string): Promise<Reply[]> getReplies(ticketId: string): Promise<Reply[]>
countRepliesByTicket(ticketId: string): Promise<number>
createReply(data: { ticketId: string; body: string; userId?: string; authorRole: Reply['authorRole'] }): Promise<Reply> createReply(data: { ticketId: string; body: string; userId?: string; authorRole: Reply['authorRole'] }): Promise<Reply>
} }

View File

@@ -3,7 +3,7 @@ import { Badge } from '../ui/Badge.tsx'
import { Button } from '../ui/Button.tsx' import { Button } from '../ui/Button.tsx'
import { FAKE_TRANSACTIONS } from './NewTicketForm.tsx' import { FAKE_TRANSACTIONS } from './NewTicketForm.tsx'
import { parseDescription } from '../../lib/ticket.ts' 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 type { Ticket, Reply } from '../../lib/types.ts'
import { CloseIcon } from '../icons/close.tsx' import { CloseIcon } from '../icons/close.tsx'
import { TrashIcon } from '../icons/trash.tsx' import { TrashIcon } from '../icons/trash.tsx'
@@ -214,10 +214,15 @@ export function TicketDetail({
const isClosed = ticket.status === 'closed' const isClosed = ticket.status === 'closed'
const hasAnyAction = onCloseTicket || onReopenTicket || onDeleteTicket const hasAnyAction = onCloseTicket || onReopenTicket || onDeleteTicket
const REPLY_LIMIT = 20
const [replies, setReplies] = useState<Reply[]>([]) const [replies, setReplies] = useState<Reply[]>([])
const [repliesLoading, setRepliesLoading] = useState(true) const [repliesLoading, setRepliesLoading] = useState(true)
const [replyLimitHit, setReplyLimitHit] = useState(false)
const threadEndRef = useRef<HTMLDivElement>(null) const threadEndRef = useRef<HTMLDivElement>(null)
const atReplyLimit = replies.length >= REPLY_LIMIT || replyLimitHit
useEffect(() => { useEffect(() => {
setRepliesLoading(true) setRepliesLoading(true)
storage.getReplies(isAuthenticated, ticket.id) storage.getReplies(isAuthenticated, ticket.id)
@@ -231,8 +236,16 @@ export function TicketDetail({
}, [replies.length]) }, [replies.length])
const handleSendReply = async (replyBody: string) => { const handleSendReply = async (replyBody: string) => {
try {
const newReply = await storage.createReply(isAuthenticated, ticket.id, replyBody, replyAs === 'support') const newReply = await storage.createReply(isAuthenticated, ticket.id, replyBody, replyAs === 'support')
setReplies(prev => [...prev, newReply]) setReplies(prev => [...prev, newReply])
} catch (err) {
if (err instanceof ApiError && err.code === 'reply_limit_reached') {
setReplyLimitHit(true)
} else {
throw err
}
}
} }
return ( return (
@@ -275,7 +288,9 @@ export function TicketDetail({
<div className="flex flex-col gap-1.5 min-h-0 flex-1"> <div className="flex flex-col gap-1.5 min-h-0 flex-1">
<p className="text-xs font-medium text-fg-200 shrink-0"> <p className="text-xs font-medium text-fg-200 shrink-0">
Replies {!repliesLoading && replies.length > 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> </p>
<div className="overflow-y-auto flex-1 min-h-[80px]"> <div className="overflow-y-auto flex-1 min-h-[80px]">
@@ -293,9 +308,21 @@ export function TicketDetail({
</div> </div>
{/* Compose — hidden for read-only viewers and closed tickets */} {/* Compose — hidden for read-only viewers and closed tickets */}
{canReply && !isClosed && ( {canReply && !isClosed && !atReplyLimit && (
<ReplyComposer onSend={handleSendReply} /> <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 && ( {canReply && isClosed && (
<p className="text-xs text-fg-300 italic border-t border-border-100 pt-3 shrink-0"> <p className="text-xs text-fg-300 italic border-t border-border-100 pt-3 shrink-0">
This ticket is closed replies are disabled. This ticket is closed replies are disabled.

View File

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