add:reply system
This commit is contained in:
10
backend/drizzle/0005_flowery_northstar.sql
Normal file
10
backend/drizzle/0005_flowery_northstar.sql
Normal 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
|
||||
);
|
||||
265
backend/drizzle/meta/0005_snapshot.json
Normal file
265
backend/drizzle/meta/0005_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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