diff --git a/backend/drizzle/0005_flowery_northstar.sql b/backend/drizzle/0005_flowery_northstar.sql new file mode 100644 index 0000000..9b344a5 --- /dev/null +++ b/backend/drizzle/0005_flowery_northstar.sql @@ -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 +); diff --git a/backend/drizzle/meta/0005_snapshot.json b/backend/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..b9d4aef --- /dev/null +++ b/backend/drizzle/meta/0005_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index 6fe8d0f..7b675f7 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/backend/src/adapters/sqlite.ts b/backend/src/adapters/sqlite.ts index 0ac7460..83fc577 100644 --- a/backend/src/adapters/sqlite.ts +++ b/backend/src/adapters/sqlite.ts @@ -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 { await db.delete(tickets).where(eq(tickets.id, id)); } + + async getReplies(ticketId: string): Promise { + 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 { + 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 { diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index fa3ea2d..e029fc1 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -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 } }) diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 378e2a5..b7f30ae 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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(), diff --git a/backend/src/middleware/contentFilter.ts b/backend/src/middleware/contentFilter.ts index fb13de6..8c4072d 100644 --- a/backend/src/middleware/contentFilter.ts +++ b/backend/src/middleware/contentFilter.ts @@ -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. * diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 76311ea..dec3036 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -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 }, diff --git a/backend/src/types.ts b/backend/src/types.ts index a287cdd..ae65e0a 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -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 getTicketsByUser(userId: string): Promise @@ -47,4 +57,6 @@ export interface StorageAdapter { createTicket(data: Pick & { userId?: string }): Promise updateTicket(id: string, patch: Partial): Promise deleteTicket(id: string): Promise + getReplies(ticketId: string): Promise + createReply(data: { ticketId: string; body: string; userId?: string; authorRole: Reply['authorRole'] }): Promise } diff --git a/frontend/src/components/icons/circleArrow.tsx b/frontend/src/components/icons/circleArrow.tsx new file mode 100644 index 0000000..fdc4caf --- /dev/null +++ b/frontend/src/components/icons/circleArrow.tsx @@ -0,0 +1,8 @@ +import type { IconProps } from "../../lib/types.ts"; + +export const CircleArrowIcon = ({ className }: IconProps) => ( + +); diff --git a/frontend/src/components/icons/close.tsx b/frontend/src/components/icons/close.tsx new file mode 100644 index 0000000..6ed207b --- /dev/null +++ b/frontend/src/components/icons/close.tsx @@ -0,0 +1,7 @@ +import type { IconProps } from "../../lib/types.ts"; + +export const CloseIcon = ({ className }: IconProps) => ( + + + +); diff --git a/frontend/src/components/icons/trash.tsx b/frontend/src/components/icons/trash.tsx new file mode 100644 index 0000000..142d1e8 --- /dev/null +++ b/frontend/src/components/icons/trash.tsx @@ -0,0 +1,7 @@ +import type { IconProps } from "../../lib/types.ts"; + +export const TrashIcon = ({ className }: IconProps) => ( + +); diff --git a/frontend/src/components/tickets/TicketDetail.tsx b/frontend/src/components/tickets/TicketDetail.tsx index 9da77a6..b63108d 100644 --- a/frontend/src/components/tickets/TicketDetail.tsx +++ b/frontend/src/components/tickets/TicketDetail.tsx @@ -1,8 +1,13 @@ import { useState, useRef, useEffect } from 'react' import { Badge } from '../ui/Badge.tsx' +import { Button } from '../ui/Button.tsx' import { FAKE_TRANSACTIONS } from './NewTicketForm.tsx' import { parseDescription } from '../../lib/ticket.ts' -import type { Ticket } from '../../lib/types.ts' +import { storage } from '../../lib/storage.ts' +import type { Ticket, Reply } from '../../lib/types.ts' +import { CloseIcon } from '../icons/close.tsx' +import { TrashIcon } from '../icons/trash.tsx' +import { CircleArrowIcon } from '../icons/circleArrow.tsx' function formatDate(iso: string): string { return new Date(iso).toLocaleDateString('en-US', { @@ -10,16 +15,22 @@ function formatDate(iso: string): string { }) } -const TYPE_LABELS: Record = { - 'bug': 'Bug', - 'billing': 'Billing', - 'account': 'Account', - 'feature-request':'Feature Request', - 'feedback': 'Feedback', - 'other': 'Other', +function formatTime(iso: string): string { + return new Date(iso).toLocaleString('en-US', { + month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', + }) } -const HOLD_DURATION = 2000 // ms +const TYPE_LABELS: Record = { + 'bug': 'Bug', + 'billing': 'Billing', + 'account': 'Account', + 'feature-request': 'Feature Request', + 'feedback': 'Feedback', + 'other': 'Other', +} + +const HOLD_DURATION = 900 // ms interface HoldButtonProps { onComplete: () => Promise @@ -61,16 +72,13 @@ function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: Hol rafRef.current = requestAnimationFrame(tick) } - // Clean up on unmount useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }, []) - // SVG arc helpers const size = 28 const stroke = 2.5 const r = (size - stroke) / 2 const circ = 2 * Math.PI * r const dash = progress * circ - const isHolding = progress > 0 && progress < 1 return ( @@ -93,32 +101,13 @@ function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: Hol `} aria-label={ariaLabel} > - {/* Progress ring */} - {/* Track */} - {/* Icon */} {icon} - {completing ? completingLabel : isHolding ? 'Keep holding…' : label} @@ -126,46 +115,131 @@ function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: Hol ) } -// Close icon (×) -const CloseIcon = ( - -) +function ReplyBubble({ reply }: { reply: Reply }) { + const isSupport = reply.authorRole === 'support' + return ( +
+
+

{reply.body}

+
+

+ {isSupport ? 'Support' : (reply.username ?? 'You')} · {formatTime(reply.createdAt)} +

+
+ ) +} -// Delete icon (trash) -const DeleteIcon = ( - -) +interface ReplyComposerProps { + onSend: (body: string) => Promise + disabled?: boolean +} + +function ReplyComposer({ onSend, disabled }: ReplyComposerProps) { + const [body, setBody] = useState('') + const [sending, setSending] = useState(false) + const [error, setError] = useState(null) + const textareaRef = useRef(null) + + const handleSend = async () => { + if (!body.trim() || sending) return + setSending(true) + setError(null) + try { + await onSend(body.trim()) + setBody('') + textareaRef.current?.focus() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to send reply.') + } finally { + setSending(false) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleSend() + } + + return ( +
+ {error && ( +

{error}

+ )} +