feat: watcher/CC system, SLA engine, and rich text comments

- Watcher system: ticket_watchers table, watch/unwatch endpoints,
  notifications to watchers on comments and updates, watcher/cc
  recipient sources in SendEmail scrip action, watch toggle and
  watcher avatars in ticket detail UI
- SLA engine: sla_policies table, SLA deadline columns on tickets,
  CRUD routes, OnSlaBreach scrip condition, scheduler SLA calculation,
  deadlines set on create/reply, cleared on resolve, SLA indicators
  on ticket list and detail, SLA Policies tab in admin
- Rich text: marked-based markdown rendering with XSS safety,
  Write/Preview toggle in comment composer, styled prose output
This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-15 21:40:18 +02:00
parent 9679734e3f
commit 653139ad0d
18 changed files with 1025 additions and 26 deletions

3
.gitignore vendored
View File

@@ -37,5 +37,8 @@ bun.lock
# Codegraph index (MCP tool)
.codegraph
# Git worktrees
.worktrees/
# Runtime data
/data

View File

@@ -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);

View File

@@ -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;

View File

@@ -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
}
]
}

View File

@@ -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' }),

View File

@@ -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);

108
src/routes/sla-policies.ts Normal file
View File

@@ -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<string, unknown> = {};
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;
}

View File

@@ -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<string, typeof users.$inferSelect>();
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'));

View File

