feat: auth system, scrip scheduler, UI widgets, and new API routes

- Add session-based authentication (login page, middleware, auth context)
- Add cron-like scrip scheduler for time-based conditions
- Add layout builder, scrip wizard, searchable select components
- Add trend chart widget for dashboards
- Add notifications, attachments, queue-permissions API routes
- Add seed-users script
- Update schema with 10 new migrations (0008-0017)
- Apply redesign: Linear-inspired dark theme, conversation-centric UI
- Gitignore runtime data directory

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-15 20:42:17 +02:00
parent 1d4dc38d06
commit 70f0924d4b
59 changed files with 21795 additions and 321 deletions

View File

@@ -1,3 +1,3 @@
DATABASE_URL=postgres://tessera:password@localhost:5432/tessera DATABASE_URL=postgres://tessera:tessera@127.0.0.1:5435/tessera
SERVER_HOST=127.0.0.1 SERVER_HOST=127.0.0.1
SERVER_PORT=9876 SERVER_PORT=9876

3
.gitignore vendored
View File

@@ -36,3 +36,6 @@ bun.lock
# Codegraph index (MCP tool) # Codegraph index (MCP tool)
.codegraph .codegraph
# Runtime data
/data

View File

@@ -36,7 +36,7 @@ tessera/
- Bun (`nix-shell -p bun` or install globally) - Bun (`nix-shell -p bun` or install globally)
- Node.js 22+ (`nix-shell -p nodejs_22`) - Node.js 22+ (`nix-shell -p nodejs_22`)
- Docker (for PostgreSQL) - Docker (for PostgreSQL)
- PostgreSQL container: `docker run -d --name tessera-db -e POSTGRES_USER=tessera -e POSTGRES_PASSWORD=*** -e POSTGRES_DB=tessera -p 127.0.0.1:5433:5432 postgres:17-alpine` - PostgreSQL container: `docker run -d --name tessera-db -e POSTGRES_USER=tessera -e POSTGRES_PASSWORD=tessera -e POSTGRES_DB=tessera -p 127.0.0.1:5435:5432 postgres:17-alpine`
### Start backend ### Start backend
```bash ```bash

View File

@@ -0,0 +1,12 @@
CREATE TABLE "transaction_attachments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"transaction_id" uuid NOT NULL,
"filename" text NOT NULL,
"mime_type" text DEFAULT 'application/octet-stream' NOT NULL,
"size_bytes" integer DEFAULT 0 NOT NULL,
"storage_path" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "transaction_attachments" ADD CONSTRAINT "transaction_attachments_transaction_id_transactions_id_fk" FOREIGN KEY ("transaction_id") REFERENCES "public"."transactions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "transaction_attachments_tx_id_idx" ON "transaction_attachments" USING btree ("transaction_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "transaction_attachments" ALTER COLUMN "transaction_id" DROP NOT NULL;

View File

@@ -0,0 +1,15 @@
CREATE TABLE "ticket_links" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"ticket_id" integer NOT NULL,
"target_ticket_id" integer NOT NULL,
"link_type" text NOT NULL,
"creator_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "ticket_links_ticket_target_type_unique" UNIQUE("ticket_id","target_ticket_id","link_type")
);
--> statement-breakpoint
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_target_ticket_id_tickets_id_fk" FOREIGN KEY ("target_ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ticket_links" ADD CONSTRAINT "ticket_links_creator_id_users_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ticket_links_ticket_id_idx" ON "ticket_links" USING btree ("ticket_id");--> statement-breakpoint
CREATE INDEX "ticket_links_target_ticket_id_idx" ON "ticket_links" USING btree ("target_ticket_id");

View File

@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "password_hash" text;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "role" text DEFAULT 'staff' NOT NULL;

View File

@@ -0,0 +1,12 @@
CREATE TABLE "queue_permissions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"queue_id" uuid NOT NULL,
"team_id" uuid NOT NULL,
"right_name" text NOT NULL,
CONSTRAINT "queue_permissions_queue_team_right_unique" UNIQUE("queue_id","team_id","right_name")
);
--> statement-breakpoint
ALTER TABLE "queue_permissions" ADD CONSTRAINT "queue_permissions_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "queue_permissions" ADD CONSTRAINT "queue_permissions_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "queue_permissions_queue_id_idx" ON "queue_permissions" USING btree ("queue_id");--> statement-breakpoint
CREATE INDEX "queue_permissions_team_id_idx" ON "queue_permissions" USING btree ("team_id");

View File

@@ -0,0 +1,12 @@
CREATE TABLE "user_permissions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"queue_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"right_name" text NOT NULL,
CONSTRAINT "user_permissions_queue_user_right_unique" UNIQUE("queue_id","user_id","right_name")
);
--> statement-breakpoint
ALTER TABLE "user_permissions" ADD CONSTRAINT "user_permissions_queue_id_queues_id_fk" FOREIGN KEY ("queue_id") REFERENCES "public"."queues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_permissions" ADD CONSTRAINT "user_permissions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "user_permissions_queue_id_idx" ON "user_permissions" USING btree ("queue_id");--> statement-breakpoint
CREATE INDEX "user_permissions_user_id_idx" ON "user_permissions" USING btree ("user_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "transactions" ADD COLUMN "time_worked_minutes" integer DEFAULT 0;

View File

@@ -0,0 +1,15 @@
CREATE TABLE "notifications" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"ticket_id" integer,
"type" text NOT NULL,
"title" text NOT NULL,
"body" text,
"read" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "notifications_user_id_idx" ON "notifications" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "notifications_user_read_idx" ON "notifications" USING btree ("user_id","read");

View File

@@ -0,0 +1,12 @@
CREATE TABLE "api_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text NOT NULL,
"token_hash" text NOT NULL,
"last_used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now(),
CONSTRAINT "api_tokens_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
ALTER TABLE "api_tokens" ADD CONSTRAINT "api_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "api_tokens_user_id_idx" ON "api_tokens" USING btree ("user_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "scrips" ADD COLUMN "applicable_trans_types" text;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,76 @@
"when": 1781009018666, "when": 1781009018666,
"tag": "0007_flimsy_roughhouse", "tag": "0007_flimsy_roughhouse",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1781039674211,
"tag": "0008_sturdy_prism",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1781039770418,
"tag": "0009_tiny_lady_vermin",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1781040536590,
"tag": "0010_misty_morg",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1781042321413,
"tag": "0011_breezy_tyrannus",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1781043175153,
"tag": "0012_living_photon",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1781043729230,
"tag": "0013_bored_silvermane",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1781045611610,
"tag": "0014_cloudy_siren",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1781078349499,
"tag": "0015_tense_patch",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1781078511943,
"tag": "0016_famous_maximus",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1781095552496,
"tag": "0017_redundant_the_renegades",
"breakpoints": true
} }
] ]
} }

View File

@@ -22,6 +22,7 @@
}, },
"dependencies": { "dependencies": {
"@types/nodemailer": "^8.0.0", "@types/nodemailer": "^8.0.0",
"jose": "^6.2.3",
"nodemailer": "^8.0.10" "nodemailer": "^8.0.10"
} }
} }

28
scripts/seed-users.ts Normal file
View File

@@ -0,0 +1,28 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { users } from '../src/db/schema.ts';
const pool = new Pool({ connectionString: process.env.DATABASE_URL! });
const db = drizzle(pool);
const BATCH = 100;
const TOTAL = 1000;
const password = await Bun.password.hash('password');
console.log(`Inserting ${TOTAL} users...`);
for (let i = 0; i < TOTAL; i += BATCH) {
const batch = [];
for (let j = i; j < Math.min(i + BATCH, TOTAL); j++) {
const n = String(j).padStart(4, '0');
batch.push({
username: `user${n}`,
email: `user${n}@test.local`,
role: 'staff',
password_hash: password,
});
}
await db.insert(users).values(batch as any).onConflictDoNothing();
process.stdout.write('.');
}
console.log(`\nDone. ${TOTAL} users seeded.`);
await pool.end();

144
src/auth/middleware.ts Normal file
View File

@@ -0,0 +1,144 @@
import type { Context, Next } from 'hono';
import { HTTPException } from 'hono/http-exception';
import * as jose from 'jose';
import { config } from '../config.ts';
import type { Db } from '../db/index.ts';
import { users, apiTokens } from '../db/schema.ts';
import { eq } from 'drizzle-orm';
export interface AuthUser {
userId: string;
username: string;
role: string;
}
declare module 'hono' {
interface ContextVariableMap {
user: AuthUser;
}
}
const secret = new TextEncoder().encode(config.JWT_SECRET);
export async function createToken(user: { id: string; username: string; role: string }): Promise<string> {
return await new jose.SignJWT({ username: user.username, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime('7d')
.sign(secret);
}
async function verifyJwt(token: string): Promise<AuthUser | null> {
try {
const { payload } = await jose.jwtVerify(token, secret);
return {
userId: payload.sub!,
username: payload.username as string,
role: payload.role as string,
};
} catch {
return null;
}
}
async function verifyApiToken(db: Db, token: string): Promise<AuthUser | null> {
try {
// Find all tokens and verify against hash
const allTokens = await db.query.apiTokens.findMany();
for (const t of allTokens) {
const valid = await Bun.password.verify(token, t.token_hash);
if (valid) {
// Update last_used_at
await db.update(apiTokens)
.set({ last_used_at: new Date() } as any)
.where(eq(apiTokens.id, t.id));
const user = await db.query.users.findFirst({
where: eq(users.id, t.user_id),
});
if (user) {
return {
userId: user.id,
username: user.username,
role: user.role,
};
}
}
}
return null;
} catch {
return null;
}
}
function extractToken(c: Context): string | null {
const auth = c.req.header('Authorization');
if (auth?.startsWith('Bearer ')) {
return auth.slice(7);
}
const cookie = c.req.header('Cookie');
if (cookie) {
const match = cookie.match(/(?:^|;\s*)token=([^;]*)/);
if (match?.[1]) return match[1];
}
return null;
}
export function createAuthMiddleware(db: Db) {
async function verifyToken(token: string): Promise<AuthUser | null> {
if (token.startsWith('tessera_')) {
return await verifyApiToken(db, token);
}
return await verifyJwt(token);
}
async function requireAuth(c: Context, next: Next) {
const token = extractToken(c);
if (!token) {
throw new HTTPException(401, { message: 'Authentication required' });
}
const user = await verifyToken(token);
if (!user) {
throw new HTTPException(401, { message: 'Invalid or expired token' });
}
c.set('user', user);
await next();
}
async function requireAdmin(c: Context, next: Next) {
const token = extractToken(c);
if (!token) {
throw new HTTPException(401, { message: 'Authentication required' });
}
const user = await verifyToken(token);
if (!user) {
throw new HTTPException(401, { message: 'Invalid or expired token' });
}
if (user.role !== 'admin') {
throw new HTTPException(403, { message: 'Admin access required' });
}
c.set('user', user);
await next();
}
async function optionalAuth(c: Context, next: Next) {
const token = extractToken(c);
if (token) {
const user = await verifyToken(token);
if (user) {
c.set('user', user);
}
}
await next();
}
return { requireAuth, requireAdmin, optionalAuth };
}
export function getUserId(c: Context): string {
const user = c.get('user');
return user?.userId ?? '00000000-0000-0000-0000-000000000000';
}

86
src/auth/permissions.ts Normal file
View File

@@ -0,0 +1,86 @@
import { HTTPException } from 'hono/http-exception';
import type { Context } from 'hono';
import type { Db } from '../db/index.ts';
import { teamMembers, queuePermissions, userPermissions } from '../db/schema.ts';
import { and, eq, inArray } from 'drizzle-orm';
import type { AuthUser } from './middleware.ts';
export type TicketRight = 'ticket.view' | 'ticket.create' | 'ticket.reply' | 'ticket.comment' | 'ticket.modify' | 'queue.admin';
const RIGHT_HIERARCHY: Record<TicketRight, TicketRight[]> = {
'queue.admin': ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'],
'ticket.modify': ['ticket.view', 'ticket.reply', 'ticket.comment', 'ticket.modify'],
'ticket.reply': ['ticket.view', 'ticket.reply'],
'ticket.comment': ['ticket.view', 'ticket.comment'],
'ticket.create': ['ticket.create'],
'ticket.view': ['ticket.view'],
};
/**
* Check whether a user has a specific right on a queue.
* Admins bypass all permission checks.
* Rights come from two sources: team memberships and per-user grants.
* Higher rights imply lower rights (e.g., queue.admin implies ticket.view).
*/
export async function userHasRight(
db: Db,
user: AuthUser,
queueId: string,
right: TicketRight,
): Promise<boolean> {
// Admins have all rights
if (user.role === 'admin') return true;
const neededRights = RIGHT_HIERARCHY[right] ?? [right];
// Check per-user permissions first (direct grant)
const userPerm = await db.query.userPermissions.findFirst({
where: (table, { and, eq: eqFn, inArray: inArr }) =>
and(
eqFn(table.user_id, user.userId),
eqFn(table.queue_id, queueId),
inArr(table.right_name, neededRights),
),
});
if (userPerm) return true;
// Check team permissions (inherited)
const memberships = await db.query.teamMembers.findMany({
where: eq(teamMembers.user_id, user.userId),
});
const teamIds = memberships.map((m) => m.team_id);
if (teamIds.length === 0) return false;
const teamPerm = await db.query.queuePermissions.findFirst({
where: (table, { and, eq: eqFn, inArray: inArr }) =>
and(
inArr(table.team_id, teamIds),
eqFn(table.queue_id, queueId),
inArr(table.right_name, neededRights),
),
});
return teamPerm !== undefined;
}
/**
* Require a specific right on a queue. Throws 403 if the user lacks the right.
*/
export async function requireRight(
c: Context,
db: Db,
queueId: string,
right: TicketRight,
): Promise<void> {
const user = c.get('user');
if (!user) {
throw new HTTPException(401, { message: 'Authentication required' });
}
const has = await userHasRight(db, user, queueId, right);
if (!has) {
throw new HTTPException(403, { message: `Missing required right: ${right}` });
}
}

View File

@@ -9,6 +9,8 @@ const configSchema = z.object({
SMTP_USER: z.string().optional(), SMTP_USER: z.string().optional(),
SMTP_PASS: z.string().optional(), SMTP_PASS: z.string().optional(),
SMTP_FROM: z.string().default('tessera@localhost'), SMTP_FROM: z.string().default('tessera@localhost'),
UPLOAD_DIR: z.string().default('./data/uploads'),
JWT_SECRET: z.string().default('tessera-dev-secret-change-in-production'),
}); });
export const config = configSchema.parse(process.env); export const config = configSchema.parse(process.env);

View File

