add:reply system

This commit is contained in:
2026-03-09 23:11:00 +09:00
parent 3c28c117a0
commit 2a81ede504
18 changed files with 772 additions and 167 deletions

View File

@@ -0,0 +1,10 @@
CREATE TABLE `ticket_replies` (
`id` text PRIMARY KEY NOT NULL,
`ticketId` text NOT NULL,
`userId` text,
`body` text NOT NULL,
`authorRole` text DEFAULT 'user' NOT NULL,
`createdAt` text NOT NULL,
FOREIGN KEY (`ticketId`) REFERENCES `tickets`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);

View File

@@ -0,0 +1,265 @@
{
"version": "6",
"dialect": "sqlite",
"id": "02c5b400-fb4d-49b1-b83f-11db254a496e",
"prevId": "e6fe4296-6abf-4980-88a2-604afe8a49ba",
"tables": {
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"data": {
"name": "data",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ticket_replies": {
"name": "ticket_replies",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"ticketId": {
"name": "ticketId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"authorRole": {
"name": "authorRole",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"ticket_replies_ticketId_tickets_id_fk": {
"name": "ticket_replies_ticketId_tickets_id_fk",
"tableFrom": "ticket_replies",
"tableTo": "tickets",
"columnsFrom": [
"ticketId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"ticket_replies_userId_users_id_fk": {
"name": "ticket_replies_userId_users_id_fk",
"tableFrom": "ticket_replies",
"tableTo": "users",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tickets": {
"name": "tickets",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'other'"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'open'"
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"tickets_userId_users_id_fk": {
"name": "tickets_userId_users_id_fk",
"tableFrom": "tickets",
"tableTo": "users",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"googleId": {
"name": "googleId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"avatarUrl": {
"name": "avatarUrl",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"users_googleId_unique": {
"name": "users_googleId_unique",
"columns": [
"googleId"
],
"isUnique": true
},
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -36,6 +36,13 @@
"when": 1773040371683,
"tag": "0004_orange_katie_power",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1773063585033,
"tag": "0005_flowery_northstar",
"breakpoints": true
}
]
}

View File

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

View File

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

View File

@@ -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(),

View File

@@ -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.
*

View File

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

View File

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