diff --git a/backend/src/adapters/sqlite.ts b/backend/src/adapters/sqlite.ts index a3cf7db..0ac7460 100644 --- a/backend/src/adapters/sqlite.ts +++ b/backend/src/adapters/sqlite.ts @@ -1,62 +1,147 @@ -import { eq, count } from 'drizzle-orm' -import { db } from '../db/index.ts' -import { tickets } from '../db/schema.ts' -import type { StorageAdapter, Ticket, TicketType } from '../types.ts' +import { eq, count, desc, and, type SQL } from "drizzle-orm"; +import { db } from "../db/index.ts"; +import { tickets, users } from "../db/schema.ts"; +import type { + StorageAdapter, + Ticket, + TicketType, + PaginatedTickets, + TicketFilters, +} from "../types.ts"; + +// Explicit column selection shared by all ticket queries +const ticketSelect = { + id: tickets.id, + userId: tickets.userId, + subject: tickets.subject, + description: tickets.description, + type: tickets.type, + status: tickets.status, + createdAt: tickets.createdAt, + username: users.username, +}; + +// Let TypeScript infer the row type directly from the select shape +type TicketRow = { + id: string; + userId: string | null; + subject: string; + description: string; + type: string; + status: string; + createdAt: string; + username: string | null; +}; export class SQLiteAdapter implements StorageAdapter { async getTickets(): Promise { - const rows = await db.select().from(tickets).orderBy(tickets.createdAt) - return rows.map(toTicket).reverse() + const rows = await db + .select(ticketSelect) + .from(tickets) + .leftJoin(users, eq(tickets.userId, users.id)) + .orderBy(desc(tickets.createdAt)); + return rows.map(toTicket); + } + + async getTicketsByUser(userId: string): Promise { + const rows = await db + .select(ticketSelect) + .from(tickets) + .leftJoin(users, eq(tickets.userId, users.id)) + .where(eq(tickets.userId, userId)) + .orderBy(desc(tickets.createdAt)); + return rows.map(toTicket); + } + + async getTicketsPaginated( + limit: number, + offset: number, + filters: TicketFilters = {}, + ): Promise { + const conditions: SQL[] = []; + if (filters.status) conditions.push(eq(tickets.status, filters.status)); + if (filters.type) conditions.push(eq(tickets.type, filters.type)); + if (filters.userId) conditions.push(eq(tickets.userId, filters.userId)); + + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const [rows, totalResult] = await Promise.all([ + db + .select(ticketSelect) + .from(tickets) + .leftJoin(users, eq(tickets.userId, users.id)) + .where(where) + .orderBy(desc(tickets.createdAt)) + .limit(limit) + .offset(offset), + db.select({ count: count() }).from(tickets).where(where), + ]); + return { + data: rows.map(toTicket), + total: totalResult[0]?.count ?? 0, + }; } async getTicket(id: string): Promise { - const rows = await db.select().from(tickets).where(eq(tickets.id, id)) - return rows[0] ? toTicket(rows[0]) : null + const rows = await db + .select(ticketSelect) + .from(tickets) + .leftJoin(users, eq(tickets.userId, users.id)) + .where(eq(tickets.id, id)); + return rows[0] ? toTicket(rows[0]) : null; } async countTicketsByUser(userId: string): Promise { const result = await db .select({ count: count() }) .from(tickets) - .where(eq(tickets.userId, userId)) - return result[0]?.count ?? 0 + .where(eq(tickets.userId, userId)); + return result[0]?.count ?? 0; } async createTicket( - data: Pick & { userId?: string } + data: Pick & { + userId?: string; + }, ): Promise { - const id = crypto.randomUUID() - const now = new Date().toISOString() + const id = crypto.randomUUID(); + const now = new Date().toISOString(); await db.insert(tickets).values({ id, userId: data.userId ?? null, subject: data.subject, description: data.description, type: data.type, - status: 'open', + status: "open", createdAt: now, - }) - return (await this.getTicket(id))! + }); + return (await this.getTicket(id))!; } - async updateTicket(id: string, patch: Partial): Promise { - await db.update(tickets).set(patch).where(eq(tickets.id, id)) - return this.getTicket(id) + async updateTicket( + id: string, + patch: Partial, + ): Promise { + // Strip username — it's a derived field from the join, not a column + const { username: _, ...columnPatch } = patch as Ticket; + await db.update(tickets).set(columnPatch).where(eq(tickets.id, id)); + return this.getTicket(id); } async deleteTicket(id: string): Promise { - await db.delete(tickets).where(eq(tickets.id, id)) + await db.delete(tickets).where(eq(tickets.id, id)); } } -function toTicket(row: typeof tickets.$inferSelect): Ticket { +function toTicket(row: TicketRow): Ticket { return { id: row.id, userId: row.userId, + username: row.username ?? null, subject: row.subject, description: row.description, type: row.type as TicketType, - status: row.status as Ticket['status'], + status: row.status as Ticket["status"], createdAt: row.createdAt, - } + }; } diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 8818a46..8891571 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -2,6 +2,8 @@ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; import type { Ticket, TicketType } from "../types.ts"; import { TICKET_LIMIT } from "../types.ts"; +const PAGE_SIZE = 20; + async function requireAuth(req: FastifyRequest, reply: FastifyReply) { if (!req.isAuthenticated) { return reply.status(401).send({ error: "Unauthorized" }); @@ -9,14 +11,42 @@ async function requireAuth(req: FastifyRequest, reply: FastifyReply) { } export const ticketsRouter: FastifyPluginAsync = async (app) => { - // GET /api/tickets/all — admin view, returns all tickets in the system - app.get("/all", { preHandler: requireAuth }, async (req) => { - return req.storage.getTickets(); + // GET /api/tickets/all — admin view, paginated with optional filters + app.get<{ + Querystring: { + page?: string; + status?: Ticket["status"]; + type?: TicketType; + mine?: string; // "true" to restrict to the requesting user's tickets + }; + }>("/all", { preHandler: requireAuth }, async (req) => { + const page = Math.max(1, parseInt(req.query.page ?? "1", 10) || 1); + const offset = (page - 1) * PAGE_SIZE; + + const filters = { + status: req.query.status, + type: req.query.type, + // If mine=true, scope to the current user's tickets + userId: req.query.mine === "true" ? req.user!.id : undefined, + }; + + const result = await req.storage.getTicketsPaginated( + PAGE_SIZE, + offset, + filters, + ); + return { + data: result.data, + total: result.total, + page, + pageSize: PAGE_SIZE, + totalPages: Math.max(1, Math.ceil(result.total / PAGE_SIZE)), + }; }); - // GET /api/tickets + // GET /api/tickets — returns only the current user's tickets app.get("/", { preHandler: requireAuth }, async (req) => { - return req.storage.getTickets(); + return req.storage.getTicketsByUser(req.user!.id); }); // GET /api/tickets/:id @@ -61,21 +91,31 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => { return reply.status(201).send(ticket); }); - // PATCH /api/tickets/:id + // PATCH /api/tickets/:id — user may only update their own tickets app.patch<{ Params: { id: string }; Body: Partial; }>("/:id", { preHandler: requireAuth }, async (req, reply) => { + const existing = await req.storage.getTicket(req.params.id); + if (!existing) return reply.status(404).send({ error: "Not found" }); + if (existing.userId !== req.user!.id) { + return reply.status(403).send({ error: "Forbidden" }); + } const ticket = await req.storage.updateTicket(req.params.id, req.body); if (!ticket) return reply.status(404).send({ error: "Not found" }); return ticket; }); - // DELETE /api/tickets/:id + // DELETE /api/tickets/:id — user may only delete their own tickets app.delete<{ Params: { id: string } }>( "/:id", { preHandler: requireAuth }, async (req, reply) => { + const existing = await req.storage.getTicket(req.params.id); + if (!existing) return reply.status(404).send({ error: "Not found" }); + if (existing.userId !== req.user!.id) { + return reply.status(403).send({ error: "Forbidden" }); + } await req.storage.deleteTicket(req.params.id); return reply.status(204).send(); }, diff --git a/backend/src/types.ts b/backend/src/types.ts index 610dbef..a22a100 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -17,6 +17,7 @@ export type TicketType = export interface Ticket { id: string userId: string | null + username: string | null subject: string description: string type: TicketType @@ -26,8 +27,21 @@ export interface Ticket { export const TICKET_LIMIT = 3 +export interface TicketFilters { + status?: Ticket['status'] + type?: TicketType + userId?: string +} + +export interface PaginatedTickets { + data: Ticket[] + total: number +} + export interface StorageAdapter { getTickets(): Promise + getTicketsByUser(userId: string): Promise + getTicketsPaginated(limit: number, offset: number, filters?: TicketFilters): Promise getTicket(id: string): Promise countTicketsByUser(userId: string): Promise createTicket(data: Pick & { userId?: string }): Promise diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ba0fc13..a9f01cb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,7 +56,7 @@ function SupportApp() { > {activeTab === 'tickets' && } - {activeTab === 'admin' && } + {activeTab === 'admin' && } ) } diff --git a/frontend/src/components/admin/AdminTable.tsx b/frontend/src/components/admin/AdminTable.tsx index 63fc512..2f29735 100644 --- a/frontend/src/components/admin/AdminTable.tsx +++ b/frontend/src/components/admin/AdminTable.tsx @@ -1,4 +1,5 @@ import { Badge } from '../ui/Badge.tsx' +import { Button } from '../ui/Button.tsx' import { parseDescription } from '../../lib/ticket.ts' import type { Ticket } from '../../lib/types.ts' @@ -8,12 +9,28 @@ function formatDate(iso: string): string { }) } -interface AdminTableProps { - tickets: Ticket[] +interface PaginationProps { + page: number + totalPages: number + total: number + pageSize: number + onPrev: () => void + onNext: () => void } -export function AdminTable({ tickets }: AdminTableProps) { - if (tickets.length === 0) { +interface AdminTableProps { + tickets: Ticket[] + onOpen: (ticket: Ticket) => void + currentUserId: string | null + pagination: PaginationProps +} + +export function AdminTable({ tickets, onOpen, currentUserId, pagination }: AdminTableProps) { + const { page, totalPages, total, pageSize, onPrev, onNext } = pagination + const start = (page - 1) * pageSize + 1 + const end = Math.min(page * pageSize, total) + + if (tickets.length === 0 && total === 0) { return (