@@ -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) {

View File

@@ -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<string, ConditionEvaluator> = {
OnCustomFieldChange: new OnCustomFieldChange(),
OnLinkCreate: new OnLinkCreate(),
OnOverdue: new OnOverdue(),
OnSlaBreach: new OnSlaBreach(),
};
export function getConditionEvaluator(type: string): ConditionEvaluator | null {

View File

@@ -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<number> {
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<string, Set<string>>();
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<string, Set<string>>();
// 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,

View File

@@ -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",

View File

@@ -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() {
<UsersIcon className="h-4 w-4" />
Teams
</TabsTrigger>
<TabsTrigger value="sla" className="px-3">
<Clock3Icon className="h-4 w-4" />
SLA Policies
</TabsTrigger>
</TabsList>
</div>
<div className="min-h-0 flex-1 overflow-auto p-5 lg:p-6">
@@ -186,6 +195,9 @@ export default function AdminPage() {
<TabsContent value="teams" className="m-0">
<TeamsTab />
</TabsContent>
<TabsContent value="sla" className="m-0">
<SlaPoliciesTab />
</TabsContent>
</div>
</Tabs>
</div>
@@ -2399,6 +2411,209 @@ function TeamsTab() {
);
}
function SlaPoliciesTab() {
const [policies, setPolicies] = useState<SlaPolicy[]>([]);
const [queues, setQueues] = useState<Queue[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(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 <div className="space-y-2">{Array.from({ length: 3 }).map((_, i) => <div key={i} className="h-10 animate-pulse rounded bg-muted" />)}</div>;
}
return (
<section className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-4 py-2.5 text-sm text-destructive">
{error}
<button onClick={() => setError(null)} className="ml-2 text-xs underline" type="button">Dismiss</button>
</div>
)}
{/* Add/Edit form */}
<div className="rounded-lg border border-border/50 bg-card p-4">
<h3 className="mb-3 text-sm font-semibold text-foreground">
{editingId ? "Edit SLA Policy" : "New SLA Policy"}
</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div>
<Label className="text-[10px] font-medium text-muted-foreground">Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Standard SLA" className="mt-1 h-8" />
</div>
<div>
<Label className="text-[10px] font-medium text-muted-foreground">Queue</Label>
<Select value={queueId || "__global__"} onValueChange={(val) => setQueueId(val === "__global__" ? "" : val ?? "")}>
<SelectTrigger className="mt-1 h-8"><SelectValue placeholder="All queues (global)" /></SelectTrigger>
<SelectContent>
<SelectItem value="__global__">All queues (global)</SelectItem>
{queues.map((q) => <SelectItem key={q.id} value={q.id}>{q.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[10px] font-medium text-muted-foreground">Response (minutes)</Label>
<Input value={responseMinutes} onChange={(e) => setResponseMinutes(e.target.value.replace(/\D/g, ""))} placeholder="e.g. 60" className="mt-1 h-8" />
</div>
<div>
<Label className="text-[10px] font-medium text-muted-foreground">Resolution (minutes)</Label>
<Input value={resolutionMinutes} onChange={(e) => setResolutionMinutes(e.target.value.replace(/\D/g, ""))} placeholder="e.g. 480" className="mt-1 h-8" />
</div>
<div className="sm:col-span-2">
<Label className="text-[10px] font-medium text-muted-foreground">Description</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="Optional description" className="mt-1 h-8" />
</div>
</div>
<div className="mt-3 flex items-center gap-3">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input type="checkbox" checked={disabled} onChange={(e) => setDisabled(e.target.checked)} className="h-3.5 w-3.5 rounded" />
Disabled
</label>
<div className="flex-1" />
{editingId && (
<Button variant="ghost" size="sm" onClick={resetForm} type="button">
Cancel
</Button>
)}
<Button size="sm" onClick={() => void handleSave()} disabled={!name.trim() || saving} type="button">
{saving ? "Saving..." : editingId ? "Update" : "Create"}
</Button>
</div>
</div>
{/* Policy list */}
<div className="rounded-lg border border-border/50">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Queue</TableHead>
<TableHead>Response</TableHead>
<TableHead>Resolution</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{policies.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
No SLA policies defined
</TableCell>
</TableRow>
) : policies.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium text-foreground">{p.name}</TableCell>
<TableCell className="text-muted-foreground">
{p.queue_id ? (queues.find((q) => q.id === p.queue_id)?.name ?? p.queue_id) : "All queues"}
</TableCell>
<TableCell className="text-muted-foreground">
{p.response_time_minutes ? `${p.response_time_minutes}m` : "—"}
</TableCell>
<TableCell className="text-muted-foreground">
{p.resolution_time_minutes ? `${p.resolution_time_minutes}m` : "—"}
</TableCell>
<TableCell>
{p.disabled ? (
<Badge variant="secondary" className="text-[10px]">Disabled</Badge>
) : (
<Badge variant="default" className="text-[10px] bg-emerald-500/10 text-emerald-600 dark:text-emerald-400">Active</Badge>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEdit(p)} type="button">Edit</Button>
<Button variant="ghost" size="sm" onClick={() => void handleDelete(p.id)} className="text-destructive hover:text-destructive" type="button">
<Trash2Icon className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
);
}
function CustomFieldsTab() {
const [fields, setFields] = useState<CustomField[]>([]);
const [queues, setQueues] = useState<Queue[]>([]);

View File

@@ -1094,6 +1094,23 @@ function TicketWorkbenchContent() {
style={{ backgroundColor: STATUS_META[ticket.status]?.color ?? "#71717a" }}
title={statusLabel(ticket.status)}
/>
{(ticket as any).sla_breached && (
<span
className="h-2 w-2 rounded-full shrink-0 bg-red-500"
title={`SLA breached: ${(ticket as any).sla_breached}`}
/>
)}
{!(ticket as any).sla_breached && (ticket as any).sla_resolution_deadline && (
<span
className={cn(
"h-2 w-2 rounded-full shrink-0",
new Date((ticket as any).sla_resolution_deadline) < new Date(Date.now() + 60 * 60 * 1000)
? "bg-amber-500"
: "bg-emerald-500"
)}
title={`SLA due ${formatDistanceToNow(new Date((ticket as any).sla_resolution_deadline), { addSuffix: true })}`}
/>
)}
</div>
{row1Fields.map((col) => {
const cellStyle = {

View File

@@ -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<string, string> = {
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({
</span>
)}
</div>
<p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
{body}
</p>
<div
className="mt-1.5 prose prose-sm max-w-none text-foreground/90 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_code]:rounded [&_code]:bg-muted/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-xs [&_code]:font-mono [&_pre]:rounded-md [&_pre]:border [&_pre]:border-border/50 [&_pre]:bg-muted/40 [&_pre]:p-3 [&_pre]:text-xs [&_pre]:overflow-x-auto [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_a]:text-primary [&_a]:underline [&_p]:leading-relaxed [&_hr]:border-border/50"
dangerouslySetInnerHTML={{ __html: renderMarkdown(body || '') }}
/>
{tx.attachments && tx.attachments.length > 0 && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{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<string | null>(null);
@@ -337,17 +355,21 @@ export default function TicketDetailPage({
const [customFieldSaving, setCustomFieldSaving] = useState<string | null>(null);
const [editingFieldId, setEditingFieldId] = useState<string | null>(null);
const [watchers, setWatchers] = useState<Watcher[]>([]);
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({
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock3Icon className="h-3.5 w-3.5" />
Updated {formatDistanceToNow(new Date(ticket.updated_at), { addSuffix: true })}
{(() => {
const currentId = getCurrentUserId();
const isWatching = watchers.some((w) => w.user_id === currentId);
return (
<button
onClick={() => void handleToggleWatch()}
disabled={watcherToggling}
className={cn(
"ml-2 inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-medium transition-colors",
isWatching
? "border-primary/50 bg-primary/5 text-primary hover:bg-primary/10"
: "border-border/50 text-muted-foreground hover:border-primary/30 hover:text-foreground"
)}
title={isWatching ? "Unwatch ticket" : "Watch ticket"}
type="button"
>
{isWatching ? (
<BellOffIcon className="h-3.5 w-3.5" />
) : (
<BellIcon className="h-3.5 w-3.5" />
)}
{isWatching ? "Unwatch" : "Watch"}
</button>
);
})()}
</div>
</div>
@@ -922,6 +999,31 @@ export default function TicketDetailPage({
<span className="text-xs text-muted-foreground">
{replyMode === "internal" ? "Visible to staff only" : "Public correspondence"}
</span>
<div className="flex-1" />
<div className="flex rounded-md border border-border bg-muted/55 p-1">
<button
onClick={() => setComposerMode("write")}
className={cn(
"h-7 rounded px-2.5 text-xs font-semibold transition-colors",
composerMode === "write"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Write
</button>
<button
onClick={() => setComposerMode("preview")}
className={cn(
"h-7 rounded px-2.5 text-xs font-semibold transition-colors",
composerMode === "preview"
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
Preview
</button>
</div>
</div>
{pendingFiles.length > 0 && (
@@ -947,6 +1049,7 @@ export default function TicketDetailPage({
)}
<div className="flex items-end gap-2">
{composerMode === "write" ? (
<textarea
value={replyText}
onChange={(event) => {
@@ -957,6 +1060,16 @@ export default function TicketDetailPage({
rows={3}
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="min-h-24 flex-1 rounded-md border border-border/50 bg-card/90 px-3 py-2 text-sm leading-6 shadow-sm overflow-y-auto prose prose-sm max-w-none text-foreground/90 [&_h1]:text-lg [&_h2]:text-base [&_h3]:text-sm [&_h1]:font-semibold [&_h2]:font-semibold [&_h3]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_code]:rounded [&_code]:bg-muted/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:text-xs [&_code]:font-mono [&_pre]:rounded-md [&_pre]:border [&_pre]:border-border/50 [&_pre]:bg-muted/40 [&_pre]:p-3 [&_pre]:text-xs [&_pre]:overflow-x-auto [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_a]:text-primary [&_a]:underline [&_p]:leading-relaxed [&_hr]:border-border/50"
dangerouslySetInnerHTML={{
__html: replyText.trim()
? renderMarkdown(replyText)
: '<p class="text-muted-foreground italic text-xs">Nothing to preview</p>'
}}
/>
)}
<div className="flex items-center gap-1">
<input
value={timeMinutes}
@@ -1024,6 +1137,16 @@ export default function TicketDetailPage({
<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>}
{composerMode === "write" && (
<p className="mt-2 text-[10px] text-muted-foreground/60">
<span className="font-medium">Markdown</span> supported:{" "}
<code className="rounded bg-muted/60 px-1 py-0.5 text-[10px] font-mono">**bold**</code>{" "}
<code className="rounded bg-muted/60 px-1 py-0.5 text-[10px] font-mono">*italic*</code>{" "}
<code className="rounded bg-muted/60 px-1 py-0.5 text-[10px] font-mono">[link](url)</code>{" "}
<code className="rounded bg-muted/60 px-1 py-0.5 text-[10px] font-mono">`code`</code>{" "}
<code className="rounded bg-muted/60 px-1 py-0.5 text-[10px] font-mono">```block```</code>
</p>
)}
</div>
</footer>
</main>
@@ -1174,6 +1297,27 @@ export default function TicketDetailPage({
</div>
</section>
{/* Watchers */}
{watchers.length > 0 && (
<section>
<h2 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Watchers ({watchers.length})
</h2>
<div className="flex flex-wrap gap-1">
{watchers.map((w) => (
<span
key={w.id}
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[11px] font-semibold text-white"
style={{ backgroundColor: getInitialColor(w.user?.username ?? w.user_id) }}
title={w.user?.username ?? w.user_id}
>
{getInitial(w.user?.username ?? w.user_id)}
</span>
))}
</div>
</section>
)}
{/* Details — simple key-value lines */}
<section>
<h2 className="mb-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">Details</h2>
@@ -1196,6 +1340,39 @@ export default function TicketDetailPage({
<span className="text-foreground">{formatDistanceToNow(new Date(ticket.resolved_at), { addSuffix: true })}</span>
</div>
)}
{/* SLA indicators */}
{ticket.sla_resolution_deadline && (
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="text-muted-foreground">SLA</span>
<span className={cn(
"text-xs font-medium",
ticket.sla_breached === 'resolution' || ticket.sla_breached === 'both'
? "text-red-600 dark:text-red-400"
: new Date(ticket.sla_resolution_deadline) < new Date(Date.now() + 60 * 60 * 1000)
? "text-amber-600 dark:text-amber-400"
: "text-emerald-600 dark:text-emerald-400"
)}>
{ticket.sla_breached === 'resolution' || ticket.sla_breached === 'both'
? 'Breached'
: `Due ${formatDistanceToNow(new Date(ticket.sla_resolution_deadline), { addSuffix: true })}`}
</span>
</div>
)}
{ticket.sla_response_deadline && !ticket.sla_breached && (
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="text-muted-foreground">Response</span>
<span className={cn(
"text-xs font-medium",
new Date(ticket.sla_response_deadline) < new Date()
? "text-red-600 dark:text-red-400"
: new Date(ticket.sla_response_deadline) < new Date(Date.now() + 60 * 60 * 1000)
? "text-amber-600 dark:text-amber-400"
: "text-emerald-600 dark:text-emerald-400"
)}>
Due {formatDistanceToNow(new Date(ticket.sla_response_deadline), { addSuffix: true })}
</span>
</div>
)}
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="text-muted-foreground">Time worked</span>
<span className="text-foreground">

View File

@@ -21,6 +21,8 @@ import type {
AttachmentUploadResult,
TicketLink,
LoginResult,
Watcher,
SlaPolicy,
} from "./types";
const BASE_URL = "/api";
@@ -181,6 +183,52 @@ export async function revokeApiToken(id: string): Promise<{ data: { ok: boolean
return request<{ ok: boolean }>(`/auth/tokens/${id}`, { method: "DELETE" });
}
// Watchers
export async function getWatchers(ticketId: number): Promise<{ data: Watcher[] | null; error: string | null }> {
return request<Watcher[]>(`/tickets/${ticketId}/watchers`);
}
export async function addWatcher(ticketId: number, userId?: string): Promise<{ data: Watcher | null; error: string | null }> {
return request<Watcher>(`/tickets/${ticketId}/watchers`, { method: "POST", body: JSON.stringify(userId ? { user_id: userId } : {}) });
}
export async function removeWatcher(ticketId: number, userId: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/tickets/${ticketId}/watchers/${userId}`, { method: "DELETE" });
}
// SLA Policies
export async function getSlaPolicies(): Promise<{ data: SlaPolicy[] | null; error: string | null }> {
return request<SlaPolicy[]>("/sla-policies");
}
export async function createSlaPolicy(data: {
name: string;
queue_id?: string;
description?: string;
response_time_minutes?: number;
resolution_time_minutes?: number;
disabled?: boolean;
}): Promise<{ data: SlaPolicy | null; error: string | null }> {
return request<SlaPolicy>("/sla-policies", { method: "POST", body: JSON.stringify(data) });
}
export async function updateSlaPolicy(id: string, data: {
name?: string;
queue_id?: string | null;
description?: string;
response_time_minutes?: number | null;
resolution_time_minutes?: number | null;
disabled?: boolean;
}): Promise<{ data: SlaPolicy | null; error: string | null }> {
return request<SlaPolicy>(`/sla-policies/${id}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteSlaPolicy(id: string): Promise<{ data: { ok: boolean } | null; error: string | null }> {
return request<{ ok: boolean }>(`/sla-policies/${id}`, { method: "DELETE" });
}
export async function getQueues(): Promise<{ data: Queue[] | null; error: string | null }> {
return request<Queue[]>("/queues");
}

30
web/src/lib/markdown.ts Normal file
View File

@@ -0,0 +1,30 @@
import { marked } from 'marked';
// Configure marked for safe rendering
marked.setOptions({
breaks: true,
gfm: true,
});
/**
* Render markdown string to sanitized HTML.
* Strips raw HTML tags for XSS safety.
*/
export function renderMarkdown(markdown: string): string {
if (!markdown) return '';
// Strip raw HTML tags for XSS safety
const sanitized = markdown.replace(/<[^>]*>/g, '');
try {
const html = marked.parse(sanitized) as string;
return html;
} catch {
// Fallback: escape and wrap in <p>
const escaped = sanitized
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<p>${escaped}</p>`;
}
}

View File

@@ -10,6 +10,9 @@ export interface Ticket {
updated_at: string;
started_at: string | null;
resolved_at: string | null;
sla_response_deadline?: string | null;
sla_resolution_deadline?: string | null;
sla_breached?: string | null;
custom_fields?: CustomFieldValue[];
blocked_by?: Array<{ id: number; subject: string; status: string }>;
}
@@ -262,6 +265,25 @@ export interface Notification {
created_at: string;
}
export interface Watcher {
id: string;
ticket_id: number;
user_id: string;
created_at: string;
user?: { id: string; username: string; email: string | null } | null;
}
export interface SlaPolicy {
id: string;
queue_id: string | null;
name: string;
description: string | null;
response_time_minutes: number | null;
resolution_time_minutes: number | null;
disabled: boolean;
created_at: string;
}
export interface ApiToken {
id: string;
name: string;