update:char limits

This commit is contained in:
2026-03-10 18:08:22 +09:00
parent 774a79eef3
commit 0f602a48c6
6 changed files with 161 additions and 63 deletions

View File

@@ -1,6 +1,18 @@
# Personal Support Ticket System # 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 # Dev
`bun run dev` `bun run dev`

View File

@@ -1,6 +1,12 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
import type { Ticket, TicketType } from "../types.ts"; 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"; import { filterContent, filterBody } from "../middleware/contentFilter.ts";
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
@@ -70,6 +76,22 @@ export const ticketsRouter: FastifyPluginAsync = async (app) => {
return reply.status(400).send({ error: "subject is required" }); 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 // Enforce per-user ticket limit
if (req.user?.id) { if (req.user?.id) {
const userTicketCount = await req.storage.countTicketsByUser(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" }); 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); const ticket = await req.storage.getTicket(req.params.id);
if (!ticket) return reply.status(404).send({ error: "Not found" }); if (!ticket) return reply.status(404).send({ error: "Not found" });

View File

@@ -1,9 +1,9 @@
export interface User { export interface User {
id: string id: string;
googleId: string googleId: string;
username: string username: string;
avatarUrl: string | null avatarUrl: string | null;
createdAt: string createdAt: string;
} }
export type TicketType = export type TicketType =
@@ -15,50 +15,67 @@ export type TicketType =
| "other"; | "other";
export interface Ticket { export interface Ticket {
id: string id: string;
userId: string | null userId: string | null;
username: string | null username: string | null;
subject: string subject: string;
description: string description: string;
type: TicketType type: TicketType;
status: 'open' | 'in-progress' | 'resolved' | 'closed' status: "open" | "in-progress" | "resolved" | "closed";
createdAt: string createdAt: string;
} }
export const TICKET_LIMIT = 10 export const TICKET_LIMIT = 10;
export const REPLY_LIMIT = 20 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 { export interface TicketFilters {
status?: Ticket['status'] status?: Ticket["status"];
type?: TicketType type?: TicketType;
userId?: string userId?: string;
} }
export interface PaginatedTickets { export interface PaginatedTickets {
data: Ticket[] data: Ticket[];
total: number total: number;
} }
export interface Reply { export interface Reply {
id: string id: string;
ticketId: string ticketId: string;
userId: string | null userId: string | null;
username: string | null username: string | null;
body: string body: string;
authorRole: 'user' | 'support' authorRole: "user" | "support";
createdAt: string createdAt: string;
} }
export interface StorageAdapter { export interface StorageAdapter {
getTickets(): Promise<Ticket[]> getTickets(): Promise<Ticket[]>;
getTicketsByUser(userId: string): Promise<Ticket[]> getTicketsByUser(userId: string): Promise<Ticket[]>;
getTicketsPaginated(limit: number, offset: number, filters?: TicketFilters): Promise<PaginatedTickets> getTicketsPaginated(
getTicket(id: string): Promise<Ticket | null> limit: number,
countTicketsByUser(userId: string): Promise<number> offset: number,
createTicket(data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }): Promise<Ticket> filters?: TicketFilters,
updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> ): Promise<PaginatedTickets>;
deleteTicket(id: string): Promise<void> getTicket(id: string): Promise<Ticket | null>;
getReplies(ticketId: string): Promise<Reply[]> countTicketsByUser(userId: string): Promise<number>;
countRepliesByTicket(ticketId: string): Promise<number> createTicket(
createReply(data: { ticketId: string; body: string; userId?: string; authorRole: Reply['authorRole'] }): Promise<Reply> 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[]>;
countRepliesByTicket(ticketId: string): Promise<number>;
createReply(data: {
ticketId: string;
body: string;
userId?: string;
authorRole: Reply["authorRole"];
}): Promise<Reply>;
} }

View File

@@ -2,6 +2,20 @@ import { useState } from 'react'
import { Button } from '../ui/Button.tsx' import { Button } from '../ui/Button.tsx'
import type { Ticket, TicketType } from '../../lib/types.ts' 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 (
<span className={`text-xs tabular-nums ${isOver ? 'text-red-400' : isWarning ? 'text-amber-400' : 'text-fg-300'}`}>
{current}/{max}
</span>
)
}
// ─── Fake transactions ──────────────────────────────────────────────────────── // ─── Fake transactions ────────────────────────────────────────────────────────
export interface FakeTransaction { export interface FakeTransaction {
@@ -12,9 +26,9 @@ export interface FakeTransaction {
} }
export const FAKE_TRANSACTIONS: FakeTransaction[] = [ export const FAKE_TRANSACTIONS: FakeTransaction[] = [
{ id: 'TXN-48291', label: 'Pro Plan — Monthly', amount: '$12.00', date: 'Mar 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-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-45882', label: 'Pro Plan — Monthly', amount: '$12.00', date: 'Feb 1, 2026' },
] ]
// ─── Step definitions ───────────────────────────────────────────────────────── // ─── Step definitions ─────────────────────────────────────────────────────────
@@ -96,9 +110,8 @@ function StepIndicator({ step, total }: { step: number; total: number }) {
<div <div
key={i} key={i}
style={{ transition: 'background-color 0.4s ease' }} style={{ transition: 'background-color 0.4s ease' }}
className={`h-0.5 flex-1 rounded-full ${ className={`h-0.5 flex-1 rounded-full ${i < step ? 'bg-fg-100' : i === step ? 'bg-fg-300' : 'bg-border-100'
i < step ? 'bg-fg-100' : i === step ? 'bg-fg-300' : 'bg-border-100' }`}
}`}
/> />
))} ))}
</div> </div>
@@ -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" 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" width="14" height="14" viewBox="0 0 14 14" fill="none"
> >
<path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/> <path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</button> </button>
) )
@@ -269,14 +282,18 @@ export function NewTicketForm({ onSubmit }: NewTicketFormProps) {
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-fg-200"> <div className="flex items-center justify-between">
Subject <span className="text-fg-300 font-normal">(required)</span> <label className="text-xs font-medium text-fg-200">
</label> Subject <span className="text-fg-300 font-normal">(required)</span>
</label>
<CharCount current={form.subject.length} max={SUBJECT_MAX} />
</div>
<input <input
className={inputClass} className={inputClass}
placeholder="Brief summary of the issue" placeholder="Brief summary of the issue"
value={form.subject} value={form.subject}
onChange={e => setForm(f => ({ ...f, subject: e.target.value }))} onChange={e => setForm(f => ({ ...f, subject: e.target.value }))}
maxLength={SUBJECT_MAX}
autoFocus autoFocus
required 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" 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" width="12" height="12" viewBox="0 0 12 12" fill="none"
> >
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/> <path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg> </svg>
</div> </div>
{selectedTxn && (() => { {selectedTxn && (() => {
@@ -323,14 +340,18 @@ export function NewTicketForm({ onSubmit }: NewTicketFormProps) {
)} )}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-fg-200"> <div className="flex items-center justify-between">
Description <span className="text-fg-300 font-normal">(optional)</span> <label className="text-xs font-medium text-fg-200">
</label> Description <span className="text-fg-300 font-normal">(optional)</span>
</label>
<CharCount current={form.description.length} max={DESCRIPTION_MAX} />
</div>
<textarea <textarea
className={`${inputClass} min-h-24 resize-y`} className={`${inputClass} min-h-24 resize-y`}
placeholder="Describe what happened, what you expected, and any steps to reproduce..." placeholder="Describe what happened, what you expected, and any steps to reproduce..."
value={form.description} value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))} onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
maxLength={DESCRIPTION_MAX}
rows={4} rows={4}
/> />
</div> </div>

View File

@@ -9,6 +9,19 @@ import { CloseIcon } from '../icons/close.tsx'
import { TrashIcon } from '../icons/trash.tsx' import { TrashIcon } from '../icons/trash.tsx'
import { CircleArrowIcon } from '../icons/circleArrow.tsx' import { CircleArrowIcon } from '../icons/circleArrow.tsx'
const REPLY_MAX = 1000
function CharCount({ current, max }: { current: number; max: number }) {
const remaining = max - current
const isWarning = remaining <= max * 0.1
const isOver = remaining < 0
return (
<span className={`text-xs tabular-nums ${isOver ? 'text-red-400' : isWarning ? 'text-amber-400' : 'text-fg-300'}`}>
{current}/{max}
</span>
)
}
function formatDate(iso: string): string { function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { return new Date(iso).toLocaleDateString('en-US', {
month: 'long', day: 'numeric', year: 'numeric', month: 'long', day: 'numeric', year: 'numeric',
@@ -147,8 +160,10 @@ function ReplyComposer({ onSend, disabled }: ReplyComposerProps) {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const isOverLimit = body.length > REPLY_MAX
const handleSend = async () => { const handleSend = async () => {
if (!body.trim() || sending) return if (!body.trim() || sending || isOverLimit) return
setSending(true) setSending(true)
setError(null) setError(null)
try { try {
@@ -179,10 +194,12 @@ function ReplyComposer({ onSend, disabled }: ReplyComposerProps) {
disabled={disabled || sending} disabled={disabled || sending}
placeholder="Write a reply… (⌘Enter to send)" placeholder="Write a reply… (⌘Enter to send)"
rows={3} rows={3}
maxLength={REPLY_MAX}
className="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 resize-none disabled:opacity-50" className="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 resize-none disabled:opacity-50"
/> />
<div className="flex justify-end"> <div className="flex items-center justify-between">
<Button onClick={handleSend} disabled={!body.trim() || sending || disabled}> <CharCount current={body.length} max={REPLY_MAX} />
<Button onClick={handleSend} disabled={!body.trim() || sending || disabled || isOverLimit}>
{sending ? 'Sending…' : 'Send Reply'} {sending ? 'Sending…' : 'Send Reply'}
</Button> </Button>
</div> </div>

View File

@@ -55,7 +55,7 @@ export function InfoBar({ user }: InfoBarProps) {
<div className="max-w-4xl px-6 mx-auto"> <div className="max-w-4xl px-6 mx-auto">
{isGuest ? ( {isGuest ? (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 px-2"> <div className="flex flex-col sm:flex-row gap-3 sm:gap-5 px-2">
<div> <div className="w-full">
<p className="text-xs font-medium text-fg-200">Guest mode</p> <p className="text-xs font-medium text-fg-200">Guest mode</p>
<p className="text-xs text-fg-300"> <p className="text-xs text-fg-300">
Tickets are stored in your browser (localStorage) only. Tickets are stored in your browser (localStorage) only.
@@ -63,7 +63,7 @@ export function InfoBar({ user }: InfoBarProps) {
Admin panel manages only your tickets. Admin panel manages only your tickets.
</p> </p>
</div> </div>
<div> <div className="w-full">
<p className="text-xs font-medium text-fg-200">After signing in</p> <p className="text-xs font-medium text-fg-200">After signing in</p>
<p className="text-xs text-fg-300"> <p className="text-xs text-fg-300">
Tickets are saved to the server persistently. Tickets are saved to the server persistently.
@@ -74,13 +74,14 @@ export function InfoBar({ user }: InfoBarProps) {
</div> </div>
) : ( ) : (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 px-2"> <div className="flex flex-col sm:flex-row gap-3 sm:gap-5 px-2">
<div> <div className="w-full">
<p className="text-xs font-medium text-fg-200">Admin Tab</p> <p className="text-xs font-medium text-fg-200">Tickets and Admin Tab</p>
<p className="text-xs text-fg-300"> <p className="text-xs text-fg-300">
You can view all users' tickets in the Admin tab. Useful for monitoring support requests. Can create 10 tickets max, each ticket can have 20 replies max.
You can view all user tickets in the Admin tab. Useful for monitoring support requests.
</p> </p>
</div> </div>
<div> <div className="w-full">
<p className="text-xs font-medium text-fg-200 pr-1">Editing</p> <p className="text-xs font-medium text-fg-200 pr-1">Editing</p>
<p className="text-xs text-fg-300"> <p className="text-xs text-fg-300">
You can only edit or manage your own tickets. Other users' tickets are read-only for you. You can only edit or manage your own tickets. Other users' tickets are read-only for you.