No tickets in the system.

@@ -28,7 +45,7 @@ export function AdminTable({ tickets }: AdminTableProps) { - {(['Subject', 'Type', 'Status', ...(hasBilling ? ['Transaction'] : []), 'Description', 'Created'] as const).map(col => ( + {(['Subject', 'User', 'Type', 'Status', ...(hasBilling ? ['Transaction'] : []), 'Description', 'Created'] as const).map(col => ( @@ -44,9 +62,23 @@ export function AdminTable({ tickets }: AdminTableProps) { const hasTxn = ticket.type === 'billing' && txnId !== null return ( - + onOpen(ticket)} + > + + ) })}
))} +
- {ticket.subject} +
+ {ticket.subject} + {currentUserId && ticket.userId === currentUserId && ( + + mine + + )} +
+
+ {ticket.username ?? guest} {ticket.type.replace('-', ' ')} @@ -73,11 +105,43 @@ export function AdminTable({ tickets }: AdminTableProps) { {formatDate(ticket.createdAt)} + +
+ + {/* Pagination footer */} +
+

+ {total === 0 ? 'No tickets' : `${start}–${end} of ${total}`} +

+
+ + + {page} / {totalPages} + + +
+
) } diff --git a/frontend/src/components/tickets/TicketDetail.tsx b/frontend/src/components/tickets/TicketDetail.tsx index c245e32..9da77a6 100644 --- a/frontend/src/components/tickets/TicketDetail.tsx +++ b/frontend/src/components/tickets/TicketDetail.tsx @@ -21,11 +21,15 @@ const TYPE_LABELS: Record = { const HOLD_DURATION = 2000 // ms -interface HoldToCloseProps { +interface HoldButtonProps { onComplete: () => Promise + label: string + completingLabel: string + icon: React.ReactNode + ariaLabel: string } -function HoldToClose({ onComplete }: HoldToCloseProps) { +function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: HoldButtonProps) { const [progress, setProgress] = useState(0) // 0–1 const [completing, setCompleting] = useState(false) const rafRef = useRef(null) @@ -87,7 +91,7 @@ function HoldToClose({ onComplete }: HoldToCloseProps) { : 'text-fg-300 hover:text-fg-100 hover:bg-bg-300' } `} - aria-label="Hold to close ticket" + aria-label={ariaLabel} > {/* Progress ring */} @@ -112,27 +116,50 @@ function HoldToClose({ onComplete }: HoldToCloseProps) { /> {/* Icon */} - + {icon} - {completing ? 'Closing…' : isHolding ? 'Keep holding…' : 'Hold to close'} + {completing ? completingLabel : isHolding ? 'Keep holding…' : label} ) } +// Close icon (×) +const CloseIcon = ( + +) + +// Delete icon (trash) +const DeleteIcon = ( + +) + +// Reopen icon (arrow rotating back) +const ReopenIcon = ( + +) + interface TicketDetailProps { ticket: Ticket - onCloseTicket: (id: string) => Promise + onCloseTicket?: (id: string) => Promise + onDeleteTicket?: (id: string) => Promise + onReopenTicket?: (id: string) => Promise } -export function TicketDetail({ ticket, onCloseTicket }: TicketDetailProps) { +export function TicketDetail({ ticket, onCloseTicket, onDeleteTicket, onReopenTicket }: TicketDetailProps) { const { txnId, body } = parseDescription(ticket.description) const txn = txnId ? FAKE_TRANSACTIONS.find(t => t.id === txnId) ?? null : null const isClosed = ticket.status === 'closed' + const hasAnyAction = onCloseTicket || onReopenTicket || onDeleteTicket return (
@@ -170,14 +197,46 @@ export function TicketDetail({ ticket, onCloseTicket }: TicketDetailProps) { )}
- {/* Footer: ticket ID + close action */} + {/* Footer: ticket ID + actions */}

