update:char limits
This commit is contained in:
14
README.md
14
README.md
@@ -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`
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user