From 2bfd94e3581f647aee8eb4a4f32812e6f98642da Mon Sep 17 00:00:00 2001 From: kokopi Date: Mon, 9 Mar 2026 16:14:25 +0900 Subject: [PATCH] add:type assigner --- backend/drizzle/0004_orange_katie_power.sql | 1 + backend/drizzle/meta/0004_snapshot.json | 185 +++++++++ backend/drizzle/meta/_journal.json | 7 + backend/src/adapters/sqlite.ts | 10 +- backend/src/db/index.ts | 4 +- backend/src/db/schema.ts | 36 +- backend/src/routes/auth.ts | 1 - backend/src/routes/tickets.ts | 20 + backend/src/types.ts | 3 + frontend/src/components/admin/AdminTable.tsx | 70 ++-- .../src/components/tickets/NewTicketForm.tsx | 371 ++++++++++++++++-- frontend/src/components/ui/Modal.tsx | 2 +- frontend/src/hooks/useStorageMode.ts | 2 +- frontend/src/pages/LoginPage.tsx | 2 +- frontend/src/pages/UserPage.tsx | 99 ++++- 15 files changed, 715 insertions(+), 98 deletions(-) create mode 100644 backend/drizzle/0004_orange_katie_power.sql create mode 100644 backend/drizzle/meta/0004_snapshot.json diff --git a/backend/drizzle/0004_orange_katie_power.sql b/backend/drizzle/0004_orange_katie_power.sql new file mode 100644 index 0000000..7b7dd69 --- /dev/null +++ b/backend/drizzle/0004_orange_katie_power.sql @@ -0,0 +1 @@ +ALTER TABLE `tickets` ADD `userId` text REFERENCES users(id); \ No newline at end of file diff --git a/backend/drizzle/meta/0004_snapshot.json b/backend/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..8ca105e --- /dev/null +++ b/backend/drizzle/meta/0004_snapshot.json @@ -0,0 +1,185 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "e6fe4296-6abf-4980-88a2-604afe8a49ba", + "prevId": "16b0dd57-a0de-4237-813b-455b4b8c0157", + "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": {} + }, + "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 f2ca773..6fe8d0f 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1773034525409, "tag": "0003_bitter_ink", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1773040371683, + "tag": "0004_orange_katie_power", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/adapters/sqlite.ts b/backend/src/adapters/sqlite.ts index 704ce99..a3cf7db 100644 --- a/backend/src/adapters/sqlite.ts +++ b/backend/src/adapters/sqlite.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm' +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' @@ -14,6 +14,14 @@ export class SQLiteAdapter implements StorageAdapter { 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 + } + async createTicket( data: Pick & { userId?: string } ): Promise { diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 7baa091..fa3ea2d 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 } from './schema.ts' +import { tickets, users, sessions } from './schema.ts' const sqlite = new Database('app.db') -export const db = drizzle(sqlite, { schema: { tickets } }) +export const db = drizzle(sqlite, { schema: { tickets, users, sessions } }) diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 2ebe7dc..378e2a5 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -1,18 +1,20 @@ import { int, text, sqliteTable } from "drizzle-orm/sqlite-core"; +export const users = sqliteTable("users", { + id: text("id").primaryKey(), // internal UUID + googleId: text("googleId").notNull().unique(), // Google's `sub` claim + username: text("username").notNull().unique(), // generated: "silent-crimson-falcon" + avatarUrl: text("avatarUrl"), // Google profile picture + createdAt: text("createdAt").notNull(), +}); + export const tickets = sqliteTable("tickets", { id: text("id").primaryKey(), + userId: text("userId").references(() => users.id, { onDelete: "set null" }), subject: text("subject").notNull(), description: text("description").notNull().default(""), type: text("type", { - enum: [ - "bug", - "billing", - "account", - "feature-request", - "feedback", - "other", - ], + enum: ["bug", "billing", "account", "feature-request", "feedback", "other"], }) .notNull() .default("other"), @@ -24,16 +26,8 @@ export const tickets = sqliteTable("tickets", { createdAt: text("createdAt").notNull(), }); -export const users = sqliteTable('users', { - id: text('id').primaryKey(), // internal UUID - googleId: text('googleId').notNull().unique(), // Google's `sub` claim - username: text('username').notNull().unique(), // generated: "silent-crimson-falcon" - avatarUrl: text('avatarUrl'), // Google profile picture - createdAt: text('createdAt').notNull(), -}) - -export const sessions = sqliteTable('sessions', { - id: text('id').primaryKey(), - data: text('data').notNull(), - expiresAt: int('expires_at').notNull(), // unix ms -}) +export const sessions = sqliteTable("sessions", { + id: text("id").primaryKey(), + data: text("data").notNull(), + expiresAt: int("expires_at").notNull(), // unix ms +}); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index d53f8e7..38ac34e 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,4 +1,3 @@ -import fp from "fastify-plugin"; import type { FastifyPluginAsync } from "fastify"; import { OAuth2Client } from "google-auth-library"; import { diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 0a92237..8818a46 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1,5 +1,6 @@ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; import type { Ticket, TicketType } from "../types.ts"; +import { TICKET_LIMIT } from "../types.ts"; async function requireAuth(req: FastifyRequest, reply: FastifyReply) { if (!req.isAuthenticated) { @@ -8,6 +9,11 @@ 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 app.get("/", { preHandler: requireAuth }, async (req) => { return req.storage.getTickets(); @@ -32,6 +38,20 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => { if (!subject?.trim()) { return reply.status(400).send({ error: "subject is required" }); } + + // Enforce per-user ticket limit + if (req.user?.id) { + const userTicketCount = await req.storage.countTicketsByUser(req.user.id); + if (userTicketCount >= TICKET_LIMIT) { + return reply.status(429).send({ + error: "ticket_limit_reached", + message: `You have reached the maximum of ${TICKET_LIMIT} support tickets. Please delete some from the admin view before creating new ones.`, + limit: TICKET_LIMIT, + current: userTicketCount, + }); + } + } + const ticket = await req.storage.createTicket({ subject: subject.trim(), description, diff --git a/backend/src/types.ts b/backend/src/types.ts index 8bc3c75..610dbef 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -24,9 +24,12 @@ export interface Ticket { createdAt: string } +export const TICKET_LIMIT = 3 + export interface StorageAdapter { getTickets(): Promise getTicket(id: string): Promise + countTicketsByUser(userId: string): Promise createTicket(data: Pick & { userId?: string }): Promise updateTicket(id: string, patch: Partial): Promise deleteTicket(id: string): Promise diff --git a/frontend/src/components/admin/AdminTable.tsx b/frontend/src/components/admin/AdminTable.tsx index 44e745b..0ec3771 100644 --- a/frontend/src/components/admin/AdminTable.tsx +++ b/frontend/src/components/admin/AdminTable.tsx @@ -7,6 +7,14 @@ function formatDate(iso: string): string { }) } +// Parse transaction reference encoded by NewTicketForm into the description +// Format: "[Transaction: TXN-XXXXX — Label $X.XX on Date]\n\n..." +function parseTransaction(description: string): { txnLine: string; body: string } | null { + const match = description.match(/^\[Transaction: ([^\]]+)\]\n?\n?(.*)$/s) + if (!match) return null + return { txnLine: match[1].trim(), body: match[2].trim() } +} + interface AdminTableProps { tickets: Ticket[] } @@ -20,12 +28,14 @@ export function AdminTable({ tickets }: AdminTableProps) { ) } + const hasBilling = tickets.some(t => t.type === 'billing') + return (
- {(['Subject', 'Type', 'Status', 'Description', 'Created'] as const).map(col => ( + {(['Subject', 'Type', 'Status', ...(hasBilling ? ['Transaction'] : []), 'Description', 'Created'] as const).map(col => ( - {tickets.map(ticket => ( - - - - - - - - ))} + {tickets.map(ticket => { + const txn = ticket.type === 'billing' ? parseTransaction(ticket.description) : null + const displayDescription = txn ? txn.body : ticket.description + + return ( + + + + + {hasBilling && ( + + )} + + + + ) + })}
- {ticket.subject} - - {ticket.type.replace('-', ' ')} - - - - - {ticket.description || No description} - - - {formatDate(ticket.createdAt)} -
+ {ticket.subject} + + {ticket.type.replace('-', ' ')} + + + + {txn ? ( + + {txn.txnLine.split(' — ')[0]} + + ) : ( + + )} + + + {displayDescription || No description} + + + {formatDate(ticket.createdAt)} +
diff --git a/frontend/src/components/tickets/NewTicketForm.tsx b/frontend/src/components/tickets/NewTicketForm.tsx index 26a3f0d..26122f5 100644 --- a/frontend/src/components/tickets/NewTicketForm.tsx +++ b/frontend/src/components/tickets/NewTicketForm.tsx @@ -2,20 +2,139 @@ import { useState } from 'react' import { Button } from '../ui/Button.tsx' import type { Ticket, TicketType } from '../../lib/types.ts' -const TICKET_TYPES: { value: TicketType; label: string }[] = [ - { 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' }, +// ─── Fake transactions ──────────────────────────────────────────────────────── + +export interface FakeTransaction { + id: string + label: string + amount: string + date: string +} + +export const FAKE_TRANSACTIONS: FakeTransaction[] = [ + { id: 'TXN-48291', label: 'Pro Plan — Monthly', amount: '$12.00', date: 'Mar 1, 2026' }, + { id: 'TXN-47103', label: 'Add-on: Extra Storage', amount: '$4.99', date: 'Feb 15, 2026' }, + { id: 'TXN-45882', label: 'Pro Plan — Monthly', amount: '$12.00', date: 'Feb 1, 2026' }, ] -const inputClass = ` - w-full rounded-md border border-border-100 bg-bg-300 px-3 py-2 text-sm text-fg-100 - placeholder:text-fg-300 outline-none transition-colors - focus:border-border-200 focus:ring-1 focus:ring-ring-100 -` +// ─── Step definitions ───────────────────────────────────────────────────────── + +interface Option { + label: string + description: string + icon: string + type: TicketType + subOptions?: SubOption[] +} + +interface SubOption { + label: string + description: string + icon: string + type: TicketType +} + +const CATEGORIES: Option[] = [ + { + label: 'Something broke', + description: 'Unexpected behavior or errors', + icon: '⚠️', + type: 'bug', + subOptions: [ + { label: 'Page or feature not loading', description: 'Blank screens, crashes, or freezes', icon: '🖥️', type: 'bug' }, + { label: 'Error message appeared', description: 'Something went wrong unexpectedly', icon: '🔴', type: 'bug' }, + { label: 'Data looks wrong', description: 'Missing or incorrect information', icon: '📊', type: 'bug' }, + ], + }, + { + label: 'Billing or payment', + description: 'Charges, invoices, or subscriptions', + icon: '💳', + type: 'billing', + subOptions: [ + { label: 'Unexpected charge', description: "A charge I didn't expect or recognise", icon: '❓', type: 'billing' }, + { label: 'Refund request', description: "I'd like money returned", icon: '↩️', type: 'billing' }, + { label: 'Subscription issue', description: 'Plan, renewal, or upgrade problem', icon: '🔄', type: 'billing' }, + ], + }, + { + label: 'My account', + description: 'Login, settings, or profile', + icon: '👤', + type: 'account', + subOptions: [ + { label: "Can't sign in", description: 'Login or password issues', icon: '🔐', type: 'account' }, + { label: 'Profile or settings', description: 'Change name, email, or preferences', icon: '⚙️', type: 'account' }, + { label: 'Account access or security', description: 'Suspicious activity or locked out', icon: '🛡️', type: 'account' }, + ], + }, + { + label: 'Suggest an idea', + description: 'Feature requests or improvements', + icon: '💡', + type: 'feature-request', + subOptions: [ + { label: 'New feature idea', description: "Something you'd love to see added", icon: '✨', type: 'feature-request' }, + { label: 'Improve something existing', description: 'Make a current feature better', icon: '🔧', type: 'feature-request' }, + { label: 'Share general feedback', description: 'Thoughts on your experience', icon: '💬', type: 'feedback' }, + ], + }, + { + label: 'Something else', + description: 'Anything not listed above', + icon: '📝', + type: 'other', + }, +] + +// ─── Sub-components ──────────────────────────────────────────────────────────── + +function StepIndicator({ step, total }: { step: number; total: number }) { + return ( +
+ {Array.from({ length: total }).map((_, i) => ( +
+ ))} +
+ ) +} + +interface OptionCardProps { + icon: string + label: string + description: string + onClick: () => void +} + +function OptionCard({ icon, label, description, onClick }: OptionCardProps) { + return ( + + ) +} + +// ─── Main form ──────────────────────────────────────────────────────────────── type FormData = Pick @@ -23,45 +142,213 @@ interface NewTicketFormProps { onSubmit: (data: FormData) => void } +type Step = 'category' | 'sub' | 'details' + +const inputClass = ` + w-full rounded-md border border-border-100 bg-bg-300 px-3 py-2 text-sm text-fg-100 + placeholder:text-fg-300 outline-none transition-colors + focus:border-border-200 focus:ring-1 focus:ring-ring-100 +` + export function NewTicketForm({ onSubmit }: NewTicketFormProps) { + const [step, setStep] = useState('category') + const [selectedCategory, setSelectedCategory] = useState