@@ -5,6 +5,8 @@ export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
username: text('username').notNull().unique(), username: text('username').notNull().unique(),
email: text('email'), email: text('email'),
password_hash: text('password_hash'),
role: text('role').notNull().default('staff'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}); });
@@ -49,6 +51,7 @@ export const transactions = pgTable('transactions', {
old_value: text('old_value'), old_value: text('old_value'),
new_value: text('new_value'), new_value: text('new_value'),
data: jsonb('data'), data: jsonb('data'),
time_worked_minutes: integer('time_worked_minutes').default(0),
creator_id: uuid('creator_id').notNull().references(() => users.id), creator_id: uuid('creator_id').notNull().references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({ }, (table) => ({
@@ -78,6 +81,7 @@ export const scrips = pgTable('scrips', {
stage: text('stage').notNull().default('TransactionCreate'), stage: text('stage').notNull().default('TransactionCreate'),
sort_order: integer('sort_order').notNull().default(0), sort_order: integer('sort_order').notNull().default(0),
disabled: boolean('disabled').notNull().default(false), disabled: boolean('disabled').notNull().default(false),
applicable_trans_types: text('applicable_trans_types'),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({ }, (table) => ({
queueIdIdx: index('scrips_queue_id_idx').on(table.queue_id), queueIdIdx: index('scrips_queue_id_idx').on(table.queue_id),
@@ -160,6 +164,78 @@ export const dashboards = pgTable('dashboards', {
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}); });
export const transactionAttachments = pgTable('transaction_attachments', {
id: uuid('id').primaryKey().defaultRandom(),
transaction_id: uuid('transaction_id').references(() => transactions.id, { onDelete: 'cascade' }),
filename: text('filename').notNull(),
mime_type: text('mime_type').notNull().default('application/octet-stream'),
size_bytes: integer('size_bytes').notNull().default(0),
storage_path: text('storage_path').notNull(),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
transactionIdIdx: index('transaction_attachments_tx_id_idx').on(table.transaction_id),
}));
export const queuePermissions = pgTable('queue_permissions', {
id: uuid('id').primaryKey().defaultRandom(),
queue_id: uuid('queue_id').notNull().references(() => queues.id, { onDelete: 'cascade' }),
team_id: uuid('team_id').notNull().references(() => teams.id, { onDelete: 'cascade' }),
right_name: text('right_name').notNull(),
}, (table) => ({
uniqueRight: unique('queue_permissions_queue_team_right_unique').on(table.queue_id, table.team_id, table.right_name),
queueIdIdx: index('queue_permissions_queue_id_idx').on(table.queue_id),
teamIdIdx: index('queue_permissions_team_id_idx').on(table.team_id),
}));
export const apiTokens = pgTable('api_tokens', {
id: uuid('id').primaryKey().defaultRandom(),
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
token_hash: text('token_hash').notNull().unique(),
last_used_at: timestamp('last_used_at', { withTimezone: true }),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
userIdIdx: index('api_tokens_user_id_idx').on(table.user_id),
}));
export const notifications = pgTable('notifications', {
id: uuid('id').primaryKey().defaultRandom(),
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
ticket_id: integer('ticket_id').references(() => tickets.id, { onDelete: 'cascade' }),
type: text('type').notNull(), // 'assigned', 'mentioned', 'commented', 'scrip_fired'
title: text('title').notNull(),
body: text('body'),
read: boolean('read').notNull().default(false),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
userIdIdx: index('notifications_user_id_idx').on(table.user_id),
unreadIdx: index('notifications_user_read_idx').on(table.user_id, table.read),
}));
export const userPermissions = pgTable('user_permissions', {
id: uuid('id').primaryKey().defaultRandom(),
queue_id: uuid('queue_id').notNull().references(() => queues.id, { onDelete: 'cascade' }),
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
right_name: text('right_name').notNull(),
}, (table) => ({
uniqueRight: unique('user_permissions_queue_user_right_unique').on(table.queue_id, table.user_id, table.right_name),
queueIdIdx: index('user_permissions_queue_id_idx').on(table.queue_id),
userIdIdx: index('user_permissions_user_id_idx').on(table.user_id),
}));
export const ticketLinks = pgTable('ticket_links', {
id: uuid('id').primaryKey().defaultRandom(),
ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
target_ticket_id: integer('target_ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
link_type: text('link_type').notNull(),
creator_id: uuid('creator_id').notNull().references(() => users.id),
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
}, (table) => ({
uniqueLink: unique('ticket_links_ticket_target_type_unique').on(table.ticket_id, table.target_ticket_id, table.link_type),
ticketIdIdx: index('ticket_links_ticket_id_idx').on(table.ticket_id),
targetTicketIdIdx: index('ticket_links_target_ticket_id_idx').on(table.target_ticket_id),
}));
export const dashboardWidgets = pgTable('dashboard_widgets', { export const dashboardWidgets = pgTable('dashboard_widgets', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }), dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }),

View File

@@ -7,8 +7,11 @@ import {
customFieldValues, customFieldValues,
lifecycles, lifecycles,
queueCustomFields, queueCustomFields,
queuePermissions,
queues, queues,
scrips, scrips,
teamMembers,
teams,
templates, templates,
tickets, tickets,
transactions, transactions,
@@ -52,7 +55,7 @@ function createSeedDb(pool: Pool) {
} }
type Db = ReturnType<typeof createSeedDb>; type Db = ReturnType<typeof createSeedDb>;
type UserSeed = { id: string; username: string; email: string }; type UserSeed = { id: string; username: string; email: string; role?: string; password_hash?: string };
type QueueSeed = { name: string; description: string }; type QueueSeed = { name: string; description: string };
type FieldSeed = { type FieldSeed = {
key?: string; key?: string;
@@ -73,12 +76,19 @@ function makeFieldKey(value: string): string {
} }
async function ensureUser(db: Db, seed: UserSeed): Promise<string> { async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
const setData = {
username: seed.username,
email: seed.email,
role: seed.role ?? 'staff',
password_hash: seed.password_hash ?? null,
};
const existingById = await db.query.users.findFirst({ const existingById = await db.query.users.findFirst({
where: eq(users.id, seed.id), where: eq(users.id, seed.id),
}); });
if (existingById) { if (existingById) {
await db.update(users) await db.update(users)
.set({ username: seed.username, email: seed.email }) .set(setData)
.where(eq(users.id, seed.id)); .where(eq(users.id, seed.id));
return existingById.id; return existingById.id;
} }
@@ -88,12 +98,12 @@ async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
}); });
if (existingByUsername) { if (existingByUsername) {
await db.update(users) await db.update(users)
.set({ email: seed.email }) .set(setData)
.where(eq(users.id, existingByUsername.id)); .where(eq(users.id, existingByUsername.id));
return existingByUsername.id; return existingByUsername.id;
} }
const [created] = await db.insert(users).values(seed).returning(); const [created] = await db.insert(users).values({ ...seed, ...setData }).returning();
if (!created) throw new Error(`Failed to seed user ${seed.username}`); if (!created) throw new Error(`Failed to seed user ${seed.username}`);
return created.id; return created.id;
} }
@@ -315,6 +325,7 @@ async function ensureTicket(
async function resetDatabase(db: Db) { async function resetDatabase(db: Db) {
await db.delete(customFieldValues); await db.delete(customFieldValues);
await db.delete(queuePermissions);
await db.delete(transactions); await db.delete(transactions);
await db.delete(queueCustomFields); await db.delete(queueCustomFields);
await db.delete(dashboardWidgets); await db.delete(dashboardWidgets);
@@ -346,34 +357,68 @@ async function main() {
await resetDatabase(db); await resetDatabase(db);
} }
const userPassword = await Bun.password.hash('password');
const adminPassword = await Bun.password.hash('admin');
const userIds = { const userIds = {
system: await ensureUser(db, { system: await ensureUser(db, {
id: SYSTEM_USER_ID, id: SYSTEM_USER_ID,
username: 'system', username: 'system',
email: 'system@tessera.local', email: 'system@tessera.local',
}), }),
admin: await ensureUser(db, {
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
username: 'admin',
email: 'admin@tessera.local',
role: 'admin',
password_hash: adminPassword,
}),
dispatcher: await ensureUser(db, { dispatcher: await ensureUser(db, {
id: '11111111-1111-4111-8111-111111111111', id: '11111111-1111-4111-8111-111111111111',
username: 'maria.dispatch', username: 'maria.dispatch',
email: 'maria.dispatch@tessera.local', email: 'maria.dispatch@tessera.local',
password_hash: userPassword,
}), }),
technician: await ensureUser(db, { technician: await ensureUser(db, {
id: '22222222-2222-4222-8222-222222222222', id: '22222222-2222-4222-8222-222222222222',
username: 'liam.field', username: 'liam.field',
email: 'liam.field@tessera.local', email: 'liam.field@tessera.local',
password_hash: userPassword,
}), }),
facilities: await ensureUser(db, { facilities: await ensureUser(db, {
id: '33333333-3333-4333-8333-333333333333', id: '33333333-3333-4333-8333-333333333333',
username: 'nora.facilities', username: 'nora.facilities',
email: 'nora.facilities@tessera.local', email: 'nora.facilities@tessera.local',
password_hash: userPassword,
}), }),
security: await ensureUser(db, { security: await ensureUser(db, {
id: '44444444-4444-4444-8444-444444444444', id: '44444444-4444-4444-8444-444444444444',
username: 'sam.security', username: 'sam.security',
email: 'sam.security@tessera.local', email: 'sam.security@tessera.local',
password_hash: userPassword,
}), }),
}; };
// Create demo team and assign all staff users to it
const [supportTeam] = await db.insert(teams).values({
name: 'Support team',
description: 'Demo support team with full queue access',
}).onConflictDoUpdate({
target: teams.name,
set: { description: 'Demo support team with full queue access' },
}).returning();
if (supportTeam) {
// Add all staff users to the team
const staffIds = [userIds.dispatcher, userIds.technician, userIds.facilities, userIds.security];
for (const userId of staffIds) {
await db.insert(teamMembers).values({
team_id: supportTeam.id,
user_id: userId,
}).onConflictDoNothing();
}
}
const lifecycle = await ensureLifecycle(db); const lifecycle = await ensureLifecycle(db);
const supportQueue = await ensureQueue(db, lifecycle.id, { const supportQueue = await ensureQueue(db, lifecycle.id, {
@@ -432,6 +477,20 @@ async function main() {
await attachFieldToQueue(db, fieldQueue.id, assetField.id, 40); await attachFieldToQueue(db, fieldQueue.id, assetField.id, 40);
await attachFieldToQueue(db, supportQueue.id, outcomeField.id, 50); await attachFieldToQueue(db, supportQueue.id, outcomeField.id, 50);
// Grant the support team full access to all demo queues
if (supportTeam) {
const allRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify'];
for (const queue of [supportQueue, fieldQueue, facilitiesQueue, securityQueue]) {
for (const right of allRights) {
await db.insert(queuePermissions).values({
queue_id: queue.id,
team_id: supportTeam.id,
right_name: right,
}).onConflictDoNothing();
}
}
}
const resolveTemplate = await ensureTemplate( const resolveTemplate = await ensureTemplate(
db, db,
'Demo resolution note', 'Demo resolution note',

View File

@@ -4,6 +4,7 @@ import { createDb } from './db/index.ts';
import type { Db } from './db/index.ts'; import type { Db } from './db/index.ts';
import { errorHandler } from './middleware/error.ts'; import { errorHandler } from './middleware/error.ts';
import { requestLogger } from './middleware/logging.ts'; import { requestLogger } from './middleware/logging.ts';
import { createAuthMiddleware } from './auth/middleware.ts';
import healthRouter from './routes/health.ts'; import healthRouter from './routes/health.ts';
import { createTicketsRouter } from './routes/tickets.ts'; import { createTicketsRouter } from './routes/tickets.ts';
import { createQueuesRouter } from './routes/queues.ts'; import { createQueuesRouter } from './routes/queues.ts';
@@ -15,6 +16,11 @@ import { createTemplatesRouter } from './routes/templates.ts';
import { createViewsRouter } from './routes/views.ts'; import { createViewsRouter } from './routes/views.ts';
import { createDashboardsRouter } from './routes/dashboards.ts'; import { createDashboardsRouter } from './routes/dashboards.ts';
import { createTeamsRouter } from './routes/teams.ts'; import { createTeamsRouter } from './routes/teams.ts';
import { createAttachmentsRouter } from './routes/attachments.ts';
import { createAuthRouter } from './routes/auth.ts';
import { createQueuePermissionsRouter } from './routes/queue-permissions.ts';
import { createNotificationsRouter } from './routes/notifications.ts';
import { startScheduler } from './scrip/scheduler.ts';
let db: Db | null = null; let db: Db | null = null;
@@ -30,17 +36,39 @@ const app = new Hono();
app.use('*', requestLogger); app.use('*', requestLogger);
app.onError(errorHandler); app.onError(errorHandler);
const { requireAuth, requireAdmin } = createAuthMiddleware(getDb());
// Public routes
app.route('/health', healthRouter); app.route('/health', healthRouter);
app.route('/tickets', createTicketsRouter(getDb())); app.route('/', createAuthRouter(getDb()));
app.route('/queues', createQueuesRouter(getDb()));
app.route('/scrips', createScripsRouter(getDb())); // Ticket routes — require authentication
app.route('/custom-fields', createCustomFieldsRouter(getDb())); const ticketsWithAuth = new Hono();
app.route('/lifecycles', createLifecyclesRouter(getDb())); ticketsWithAuth.use('*', requireAuth);
app.route('/users', createUsersRouter(getDb())); ticketsWithAuth.route('/tickets', createTicketsRouter(getDb()));
app.route('/templates', createTemplatesRouter(getDb())); ticketsWithAuth.route('/', createNotificationsRouter(getDb()));
app.route('/views', createViewsRouter(getDb())); app.route('/', ticketsWithAuth);
app.route('/dashboards', createDashboardsRouter(getDb()));
app.route('/teams', createTeamsRouter(getDb())); // Attachment serving — require authentication
const attachmentsWithAuth = new Hono();
attachmentsWithAuth.use('*', requireAuth);
attachmentsWithAuth.route('/', createAttachmentsRouter(getDb()));
app.route('/', attachmentsWithAuth);
// Admin routes — require admin role
const admin = new Hono();
admin.use('*', requireAdmin);
admin.route('/queues', createQueuesRouter(getDb()));
admin.route('/scrips', createScripsRouter(getDb()));
admin.route('/custom-fields', createCustomFieldsRouter(getDb()));
admin.route('/lifecycles', createLifecyclesRouter(getDb()));
admin.route('/users', createUsersRouter(getDb()));
admin.route('/templates', createTemplatesRouter(getDb()));
admin.route('/views', createViewsRouter(getDb()));
admin.route('/dashboards', createDashboardsRouter(getDb()));
admin.route('/teams', createTeamsRouter(getDb()));
admin.route('/', createQueuePermissionsRouter(getDb()));
app.route('/', admin);
export default app; export default app;
export { app }; export { app };
@@ -54,4 +82,7 @@ if (Bun.main === import.meta.path) {
development: false, development: false,
}); });
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`); console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
// Start the scrip scheduler (runs every 5 minutes)
startScheduler(getDb());
} }

View File

@@ -5,13 +5,17 @@ export interface LifecycleDefinition {
inactive: string[]; inactive: string[];
}; };
transitions: Record<string, string[]>; transitions: Record<string, string[]>;
transition_rights?: Record<string, string>; // "from→to" → rightName
} }
export interface ValidationResult { export interface ValidationResult {
valid: boolean; valid: boolean;
error?: string; error?: string;
requiredRight?: string; // Named right required for this transition, if any
} }
const FALLBACK_RIGHT = 'ticket.modify';
export class LifecycleValidator { export class LifecycleValidator {
validateTransition( validateTransition(
lifecycleDef: LifecycleDefinition, lifecycleDef: LifecycleDefinition,
@@ -35,13 +39,15 @@ export class LifecycleValidator {
const allowedTransitions = this.getAllowedTransitions(lifecycleDef, fromStatus); const allowedTransitions = this.getAllowedTransitions(lifecycleDef, fromStatus);
if (allowedTransitions.includes(toStatus)) { if (allowedTransitions.includes(toStatus)) {
return { valid: true }; const right = this.getRequiredRight(lifecycleDef, fromStatus, toStatus);
return { valid: true, requiredRight: right ?? FALLBACK_RIGHT };
} }
// Also handle wildcard "*" -> any transition // Also handle wildcard "*" -> any transition
const wildcardTransitions = this.getAllowedTransitions(lifecycleDef, '*'); const wildcardTransitions = this.getAllowedTransitions(lifecycleDef, '*');
if (wildcardTransitions.includes(toStatus)) { if (wildcardTransitions.includes(toStatus)) {
return { valid: true }; const right = this.getRequiredRight(lifecycleDef, fromStatus, toStatus);
return { valid: true, requiredRight: right ?? FALLBACK_RIGHT };
} }
return { return {
@@ -50,6 +56,37 @@ export class LifecycleValidator {
}; };
} }
/**
* Get the required right for a transition using RT's 4-level priority:
* 1. exact "from→to"
* 2. wildcard from "*→to"
* 3. wildcard to "from→*"
* 4. full wildcard "*→*"
* 5. fallback: ticket.modify
*/
getRequiredRight(
lifecycleDef: LifecycleDefinition,
fromStatus: string,
toStatus: string,
): string | null {
const rights = lifecycleDef.transition_rights ?? {};
// Priority 1: exact match
if (rights[`${fromStatus}${toStatus}`]) return rights[`${fromStatus}${toStatus}`];
// Priority 2: wildcard from
if (rights[`*→${toStatus}`]) return rights[`*→${toStatus}`];
// Priority 3: wildcard to
if (rights[`${fromStatus}→*`]) return rights[`${fromStatus}→*`];
// Priority 4: full wildcard
if (rights['*→*']) return rights['*→*'];
// Priority 5: fallback
return null;
}
isResolvedStatus(lifecycleDef: LifecycleDefinition, status: string): boolean { isResolvedStatus(lifecycleDef: LifecycleDefinition, status: string): boolean {
return lifecycleDef.statuses.inactive.includes(status); return lifecycleDef.statuses.inactive.includes(status);
} }
@@ -58,16 +95,12 @@ export class LifecycleValidator {
lifecycleDef: LifecycleDefinition, lifecycleDef: LifecycleDefinition,
fromStatus: string, fromStatus: string,
): string[] { ): string[] {
// Direct transition
if (lifecycleDef.transitions[fromStatus]) { if (lifecycleDef.transitions[fromStatus]) {
return lifecycleDef.transitions[fromStatus]!; return lifecycleDef.transitions[fromStatus]!;
} }
// Wildcard transitions
if (lifecycleDef.transitions['*']) { if (lifecycleDef.transitions['*']) {
return lifecycleDef.transitions['*']!; return lifecycleDef.transitions['*']!;
} }
return []; return [];
} }
} }

View File

@@ -22,4 +22,6 @@ export const CommentSchema = z.object({
body: z.string().min(1), body: z.string().min(1),
creator_id: z.string().optional().default('00000000-0000-0000-0000-000000000000'), creator_id: z.string().optional().default('00000000-0000-0000-0000-000000000000'),
internal: z.boolean().optional().default(false), internal: z.boolean().optional().default(false),
attachment_ids: z.array(z.string()).optional(),
time_worked_minutes: z.number().int().min(0).optional(),
}); });

View File

@@ -11,6 +11,8 @@ export const TransactionType = {
Comment: 'Comment', Comment: 'Comment',
CustomField: 'CustomField', CustomField: 'CustomField',
Correspond: 'Correspond', Correspond: 'Correspond',
LinkCreate: 'LinkCreate',
LinkDelete: 'LinkDelete',
} as const; } as const;
export type TransactionType = (typeof TransactionType)[keyof typeof TransactionType]; export type TransactionType = (typeof TransactionType)[keyof typeof TransactionType];

190
src/routes/attachments.ts Normal file
View File

@@ -0,0 +1,190 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { existsSync, mkdirSync, createReadStream } from 'node:fs';
import { join, extname } from 'node:path';
import { writeFile, unlink } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import type { Db } from '../db/index.ts';
import { config } from '../config.ts';
import { transactionAttachments, transactions, tickets } from '../db/schema.ts';
import { eq, inArray } from 'drizzle-orm';
function ensureDir(dir: string) {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
function storageDir(): string {
const now = new Date();
const year = now.getFullYear().toString();
const month = String(now.getMonth() + 1).padStart(2, '0');
const dir = join(config.UPLOAD_DIR, year, month);
ensureDir(dir);
return dir;
}
const MIME_MAP: Record<string, string> = {
'.txt': 'text/plain',
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json',
'.xml': 'application/xml',
'.pdf': 'application/pdf',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
'.zip': 'application/zip',
'.gz': 'application/gzip',
'.tar': 'application/x-tar',
'.csv': 'text/csv',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.mp4': 'video/mp4',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.mov': 'video/quicktime',
'.avif': 'image/avif',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.log': 'text/plain',
'.md': 'text/markdown',
'.yaml': 'text/yaml',
'.yml': 'text/yaml',
};
function guessMimeType(filename: string): string {
const ext = extname(filename).toLowerCase();
return MIME_MAP[ext] || 'application/octet-stream';
}
export function createAttachmentsRouter(db: Db): Hono {
const router = new Hono();
// POST /tickets/:id/attachments — upload files (returns metadata, no transaction created yet)
router.post('/tickets/:id/attachments', async (c) => {
const ticketId = Number(c.req.param('id'));
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
const formData = await c.req.formData();
const files = formData.getAll('files') as File[];
if (files.length === 0) {
throw new HTTPException(422, { message: 'No files provided' });
}
const dir = storageDir();
const result: Array<{
id: string;
filename: string;
mime_type: string;
size_bytes: number;
}> = [];
for (const file of files) {
if (!(file instanceof File)) continue;
const ext = extname(file.name);
const storedName = `${randomUUID()}${ext}`;
const storagePath = join(dir, storedName);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(storagePath, buffer);
const [saved] = await db.insert(transactionAttachments).values({
filename: file.name,
mime_type: file.type || guessMimeType(file.name),
size_bytes: buffer.length,
storage_path: storagePath,
}).returning();
if (saved) {
result.push({
id: saved.id,
filename: saved.filename,
mime_type: saved.mime_type,
size_bytes: saved.size_bytes,
});
}
}
return c.json({ attachments: result }, 201);
});
// GET /attachments/:id — serve/download an attachment
router.get('/attachments/:id', async (c) => {
const attachmentId = c.req.param('id');
const attachment = await db.query.transactionAttachments.findFirst({
where: eq(transactionAttachments.id, attachmentId),
});
if (!attachment) {
throw new HTTPException(404, { message: 'Attachment not found' });
}
if (!existsSync(attachment.storage_path)) {
throw new HTTPException(404, { message: 'Attachment file not found on disk' });
}
const disposition = c.req.query('download') === 'true' ? 'attachment' : 'inline';
const stream = createReadStream(attachment.storage_path);
return new Response(stream as any, {
status: 200,
headers: {
'Content-Type': attachment.mime_type,
'Content-Disposition': `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`,
'Content-Length': String(attachment.size_bytes),
},
});
});
// GET /tickets/:id/attachments — list attachments for a ticket
router.get('/tickets/:id/attachments', async (c) => {
const ticketId = Number(c.req.param('id'));
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
const ticketTransactions = await db.query.transactions.findMany({
where: eq(transactions.ticket_id, ticketId),
});
const txIds = ticketTransactions.map((tx) => tx.id);
if (txIds.length === 0) {
return c.json([]);
}
const attachments = await Promise.all(
txIds.map((txId) =>
db.query.transactionAttachments.findMany({
where: eq(transactionAttachments.transaction_id, txId),
})
)
);
return c.json(attachments.flat());
});
return router;
}

132
src/routes/auth.ts Normal file
View File

@@ -0,0 +1,132 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { z } from 'zod/v4';
import type { Db } from '../db/index.ts';
import { users, apiTokens } from '../db/schema.ts';
import { eq, desc, sql } from 'drizzle-orm';
import { createToken, createAuthMiddleware } from '../auth/middleware.ts';
const LoginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export function createAuthRouter(db: Db): Hono {
const router = new Hono();
const { requireAuth } = createAuthMiddleware(db);
// POST /auth/login
router.post('/auth/login', async (c) => {
const body = await c.req.json();
const parsed = LoginSchema.parse(body);
const user = await db.query.users.findFirst({
where: eq(users.username, parsed.username),
});
if (!user || !user.password_hash) {
throw new HTTPException(401, { message: 'Invalid username or password' });
}
const valid = await Bun.password.verify(parsed.password, user.password_hash);
if (!valid) {
throw new HTTPException(401, { message: 'Invalid username or password' });
}
const token = await createToken({
id: user.id,
username: user.username,
role: user.role,
});
return c.json({
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role,
},
});
});
// GET /auth/me — return current user from token
router.get('/auth/me', requireAuth, async (c) => {
const authUser = c.get('user');
const user = await db.query.users.findFirst({
where: eq(users.id, authUser.userId),
});
if (!user) {
throw new HTTPException(404, { message: 'User not found' });
}
return c.json({
id: user.id,
username: user.username,
email: user.email,
role: user.role,
});
});
// POST /auth/tokens — create API token
router.post('/auth/tokens', requireAuth, async (c) => {
const body = await c.req.json();
const name = String(body.name || 'API token').trim();
const authUser = c.get('user');
const rawToken = `tessera_${crypto.randomUUID().replace(/-/g, '')}`;
const tokenHash = await Bun.password.hash(rawToken);
const [token] = await db.insert(apiTokens).values({
user_id: authUser.userId,
name,
token_hash: tokenHash,
}).returning();
if (!token) {
throw new HTTPException(500, { message: 'Failed to create token' });
}
return c.json({
id: token.id,
name: token.name,
token: rawToken,
created_at: token.created_at,
}, 201);
});
// GET /auth/tokens — list tokens
router.get('/auth/tokens', requireAuth, async (c) => {
const authUser = c.get('user');
const result = await db.query.apiTokens.findMany({
where: eq(apiTokens.user_id, authUser.userId),
orderBy: desc(apiTokens.created_at),
});
return c.json(result.map((t) => ({
id: t.id,
name: t.name,
last_used_at: t.last_used_at,
created_at: t.created_at,
})));
});
// DELETE /auth/tokens/:id — revoke token
router.delete('/auth/tokens/:id', requireAuth, async (c) => {
const id = c.req.param('id');
const authUser = c.get('user');
// Verify ownership before revoke
const allTokens = await db.query.apiTokens.findMany();
const existing = allTokens.find((t) => t.id === id && t.user_id === authUser.userId);
if (!existing) {
throw new HTTPException(404, { message: 'Token not found' });
}
// Raw delete to avoid Drizzle type issue with new apiTokens table
await db.execute(sql`DELETE FROM api_tokens WHERE id = ${id}`);
return c.json({ ok: true });
});
return router;
}

View File

@@ -276,6 +276,30 @@ export function createDashboardsRouter(db: Db): Hono {
} }
} }
// Widget-level filters override or add to view filters
const widgetFilters = (widget.config as Record<string, unknown>)?.filters as Array<{ field: string; operator: string; value: string }> | undefined;
if (widgetFilters) {
for (const f of widgetFilters) {
if (f.field === 'status') {
if (f.operator === 'is_not') result = result.filter((t) => t.status !== f.value);
else result = result.filter((t) => t.status === f.value);
} else if (f.field === 'queue') {
if (f.operator === 'is_not') result = result.filter((t) => t.queue_id !== f.value);
else result = result.filter((t) => t.queue_id === f.value);
} else if (f.field === 'owner') {
if (f.value === 'unassigned') result = result.filter((t) => !t.owner_id);
else result = result.filter((t) => t.owner_id === f.value);
} else if (f.field === 'q') {
const q = f.value.toLowerCase();
result = result.filter((t) =>
t.subject.toLowerCase().includes(q) ||
String(t.id).includes(q) ||
(queueName.get(t.queue_id) ?? '').toLowerCase().includes(q)
);
}
}
}
const limit = (widget.config as Record<string, unknown>)?.limit as number ?? 5; const limit = (widget.config as Record<string, unknown>)?.limit as number ?? 5;
// Find lifecycle for status classification // Find lifecycle for status classification
@@ -337,6 +361,61 @@ export function createDashboardsRouter(db: Db): Hono {
return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id }); return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id });
} }
case 'my_tickets': {
const authUser = c.get('user');
const myTickets = result.filter((t) => t.owner_id === authUser.userId);
return c.json({ type: 'my_tickets', total: myTickets.length, title: widget.title, view_id: view.id });
}
case 'trend_chart': {
const period = ((widget.config as Record<string, unknown>)?.period as string) ?? 'day';
const days = (widget.config as Record<string, unknown>)?.days as number ?? 30;
const trendField = ((widget.config as Record<string, unknown>)?.field as string) ?? 'created_at';
const now = new Date();
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
const filtered = result.filter((t) => {
const d = trendField === 'updated_at' ? t.updated_at : t.created_at;
return d && new Date(d) >= start;
});
const points: Record<string, number> = {};
for (const t of filtered) {
const d = new Date(trendField === 'updated_at' ? t.updated_at! : t.created_at!);
let key: string;
if (period === 'week') {
const weekStart = new Date(d);
weekStart.setDate(d.getDate() - d.getDay());
key = weekStart.toISOString().slice(0, 10);
} else {
key = d.toISOString().slice(0, 10); // YYYY-MM-DD
}
points[key] = (points[key] ?? 0) + 1;
}
return c.json({ type: 'trend_chart', counts: points, total: result.length, title: widget.title, view_id: view.id });
}
case 'overdue': {
const dateFieldKey = (widget.config as Record<string, unknown>)?.field_key as string;
const now = new Date();
const overdue = result.filter((t) => {
if (!dateFieldKey) {
// No specific field — check if any inactive-adjacent status
const lc = lifecycleByQueue.get(t.queue_id);
if (lc) {
const inactive = lc.statuses.inactive;
if (inactive.includes(t.status)) return false; // already resolved
}
// Check if updated_at is older than 7 days
const updated = t.updated_at ? new Date(t.updated_at) : new Date(0);
return (now.getTime() - updated.getTime()) > 7 * 24 * 60 * 60 * 1000;
}
return false; // Would need CF value lookup for date field
});
return c.json({ type: 'overdue', total: overdue.length, title: widget.title, view_id: view.id });
}
case 'grouped_counts': { case 'grouped_counts': {
const groupBy = (widget.config as Record<string, unknown>)?.group_by as string ?? 'owner'; const groupBy = (widget.config as Record<string, unknown>)?.group_by as string ?? 'owner';
const groups: Record<string, number> = {}; const groups: Record<string, number> = {};

View File

@@ -0,0 +1,64 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts';
import { notifications } from '../db/schema.ts';
import { and, eq, desc } from 'drizzle-orm';
export function createNotificationsRouter(db: Db): Hono {
const router = new Hono();
// GET /notifications — list notifications for current user
router.get('/notifications', async (c) => {
const user = c.get('user');
const result = await db.query.notifications.findMany({
where: eq(notifications.user_id, user.userId),
orderBy: desc(notifications.created_at),
// Return last 50
});
return c.json(result.slice(0, 50));
});
// GET /notifications/unread-count
router.get('/notifications/unread-count', async (c) => {
const user = c.get('user');
const result = await db.query.notifications.findMany({
where: and(
eq(notifications.user_id, user.userId),
eq(notifications.read, false),
),
});
return c.json({ count: result.length });
});
// PATCH /notifications/:id/read — mark as read
router.patch('/notifications/:id/read', async (c) => {
const id = c.req.param('id');
await db.update(notifications).set({ read: true }).where(eq(notifications.id, id));
return c.json({ ok: true });
});
// PATCH /notifications/read-all — mark all as read
router.patch('/notifications/read-all', async (c) => {
const user = c.get('user');
await db.update(notifications)
.set({ read: true })
.where(eq(notifications.user_id, user.userId));
return c.json({ ok: true });
});
return router;
}
// Helper to create notifications (used by other routes)
export async function createNotification(
db: Db,
data: { user_id: string; ticket_id?: number; type: string; title: string; body?: string },
) {
await db.insert(notifications).values({
user_id: data.user_id,
ticket_id: data.ticket_id ?? null,
type: data.type,
title: data.title,
body: data.body ?? null,
});
}

View File

@@ -0,0 +1,176 @@
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import type { Db } from '../db/index.ts';
import { queuePermissions, userPermissions, teams, queues, users } from '../db/schema.ts';
import { eq } from 'drizzle-orm';
export function createQueuePermissionsRouter(db: Db): Hono {
const router = new Hono();
// GET /queue-permissions — list all permissions (with team + queue names)
router.get('/queue-permissions', async (c) => {
const all = await db.query.queuePermissions.findMany();
// Enrich with names
const teamIds = [...new Set(all.map((p) => p.team_id))];
const queueIds = [...new Set(all.map((p) => p.queue_id))];
const teamList = teamIds.length > 0
? await db.query.teams.findMany({ where: (t, { inArray }) => inArray(t.id, teamIds) })
: [];
const queueList = queueIds.length > 0
? await db.query.queues.findMany({ where: (t, { inArray }) => inArray(t.id, queueIds) })
: [];
const teamById = new Map(teamList.map((t) => [t.id, t]));
const queueById = new Map(queueList.map((q) => [q.id, q]));
const enriched = all.map((p) => ({
...p,
team_name: teamById.get(p.team_id)?.name ?? p.team_id,
queue_name: queueById.get(p.queue_id)?.name ?? p.queue_id,
}));
return c.json(enriched);
});
// GET /queue-permissions/teams-and-queues — return teams and queues for the form
router.get('/queue-permissions/teams-and-queues', async (c) => {
const [teamList, queueList] = await Promise.all([
db.query.teams.findMany(),
db.query.queues.findMany(),
]);
return c.json({ teams: teamList, queues: queueList });
});
// POST /queue-permissions — grant a right
router.post('/queue-permissions', async (c) => {
const body = await c.req.json();
const { queue_id, team_id, right_name } = body;
if (!queue_id || !team_id || !right_name) {
throw new HTTPException(422, { message: 'queue_id, team_id, and right_name are required' });
}
const validRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'];
if (!validRights.includes(right_name)) {
throw new HTTPException(422, { message: `Invalid right. Must be one of: ${validRights.join(', ')}` });
}
// Check for duplicate
const existing = await db.query.queuePermissions.findFirst({
where: (table, { and, eq: eqFn }) =>
and(
eqFn(table.queue_id, queue_id),
eqFn(table.team_id, team_id),
eqFn(table.right_name, right_name),
),
});
if (existing) {
return c.json(existing); // Idempotent — return existing
}
const [perm] = await db.insert(queuePermissions).values({
queue_id,
team_id,
right_name,
}).returning();
if (!perm) {
throw new HTTPException(500, { message: 'Failed to create permission' });
}
return c.json(perm, 201);
});
// DELETE /queue-permissions/:id — revoke a right
router.delete('/queue-permissions/:id', async (c) => {
const id = c.req.param('id');
const existing = await db.query.queuePermissions.findFirst({
where: eq(queuePermissions.id, id),
});
if (!existing) {
throw new HTTPException(404, { message: 'Permission not found' });
}
await db.delete(queuePermissions).where(eq(queuePermissions.id, id));
return c.json({ ok: true });
});
// GET /user-permissions — list all per-user permissions
router.get('/user-permissions', async (c) => {
const all = await db.query.userPermissions.findMany();
const userIds = [...new Set(all.map((p) => p.user_id))];
const queueIds = [...new Set(all.map((p) => p.queue_id))];
const userList = userIds.length > 0
? await db.query.users.findMany({ where: (t, { inArray }) => inArray(t.id, userIds) })
: [];
const queueList = queueIds.length > 0
? await db.query.queues.findMany({ where: (t, { inArray }) => inArray(t.id, queueIds) })
: [];
const userById = new Map(userList.map((u) => [u.id, u]));
const queueById = new Map(queueList.map((q) => [q.id, q]));
const enriched = all.map((p) => ({
...p,
username: userById.get(p.user_id)?.username ?? p.user_id,
queue_name: queueById.get(p.queue_id)?.name ?? p.queue_id,
}));
return c.json(enriched);
});
// POST /user-permissions — grant a right to a user
router.post('/user-permissions', async (c) => {
const body = await c.req.json();
const { queue_id, user_id, right_name } = body;
if (!queue_id || !user_id || !right_name) {
throw new HTTPException(422, { message: 'queue_id, user_id, and right_name are required' });
}
const validRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'];
if (!validRights.includes(right_name)) {
throw new HTTPException(422, { message: `Invalid right. Must be one of: ${validRights.join(', ')}` });
}
const existing = await db.query.userPermissions.findFirst({
where: (table, { and, eq: eqFn }) =>
and(
eqFn(table.queue_id, queue_id),
eqFn(table.user_id, user_id),
eqFn(table.right_name, right_name),
),
});
if (existing) return c.json(existing);
const [perm] = await db.insert(userPermissions).values({
queue_id,
user_id,
right_name,
}).returning();
if (!perm) {
throw new HTTPException(500, { message: 'Failed to create user permission' });
}
return c.json(perm, 201);
});
// DELETE /user-permissions/:id — revoke a user right
router.delete('/user-permissions/:id', async (c) => {
const id = c.req.param('id');
await db.delete(userPermissions).where(eq(userPermissions.id, id));
return c.json({ ok: true });
});
return router;
}

View File

@@ -1,7 +1,15 @@
import { Hono } from 'hono'; import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception'; import { HTTPException } from 'hono/http-exception';
import { existsSync, mkdirSync } from 'node:fs';
import { join, extname } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import type { Db } from '../db/index.ts'; import type { Db } from '../db/index.ts';
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers } from '../db/schema.ts'; import { config } from '../config.ts';
import { getUserId } from '../auth/middleware.ts';
import { requireRight } from '../auth/permissions.ts';
import { createNotification } from './notifications.ts';
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers, transactionAttachments, ticketLinks } from '../db/schema.ts';
import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm'; import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm';
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts'; import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
import { ScripEngine } from '../scrip/engine.ts'; import { ScripEngine } from '../scrip/engine.ts';
@@ -22,8 +30,14 @@ export function createTicketsRouter(db: Db): Hono {
// GET / — list tickets // GET / — list tickets
router.get('/', async (c) => { router.get('/', async (c) => {
const params = new URL(c.req.url).searchParams;
const queueId = c.req.query('queue_id'); const queueId = c.req.query('queue_id');
// If filtering by queue, check view permission
if (queueId) {
await requireRight(c, db, queueId, 'ticket.view');
}
const params = new URL(c.req.url).searchParams;
const status = c.req.query('status'); const status = c.req.query('status');
const ownerId = c.req.query('owner_id'); const ownerId = c.req.query('owner_id');
const teamId = c.req.query('team_id'); const teamId = c.req.query('team_id');
@@ -63,17 +77,70 @@ export function createTicketsRouter(db: Db): Hono {
} }
} }
// Text search: push to SQL via ilike on ticket columns + queue name join // Subject filter: supports "contains:<text>", "is:<text>", "is_not:<text>", "starts_with:<text>"
const subjectFilter = c.req.query('subject');
if (subjectFilter) {
const idx = subjectFilter.indexOf(':');
const op = idx > -1 ? subjectFilter.slice(0, idx) : 'contains';
const val = idx > -1 ? subjectFilter.slice(idx + 1) : subjectFilter;
if (val) {
if (op === 'is') conditions.push(eq(tickets.subject, val));
else if (op === 'is_not') conditions.push(sql`${tickets.subject} != ${val}`);
else if (op === 'starts_with') conditions.push(ilike(tickets.subject, `${val}%`));
else conditions.push(ilike(tickets.subject, `%${val}%`)); // contains
}
}
// Date filters: format "before:YYYY-MM-DD" or "after:YYYY-MM-DD"
for (const [fieldName, column] of [['created', tickets.created_at], ['updated', tickets.updated_at]] as const) {
const dateFilter = c.req.query(fieldName);
if (dateFilter) {
const idx = dateFilter.indexOf(':');
const op = idx > -1 ? dateFilter.slice(0, idx) : 'after';
const val = idx > -1 ? dateFilter.slice(idx + 1) : dateFilter;
if (val) {
if (op === 'before') conditions.push(sql`${column} <= ${val}::timestamptz`);
else conditions.push(sql`${column} >= ${val}::timestamptz`); // after
}
}
}
// Text search across tickets, transactions, queue names, and custom fields
if (query) { if (query) {
const pattern = `%${query}%`; const pattern = `%${query}%`;
conditions.push( conditions.push(
or( or(
ilike(tickets.subject, pattern), ilike(tickets.subject, pattern),
ilike(tickets.status, pattern), sql`${tickets.id}::text ILIKE ${pattern}`,
sql`${tickets.id}::text ILIKE ${pattern}` // Queue name
exists(
db.select({ n: sql`1` })
.from(queues)
.where(and(
eq(queues.id, tickets.queue_id),
ilike(queues.name, pattern)
))
),
// Transaction bodies (comments, correspondence)
exists(
db.select({ n: sql`1` })
.from(transactions)
.where(and(
eq(transactions.ticket_id, tickets.id),
sql`transactions.data->>'body' ILIKE ${pattern}`
))
),
// Custom field values
exists(
db.select({ n: sql`1` })
.from(customFieldValues)
.where(and(
eq(customFieldValues.ticket_id, tickets.id),
ilike(customFieldValues.value, pattern)
))
)
)! )!
); );
// Queue name search requires join — keep as post-filter
} }
// Custom field filters: use EXISTS subquery // Custom field filters: use EXISTS subquery
@@ -100,19 +167,9 @@ export function createTicketsRouter(db: Db): Hono {
limit, limit,
}); });
// Post-filter for queue name text search (requires in-memory join)
let filtered = result;
if (query) {
const queuesForSearch = await db.query.queues.findMany();
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
filtered = result.filter((ticket) =>
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query.toLowerCase())
);
}
// Attach custom field values to all tickets // Attach custom field values to all tickets
if (filtered.length > 0) { if (result.length > 0) {
const ticketIds = filtered.map((t) => t.id); const ticketIds = result.map((t) => t.id);
const allCfValues = await db.query.customFieldValues.findMany({ const allCfValues = await db.query.customFieldValues.findMany({
where: (table, { inArray }) => inArray(table.ticket_id, ticketIds), where: (table, { inArray }) => inArray(table.ticket_id, ticketIds),
}); });
@@ -124,7 +181,7 @@ export function createTicketsRouter(db: Db): Hono {
: []; : [];
const fieldMap = new Map(allFields.map((f) => [f.id, f])); const fieldMap = new Map(allFields.map((f) => [f.id, f]));
const ticketsWithCf = filtered.map((ticket) => { const ticketsWithCf = result.map((ticket) => {
const cfs = allCfValues const cfs = allCfValues
.filter((v) => v.ticket_id === ticket.id) .filter((v) => v.ticket_id === ticket.id)
.map((v) => ({ .map((v) => ({
@@ -149,14 +206,16 @@ export function createTicketsRouter(db: Db): Hono {
return c.json(ticketsWithCf); return c.json(ticketsWithCf);
} }
return c.json(filtered); return c.json(result);
}); });
// POST / — create ticket // POST / — create ticket
router.post('/', async (c) => { router.post('/', async (c) => {
const body = await c.req.json(); const body = await c.req.json();
const parsed = CreateTicketSchema.parse(body); const parsed = CreateTicketSchema.parse(body);
const creatorId = '00000000-0000-0000-0000-000000000000';
await requireRight(c, db, parsed.queue_id, 'ticket.create');
const creatorId = getUserId(c);
const customFieldInput = parsed.custom_fields ?? {}; const customFieldInput = parsed.custom_fields ?? {};
const customFieldEntries = Object.entries(customFieldInput) const customFieldEntries = Object.entries(customFieldInput)
.map(([fieldId, value]) => [fieldId, value.trim()] as const) .map(([fieldId, value]) => [fieldId, value.trim()] as const)
@@ -208,6 +267,21 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` }); throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
} }
} }
if (field.field_type === 'date') {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new HTTPException(422, { message: `${field.name}: value must be a date in YYYY-MM-DD format` });
}
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new HTTPException(422, { message: `${field.name}: invalid date` });
}
}
if (field.field_type === 'datetime') {
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new HTTPException(422, { message: `${field.name}: value must be a valid ISO 8601 datetime` });
}
}
if (field.pattern) { if (field.pattern) {
const regex = new RegExp(field.pattern); const regex = new RegExp(field.pattern);
if (!regex.test(value)) { if (!regex.test(value)) {
@@ -285,6 +359,8 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Ticket not found' }); throw new HTTPException(404, { message: 'Ticket not found' });
} }
await requireRight(c, db, ticket.queue_id, 'ticket.view');
const cfValues = await db.query.customFieldValues.findMany({ const cfValues = await db.query.customFieldValues.findMany({
where: eq(customFieldValues.ticket_id, id), where: eq(customFieldValues.ticket_id, id),
}); });
@@ -301,10 +377,32 @@ export function createTicketsRouter(db: Db): Hono {
custom_field: cfMap.get(v.custom_field_id) ?? null, custom_field: cfMap.get(v.custom_field_id) ?? null,
})); }));
return c.json({ ...ticket, custom_fields: customFieldsMapped }); // Blocking dependencies: tickets this one DependsOn that aren't resolved yet
}); const dependsOnLinks = await db.query.ticketLinks.findMany({
where: (t, { and, eq: eqFn }) =>
and(eqFn(t.ticket_id, id), eqFn(t.link_type, 'DependsOn')),
});
const blockingIds = dependsOnLinks.map((l) => l.target_ticket_id);
let blockedBy: Array<{ id: number; subject: string; status: string }> = [];
if (blockingIds.length > 0) {
const blockingTickets = await db.query.tickets.findMany({
where: (t, { inArray }) => inArray(t.id, blockingIds),
});
const queue = await db.query.queues.findFirst({
where: eq(queues.id, ticket.queue_id),
});
let inactiveStatuses: string[] = [];
if (queue?.lifecycle_id) {
const lc = await db.query.lifecycles.findFirst({ where: eq(lifecycles.id, queue.lifecycle_id) });
if (lc) inactiveStatuses = (lc.definition as any)?.statuses?.inactive ?? [];
}
blockedBy = blockingTickets
.filter((t) => !inactiveStatuses.includes(t.status))
.map((t) => ({ id: t.id, subject: t.subject, status: t.status }));
}
// PATCH /:id — update ticket return c.json({ ...ticket, custom_fields: customFieldsMapped, blocked_by: blockedBy });
});
router.patch('/:id', async (c) => { router.patch('/:id', async (c) => {
const id = Number(c.req.param('id')); const id = Number(c.req.param('id'));
const body = await c.req.json(); const body = await c.req.json();
@@ -318,6 +416,8 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Ticket not found' }); throw new HTTPException(404, { message: 'Ticket not found' });
} }
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
let lifecycleDef: LifecycleDefinition | null = null; let lifecycleDef: LifecycleDefinition | null = null;
// Validate lifecycle transition if status is changing // Validate lifecycle transition if status is changing
@@ -337,6 +437,34 @@ export function createTicketsRouter(db: Db): Hono {
if (!result.valid) { if (!result.valid) {
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' }); throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
} }
// Check transition-gating right
if (result.requiredRight) {
await requireRight(c, db, ticket.queue_id, result.requiredRight as any);
}
// Check dependency enforcement: can't resolve/close if this ticket DependsOn unresolved tickets
const inactiveStatuses = lifecycleDef.statuses.inactive;
if (inactiveStatuses.includes(parsed.status)) {
const dependsOnLinks = await db.query.ticketLinks.findMany({
where: (t, { and, eq: eqFn }) =>
and(
eqFn(t.ticket_id, id),
eqFn(t.link_type, 'DependsOn'),
),
});
for (const link of dependsOnLinks) {
const target = await db.query.tickets.findFirst({
where: eq(tickets.id, link.target_ticket_id),
});
if (target && !inactiveStatuses.includes(target.status)) {
throw new HTTPException(422, {
message: `Cannot resolve: this ticket depends on ticket ${target.id} (${target.subject}) which is still ${target.status}. Resolve or close that ticket first.`,
});
}
}
}
} }
} }
} }
@@ -350,7 +478,7 @@ export function createTicketsRouter(db: Db): Hono {
field: 'subject', field: 'subject',
old_value: ticket.subject, old_value: ticket.subject,
new_value: parsed.subject, new_value: parsed.subject,
creator_id: '00000000-0000-0000-0000-000000000000', creator_id: getUserId(c),
}); });
} }
@@ -361,7 +489,7 @@ export function createTicketsRouter(db: Db): Hono {
field: 'status', field: 'status',
old_value: ticket.status, old_value: ticket.status,
new_value: parsed.status, new_value: parsed.status,
creator_id: '00000000-0000-0000-0000-000000000000', creator_id: getUserId(c),
}); });
} }
@@ -372,7 +500,7 @@ export function createTicketsRouter(db: Db): Hono {
field: 'owner_id', field: 'owner_id',
old_value: ticket.owner_id ?? null, old_value: ticket.owner_id ?? null,
new_value: parsed.owner_id, new_value: parsed.owner_id,
creator_id: '00000000-0000-0000-0000-000000000000', creator_id: getUserId(c),
}); });
} }
@@ -383,7 +511,7 @@ export function createTicketsRouter(db: Db): Hono {
field: 'team_id', field: 'team_id',
old_value: (ticket as any).team_id ?? null, old_value: (ticket as any).team_id ?? null,
new_value: parsed.team_id, new_value: parsed.team_id,
creator_id: '00000000-0000-0000-0000-000000000000', creator_id: getUserId(c),
}); });
} }
@@ -425,10 +553,24 @@ export function createTicketsRouter(db: Db): Hono {
await db.insert(transactions).values(txList as any); await db.insert(transactions).values(txList as any);
} }
// Run scrips // Run scrips — use TransactionBatch when multiple changes, TransactionCreate for single
const prepared = await scripEngine.prepare(id, txList as any); const stage = txList.length > 1 ? 'TransactionBatch' as const : 'TransactionCreate' as const;
const prepared = await scripEngine.prepare(id, txList as any, stage);
const results = await scripEngine.commit(prepared); const results = await scripEngine.commit(prepared);
// Notify on assignment change
if (parsed.owner_id !== undefined && parsed.owner_id !== ticket.owner_id) {
if (parsed.owner_id) {
await createNotification(db, {
user_id: parsed.owner_id,
ticket_id: id,
type: 'assigned',
title: `You were assigned to ticket ${id}`,
body: ticket.subject,
});
}
}
return c.json({ ticket: updated, scrip_results: results }); return c.json({ ticket: updated, scrip_results: results });
}); });
@@ -446,6 +588,8 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Ticket not found' }); throw new HTTPException(404, { message: 'Ticket not found' });
} }
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
if (parsed.status) { if (parsed.status) {
const queue = await db.query.queues.findFirst({ const queue = await db.query.queues.findFirst({
where: eq(queues.id, ticket.queue_id), where: eq(queues.id, ticket.queue_id),
@@ -470,13 +614,13 @@ export function createTicketsRouter(db: Db): Hono {
if (parsed.status && parsed.status !== ticket.status) { if (parsed.status && parsed.status !== ticket.status) {
txList.push({ txList.push({
id: '00000000-0000-0000-0000-000000000000', id: getUserId(c),
ticket_id: id, ticket_id: id,
transaction_type: 'StatusChange', transaction_type: 'StatusChange',
field: 'status', field: 'status',
old_value: ticket.status, old_value: ticket.status,
new_value: parsed.status, new_value: parsed.status,
creator_id: '00000000-0000-0000-0000-000000000000', creator_id: getUserId(c),
}); });
} }
@@ -487,7 +631,7 @@ export function createTicketsRouter(db: Db): Hono {
return c.json({ prepared_scrips: results }); return c.json({ prepared_scrips: results });
}); });
// GET /:id/transactions — list transactions for ticket // GET /:id/transactions — list transactions for ticket (with attachments)
router.get('/:id/transactions', async (c) => { router.get('/:id/transactions', async (c) => {
const id = Number(c.req.param('id')); const id = Number(c.req.param('id'));
@@ -496,7 +640,105 @@ export function createTicketsRouter(db: Db): Hono {
orderBy: asc(transactions.created_at), orderBy: asc(transactions.created_at),
}); });
return c.json(result); // Fetch attachments for these transactions
const txIds = result.map((tx) => tx.id);
if (txIds.length > 0) {
const attachments = await db.query.transactionAttachments.findMany({
where: inArray(transactionAttachments.transaction_id, txIds),
});
const attachmentsByTxId = new Map<string, typeof attachments>();
for (const att of attachments) {
if (!att.transaction_id) continue;
const list = attachmentsByTxId.get(att.transaction_id);
if (list) {
list.push(att);
} else {
attachmentsByTxId.set(att.transaction_id, [att]);
}
}
const resultWithAttachments = result.map((tx) => ({
...tx,
attachments: attachmentsByTxId.get(tx.id) ?? [],
}));
return c.json(resultWithAttachments);
}
return c.json(result.map((tx) => ({ ...tx, attachments: [] })));
});
// POST /:id/attachments — upload file attachments for a ticket
router.post('/:id/attachments', async (c) => {
const ticketId = Number(c.req.param('id'));
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, ticketId),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
await requireRight(c, db, ticket.queue_id, 'ticket.reply');
const formData = await c.req.formData();
const files = formData.getAll('files') as File[];
if (files.length === 0) {
throw new HTTPException(422, { message: 'No files provided' });
}
const now = new Date();
const year = now.getFullYear().toString();
const month = String(now.getMonth() + 1).padStart(2, '0');
const dir = join(config.UPLOAD_DIR, year, month);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const MIME_MAP: Record<string, string> = {
'.txt': 'text/plain', '.html': 'text/html', '.css': 'text/css',
'.js': 'application/javascript', '.json': 'application/json', '.xml': 'application/xml',
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml',
'.webp': 'image/webp', '.zip': 'application/zip', '.gz': 'application/gzip',
'.csv': 'text/csv', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg',
'.md': 'text/markdown', '.yaml': 'text/yaml', '.yml': 'text/yaml',
};
const result: Array<{ id: string; filename: string; mime_type: string; size_bytes: number }> = [];
for (const file of files) {
if (!(file instanceof File)) continue;
const ext = extname(file.name).toLowerCase();
const storedName = `${randomUUID()}${ext}`;
const storagePath = join(dir, storedName);
const buffer = Buffer.from(await file.arrayBuffer());
await writeFile(storagePath, buffer);
const mimeType = file.type || MIME_MAP[ext] || 'application/octet-stream';
const [saved] = await db.insert(transactionAttachments).values({
filename: file.name,
mime_type: mimeType,
size_bytes: buffer.length,
storage_path: storagePath,
}).returning();
if (saved) {
result.push({
id: saved.id,
filename: saved.filename,
mime_type: saved.mime_type,
size_bytes: saved.size_bytes,
});
}
}
return c.json({ attachments: result }, 201);
}); });
// POST /:id/comment — add a comment (reply or internal note) // POST /:id/comment — add a comment (reply or internal note)
@@ -513,12 +755,23 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Ticket not found' }); throw new HTTPException(404, { message: 'Ticket not found' });
} }
await requireRight(c, db, ticket.queue_id, parsed.internal ? 'ticket.comment' : 'ticket.reply');
const transactionType = parsed.internal ? 'Comment' : 'Correspond'; const transactionType = parsed.internal ? 'Comment' : 'Correspond';
const attachmentIds = parsed.attachment_ids ?? [];
const txData: Record<string, unknown> = { body: parsed.body };
if (attachmentIds.length > 0) {
txData.attachment_ids = attachmentIds;
}
const timeWorked = parsed.time_worked_minutes ?? 0;
const [tx] = await db.insert(transactions).values({ const [tx] = await db.insert(transactions).values({
ticket_id: id, ticket_id: id,
transaction_type: transactionType, transaction_type: transactionType,
data: { body: parsed.body }, data: txData,
time_worked_minutes: timeWorked,
creator_id: parsed.creator_id, creator_id: parsed.creator_id,
}).returning(); }).returning();
@@ -526,14 +779,454 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(500, { message: 'Failed to create comment' }); throw new HTTPException(500, { message: 'Failed to create comment' });
} }
// Link pre-uploaded attachment records to this transaction
if (attachmentIds.length > 0) {
await db.update(transactionAttachments)
.set({ transaction_id: tx.id })
.where(inArray(transactionAttachments.id, attachmentIds));
}
// Run scrips // Run scrips
const txList = [tx]; const txList = [tx];
const prepared = await scripEngine.prepare(id, txList as any); const prepared = await scripEngine.prepare(id, txList as any);
await scripEngine.commit(prepared); await scripEngine.commit(prepared);
// Notify ticket owner and creator
const commenterId = getUserId(c);
const notifyTargets = new Set([ticket.owner_id, ticket.creator_id].filter(Boolean) as string[]);
notifyTargets.delete(commenterId);
for (const userId of notifyTargets) {
await createNotification(db, {
user_id: userId,
ticket_id: id,
type: 'commented',
title: `New ${transactionType === 'Comment' ? 'internal note' : 'reply'} on ticket ${id}`,
body: parsed.body.slice(0, 200),
});
}
return c.json(tx, 201); return c.json(tx, 201);
}); });
// GET /:id/links — list links for a ticket (with target ticket info)
router.get('/:id/links', async (c) => {
const id = Number(c.req.param('id'));
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, id),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
await requireRight(c, db, ticket.queue_id, 'ticket.view');
const links = await db.query.ticketLinks.findMany({
where: eq(ticketLinks.ticket_id, id),
orderBy: asc(ticketLinks.created_at),
});
// Enrich with target ticket info
const targetIds = [...new Set(links.map((l) => l.target_ticket_id))];
const targetTickets = targetIds.length > 0
? await db.query.tickets.findMany({
where: (table, { inArray }) => inArray(table.id, targetIds),
})
: [];
const ticketById = new Map(targetTickets.map((t) => [t.id, t]));
const enriched = links.map((link) => {
const target = ticketById.get(link.target_ticket_id);
return {
...link,
target_ticket: target
? { id: target.id, subject: target.subject, status: target.status }
: null,
};
});
return c.json(enriched);
});
// POST /:id/links — create a link to another ticket
router.post('/:id/links', async (c) => {
const id = Number(c.req.param('id'));
const body = await c.req.json();
const targetTicketId = Number(body.target_ticket_id);
const linkType = String(body.link_type || 'RelatedTo');
if (!targetTicketId || isNaN(targetTicketId)) {
throw new HTTPException(422, { message: 'target_ticket_id is required' });
}
if (targetTicketId === id) {
throw new HTTPException(422, { message: 'Cannot link a ticket to itself' });
}
const validTypes = ['DependsOn', 'Blocks', 'RefersTo', 'RelatedTo', 'Duplicates', 'MemberOf'];
if (!validTypes.includes(linkType)) {
throw new HTTPException(422, { message: `Invalid link_type. Must be one of: ${validTypes.join(', ')}` });
}
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, id),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
const target = await db.query.tickets.findFirst({
where: eq(tickets.id, targetTicketId),
});
if (!target) {
throw new HTTPException(404, { message: 'Target ticket not found' });
}
// Check for duplicate
const existing = await db.query.ticketLinks.findFirst({
where: (table, { and, eq: eqFn }) =>
and(
eqFn(table.ticket_id, id),
eqFn(table.target_ticket_id, targetTicketId),
eqFn(table.link_type, linkType),
),
});
if (existing) {
throw new HTTPException(422, { message: 'This link already exists' });
}
const creatorId = body.creator_id || getUserId(c);
const [link] = await db.insert(ticketLinks).values({
ticket_id: id,
target_ticket_id: targetTicketId,
link_type: linkType,
creator_id: creatorId,
}).returning();
if (!link) {
throw new HTTPException(500, { message: 'Failed to create link' });
}
// Create transactions on both tickets
const linkData = {
link_type: linkType,
link_id: link.id,
target_ticket_id: targetTicketId,
target_subject: target.subject,
};
const reverseLinkData = {
link_type: linkType,
link_id: link.id,
target_ticket_id: id,
target_subject: ticket.subject,
};
const [txSource] = await db.insert(transactions).values({
ticket_id: id,
transaction_type: 'LinkCreate',
field: linkType,
old_value: null,
new_value: String(targetTicketId),
data: linkData,
creator_id: creatorId,
}).returning();
const [txTarget] = await db.insert(transactions).values({
ticket_id: targetTicketId,
transaction_type: 'LinkCreate',
field: linkType,
old_value: null,
new_value: String(id),
data: reverseLinkData,
creator_id: creatorId,
}).returning();
// Run scrips on source ticket
if (txSource) {
const prepared = await scripEngine.prepare(id, [txSource] as any);
await scripEngine.commit(prepared);
}
// Run scrips on target ticket
if (txTarget) {
const prepared = await scripEngine.prepare(targetTicketId, [txTarget] as any);
await scripEngine.commit(prepared);
}
// Include target ticket info in response
return c.json({
...link,
target_ticket: { id: target.id, subject: target.subject, status: target.status },
}, 201);
});
// DELETE /:id/links/:linkId — remove a link
router.delete('/:id/links/:linkId', async (c) => {
const id = Number(c.req.param('id'));
const linkId = c.req.param('linkId');
const ticket = await db.query.tickets.findFirst({
where: eq(tickets.id, id),
});
if (!ticket) {
throw new HTTPException(404, { message: 'Ticket not found' });
}
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
const link = await db.query.ticketLinks.findFirst({
where: eq(ticketLinks.id, linkId),
});
if (!link || link.ticket_id !== id) {
throw new HTTPException(404, { message: 'Link not found' });
}
await db.delete(ticketLinks).where(eq(ticketLinks.id, linkId));
const target = await db.query.tickets.findFirst({
where: eq(tickets.id, link.target_ticket_id),
});
const creatorId = getUserId(c);
const [txSource] = await db.insert(transactions).values({
ticket_id: id,
transaction_type: 'LinkDelete',
field: link.link_type,
old_value: String(link.target_ticket_id),
new_value: null,
data: {
link_type: link.link_type,
target_ticket_id: link.target_ticket_id,
target_subject: target?.subject ?? 'unknown',
},
creator_id: creatorId,
}).returning();
// Run scrips on source ticket
if (txSource) {
const prepared = await scripEngine.prepare(id, [txSource] as any);
await scripEngine.commit(prepared);
}
return c.json({ ok: true });
});
// POST /:id/merge — merge this ticket into another
router.post('/:id/merge', async (c) => {
const id = Number(c.req.param('id'));
const body = await c.req.json();
const targetId = Number(body.target_ticket_id);
if (!targetId || isNaN(targetId) || targetId === id) {
throw new HTTPException(422, { message: 'target_ticket_id must be a different ticket ID' });
}
const source = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
if (!source) throw new HTTPException(404, { message: 'Source ticket not found' });
const target = await db.query.tickets.findFirst({ where: eq(tickets.id, targetId) });
if (!target) throw new HTTPException(404, { message: 'Target ticket not found' });
await requireRight(c, db, source.queue_id, 'ticket.modify');
await requireRight(c, db, target.queue_id, 'ticket.modify');
const creatorId = getUserId(c);
// Move transactions
await db.update(transactions)
.set({ ticket_id: targetId } as any)
.where(eq(transactions.ticket_id, id));
// Move attachments
const sourceTxs = await db.query.transactions.findMany({ where: eq(transactions.ticket_id, id) });
// (attachments are linked via transaction_id which stays the same, no-op)
// Move custom field values
const sourceCfs = await db.query.customFieldValues.findMany({
where: eq(customFieldValues.ticket_id, id),
});
for (const cf of sourceCfs) {
const existing = await db.query.customFieldValues.findFirst({
where: (t, { and, eq: eqFn }) =>
and(
eqFn(t.custom_field_id, cf.custom_field_id),
eqFn(t.ticket_id, targetId),
eqFn(t.value, cf.value),
),
});
if (existing) {
await db.delete(customFieldValues).where(eq(customFieldValues.id, cf.id));
} else {
await db.update(customFieldValues)
.set({ ticket_id: targetId } as any)
.where(eq(customFieldValues.id, cf.id));
}
}
// Move ticket links (update source references to target)
await db.update(ticketLinks)
.set({ ticket_id: targetId } as any)
.where(eq(ticketLinks.ticket_id, id));
// Update links pointing TO this ticket to point to target instead
await db.update(ticketLinks)
.set({ target_ticket_id: targetId } as any)
.where(eq(ticketLinks.target_ticket_id, id));
// Close the source ticket
await db.update(tickets).set({
status: 'closed',
updated_at: new Date(),
} as any).where(eq(tickets.id, id));
// Create merge transactions on both tickets
await db.insert(transactions).values({
ticket_id: targetId,
transaction_type: 'Comment',
data: { body: `Ticket ${source.id} (${source.subject}) was merged into this ticket.` },
creator_id: creatorId,
});
await db.insert(transactions).values({
ticket_id: id,
transaction_type: 'StatusChange',
field: 'status',
old_value: source.status,
new_value: 'closed',
data: { merged_into: targetId, body: `Merged into ticket ${targetId} (${target.subject}).` },
creator_id: creatorId,
});
// Create a duplicate link
await db.insert(ticketLinks).values({
ticket_id: id,
target_ticket_id: targetId,
link_type: 'Duplicates',
creator_id: creatorId,
}).onConflictDoNothing();
return c.json({ ok: true, target_id: targetId });
});
// POST /batch — bulk update tickets
router.post('/batch', async (c) => {
const body = await c.req.json();
const ticketIds: number[] = (body.ticket_ids ?? []).map(Number).filter((n: number) => !isNaN(n) && n > 0);
const { status, owner_id, team_id } = body;
if (ticketIds.length === 0) {
throw new HTTPException(422, { message: 'ticket_ids is required and must be an array of ticket IDs' });
}
if (ticketIds.length > 100) {
throw new HTTPException(422, { message: 'Maximum 100 tickets per batch update' });
}
const results: Array<{ id: number; ok: boolean; error?: string }> = [];
for (const id of ticketIds) {
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
if (!ticket) {
results.push({ id, ok: false, error: 'Ticket not found' });
continue;
}
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
try {
const txList: any[] = [];
const updateData: Record<string, unknown> = { updated_at: new Date() };
if (status !== undefined && status !== ticket.status) {
// Validate lifecycle transition
const queue = await db.query.queues.findFirst({ where: eq(queues.id, ticket.queue_id) });
if (queue?.lifecycle_id) {
const lifecycle = await db.query.lifecycles.findFirst({ where: eq(lifecycles.id, queue.lifecycle_id) });
if (lifecycle) {
const lifecycleDef = lifecycle.definition as LifecycleDefinition;
const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, status);
if (!result.valid) {
results.push({ id, ok: false, error: result.error ?? 'Invalid transition' });
continue;
}
// Dependency enforcement
const inactiveStatuses = lifecycleDef.statuses.inactive;
if (inactiveStatuses.includes(status)) {
const dependsOnLinks = await db.query.ticketLinks.findMany({
where: (t, { and, eq: eqFn }) => and(eqFn(t.ticket_id, id), eqFn(t.link_type, 'DependsOn')),
});
let blocked = false;
for (const link of dependsOnLinks) {
const target = await db.query.tickets.findFirst({ where: eq(tickets.id, link.target_ticket_id) });
if (target && !inactiveStatuses.includes(target.status)) {
results.push({ id, ok: false, error: `Blocked by ticket ${target.id} (${target.subject}) — still ${target.status}` });
blocked = true;
break;
}
}
if (blocked) continue;
}
}
}
txList.push({
ticket_id: id,
transaction_type: 'StatusChange',
field: 'status',
old_value: ticket.status,
new_value: status,
creator_id: getUserId(c),
});
updateData.status = status;
}
if (owner_id !== undefined && owner_id !== ticket.owner_id) {
txList.push({
ticket_id: id,
transaction_type: 'SetOwner',
field: 'owner_id',
old_value: ticket.owner_id ?? null,
new_value: owner_id,
creator_id: getUserId(c),
});
updateData.owner_id = owner_id;
}
if (team_id !== undefined && team_id !== (ticket as any).team_id) {
txList.push({
ticket_id: id,
transaction_type: 'SetTeam',
field: 'team_id',
old_value: (ticket as any).team_id ?? null,
new_value: team_id,
creator_id: getUserId(c),
});
updateData.team_id = team_id;
}
await db.update(tickets).set(updateData as any).where(eq(tickets.id, id));
if (txList.length > 0) {
await db.insert(transactions).values(txList as any);
}
results.push({ id, ok: true });
} catch (err) {
results.push({ id, ok: false, error: err instanceof Error ? err.message : String(err) });
}
}
return c.json({ results });
});
// PATCH /:id/custom-fields/:fieldId — set or clear a custom field value // PATCH /:id/custom-fields/:fieldId — set or clear a custom field value
router.patch('/:id/custom-fields/:fieldId', async (c) => { router.patch('/:id/custom-fields/:fieldId', async (c) => {
const id = Number(c.req.param('id')); const id = Number(c.req.param('id'));
@@ -549,6 +1242,8 @@ export function createTicketsRouter(db: Db): Hono {
throw new HTTPException(404, { message: 'Ticket not found' }); throw new HTTPException(404, { message: 'Ticket not found' });
} }
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
const assignment = await db.query.queueCustomFields.findFirst({ const assignment = await db.query.queueCustomFields.findFirst({
where: and( where: and(
eq(queueCustomFields.queue_id, ticket.queue_id), eq(queueCustomFields.queue_id, ticket.queue_id),
@@ -575,6 +1270,22 @@ export function createTicketsRouter(db: Db): Hono {
} }
} }
if (value && field.field_type === 'date') {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new HTTPException(422, { message: `${field.name}: value must be a date in YYYY-MM-DD format` });
}
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new HTTPException(422, { message: `${field.name}: invalid date` });
}
}
if (value && field.field_type === 'datetime') {
const parsed = new Date(value);
if (isNaN(parsed.getTime())) {
throw new HTTPException(422, { message: `${field.name}: value must be a valid ISO 8601 datetime` });
}
}
if (value && field.pattern) { if (value && field.pattern) {
const regex = new RegExp(field.pattern); const regex = new RegExp(field.pattern);
if (!regex.test(value)) { if (!regex.test(value)) {
@@ -613,7 +1324,7 @@ export function createTicketsRouter(db: Db): Hono {
field: field.key, field: field.key,
old_value: oldValue || null, old_value: oldValue || null,
new_value: value || null, new_value: value || null,
creator_id: '00000000-0000-0000-0000-000000000000', creator_id: getUserId(c),
}).returning(); }).returning();
const prepared = await scripEngine.prepare(id, [tx] as any); const prepared = await scripEngine.prepare(id, [tx] as any);

View File

@@ -18,6 +18,8 @@ export function createUsersRouter(db: Db): Hono {
const body = await c.req.json(); const body = await c.req.json();
const username = String(body.username ?? '').trim(); const username = String(body.username ?? '').trim();
const email = body.email ? String(body.email).trim() : null; const email = body.email ? String(body.email).trim() : null;
const role = body.role ? String(body.role) : 'staff';
const password = body.password ? String(body.password).trim() : null;
if (!username) { if (!username) {
throw new HTTPException(400, { message: 'username is required' }); throw new HTTPException(400, { message: 'username is required' });
@@ -26,6 +28,8 @@ export function createUsersRouter(db: Db): Hono {
const [user] = await db.insert(users).values({ const [user] = await db.insert(users).values({
username, username,
email, email,
role,
password_hash: password ? await Bun.password.hash(password) : null,
}).returning(); }).returning();
if (!user) { if (!user) {
@@ -50,6 +54,10 @@ export function createUsersRouter(db: Db): Hono {
const updateData: Partial<typeof users.$inferInsert> = {}; const updateData: Partial<typeof users.$inferInsert> = {};
if (body.username !== undefined) updateData.username = String(body.username).trim(); if (body.username !== undefined) updateData.username = String(body.username).trim();
if (body.email !== undefined) updateData.email = body.email ? String(body.email).trim() : null; if (body.email !== undefined) updateData.email = body.email ? String(body.email).trim() : null;
if (body.role !== undefined) updateData.role = String(body.role);
if (body.password !== undefined && String(body.password).trim()) {
updateData.password_hash = await Bun.password.hash(String(body.password).trim());
}
const [updated] = await db.update(users) const [updated] = await db.update(users)
.set(updateData) .set(updateData)

View File

@@ -5,6 +5,7 @@ import type { LifecycleDefinition } from '../lifecycle/validator.ts';
export interface ConditionEvaluateContext { export interface ConditionEvaluateContext {
lifecycleDef?: LifecycleDefinition; lifecycleDef?: LifecycleDefinition;
customFields?: Record<string, string>; // key → value map of CF values
} }
export interface ConditionConfig { export interface ConditionConfig {
@@ -16,6 +17,7 @@ export interface ConditionConfig {
old_value?: unknown; old_value?: unknown;
new_value?: unknown; new_value?: unknown;
value?: unknown; value?: unknown;
link_type?: unknown;
} }
export interface ConditionEvaluator { export interface ConditionEvaluator {
@@ -82,11 +84,52 @@ export class OnCustomFieldChange implements ConditionEvaluator {
} }
} }
export class OnLinkCreate implements ConditionEvaluator {
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
return transactions.some((tx) => {
if (tx.transaction_type !== 'LinkCreate') return false;
if (config?.link_type) {
const linkType = tx.field;
if (!matchesStatusFilter(linkType, config.link_type)) return false;
}
return true;
});
}
}
export class OnOverdue implements ConditionEvaluator {
evaluate(_ticket: Ticket, _transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
const fieldKey = config?.field_key ?? config?.field_id ?? config?.field;
if (!fieldKey) return false;
const cfValue = context?.customFields?.[String(fieldKey)];
if (!cfValue) return false;
// Parse the date value
const dueDate = new Date(cfValue);
if (isNaN(dueDate.getTime())) return false;
// Check if overdue (past due date)
if (new Date() <= dueDate) return false;
// Check that ticket is still active (not in inactive state)
const lifecycleDef = context?.lifecycleDef;
if (lifecycleDef) {
const inactiveStates = lifecycleDef.statuses.inactive;
if (inactiveStates.includes(_ticket.status)) return false;
}
return true;
}
}
const conditionRegistry: Record<string, ConditionEvaluator> = { const conditionRegistry: Record<string, ConditionEvaluator> = {
OnCreate: new OnCreate(), OnCreate: new OnCreate(),
OnStatusChange: new OnStatusChange(), OnStatusChange: new OnStatusChange(),
OnResolve: new OnResolve(), OnResolve: new OnResolve(),
OnCustomFieldChange: new OnCustomFieldChange(), OnCustomFieldChange: new OnCustomFieldChange(),
OnLinkCreate: new OnLinkCreate(),
OnOverdue: new OnOverdue(),
}; };
export function getConditionEvaluator(type: string): ConditionEvaluator | null { export function getConditionEvaluator(type: string): ConditionEvaluator | null {

View File

@@ -37,6 +37,7 @@ export class ScripEngine {
async prepare( async prepare(
ticketId: number, ticketId: number,
transactions: Transaction[], transactions: Transaction[],
stage: 'TransactionCreate' | 'TransactionBatch' = 'TransactionCreate',
): Promise<PreparedScrip[]> { ): Promise<PreparedScrip[]> {
const ticketRecord = await this.db.query.tickets.findFirst({ const ticketRecord = await this.db.query.tickets.findFirst({
where: eq(tickets.id, ticketId), where: eq(tickets.id, ticketId),
@@ -53,6 +54,15 @@ export class ScripEngine {
const matchingScrips = allScrips.filter((scrip) => { const matchingScrips = allScrips.filter((scrip) => {
if (scrip.disabled) return false; if (scrip.disabled) return false;
if (scrip.queue_id !== null && scrip.queue_id !== ticketRecord.queue_id) return false; if (scrip.queue_id !== null && scrip.queue_id !== ticketRecord.queue_id) return false;
if (scrip.stage !== stage) return false;
// Filter by applicable transaction types — if set, at least one tx must match
if (scrip.applicable_trans_types) {
const types = scrip.applicable_trans_types.split(',').map((t) => t.trim()).filter(Boolean);
if (types.length > 0 && !types.includes('Any')) {
const txTypes = new Set(transactions.map((tx) => tx.transaction_type));
if (!types.some((t) => txTypes.has(t))) return false;
}
}
return true; return true;
}); });
@@ -70,10 +80,6 @@ export class ScripEngine {
} }
} }
const conditionContext: ConditionEvaluateContext = {
lifecycleDef,
};
const cfValues = await this.db.query.customFieldValues.findMany({ const cfValues = await this.db.query.customFieldValues.findMany({
where: eq(customFieldValues.ticket_id, ticketId), where: eq(customFieldValues.ticket_id, ticketId),
}); });
@@ -94,6 +100,11 @@ export class ScripEngine {
} }
} }
const conditionContext: ConditionEvaluateContext = {
lifecycleDef,
customFields: customFieldsMap,
};
const prepared: PreparedScrip[] = []; const prepared: PreparedScrip[] = [];
for (const scrip of matchingScrips) { for (const scrip of matchingScrips) {

92
src/scrip/scheduler.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { Db } from '../db/index.ts';
import { transactions, tickets, queues, lifecycles } from '../db/schema.ts';
import { eq, and, isNull } from 'drizzle-orm';
import { ScripEngine } from './engine.ts';
const SYSTEM_USER = '00000000-0000-0000-0000-000000000000';
/**
* Run scheduled scrips against all active tickets.
* Creates a synthetic "Scheduled" transaction so conditions like OnOverdue can fire.
*/
export async function runScheduledScrips(db: Db): Promise<{ checked: number; fired: number }> {
const engine = new ScripEngine(db);
// Get all lifecycles to determine inactive statuses
const allLifecycles = await db.query.lifecycles.findMany();
const inactiveByQueue = new Map<string, Set<string>>();
// Get all queues with lifecycles
const allQueues = await db.query.queues.findMany();
for (const q of allQueues) {
if (q.lifecycle_id) {
const lc = allLifecycles.find((l) => l.id === q.lifecycle_id);
if (lc) {
const def = lc.definition as any;
inactiveByQueue.set(q.id, new Set(def?.statuses?.inactive ?? ['resolved', 'closed']));
}
}
}
// Find all potentially active tickets
const allTickets = await db.query.tickets.findMany();
const active = allTickets.filter((t) => {
const inactive = inactiveByQueue.get(t.queue_id);
if (inactive) return !inactive.has(t.status);
return !['resolved', 'closed'].includes(t.status);
});
let fired = 0;
for (const ticket of active) {
try {
// Create a synthetic Scheduled transaction
const [tx] = await db.insert(transactions).values({
ticket_id: ticket.id,
transaction_type: 'Comment' as any,
field: 'scheduled',
data: { body: 'Scheduled scrip evaluation' },
creator_id: SYSTEM_USER,
} as any).returning();
if (!tx) continue;
// Run scrips
const prepared = await engine.prepare(ticket.id, [tx as any]);
if (prepared.length > 0) {
const results = await engine.commit(prepared);
const successes = results.filter((r) => r.success);
if (successes.length > 0) fired += successes.length;
}
} catch (err) {
// Log and continue — don't let one failing ticket block the scheduler
console.error(`[scheduler] Error processing ticket ${ticket.id}:`, err instanceof Error ? err.message : String(err));
}
}
return { checked: active.length, fired };
}
/**
* Start the background scheduler. Runs every `intervalMinutes` minutes.
*/
export function startScheduler(db: Db, intervalMinutes = 5) {
console.log(`[scheduler] Starting scrip scheduler (every ${intervalMinutes}m)`);
const run = async () => {
try {
const result = await runScheduledScrips(db);
if (result.fired > 0) {
console.log(`[scheduler] Checked ${result.checked} tickets, fired ${result.fired} scrip actions`);
}
} catch (err) {
console.error('[scheduler] Error:', err instanceof Error ? err.message : String(err));
}
};
// Run once at startup after a short delay
setTimeout(run, 10000);
// Then run on interval
setInterval(run, intervalMinutes * 60 * 1000);
}

View File

@@ -73,6 +73,7 @@ import {
removeTeamMember, removeTeamMember,
} from "@/lib/api"; } from "@/lib/api";
import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User, Team } from "@/lib/types"; import type { Queue, Ticket, Lifecycle, LifecycleDefinition, Scrip, Template, CustomField, QueueCustomField, TemplatePreview, User, Team } from "@/lib/types";
import { ScripWizard } from "@/components/scrip-wizard";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function AdminHeader() { function AdminHeader() {
@@ -802,6 +803,7 @@ return { message: "Metadata fetched" };`);
const [disabled, setDisabled] = useState(false); const [disabled, setDisabled] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null); const [saveError, setSaveError] = useState<string | null>(null);
const [wizardOpen, setWizardOpen] = useState(false);
const fetchScrips = useCallback(async () => { const fetchScrips = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -1189,10 +1191,15 @@ return { message: "Metadata fetched" };`);
Build automations visually, then fine-tune the exact action payload in JSON. Build automations visually, then fine-tune the exact action payload in JSON.
</p> </p>
</div> </div>
<Button size="sm" onClick={resetBuilder} className="h-8 bg-primary"> <div className="flex items-center gap-2">
<PlusIcon className="h-4 w-4" /> <Button size="sm" onClick={resetBuilder} className="h-8 bg-primary">
New scrip <PlusIcon className="h-4 w-4" />
</Button> New scrip
</Button>
<Button size="sm" variant="outline" onClick={() => setWizardOpen(true)} className="h-8">
Guided setup
</Button>
</div>
</div> </div>
<ErrorBanner error={error} /> <ErrorBanner error={error} />
{loading ? ( {loading ? (
@@ -1701,6 +1708,24 @@ return { message: "Metadata fetched" };`);
</div> </div>
</div> </div>
)} )}
<ScripWizard
open={wizardOpen}
onClose={() => setWizardOpen(false)}
error={saveError}
onCreate={async (data) => {
// Strip nulls for Zod optional fields
const payload = Object.fromEntries(
Object.entries(data).filter(([, v]) => v !== null)
);
const { error: createErr } = await createScrip(payload as any);
if (createErr) { setSaveError(createErr); return; }
setWizardOpen(false);
await fetchScrips();
}}
queues={queues}
customFields={customFields}
templates={templates}
/>
</section> </section>
); );
} }

View File

@@ -41,6 +41,7 @@ import { CountWidget } from "@/components/widgets/count-widget";
import { TicketListWidget } from "@/components/widgets/ticket-list-widget"; import { TicketListWidget } from "@/components/widgets/ticket-list-widget";
import { StatusChartWidget } from "@/components/widgets/status-chart-widget"; import { StatusChartWidget } from "@/components/widgets/status-chart-widget";
import { GroupedCountsWidget } from "@/components/widgets/grouped-counts-widget"; import { GroupedCountsWidget } from "@/components/widgets/grouped-counts-widget";
import { TrendChartWidget } from "@/components/widgets/trend-chart-widget";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) { function widgetGridStyle(position: { x: number; y: number; w: number; h: number }) {
@@ -331,13 +332,18 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
switch (widget.data.type) { switch (widget.data.type) {
case "count": case "count":
case "my_tickets":
return <CountWidget data={widget.data} />; return <CountWidget data={widget.data} />;
case "overdue":
return <CountWidget data={{ ...widget.data, type: "count" }} />;
case "ticket_list": case "ticket_list":
return <TicketListWidget data={widget.data} />; return <TicketListWidget data={widget.data} />;
case "status_chart": case "status_chart":
return <StatusChartWidget data={widget.data} />; return <StatusChartWidget data={widget.data} />;
case "grouped_counts": case "grouped_counts":
return <GroupedCountsWidget data={widget.data} />; return <GroupedCountsWidget data={widget.data} />;
case "trend_chart":
return <TrendChartWidget data={widget.data} />;
default: default:
return ( return (
<div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4"> <div className="flex h-full items-center justify-center rounded-lg border border-border bg-card p-4">
@@ -554,6 +560,9 @@ export default function DashboardPage({ params }: { params: Promise<{ id: string
<option value="ticket_list">Ticket list (mini table)</option> <option value="ticket_list">Ticket list (mini table)</option>
<option value="status_chart">Status chart (donut)</option> <option value="status_chart">Status chart (donut)</option>
<option value="grouped_counts">Grouped counts (bar chart)</option> <option value="grouped_counts">Grouped counts (bar chart)</option>
<option value="my_tickets">My tickets (auto-scoped)</option>
<option value="overdue">Overdue / stale</option>
<option value="trend_chart">Trend chart (bar)</option>
</select> </select>
</div> </div>
{addType === "grouped_counts" && ( {addType === "grouped_counts" && (

View File

@@ -4,6 +4,7 @@ import { Suspense } from "react";
import { ThemeProvider } from "next-themes"; import { ThemeProvider } from "next-themes";
import "./globals.css"; import "./globals.css";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { AuthProvider } from "@/lib/auth-context";
const ibmPlexSans = IBM_Plex_Sans({ const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"], subsets: ["latin"],
@@ -31,9 +32,11 @@ export default function RootLayout({
style={{ fontSize: "15px", lineHeight: 1.5 }} style={{ fontSize: "15px", lineHeight: 1.5 }}
> >
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}> <ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
<Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground">Loading...</div>}> <AuthProvider>
<AppShell>{children}</AppShell> <Suspense fallback={<div className="flex items-center justify-center h-screen text-muted-foreground">Loading...</div>}>
</Suspense> <AppShell>{children}</AppShell>
</Suspense>
</AuthProvider>
</ThemeProvider> </ThemeProvider>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,97 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context";
import { LogInIcon } from "lucide-react";
export default function LoginPage() {
const router = useRouter();
const { login, user } = useAuth();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
// Already logged in
if (user) {
router.replace("/");
return null;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !password) return;
setLoading(true);
setError(null);
const result = await login(username.trim(), password);
setLoading(false);
if (result) {
setError(result);
} else {
router.push("/");
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background/80">
<div className="w-full max-w-sm space-y-6">
<div className="text-center">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Tessera</h1>
<p className="mt-1.5 text-sm text-muted-foreground">Sign in to your account</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="username" className="text-[10px] font-medium text-muted-foreground">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
autoFocus
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:border-ring"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="password" className="text-[10px] font-medium text-muted-foreground">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm outline-none focus:border-ring"
/>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
<button
type="submit"
disabled={loading || !username.trim() || !password}
className="flex h-9 w-full items-center justify-center gap-2 rounded-md bg-primary text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
>
{loading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
) : (
<LogInIcon className="h-4 w-4" />
)}
Sign in
</button>
</form>
<p className="text-center text-[10px] text-muted-foreground/60">
Demo: admin / admin
</p>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
ArrowDownAZIcon, ArrowDownAZIcon,
CheckCircle2Icon, CheckCircle2Icon,
ChevronRightIcon, ChevronRightIcon,
DownloadIcon,
GaugeIcon, GaugeIcon,
LayoutGridIcon, LayoutGridIcon,
LayoutListIcon, LayoutListIcon,
@@ -18,8 +19,8 @@ import {
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards, updateTicket } from "@/lib/api"; import { createTicket, getCustomFields, getLifecycles, getQueueCustomFields, getQueues, getTickets, getUsers, getViews, createView, deleteView, getDashboards, updateTicket, batchUpdateTickets, getTeams } from "@/lib/api";
import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Ticket, User } from "@/lib/types"; import type { CustomField, Lifecycle, Queue, QueueCustomField, SavedView, Team, Ticket, User } from "@/lib/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -30,6 +31,8 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn, formatTicketId } from "@/lib/utils"; import { cn, formatTicketId } from "@/lib/utils";
import { SearchableSelect } from "@/components/searchable-select";
import { LayoutBuilder, type SubtitleEntry } from "@/components/layout-builder";
const STATUS_META: Record<string, { label: string; color: string; tone: string }> = { const STATUS_META: Record<string, { label: string; color: string; tone: string }> = {
new: { label: "New", color: "#64748b", tone: "bg-slate-500/10 text-slate-700 dark:text-slate-300" }, new: { label: "New", color: "#64748b", tone: "bg-slate-500/10 text-slate-700 dark:text-slate-300" },
@@ -51,35 +54,22 @@ type SortKey = "updated" | "created" | "id";
interface ColumnConfig { interface ColumnConfig {
key: string; key: string;
label: string; label: string;
width: number; // px width: number;
visible: boolean;
} }
const ALL_COLUMNS: ColumnConfig[] = [ const ALL_FIELDS: ColumnConfig[] = [
{ key: "id", label: "ID", width: 100, visible: true }, { key: "id", label: "ID", width: 100 },
{ key: "subject", label: "Subject", width: 320, visible: true }, { key: "subject", label: "Subject", width: 400 },
{ key: "status", label: "Status", width: 120, visible: true }, { key: "status", label: "Status", width: 120 },
{ key: "queue", label: "Queue", width: 140, visible: true }, { key: "queue", label: "Queue", width: 140 },
{ key: "owner", label: "Owner", width: 130, visible: true }, { key: "owner", label: "Owner", width: 130 },
{ key: "created", label: "Created", width: 130, visible: false }, { key: "created", label: "Created", width: 130 },
{ key: "updated", label: "Updated", width: 130, visible: false }, { key: "updated", label: "Updated", width: 130 },
{ key: "team", label: "Team", width: 130 },
]; ];
function baseColumns(): ColumnConfig[] { const DEFAULT_ROW1 = ["id", "subject", "status"];
return [ const DEFAULT_ROW2 = ["queue", "owner"];
{ key: "id", label: "ID", width: 100, visible: true },
{ key: "subject", label: "Subject", width: 400, visible: true },
{ key: "status", label: "Status", width: 120, visible: true },
{ key: "queue", label: "Queue", width: 140, visible: true },
{ key: "owner", label: "Owner", width: 130, visible: true },
{ key: "created", label: "Created", width: 130, visible: false },
{ key: "updated", label: "Updated", width: 130, visible: false },
];
}
function defaultColumns(): ColumnConfig[] {
return baseColumns().map((c) => ({ ...c }));
}
const LS_KEY = "tessera_columns"; const LS_KEY = "tessera_columns";
@@ -105,6 +95,22 @@ function queueName(queues: Queue[], queueId: string) {
return queues.find((queue) => queue.id === queueId)?.name ?? queueId.slice(0, 8); return queues.find((queue) => queue.id === queueId)?.name ?? queueId.slice(0, 8);
} }
function getSubtitleValue(key: string, ticket: Ticket, context: { users: User[]; queues: Queue[]; teamsList: Team[] }): string | null {
if (key === "subject") return null;
if (key === "id") return formatTicketId(ticket.id);
if (key === "status") return statusLabel(ticket.status);
if (key === "queue") return context.queues.find((q) => q.id === ticket.queue_id)?.name ?? ticket.queue_id.slice(0, 8);
if (key === "owner") return ticket.owner_id ? (context.users.find((u) => u.id === ticket.owner_id)?.username ?? "assigned") : "Unassigned";
if (key === "team") return context.teamsList.find((t) => t.id === ticket.team_id)?.name ?? null;
if (key === "created") return relativeTime(ticket.created_at);
if (key === "updated") return relativeTime(ticket.updated_at);
if (key.startsWith("cf.")) {
const cfKey = key.slice(3);
return ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value ?? null;
}
return null;
}
function relativeTime(value: string) { function relativeTime(value: string) {
return formatDistanceToNow(new Date(value), { addSuffix: true }); return formatDistanceToNow(new Date(value), { addSuffix: true });
} }
@@ -178,8 +184,10 @@ function TicketWorkbenchContent() {
const [queues, setQueues] = useState<Queue[]>([]); const [queues, setQueues] = useState<Queue[]>([]);
const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]); const [lifecycles, setLifecycles] = useState<Lifecycle[]>([]);
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [teamsList, setTeamsList] = useState<Team[]>([]);
const [customFields, setCustomFields] = useState<CustomField[]>([]); const [customFields, setCustomFields] = useState<CustomField[]>([]);
const [clock, setClock] = useState(0); const [clock, setClock] = useState(0);
const [initialLoad, setInitialLoad] = useState(true);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -187,47 +195,82 @@ function TicketWorkbenchContent() {
const [batchSaving, setBatchSaving] = useState(false); const [batchSaving, setBatchSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const searchRef = useRef(searchQuery);
searchRef.current = searchQuery;
// Debounce search: update debouncedQuery 300ms after user stops typing
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(searchQuery), 300);
return () => clearTimeout(timer);
}, [searchQuery]);
const [filters, setFilters] = useState<Filter[]>([]); const [filters, setFilters] = useState<Filter[]>([]);
const [columns, setColumns] = useState<ColumnConfig[]>(() => { const [row1Keys, setRow1Keys] = useState<string[]>(() => {
if (typeof window === "undefined") return defaultColumns(); if (typeof window === "undefined") return DEFAULT_ROW1;
try { try {
const stored = localStorage.getItem(LS_KEY); const stored = localStorage.getItem(LS_KEY);
if (stored) return JSON.parse(stored) as ColumnConfig[]; if (stored) {
const parsed = JSON.parse(stored);
if (parsed.row1) return parsed.row1 as string[];
// Migrate old format
if (Array.isArray(parsed)) {
return parsed.filter((c: any) => c.visible !== false && c.display !== "subtitle" && c.display !== "hidden").map((c: any) => c.key);
}
}
} catch { /* ignore */ } } catch { /* ignore */ }
return defaultColumns(); return DEFAULT_ROW1;
});
const [row2Entries, setRow2Entries] = useState<SubtitleEntry[]>(() => {
if (typeof window === "undefined") return DEFAULT_ROW2.map((k) => ({ key: k, under: k }));
try {
const stored = localStorage.getItem(LS_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (parsed.row2Entries) return parsed.row2Entries as SubtitleEntry[];
if (parsed.row2 && Array.isArray(parsed.row2)) {
// Migrate: old flat keys → entries with self as under
if (typeof parsed.row2[0] === "string") return parsed.row2.map((k: string) => ({ key: k, under: k }));
return parsed.row2 as SubtitleEntry[];
}
}
} catch { /* ignore */ }
return DEFAULT_ROW2.map((k) => ({ key: k, under: k }));
}); });
const [density, setDensity] = useState<Density>("comfortable"); const [density, setDensity] = useState<Density>("comfortable");
const [sortKey, setSortKey] = useState<SortKey>("updated"); const [sortKey, setSortKey] = useState<SortKey>("updated");
const [resizingCol, setResizingCol] = useState<string | null>(null); const [resizingCol, setResizingCol] = useState<string | null>(null);
const [colWidths, setColWidths] = useState<Record<string, number>>({});
const [colPickerOpen, setColPickerOpen] = useState(false); const [colPickerOpen, setColPickerOpen] = useState(false);
// Persist columns to localStorage // Persist layout to localStorage
useEffect(() => { useEffect(() => {
try { localStorage.setItem(LS_KEY, JSON.stringify(columns)); } catch { /* ignore */ } try { localStorage.setItem(LS_KEY, JSON.stringify({ row1: row1Keys, row2Entries })); } catch { /* ignore */ }
}, [columns]); }, [row1Keys, row2Entries]);
// Build available columns: base + custom fields // Build available fields: base + custom fields
const availableColumns = useMemo(() => { const allFields = useMemo(() => {
const base = baseColumns(); const cfFields: ColumnConfig[] = customFields
const cfCols: ColumnConfig[] = customFields
.filter((cf) => cf.key) .filter((cf) => cf.key)
.map((cf) => ({ .map((cf) => ({ key: `cf.${cf.key}`, label: cf.name, width: 140 }));
key: `cf.${cf.key}`, return [...ALL_FIELDS, ...cfFields];
label: cf.name, }, [customFields]);
width: 140,
visible: columns.find((c) => c.key === `cf.${cf.key}`)?.visible ?? false, const fieldByKey = useMemo(() => {
})); const map = new Map<string, ColumnConfig>();
// Merge with current visibility state for (const f of allFields) map.set(f.key, f);
const merged = base.map((bc) => { return map;
const current = columns.find((c) => c.key === bc.key); }, [allFields]);
return current ?? bc;
}); const row1Fields = row1Keys.map((k) => fieldByKey.get(k)).filter(Boolean) as ColumnConfig[];
for (const cf of cfCols) { const row2EntriesResolved = row2Entries.filter((e) => fieldByKey.has(e.key));
const current = columns.find((c) => c.key === cf.key); // Group subtitle entries by which column they sit under
merged.push(current ?? cf); const subsByColumn = new Map<string, SubtitleEntry[]>();
} for (const e of row2EntriesResolved) {
return merged; const list = subsByColumn.get(e.under) ?? [];
}, [customFields, columns]); list.push(e);
subsByColumn.set(e.under, list);
}
const colWidth = (key: string, fallback: number) => colWidths[key] ?? fallback;
// Saved views // Saved views
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]); const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
@@ -268,6 +311,9 @@ function TicketWorkbenchContent() {
const apiStatus = filters.find((f) => f.field === "status")?.value; const apiStatus = filters.find((f) => f.field === "status")?.value;
const apiOwner = filters.find((f) => f.field === "owner")?.value; const apiOwner = filters.find((f) => f.field === "owner")?.value;
const apiQueue = filters.find((f) => f.field === "queue")?.value; const apiQueue = filters.find((f) => f.field === "queue")?.value;
const apiSubject = filters.find((f) => f.field === "subject");
const apiCreated = filters.find((f) => f.field === "created");
const apiUpdated = filters.find((f) => f.field === "updated");
const customFieldFilters: Record<string, string> = {}; const customFieldFilters: Record<string, string> = {};
for (const f of filters) { for (const f of filters) {
if (f.field.startsWith("cf.")) { if (f.field.startsWith("cf.")) {
@@ -276,19 +322,23 @@ function TicketWorkbenchContent() {
} }
const routeTeamId = searchParams.get("team_id") ?? ""; const routeTeamId = searchParams.get("team_id") ?? "";
const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes] = await Promise.all([ const [ticketsRes, queuesRes, usersRes, fieldsRes, lifecycleRes, teamsRes] = await Promise.all([
getTickets({ getTickets({
q: searchQuery.trim() || undefined, q: debouncedQuery.trim() || undefined,
status: apiStatus || undefined, status: apiStatus || undefined,
queue_id: activeQueue || apiQueue || undefined, queue_id: activeQueue || apiQueue || undefined,
owner_id: apiOwner || undefined, owner_id: apiOwner || undefined,
team_id: routeTeamId || undefined, team_id: routeTeamId || undefined,
custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined, custom_fields: Object.keys(customFieldFilters).length > 0 ? customFieldFilters : undefined,
subject: apiSubject ? `${apiSubject.operator}:${apiSubject.value}` : undefined,
created: apiCreated ? `${apiCreated.operator}:${apiCreated.value}` : undefined,
updated: apiUpdated ? `${apiUpdated.operator}:${apiUpdated.value}` : undefined,
}), }),
getQueues(), getQueues(),
getUsers(), getUsers(),
getCustomFields(), getCustomFields(),
getLifecycles(), getLifecycles(),
getTeams(),
]); ]);
if (ticketsRes.error) { if (ticketsRes.error) {
@@ -314,6 +364,12 @@ function TicketWorkbenchContent() {
setUsers(usersRes.data ?? []); setUsers(usersRes.data ?? []);
} }
if (teamsRes?.error) {
setError((current) => current ?? teamsRes.error);
} else if (teamsRes?.data) {
setTeamsList(teamsRes.data);
}
if (fieldsRes.error) { if (fieldsRes.error) {
setError((current) => current ?? fieldsRes.error); setError((current) => current ?? fieldsRes.error);
} else { } else {
@@ -327,10 +383,11 @@ function TicketWorkbenchContent() {
} }
setLoading(false); setLoading(false);
setInitialLoad(false);
setRefreshing(false); setRefreshing(false);
setClock(fetchedAt); setClock(fetchedAt);
}, },
[filters, newQueueId, routeQueue, searchQuery] [filters, newQueueId, routeQueue, debouncedQuery]
); );
useEffect(() => { useEffect(() => {
@@ -422,7 +479,12 @@ function TicketWorkbenchContent() {
); );
if (view.sort_key) setSortKey(view.sort_key as SortKey); if (view.sort_key) setSortKey(view.sort_key as SortKey);
if (view.columns && Array.isArray(view.columns) && view.columns.length > 0) { if (view.columns && Array.isArray(view.columns) && view.columns.length > 0) {
setColumns(view.columns as ColumnConfig[]); // Load row1/row2 from saved view columns if available, else fall back to default
const cols = view.columns as any[];
const r1 = cols.filter((c: any) => c.display !== "subtitle" && c.visible !== false).map((c: any) => c.key);
const r2 = cols.filter((c: any) => c.display === "subtitle").map((c: any) => c.key);
if (r1.length > 0) setRow1Keys(r1);
if (r2.length > 0) setRow2Entries(r2.map((k: string) => ({ key: k, under: k })));
} }
} }
}); });
@@ -430,7 +492,8 @@ function TicketWorkbenchContent() {
// User navigated away from a view — clear filters and reset columns // User navigated away from a view — clear filters and reset columns
setFilters([]); setFilters([]);
setSearchQuery(""); setSearchQuery("");
setColumns(defaultColumns()); setRow1Keys(DEFAULT_ROW1);
setRow2Entries(DEFAULT_ROW2.map((k) => ({ key: k, under: k })));
} }
}, [searchParams]); }, [searchParams]);
@@ -485,7 +548,6 @@ function TicketWorkbenchContent() {
}, [clock, inactiveStatuses, tickets]); }, [clock, inactiveStatuses, tickets]);
const filteredTickets = useMemo(() => { const filteredTickets = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
const now = clock || 0; const now = clock || 0;
const queue = routeQueue; const queue = routeQueue;
const statusFilterValue = filters.find((f) => f.field === "status")?.value; const statusFilterValue = filters.find((f) => f.field === "status")?.value;
@@ -506,13 +568,7 @@ function TicketWorkbenchContent() {
if (statusFilterValue && ticket.status !== statusFilterValue) return false; if (statusFilterValue && ticket.status !== statusFilterValue) return false;
if (queueFilterValue && ticket.queue_id !== queueFilterValue) return false; if (queueFilterValue && ticket.queue_id !== queueFilterValue) return false;
if (queue && ticket.queue_id !== queue) return false; if (queue && ticket.queue_id !== queue) return false;
if (!query) return true; return true;
return (
ticket.subject.toLowerCase().includes(query) ||
formatTicketId(ticket.id).toLowerCase().includes(query) ||
statusLabel(ticket.status).toLowerCase().includes(query) ||
queueName(queues, ticket.queue_id).toLowerCase().includes(query)
);
}) })
.sort((a, b) => { .sort((a, b) => {
if (sortKey === "id") return b.id - a.id; if (sortKey === "id") return b.id - a.id;
@@ -520,7 +576,7 @@ function TicketWorkbenchContent() {
const bDate = sortKey === "created" ? b.created_at : b.updated_at; const bDate = sortKey === "created" ? b.created_at : b.updated_at;
return new Date(bDate).getTime() - new Date(aDate).getTime(); return new Date(bDate).getTime() - new Date(aDate).getTime();
}); });
}, [clock, filters, queues, routeQueue, searchQuery, sortKey, tickets, view]); }, [clock, filters, queues, routeQueue, sortKey, tickets, view]);
@@ -538,24 +594,19 @@ function TicketWorkbenchContent() {
} }
}; };
const handleBatchStatus = async (newStatus: string) => { const handleBatchAction = async (update: { status?: string; owner_id?: string | null; team_id?: string | null }) => {
setBatchSaving(true); setBatchSaving(true);
for (const id of batchIds) { const ids = Array.from(batchIds);
await updateTicket(id, { status: newStatus }); const { data, error } = await batchUpdateTickets({ ticket_ids: ids, ...update });
}
setBatchSaving(false); setBatchSaving(false);
setBatchIds(new Set()); if (!error && data) {
await fetchData(); const failed = data.results.filter((r) => !r.ok);
}; if (failed.length > 0) {
setError(`${failed.length} of ${ids.length} tickets failed to update`);
const handleBatchAssign = async () => { }
const me = users[0]?.id; } else if (error) {
if (!me) return; setError(error);
setBatchSaving(true);
for (const id of batchIds) {
await updateTicket(id, { owner_id: me });
} }
setBatchSaving(false);
setBatchIds(new Set()); setBatchIds(new Set());
await fetchData(); await fetchData();
}; };
@@ -572,30 +623,27 @@ function TicketWorkbenchContent() {
e.stopPropagation(); e.stopPropagation();
setResizingCol(leftKey); setResizingCol(leftKey);
const startX = e.clientX; const startX = e.clientX;
const leftCol = columns.find((c) => c.key === leftKey); const leftField = fieldByKey.get(leftKey);
const rightCol = rightKey ? columns.find((c) => c.key === rightKey) : null; const rightField = rightKey ? fieldByKey.get(rightKey) : null;
const leftStart = leftCol?.width ?? 140; const leftStart = colWidths[leftKey] ?? leftField?.width ?? 140;
const rightStart = rightCol?.width ?? 140; const rightStart = rightField ? (colWidths[rightField.key] ?? rightField.width) : 140;
const onMove = (ev: MouseEvent) => { const onMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX; const delta = ev.clientX - startX;
const newLeft = Math.max(50, Math.min(800, leftStart + delta)); const newLeft = Math.max(50, Math.min(800, leftStart + delta));
const newRight = rightCol ? Math.max(50, Math.min(800, rightStart - delta)) : undefined; const newRight = rightField ? Math.max(50, Math.min(800, rightStart - delta)) : undefined;
setColumns((prev) => setColWidths((prev) => {
prev.map((c) => { const next = { ...prev, [leftKey]: newLeft };
if (c.key === leftKey) return { ...c, width: newLeft }; if (rightField && newRight !== undefined) next[rightField.key] = newRight;
if (rightCol && c.key === rightCol.key) return { ...c, width: newRight! }; return next;
return c; });
})
);
}; };
const onUp = () => { const onUp = () => {
setResizingCol(null);
document.removeEventListener("mousemove", onMove); document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp); document.removeEventListener("mouseup", onUp);
document.body.classList.remove("select-none");
setResizingCol(null);
}; };
document.body.classList.add("select-none");
document.addEventListener("mousemove", onMove); document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp); document.addEventListener("mouseup", onUp);
}; };
@@ -659,7 +707,7 @@ function TicketWorkbenchContent() {
if (data) router.push(`/tickets/${data.ticket.id}`); if (data) router.push(`/tickets/${data.ticket.id}`);
}; };
if (loading) return <SkeletonWorkbench />; if (initialLoad && loading) return <SkeletonWorkbench />;
return ( return (
<div className="flex h-full flex-col bg-background/80"> <div className="flex h-full flex-col bg-background/80">
@@ -676,6 +724,36 @@ function TicketWorkbenchContent() {
</h1> </h1>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
// Build CSV from visible columns
const allCols = [...row1Fields, ...(density === "comfortable" ? row2EntriesResolved.map((e) => fieldByKey.get(e.key)).filter(Boolean) as ColumnConfig[] : [])];
const headers = allCols.map((c) => c.label);
const rows = filteredTickets.map((ticket) => {
const ctx = { users, queues, teamsList };
return allCols.map((col) => {
if (col.key.startsWith("cf.")) {
const cfKey = col.key.slice(3);
return ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value ?? "";
}
const v = getSubtitleValue(col.key, ticket, ctx);
return v ?? "";
});
});
const csv = [headers.join(','), ...rows.map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `tickets-${new Date().toISOString().slice(0,10)}.csv`;
a.click(); URL.revokeObjectURL(url);
}}
className="h-8 border-border/80 bg-card/70"
>
<DownloadIcon className="h-4 w-4" />
Export
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -723,7 +801,7 @@ function TicketWorkbenchContent() {
<input <input
value={searchQuery} value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)} onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search subject, ticket ID, queue, or status" placeholder="Search tickets, comments, custom fields..."
className="h-9 w-full rounded-md border border-input bg-card/90 pl-9 pr-3 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring" className="h-9 w-full rounded-md border border-input bg-card/90 pl-9 pr-3 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
/> />
</div> </div>
@@ -830,7 +908,7 @@ function TicketWorkbenchContent() {
</button> </button>
</span> </span>
))} ))}
<div> <div className="flex items-center gap-1.5">
<button <button
ref={addFilterBtnRef} ref={addFilterBtnRef}
type="button" type="button"
@@ -846,6 +924,15 @@ function TicketWorkbenchContent() {
<PlusIcon className="h-3 w-3" /> <PlusIcon className="h-3 w-3" />
Add filter Add filter
</button> </button>
{filters.length > 0 && (
<button
type="button"
onClick={() => setFilters([])}
className="inline-flex h-7 items-center rounded px-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear all
</button>
)}
</div> </div>
</div> </div>
@@ -881,6 +968,35 @@ function TicketWorkbenchContent() {
</div> </div>
) : ( ) : (
<> <>
{batchIds.size > 0 && (
<div className="flex items-center gap-3 border-b border-border bg-primary/5 px-4 py-2">
<span className="text-xs font-semibold text-foreground">{batchIds.size} selected</span>
<select
onChange={(e) => {
const val = e.target.value;
if (val) { handleBatchAction({ status: val }); e.target.value = ""; }
}}
className="h-7 rounded border border-border/50 bg-card px-2 text-[11px] outline-none"
>
<option value="">Set status...</option>
{Array.from(new Set(filteredTickets.map((t) => t.status))).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<button
onClick={() => handleBatchAction({ owner_id: null })}
className="h-7 rounded border border-border/50 bg-card px-2 text-[11px] hover:bg-accent"
>
Unassign
</button>
<button
onClick={() => setBatchIds(new Set())}
className="ml-auto text-[11px] text-muted-foreground hover:text-foreground"
>
Clear
</button>
</div>
)}
{/* Table layout for consistent column alignment */} {/* Table layout for consistent column alignment */}
<div style={{ display: "table", tableLayout: "fixed", minWidth: "100%" }}> <div style={{ display: "table", tableLayout: "fixed", minWidth: "100%" }}>
{/* Column header */} {/* Column header */}
@@ -889,11 +1005,11 @@ function TicketWorkbenchContent() {
density === "compact" ? "min-h-7" : "min-h-8" density === "compact" ? "min-h-7" : "min-h-8"
)} style={{ display: "table-row" }}> )} style={{ display: "table-row" }}>
<div style={{ display: "table-cell", width: 48 }} /> <div style={{ display: "table-cell", width: 48 }} />
{availableColumns.filter((c) => c.visible).map((col, idx, arr) => ( {row1Fields.map((col, idx, arr) => (
<div <div
key={col.key} key={col.key}
className="relative border-r border-border/60 px-3 align-middle last:border-r-0" className="relative border-r border-border/60 px-3 align-middle last:border-r-0"
style={{ display: "table-cell", width: col.width }} style={{ display: "table-cell", width: colWidth(col.key, col.width) }}
> >
{/* Resize handle: drags the boundary, resizes column to the LEFT */} {/* Resize handle: drags the boundary, resizes column to the LEFT */}
{idx > 0 && ( {idx > 0 && (
@@ -910,6 +1026,39 @@ function TicketWorkbenchContent() {
<div style={{ display: "table-cell", width: 48 }} /> <div style={{ display: "table-cell", width: 48 }} />
</div> </div>
{/* Subtitle header — labels for each row2 field under its matching column */}
{row2EntriesResolved.length > 0 && (
<div className="border-b border-border/30 bg-muted/30" style={{ display: "table-row" }}>
<div style={{ display: "table-cell", width: 48 }} />
{row1Fields.map((col) => {
const subsHere = subsByColumn.get(col.key) ?? [];
const orphans = col.key === "subject" ? row2EntriesResolved.filter((e) => !row1Fields.some((rf) => rf.key === e.under)) : [];
if (subsHere.length > 0 || orphans.length > 0) {
return (
<div
key={col.key}
className="px-3 py-0.5 align-middle"
style={{ display: "table-cell", width: colWidth(col.key, col.width) }}
>
<div className="flex items-center gap-2 text-[9px] font-medium uppercase text-muted-foreground/50">
{subsHere.map((e) => {
const f = fieldByKey.get(e.key);
return <span key={e.key}>{f?.label ?? e.key}</span>;
})}
{orphans.map((e) => {
const f = fieldByKey.get(e.key);
return <span key={e.key}>{f?.label ?? e.key}</span>;
})}
</div>
</div>
);
}
return <div key={col.key} style={{ display: "table-cell", width: colWidth(col.key, col.width) }} />;
})}
<div style={{ display: "table-cell", width: 48 }} />
</div>
)}
{filteredTickets.map((ticket) => { {filteredTickets.map((ticket) => {
const selected = false; const selected = false;
const ownerName = ticket.owner_id const ownerName = ticket.owner_id
@@ -946,10 +1095,10 @@ function TicketWorkbenchContent() {
title={statusLabel(ticket.status)} title={statusLabel(ticket.status)}
/> />
</div> </div>
{availableColumns.filter((c) => c.visible).map((col) => { {row1Fields.map((col) => {
const cellStyle = { const cellStyle = {
display: "table-cell" as const, display: "table-cell" as const,
width: col.width, width: colWidth(col.key, col.width),
verticalAlign: "middle" as const, verticalAlign: "middle" as const,
padding: density === "compact" ? "4px 12px" : "8px 12px", padding: density === "compact" ? "4px 12px" : "8px 12px",
}; };
@@ -957,64 +1106,121 @@ function TicketWorkbenchContent() {
if (col.key.startsWith("cf.")) { if (col.key.startsWith("cf.")) {
const cfKey = col.key.slice(3); const cfKey = col.key.slice(3);
const cfValue = ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value; const cfValue = ticket.custom_fields?.find((v) => v.custom_field?.key === cfKey || v.custom_field?.name === cfKey)?.value;
const cfSubs = subsByColumn.get(col.key) ?? [];
return ( return (
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}> <div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
{cfValue ?? "—"} {cfValue ?? "—"}
{density === "comfortable" && cfSubs.map((e) => <div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>)}
</div> </div>
); );
} }
switch (col.key) { switch (col.key) {
case "id": case "id": {
const idSubs = subsByColumn.get("id") ?? [];
return ( return (
<div key={col.key} className="font-mono text-xs font-semibold text-muted-foreground" style={cellStyle}> <div key={col.key} className="font-mono text-xs font-semibold text-muted-foreground" style={cellStyle}>
{formatTicketId(ticket.id)} {formatTicketId(ticket.id)}
{density === "comfortable" && idSubs.map((e) => <div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>)}
</div> </div>
); );
case "subject": }
case "subject": {
// Subtitle fields that don't have a matching row1 column
// Subtitle under subject + orphans (under column not in row1)
const subsHere = row2EntriesResolved.filter((e) =>
e.under === "subject" || !row1Fields.some((rf) => rf.key === e.under)
);
const subParts: string[] = [];
const ctx = { users, queues, teamsList };
for (const e of subsHere) {
const v = getSubtitleValue(e.key, ticket, ctx);
if (v) subParts.push(v);
}
return ( return (
<div key={col.key} className="min-w-[200px]" style={cellStyle}> <div key={col.key} className="min-w-[240px]" style={cellStyle}>
<span className="block truncate text-sm font-semibold text-foreground"> <span className="block truncate text-sm font-semibold text-foreground">
{ticket.subject} {ticket.subject}
</span> </span>
{density === "comfortable" && ( {density === "comfortable" && subParts.length > 0 && (
<span className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground"> <div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{ownerName ?? "Unassigned"} {subParts.map((part, i) => (
<span className="h-1 w-1 rounded-full bg-border" /> <span key={i} className="flex items-center gap-1.5">
Created {relativeTime(ticket.created_at)} {i > 0 && <span className="h-1 w-1 rounded-full bg-border shrink-0" />}
</span> {part}
</span>
))}
</div>
)} )}
</div> </div>
); );
}
case "status": case "status":
const statusSubs = subsByColumn.get("status") ?? [];
return ( return (
<div key={col.key} style={cellStyle}> <div key={col.key} style={cellStyle}>
<TicketStatusBadge status={ticket.status} /> <TicketStatusBadge status={ticket.status} />
{density === "comfortable" && statusSubs.map((e) => (
<div key={e.key} className="mt-0.5 text-xs text-muted-foreground">
{getSubtitleValue(e.key, ticket, { users, queues, teamsList })}
</div>
))}
</div> </div>
); );
case "queue": case "queue": {
const subs = subsByColumn.get("queue") ?? [];
return ( return (
<div key={col.key} className="truncate text-sm font-medium text-muted-foreground" style={cellStyle}> <div key={col.key} className="truncate text-sm font-medium text-muted-foreground" style={cellStyle}>
{queueName(queues, ticket.queue_id)} {queueName(queues, ticket.queue_id)}
{density === "comfortable" && subs.map((e) => (
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
))}
</div> </div>
); );
case "owner": }
case "owner": {
const subs = subsByColumn.get("owner") ?? [];
return ( return (
<div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}> <div key={col.key} className="truncate text-sm text-foreground" style={cellStyle}>
{ownerName ?? "—"} {ownerName ?? "—"}
{density === "comfortable" && subs.map((e) => (
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
))}
</div> </div>
); );
case "created": }
case "created": {
const subs = subsByColumn.get("created") ?? [];
return ( return (
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}> <div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
{relativeTime(ticket.created_at)} {relativeTime(ticket.created_at)}
{density === "comfortable" && subs.map((e) => (
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
))}
</div> </div>
); );
case "updated": }
case "updated": {
const subs = subsByColumn.get("updated") ?? [];
return ( return (
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}> <div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
{relativeTime(ticket.updated_at)} {relativeTime(ticket.updated_at)}
{density === "comfortable" && subs.map((e) => (
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
))}
</div> </div>
); );
}
case "team": {
const subs = subsByColumn.get("team") ?? [];
return (
<div key={col.key} className="text-xs text-muted-foreground" style={cellStyle}>
{teamsList.find((t) => t.id === ticket.team_id)?.name ?? "—"}
{density === "comfortable" && subs.map((e) => (
<div key={e.key} className="text-xs text-muted-foreground/70">{getSubtitleValue(e.key, ticket, {users, queues, teamsList})}</div>
))}
</div>
);
}
default: default:
return <div key={col.key} style={cellStyle} />; return <div key={col.key} style={cellStyle} />;
} }
@@ -1188,7 +1394,7 @@ function TicketWorkbenchContent() {
name: saveViewName.trim(), name: saveViewName.trim(),
filters: storedFilters, filters: storedFilters,
sort_key: sortKey, sort_key: sortKey,
columns, columns: [...row1Fields.map((f) => ({...f, display: "column"})), ...row2EntriesResolved.map((e) => ({key: e.key, under: e.under, display: "subtitle"}))] as any,
}); });
if (!error && data) { if (!error && data) {
setSavedViewsList((prev) => [...prev, data]); setSavedViewsList((prev) => [...prev, data]);
@@ -1220,7 +1426,7 @@ function TicketWorkbenchContent() {
name: saveViewName.trim(), name: saveViewName.trim(),
filters: storedFilters, filters: storedFilters,
sort_key: sortKey, sort_key: sortKey,
columns, columns: [...row1Fields.map((f) => ({...f, display: "column"})), ...row2EntriesResolved.map((e) => ({key: e.key, under: e.under, display: "subtitle"}))] as any,
}); });
if (!error && data) { if (!error && data) {
setSavedViewsList((prev) => [...prev, data]); setSavedViewsList((prev) => [...prev, data]);
@@ -1250,32 +1456,26 @@ function TicketWorkbenchContent() {
> >
{!addFilterField ? ( {!addFilterField ? (
<> <>
<button {[
type="button" { field: "queue", label: "Queue" },
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent" { field: "owner", label: "Owner" },
onClick={() => { { field: "subject", label: "Subject" },
if (!filters.find((f) => f.field === "queue")) { { field: "created", label: "Created date" },
setAddFilterField("queue"); { field: "updated", label: "Updated date" },
setAddFilterOperator("is"); ].map(({ field, label }) => (
<button
key={field}
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
if (field === "queue" && filters.find((f) => f.field === "queue")) { setAddFilterOpen(false); return; }
setAddFilterField(field);
setAddFilterOperator(field === "created" || field === "updated" ? "before" : "contains");
setAddFilterValue(""); setAddFilterValue("");
} else { }}
setAddFilterOpen(false); >{label}</button>
} ))}
}} <div className="my-1 border-t border-border/30" />
>Queue</button>
<button
type="button"
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => {
if (!filters.find((f) => f.field === "owner")) {
setAddFilterField("owner");
setAddFilterOperator("is");
setAddFilterValue("");
} else {
setAddFilterOpen(false);
}
}}
>Owner</button>
{customFields.map((cf) => ( {customFields.map((cf) => (
<button <button
key={`cf-portal-${cf.id}`} key={`cf-portal-${cf.id}`}
@@ -1283,7 +1483,7 @@ function TicketWorkbenchContent() {
className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent" className="w-full rounded px-2 py-1.5 text-left text-xs text-foreground hover:bg-accent"
onClick={() => { onClick={() => {
setAddFilterField(`cf.${cf.key}`); setAddFilterField(`cf.${cf.key}`);
setAddFilterOperator("is"); setAddFilterOperator(cf.field_type === "date" || cf.field_type === "datetime" ? "before" : "contains");
setAddFilterValue(""); setAddFilterValue("");
}} }}
>{cf.name}</button> >{cf.name}</button>
@@ -1295,26 +1495,55 @@ function TicketWorkbenchContent() {
<button type="button" onClick={() => setAddFilterField(null)} className="text-muted-foreground hover:text-foreground"></button> <button type="button" onClick={() => setAddFilterField(null)} className="text-muted-foreground hover:text-foreground"></button>
<span className="font-medium text-foreground">{addFilterField.startsWith("cf.") ? addFilterField.slice(3) : addFilterField}</span> <span className="font-medium text-foreground">{addFilterField.startsWith("cf.") ? addFilterField.slice(3) : addFilterField}</span>
</div> </div>
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"> {addFilterField === "queue" || addFilterField === "owner" ? (
<option value="is">is</option> <select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
<option value="is_not">is not</option> <option value="is">is</option>
</select> <option value="is_not">is not</option>
</select>
) : addFilterField === "created" || addFilterField === "updated" ? (
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
<option value="before">before</option>
<option value="after">after</option>
</select>
) : (
<select value={addFilterOperator} onChange={(e) => setAddFilterOperator(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none">
<option value="contains">contains</option>
<option value="is">is</option>
<option value="is_not">is not</option>
<option value="starts_with">starts with</option>
</select>
)}
{addFilterField === "queue" ? ( {addFilterField === "queue" ? (
<select value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"> <SearchableSelect
<option value="">Select queue...</option> value={addFilterValue}
{queues.map((q) => (<option key={q.id} value={q.id}>{q.name}</option>))} onChange={setAddFilterValue}
</select> options={queues.map((q) => ({ value: q.id, label: q.name }))}
placeholder="Select queue..."
searchPlaceholder="Search queues..."
className="w-48"
/>
) : addFilterField === "owner" ? ( ) : addFilterField === "owner" ? (
<select value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"> <SearchableSelect
<option value="">Select owner...</option> value={addFilterValue}
<option value="unassigned">Unassigned</option> onChange={setAddFilterValue}
{users.map((u) => (<option key={u.id} value={u.id}>{u.username}</option>))} options={[
</select> { value: "unassigned", label: "Unassigned" },
...users.map((u) => ({ value: u.id, label: u.username })),
]}
placeholder="Select owner..."
searchPlaceholder="Search users..."
className="w-48"
/>
) : addFilterField === "created" || addFilterField === "updated" ? (
<input type="date" value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none" />
) : ( ) : (
<input value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} placeholder="Value" className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none" <input value={addFilterValue} onChange={(e) => setAddFilterValue(e.target.value)} placeholder="Value" className="h-7 w-full rounded border border-input bg-card px-2 text-xs outline-none"
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && addFilterValue.trim()) { if (e.key === "Enter" && addFilterValue.trim()) {
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field: addFilterField, operator: addFilterOperator, value: addFilterValue, label: buildFilterLabel(addFilterField, addFilterOperator, addFilterValue) }]); const field = addFilterField!;
const value = addFilterValue;
let valueLabel = field === "created" || field === "updated" ? new Date(value).toLocaleDateString() : value;
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]);
setAddFilterField(null); setAddFilterField(null);
setAddFilterOpen(false); setAddFilterOpen(false);
} }
@@ -1326,11 +1555,12 @@ function TicketWorkbenchContent() {
<button type="button" disabled={!addFilterValue.trim()} <button type="button" disabled={!addFilterValue.trim()}
onClick={() => { onClick={() => {
if (!addFilterValue.trim()) return; if (!addFilterValue.trim()) return;
const field = addFilterField; const field = addFilterField!;
const value = addFilterValue; const value = addFilterValue;
let valueLabel = value; let valueLabel = value;
if (field === "queue") valueLabel = queues.find((q) => q.id === value)?.name ?? value; if (field === "queue") valueLabel = queues.find((q) => q.id === value)?.name ?? value;
else if (field === "owner") valueLabel = value === "unassigned" ? "Unassigned" : users.find((u) => u.id === value)?.username ?? value; else if (field === "owner") valueLabel = value === "unassigned" ? "Unassigned" : users.find((u) => u.id === value)?.username ?? value;
else if (field === "created" || field === "updated") valueLabel = new Date(value).toLocaleDateString();
setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]); setFilters((prev) => [...prev, { id: crypto.randomUUID(), field, operator: addFilterOperator, value, label: buildFilterLabel(field, addFilterOperator, valueLabel) }]);
setAddFilterField(null); setAddFilterField(null);
setAddFilterOpen(false); setAddFilterOpen(false);
@@ -1346,33 +1576,16 @@ function TicketWorkbenchContent() {
)} )}
{typeof document !== "undefined" && colPickerOpen && createPortal( {typeof document !== "undefined" && colPickerOpen && createPortal(
<> <LayoutBuilder
<div className="fixed inset-0 z-[9998]" onClick={() => setColPickerOpen(false)} /> fields={allFields}
<div className="fixed z-[9999] w-48 rounded-md border border-border bg-card p-1 shadow-lg" row1={row1Fields}
style={{ left: "calc(100% - 220px)", top: "72px" }} row2={row2EntriesResolved}
> onChange={(r1, r2) => {
{availableColumns.map((col) => { setRow1Keys(r1.map((f) => f.key));
const isVisible = columns.find((c) => c.key === col.key)?.visible ?? col.visible; setRow2Entries(r2);
return ( }}
<button onClose={() => setColPickerOpen(false)}
key={col.key} />,
type="button"
onClick={() => {
setColumns((prev) =>
prev.map((c) => c.key === col.key ? { ...c, visible: !c.visible } : c)
);
}}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs text-foreground hover:bg-accent"
>
<span className={cn("text-xs", isVisible ? "text-primary" : "text-muted-foreground/30")}>
{isVisible ? "✓" : "—"}
</span>
{col.label}
</button>
);
})}
</div>
</>,
document.body document.body
)} )}
</div> </div>

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { useState, useEffect, use, useCallback } from "react"; import { useState, useEffect, use, useCallback, useRef } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow, format } from "date-fns";
import { import {
ArrowLeftIcon, ArrowLeftIcon,
BotIcon, BotIcon,
@@ -12,11 +12,13 @@ import {
CircleIcon, CircleIcon,
Clock3Icon, Clock3Icon,
FileTextIcon, FileTextIcon,
Link2Icon,
MessageSquareIcon, MessageSquareIcon,
PaperclipIcon, PaperclipIcon,
PencilIcon, PencilIcon,
SaveIcon, SaveIcon,
SendIcon, SendIcon,
Trash2Icon,
UserRoundIcon, UserRoundIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
@@ -32,6 +34,12 @@ import {
updateTicket, updateTicket,
updateTicketCustomField, updateTicketCustomField,
sendComment, sendComment,
uploadAttachments,
getAttachmentUrl,
getTicketLinks,
createTicketLink,
deleteTicketLink,
mergeTickets,
} from "@/lib/api"; } from "@/lib/api";
import type { import type {
Ticket, Ticket,
@@ -43,8 +51,12 @@ import type {
QueueCustomField, QueueCustomField,
PreviewResult, PreviewResult,
UpdateResult, UpdateResult,
AttachmentUploadResult,
Attachment,
TicketLink,
} from "@/lib/types"; } from "@/lib/types";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { SearchableSelect } from "@/components/searchable-select";
import { cn, formatTicketId } from "@/lib/utils"; import { cn, formatTicketId } from "@/lib/utils";
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
@@ -101,6 +113,25 @@ function userLabel(users: User[], userId: string | null) {
return user?.username ?? userId; return user?.username ?? userId;
} }
function formatCfValue(value: string, fieldType: string): string {
if (!value) return "";
if (fieldType === "date") {
try {
return format(new Date(value), "MMM d, yyyy");
} catch {
return value;
}
}
if (fieldType === "datetime") {
try {
return format(new Date(value), "MMM d, yyyy HH:mm");
} catch {
return value;
}
}
return value;
}
function TransactionCard({ function TransactionCard({
tx, tx,
users, users,
@@ -117,6 +148,8 @@ function TransactionCard({
tx.transaction_type === "SetOwner" || tx.transaction_type === "SetOwner" ||
tx.transaction_type === "SetTeam" || tx.transaction_type === "SetTeam" ||
tx.transaction_type === "CustomFieldChange" || tx.transaction_type === "CustomFieldChange" ||
tx.transaction_type === "LinkCreate" ||
tx.transaction_type === "LinkDelete" ||
tx.transaction_type === "Create"; tx.transaction_type === "Create";
const isInternal = tx.transaction_type === "Comment"; const isInternal = tx.transaction_type === "Comment";
const isMessage = tx.transaction_type === "Correspond" || isInternal; const isMessage = tx.transaction_type === "Correspond" || isInternal;
@@ -141,6 +174,26 @@ function TransactionCard({
} else if (tx.transaction_type === "CustomFieldChange") { } else if (tx.transaction_type === "CustomFieldChange") {
const fieldName = tx.field ? customFieldLabels[tx.field] ?? "Custom field" : "Custom field"; const fieldName = tx.field ? customFieldLabels[tx.field] ?? "Custom field" : "Custom field";
message = tx.new_value ? `${fieldName} set to ${tx.new_value}` : `${fieldName} cleared`; message = tx.new_value ? `${fieldName} set to ${tx.new_value}` : `${fieldName} cleared`;
} else if (tx.transaction_type === "LinkCreate") {
const targetId = typeof tx.data === "object" && tx.data !== null && "target_ticket_id" in (tx.data as Record<string, unknown>)
? Number((tx.data as Record<string, unknown>).target_ticket_id)
: null;
const targetSubject = typeof tx.data === "object" && tx.data !== null && "target_subject" in (tx.data as Record<string, unknown>)
? String((tx.data as Record<string, unknown>).target_subject)
: "";
const linkType = tx.field || "RelatedTo";
const targetLabel = targetId ? `${formatTicketId(targetId)}${targetSubject ? ` (${targetSubject})` : ""}` : "?";
message = `Linked as ${linkType} to ${targetLabel}`;
} else if (tx.transaction_type === "LinkDelete") {
const targetId = typeof tx.data === "object" && tx.data !== null && "target_ticket_id" in (tx.data as Record<string, unknown>)
? Number((tx.data as Record<string, unknown>).target_ticket_id)
: null;
const targetSubject = typeof tx.data === "object" && tx.data !== null && "target_subject" in (tx.data as Record<string, unknown>)
? String((tx.data as Record<string, unknown>).target_subject)
: "";
const linkType = tx.field || "RelatedTo";
const targetLabel = targetId ? `${formatTicketId(targetId)}${targetSubject ? ` (${targetSubject})` : ""}` : "?";
message = `Link ${linkType} to ${targetLabel} removed`;
} }
return ( return (
@@ -172,10 +225,42 @@ function TransactionCard({
</span> </span>
)} )}
<span className="text-[11px] text-muted-foreground/50">{timeAgo}</span> <span className="text-[11px] text-muted-foreground/50">{timeAgo}</span>
{(tx.time_worked_minutes ?? 0) > 0 && (
<span className="rounded bg-emerald-500/10 px-1 py-0 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
+{tx.time_worked_minutes}m
</span>
)}
</div> </div>
<p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed text-foreground/90"> <p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
{body} {body}
</p> </p>
{tx.attachments && tx.attachments.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{tx.attachments.map((att) => (
<a
key={att.id}
href={getAttachmentUrl(att.id)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-border/50 bg-card px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:border-primary/30 hover:bg-accent/30"
>
{att.mime_type.startsWith("image/") ? (
<FileTextIcon className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<FileTextIcon className="h-3.5 w-3.5 text-muted-foreground" />
)}
<span className="truncate max-w-48">{att.filename}</span>
<span className="shrink-0 text-[10px] text-muted-foreground/70">
{att.size_bytes < 1024
? `${att.size_bytes}B`
: att.size_bytes < 1024 * 1024
? `${(att.size_bytes / 1024).toFixed(0)}KB`
: `${(att.size_bytes / (1024 * 1024)).toFixed(1)}MB`}
</span>
</a>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -212,8 +297,12 @@ export default function TicketDetailPage({
const [replyText, setReplyText] = useState(""); const [replyText, setReplyText] = useState("");
const [replyMode, setReplyMode] = useState<"public" | "internal">("public"); const [replyMode, setReplyMode] = useState<"public" | "internal">("public");
const [timeMinutes, setTimeMinutes] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [sendError, setSendError] = useState<string | null>(null); const [sendError, setSendError] = useState<string | null>(null);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const [uploadingFiles, setUploadingFiles] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [statusSelectOpen, setStatusSelectOpen] = useState(false); const [statusSelectOpen, setStatusSelectOpen] = useState(false);
const [pendingStatus, setPendingStatus] = useState<string | null>(null); const [pendingStatus, setPendingStatus] = useState<string | null>(null);
@@ -224,6 +313,16 @@ export default function TicketDetailPage({
const [editingSubject, setEditingSubject] = useState(false); const [editingSubject, setEditingSubject] = useState(false);
const [subjectDraft, setSubjectDraft] = useState(""); const [subjectDraft, setSubjectDraft] = useState("");
const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | "team" | null>(null); const [fieldSaving, setFieldSaving] = useState<"subject" | "owner" | "team" | null>(null);
const [links, setLinks] = useState<TicketLink[]>([]);
const [linkTargetId, setLinkTargetId] = useState("");
const [linkType, setLinkType] = useState("RelatedTo");
const [linkSaving, setLinkSaving] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const [linkDeleting, setLinkDeleting] = useState<string | null>(null);
const [mergeTargetId, setMergeTargetId] = useState("");
const [mergeSaving, setMergeSaving] = useState(false);
const [mergeError, setMergeError] = useState<string | null>(null);
const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({}); const [customFieldDrafts, setCustomFieldDrafts] = useState<Record<string, string>>({});
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null); const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
const [editingFieldId, setEditingFieldId] = useState<string | null>(null); const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
@@ -287,6 +386,14 @@ export default function TicketDetailPage({
setTeams(teamsRes.data ?? []); setTeams(teamsRes.data ?? []);
} }
// Load ticket links
const linksRes = await getTicketLinks(id);
if (linksRes.error) {
setError((prev) => prev || linksRes.error);
} else {
setLinks(linksRes.data ?? []);
}
setLoading(false); setLoading(false);
}, [id]); }, [id]);
@@ -466,13 +573,34 @@ export default function TicketDetailPage({
}; };
const handleSendComment = async () => { const handleSendComment = async () => {
if (!replyText.trim() || sending) return; if ((!replyText.trim() && pendingFiles.length === 0) || sending) return;
setSending(true); setSending(true);
setSendError(null); setSendError(null);
let attachmentIds: string[] = [];
// Upload files first if any
if (pendingFiles.length > 0) {
setUploadingFiles(true);
const { data, error } = await uploadAttachments(id, pendingFiles);
setUploadingFiles(false);
if (error) {
setSendError(error);
setSending(false);
return;
}
if (data) {
attachmentIds = data.attachments.map((a) => a.id);
}
}
const { error } = await sendComment(id, { const { error } = await sendComment(id, {
body: replyText.trim(), body: replyText.trim() || "(attached files)",
internal: replyMode === "internal", internal: replyMode === "internal",
attachment_ids: attachmentIds.length > 0 ? attachmentIds : undefined,
time_worked_minutes: timeMinutes.trim() ? Number(timeMinutes.trim()) : undefined,
}); });
setSending(false); setSending(false);
@@ -481,12 +609,88 @@ export default function TicketDetailPage({
setSendError(error); setSendError(error);
} else { } else {
setReplyText(""); setReplyText("");
setTimeMinutes("");
setPendingFiles([]);
setSendError(null); setSendError(null);
const txRes = await getTicketTransactions(id); const txRes = await getTicketTransactions(id);
if (txRes.data) setTransactions(txRes.data); if (txRes.data) setTransactions(txRes.data);
} }
}; };
const handleCreateLink = async () => {
// Accept both raw numbers and TKT-XXXX format
const raw = linkTargetId.trim().replace(/^TKT-0*/i, '');
const targetId = Number(raw);
if (!raw || isNaN(targetId)) {
setLinkError('Enter a ticket ID (e.g. "42" or "TKT-0042")');
return;
}
if (linkSaving) return;
setLinkSaving(true);
setLinkError(null);
const { data, error } = await createTicketLink(id, {
target_ticket_id: targetId,
link_type: linkType,
});
setLinkSaving(false);
if (error) {
setLinkError(error);
} else {
setLinkTargetId("");
setLinkType("RelatedTo");
// Refresh links and transactions
const [linksRes, txRes] = await Promise.all([
getTicketLinks(id),
getTicketTransactions(id),
]);
if (linksRes.data) setLinks(linksRes.data);
if (txRes.data) setTransactions(txRes.data);
}
};
const handleDeleteLink = async (linkId: string) => {
if (linkDeleting) return;
setLinkDeleting(linkId);
const { error } = await deleteTicketLink(id, linkId);
setLinkDeleting(null);
if (!error) {
const [linksRes, txRes] = await Promise.all([
getTicketLinks(id),
getTicketTransactions(id),
]);
if (linksRes.data) setLinks(linksRes.data);
if (txRes.data) setTransactions(txRes.data);
}
};
const handleMerge = async () => {
const targetId = Number(mergeTargetId.trim().replace(/^TKT-0*/i, ''));
if (!targetId || isNaN(targetId) || mergeSaving) return;
setMergeSaving(true);
setMergeError(null);
const { data, error } = await mergeTickets(id, targetId);
setMergeSaving(false);
if (error) {
setMergeError(error);
} else if (data?.ok) {
setMergeTargetId("");
// Reload ticket and transactions
const [ticketRes, txRes] = await Promise.all([
getTicket(id),
getTicketTransactions(id),
]);
if (ticketRes.data) setTicket(ticketRes.data);
if (txRes.data) setTransactions(txRes.data);
}
};
if (loading) { if (loading) {
return ( return (
<div className="grid h-full grid-cols-1 xl:grid-cols-[minmax(0,1fr)_348px]"> <div className="grid h-full grid-cols-1 xl:grid-cols-[minmax(0,1fr)_348px]">
@@ -546,10 +750,13 @@ export default function TicketDetailPage({
const currentStatusColor = STATUS_COLORS[ticket.status] || STATUS_COLORS.new; const currentStatusColor = STATUS_COLORS[ticket.status] || STATUS_COLORS.new;
const currentStatusLabel = statusLabel(ticket.status); const currentStatusLabel = statusLabel(ticket.status);
const customFieldLabels = Object.fromEntries( const customFieldLabels = Object.fromEntries(
queueFields.map((assignment) => [ queueFields.flatMap((assignment) => {
assignment.custom_field_id, const name = assignment.custom_field?.name ?? assignment.custom_field_id;
assignment.custom_field?.name ?? assignment.custom_field_id, const key = assignment.custom_field?.key;
]) const entries: [string, string][] = [[assignment.custom_field_id, name]];
if (key) entries.push([key, name]);
return entries;
})
); );
return ( return (
@@ -707,6 +914,28 @@ export default function TicketDetailPage({
</span> </span>
</div> </div>
{pendingFiles.length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{pendingFiles.map((file, i) => (
<span
key={`${file.name}-${i}`}
className="inline-flex items-center gap-1 rounded-md border border-border/50 bg-accent/20 px-2 py-0.5 text-xs text-foreground"
>
<FileTextIcon className="h-3 w-3 text-muted-foreground" />
<span className="truncate max-w-32">{file.name}</span>
<button
onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== i))}
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
title="Remove file"
type="button"
>
<XIcon className="h-2.5 w-2.5" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<textarea <textarea
value={replyText} value={replyText}
@@ -719,19 +948,46 @@ export default function TicketDetailPage({
className="min-h-24 flex-1 resize-none rounded-md border border-input bg-card/90 px-3 py-2 text-sm leading-6 text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring" className="min-h-24 flex-1 resize-none rounded-md border border-input bg-card/90 px-3 py-2 text-sm leading-6 text-foreground shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus:border-ring"
/> />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input
value={timeMinutes}
onChange={(e) => setTimeMinutes(e.target.value.replace(/\D/g, ''))}
placeholder="min"
className="h-9 w-14 rounded-md border border-input bg-card/90 px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-ring"
title="Time worked (minutes)"
/>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
const selected = Array.from(event.target.files ?? []);
if (selected.length > 0) {
setPendingFiles((prev) => [...prev, ...selected]);
}
// Reset so the same file can be re-selected
event.target.value = "";
}}
/>
<button <button
className="flex h-9 w-9 items-center justify-center rounded-md border border-border bg-card text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" onClick={() => fileInputRef.current?.click()}
title="Attach file (coming soon)" className={cn(
"flex h-9 w-9 items-center justify-center rounded-md border border-border bg-card transition-colors hover:bg-accent",
pendingFiles.length > 0
? "text-primary border-primary/50"
: "text-muted-foreground hover:text-foreground"
)}
title="Attach files"
type="button" type="button"
> >
<PaperclipIcon className="h-4 w-4" /> <PaperclipIcon className="h-4 w-4" />
</button> </button>
<button <button
onClick={handleSendComment} onClick={handleSendComment}
disabled={!replyText.trim() || sending} disabled={(!replyText.trim() && pendingFiles.length === 0) || sending}
className={cn( className={cn(
"flex h-9 w-9 items-center justify-center rounded-md transition-colors", "flex h-9 w-9 items-center justify-center rounded-md transition-colors",
replyText.trim() && !sending (replyText.trim() || pendingFiles.length > 0) && !sending
? "bg-primary text-primary-foreground hover:bg-primary/90" ? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground" : "bg-muted text-muted-foreground"
)} )}
@@ -749,6 +1005,14 @@ export default function TicketDetailPage({
</div> </div>
</div> </div>
{pendingFiles.length > 0 && (
<p className="mt-1.5 text-[10px] text-muted-foreground/70">
{pendingFiles.length} file{pendingFiles.length !== 1 ? "s" : ""} selected will be uploaded when you send
</p>
)}
{uploadingFiles && (
<p className="mt-1.5 text-[10px] text-muted-foreground/70">Uploading files...</p>
)}
{sendError && <p className="mt-2 text-xs text-destructive">{sendError}</p>} {sendError && <p className="mt-2 text-xs text-destructive">{sendError}</p>}
</div> </div>
</footer> </footer>
@@ -803,6 +1067,30 @@ export default function TicketDetailPage({
</div> </div>
</section> </section>
{ticket.blocked_by && ticket.blocked_by.length > 0 && (
<section className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
<span className="h-2 w-2 rounded-full bg-amber-500" /> Blocked
</div>
<p className="mt-1 text-[11px] text-muted-foreground">
This ticket depends on unresolved tickets:
</p>
<div className="mt-1.5 space-y-0.5">
{ticket.blocked_by.map((b) => (
<Link
key={b.id}
href={`/tickets/${b.id}`}
className="flex items-center gap-1.5 text-[11px] text-foreground hover:text-primary"
>
<span className="font-mono text-[10px]">{formatTicketId(b.id)}</span>
<span className="truncate">{b.subject}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">{b.status}</span>
</Link>
))}
</div>
</section>
)}
{preview && ( {preview && (
<section className="rounded-lg border border-border bg-accent/20 p-3"> <section className="rounded-lg border border-border bg-accent/20 p-3">
<div className="flex items-center gap-2 text-xs font-semibold text-foreground"> <div className="flex items-center gap-2 text-xs font-semibold text-foreground">
@@ -851,29 +1139,27 @@ export default function TicketDetailPage({
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">Owner</label> <label className="mb-1 block text-[10px] font-medium text-muted-foreground">Owner</label>
<select <SearchableSelect
value={ticket.owner_id ?? ""} value={ticket.owner_id ?? ""}
onChange={(event) => void handleOwnerChange(event.target.value)} onChange={(val) => void handleOwnerChange(val)}
options={users.map((u) => ({ value: u.id, label: u.username }))}
placeholder="Unassigned"
searchPlaceholder="Search users..."
disabled={fieldSaving === "owner"} disabled={fieldSaving === "owner"}
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm text-foreground outline-none focus:border-ring disabled:opacity-50" clearLabel="Unassigned"
aria-label="Owner" />
>
<option value="">Unassigned</option>
{users.map((user) => (<option key={user.id} value={user.id}>{user.username}</option>))}
</select>
</div> </div>
<div> <div>
<label className="mb-1 block text-[10px] font-medium text-muted-foreground">Team</label> <label className="mb-1 block text-[10px] font-medium text-muted-foreground">Team</label>
<select <SearchableSelect
value={ticket.team_id ?? ""} value={ticket.team_id ?? ""}
onChange={(event) => void handleTeamChange(event.target.value)} onChange={(val) => void handleTeamChange(val)}
options={teams.map((t) => ({ value: t.id, label: t.name }))}
placeholder="No team"
searchPlaceholder="Search teams..."
disabled={fieldSaving === "team"} disabled={fieldSaving === "team"}
className="h-8 w-full rounded-md border border-input bg-transparent px-2.5 text-sm text-foreground outline-none focus:border-ring disabled:opacity-50" clearLabel="No team"
aria-label="Team" />
>
<option value="">No team</option>
{teams.map((team) => (<option key={team.id} value={team.id}>{team.name}</option>))}
</select>
</div> </div>
</div> </div>
</section> </section>
@@ -900,9 +1186,117 @@ export default function TicketDetailPage({
<span className="text-foreground">{formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })}</span> <span className="text-foreground">{formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })}</span>
</div> </div>
)} )}
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="text-muted-foreground">Time worked</span>
<span className="text-foreground">
{(() => {
const totalMin = transactions.reduce((sum, tx) => sum + (tx.time_worked_minutes ?? 0), 0);
if (totalMin < 60) return `${totalMin}m`;
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return m > 0 ? `${h}h ${m}m` : `${h}h`;
})()}
</span>
</div>
</div> </div>
</section> </section>
<Separator />
{/* Linked tickets */}
<section>
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Linked tickets</h2>
{links.length === 0 && !linkSaving && (
<p className="text-xs text-muted-foreground">No linked tickets.</p>
)}
<div className="space-y-1">
{links.map((link) => (
<div
key={link.id}
className="group flex items-center gap-1.5 rounded px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent/30"
>
<Link2Icon className="h-3 w-3 shrink-0 text-muted-foreground/60" />
<span className="text-[10px] font-medium text-muted-foreground shrink-0">{link.link_type}</span>
<span className="text-[10px] text-muted-foreground/50 shrink-0"></span>
{link.target_ticket ? (
<Link
href={`/tickets/${link.target_ticket_id}`}
className="font-mono text-[11px] font-semibold text-foreground shrink-0 transition-colors hover:text-primary"
>
{formatTicketId(link.target_ticket_id)}
</Link>
) : (
<span className="font-mono text-[11px] text-muted-foreground shrink-0">{link.target_ticket_id}</span>
)}
{link.target_ticket && (
<span className="min-w-0 truncate text-xs text-muted-foreground">
{link.target_ticket.subject}
</span>
)}
<button
onClick={() => handleDeleteLink(link.id)}
disabled={linkDeleting === link.id}
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/20 hover:text-destructive group-hover:opacity-100 disabled:opacity-50"
title="Remove link"
type="button"
>
{linkDeleting === link.id ? (
<div className="h-2.5 w-2.5 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
) : (
<Trash2Icon className="h-2.5 w-2.5" />
)}
</button>
</div>
))}
</div>
{/* Inline link form */}
<div className="mt-2.5 space-y-2 rounded-md border border-border/30 p-2">
<div className="flex gap-1.5">
<input
value={linkTargetId}
onChange={(event) => {
setLinkTargetId(event.target.value);
setLinkError(null);
}}
onKeyDown={(event) => {
if (event.key === "Enter") handleCreateLink();
}}
placeholder="Ticket ID..."
className="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-ring"
/>
<select
value={linkType}
onChange={(event) => setLinkType(event.target.value)}
className="h-7 w-28 shrink-0 rounded-md border border-input bg-transparent px-1.5 text-sm text-foreground outline-none focus:border-ring"
>
<option value="RelatedTo">Related to</option>
<option value="DependsOn">Depends on</option>
<option value="Blocks">Blocks</option>
<option value="RefersTo">Refers to</option>
<option value="Duplicates">Duplicates</option>
<option value="MemberOf">Member of</option>
</select>
</div>
<button
onClick={handleCreateLink}
disabled={!linkTargetId.trim() || linkSaving}
className="flex h-6.5 w-full items-center justify-center gap-1 rounded-md bg-primary text-[11px] font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
type="button"
>
{linkSaving ? (
<div className="h-3 w-3 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
) : (
<Link2Icon className="h-3 w-3" />
)}
Link
</button>
{linkError && <p className="text-[10px] text-destructive">{linkError}</p>}
</div>
</section>
<Separator />
{/* Custom fields — flat, no heavy borders */} {/* Custom fields — flat, no heavy borders */}
<section> <section>
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Custom fields</h2> <h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Custom fields</h2>
@@ -944,6 +1338,31 @@ export default function TicketDetailPage({
</option> </option>
))} ))}
</select> </select>
) : field?.field_type === 'date' ? (
<input
type="date"
value={draftValue}
onChange={(event) => {
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: event.target.value }));
void handleCustomFieldSave(fieldId, event.target.value);
}}
onBlur={() => setEditingFieldId(null)}
autoFocus
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
/>
) : field?.field_type === 'datetime' ? (
<input
type="datetime-local"
value={draftValue ? draftValue.slice(0, 16) : ""}
onChange={(event) => {
const nextValue = event.target.value ? new Date(event.target.value).toISOString() : "";
setCustomFieldDrafts((c) => ({ ...c, [fieldId]: nextValue }));
void handleCustomFieldSave(fieldId, nextValue);
}}
onBlur={() => setEditingFieldId(null)}
autoFocus
className="h-8 flex-1 rounded-md border border-ring bg-card px-2 text-sm text-foreground outline-none"
/>
) : ( ) : (
<input <input
value={draftValue} value={draftValue}
@@ -1001,7 +1420,7 @@ export default function TicketDetailPage({
currentValue ? "text-foreground" : "text-muted-foreground" currentValue ? "text-foreground" : "text-muted-foreground"
)} )}
> >
{currentValue || "Not set"} {currentValue ? formatCfValue(currentValue, field?.field_type ?? "text") : "Not set"}
</span> </span>
<PencilIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" /> <PencilIcon className="h-3 w-3 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</button> </button>
@@ -1013,6 +1432,34 @@ export default function TicketDetailPage({
)} )}
</section> </section>
<Separator />
{/* Merge */}
<section>
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Merge</h2>
<p className="mb-2 text-xs text-muted-foreground">
Move all transactions, attachments, and links into another ticket. This ticket will be closed.
</p>
<div className="flex items-center gap-1.5">
<input
value={mergeTargetId}
onChange={(e) => { setMergeTargetId(e.target.value); setMergeError(null); }}
onKeyDown={(e) => { if (e.key === "Enter") handleMerge(); }}
placeholder="Target ticket ID..."
className="h-7 min-w-0 flex-1 rounded-md border border-input bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground focus:border-ring"
/>
<button
onClick={handleMerge}
disabled={!mergeTargetId.trim() || mergeSaving}
className="h-7 shrink-0 rounded-md bg-destructive/90 px-2.5 text-[11px] font-semibold text-destructive-foreground hover:bg-destructive disabled:opacity-50"
type="button"
>
{mergeSaving ? "..." : "Merge"}
</button>
</div>
{mergeError && <p className="mt-1.5 text-[10px] text-destructive">{mergeError}</p>}
</section>
</div> </div>
</aside> </aside>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useState, useEffect, Suspense, createContext, useContext } from "react"
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
BellIcon,
CircleIcon, CircleIcon,
LayoutGridIcon, LayoutGridIcon,
UserIcon, UserIcon,
@@ -15,11 +16,12 @@ import {
PanelLeftIcon, PanelLeftIcon,
CommandIcon, CommandIcon,
} from "lucide-react"; } from "lucide-react";
import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard } from "@/lib/api"; import { getTickets, getQueues, getViews, getDashboards, getUsers, getTeams, createDashboard, getUnreadCount, getNotifications, markNotificationRead, markAllNotificationsRead, getApiTokens, createApiToken, revokeApiToken } from "@/lib/api";
import type { Dashboard, Queue, SavedView, Team, User } from "@/lib/types"; import type { Dashboard, Queue, SavedView, Team, User, Notification, ApiToken } from "@/lib/types";
import { CommandPalette } from "@/components/command-palette"; import { CommandPalette } from "@/components/command-palette";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { cn } from "@/lib/utils"; import { useAuth } from "@/lib/auth-context";
import { cn, formatTicketId } from "@/lib/utils";
const SidebarCollapsedContext = createContext(false); const SidebarCollapsedContext = createContext(false);
@@ -77,6 +79,7 @@ function SidebarNavItem({
function SidebarNav() { function SidebarNav() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { user: authUser } = useAuth();
const [counts, setCounts] = useState<ViewCounts>({ const [counts, setCounts] = useState<ViewCounts>({
all: 0, all: 0,
@@ -87,20 +90,17 @@ function SidebarNav() {
const [queues, setQueues] = useState<(Queue & { count: number })[]>([]); const [queues, setQueues] = useState<(Queue & { count: number })[]>([]);
const [savedViews, setSavedViews] = useState<SavedView[]>([]); const [savedViews, setSavedViews] = useState<SavedView[]>([]);
const [dashboards, setDashboards] = useState<Dashboard[]>([]); const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
const [myTeamId, setMyTeamId] = useState<string | null>(null); const [myTeamId, setMyTeamId] = useState<string | null>(null);
const [newDashboardName, setNewDashboardName] = useState(""); const [newDashboardName, setNewDashboardName] = useState("");
const [addingDashboard, setAddingDashboard] = useState(false); const [addingDashboard, setAddingDashboard] = useState(false);
const currentUserId = authUser?.id ?? null;
useEffect(() => { useEffect(() => {
async function load() { async function load() {
// Find current user const myId = currentUserId;
const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]); const [ticketRes, userRes] = await Promise.all([getTickets(), getUsers()]);
const data = ticketRes.data; const data = ticketRes.data;
const users = userRes.data ?? [];
const currentUser = users.find((u) => u.username !== 'system') ?? users[0] ?? null;
const myId = currentUser?.id ?? null;
setCurrentUserId(myId);
if (data) { if (data) {
const now = Date.now(); const now = Date.now();
@@ -135,9 +135,9 @@ function SidebarNav() {
const [dashRes, teamRes] = await Promise.all([getDashboards(), getTeams()]); const [dashRes, teamRes] = await Promise.all([getDashboards(), getTeams()]);
const allDashboards = dashRes.data ?? []; const allDashboards = dashRes.data ?? [];
const allTeams = teamRes.data ?? []; const allTeams = teamRes.data ?? [];
const userTeams = allTeams.filter((t) => const userTeams = myId
(t.members ?? []).some((m) => m.id === myId) ? allTeams.filter((t) => (t.members ?? []).some((m) => m.id === myId))
); : [];
setMyTeamId(userTeams[0]?.id ?? null); setMyTeamId(userTeams[0]?.id ?? null);
const teamIds = new Set(userTeams.map((t) => t.id)); const teamIds = new Set(userTeams.map((t) => t.id));
const visible = allDashboards.filter((d) => const visible = allDashboards.filter((d) =>
@@ -146,7 +146,7 @@ function SidebarNav() {
setDashboards(visible); setDashboards(visible);
} }
void load(); void load();
}, []); }, [currentUserId]);
const collapsed = useSidebarCollapsed(); const collapsed = useSidebarCollapsed();
@@ -326,22 +326,268 @@ function SidebarNav() {
function SidebarBottom() { function SidebarBottom() {
const pathname = usePathname(); const pathname = usePathname();
const collapsed = useSidebarCollapsed(); const collapsed = useSidebarCollapsed();
const { user, logout, isAdmin } = useAuth();
const [tokenOpen, setTokenOpen] = useState(false);
const [tokens, setTokens] = useState<ApiToken[]>([]);
const [newTokenName, setNewTokenName] = useState("");
const [newTokenValue, setNewTokenValue] = useState<string | null>(null);
const [tokenError, setTokenError] = useState<string | null>(null);
const loadTokens = async () => {
const { data } = await getApiTokens();
if (data) setTokens(data);
};
useEffect(() => { if (tokenOpen) { void loadTokens(); } }, [tokenOpen]);
const handleCreateToken = async () => {
if (!newTokenName.trim()) return;
setTokenError(null);
const { data, error } = await createApiToken(newTokenName.trim());
if (error) { setTokenError(error); return; }
if (data) {
setNewTokenValue(data.token);
setNewTokenName("");
await loadTokens();
}
};
const handleRevoke = async (id: string) => {
await revokeApiToken(id);
await loadTokens();
};
return ( return (
<div className="border-t border-sidebar-border/50 p-2"> <div className="border-t border-sidebar-border/50 p-2 space-y-1">
<SidebarNavItem {isAdmin && (
href="/admin" <SidebarNavItem
icon={SettingsIcon} href="/admin"
label="Admin" icon={SettingsIcon}
active={pathname === "/admin"} label="Admin"
/> active={pathname === "/admin"}
<div className={cn("flex", collapsed ? "justify-center mt-2" : "mt-2 px-1")}> />
)}
{user ? (
<>
{!collapsed && (
<div className="px-2.5 py-1 text-[11px] text-sidebar-foreground/50 truncate">
{user.username}
{isAdmin && <span className="ml-1 text-[10px] text-sidebar-foreground/30">(admin)</span>}
</div>
)}
<button
onClick={() => setTokenOpen(true)}
className={cn(
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
collapsed ? "justify-center" : ""
)}
>
<span className="opacity-50">API tokens</span>
</button>
<button
onClick={logout}
className={cn(
"w-full flex items-center px-2.5 py-1.5 rounded-md text-[13px] transition-colors",
"text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50",
collapsed ? "justify-center" : ""
)}
>
<span className="opacity-50">Sign out</span>
</button>
{/* Token dialog */}
{tokenOpen && (
<>
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { setTokenOpen(false); setNewTokenValue(null); }} />
<div className="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
<div className="border-b border-border/50 px-4 py-3">
<h3 className="text-sm font-semibold text-foreground">API tokens</h3>
</div>
<div className="p-4 space-y-3">
{newTokenValue ? (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
<p className="text-xs font-semibold text-foreground">Token created copy it now:</p>
<pre className="mt-1.5 select-all rounded bg-background px-2 py-1.5 font-mono text-xs break-all">{newTokenValue}</pre>
<p className="mt-1 text-[10px] text-muted-foreground">This won't be shown again.</p>
</div>
) : (
<div className="flex items-center gap-1.5">
<input
value={newTokenName}
onChange={(e) => setNewTokenName(e.target.value)}
placeholder="Token name..."
className="h-7 flex-1 rounded-md border border-input bg-transparent px-2 text-xs outline-none focus:border-ring"
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateToken(); }}
/>
<button
onClick={handleCreateToken}
disabled={!newTokenName.trim()}
className="h-7 rounded-md bg-primary px-2.5 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
>
Create
</button>
</div>
)}
{tokenError && <p className="text-xs text-destructive">{tokenError}</p>}
{tokens.length > 0 ? (
<div className="space-y-1">
{tokens.map((t) => (
<div key={t.id} className="flex items-center justify-between rounded-md border border-border/30 px-2.5 py-1.5">
<div>
<p className="text-xs font-medium">{t.name}</p>
<p className="text-[10px] text-muted-foreground">
Created {new Date(t.created_at).toLocaleDateString()}
{t.last_used_at && ` · Last used ${new Date(t.last_used_at).toLocaleDateString()}`}
</p>
</div>
<button
onClick={() => handleRevoke(t.id)}
className="text-[10px] text-muted-foreground hover:text-destructive"
>
Revoke
</button>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No API tokens yet.</p>
)}
</div>
</div>
</>
)}
</>
) : (
<SidebarNavItem
href="/login"
icon={UserIcon}
label="Sign in"
active={pathname === "/login"}
/>
)}
<div className={cn("flex", collapsed ? "justify-center" : "px-1")}>
<ThemeToggle /> <ThemeToggle />
</div> </div>
</div> </div>
); );
} }
function NotificationBell({ collapsed, setCommandOpen }: { collapsed: boolean; setCommandOpen: (v: boolean) => void }) {
const [unread, setUnread] = useState(0);
const [notifs, setNotifs] = useState<Notification[]>([]);
const [open, setOpen] = useState(false);
const { user } = useAuth();
useEffect(() => {
if (!user) return;
const load = async () => {
const [countRes, notifRes] = await Promise.all([getUnreadCount(), getNotifications()]);
if (countRes.data) setUnread(countRes.data.count);
if (notifRes.data) setNotifs(notifRes.data);
};
void load();
// Poll every 30s
const interval = setInterval(() => { void load(); }, 30000);
return () => clearInterval(interval);
}, [user]);
if (!user) return null;
const handleMarkRead = async (id: string) => {
await markNotificationRead(id);
setUnread((c) => Math.max(0, c - 1));
setNotifs((prev) => prev.map((n) => n.id === id ? { ...n, read: true } : n));
};
const handleMarkAll = async () => {
await markAllNotificationsRead();
setUnread(0);
setNotifs((prev) => prev.map((n) => ({ ...n, read: true })));
};
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="relative flex h-7 w-7 items-center justify-center rounded text-sidebar-foreground/55 hover:text-sidebar-foreground hover:bg-sidebar-accent/50 transition-colors"
>
<BellIcon className="h-4 w-4" />
{unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[9px] font-bold text-destructive-foreground">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<>
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full z-40 mt-1 w-80 rounded-lg border border-border bg-popover shadow-lg">
<div className="flex items-center justify-between border-b border-border/50 px-3 py-2">
<span className="text-xs font-semibold text-foreground">Notifications</span>
{unread > 0 && (
<button onClick={handleMarkAll} className="text-[10px] text-muted-foreground hover:text-foreground">
Mark all read
</button>
)}
</div>
<div className="max-h-80 overflow-auto">
{notifs.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
No notifications yet.
</div>
) : (
notifs.slice(0, 20).map((n) => (
<button
key={n.id}
onClick={() => {
handleMarkRead(n.id);
if (n.ticket_id) window.location.href = `/tickets/${n.ticket_id}`;
}}
className={cn(
"w-full border-b border-border/30 px-3 py-2.5 text-left transition-colors hover:bg-accent/30",
!n.read && "bg-primary/5"
)}
>
<div className="flex items-start gap-2">
<div className={cn(
"mt-0.5 h-2 w-2 shrink-0 rounded-full",
n.read ? "bg-border" : "bg-primary"
)} />
<div className="min-w-0">
<p className="text-xs font-medium text-foreground">{n.title}</p>
{n.body && (
<p className="mt-0.5 truncate text-[11px] text-muted-foreground">{n.body}</p>
)}
{n.ticket_id && (
<p className="mt-0.5 text-[10px] text-muted-foreground/60">
{formatTicketId(n.ticket_id)}
</p>
)}
</div>
</div>
</button>
))
)}
</div>
</div>
</>
)}
{!collapsed && (
<button
onClick={() => setCommandOpen(true)}
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
aria-label="Open command palette"
>
<CommandIcon className="h-3 w-3" />K
</button>
)}
</div>
);
}
export function AppShell({ children }: { children: React.ReactNode }) { export function AppShell({ children }: { children: React.ReactNode }) {
const [commandOpen, setCommandOpen] = useState(false); const [commandOpen, setCommandOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
@@ -384,15 +630,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
<span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span> <span className="text-sm font-semibold text-sidebar-foreground tracking-tight">Tessera</span>
)} )}
</Link> </Link>
{!sidebarCollapsed && ( <NotificationBell collapsed={sidebarCollapsed} setCommandOpen={setCommandOpen} />
<button
onClick={() => setCommandOpen(true)}
className="flex h-6 items-center gap-1 rounded border border-sidebar-border/50 px-1.5 text-[10px] text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground/60"
aria-label="Open command palette"
>
<CommandIcon className="h-3 w-3" />K
</button>
)}
</div> </div>
{/* Nav */} {/* Nav */}

View File

@@ -0,0 +1,210 @@
"use client";
import { useState, useCallback } from "react";
import { GripVerticalIcon, XIcon, ArrowDownIcon } from "lucide-react";
export interface LayoutField {
key: string;
label: string;
width: number;
}
export interface SubtitleEntry {
key: string;
under: string; // which row1 column this subtitle field sits under
}
interface LayoutBuilderProps {
fields: LayoutField[];
row1: LayoutField[];
row2: SubtitleEntry[];
onChange: (row1: LayoutField[], row2: SubtitleEntry[]) => void;
onClose: () => void;
}
export function LayoutBuilder({ fields: allFields, row1, row2, onChange, onClose }: LayoutBuilderProps) {
const [dragKey, setDragKey] = useState<string | null>(null);
const handleDragStart = useCallback((e: React.DragEvent, key: string) => {
setDragKey(key);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", key);
}, []);
const handleDragEnd = useCallback(() => {
setDragKey(null);
}, []);
const makeRow1Drop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const key = e.dataTransfer.getData("text/plain");
if (!key) return;
// Remove from row2 if present
const newRow2 = row2.filter((e) => e.key !== key);
const field = allFields.find((f) => f.key === key);
if (!field) return;
// Insert into row1 via drop position
const container = e.currentTarget;
const children = Array.from(container.children).filter((c) => (c as HTMLElement).dataset?.chipkey);
const mouseX = e.clientX;
let idx = children.length;
for (let i = 0; i < children.length; i++) {
const rect = (children[i] as HTMLElement).getBoundingClientRect();
if (mouseX < rect.left + rect.width / 2) { idx = i; break; }
}
const newRow1 = [...row1.filter((f) => f.key !== key)];
newRow1.splice(idx, 0, field);
onChange(newRow1, newRow2);
}, [allFields, row1, row2, onChange]);
const makeSubtitleDrop = useCallback((underCol: string) => {
return (e: React.DragEvent) => {
e.preventDefault();
const key = e.dataTransfer.getData("text/plain");
if (!key || key === "subject") return;
// Remove from row1 if present
const newRow1 = row1.filter((f) => f.key !== key);
// Remove from row2 if present
const newRow2 = row2.filter((e) => e.key !== key);
// Add to row2 under this column
newRow2.push({ key, under: underCol });
onChange(newRow1, newRow2);
};
}, [row1, row2, onChange]);
const makePaletteDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const key = e.dataTransfer.getData("text/plain");
if (!key) return;
onChange(
row1.filter((f) => f.key !== key),
row2.filter((e) => e.key !== key),
);
}, [row1, row2, onChange]);
const renderChip = (label: string, key: string) => (
<div
key={key}
data-chipkey={key}
draggable
onDragStart={(e) => handleDragStart(e, key)}
onDragEnd={handleDragEnd}
className="flex cursor-grab items-center gap-1 rounded border border-border/50 bg-card px-2 py-1 text-xs text-foreground shadow-sm transition-colors hover:border-primary/30 active:cursor-grabbing"
>
<GripVerticalIcon className="h-3 w-3 text-muted-foreground/50" />
{label}
</div>
);
// Fields not in row1 or row2
const usedKeys = new Set([...row1.map((f) => f.key), ...row2.map((e) => e.key)]);
const palette = allFields.filter((f) => !usedKeys.has(f.key) && f.key !== "subject");
return (
<>
<div className="fixed inset-0 z-[9998]" onClick={onClose} />
<div className="fixed left-1/2 top-1/2 z-[9999] w-[560px] -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-popover shadow-xl">
<div className="flex items-center justify-between border-b border-border/50 px-4 py-2.5">
<h3 className="text-sm font-semibold text-foreground">Layout builder</h3>
<button onClick={onClose} className="rounded text-muted-foreground hover:text-foreground">
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="space-y-3 p-4">
{/* Row 1 */}
<div>
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Main row</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={makeRow1Drop}
className="flex min-h-9 flex-wrap items-center gap-1.5 rounded-md border border-dashed border-border/50 p-2 transition-colors"
>
{row1.length === 0 ? (
<span className="text-xs text-muted-foreground/50">Drop fields here</span>
) : (
row1.map((f) => renderChip(f.label, f.key))
)}
</div>
</div>
{/* Row 2 — subtitle fields under specific columns */}
<div>
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Subtitle (drop under a column)</div>
<div className="space-y-2">
{row1.map((col) => {
const entries = row2.filter((e) => e.under === col.key);
return (
<div key={col.key} className="flex items-start gap-2">
<div className="mt-1.5 w-20 shrink-0 text-right text-[10px] font-medium text-muted-foreground truncate">
{col.label}
<ArrowDownIcon className="inline-block ml-0.5 h-2.5 w-2.5" />
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={makeSubtitleDrop(col.key)}
className="flex min-h-7 flex-1 flex-wrap items-center gap-1 rounded border border-dashed border-border/50 px-2 py-1 transition-colors"
>
{entries.length === 0 ? (
<span className="text-[10px] text-muted-foreground/40">drop here</span>
) : (
entries.map((e) => {
const field = allFields.find((f) => f.key === e.key);
return renderChip(field?.label ?? e.key, e.key);
})
)}
</div>
</div>
);
})}
{/* Orphans: subtitle fields under columns not in row1 */}
{(() => {
const orphanEntries = row2.filter((e) => !row1.some((c) => c.key === e.under));
if (orphanEntries.length === 0) return null;
return (
<div className="flex items-start gap-2">
<div className="mt-1.5 w-20 shrink-0 text-right text-[10px] font-medium text-muted-foreground truncate">
subject
<ArrowDownIcon className="inline-block ml-0.5 h-2.5 w-2.5" />
</div>
<div className="flex min-h-7 flex-1 flex-wrap items-center gap-1 rounded border border-dashed border-border/50 px-2 py-1">
{orphanEntries.map((e) => {
const field = allFields.find((f) => f.key === e.key);
return renderChip(field?.label ?? e.key, e.key);
})}
</div>
</div>
);
})()}
</div>
</div>
{/* Palette */}
<div>
<div className="mb-1.5 text-[10px] font-semibold uppercase text-muted-foreground/60">Available</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={makePaletteDrop}
className="flex min-h-9 flex-wrap gap-1.5 rounded-md border border-dashed border-border/30 p-2 transition-colors"
>
{palette.length === 0 ? (
<span className="text-xs text-muted-foreground/50">All fields are placed</span>
) : (
palette.map((f) => renderChip(f.label, f.key))
)}
</div>
</div>
</div>
<div className="flex justify-end border-t border-border/50 px-4 py-2.5">
<button
onClick={onClose}
className="h-7 rounded-md bg-primary px-3 text-[11px] font-semibold text-primary-foreground hover:bg-primary/90"
>
Done
</button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,395 @@
"use client";
import { useState } from "react";
import { XIcon, ArrowLeftIcon, ArrowRightIcon, CheckIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import type { Queue, CustomField, Template } from "@/lib/types";
interface Props {
open: boolean;
onClose: () => void;
error?: string | null;
onCreate: (data: {
name: string; condition_type: string; condition_config: Record<string, unknown>;
action_type: string; action_config: Record<string, unknown>;
template_id: string | null; queue_id: string | null;
stage: string; sort_order: number;
}) => Promise<void>;
queues: Queue[];
customFields: CustomField[];
templates: Template[];
}
const CONDITIONS = [
{ type: "OnCreate", icon: "", label: "Ticket created", desc: "When a new ticket is created" },
{ type: "OnStatusChange", icon: "🔄", label: "Status changes", desc: "When ticket status changes" },
{ type: "OnResolve", icon: "✅", label: "Ticket resolved", desc: "When a ticket is resolved" },
{ type: "OnCustomFieldChange", icon: "📝", label: "Custom field changes", desc: "When a field value changes" },
{ type: "OnLinkCreate", icon: "🔗", label: "Ticket linked", desc: "When linked to another ticket" },
{ type: "OnOverdue", icon: "⏰", label: "Ticket overdue", desc: "When a date field passes due" },
];
const ACTIONS = [
{ type: "SendEmail", icon: "📧", label: "Send email", desc: "Send a templated email notification" },
{ type: "SetCustomField", icon: "🏷️", label: "Set custom field", desc: "Update a field on the ticket" },
{ type: "Webhook", icon: "🌐", label: "Call webhook", desc: "POST to an external URL" },
{ type: "FetchMetadata", icon: "📡", label: "Fetch metadata", desc: "Pull data from an API" },
{ type: "RunScript", icon: "⚡", label: "Run script", desc: "Execute custom JavaScript" },
];
export function ScripWizard({ open, onClose, onCreate, queues, customFields, templates, error: externalError }: Props) {
const [step, setStep] = useState(1);
const [saving, setSaving] = useState(false);
// Trigger
const [conditionType, setConditionType] = useState("OnCreate");
const [fromStatus, setFromStatus] = useState("");
const [toStatus, setToStatus] = useState("");
const [conditionFieldKey, setConditionFieldKey] = useState("");
// Action
const [actionType, setActionType] = useState("SendEmail");
const [emailTo, setEmailTo] = useState("requestor");
const [emailRecipients, setEmailRecipients] = useState("");
const [emailSubject, setEmailSubject] = useState("");
const [emailBody, setEmailBody] = useState("");
const [templateId, setTemplateId] = useState("");
const [fieldKey, setFieldKey] = useState("");
const [fieldValue, setFieldValue] = useState("");
const [webhookUrl, setWebhookUrl] = useState("");
// Scope
const [name, setName] = useState("");
const [queueId, setQueueId] = useState("");
const [stage, setStage] = useState("TransactionCreate");
const handleCreate = async () => {
setSaving(true);
let conditionConfig: Record<string, unknown> = {};
if (conditionType === "OnStatusChange" || conditionType === "OnResolve") {
if (fromStatus) conditionConfig.from_status = fromStatus;
if (toStatus) conditionConfig.to_status = toStatus;
} else if (conditionType === "OnOverdue") {
if (conditionFieldKey) conditionConfig.field_key = conditionFieldKey;
}
let actionConfig: Record<string, unknown> = {};
if (actionType === "SendEmail") {
const sources = emailTo === "requestor" ? ["requestor"] : emailTo === "owner" ? ["owner"] : [];
actionConfig = {
recipients: emailRecipients ? emailRecipients.split(",").map((s) => s.trim()).filter(Boolean) : [],
recipient_sources: sources,
subject: emailSubject || "",
body: emailBody || "",
};
} else if (actionType === "SetCustomField") {
actionConfig = { field_key: fieldKey || "", value: fieldValue || "" };
} else if (actionType === "Webhook") {
actionConfig = { url: webhookUrl || "", method: "POST" };
}
await onCreate({
name: name || `Scrip: ${conditionType}${actionType}`,
condition_type: conditionType,
condition_config: conditionConfig,
action_type: actionType,
action_config: actionConfig,
template_id: templateId || null,
queue_id: queueId || null,
stage,
sort_order: 0,
});
setSaving(false);
reset();
};
const reset = () => {
setStep(1); setConditionType("OnCreate"); setFromStatus(""); setToStatus(""); setConditionFieldKey("");
setActionType("SendEmail"); setEmailTo("requestor"); setEmailRecipients(""); setEmailSubject("");
setEmailBody(""); setTemplateId(""); setFieldKey(""); setFieldValue(""); setWebhookUrl("");
setName(""); setQueueId(""); setStage("TransactionCreate");
};
if (!open) return null;
const stepLabels = ["Trigger", "Action", "Configure", "Review"];
const selectedQueue = queueId ? queues.find((q) => q.id === queueId) : null;
const dateFields = customFields.filter((cf) => cf.field_type === "date" || cf.field_type === "datetime");
const emailTemplates = templates.filter((t) => !t.queue_id || t.queue_id === queueId);
return (
<>
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => { onClose(); reset(); }} />
<div className="fixed left-1/2 top-1/2 z-50 w-[600px] max-h-[80vh] -translate-x-1/2 -translate-y-1/2 overflow-auto rounded-lg border border-border bg-popover shadow-xl">
{/* Header with steps */}
<div className="border-b border-border/50 px-6 py-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-foreground">New automation</h2>
<button onClick={() => { onClose(); reset(); }} className="text-muted-foreground hover:text-foreground">
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="mt-3 flex items-center gap-2">
{stepLabels.map((label, i) => (
<div key={label} className="flex items-center gap-2">
<div className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-[10px] font-bold",
step > i + 1 ? "bg-primary text-primary-foreground" :
step === i + 1 ? "bg-primary text-primary-foreground ring-2 ring-primary/30" :
"bg-muted text-muted-foreground"
)}>
{step > i + 1 ? <CheckIcon className="h-3 w-3" /> : i + 1}
</div>
<span className={cn("text-[11px] font-medium", step === i + 1 ? "text-foreground" : "text-muted-foreground")}>{label}</span>
{i < 3 && <div className="h-px w-6 bg-border" />}
</div>
))}
</div>
</div>
<div className="p-6">
{/* Step 1: Trigger */}
{step === 1 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">When should this automation run?</p>
<div className="grid grid-cols-2 gap-2">
{CONDITIONS.map((c) => (
<button
key={c.type}
type="button"
onClick={() => { setConditionType(c.type); setFromStatus(""); setToStatus(""); setConditionFieldKey(""); }}
className={cn(
"rounded-lg border p-3 text-left transition-colors",
conditionType === c.type ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border/50 hover:border-primary/30 hover:bg-accent/30"
)}
>
<span className="text-lg">{c.icon}</span>
<div className="mt-1 text-sm font-semibold">{c.label}</div>
<div className="text-[11px] text-muted-foreground">{c.desc}</div>
</button>
))}
</div>
{(conditionType === "OnStatusChange" || conditionType === "OnResolve") && (
<div className="flex gap-2 pt-2">
<div className="flex-1">
<Label className="text-[10px]">From status</Label>
<Select value={fromStatus || "_any"} onValueChange={(v) => setFromStatus((v === "_any" || !v) ? "" : v)}>
<SelectTrigger className="h-8"><SelectValue placeholder="Any status" /></SelectTrigger>
<SelectContent>
<SelectItem value="_any">Any status</SelectItem>
{["new","open","in_progress","resolved","closed"].map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Label className="text-[10px]">To status</Label>
<Select value={toStatus || "_any"} onValueChange={(v) => setToStatus((v === "_any" || !v) ? "" : v)}>
<SelectTrigger className="h-8"><SelectValue placeholder={conditionType === "OnResolve" ? "Any resolved" : "Any status"} /></SelectTrigger>
<SelectContent>
<SelectItem value="_any">{conditionType === "OnResolve" ? "Any resolved" : "Any status"}</SelectItem>
{["new","open","in_progress","resolved","closed"].map((s) => <SelectItem key={s} value={s}>{s}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
)}
{conditionType === "OnOverdue" && dateFields.length > 0 && (
<div>
<Label className="text-[10px]">Date field to check</Label>
<Select value={conditionFieldKey} onValueChange={(v) => setConditionFieldKey(v || "")}>
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a date field..." /></SelectTrigger>
<SelectContent>
{dateFields.map((cf) => <SelectItem key={cf.id} value={cf.key}>{cf.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{/* Step 2: Action */}
{step === 2 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">What should happen when triggered?</p>
<div className="grid grid-cols-2 gap-2">
{ACTIONS.map((a) => (
<button
key={a.type}
type="button"
onClick={() => setActionType(a.type)}
className={cn(
"rounded-lg border p-3 text-left transition-colors",
actionType === a.type ? "border-primary/50 bg-primary/5 ring-1 ring-primary/30" : "border-border/50 hover:border-primary/30 hover:bg-accent/30"
)}
>
<span className="text-lg">{a.icon}</span>
<div className="mt-1 text-sm font-semibold">{a.label}</div>
<div className="text-[11px] text-muted-foreground">{a.desc}</div>
</button>
))}
</div>
</div>
)}
{/* Step 3: Configure */}
{step === 3 && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Configure the details.</p>
{actionType === "SendEmail" && (
<div className="space-y-3">
<div>
<Label>Recipients</Label>
<Select value={emailTo} onValueChange={(v) => setEmailTo(v ?? "requestor")}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="requestor">Ticket requestor (creator)</SelectItem>
<SelectItem value="owner">Ticket owner</SelectItem>
<SelectItem value="manual">Custom recipients</SelectItem>
</SelectContent>
</Select>
</div>
{emailTo === "manual" && (
<div>
<Label>Email addresses (comma-separated)</Label>
<Input placeholder="user@example.com, other@example.com" value={emailRecipients} onChange={(e) => setEmailRecipients(e.target.value)} />
</div>
)}
<div>
<Label>Subject</Label>
<Input placeholder="Ticket #{{ticket.id}}: {{ticket.subject}}" value={emailSubject} onChange={(e) => setEmailSubject(e.target.value)} />
</div>
<div>
<Label>Body</Label>
<Textarea rows={3} placeholder="The ticket has been updated..." value={emailBody} onChange={(e) => setEmailBody(e.target.value)} />
<p className="mt-1 text-[10px] text-muted-foreground">Variables: {"{{ticket.id}} {{ticket.subject}} {{ticket.status}} {{queue.name}} {{transaction.old_value}} {{transaction.new_value}}"}</p>
</div>
<div>
<Label>Template (optional)</Label>
<Select value={templateId} onValueChange={(v) => setTemplateId(v || "")}>
<SelectTrigger className="h-8"><SelectValue placeholder="No template — use subject/body above" /></SelectTrigger>
<SelectContent>
<SelectItem value="">No template</SelectItem>
{emailTemplates.map((t) => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
)}
{actionType === "SetCustomField" && (() => {
const selectedField = customFields.find((cf) => cf.key === fieldKey);
const fieldOptions: string[] = Array.isArray(selectedField?.values) ? selectedField.values.map((v: any) => String(v)) : [];
return (
<div className="flex gap-2">
<div className="flex-1">
<Label>Field</Label>
<Select value={fieldKey} onValueChange={(v) => { setFieldKey((v && v !== "_any") ? v : ""); setFieldValue(""); }}>
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a field" /></SelectTrigger>
<SelectContent>
{customFields.map((cf) => <SelectItem key={cf.id} value={cf.key}>{cf.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="flex-1">
<Label>Value</Label>
{fieldOptions.length > 0 ? (
<Select value={fieldValue} onValueChange={(v) => setFieldValue(v || "")}>
<SelectTrigger className="h-8"><SelectValue placeholder="Pick a value" /></SelectTrigger>
<SelectContent>
{fieldOptions.map((opt) => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}
</SelectContent>
</Select>
) : (
<Input placeholder="Value to set" value={fieldValue} onChange={(e) => setFieldValue(e.target.value)} />
)}
</div>
</div>
);
})()}
{actionType === "Webhook" && (
<div>
<Label>URL</Label>
<Input placeholder="https://hooks.slack.com/..." value={webhookUrl} onChange={(e) => setWebhookUrl(e.target.value)} />
</div>
)}
<div className="border-t border-border/30 pt-3 space-y-3">
<div className="flex gap-2">
<div className="flex-1">
<Label>Name (optional)</Label>
<Input placeholder="My automation" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="flex-1">
<Label>Queue scope</Label>
<Select value={queueId} onValueChange={(v) => setQueueId(v || "")}>
<SelectTrigger className="h-8"><SelectValue placeholder="All queues" /></SelectTrigger>
<SelectContent>
<SelectItem value="">All queues (global)</SelectItem>
{queues.map((q) => <SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
)}
{/* Step 4: Review */}
{step === 4 && (
<div className="space-y-4">
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4 text-sm space-y-2">
<div className="text-[10px] font-semibold uppercase text-muted-foreground/60">Summary</div>
<p><strong>When:</strong> {CONDITIONS.find((c) => c.type === conditionType)?.label}</p>
<p><strong>Then:</strong> {ACTIONS.find((a) => a.type === actionType)?.label}</p>
{queueId && <p><strong>Queue:</strong> {selectedQueue?.name}</p>}
<p><strong>Stage:</strong> {stage === "TransactionCreate" ? "Per transaction" : "After batch"}</p>
</div>
<div>
<Label>Name</Label>
<Input placeholder="My automation" value={name} onChange={(e) => setName(e.target.value)} />
</div>
</div>
)}
</div>
{/* Error */}
{externalError && (
<div className="border-t border-destructive/20 bg-destructive/10 px-6 py-2 text-sm text-destructive">{externalError}</div>
)}
{/* Footer */}
<div className="flex justify-between border-t border-border/50 px-6 py-3">
<div>
{step > 1 && (
<Button variant="outline" size="sm" onClick={() => setStep(step - 1)}>
<ArrowLeftIcon className="h-3.5 w-3.5 mr-1" /> Back
</Button>
)}
</div>
<div>
{step < 4 ? (
<Button size="sm" onClick={() => setStep(step + 1)}>
Next <ArrowRightIcon className="h-3.5 w-3.5 ml-1" />
</Button>
) : (
<Button size="sm" className="bg-primary" disabled={saving} onClick={handleCreate}>
<CheckIcon className="h-3.5 w-3.5 mr-1" />
{saving ? "Creating..." : "Create automation"}
</Button>
)}
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,163 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
export interface SelectOption {
value: string;
label: string;
}
interface SearchableSelectProps {
options: SelectOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
disabled?: boolean;
className?: string;
allowClear?: boolean;
clearLabel?: string;
}
export function SearchableSelect({
options,
value,
onChange,
placeholder = "Select...",
searchPlaceholder = "Search...",
disabled = false,
className,
allowClear = true,
clearLabel = "None",
}: SearchableSelectProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [highlightIdx, setHighlightIdx] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const selected = options.find((o) => o.value === value);
const filtered = search.trim()
? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
: options;
// Reset highlight when search changes
useEffect(() => {
setHighlightIdx(0);
}, [search]);
// Focus search input when opened
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 50);
setSearch("");
}
}, [open]);
// Close on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const select = useCallback((optValue: string) => {
onChange(optValue);
setOpen(false);
}, [onChange]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightIdx((prev) => Math.min(prev + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightIdx((prev) => Math.max(prev - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[highlightIdx]) {
select(filtered[highlightIdx].value);
}
} else if (e.key === "Escape") {
setOpen(false);
}
};
return (
<div ref={containerRef} className={cn("relative", className)}>
<button
type="button"
onClick={() => !disabled && setOpen(!open)}
disabled={disabled}
className={cn(
"flex h-8 w-full items-center justify-between gap-1 rounded-md border border-input bg-transparent px-2.5 text-sm outline-none transition-colors",
disabled ? "opacity-50 cursor-not-allowed" : "hover:border-ring/50 focus:border-ring",
!selected && "text-muted-foreground"
)}
>
<span className="truncate">{selected?.label ?? placeholder}</span>
<ChevronDownIcon className={cn("h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
</button>
{open && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-md border border-border bg-popover shadow-lg">
<div className="flex items-center gap-1.5 border-b border-border/50 px-2">
<SearchIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={searchPlaceholder}
className="h-8 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
/>
{search && (
<button onClick={() => setSearch("")} className="shrink-0 text-muted-foreground hover:text-foreground">
<XIcon className="h-3 w-3" />
</button>
)}
</div>
<div className="max-h-48 overflow-auto">
{allowClear && (
<button
type="button"
onClick={() => select("")}
className="flex w-full items-center gap-2 border-b border-border/30 px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
>
{clearLabel}
</button>
)}
{filtered.length === 0 ? (
<div className="px-2.5 py-3 text-center text-xs text-muted-foreground">
No results
</div>
) : (
filtered.map((opt, idx) => (
<button
key={opt.value || "__empty__"}
type="button"
onClick={() => select(opt.value)}
className={cn(
"flex w-full items-center px-2.5 py-1.5 text-xs transition-colors",
idx === highlightIdx ? "bg-accent text-foreground" : "text-foreground hover:bg-accent/50",
opt.value === value && "font-semibold"
)}
>
{opt.label}
</button>
))
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import type { WidgetData } from "@/lib/types";
export function TrendChartWidget({ data }: { data: WidgetData }) {
const points = data.counts ?? {};
const entries = Object.entries(points).sort(([a], [b]) => a.localeCompare(b));
const maxVal = Math.max(1, ...Object.values(points));
return (
<div className="flex h-full flex-col rounded-lg border border-border/50 bg-card p-3">
<div className="mb-1 text-[10px] font-semibold uppercase text-muted-foreground/60">
{data.title}
</div>
<div className="text-lg font-bold text-foreground tabular-nums">{data.total}</div>
<div className="mt-2 flex flex-1 items-end gap-px">
{entries.length === 0 ? (
<span className="text-xs text-muted-foreground">No data</span>
) : (
entries.map(([label, count]) => {
const h = Math.max(4, (count / maxVal) * 100);
return (
<div
key={label}
className="flex flex-1 flex-col items-center justify-end"
title={`${label}: ${count}`}
>
<div className="text-[9px] tabular-nums text-muted-foreground">{count}</div>
<div
className="w-full min-w-[3px] rounded-t bg-primary/60"
style={{ height: `${h}%` }}
/>
</div>
);
})
)}
</div>
<div className="mt-1 text-[9px] text-muted-foreground/50 text-right">
{entries.length > 0 && `${entries[0]?.[0]}${entries[entries.length - 1]?.[0]}`}
</div>
</div>
);
}

View File

@@ -17,15 +17,30 @@ import type {
QueueCustomField, QueueCustomField,
PreviewResult, PreviewResult,
UpdateResult, UpdateResult,
Attachment,
AttachmentUploadResult,
TicketLink,
LoginResult,
} from "./types"; } from "./types";
const BASE_URL = "/api"; const BASE_URL = "/api";
async function request<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> { async function request<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
try { try {
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
// Merge with options headers if any
const opts = { ...options };
if (opts.headers) {
Object.assign(headers, opts.headers as Record<string, string>);
delete opts.headers;
}
const res = await fetch(`${BASE_URL}${url}`, { const res = await fetch(`${BASE_URL}${url}`, {
headers: { "Content-Type": "application/json" }, headers,
...options, ...opts,
}); });
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText })); const body = await res.json().catch(() => ({ error: res.statusText }));
@@ -45,6 +60,9 @@ export async function getTickets(params?: {
owner_id?: string; owner_id?: string;
team_id?: string; team_id?: string;
custom_fields?: Record<string, string>; custom_fields?: Record<string, string>;
subject?: string;
created?: string;
updated?: string;
}): Promise<{ data: Ticket[] | null; error: string | null }> { }): Promise<{ data: Ticket[] | null; error: string | null }> {
const sp = new URLSearchParams(); const sp = new URLSearchParams();
if (params?.queue_id) sp.set("queue_id", params.queue_id); if (params?.queue_id) sp.set("queue_id", params.queue_id);
@@ -52,6 +70,9 @@ export async function getTickets(params?: {
if (params?.q) sp.set("q", params.q); if (params?.q) sp.set("q", params.q);
if (params?.owner_id) sp.set("owner_id", params.owner_id); if (params?.owner_id) sp.set("owner_id", params.owner_id);
if (params?.team_id) sp.set("team_id", params.team_id); if (params?.team_id) sp.set("team_id", params.team_id);
if (params?.subject) sp.set("subject", params.subject);
if (params?.created) sp.set("created", params.created);
if (params?.updated) sp.set("updated", params.updated);
if (params?.custom_fields) { if (params?.custom_fields) {
for (const [fieldId, value] of Object.entries(params.custom_fields)) { for (const [fieldId, value] of Object.entries(params.custom_fields)) {
if (value) sp.set(`cf.${fieldId}`, value); if (value) sp.set(`cf.${fieldId}`, value);
@@ -86,10 +107,80 @@ export async function getTicketTransactions(id: number): Promise<{ data: Transac
return request<Transaction[]>(`/tickets/${id}/transactions`); return request<Transaction[]>(`/tickets/${id}/transactions`);
} }
export async function sendComment(id: number, data: { body: string; internal?: boolean }): Promise<{ data: Transaction | null; error: string | null }> { export async function sendComment(id: number, data: { body: string; internal?: boolean; attachment_ids?: string[]; time_worked_minutes?: number }): Promise<{ data: Transaction | null; error: string | null }> {
return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) }); return request<Transaction>(`/tickets/${id}/comment`, { method: "POST", body: JSON.stringify(data) });
} }
export async function batchUpdateTickets(data: {
ticket_ids: number[];
status?: string;
owner_id?: string | null;
team_id?: string | null;
}): Promise<{ data: { results: Array<{ id: number; ok: boolean; error?: string }> } | null; error: string | null }> {
return request<{ results: Array<{ id: number; ok: boolean; error?: string }> }>("/tickets/batch", { method: "POST", body: JSON.stringify(data) });
}
export async function mergeTickets(sourceId: number, targetTicketId: number): Promise<{ data: { ok: boolean; target_id: number } | null; error: string | null }> {
return request<{ ok: boolean; target_id: number }>(`/tickets/${sourceId}/merge`, { method: "POST", body: JSON.stringify({ target_ticket_id: targetTicketId }) });
}
// Notifications
export interface Notification {
id: string;
user_id: string;
ticket_id: number | null;
type: string;
title: string;
body: string | null;
read: boolean;
created_at: string;
}
export async function getNotifications(): Promise<{ data: Notification[] | null; error: string | null }> {
return request<Notification[]>("/notifications");
}
export async function getUnreadCount(): Promise<{ data: { count: number } | null; error: string | null }> {
return request<{ count: number }>("/notifications/unread-count");
}
export async function markNotificationRead(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/notifications/${id}/read`, { method: "PATCH" });
}
export async function markAllNotificationsRead(): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>("/notifications/read-all", { method: "PATCH" });
}
// API Tokens
export interface ApiToken {
id: string;
name: string;
last_used_at: string | null;
created_at: string;
}
export interface ApiTokenCreated {
id: string;
name: string;
token: string;
created_at: string;
}
export async function getApiTokens(): Promise<{ data: ApiToken[] | null; error: string | null }> {
return request<ApiToken[]>("/auth/tokens");
}
export async function createApiToken(name: string): Promise<{ data: ApiTokenCreated | null; error: string | null }> {
return request<ApiTokenCreated>("/auth/tokens", { method: "POST", body: JSON.stringify({ name }) });
}
export async function revokeApiToken(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/auth/tokens/${id}`, { method: "DELETE" });
}
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> { export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
return request<Queue[]>("/queues"); return request<Queue[]>("/queues");
} }
@@ -101,6 +192,8 @@ export async function getUsers(): Promise<{ data: User[] | null; error: string |
export async function createUser(data: { export async function createUser(data: {
username: string; username: string;
email?: string | null; email?: string | null;
role?: string;
password?: string | null;
}): Promise<{ data: User | null; error: string | null }> { }): Promise<{ data: User | null; error: string | null }> {
return request<User>("/users", { method: "POST", body: JSON.stringify(data) }); return request<User>("/users", { method: "POST", body: JSON.stringify(data) });
} }
@@ -108,6 +201,8 @@ export async function createUser(data: {
export async function updateUser(id: string, data: { export async function updateUser(id: string, data: {
username?: string; username?: string;
email?: string | null; email?: string | null;
role?: string;
password?: string | null;
}): Promise<{ data: User | null; error: string | null }> { }): Promise<{ data: User | null; error: string | null }> {
return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }); return request<User>(`/users/${id}`, { method: "PATCH", body: JSON.stringify(data) });
} }
@@ -268,7 +363,7 @@ export async function createView(data: {
name: string; name: string;
filters: { field: string; operator: string; value: string }[]; filters: { field: string; operator: string; value: string }[];
sort_key?: string; sort_key?: string;
columns?: { key: string; label: string; width: number; visible: boolean }[]; columns?: { key: string; label: string; width: number; display: string }[];
is_public?: boolean; is_public?: boolean;
}): Promise<{ data: SavedView | null; error: string | null }> { }): Promise<{ data: SavedView | null; error: string | null }> {
return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) }); return request<SavedView>("/views", { method: "POST", body: JSON.stringify(data) });
@@ -373,3 +468,207 @@ export async function addTeamMember(teamId: string, userId: string): Promise<{ d
export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> { export async function removeTeamMember(teamId: string, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" }); return request<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, { method: "DELETE" });
} }
export async function uploadAttachments(
ticketId: number,
files: File[],
): Promise<{ data: { attachments: AttachmentUploadResult[] } | null; error: string | null }> {
try {
const formData = new FormData();
for (const file of files) {
formData.append("files", file);
}
const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null;
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`/api/tickets/${ticketId}/attachments`, {
method: "POST",
headers,
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}
export function getAttachmentUrl(attachmentId: string): string {
return `/api/attachments/${attachmentId}`;
}
export async function getTicketAttachments(
ticketId: number,
): Promise<{ data: Attachment[] | null; error: string | null }> {
return request<Attachment[]>(`/tickets/${ticketId}/attachments`);
}
export async function getTicketLinks(
ticketId: number,
): Promise<{ data: TicketLink[] | null; error: string | null }> {
return request<TicketLink[]>(`/tickets/${ticketId}/links`);
}
export async function createTicketLink(
ticketId: number,
data: { target_ticket_id: number; link_type: string },
): Promise<{ data: TicketLink | null; error: string | null }> {
return request<TicketLink>(`/tickets/${ticketId}/links`, { method: "POST", body: JSON.stringify(data) });
}
export async function deleteTicketLink(
ticketId: number,
linkId: string,
): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/tickets/${ticketId}/links/${linkId}`, { method: "DELETE" });
}
// Queue Permissions (admin)
export interface QueuePermission {
id: string;
queue_id: string;
team_id: string;
right_name: string;
team_name?: string;
queue_name?: string;
}
export async function getQueuePermissions(): Promise<{ data: QueuePermission[] | null; error: string | null }> {
return request<QueuePermission[]>("/queue-permissions");
}
export async function getTeamsAndQueues(): Promise<{ data: { teams: Team[]; queues: Queue[] } | null; error: string | null }> {
return request<{ teams: Team[]; queues: Queue[] }>("/queue-permissions/teams-and-queues");
}
export async function grantQueuePermission(
queue_id: string,
team_id: string,
right_name: string,
): Promise<{ data: QueuePermission | null; error: string | null }> {
return request<QueuePermission>("/queue-permissions", {
method: "POST",
body: JSON.stringify({ queue_id, team_id, right_name }),
});
}
export async function revokeQueuePermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/queue-permissions/${id}`, { method: "DELETE" });
}
// User Permissions (admin)
export interface UserPermission {
id: string;
queue_id: string;
user_id: string;
right_name: string;
username?: string;
queue_name?: string;
}
export async function getUserPermissions(): Promise<{ data: UserPermission[] | null; error: string | null }> {
return request<UserPermission[]>("/user-permissions");
}
export async function grantUserPermission(
queue_id: string,
user_id: string,
right_name: string,
): Promise<{ data: UserPermission | null; error: string | null }> {
return request<UserPermission>("/user-permissions", {
method: "POST",
body: JSON.stringify({ queue_id, user_id, right_name }),
});
}
export async function revokeUserPermission(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/user-permissions/${id}`, { method: "DELETE" });
}
// Auth
function getStoredToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("tessera_token");
}
export function setStoredToken(token: string | null) {
if (typeof window === "undefined") return;
if (token) {
localStorage.setItem("tessera_token", token);
} else {
localStorage.removeItem("tessera_token");
}
}
export async function login(
username: string,
password: string,
): Promise<{ data: LoginResult | null; error: string | null }> {
const result = await request<LoginResult>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
if (result.data?.token) {
setStoredToken(result.data.token);
}
return result;
}
export function logout() {
setStoredToken(null);
}
export async function getMe(): Promise<{ data: User | null; error: string | null }> {
const token = getStoredToken();
if (!token) return { data: null, error: "Not authenticated" };
try {
const res = await fetch(`/api/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
setStoredToken(null);
return { data: null, error: "Session expired" };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}
/**
* Fetch wrapper that includes the auth token.
*/
async function authRequest<T>(url: string, options?: RequestInit): Promise<{ data: T | null; error: string | null }> {
const token = getStoredToken();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
try {
const res = await fetch(`/api${url}`, { ...options, headers: { ...headers, ...(options?.headers as Record<string, string> ?? {}) } });
if (!res.ok) {
if (res.status === 401 || res.status === 403) {
setStoredToken(null);
}
const body = await res.json().catch(() => ({ error: res.statusText }));
return { data: null, error: body.error || body.message || `HTTP ${res.status}` };
}
const data = await res.json();
return { data, error: null };
} catch (err) {
return { data: null, error: err instanceof Error ? err.message : "Unknown error" };
}
}

View File

@@ -0,0 +1,61 @@
"use client";
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
import { login as apiLogin, logout as apiLogout, getMe } from "./api";
import type { User, LoginResult } from "./types";
interface AuthState {
user: User | null;
loading: boolean;
login: (username: string, password: string) => Promise<string | null>;
logout: () => void;
isAdmin: boolean;
}
const AuthContext = createContext<AuthState>({
user: null,
loading: true,
login: async () => null,
logout: () => {},
isAdmin: false,
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// Check existing session on mount
useEffect(() => {
void Promise.resolve().then(async () => {
const { data } = await getMe();
if (data) {
setUser(data);
}
setLoading(false);
});
}, []);
const login = useCallback(async (username: string, password: string): Promise<string | null> => {
const { data, error } = await apiLogin(username, password);
if (error || !data) {
return error || "Login failed";
}
setUser(data.user);
return null; // null = success
}, []);
const logout = useCallback(() => {
apiLogout();
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, loading, login, logout, isAdmin: user?.role === "admin" }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

View File

@@ -11,6 +11,7 @@ export interface Ticket {
started_at: string | null; started_at: string | null;
resolved_at: string | null; resolved_at: string | null;
custom_fields?: CustomFieldValue[]; custom_fields?: CustomFieldValue[];
blocked_by?: Array<{ id: number; subject: string; status: string }>;
} }
export interface Queue { export interface Queue {
@@ -25,9 +26,15 @@ export interface User {
id: string; id: string;
username: string; username: string;
email: string | null; email: string | null;
role: string;
created_at: string; created_at: string;
} }
export interface LoginResult {
token: string;
user: User;
}
export interface Transaction { export interface Transaction {
id: string; id: string;
ticket_id: number; ticket_id: number;
@@ -36,8 +43,10 @@ export interface Transaction {
old_value: string | null; old_value: string | null;
new_value: string | null; new_value: string | null;
data: unknown; data: unknown;
time_worked_minutes: number;
creator_id: string; creator_id: string;
created_at: string; created_at: string;
attachments?: Attachment[];
} }
export interface Scrip { export interface Scrip {
@@ -53,6 +62,7 @@ export interface Scrip {
stage: string; stage: string;
sort_order: number; sort_order: number;
disabled: boolean; disabled: boolean;
applicable_trans_types: string | null;
created_at: string; created_at: string;
} }
@@ -197,4 +207,50 @@ export interface WidgetData {
counts?: Record<string, number>; counts?: Record<string, number>;
groups?: Record<string, number>; groups?: Record<string, number>;
group_by?: string; group_by?: string;
config?: Record<string, unknown>;
}
export interface Attachment {
id: string;
transaction_id: string | null;
filename: string;
mime_type: string;
size_bytes: number;
storage_path: string;
created_at: string;
}
export interface AttachmentUploadResult {
id: string;
filename: string;
mime_type: string;
size_bytes: number;
}
export interface TicketLink {
id: string;
ticket_id: number;
target_ticket_id: number;
link_type: string;
creator_id: string;
created_at: string;
target_ticket?: { id: number; subject: string; status: string } | null;
}
export interface Notification {
id: string;
user_id: string;
ticket_id: number | null;
type: string;
title: string;
body: string | null;
read: boolean;
created_at: string;
}
export interface ApiToken {
id: string;
name: string;
last_used_at: string | null;
created_at: string;
} }