diff --git a/.gitignore b/.gitignore index e26a5f3..0130d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,8 @@ bun.lock # Codegraph index (MCP tool) .codegraph +# Git worktrees +.worktrees/ + # Runtime data /data diff --git a/drizzle/migrations/0019_watcher_tables.sql b/drizzle/migrations/0019_watcher_tables.sql new file mode 100644 index 0000000..7d927be --- /dev/null +++ b/drizzle/migrations/0019_watcher_tables.sql @@ -0,0 +1,9 @@ +CREATE TABLE ticket_watchers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(ticket_id, user_id) +); +CREATE INDEX ticket_watchers_ticket_id_idx ON ticket_watchers(ticket_id); +CREATE INDEX ticket_watchers_user_id_idx ON ticket_watchers(user_id); diff --git a/drizzle/migrations/0020_sla_tables.sql b/drizzle/migrations/0020_sla_tables.sql new file mode 100644 index 0000000..2f0e170 --- /dev/null +++ b/drizzle/migrations/0020_sla_tables.sql @@ -0,0 +1,15 @@ +CREATE TABLE sla_policies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + queue_id UUID REFERENCES queues(id) ON DELETE SET NULL, + name TEXT NOT NULL, + description TEXT, + response_time_minutes INTEGER, + resolution_time_minutes INTEGER, + disabled BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX sla_policies_queue_id_idx ON sla_policies(queue_id); + +ALTER TABLE tickets ADD COLUMN sla_response_deadline TIMESTAMPTZ; +ALTER TABLE tickets ADD COLUMN sla_resolution_deadline TIMESTAMPTZ; +ALTER TABLE tickets ADD COLUMN sla_breached TEXT; diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 845d880..0c8f3ee 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -134,6 +134,20 @@ "when": 1781551130161, "tag": "0018_dapper_jack_power", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1781552000000, + "tag": "0019_watcher_tables", + "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1781552001000, + "tag": "0020_sla_tables", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 47ca45f..623020d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -38,11 +38,27 @@ export const tickets = pgTable('tickets', { updated_at: timestamp('updated_at', { withTimezone: true }).defaultNow(), started_at: timestamp('started_at', { withTimezone: true }), resolved_at: timestamp('resolved_at', { withTimezone: true }), + sla_response_deadline: timestamp('sla_response_deadline', { withTimezone: true }), + sla_resolution_deadline: timestamp('sla_resolution_deadline', { withTimezone: true }), + sla_breached: text('sla_breached'), // 'response' | 'resolution' | 'both' | null }, (table) => ({ queueIdIdx: index('tickets_queue_id_idx').on(table.queue_id), statusIdx: index('tickets_status_idx').on(table.status), })); +export const slaPolicies = pgTable('sla_policies', { + id: uuid('id').primaryKey().defaultRandom(), + queue_id: uuid('queue_id').references(() => queues.id, { onDelete: 'set null' }), + name: text('name').notNull(), + description: text('description'), + response_time_minutes: integer('response_time_minutes'), + resolution_time_minutes: integer('resolution_time_minutes'), + disabled: boolean('disabled').notNull().default(false), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), +}, (table) => ({ + queueIdIdx: index('sla_policies_queue_id_idx').on(table.queue_id), +})); + export const transactions = pgTable('transactions', { id: uuid('id').primaryKey().defaultRandom(), ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }), @@ -200,6 +216,17 @@ export const apiTokens = pgTable('api_tokens', { userIdIdx: index('api_tokens_user_id_idx').on(table.user_id), })); +export const ticketWatchers = pgTable('ticket_watchers', { + id: uuid('id').primaryKey().defaultRandom(), + ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }), + user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + created_at: timestamp('created_at', { withTimezone: true }).defaultNow(), +}, (table) => ({ + uniqueWatcher: unique('ticket_watchers_ticket_id_user_id_unique').on(table.ticket_id, table.user_id), + ticketIdIdx: index('ticket_watchers_ticket_id_idx').on(table.ticket_id), + userIdIdx: index('ticket_watchers_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' }), diff --git a/src/index.ts b/src/index.ts index 0c3444f..1286b2b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ 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 { createSlaPoliciesRouter } from './routes/sla-policies.ts'; import { createNotificationsRouter } from './routes/notifications.ts'; import { startScheduler } from './scrip/scheduler.ts'; @@ -67,6 +68,7 @@ admin.route('/templates', createTemplatesRouter(getDb())); admin.route('/views', createViewsRouter(getDb())); admin.route('/dashboards', createDashboardsRouter(getDb())); admin.route('/teams', createTeamsRouter(getDb())); +admin.route('/sla-policies', createSlaPoliciesRouter(getDb())); admin.route('/', createQueuePermissionsRouter(getDb())); app.route('/', admin); diff --git a/src/routes/sla-policies.ts b/src/routes/sla-policies.ts new file mode 100644 index 0000000..ebe6033 --- /dev/null +++ b/src/routes/sla-policies.ts @@ -0,0 +1,108 @@ +import { Hono } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import type { Db } from '../db/index.ts'; +import { slaPolicies } from '../db/schema.ts'; +import { eq, asc } from 'drizzle-orm'; +import { z } from 'zod'; + +const CreateSlaSchema = z.object({ + name: z.string().min(1), + queue_id: z.string().uuid().optional(), + description: z.string().optional(), + response_time_minutes: z.number().int().positive().optional(), + resolution_time_minutes: z.number().int().positive().optional(), + disabled: z.boolean().optional(), +}); + +const UpdateSlaSchema = z.object({ + name: z.string().min(1).optional(), + queue_id: z.string().uuid().nullable().optional(), + description: z.string().optional().nullable(), + response_time_minutes: z.number().int().positive().nullable().optional(), + resolution_time_minutes: z.number().int().positive().nullable().optional(), + disabled: z.boolean().optional(), +}); + +export function createSlaPoliciesRouter(db: Db): Hono { + const router = new Hono(); + + // GET / — list all SLA policies + router.get('/', async (c) => { + const policies = await db.query.slaPolicies.findMany({ + orderBy: asc(slaPolicies.name), + }); + return c.json(policies); + }); + + // POST / — create SLA policy + router.post('/', async (c) => { + const body = await c.req.json(); + const parsed = CreateSlaSchema.parse(body); + + const [policy] = await db.insert(slaPolicies).values({ + name: parsed.name, + queue_id: parsed.queue_id ?? null, + description: parsed.description ?? null, + response_time_minutes: parsed.response_time_minutes ?? null, + resolution_time_minutes: parsed.resolution_time_minutes ?? null, + disabled: parsed.disabled ?? false, + }).returning(); + + if (!policy) { + throw new HTTPException(500, { message: 'Failed to create SLA policy' }); + } + + return c.json(policy, 201); + }); + + // PATCH /:id — update SLA policy + router.patch('/:id', async (c) => { + const id = c.req.param('id'); + const body = await c.req.json(); + const parsed = UpdateSlaSchema.parse(body); + + const existing = await db.query.slaPolicies.findFirst({ + where: eq(slaPolicies.id, id), + }); + if (!existing) { + throw new HTTPException(404, { message: 'SLA policy not found' }); + } + + const updateData: Record = {}; + if (parsed.name !== undefined) updateData.name = parsed.name; + if (parsed.queue_id !== undefined) updateData.queue_id = parsed.queue_id; + if (parsed.description !== undefined) updateData.description = parsed.description; + if (parsed.response_time_minutes !== undefined) updateData.response_time_minutes = parsed.response_time_minutes; + if (parsed.resolution_time_minutes !== undefined) updateData.resolution_time_minutes = parsed.resolution_time_minutes; + if (parsed.disabled !== undefined) updateData.disabled = parsed.disabled; + + const [updated] = await db.update(slaPolicies) + .set(updateData as any) + .where(eq(slaPolicies.id, id)) + .returning(); + + if (!updated) { + throw new HTTPException(500, { message: 'Failed to update SLA policy' }); + } + + return c.json(updated); + }); + + // DELETE /:id — delete SLA policy + router.delete('/:id', async (c) => { + const id = c.req.param('id'); + + const existing = await db.query.slaPolicies.findFirst({ + where: eq(slaPolicies.id, id), + }); + if (!existing) { + throw new HTTPException(404, { message: 'SLA policy not found' }); + } + + await db.delete(slaPolicies).where(eq(slaPolicies.id, id)); + + return c.json({ ok: true }); + }); + + return router; +} diff --git a/src/routes/tickets.ts b/src/routes/tickets.ts index 4fc46df..784d391 100644 --- a/src/routes/tickets.ts +++ b/src/routes/tickets.ts @@ -9,7 +9,7 @@ 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 { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers, transactionAttachments, ticketLinks, ticketWatchers, users, slaPolicies } from '../db/schema.ts'; import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm'; import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts'; import { ScripEngine } from '../scrip/engine.ts'; @@ -325,13 +325,27 @@ export function createTicketsRouter(db: Db): Hono { } } + // Calculate SLA resolution deadline from policy + let slaResolutionDeadline: Date | null = null; + const slaPolicy = await db.query.slaPolicies.findFirst({ + where: (table, { or, eq, and, isNull }) => + or( + eq(table.queue_id, parsed.queue_id), + and(isNull(table.queue_id), eq(table.disabled, false)), + ), + }); + if (slaPolicy && !slaPolicy.disabled && slaPolicy.resolution_time_minutes) { + slaResolutionDeadline = new Date(Date.now() + slaPolicy.resolution_time_minutes * 60 * 1000); + } + const [ticket] = await db.insert(tickets).values({ subject: parsed.subject, queue_id: parsed.queue_id, status: initialStatus, creator_id: creatorId, team_id: (queue as any).team_id ?? null, - }).returning(); + sla_resolution_deadline: slaResolutionDeadline, + } as any).returning(); if (!ticket) { throw new HTTPException(500, { message: 'Failed to create ticket' }); @@ -566,10 +580,26 @@ export function createTicketsRouter(db: Db): Hono { if ((fromClass === 'initial' || fromClass === 'active') && toClass === 'inactive') { updateData.resolved_at = now; + // Clear SLA deadlines on resolution + updateData.sla_response_deadline = null; + updateData.sla_resolution_deadline = null; + updateData.sla_breached = null; } if (fromClass === 'inactive' && toClass !== 'inactive') { updateData.resolved_at = null; + // Re-open: recalculate SLA resolution deadline + const slaPolicy = await db.query.slaPolicies.findFirst({ + where: (table, { or, eq, and, isNull }) => + or( + eq(table.queue_id, ticket.queue_id), + and(isNull(table.queue_id), eq(table.disabled, false)), + ), + }); + if (slaPolicy && !slaPolicy.disabled && slaPolicy.resolution_time_minutes) { + updateData.sla_resolution_deadline = new Date(Date.now() + slaPolicy.resolution_time_minutes * 60 * 1000); + } + updateData.sla_breached = null; } } } @@ -605,6 +635,32 @@ export function createTicketsRouter(db: Db): Hono { } } + // Notify watchers on any update (status, CF, assignment change) + if (txList.length > 0) { + const watchers = await db.query.ticketWatchers.findMany({ + where: eq(ticketWatchers.ticket_id, id), + }); + const updateActor = getUserId(c); + for (const w of watchers) { + if (w.user_id !== updateActor) { + const changeDesc = txList.map((tx: any) => + tx.transaction_type === 'StatusChange' ? `Status → ${tx.new_value}` : + tx.transaction_type === 'CustomFieldChange' ? `${tx.field} → ${tx.new_value}` : + tx.transaction_type === 'SetOwner' ? `Owner changed` : + tx.transaction_type === 'SetTeam' ? `Team changed` : + tx.transaction_type + ).join(', '); + await createNotification(db, { + user_id: w.user_id, + ticket_id: id, + type: 'ticket_updated', + title: `Ticket ${id} updated`, + body: changeDesc || 'Ticket was updated', + }); + } + } + } + return c.json({ ticket: updated, scrip_results: results }); }); @@ -820,15 +876,43 @@ export function createTicketsRouter(db: Db): Hono { .where(inArray(transactionAttachments.id, attachmentIds)); } + // Set SLA response deadline on first public reply + if (transactionType === 'Correspond' && !ticket.sla_response_deadline) { + const slaPolicy = await db.query.slaPolicies.findFirst({ + where: (table, { or, eq, and, isNull }) => + or( + eq(table.queue_id, ticket.queue_id), + and(isNull(table.queue_id), eq(table.disabled, false)), + ), + }); + if (slaPolicy && !slaPolicy.disabled && slaPolicy.response_time_minutes) { + const responseDeadline = new Date(Date.now() + slaPolicy.response_time_minutes * 60 * 1000); + await db.update(tickets) + .set({ sla_response_deadline: responseDeadline } as any) + .where(eq(tickets.id, id)); + } + } + // Run scrips const txList = [tx]; const prepared = await scripEngine.prepare(id, txList as any); await scripEngine.commit(prepared); - // Notify ticket owner and creator + // Notify ticket owner, creator, and watchers const commenterId = getUserId(c); const notifyTargets = new Set([ticket.owner_id, ticket.creator_id].filter(Boolean) as string[]); notifyTargets.delete(commenterId); + + // Add watchers + const watchers = await db.query.ticketWatchers.findMany({ + where: eq(ticketWatchers.ticket_id, id), + }); + for (const w of watchers) { + if (w.user_id !== commenterId) { + notifyTargets.add(w.user_id); + } + } + for (const userId of notifyTargets) { await createNotification(db, { user_id: userId, @@ -842,6 +926,124 @@ export function createTicketsRouter(db: Db): Hono { return c.json(tx, 201); }); + // GET /:id/watchers — list watchers for a ticket + router.get('/:id/watchers', 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 watchers = await db.query.ticketWatchers.findMany({ + where: eq(ticketWatchers.ticket_id, id), + orderBy: asc(ticketWatchers.created_at), + }); + + // Enrich with user details + const userIds = [...new Set(watchers.map((w) => w.user_id))]; + const userMap = new Map(); + if (userIds.length > 0) { + const userRows = await db.query.users.findMany({ + where: (table, { inArray }) => inArray(table.id, userIds), + }); + for (const u of userRows) { + userMap.set(u.id, u); + } + } + + const enriched = watchers.map((w) => ({ + id: w.id, + ticket_id: w.ticket_id, + user_id: w.user_id, + created_at: w.created_at, + user: userMap.get(w.user_id) ? { + id: userMap.get(w.user_id)!.id, + username: userMap.get(w.user_id)!.username, + email: userMap.get(w.user_id)!.email, + } : null, + })); + + return c.json(enriched); + }); + + // POST /:id/watchers — add a watcher + router.post('/:id/watchers', async (c) => { + const id = Number(c.req.param('id')); + const body = await c.req.json().catch(() => ({})); + const userId = body.user_id || getUserId(c); + + 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'); + + // Check user exists + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + if (!user) { + throw new HTTPException(404, { message: 'User not found' }); + } + + // Upsert: ignore if already watching + const [watcher] = await db.insert(ticketWatchers).values({ + ticket_id: id, + user_id: userId, + }).onConflictDoNothing().returning(); + + // Notify the added user + if (watcher) { + await createNotification(db, { + user_id: userId, + ticket_id: id, + type: 'watcher_added', + title: `You are now watching ticket ${id}`, + body: ticket.subject, + }); + } + + return c.json(watcher ?? { ticket_id: id, user_id: userId, already_watching: true }, 201); + }); + + // DELETE /:id/watchers/:userId — remove a watcher + router.delete('/:id/watchers/:userId', async (c) => { + const id = Number(c.req.param('id')); + const userId = c.req.param('userId'); + + const ticket = await db.query.tickets.findFirst({ + where: eq(tickets.id, id), + }); + + if (!ticket) { + throw new HTTPException(404, { message: 'Ticket not found' }); + } + + // Allow self-removal or admin + const currentUserId = getUserId(c); + if (userId !== currentUserId) { + await requireRight(c, db, ticket.queue_id, 'ticket.modify'); + } else { + await requireRight(c, db, ticket.queue_id, 'ticket.view'); + } + + await db.delete(ticketWatchers).where( + and(eq(ticketWatchers.ticket_id, id), eq(ticketWatchers.user_id, userId)) + ); + + return c.json({ ok: true }); + }); + // 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')); diff --git a/src/scrip/actions.ts b/src/scrip/actions.ts index 3e2b09e..379ca1c 100644 --- a/src/scrip/actions.ts +++ b/src/scrip/actions.ts @@ -4,7 +4,7 @@ import Handlebars from 'handlebars'; import { config } from '../config.ts'; import type { Db } from '../db/index.ts'; import * as schema from '../db/schema.ts'; -import { customFieldValues, tickets, transactions, users } from '../db/schema.ts'; +import { customFieldValues, tickets, transactions, users, ticketWatchers } from '../db/schema.ts'; import { and, eq, inArray } from 'drizzle-orm'; export interface ActionExecutor { @@ -75,6 +75,14 @@ export class SendEmail implements ActionExecutor { if (['owner', 'ticket_owner'].includes(source) && ticket.owner_id) { userIds.add(ticket.owner_id); } + if (['watchers', 'cc'].includes(source) && payload.ticketId) { + const watchers = await this.db.query.ticketWatchers.findMany({ + where: eq(ticketWatchers.ticket_id, payload.ticketId), + }); + for (const w of watchers) { + userIds.add(w.user_id); + } + } } if (userIds.size === 0) { diff --git a/src/scrip/conditions.ts b/src/scrip/conditions.ts index 796c3d8..882710e 100644 --- a/src/scrip/conditions.ts +++ b/src/scrip/conditions.ts @@ -18,6 +18,7 @@ export interface ConditionConfig { new_value?: unknown; value?: unknown; link_type?: unknown; + breach_type?: unknown; } export interface ConditionEvaluator { @@ -97,6 +98,27 @@ export class OnLinkCreate implements ConditionEvaluator { } } +export class OnSlaBreach implements ConditionEvaluator { + evaluate(ticket: Ticket, _transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean { + const breachType = config?.breach_type ?? 'any'; + + // Check if ticket has sla_breached set + const breached = (ticket as any).sla_breached as string | null; + + if (breachType === 'any') { + return breached === 'response' || breached === 'resolution' || breached === 'both'; + } + if (breachType === 'response') { + return breached === 'response' || breached === 'both'; + } + if (breachType === 'resolution') { + return breached === 'resolution' || breached === 'both'; + } + + return false; + } +} + 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; @@ -130,6 +152,7 @@ const conditionRegistry: Record = { OnCustomFieldChange: new OnCustomFieldChange(), OnLinkCreate: new OnLinkCreate(), OnOverdue: new OnOverdue(), + OnSlaBreach: new OnSlaBreach(), }; export function getConditionEvaluator(type: string): ConditionEvaluator | null { diff --git a/src/scrip/scheduler.ts b/src/scrip/scheduler.ts index 67f797a..0f5f461 100644 --- a/src/scrip/scheduler.ts +++ b/src/scrip/scheduler.ts @@ -1,22 +1,100 @@ import type { Db } from '../db/index.ts'; -import { transactions, tickets, queues, lifecycles } from '../db/schema.ts'; +import { transactions, tickets, queues, lifecycles, slaPolicies } 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. + * Calculate SLA status for all active tickets. + * Checks response and resolution deadlines against current time. */ -export async function runScheduledScrips(db: Db): Promise<{ checked: number; fired: number }> { - const engine = new ScripEngine(db); +async function calculateSlaStatus(db: Db): Promise { + let breaches = 0; + + // Get all queues with SLA policies + const allPolicies = await db.query.slaPolicies.findMany({ + where: eq(slaPolicies.disabled, false), + }); + + if (allPolicies.length === 0) return 0; + + // Get all lifecycles to determine inactive statuses + const allLifecycles = await db.query.lifecycles.findMany(); + const inactiveByQueue = new Map>(); + + 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'])); + } + } + } + + const allTickets = await db.query.tickets.findMany(); + const now = new Date(); + + for (const ticket of allTickets) { + // Skip inactive tickets + const inactive = inactiveByQueue.get(ticket.queue_id); + if (inactive && inactive.has(ticket.status)) continue; + if (!inactive && ['resolved', 'closed'].includes(ticket.status)) continue; + + let newBreach: string | null = null; + + // Check response deadline + if (ticket.sla_response_deadline && now > new Date(ticket.sla_response_deadline)) { + newBreach = 'response'; + } + + // Check resolution deadline + if (ticket.sla_resolution_deadline && now > new Date(ticket.sla_resolution_deadline)) { + newBreach = newBreach === 'response' ? 'both' : 'resolution'; + } + + // Only update if breach status changed + if (newBreach && newBreach !== ticket.sla_breached) { + await db.update(tickets) + .set({ sla_breached: newBreach } as any) + .where(eq(tickets.id, ticket.id)); + + // Create SlaBreach transaction + await db.insert(transactions).values({ + ticket_id: ticket.id, + transaction_type: 'SlaBreach' as any, + field: 'sla_breached', + old_value: ticket.sla_breached ?? null, + new_value: newBreach, + creator_id: SYSTEM_USER, + } as any); + + breaches++; + } + } + + return breaches; +} + +/** + * Run scheduled scrips against all active tickets. + * Creates a synthetic transaction so conditions like OnOverdue can fire. + */ +export async function runScheduledScrips(db: Db): Promise<{ checked: number; fired: number }> { + const engine = new ScripEngine(db); + + // Calculate SLA statuses first + const slaBreaches = await calculateSlaStatus(db); + if (slaBreaches > 0) { + console.log(`[scheduler] SLA breaches detected: ${slaBreaches}`); + } // Get all lifecycles to determine inactive statuses const allLifecycles = await db.query.lifecycles.findMany(); const inactiveByQueue = new Map>(); - // Get all queues with lifecycles const allQueues = await db.query.queues.findMany(); for (const q of allQueues) { if (q.lifecycle_id) { @@ -40,7 +118,7 @@ export async function runScheduledScrips(db: Db): Promise<{ checked: number; fir for (const ticket of active) { try { - // Create a synthetic Scheduled transaction + // Create a synthetic transaction const [tx] = await db.insert(transactions).values({ ticket_id: ticket.id, transaction_type: 'Comment' as any, diff --git a/web/package.json b/web/package.json index 0a2f6dc..9305e4d 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "lucide-react": "^1.17.0", + "marked": "^18.0.5", "next": "16.2.7", "next-themes": "^0.4.6", "react": "19.2.4", diff --git a/web/src/app/admin/page-content.tsx b/web/src/app/admin/page-content.tsx index b15df58..b3e6881 100644 --- a/web/src/app/admin/page-content.tsx +++ b/web/src/app/admin/page-content.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useCallback, useRef } from "react"; import type { ReactNode } from "react"; import { ActivityIcon, + Clock3Icon, DatabaseIcon, FileTextIcon, GitBranchIcon, @@ -71,8 +72,12 @@ import { deleteTeam, addTeamMember, removeTeamMember, + getSlaPolicies, + createSlaPolicy, + updateSlaPolicy, + deleteSlaPolicy, } 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, SlaPolicy } from "@/lib/types"; import { ScripWizard } from "@/components/scrip-wizard"; import { cn } from "@/lib/utils"; @@ -162,6 +167,10 @@ export default function AdminPage() { Teams + + + SLA Policies +
@@ -186,6 +195,9 @@ export default function AdminPage() { + + +
@@ -2399,6 +2411,209 @@ function TeamsTab() { ); } +function SlaPoliciesTab() { + const [policies, setPolicies] = useState([]); + const [queues, setQueues] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingId, setEditingId] = useState(null); + const [name, setName] = useState(""); + const [queueId, setQueueId] = useState(""); + const [responseMinutes, setResponseMinutes] = useState(""); + const [resolutionMinutes, setResolutionMinutes] = useState(""); + const [description, setDescription] = useState(""); + const [disabled, setDisabled] = useState(false); + const [saving, setSaving] = useState(false); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + const [policiesRes, queuesRes] = await Promise.all([ + getSlaPolicies(), + getQueues(), + ]); + if (policiesRes.error) setError(policiesRes.error); + else setPolicies(policiesRes.data ?? []); + if (queuesRes.data) setQueues(queuesRes.data); + setLoading(false); + }, []); + + useEffect(() => { void fetchData(); }, [fetchData]); + + const resetForm = () => { + setEditingId(null); + setName(""); + setQueueId(""); + setResponseMinutes(""); + setResolutionMinutes(""); + setDescription(""); + setDisabled(false); + }; + + const handleEdit = (p: SlaPolicy) => { + setEditingId(p.id); + setName(p.name); + setQueueId(p.queue_id ?? ""); + setResponseMinutes(p.response_time_minutes?.toString() ?? ""); + setResolutionMinutes(p.resolution_time_minutes?.toString() ?? ""); + setDescription(p.description ?? ""); + setDisabled(p.disabled); + }; + + const handleSave = async () => { + if (!name.trim()) return; + setSaving(true); + setError(null); + + const data = { + name: name.trim(), + queue_id: queueId || undefined, + response_time_minutes: responseMinutes ? parseInt(responseMinutes, 10) : undefined, + resolution_time_minutes: resolutionMinutes ? parseInt(resolutionMinutes, 10) : undefined, + description: description || undefined, + disabled, + }; + + if (editingId) { + const { error: updateErr } = await updateSlaPolicy(editingId, data); + if (updateErr) setError(updateErr); + else resetForm(); + } else { + const { error: createErr } = await createSlaPolicy(data); + if (createErr) setError(createErr); + else resetForm(); + } + + setSaving(false); + await fetchData(); + }; + + const handleDelete = async (id: string) => { + if (!confirm("Delete this SLA policy?")) return; + const { error: deleteErr } = await deleteSlaPolicy(id); + if (deleteErr) setError(deleteErr); + else await fetchData(); + }; + + if (loading) { + return
{Array.from({ length: 3 }).map((_, i) =>
)}
; + } + + return ( +
+ {error && ( +
+ {error} + +
+ )} + + {/* Add/Edit form */} +
+

+ {editingId ? "Edit SLA Policy" : "New SLA Policy"} +

+
+
+ + setName(e.target.value)} placeholder="Standard SLA" className="mt-1 h-8" /> +
+
+ + +
+
+ + setResponseMinutes(e.target.value.replace(/\D/g, ""))} placeholder="e.g. 60" className="mt-1 h-8" /> +
+
+ + setResolutionMinutes(e.target.value.replace(/\D/g, ""))} placeholder="e.g. 480" className="mt-1 h-8" /> +
+
+ + setDescription(e.target.value)} placeholder="Optional description" className="mt-1 h-8" /> +
+
+
+ +
+ {editingId && ( + + )} + +
+
+ + {/* Policy list */} +
+ + + + Name + Queue + Response + Resolution + Status + + + + + {policies.length === 0 ? ( + + + No SLA policies defined + + + ) : policies.map((p) => ( + + {p.name} + + {p.queue_id ? (queues.find((q) => q.id === p.queue_id)?.name ?? p.queue_id) : "All queues"} + + + {p.response_time_minutes ? `${p.response_time_minutes}m` : "—"} + + + {p.resolution_time_minutes ? `${p.resolution_time_minutes}m` : "—"} + + + {p.disabled ? ( + Disabled + ) : ( + Active + )} + + +
+ + +
+
+
+ ))} +
+
+
+
+ ); +} + function CustomFieldsTab() { const [fields, setFields] = useState([]); const [queues, setQueues] = useState([]); diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 5e09a69..bdb73b2 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1094,6 +1094,23 @@ function TicketWorkbenchContent() { style={{ backgroundColor: STATUS_META[ticket.status]?.color ?? "#71717a" }} title={statusLabel(ticket.status)} /> + {(ticket as any).sla_breached && ( + + )} + {!(ticket as any).sla_breached && (ticket as any).sla_resolution_deadline && ( + + )}
{row1Fields.map((col) => { const cellStyle = { diff --git a/web/src/app/tickets/[id]/page.tsx b/web/src/app/tickets/[id]/page.tsx index aa57399..4d1fdf4 100644 --- a/web/src/app/tickets/[id]/page.tsx +++ b/web/src/app/tickets/[id]/page.tsx @@ -6,6 +6,8 @@ import Link from "next/link"; import { formatDistanceToNow, format } from "date-fns"; import { ArrowLeftIcon, + BellIcon, + BellOffIcon, BotIcon, CheckCircle2Icon, ChevronDownIcon, @@ -40,6 +42,9 @@ import { createTicketLink, deleteTicketLink, mergeTickets, + getWatchers, + addWatcher, + removeWatcher, } from "@/lib/api"; import type { Ticket, @@ -54,10 +59,12 @@ import type { AttachmentUploadResult, Attachment, TicketLink, + Watcher, } from "@/lib/types"; import { Separator } from "@/components/ui/separator"; import { SearchableSelect } from "@/components/searchable-select"; import { cn, formatTicketId } from "@/lib/utils"; +import { renderMarkdown } from "@/lib/markdown"; const STATUS_COLORS: Record = { new: "#64748b", @@ -107,6 +114,15 @@ function StatusBadge({ status }: { status: string }) { ); } +function getCurrentUserId(): string { + try { + const token = typeof window !== "undefined" ? localStorage.getItem("tessera_token") : null; + if (!token) return ""; + const payload = JSON.parse(atob(token.split(".")[1])); + return payload.sub || ""; + } catch { return ""; } +} + function userLabel(users: User[], userId: string | null) { if (!userId) return "Unassigned"; const user = users.find((item) => item.id === userId); @@ -241,9 +257,10 @@ function TransactionCard({ )} -

- {body} -

+
{tx.attachments && tx.attachments.length > 0 && (
{tx.attachments.map((att) => ( @@ -307,6 +324,7 @@ export default function TicketDetailPage({ const [replyText, setReplyText] = useState(""); const [replyMode, setReplyMode] = useState<"public" | "internal">("public"); + const [composerMode, setComposerMode] = useState<"write" | "preview">("write"); const [timeMinutes, setTimeMinutes] = useState(""); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(null); @@ -337,17 +355,21 @@ export default function TicketDetailPage({ const [customFieldSaving, setCustomFieldSaving] = useState(null); const [editingFieldId, setEditingFieldId] = useState(null); + const [watchers, setWatchers] = useState([]); + const [watcherToggling, setWatcherToggling] = useState(false); + const fetchData = useCallback(async () => { setLoading(true); setError(null); - const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes, teamsRes] = await Promise.all([ + const [ticketRes, txRes, queuesRes, lifecycleRes, usersRes, teamsRes, watchersRes] = await Promise.all([ getTicket(id), getTicketTransactions(id), getQueues(), getLifecycles(), getUsers(), getTeams(), + getWatchers(id), ]); if (ticketRes.error) { @@ -404,6 +426,13 @@ export default function TicketDetailPage({ setLinks(linksRes.data ?? []); } + if (watchersRes.error) { + // watchers are non-critical, don't surface as error + console.warn('Failed to load watchers:', watchersRes.error); + } else { + setWatchers(watchersRes.data ?? []); + } + setLoading(false); }, [id]); @@ -458,6 +487,29 @@ export default function TicketDetailPage({ setScripResults(null); }; + const handleToggleWatch = async () => { + if (!ticket || watcherToggling) return; + setWatcherToggling(true); + + const currentUserId = getCurrentUserId(); + const isWatching = watchers.some((w) => w.user_id === currentUserId); + + if (isWatching) { + const { error } = await removeWatcher(id, currentUserId); + if (!error) { + setWatchers((prev) => prev.filter((w) => w.user_id !== currentUserId)); + } + } else { + const { data, error } = await addWatcher(id); + if (!error && data) { + const { data: refreshed } = await getWatchers(id); + if (refreshed) setWatchers(refreshed); + } + } + + setWatcherToggling(false); + }; + const refreshTransactions = async () => { const txRes = await getTicketTransactions(id); if (txRes.data) setTransactions(txRes.data); @@ -784,6 +836,31 @@ export default function TicketDetailPage({
Updated {formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })} + {(() => { + const currentId = getCurrentUserId(); + const isWatching = watchers.some((w) => w.user_id === currentId); + return ( + + ); + })()}
@@ -922,6 +999,31 @@ export default function TicketDetailPage({ {replyMode === "internal" ? "Visible to staff only" : "Public correspondence"} +
+
+ + +
{pendingFiles.length > 0 && ( @@ -947,16 +1049,27 @@ export default function TicketDetailPage({ )}
-