From 0f602a48c6b670587c8e349fd270cda88488bc04 Mon Sep 17 00:00:00 2001 From: kokopi Date: Tue, 10 Mar 2026 18:08:22 +0900 Subject: [PATCH] update:char limits --- README.md | 14 ++- backend/src/routes/tickets.ts | 32 ++++++- backend/src/types.ts | 93 +++++++++++-------- .../src/components/tickets/NewTicketForm.tsx | 49 +++++++--- .../src/components/tickets/TicketDetail.tsx | 23 ++++- frontend/src/components/ui/InfoBar.tsx | 13 +-- 6 files changed, 161 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 799c8bd..a2513d1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,18 @@ # Personal Support Ticket System -For demo purposes. +For demo purposes. A support ticket system that lets the user experience both the user and admin side features. + +# Features +- Auth and Non-Auth'd User States + - Auth Users + - Tickets and replies are stored in database + - Able to see all tickets created by all users in the Admin tab + - Tickets, can create 10 max + - Ticket replies, can reply 20 max per ticket + - Non-Auth Users + - Tickets and replies are stored in localstorage (browser) + - Admin tab only shows localstorage tickets + - Tickets and replies have no creation limit # Dev `bun run dev` diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index e2c45a7..a613317 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1,6 +1,12 @@ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; import type { Ticket, TicketType } from "../types.ts"; -import { TICKET_LIMIT, REPLY_LIMIT } from "../types.ts"; +import { + TICKET_LIMIT, + REPLY_LIMIT, + SUBJECT_MAX_LENGTH, + DESCRIPTION_MAX_LENGTH, + REPLY_MAX_LENGTH, +} from "../types.ts"; import { filterContent, filterBody } from "../middleware/contentFilter.ts"; const PAGE_SIZE = 10; @@ -70,6 +76,22 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => { return reply.status(400).send({ error: "subject is required" }); } + if (subject.trim().length > SUBJECT_MAX_LENGTH) { + return reply.status(400).send({ + error: "subject_too_long", + message: `Subject must be ${SUBJECT_MAX_LENGTH} characters or fewer.`, + max: SUBJECT_MAX_LENGTH, + }); + } + + if (description.length > DESCRIPTION_MAX_LENGTH) { + return reply.status(400).send({ + error: "description_too_long", + message: `Description must be ${DESCRIPTION_MAX_LENGTH} characters or fewer.`, + max: DESCRIPTION_MAX_LENGTH, + }); + } + // Enforce per-user ticket limit if (req.user?.id) { const userTicketCount = await req.storage.countTicketsByUser(req.user.id); @@ -138,6 +160,14 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => { return reply.status(400).send({ error: "body is required" }); } + if (body.trim().length > REPLY_MAX_LENGTH) { + return reply.status(400).send({ + error: "reply_too_long", + message: `Reply must be ${REPLY_MAX_LENGTH} characters or fewer.`, + max: REPLY_MAX_LENGTH, + }); + } + const ticket = await req.storage.getTicket(req.params.id); if (!ticket) return reply.status(404).send({ error: "Not found" }); diff --git a/backend/src/types.ts b/backend/src/types.ts index 1ff52bf..d1ecfa4 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1,9 +1,9 @@ export interface User { - id: string - googleId: string - username: string - avatarUrl: string | null - createdAt: string + id: string; + googleId: string; + username: string; + avatarUrl: string | null; + createdAt: string; } export type TicketType = @@ -15,50 +15,67 @@ export type TicketType = | "other"; export interface Ticket { - id: string - userId: string | null - username: string | null - subject: string - description: string - type: TicketType - status: 'open' | 'in-progress' | 'resolved' | 'closed' - createdAt: string + id: string; + userId: string | null; + username: string | null; + subject: string; + description: string; + type: TicketType; + status: "open" | "in-progress" | "resolved" | "closed"; + createdAt: string; } -export const TICKET_LIMIT = 10 -export const REPLY_LIMIT = 20 +export const TICKET_LIMIT = 10; +export const REPLY_LIMIT = 20; + +export const SUBJECT_MAX_LENGTH = 128; +export const DESCRIPTION_MAX_LENGTH = 2000; +export const REPLY_MAX_LENGTH = 1000; export interface TicketFilters { - status?: Ticket['status'] - type?: TicketType - userId?: string + status?: Ticket["status"]; + type?: TicketType; + userId?: string; } export interface PaginatedTickets { - data: Ticket[] - total: number + data: Ticket[]; + total: number; } export interface Reply { - id: string - ticketId: string - userId: string | null - username: string | null - body: string - authorRole: 'user' | 'support' - createdAt: string + 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 - getTicketsPaginated(limit: number, offset: number, filters?: TicketFilters): 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 - getReplies(ticketId: string): Promise - countRepliesByTicket(ticketId: string): Promise - createReply(data: { ticketId: string; body: string; userId?: string; authorRole: Reply['authorRole'] }): Promise + 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; + updateTicket(id: string, patch: Partial): Promise; + deleteTicket(id: string): Promise; + getReplies(ticketId: string): Promise; + countRepliesByTicket(ticketId: string): Promise; + createReply(data: { + ticketId: string; + body: string; + userId?: string; + authorRole: Reply["authorRole"]; + }): Promise; } diff --git a/frontend/src/components/tickets/NewTicketForm.tsx b/frontend/src/components/tickets/NewTicketForm.tsx index 26122f5..38c8739 100644 --- a/frontend/src/components/tickets/NewTicketForm.tsx +++ b/frontend/src/components/tickets/NewTicketForm.tsx @@ -2,6 +2,20 @@ import { useState } from 'react' import { Button } from '../ui/Button.tsx' import type { Ticket, TicketType } from '../../lib/types.ts' +const SUBJECT_MAX = 128 +const DESCRIPTION_MAX = 2000 + +function CharCount({ current, max }: { current: number; max: number }) { + const remaining = max - current + const isWarning = remaining <= max * 0.1 // warn in the last 10% + const isOver = remaining < 0 + return ( + + {current}/{max} + + ) +} + // ─── Fake transactions ──────────────────────────────────────────────────────── export interface FakeTransaction { @@ -12,9 +26,9 @@ export interface FakeTransaction { } 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' }, + { 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' }, ] // ─── Step definitions ───────────────────────────────────────────────────────── @@ -96,9 +110,8 @@ function StepIndicator({ step, total }: { step: number; total: number }) {
))}
@@ -128,7 +141,7 @@ function OptionCard({ icon, label, description, onClick }: OptionCardProps) { className="ml-auto mt-1 shrink-0 text-fg-300 opacity-0 group-hover:opacity-100 transition-opacity duration-150" width="14" height="14" viewBox="0 0 14 14" fill="none" > - + ) @@ -269,14 +282,18 @@ export function NewTicketForm({ onSubmit }: NewTicketFormProps) {
- +
+ + +
setForm(f => ({ ...f, subject: e.target.value }))} + maxLength={SUBJECT_MAX} autoFocus required /> @@ -304,7 +321,7 @@ export function NewTicketForm({ onSubmit }: NewTicketFormProps) { className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-300" width="12" height="12" viewBox="0 0 12 12" fill="none" > - +
{selectedTxn && (() => { @@ -323,14 +340,18 @@ export function NewTicketForm({ onSubmit }: NewTicketFormProps) { )}
- +
+ + +