Merge SERVICE role into AGENT
Build & Push / Test (client) (push) Successful in 31s
Build & Push / Test (server) (push) Successful in 38s
Build & Push / Build Client (push) Successful in 1m17s
Build & Push / Build Server (push) Successful in 1m18s

Every AGENT now gets an auto-generated API key on creation, shown once
in a modal. AGENTs log in with password and authenticate to the API
with X-Api-Key. pre-push.sql defensively migrates any residual SERVICE
rows to AGENT before Prisma rewrites the enum. Goddard is no longer
baked into the seed — create agents via Admin → Users.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 22:44:32 -04:00
parent a9ba74f1af
commit d8785a964d
18 changed files with 73 additions and 130 deletions
+23 -22
View File
@@ -19,21 +19,20 @@ Internal ticketing system with CTI-based routing, severity levels, role-based ac
- **Command palette + keyboard shortcuts** — ⌘K palette, `j/k` navigation, `g d/t/m/n/s` leaders, `?` help - **Command palette + keyboard shortcuts** — ⌘K palette, `j/k` navigation, `g d/t/m/n/s` leaders, `?` help
- **PWA** — installable on mobile, offline app shell - **PWA** — installable on mobile, offline app shell
- **Comments** — GitHub/Gitea-style threads with markdown, draft autosave, Ctrl+Enter submit - **Comments** — GitHub/Gitea-style threads with markdown, draft autosave, Ctrl+Enter submit
- **Roles** — Admin, Agent, User, Service (API key auth for automation) - **Roles** — Admin, Agent, User
- **Audit log** — every action tracked with actor, timestamp, and expandable detail - **Audit log** — every action tracked with actor, timestamp, and expandable detail
- **Admin panel** — manage users, CTI hierarchy, and webhooks via UI - **Admin panel** — manage users, CTI hierarchy, and webhooks via UI
- **n8n ready** — service accounts authenticate via `X-Api-Key` header - **n8n ready** — every Agent gets an auto-generated API key for `X-Api-Key` header auth
--- ---
## Roles ## Roles
| Role | Access | | Role | Access |
| ----------- | ---------------------------------------------------------------------------- | | --------- | ----------------------------------------------------------------------------------------- |
| **Admin** | Full access — manage users, CTI config, webhooks, close and delete tickets | | **Admin** | Full access — manage users, CTI config, webhooks, close and delete tickets |
| **Agent** | Manage tickets — create, update, assign, comment, change status (not Closed) | | **Agent** | Manage tickets — create, update, assign, comment, change status (not Closed). Logs in with password and can authenticate via `X-Api-Key` header (key shown once at creation) |
| **User** | Basic access — view tickets and add comments only | | **User** | Basic access — view tickets and add comments only |
| **Service** | Automation account — authenticates via API key, no password login |
> Only **Admins** can manually set a ticket status to **Closed**. > Only **Admins** can manually set a ticket status to **Closed**.
@@ -99,7 +98,9 @@ docker compose exec server npm run db:seed
This creates: This creates:
- `admin` user (password: `admin123`) — **change this immediately** - `admin` user (password: `admin123`) — **change this immediately**
- `goddard` service account — API key is printed to the console; copy it now - Sample CTI hierarchy (categories, types, items)
Automation accounts are no longer seeded. Create an **Agent** via Admin → Users to get an API key for n8n / scripts — the key is shown once in a modal at creation time.
### Upgrading from v0.9 ### Upgrading from v0.9
@@ -140,7 +141,7 @@ cd server
cp .env.example .env # set DATABASE_URL and JWT_SECRET cp .env.example .env # set DATABASE_URL and JWT_SECRET
npm install npm install
npm run db:push # creates tables + search indexes npm run db:push # creates tables + search indexes
npm run db:seed # seeds admin + Goddard + sample CTI npm run db:seed # seeds admin user + sample CTI
npm run dev # http://localhost:3000 npm run dev # http://localhost:3000
npm test # vitest (service layer) npm test # vitest (service layer)
npm run typecheck npm run typecheck
@@ -165,7 +166,7 @@ CI runs typecheck + tests on both packages before building Docker images.
All endpoints (except `/api/auth/*` and `/healthz`) require authentication via one of: All endpoints (except `/api/auth/*` and `/healthz`) require authentication via one of:
- **JWT**: `Authorization: Bearer <token>` (obtained from `POST /api/auth/login`) - **JWT**: `Authorization: Bearer <token>` (obtained from `POST /api/auth/login`)
- **API Key**: `X-Api-Key: sk_<key>` (Service accounts only) - **API Key**: `X-Api-Key: sk_<key>` (on any Agent account)
Base URL: `https://tickets.thewrightserver.net/api` Base URL: `https://tickets.thewrightserver.net/api`
@@ -234,7 +235,7 @@ List tickets, sorted by severity (ASC) then created date (DESC).
} }
``` ```
**Response (unpaginated — `page` omitted):** Array of ticket objects with nested `category`, `type`, `item`, `assignee`, `createdBy`, and `_count.comments`. This preserves compatibility with v0.9 clients and the Goddard integration. **Response (unpaginated — `page` omitted):** Array of ticket objects with nested `category`, `type`, `item`, `assignee`, `createdBy`, and `_count.comments`. This preserves compatibility with v0.9 clients and API-key integrations.
--- ---
@@ -246,7 +247,7 @@ Fetch a single ticket by internal ID or display ID (e.g. `V325813929`). Includes
#### `POST /api/tickets` #### `POST /api/tickets`
Create a new ticket. Requires **Agent**, **Admin**, or **Service** role. Create a new ticket. Requires **Agent** or **Admin** role.
**Body:** **Body:**
@@ -268,7 +269,7 @@ Create a new ticket. Requires **Agent**, **Admin**, or **Service** role.
#### `PATCH /api/tickets/:id` #### `PATCH /api/tickets/:id`
Update a ticket. Accepts any combination of fields. Requires **Agent**, **Admin**, or **Service** role. Update a ticket. Accepts any combination of fields. Requires **Agent** or **Admin** role.
> Setting `status` to `CLOSED` requires **Admin** role. > Setting `status` to `CLOSED` requires **Admin** role.
@@ -468,12 +469,12 @@ Create a user.
"username": "string", "username": "string",
"email": "string", "email": "string",
"displayName": "string", "displayName": "string",
"password": "string (not required for SERVICE role)", "password": "string (min 8 chars)",
"role": "ADMIN | AGENT | USER | SERVICE" "role": "ADMIN | AGENT | USER"
} }
``` ```
Service accounts receive an auto-generated API key returned in the response. Copy it immediately — it is not shown again. Agent accounts receive an auto-generated API key returned in the response. Copy it immediately — it is not shown again. Use `PATCH /api/users/:id` with `{ "regenerateApiKey": true }` to rotate.
#### `PATCH /api/users/:id` #### `PATCH /api/users/:id`
@@ -485,15 +486,15 @@ Delete a user. Cannot delete your own account.
--- ---
## n8n Integration (Goddard) ## n8n Integration
The `goddard` service account authenticates via API key — no login flow needed. Create an **Agent** account via Admin → Users. The API key is shown once in a modal at creation — copy it into n8n's credentials as the `X-Api-Key` header value. Every Agent can authenticate via both password (for the UI) and API key (for automation).
**Create a ticket from n8n:** **Create a ticket from n8n:**
``` ```
POST /api/tickets POST /api/tickets
X-Api-Key: sk_<goddard api key> X-Api-Key: sk_<agent api key>
Content-Type: application/json Content-Type: application/json
{ {
@@ -503,7 +504,7 @@ Content-Type: application/json
"categoryId": "<TheWrightServer category ID>", "categoryId": "<TheWrightServer category ID>",
"typeId": "<Automation type ID>", "typeId": "<Automation type ID>",
"itemId": "<Backup item ID>", "itemId": "<Backup item ID>",
"assigneeId": "<Goddard user ID>" "assigneeId": "<agent user ID>"
} }
``` ```
@@ -513,7 +514,7 @@ CTI IDs can be fetched from:
- `GET /api/cti/types?categoryId=<id>` - `GET /api/cti/types?categoryId=<id>`
- `GET /api/cti/items?typeId=<id>` - `GET /api/cti/items?typeId=<id>`
To regenerate the Goddard API key: Admin → Users → refresh icon next to Goddard. To rotate an Agent's API key: Admin → Users → refresh icon next to the Agent. The old key stops working immediately.
--- ---
+5 -7
View File
@@ -105,13 +105,11 @@ export default function NewTicketModal({ onClose }: NewTicketModalProps) {
<label className={labelClass}>Assignee</label> <label className={labelClass}>Assignee</label>
<select className={inputClass} {...register('assigneeId')}> <select className={inputClass} {...register('assigneeId')}>
<option value="">Unassigned</option> <option value="">Unassigned</option>
{users {users.map((u) => (
.filter((u) => u.role !== 'SERVICE') <option key={u.id} value={u.id}>
.map((u) => ( {u.displayName}
<option key={u.id} value={u.id}> </option>
{u.displayName} ))}
</option>
))}
</select> </select>
</div> </div>
</div> </div>
-25
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Copy } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
@@ -47,12 +46,6 @@ export default function Settings() {
} }
}; };
const copyKey = async () => {
if (!user?.apiKey) return;
await navigator.clipboard.writeText(user.apiKey);
toast.success('API key copied');
};
return ( return (
<Layout title="Settings"> <Layout title="Settings">
<div className="space-y-6"> <div className="space-y-6">
@@ -118,24 +111,6 @@ export default function Settings() {
</p> </p>
</section> </section>
{/* API key (service accounts only) */}
{user?.role === 'SERVICE' && user?.apiKey && (
<section className="rounded-md border border-border p-4">
<h2 className="text-sm font-semibold mb-3">API key</h2>
<div className="flex items-center gap-2 bg-muted rounded-md px-3 py-2 font-mono text-xs break-all">
<span className="flex-1">{user.apiKey}</span>
<button
onClick={copyKey}
className="text-muted-foreground hover:text-foreground"
>
<Copy size={14} />
</button>
</div>
<p className="mt-2 text-xs text-muted-foreground">
Pass as <code>x-api-key</code> header on any server-to-server request.
</p>
</section>
)}
</div> </div>
</Layout> </Layout>
); );
+1 -1
View File
@@ -243,7 +243,7 @@ export default function TicketDetail() {
} }
const commentCount = ticket.comments?.length ?? 0; const commentCount = ticket.comments?.length ?? 0;
const agentUsers = users.filter((u) => u.role !== 'SERVICE'); const agentUsers = users;
const statusOptions: { value: TicketStatus; label: string }[] = [ const statusOptions: { value: TicketStatus; label: string }[] = [
{ value: 'OPEN', label: 'Open' }, { value: 'OPEN', label: 'Open' },
+1 -1
View File
@@ -214,7 +214,7 @@ export default function Tickets() {
setSearchInput(String(filters.search ?? '')); setSearchInput(String(filters.search ?? ''));
}; };
const agentUsers = users.filter((u) => u.role !== 'SERVICE'); const agentUsers = users;
// Keyboard navigation // Keyboard navigation
useEffect(() => { useEffect(() => {
+3 -7
View File
@@ -42,21 +42,18 @@ const ROLE_LABELS: Record<Role, string> = {
ADMIN: 'Admin', ADMIN: 'Admin',
AGENT: 'Agent', AGENT: 'Agent',
USER: 'User', USER: 'User',
SERVICE: 'Service',
}; };
const ROLE_BADGE: Record<Role, string> = { const ROLE_BADGE: Record<Role, string> = {
ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-500/30', ADMIN: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
AGENT: 'bg-blue-500/20 text-blue-400 border-blue-500/30', AGENT: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
USER: 'bg-gray-500/20 text-gray-400 border-gray-500/30', USER: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
SERVICE: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
}; };
const ROLE_DESCRIPTIONS: Record<Role, string> = { const ROLE_DESCRIPTIONS: Record<Role, string> = {
ADMIN: 'Full access — manage users, CTI config, close and delete tickets', ADMIN: 'Full access — manage users, CTI config, close and delete tickets',
AGENT: 'Manage tickets — create, update, assign, comment, change status', AGENT: 'Manage tickets and automation — logs in with password and can authenticate via API key',
USER: 'Basic access — view tickets and add comments only', USER: 'Basic access — view tickets and add comments only',
SERVICE: 'Automation account — authenticates via API key, no password login',
}; };
export default function AdminUsers() { export default function AdminUsers() {
@@ -227,7 +224,7 @@ export default function AdminUsers() {
<td className="px-5 py-3 text-gray-400">{u.email}</td> <td className="px-5 py-3 text-gray-400">{u.email}</td>
<td className="px-5 py-3"> <td className="px-5 py-3">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{u.role === 'SERVICE' && ( {u.role === 'AGENT' && (
<button <button
onClick={() => setRotating(u)} onClick={() => setRotating(u)}
className="text-gray-600 hover:text-gray-300 transition-colors" className="text-gray-600 hover:text-gray-300 transition-colors"
@@ -343,7 +340,7 @@ export default function AdminUsers() {
type="password" type="password"
value={form.password} value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
required={modal === 'add' && form.role !== 'SERVICE'} required={modal === 'add'}
className={inputClass} className={inputClass}
placeholder={modal === 'edit' ? '••••••••' : ''} placeholder={modal === 'edit' ? '••••••••' : ''}
/> />
@@ -359,7 +356,6 @@ export default function AdminUsers() {
<option value="AGENT">Agent</option> <option value="AGENT">Agent</option>
<option value="USER">User</option> <option value="USER">User</option>
<option value="ADMIN">Admin</option> <option value="ADMIN">Admin</option>
<option value="SERVICE">Service</option>
</select> </select>
<p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p> <p className="mt-1.5 text-xs text-gray-500">{ROLE_DESCRIPTIONS[form.role]}</p>
</div> </div>
+1 -1
View File
@@ -1,4 +1,4 @@
export type Role = 'ADMIN' | 'AGENT' | 'USER' | 'SERVICE'; export type Role = 'ADMIN' | 'AGENT' | 'USER';
export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED'; export type TicketStatus = 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'CLOSED';
export interface User { export interface User {
+2 -2
View File
@@ -5,8 +5,8 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/server/src/index.js", "start": "node dist/server/src/index.js",
"start:prod": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js", "start:prod": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma && node dist/server/src/index.js",
"db:push": "prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma", "db:push": "prisma db execute --file prisma/pre-push.sql --schema prisma/schema.prisma && prisma db push && prisma db execute --file prisma/post-push.sql --schema prisma/schema.prisma",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
+14
View File
@@ -0,0 +1,14 @@
-- Idempotent SQL applied BEFORE `prisma db push`.
-- Flips any residual SERVICE-role users to AGENT before Prisma rewrites the Role enum.
-- Safe no-op on fresh databases or databases already migrated past the SERVICE role.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_enum e ON e.enumtypid = t.oid
WHERE t.typname = 'Role' AND e.enumlabel = 'SERVICE'
) THEN
EXECUTE 'UPDATE "User" SET "role" = ''AGENT'' WHERE "role"::text = ''SERVICE''';
END IF;
END $$;
-1
View File
@@ -11,7 +11,6 @@ enum Role {
ADMIN ADMIN
AGENT AGENT
USER USER
SERVICE
} }
enum TicketStatus { enum TicketStatus {
-20
View File
@@ -1,6 +1,5 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import crypto from 'crypto';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@@ -20,25 +19,6 @@ async function main() {
}, },
}); });
// Goddard — n8n service account
const apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
await prisma.user.upsert({
where: { username: 'goddard' },
update: {},
create: {
username: 'goddard',
email: 'goddard@internal',
displayName: 'Goddard',
passwordHash: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12),
role: 'SERVICE',
apiKey,
},
});
const existingGoddard = await prisma.user.findUnique({ where: { username: 'goddard' } });
console.log(`\nGoddard API key: ${existingGoddard?.apiKey ?? apiKey}`);
console.log('(This key is only displayed once on first seed — copy it now)\n');
// Sample CTI structure // Sample CTI structure
const theWrightServer = await prisma.category.upsert({ const theWrightServer = await prisma.category.upsert({
where: { name: 'TheWrightServer' }, where: { name: 'TheWrightServer' },
+2 -2
View File
@@ -15,7 +15,7 @@ export const authenticate = async (req: AuthRequest, res: Response, next: NextFu
if (apiKey) { if (apiKey) {
const user = await prisma.user.findUnique({ where: { apiKey } }); const user = await prisma.user.findUnique({ where: { apiKey } });
if (!user || user.role !== 'SERVICE') { if (!user) {
return res.status(401).json({ error: 'Invalid API key' }); return res.status(401).json({ error: 'Invalid API key' });
} }
req.user = { id: user.id, role: user.role, username: user.username }; req.user = { id: user.id, role: user.role, username: user.username };
@@ -48,7 +48,7 @@ export const requireAdmin = (req: AuthRequest, res: Response, next: NextFunction
next(); next();
}; };
// Blocks USER role — allows ADMIN, AGENT, SERVICE // Blocks USER role — allows ADMIN and AGENT
export const requireAgent = (req: AuthRequest, res: Response, next: NextFunction) => { export const requireAgent = (req: AuthRequest, res: Response, next: NextFunction) => {
if (req.user?.role === 'USER') { if (req.user?.role === 'USER') {
return res.status(403).json({ error: 'Insufficient permissions' }); return res.status(403).json({ error: 'Insufficient permissions' });
-20
View File
@@ -51,24 +51,4 @@ describe('authService.login', () => {
}); });
}); });
it('rejects SERVICE role from password login', async () => {
const password = 'svc-pw';
prismaMock.user.findUnique.mockResolvedValue({
id: 'svc',
username: 'goddard',
email: 'g@x.io',
displayName: 'Goddard',
passwordHash: await bcrypt.hash(password, 4),
role: 'SERVICE',
apiKey: 'sk_xyz',
notificationPrefs: null,
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(login({ username: 'goddard', password })).rejects.toMatchObject({
status: 401,
message: expect.stringMatching(/API key/i),
});
});
}); });
-4
View File
@@ -10,10 +10,6 @@ export async function login({ username, password }: LoginInput) {
throw new HttpError(401, 'Invalid credentials'); throw new HttpError(401, 'Invalid credentials');
} }
if (user.role === 'SERVICE') {
throw new HttpError(401, 'Service accounts must authenticate via API key');
}
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, role: user.role, username: user.username }, { id: user.id, role: user.role, username: user.username },
process.env.JWT_SECRET!, process.env.JWT_SECRET!,
+10 -9
View File
@@ -17,15 +17,15 @@ const stubUser = {
}; };
describe('userService.createUser', () => { describe('userService.createUser', () => {
it('hashes the password and omits apiKey for non-SERVICE roles', async () => { it('hashes the password and omits apiKey for ADMIN and USER roles', async () => {
prismaMock.user.create.mockResolvedValue(stubUser); prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'USER' });
await createUser({ await createUser({
username: 'bob', username: 'bob',
email: 'b@x.io', email: 'b@x.io',
displayName: 'Bob', displayName: 'Bob',
password: 'hunter2!', password: 'hunter2!',
role: 'AGENT', role: 'USER',
}); });
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> }; const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
@@ -34,14 +34,15 @@ describe('userService.createUser', () => {
expect(call.data.apiKey).toBeUndefined(); expect(call.data.apiKey).toBeUndefined();
}); });
it('assigns an apiKey for SERVICE role', async () => { it('assigns an apiKey for AGENT role', async () => {
prismaMock.user.create.mockResolvedValue({ ...stubUser, role: 'SERVICE' }); prismaMock.user.create.mockResolvedValue(stubUser);
await createUser({ await createUser({
username: 'svc', username: 'agent',
email: 's@x.io', email: 'a@x.io',
displayName: 'Svc', displayName: 'Agent',
role: 'SERVICE', password: 'hunter2!',
role: 'AGENT',
}); });
const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> }; const call = prismaMock.user.create.mock.calls[0][0] as { data: Record<string, unknown> };
+9 -6
View File
@@ -41,12 +41,10 @@ export async function getCurrentUser(id: string) {
} }
export async function createUser(data: CreateUserInput) { export async function createUser(data: CreateUserInput) {
const passwordHash = data.password const passwordHash = await bcrypt.hash(data.password, 12);
? await bcrypt.hash(data.password, 12)
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 12);
const apiKey = const apiKey =
data.role === 'SERVICE' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined; data.role === 'AGENT' ? `sk_${crypto.randomBytes(32).toString('hex')}` : undefined;
return prisma.user.create({ return prisma.user.create({
data: { data: {
@@ -68,8 +66,13 @@ export async function updateUser(id: string, data: UpdateUserInput) {
if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12); if (data.password) update.passwordHash = await bcrypt.hash(data.password, 12);
if (data.role) { if (data.role) {
update.role = data.role; update.role = data.role;
if (data.role === 'SERVICE' && !update.apiKey) { if (data.role === 'AGENT') {
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`; const existing = await prisma.user.findUnique({ where: { id }, select: { apiKey: true } });
if (!existing?.apiKey) {
update.apiKey = `sk_${crypto.randomBytes(32).toString('hex')}`;
}
} else {
update.apiKey = null;
} }
} }
if (data.regenerateApiKey) { if (data.regenerateApiKey) {
+1 -1
View File
@@ -1,6 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
export const ROLES = ['ADMIN', 'AGENT', 'USER', 'SERVICE'] as const; export const ROLES = ['ADMIN', 'AGENT', 'USER'] as const;
export const TICKET_STATUSES = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'] as const; export const TICKET_STATUSES = ['OPEN', 'IN_PROGRESS', 'RESOLVED', 'CLOSED'] as const;
export const roleSchema = z.enum(ROLES); export const roleSchema = z.enum(ROLES);
+1 -1
View File
@@ -5,7 +5,7 @@ export const createUserSchema = z.object({
username: z.string().min(1).max(50), username: z.string().min(1).max(50),
email: z.string().email(), email: z.string().email(),
displayName: z.string().min(1).max(100), displayName: z.string().min(1).max(100),
password: z.string().min(8).optional(), password: z.string().min(8),
role: roleSchema.default('AGENT'), role: roleSchema.default('AGENT'),
}); });