ID: {ticket.id}

- {isClosed ? ( - This ticket is closed. - ) : ( - onCloseTicket(ticket.id)} /> - )} +
+ {!hasAnyAction && ( + Read only + )} + {isClosed ? ( + onReopenTicket ? ( + onReopenTicket(ticket.id)} + label="Hold to reopen" + completingLabel="Reopening…" + icon={ReopenIcon} + ariaLabel="Hold to reopen ticket" + /> + ) : onCloseTicket ? ( + This ticket is closed. + ) : null + ) : ( + onCloseTicket && ( + onCloseTicket(ticket.id)} + label="Hold to close" + completingLabel="Closing…" + icon={CloseIcon} + ariaLabel="Hold to close ticket" + /> + ) + )} + {onDeleteTicket && ( + onDeleteTicket(ticket.id)} + label="Hold to delete" + completingLabel="Deleting…" + icon={DeleteIcon} + ariaLabel="Hold to delete ticket" + /> + )} +
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 4f9ffc3..f1a7133 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -66,6 +66,22 @@ export const localAdapter = { }, } +// ─── Paginated response envelope ───────────────────────────────────────────── + +export interface PaginatedResponse { + data: T[] + total: number + page: number + pageSize: number + totalPages: number +} + +export interface TicketFilters { + status?: Ticket['status'] + type?: TicketType + mine?: boolean // restrict to the current user's tickets +} + // ─── Storage API ────────────────────────────────────────────────────────────── export const storage = { @@ -78,13 +94,44 @@ export const storage = { } }, - // Admin view — all DB tickets when authenticated, localStorage when guest - async getAllTickets(isAuthenticated: boolean): Promise { - if (!isAuthenticated) return localAdapter.getTickets() + // Admin view — paginated from API when authenticated, sliced localStorage when guest + async getAllTickets( + isAuthenticated: boolean, + page = 1, + pageSize = 20, + filters: TicketFilters = {}, + ): Promise> { + if (!isAuthenticated) { + let all = localAdapter.getTickets() + if (filters.status) all = all.filter(t => t.status === filters.status) + if (filters.type) all = all.filter(t => t.type === filters.type) + const start = (page - 1) * pageSize + return { + data: all.slice(start, start + pageSize), + total: all.length, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(all.length / pageSize)), + } + } + + const params = new URLSearchParams({ page: String(page) }) + if (filters.status) params.set('status', filters.status) + if (filters.type) params.set('type', filters.type) + if (filters.mine) params.set('mine', 'true') + try { - return await apiFetch('/api/tickets/all') + return await apiFetch>(`/api/tickets/all?${params}`) } catch { - return localAdapter.getTickets() + const all = localAdapter.getTickets() + const start = (page - 1) * pageSize + return { + data: all.slice(start, start + pageSize), + total: all.length, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(all.length / pageSize)), + } } }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 85f61a1..e746241 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -9,6 +9,7 @@ export type TicketType = export interface Ticket { id: string userId: string | null + username: string | null subject: string description: string type: TicketType diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index b552695..1b8f73f 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -1,14 +1,15 @@ import { useState, useEffect } from 'react' import { AdminTable } from '../components/admin/AdminTable.tsx' +import { TicketDetail } from '../components/tickets/TicketDetail.tsx' +import { Modal } from '../components/ui/Modal.tsx' import { storage } from '../lib/storage.ts' -import type { Ticket } from '../lib/types.ts' +import type { PaginatedResponse, TicketFilters } from '../lib/storage.ts' +import { useModal } from '../hooks/useModal.ts' +import type { Ticket, User } from '../lib/types.ts' -interface StatCardProps { - label: string - value: number -} +// ─── Stat card ──────────────────────────────────────────────────────────────── -function StatCard({ label, value }: StatCardProps) { +function StatCard({ label, value }: { label: string; value: number }) { return (

