add:reply system
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
import { eq, count, desc, and, type SQL } from "drizzle-orm";
|
||||
import { db } from "../db/index.ts";
|
||||
import { tickets, users } from "../db/schema.ts";
|
||||
import { tickets, users, ticketReplies } from "../db/schema.ts";
|
||||
import type {
|
||||
StorageAdapter,
|
||||
Ticket,
|
||||
TicketType,
|
||||
PaginatedTickets,
|
||||
TicketFilters,
|
||||
Reply,
|
||||
} from "../types.ts";
|
||||
|
||||
// Explicit column selection shared by all ticket queries
|
||||
const ticketSelect = {
|
||||
id: tickets.id,
|
||||
userId: tickets.userId,
|
||||
@@ -21,7 +21,6 @@ const ticketSelect = {
|
||||
username: users.username,
|
||||
};
|
||||
|
||||
// Let TypeScript infer the row type directly from the select shape
|
||||
type TicketRow = {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
@@ -131,6 +130,61 @@ export class SQLiteAdapter implements StorageAdapter {
|
||||
async deleteTicket(id: string): Promise<void> {
|
||||
await db.delete(tickets).where(eq(tickets.id, id));
|
||||
}
|
||||
|
||||
async getReplies(ticketId: string): Promise<Reply[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: ticketReplies.id,
|
||||
ticketId: ticketReplies.ticketId,
|
||||
userId: ticketReplies.userId,
|
||||
body: ticketReplies.body,
|
||||
authorRole: ticketReplies.authorRole,
|
||||
createdAt: ticketReplies.createdAt,
|
||||
username: users.username,
|
||||
})
|
||||
.from(ticketReplies)
|
||||
.leftJoin(users, eq(ticketReplies.userId, users.id))
|
||||
.where(eq(ticketReplies.ticketId, ticketId))
|
||||
.orderBy(ticketReplies.createdAt);
|
||||
return rows.map(r => ({
|
||||
...r,
|
||||
authorRole: r.authorRole as Reply['authorRole'],
|
||||
username: r.username ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async createReply(data: {
|
||||
ticketId: string;
|
||||
body: string;
|
||||
userId?: string;
|
||||
authorRole: Reply['authorRole'];
|
||||
}): Promise<Reply> {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
await db.insert(ticketReplies).values({
|
||||
id,
|
||||
ticketId: data.ticketId,
|
||||
userId: data.userId ?? null,
|
||||
body: data.body,
|
||||
authorRole: data.authorRole,
|
||||
createdAt: now,
|
||||
});
|
||||
const rows = await db
|
||||
.select({
|
||||
id: ticketReplies.id,
|
||||
ticketId: ticketReplies.ticketId,
|
||||
userId: ticketReplies.userId,
|
||||
body: ticketReplies.body,
|
||||
authorRole: ticketReplies.authorRole,
|
||||
createdAt: ticketReplies.createdAt,
|
||||
username: users.username,
|
||||
})
|
||||
.from(ticketReplies)
|
||||
.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 };
|
||||
}
|
||||
}
|
||||
|
||||
function toTicket(row: TicketRow): Ticket {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Database } from 'bun:sqlite'
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite'
|
||||
import { tickets, users, sessions } from './schema.ts'
|
||||
import { tickets, users, sessions, ticketReplies } from './schema.ts'
|
||||
|
||||
const sqlite = new Database('app.db')
|
||||
export const db = drizzle(sqlite, { schema: { tickets, users, sessions } })
|
||||
export const db = drizzle(sqlite, { schema: { tickets, users, sessions, ticketReplies } })
|
||||
|
||||
@@ -26,6 +26,16 @@ export const tickets = sqliteTable("tickets", {
|
||||
createdAt: text("createdAt").notNull(),
|
||||
});
|
||||
|
||||
export const ticketReplies = sqliteTable("ticket_replies", {
|
||||
id: text("id").primaryKey(),
|
||||
ticketId: text("ticketId").notNull().references(() => tickets.id, { onDelete: "cascade" }),
|
||||
userId: text("userId").references(() => users.id, { onDelete: "set null" }),
|
||||
body: text("body").notNull(),
|
||||
// "user" = ticket owner or guest, "support" = another authenticated user
|
||||
authorRole: text("authorRole", { enum: ["user", "support"] }).notNull().default("user"),
|
||||
createdAt: text("createdAt").notNull(),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
data: text("data").notNull(),
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { Profanity } from "@2toad/profanity";
|
||||
|
||||
// ─── Profanity checker ────────────────────────────────────────────────────────
|
||||
|
||||
// Whole-word mode avoids false positives like "assassin", "classic", "scunthorpe"
|
||||
const profanity = new Profanity({ wholeWord: true });
|
||||
|
||||
// ─── PII redaction patterns ───────────────────────────────────────────────────
|
||||
// Inlined from source inspection of @redactpii/node — pure regex, no dependency needed.
|
||||
// Each entry defines what to match and what label to replace it with.
|
||||
|
||||
const PII_PATTERNS: Array<{ pattern: RegExp; label: string }> = [
|
||||
{
|
||||
// Standard email addresses
|
||||
@@ -67,6 +62,20 @@ export interface ContentFilterRejection {
|
||||
|
||||
export type ContentFilterOutcome = ContentFilterResult | ContentFilterRejection;
|
||||
|
||||
/**
|
||||
* Filters a single body of text (e.g. a reply). Rejects profanity and redacts PII.
|
||||
*/
|
||||
export function filterBody(body: string): ContentFilterOutcome {
|
||||
if (profanity.exists(body)) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "profanity",
|
||||
message: "Your reply contains language that is not allowed. Please revise it before submitting.",
|
||||
};
|
||||
}
|
||||
return { ok: true, subject: "", description: redactPII(body) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs subject and description through the content filter.
|
||||
*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { Ticket, TicketType } from "../types.ts";
|
||||
import { TICKET_LIMIT } from "../types.ts";
|
||||
import { filterContent } from "../middleware/contentFilter.ts";
|
||||
import { filterContent, filterBody } from "../middleware/contentFilter.ts";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -116,7 +116,67 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
|
||||
return ticket;
|
||||
});
|
||||
|
||||
// DELETE /api/tickets/:id — user may only delete their own tickets
|
||||
// GET /api/tickets/:id/replies
|
||||
app.get<{ Params: { id: string } }>(
|
||||
"/:id/replies",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const ticket = await req.storage.getTicket(req.params.id);
|
||||
if (!ticket) return reply.status(404).send({ error: "Not found" });
|
||||
const replies = await req.storage.getReplies(req.params.id);
|
||||
return replies;
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/tickets/:id/replies
|
||||
app.post<{
|
||||
Params: { id: string };
|
||||
Body: { body: string; asSupport?: boolean };
|
||||
}>("/:id/replies", { preHandler: requireAuth }, async (req, reply) => {
|
||||
const { body, asSupport = false } = req.body;
|
||||
if (!body?.trim()) {
|
||||
return reply.status(400).send({ error: "body is required" });
|
||||
}
|
||||
|
||||
const ticket = await req.storage.getTicket(req.params.id);
|
||||
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.",
|
||||
});
|
||||
}
|
||||
|
||||
// Determine role:
|
||||
// - Guest ticket (userId null): always "support" since only auth'd users can POST
|
||||
// - Owner replying from admin tab with asSupport flag: "support"
|
||||
// - Owner replying normally: "user"
|
||||
// - Non-owner: blocked by canModify on the frontend; defence-in-depth here returns 403
|
||||
const isOwner = ticket.userId === req.user!.id;
|
||||
if (!isOwner && ticket.userId !== null) {
|
||||
return reply.status(403).send({ error: "Forbidden" });
|
||||
}
|
||||
const authorRole: "user" | "support" =
|
||||
ticket.userId === null || asSupport ? "support" : "user";
|
||||
|
||||
const filtered = filterBody(body.trim());
|
||||
if (!filtered.ok) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ error: filtered.reason, message: filtered.message });
|
||||
}
|
||||
|
||||
const newReply = await req.storage.createReply({
|
||||
ticketId: req.params.id,
|
||||
body: filtered.description,
|
||||
userId: req.user!.id,
|
||||
authorRole,
|
||||
});
|
||||
return reply.status(201).send(newReply);
|
||||
});
|
||||
app.delete<{ Params: { id: string } }>(
|
||||
"/:id",
|
||||
{ preHandler: requireAuth },
|
||||
|
||||
@@ -38,6 +38,16 @@ export interface PaginatedTickets {
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Reply {
|
||||
id: string
|
||||
ticketId: string
|
||||
userId: string | null
|
||||
username: string | null
|
||||
body: string
|
||||
authorRole: 'user' | 'support'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface StorageAdapter {
|
||||
getTickets(): Promise<Ticket[]>
|
||||
getTicketsByUser(userId: string): Promise<Ticket[]>
|
||||
@@ -47,4 +57,6 @@ export interface StorageAdapter {
|
||||
createTicket(data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }): Promise<Ticket>
|
||||
updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null>
|
||||
deleteTicket(id: string): Promise<void>
|
||||
getReplies(ticketId: string): Promise<Reply[]>
|
||||
createReply(data: { ticketId: string; body: string; userId?: string; authorRole: Reply['authorRole'] }): Promise<Reply>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user