Compare commits
4 Commits
feature/cu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
392f941046 | ||
|
|
1c92f488f7 | ||
|
|
631365ab07 | ||
|
|
653139ad0d |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -37,5 +37,11 @@ bun.lock
|
||||
# Codegraph index (MCP tool)
|
||||
.codegraph
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
||||
# Nimbalyst local plans
|
||||
nimbalyst-local/plans/
|
||||
|
||||
# Runtime data
|
||||
/data
|
||||
|
||||
9
drizzle/migrations/0019_watcher_tables.sql
Normal file
9
drizzle/migrations/0019_watcher_tables.sql
Normal 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);
|
||||
15
drizzle/migrations/0020_sla_tables.sql
Normal file
15
drizzle/migrations/0020_sla_tables.sql
Normal 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;
|
||||
1
drizzle/migrations/0021_romantic_captain_midlands.sql
Normal file
1
drizzle/migrations/0021_romantic_captain_midlands.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "queues" ADD COLUMN "mail_alias" text;
|
||||
2024
drizzle/migrations/meta/0021_snapshot.json
Normal file
2024
drizzle/migrations/meta/0021_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,27 @@
|
||||
"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
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1781552215621,
|
||||
"tag": "0021_romantic_captain_midlands",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ const configSchema = z.object({
|
||||
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'),
|
||||
// Inbound email
|
||||
MAIL_TRANSPORT: z.enum(['mailtm', 'webhook', 'none']).default('none'),
|
||||
MAILTM_POLL_SECONDS: z.coerce.number().int().positive().default(30),
|
||||
MAILTM_ADDRESS: z.string().optional(),
|
||||
MAILTM_ACCOUNT_ID: z.string().optional(),
|
||||
MAILTM_TOKEN: z.string().optional(),
|
||||
});
|
||||
|
||||
export const config = configSchema.parse(process.env);
|
||||
|
||||
@@ -16,6 +16,7 @@ export const queues = pgTable('queues', {
|
||||
description: text('description'),
|
||||
lifecycle_id: uuid('lifecycle_id').references(() => lifecycles.id),
|
||||
team_id: uuid('team_id').references(() => teams.id, { onDelete: 'set null' }),
|
||||
mail_alias: text('mail_alias'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
@@ -38,11 +39,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 +217,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' }),
|
||||
|
||||
250
src/email/mailtm.ts
Normal file
250
src/email/mailtm.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { config } from '../config.ts';
|
||||
import type { InboundEmail } from './types.ts';
|
||||
import type { EmailProcessor } from './processor.ts';
|
||||
|
||||
interface MailTmAccount {
|
||||
id: string;
|
||||
address: string;
|
||||
password: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface MailTmMessage {
|
||||
id: string;
|
||||
msgid: string;
|
||||
from: {
|
||||
address: string;
|
||||
name: string;
|
||||
};
|
||||
to: Array<{
|
||||
address: string;
|
||||
name: string;
|
||||
}>;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
seen: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const API_BASE = 'https://api.mail.tm';
|
||||
|
||||
/**
|
||||
* mail.tm transport: creates a disposable inbox, polls for new messages,
|
||||
* and feeds them to the EmailProcessor.
|
||||
*/
|
||||
export class MailTmTransport {
|
||||
private account: MailTmAccount | null = null;
|
||||
private processor: EmailProcessor | null = null;
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
|
||||
constructor(processor: EmailProcessor) {
|
||||
this.processor = processor;
|
||||
}
|
||||
|
||||
async start(): Promise<string> {
|
||||
if (this.running) {
|
||||
return this.account?.address ?? '';
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
// If account cached in env, reuse it
|
||||
if (config.MAILTM_ACCOUNT_ID && config.MAILTM_TOKEN) {
|
||||
this.account = {
|
||||
id: config.MAILTM_ACCOUNT_ID,
|
||||
address: config.MAILTM_ADDRESS ?? 'unknown@mail.tm',
|
||||
password: '', // not needed when reusing token
|
||||
token: config.MAILTM_TOKEN,
|
||||
};
|
||||
console.log(`[mailtm] Reusing cached inbox: ${this.account.address}`);
|
||||
} else {
|
||||
this.account = await this.createAccount();
|
||||
console.log(`[mailtm] Created test inbox: ${this.account.address}`);
|
||||
console.log(`[mailtm] To persist this inbox, set in .env:`);
|
||||
console.log(` MAILTM_ADDRESS=${this.account.address}`);
|
||||
console.log(` MAILTM_ACCOUNT_ID=${this.account.id}`);
|
||||
console.log(` MAILTM_TOKEN=${this.account.token}`);
|
||||
}
|
||||
|
||||
// Start polling
|
||||
const seconds = config.MAILTM_POLL_SECONDS;
|
||||
console.log(`[mailtm] Polling every ${seconds}s — use this address: ${this.account.address}`);
|
||||
this.poll(); // immediate first poll
|
||||
this.intervalId = setInterval(() => this.poll(), seconds * 1000);
|
||||
|
||||
return this.account.address;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
console.log('[mailtm] Stopped');
|
||||
}
|
||||
|
||||
private async createAccount(): Promise<MailTmAccount> {
|
||||
// Get available domains first (mail.tm rotates domains)
|
||||
let domain = 'mail.tm';
|
||||
try {
|
||||
const domainResp = await fetch(`${API_BASE}/domains`);
|
||||
if (domainResp.ok) {
|
||||
const data = await domainResp.json() as { 'hydra:member': Array<{ domain: string }> };
|
||||
if (data['hydra:member']?.length > 0) {
|
||||
domain = data['hydra:member'][0]!.domain;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to default domain
|
||||
}
|
||||
|
||||
const username = `tessera-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const password = crypto.randomUUID();
|
||||
|
||||
const resp = await fetch(`${API_BASE}/accounts`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address: `${username}@${domain}`, password }),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '');
|
||||
throw new Error(`mail.tm account creation failed: HTTP ${resp.status} ${body}`);
|
||||
}
|
||||
|
||||
const account = (await resp.json()) as { id: string; address: string };
|
||||
|
||||
// Get token
|
||||
const tokenResp = await fetch(`${API_BASE}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ address: account.address, password }),
|
||||
});
|
||||
|
||||
if (!tokenResp.ok) {
|
||||
throw new Error(`mail.tm token request failed: HTTP ${tokenResp.status}`);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResp.json()) as { token: string };
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
address: account.address,
|
||||
password,
|
||||
token: tokenData.token,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchMessages(): Promise<MailTmMessage[]> {
|
||||
if (!this.account?.token) return [];
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/messages`, {
|
||||
headers: { Authorization: `Bearer ${this.account.token}` },
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
// Token might have expired, try to refresh
|
||||
if (resp.status === 401) {
|
||||
try {
|
||||
this.account = await this.createAccount();
|
||||
return [];
|
||||
} catch {
|
||||
console.error('[mailtm] Failed to refresh account');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await resp.json()) as { 'hydra:member': MailTmMessage[] };
|
||||
return (data['hydra:member'] ?? []).filter((m) => !m.seen);
|
||||
} catch (err) {
|
||||
console.error('[mailtm] Error fetching messages:', err instanceof Error ? err.message : String(err));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async getFullMessage(messageId: string): Promise<MailTmMessage | null> {
|
||||
if (!this.account?.token) return null;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/messages/${messageId}`, {
|
||||
headers: { Authorization: `Bearer ${this.account.token}` },
|
||||
});
|
||||
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as MailTmMessage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async markRead(messageId: string): Promise<void> {
|
||||
if (!this.account?.token) return;
|
||||
|
||||
try {
|
||||
await fetch(`${API_BASE}/messages/${messageId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.account.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ seen: true }),
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
private async poll(): Promise<void> {
|
||||
if (!this.running || !this.processor) return;
|
||||
|
||||
const messages = await this.fetchMessages();
|
||||
if (messages.length === 0) return;
|
||||
|
||||
console.log(`[mailtm] Found ${messages.length} new message(s)`);
|
||||
|
||||
for (const summary of messages) {
|
||||
const full = await this.getFullMessage(summary.id);
|
||||
if (!full) continue;
|
||||
|
||||
// Build the from display string
|
||||
const fromName = full.from?.name ?? '';
|
||||
const fromAddr = full.from?.address ?? '';
|
||||
const fromDisplay = fromName ? `${fromName} <${fromAddr}>` : fromAddr;
|
||||
|
||||
// Build the to display string
|
||||
const toDisplay = (full.to ?? [])
|
||||
.map((t) => (t.name ? `${t.name} <${t.address}>` : t.address))
|
||||
.join(', ');
|
||||
|
||||
const inbound: InboundEmail = {
|
||||
from: fromDisplay,
|
||||
fromAddress: fromAddr,
|
||||
to: toDisplay || this.account!.address,
|
||||
subject: full.subject ?? '(no subject)',
|
||||
bodyText: full.text ?? '',
|
||||
bodyHtml: full.html,
|
||||
attachments: [],
|
||||
messageId: full.msgid ?? full.id,
|
||||
receivedAt: new Date(full.createdAt ?? Date.now()),
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await this.processor.process(inbound);
|
||||
console.log(`[mailtm] Processed: ${result.action} — ${result.detail}`);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[mailtm] Error processing message ${summary.id}:`,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
await this.markRead(summary.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/email/processor.ts
Normal file
172
src/email/processor.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { tickets, transactions, queues, lifecycles } from '../db/schema.ts';
|
||||
import { ScripEngine } from '../scrip/engine.ts';
|
||||
import { LifecycleValidator } from '../lifecycle/validator.ts';
|
||||
import type { LifecycleDefinition } from '../lifecycle/validator.ts';
|
||||
import { resolveUser, resolveQueue, matchTicket } from './resolvers.ts';
|
||||
import type { InboundEmail, ProcessResult } from './types.ts';
|
||||
|
||||
/** Tracks recently seen message IDs for dedup */
|
||||
const seenMessageIds = new Set<string>();
|
||||
const MAX_SEEN_IDS = 2000;
|
||||
|
||||
function isSeen(messageId: string): boolean {
|
||||
if (seenMessageIds.has(messageId)) return true;
|
||||
seenMessageIds.add(messageId);
|
||||
// Prune oldest entries if set grows too large
|
||||
if (seenMessageIds.size > MAX_SEEN_IDS) {
|
||||
const iter = seenMessageIds.values();
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const { value, done } = iter.next();
|
||||
if (done) break;
|
||||
seenMessageIds.delete(value!);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the plain from-address from a From header.
|
||||
* Handles "Alice <alice@example.com>" and plain "alice@example.com".
|
||||
*/
|
||||
function parseAddress(raw: string): string {
|
||||
const match = raw.match(/<([^>]+)>/);
|
||||
if (match) return match[1]!.trim().toLowerCase();
|
||||
return raw.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export class EmailProcessor {
|
||||
private db: Db;
|
||||
private scripEngine: ScripEngine;
|
||||
private lifecycleValidator: LifecycleValidator;
|
||||
|
||||
constructor(db: Db) {
|
||||
this.db = db;
|
||||
this.scripEngine = new ScripEngine(db);
|
||||
this.lifecycleValidator = new LifecycleValidator();
|
||||
}
|
||||
|
||||
async process(email: InboundEmail): Promise<ProcessResult> {
|
||||
// Dedup by messageId
|
||||
if (isSeen(email.messageId)) {
|
||||
console.log(`[email] Skipping duplicate message: ${email.messageId}`);
|
||||
return { action: 'skipped', detail: `Duplicate messageId: ${email.messageId}` };
|
||||
}
|
||||
|
||||
const fromAddress = parseAddress(email.from);
|
||||
const toAddress = parseAddress(email.to);
|
||||
|
||||
// Resolve user
|
||||
const { userId, isNew } = await resolveUser(this.db, fromAddress);
|
||||
if (isNew) {
|
||||
console.log(`[email] Created stub user for ${fromAddress}`);
|
||||
}
|
||||
|
||||
// Resolve queue
|
||||
const { queueId, queueName } = await resolveQueue(this.db, toAddress);
|
||||
console.log(`[email] Routed to queue "${queueName}"`);
|
||||
|
||||
// Match or create ticket
|
||||
const matchedTicket = await matchTicket(this.db, email.subject);
|
||||
|
||||
if (matchedTicket) {
|
||||
// Reply: add Correspond transaction
|
||||
const [tx] = await this.db
|
||||
.insert(transactions)
|
||||
.values({
|
||||
ticket_id: matchedTicket.id,
|
||||
transaction_type: 'Correspond',
|
||||
data: {
|
||||
body: email.bodyText || email.bodyHtml || '(no body)',
|
||||
from: fromAddress,
|
||||
message_id: email.messageId,
|
||||
},
|
||||
creator_id: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!tx) {
|
||||
return { action: 'skipped', detail: 'Failed to create transaction' };
|
||||
}
|
||||
|
||||
// Update ticket timestamp
|
||||
await this.db
|
||||
.update(tickets)
|
||||
.set({ updated_at: new Date() } as any)
|
||||
.where(eq(tickets.id, matchedTicket.id));
|
||||
|
||||
// Fire scrips (e.g. OnCorrespond → auto-reply)
|
||||
const prepared = await this.scripEngine.prepare(matchedTicket.id, [tx] as any);
|
||||
const results = await this.scripEngine.commit(prepared);
|
||||
|
||||
console.log(
|
||||
`[email] Reply on ticket ${matchedTicket.id}, scrips: ${results.length} (${results.map((r) => r.message).join(', ')})`,
|
||||
);
|
||||
return { action: 'replied', ticketId: matchedTicket.id, detail: `Correspond on ticket ${matchedTicket.id}` };
|
||||
}
|
||||
|
||||
// New ticket
|
||||
const queue = await this.db.query.queues.findFirst({
|
||||
where: eq(queues.id, queueId),
|
||||
});
|
||||
|
||||
let initialStatus = 'new';
|
||||
if (queue?.lifecycle_id) {
|
||||
const lifecycle = await this.db.query.lifecycles.findFirst({
|
||||
where: eq(lifecycles.id, queue.lifecycle_id),
|
||||
});
|
||||
const definition = lifecycle?.definition as LifecycleDefinition | undefined;
|
||||
initialStatus = definition?.statuses.initial[0] ?? initialStatus;
|
||||
}
|
||||
|
||||
const [ticket] = await this.db
|
||||
.insert(tickets)
|
||||
.values({
|
||||
subject: email.subject,
|
||||
queue_id: queueId,
|
||||
status: initialStatus,
|
||||
creator_id: userId,
|
||||
team_id: (queue as any)?.team_id ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!ticket) {
|
||||
return { action: 'skipped', detail: 'Failed to create ticket' };
|
||||
}
|
||||
|
||||
// Create transaction + correspond in one batch
|
||||
const txList = [
|
||||
{
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'Create',
|
||||
field: 'status',
|
||||
new_value: initialStatus,
|
||||
creator_id: userId,
|
||||
},
|
||||
{
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'Correspond',
|
||||
field: null,
|
||||
new_value: null,
|
||||
data: {
|
||||
body: email.bodyText || email.bodyHtml || '(no body)',
|
||||
from: fromAddress,
|
||||
message_id: email.messageId,
|
||||
},
|
||||
creator_id: userId,
|
||||
},
|
||||
];
|
||||
|
||||
await this.db.insert(transactions).values(txList as any);
|
||||
|
||||
// Fire scrips on TransactionBatch (OnCreate + OnCorrespond)
|
||||
const prepared = await this.scripEngine.prepare(ticket.id, txList as any, 'TransactionBatch');
|
||||
const results = await this.scripEngine.commit(prepared);
|
||||
|
||||
console.log(
|
||||
`[email] Created ticket ${ticket.id} in "${queueName}", scrips: ${results.length} (${results.map((r) => r.message).join(', ')})`,
|
||||
);
|
||||
return { action: 'created', ticketId: ticket.id, detail: `Ticket ${ticket.id} created in ${queueName}` };
|
||||
}
|
||||
}
|
||||
128
src/email/resolvers.ts
Normal file
128
src/email/resolvers.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { eq, ilike, and } from 'drizzle-orm';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { users, queues, tickets } from '../db/schema.ts';
|
||||
|
||||
/**
|
||||
* Resolve a sender email address to a user record.
|
||||
* Creates a stub "unverified" user if no match is found.
|
||||
*/
|
||||
export async function resolveUser(
|
||||
db: Db,
|
||||
fromAddress: string,
|
||||
): Promise<{ userId: string; isNew: boolean }> {
|
||||
// Try exact match first
|
||||
const existing = await db.query.users.findFirst({
|
||||
where: eq(users.email, fromAddress),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return { userId: existing.id, isNew: false };
|
||||
}
|
||||
|
||||
// Create a stub user with role 'unverified'
|
||||
const username = fromAddress.replace(/@/g, '-at-').replace(/[^a-zA-Z0-9._-]/g, '');
|
||||
const [stub] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
username,
|
||||
email: fromAddress,
|
||||
role: 'unverified',
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!stub) {
|
||||
throw new Error(`Failed to create stub user for ${fromAddress}`);
|
||||
}
|
||||
|
||||
return { userId: stub.id, isNew: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the "to" address to a queue.
|
||||
* Strategy:
|
||||
* 1. Check for exact mail_alias match on any queue
|
||||
* 2. Check if the local-part matches a queue name
|
||||
* 3. Fallback to the first available queue
|
||||
*/
|
||||
export async function resolveQueue(
|
||||
db: Db,
|
||||
toAddress: string,
|
||||
): Promise<{ queueId: string; queueName: string }> {
|
||||
const localPart = toAddress.split('@')[0]?.toLowerCase() ?? '';
|
||||
const fullAddress = toAddress.trim().toLowerCase();
|
||||
|
||||
// 1. Check mail_alias exact match
|
||||
if (fullAddress) {
|
||||
const byAlias = await db.query.queues.findFirst({
|
||||
where: eq(queues.mail_alias, fullAddress),
|
||||
});
|
||||
if (byAlias) {
|
||||
return { queueId: byAlias.id, queueName: byAlias.name };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check queue name matching the local-part
|
||||
if (localPart) {
|
||||
const byName = await db.query.queues.findFirst({
|
||||
where: eq(queues.name, localPart),
|
||||
});
|
||||
if (byName) {
|
||||
return { queueId: byName.id, queueName: byName.name };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to first queue
|
||||
const allQueues = await db.query.queues.findMany({ limit: 1 });
|
||||
const fallback = allQueues[0];
|
||||
if (fallback) {
|
||||
return { queueId: fallback.id, queueName: fallback.name };
|
||||
}
|
||||
|
||||
throw new Error('No queues exist — cannot route inbound email');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan subject for ticket ID patterns.
|
||||
* Supports: TKT-XXXX, TKT-XXXXX, #NNN, [queue-name #NNN]
|
||||
*
|
||||
* Returns the matched ticket if found and not in a closed state,
|
||||
* or null if no ticket was matched.
|
||||
*/
|
||||
export async function matchTicket(
|
||||
db: Db,
|
||||
subject: string,
|
||||
): Promise<{ id: number; subject: string; status: string } | null> {
|
||||
const trimmed = subject.trim();
|
||||
|
||||
// TKT-XXXX or TKT-XXXXX (Tessera display format)
|
||||
const tktMatch = trimmed.match(/\bTKT-(\d{4,5})\b/i);
|
||||
if (tktMatch) {
|
||||
const id = parseInt(tktMatch[1]!, 10);
|
||||
if (!isNaN(id)) {
|
||||
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
|
||||
if (ticket) return { id: ticket.id, subject: ticket.subject, status: ticket.status };
|
||||
}
|
||||
}
|
||||
|
||||
// [queue-name #NNN] (RT-compatible bracket format)
|
||||
const bracketMatch = trimmed.match(/\[[\w-]+\s*#(\d+)\]/i);
|
||||
if (bracketMatch) {
|
||||
const id = parseInt(bracketMatch[1]!, 10);
|
||||
if (!isNaN(id)) {
|
||||
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
|
||||
if (ticket) return { id: ticket.id, subject: ticket.subject, status: ticket.status };
|
||||
}
|
||||
}
|
||||
|
||||
// #NNN (shorthand, requires word boundary before #)
|
||||
const hashMatch = trimmed.match(/(?:^|\s)#(\d+)\b/);
|
||||
if (hashMatch) {
|
||||
const id = parseInt(hashMatch[1]!, 10);
|
||||
if (!isNaN(id)) {
|
||||
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
|
||||
if (ticket) return { id: ticket.id, subject: ticket.subject, status: ticket.status };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
24
src/email/types.ts
Normal file
24
src/email/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface InboundEmailAttachment {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
content: Buffer;
|
||||
}
|
||||
|
||||
export interface InboundEmail {
|
||||
from: string; // "Alice <alice@example.com>"
|
||||
fromAddress: string; // "alice@example.com"
|
||||
to: string; // "support@mail.tm"
|
||||
subject: string;
|
||||
bodyText: string;
|
||||
bodyHtml?: string;
|
||||
attachments: InboundEmailAttachment[];
|
||||
messageId: string; // for dedup
|
||||
receivedAt: Date;
|
||||
}
|
||||
|
||||
/** Result of processing one inbound email */
|
||||
export interface ProcessResult {
|
||||
action: 'created' | 'replied' | 'skipped';
|
||||
ticketId?: number;
|
||||
detail: string;
|
||||
}
|
||||
19
src/index.ts
19
src/index.ts
@@ -19,8 +19,12 @@ 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 { createMailgateRouter } from './routes/mailgate.ts';
|
||||
import { startScheduler } from './scrip/scheduler.ts';
|
||||
import { EmailProcessor } from './email/processor.ts';
|
||||
import { MailTmTransport } from './email/mailtm.ts';
|
||||
|
||||
let db: Db | null = null;
|
||||
|
||||
@@ -38,10 +42,16 @@ app.onError(errorHandler);
|
||||
|
||||
const { requireAuth, requireAdmin } = createAuthMiddleware(getDb());
|
||||
|
||||
// Email processor (shared between transport and webhook)
|
||||
const emailProcessor = new EmailProcessor(getDb());
|
||||
|
||||
// Public routes
|
||||
app.route('/health', healthRouter);
|
||||
app.route('/', createAuthRouter(getDb()));
|
||||
|
||||
// Mailgate webhook — public endpoint for receiving inbound emails
|
||||
app.route('/mailgate', createMailgateRouter(getDb(), emailProcessor));
|
||||
|
||||
// Ticket routes — require authentication
|
||||
const ticketsWithAuth = new Hono();
|
||||
ticketsWithAuth.use('*', requireAuth);
|
||||
@@ -67,6 +77,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);
|
||||
|
||||
@@ -85,4 +96,12 @@ if (Bun.main === import.meta.path) {
|
||||
|
||||
// Start the scrip scheduler (runs every 5 minutes)
|
||||
startScheduler(getDb());
|
||||
|
||||
// Start inbound email transport
|
||||
if (config.MAIL_TRANSPORT === 'mailtm') {
|
||||
const transport = new MailTmTransport(emailProcessor);
|
||||
transport.start().catch((err) => {
|
||||
console.error('[email] Failed to start mail.tm transport:', err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
69
src/routes/mailgate.ts
Normal file
69
src/routes/mailgate.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { z } from 'zod/v4';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import type { InboundEmail } from '../email/types.ts';
|
||||
import type { EmailProcessor } from '../email/processor.ts';
|
||||
|
||||
const WebhookPayloadSchema = z.object({
|
||||
from: z.string().min(1),
|
||||
to: z.string().min(1),
|
||||
subject: z.string().min(1),
|
||||
body_text: z.string().optional().default(''),
|
||||
body_html: z.string().optional(),
|
||||
message_id: z.string().optional(),
|
||||
attachments: z
|
||||
.array(
|
||||
z.object({
|
||||
filename: z.string(),
|
||||
mime_type: z.string().optional().default('application/octet-stream'),
|
||||
content_base64: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /mailgate — webhook endpoint for receiving inbound emails.
|
||||
*
|
||||
* Accepts JSON payload from external mail services (SendGrid, Mailgun, etc.).
|
||||
* When MAIL_TRANSPORT is 'webhook', this is the primary inbound path.
|
||||
* When MAIL_TRANSPORT is 'mailtm' or 'none', it's still available as a
|
||||
* secondary path (useful for testing or hybrid setups).
|
||||
*/
|
||||
export function createMailgateRouter(db: Db, processor: EmailProcessor): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = WebhookPayloadSchema.parse(body);
|
||||
|
||||
const inbound: InboundEmail = {
|
||||
from: parsed.from,
|
||||
fromAddress: extractAddress(parsed.from),
|
||||
to: parsed.to,
|
||||
subject: parsed.subject,
|
||||
bodyText: parsed.body_text,
|
||||
bodyHtml: parsed.body_html,
|
||||
messageId: parsed.message_id ?? `${Date.now()}-${crypto.randomUUID()}`,
|
||||
receivedAt: new Date(),
|
||||
attachments: parsed.attachments.map((att) => ({
|
||||
filename: att.filename,
|
||||
mimeType: att.mime_type,
|
||||
content: Buffer.from(att.content_base64, 'base64'),
|
||||
})),
|
||||
};
|
||||
|
||||
const result = await processor.process(inbound);
|
||||
return c.json(result, result.action === 'skipped' ? 200 : 201);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function extractAddress(raw: string): string {
|
||||
const match = raw.match(/<([^>]+)>/);
|
||||
if (match) return match[1]!.trim().toLowerCase();
|
||||
return raw.trim().toLowerCase();
|
||||
}
|
||||
108
src/routes/sla-policies.ts
Normal file
108
src/routes/sla-policies.ts
Normal 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;
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
0
web/public/favicon.ico
Normal file
0
web/public/favicon.ico
Normal 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[]>([]);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,16 +1049,27 @@ export default function TicketDetailPage({
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(event) => {
|
||||
setReplyText(event.target.value);
|
||||
setSendError(null);
|
||||
}}
|
||||
placeholder={replyMode === "internal" ? "Add internal context..." : "Write a reply..."}
|
||||
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"
|
||||
/>
|
||||
{composerMode === "write" ? (
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(event) => {
|
||||
setReplyText(event.target.value);
|
||||
setSendError(null);
|
||||
}}
|
||||
placeholder={replyMode === "internal" ? "Add internal context..." : "Write a reply..."}
|
||||
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">
|
||||
|
||||
@@ -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
30
web/src/lib/markdown.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<p>${escaped}</p>`;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user