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

View File

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

View File

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