{label}

@@ -17,24 +18,194 @@ function StatCard({ label, value }: StatCardProps) { ) } -interface AdminPageProps { +// ─── Filter bar ─────────────────────────────────────────────────────────────── + +const STATUS_OPTIONS: { value: Ticket['status'] | ''; label: string }[] = [ + { value: '', label: 'All statuses' }, + { value: 'open', label: 'Open' }, + { value: 'in-progress', label: 'In progress' }, + { value: 'resolved', label: 'Resolved' }, + { value: 'closed', label: 'Closed' }, +] + +const TYPE_OPTIONS: { value: Ticket['type'] | ''; label: string }[] = [ + { value: '', label: 'All types' }, + { value: 'bug', label: 'Bug' }, + { value: 'billing', label: 'Billing' }, + { value: 'account', label: 'Account' }, + { value: 'feature-request', label: 'Feature request' }, + { value: 'feedback', label: 'Feedback' }, + { value: 'other', label: 'Other' }, +] + +const selectClass = ` + rounded-md border border-border-100 bg-bg-200 px-3 py-1.5 text-xs text-fg-100 + outline-none transition-colors cursor-pointer appearance-none pr-7 + hover:border-border-200 focus:border-border-200 focus:ring-1 focus:ring-ring-100 +` + +interface FilterBarProps { + filters: TicketFilters isAuthenticated: boolean + onChange: (f: TicketFilters) => void } -export function AdminPage({ isAuthenticated }: AdminPageProps) { - const [tickets, setTickets] = useState([]) +function FilterBar({ filters, isAuthenticated, onChange }: FilterBarProps) { + const hasActive = !!(filters.status || filters.type || filters.mine) + + return ( +
+ {/* Status */} +
+ + +
+ + {/* Type */} +
+ + +
+ + {/* Mine toggle — only visible when authenticated */} + {isAuthenticated && ( + + )} + + {/* Clear */} + {hasActive && ( + + )} +
+ ) +} + +function ChevronIcon() { + return ( + + ) +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +interface AdminPageProps { + isAuthenticated: boolean + user: User | null +} + +const EMPTY_PAGE: PaginatedResponse = { + data: [], total: 0, page: 1, pageSize: 20, totalPages: 1, +} + +export function AdminPage({ isAuthenticated, user }: AdminPageProps) { + const [result, setResult] = useState>(EMPTY_PAGE) + const [page, setPage] = useState(1) + const [filters, setFilters] = useState({}) + const [selectedTicket, setSelectedTicket] = useState(null) + const detailModal = useModal() useEffect(() => { - storage.getAllTickets(isAuthenticated).then(setTickets) - }, [isAuthenticated]) + storage.getAllTickets(isAuthenticated, page, 20, filters).then(setResult) + }, [isAuthenticated, page, filters]) + + const handleFilterChange = (next: TicketFilters) => { + setFilters(next) + setPage(1) // reset to first page on filter change + } const stats = { - total: tickets.length, - open: tickets.filter(t => t.status === 'open').length, - inProgress: tickets.filter(t => t.status === 'in-progress').length, - resolved: tickets.filter(t => t.status === 'resolved').length, + total: result.total, + open: result.data.filter(t => t.status === 'open').length, + inProgress: result.data.filter(t => t.status === 'in-progress').length, + resolved: result.data.filter(t => t.status === 'resolved').length, } + const handleOpen = (ticket: Ticket) => { + setSelectedTicket(ticket) + detailModal.open() + } + + const handleDetailClose = () => { + detailModal.close() + setSelectedTicket(null) + } + + const refetch = async () => { + const fresh = await storage.getAllTickets(isAuthenticated, page, 20, filters) + if (fresh.data.length === 0 && page > 1) { + setPage(p => p - 1) + } else { + setResult(fresh) + } + } + + const handleCloseTicket = async (id: string) => { + const updated = await storage.updateTicket(id, { status: 'closed' }) + if (updated) { + setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) })) + setSelectedTicket(updated) + } + } + + const handleReopenTicket = async (id: string) => { + const updated = await storage.updateTicket(id, { status: 'open' }) + if (updated) { + setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) })) + setSelectedTicket(updated) + } + } + + const handleDeleteTicket = async (id: string) => { + await storage.deleteTicket(id) + handleDetailClose() + await refetch() + } + + // A ticket is owned by the current user if their IDs match. + // Unauthenticated (guest) tickets have userId: null — those are always editable locally. + const canModify = (ticket: Ticket) => + !isAuthenticated || (user !== null && ticket.userId === user.id) + return ( <>
@@ -45,13 +216,46 @@ export function AdminPage({ isAuthenticated }: AdminPageProps) {
- - + + - +
- + + + setPage(p => p - 1), + onNext: () => setPage(p => p + 1), + }} + /> + + + {selectedTicket && ( + + )} + ) }