feat: auth system, scrip scheduler, UI widgets, and new API routes
- Add session-based authentication (login page, middleware, auth context) - Add cron-like scrip scheduler for time-based conditions - Add layout builder, scrip wizard, searchable select components - Add trend chart widget for dashboards - Add notifications, attachments, queue-permissions API routes - Add seed-users script - Update schema with 10 new migrations (0008-0017) - Apply redesign: Linear-inspired dark theme, conversation-centric UI - Gitignore runtime data directory Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
144
src/auth/middleware.ts
Normal file
144
src/auth/middleware.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Context, Next } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import * as jose from 'jose';
|
||||
import { config } from '../config.ts';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { users, apiTokens } from '../db/schema.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export interface AuthUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
user: AuthUser;
|
||||
}
|
||||
}
|
||||
|
||||
const secret = new TextEncoder().encode(config.JWT_SECRET);
|
||||
|
||||
export async function createToken(user: { id: string; username: string; role: string }): Promise<string> {
|
||||
return await new jose.SignJWT({ username: user.username, role: user.role })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setSubject(user.id)
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('7d')
|
||||
.sign(secret);
|
||||
}
|
||||
|
||||
async function verifyJwt(token: string): Promise<AuthUser | null> {
|
||||
try {
|
||||
const { payload } = await jose.jwtVerify(token, secret);
|
||||
return {
|
||||
userId: payload.sub!,
|
||||
username: payload.username as string,
|
||||
role: payload.role as string,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyApiToken(db: Db, token: string): Promise<AuthUser | null> {
|
||||
try {
|
||||
// Find all tokens and verify against hash
|
||||
const allTokens = await db.query.apiTokens.findMany();
|
||||
for (const t of allTokens) {
|
||||
const valid = await Bun.password.verify(token, t.token_hash);
|
||||
if (valid) {
|
||||
// Update last_used_at
|
||||
await db.update(apiTokens)
|
||||
.set({ last_used_at: new Date() } as any)
|
||||
.where(eq(apiTokens.id, t.id));
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, t.user_id),
|
||||
});
|
||||
if (user) {
|
||||
return {
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractToken(c: Context): string | null {
|
||||
const auth = c.req.header('Authorization');
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
return auth.slice(7);
|
||||
}
|
||||
|
||||
const cookie = c.req.header('Cookie');
|
||||
if (cookie) {
|
||||
const match = cookie.match(/(?:^|;\s*)token=([^;]*)/);
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function createAuthMiddleware(db: Db) {
|
||||
async function verifyToken(token: string): Promise<AuthUser | null> {
|
||||
if (token.startsWith('tessera_')) {
|
||||
return await verifyApiToken(db, token);
|
||||
}
|
||||
return await verifyJwt(token);
|
||||
}
|
||||
|
||||
async function requireAuth(c: Context, next: Next) {
|
||||
const token = extractToken(c);
|
||||
if (!token) {
|
||||
throw new HTTPException(401, { message: 'Authentication required' });
|
||||
}
|
||||
const user = await verifyToken(token);
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'Invalid or expired token' });
|
||||
}
|
||||
c.set('user', user);
|
||||
await next();
|
||||
}
|
||||
|
||||
async function requireAdmin(c: Context, next: Next) {
|
||||
const token = extractToken(c);
|
||||
if (!token) {
|
||||
throw new HTTPException(401, { message: 'Authentication required' });
|
||||
}
|
||||
const user = await verifyToken(token);
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'Invalid or expired token' });
|
||||
}
|
||||
if (user.role !== 'admin') {
|
||||
throw new HTTPException(403, { message: 'Admin access required' });
|
||||
}
|
||||
c.set('user', user);
|
||||
await next();
|
||||
}
|
||||
|
||||
async function optionalAuth(c: Context, next: Next) {
|
||||
const token = extractToken(c);
|
||||
if (token) {
|
||||
const user = await verifyToken(token);
|
||||
if (user) {
|
||||
c.set('user', user);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
}
|
||||
|
||||
return { requireAuth, requireAdmin, optionalAuth };
|
||||
}
|
||||
|
||||
export function getUserId(c: Context): string {
|
||||
const user = c.get('user');
|
||||
return user?.userId ?? '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
86
src/auth/permissions.ts
Normal file
86
src/auth/permissions.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Context } from 'hono';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { teamMembers, queuePermissions, userPermissions } from '../db/schema.ts';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import type { AuthUser } from './middleware.ts';
|
||||
|
||||
export type TicketRight = 'ticket.view' | 'ticket.create' | 'ticket.reply' | 'ticket.comment' | 'ticket.modify' | 'queue.admin';
|
||||
|
||||
const RIGHT_HIERARCHY: Record<TicketRight, TicketRight[]> = {
|
||||
'queue.admin': ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'],
|
||||
'ticket.modify': ['ticket.view', 'ticket.reply', 'ticket.comment', 'ticket.modify'],
|
||||
'ticket.reply': ['ticket.view', 'ticket.reply'],
|
||||
'ticket.comment': ['ticket.view', 'ticket.comment'],
|
||||
'ticket.create': ['ticket.create'],
|
||||
'ticket.view': ['ticket.view'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a user has a specific right on a queue.
|
||||
* Admins bypass all permission checks.
|
||||
* Rights come from two sources: team memberships and per-user grants.
|
||||
* Higher rights imply lower rights (e.g., queue.admin implies ticket.view).
|
||||
*/
|
||||
export async function userHasRight(
|
||||
db: Db,
|
||||
user: AuthUser,
|
||||
queueId: string,
|
||||
right: TicketRight,
|
||||
): Promise<boolean> {
|
||||
// Admins have all rights
|
||||
if (user.role === 'admin') return true;
|
||||
|
||||
const neededRights = RIGHT_HIERARCHY[right] ?? [right];
|
||||
|
||||
// Check per-user permissions first (direct grant)
|
||||
const userPerm = await db.query.userPermissions.findFirst({
|
||||
where: (table, { and, eq: eqFn, inArray: inArr }) =>
|
||||
and(
|
||||
eqFn(table.user_id, user.userId),
|
||||
eqFn(table.queue_id, queueId),
|
||||
inArr(table.right_name, neededRights),
|
||||
),
|
||||
});
|
||||
|
||||
if (userPerm) return true;
|
||||
|
||||
// Check team permissions (inherited)
|
||||
const memberships = await db.query.teamMembers.findMany({
|
||||
where: eq(teamMembers.user_id, user.userId),
|
||||
});
|
||||
|
||||
const teamIds = memberships.map((m) => m.team_id);
|
||||
if (teamIds.length === 0) return false;
|
||||
|
||||
const teamPerm = await db.query.queuePermissions.findFirst({
|
||||
where: (table, { and, eq: eqFn, inArray: inArr }) =>
|
||||
and(
|
||||
inArr(table.team_id, teamIds),
|
||||
eqFn(table.queue_id, queueId),
|
||||
inArr(table.right_name, neededRights),
|
||||
),
|
||||
});
|
||||
|
||||
return teamPerm !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require a specific right on a queue. Throws 403 if the user lacks the right.
|
||||
*/
|
||||
export async function requireRight(
|
||||
c: Context,
|
||||
db: Db,
|
||||
queueId: string,
|
||||
right: TicketRight,
|
||||
): Promise<void> {
|
||||
const user = c.get('user');
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'Authentication required' });
|
||||
}
|
||||
|
||||
const has = await userHasRight(db, user, queueId, right);
|
||||
if (!has) {
|
||||
throw new HTTPException(403, { message: `Missing required right: ${right}` });
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ const configSchema = z.object({
|
||||
SMTP_USER: z.string().optional(),
|
||||
SMTP_PASS: z.string().optional(),
|
||||
SMTP_FROM: z.string().default('tessera@localhost'),
|
||||
UPLOAD_DIR: z.string().default('./data/uploads'),
|
||||
JWT_SECRET: z.string().default('tessera-dev-secret-change-in-production'),
|
||||
});
|
||||
|
||||
export const config = configSchema.parse(process.env);
|
||||
|
||||
@@ -5,6 +5,8 @@ export const users = pgTable('users', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
username: text('username').notNull().unique(),
|
||||
email: text('email'),
|
||||
password_hash: text('password_hash'),
|
||||
role: text('role').notNull().default('staff'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
@@ -49,6 +51,7 @@ export const transactions = pgTable('transactions', {
|
||||
old_value: text('old_value'),
|
||||
new_value: text('new_value'),
|
||||
data: jsonb('data'),
|
||||
time_worked_minutes: integer('time_worked_minutes').default(0),
|
||||
creator_id: uuid('creator_id').notNull().references(() => users.id),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
@@ -78,6 +81,7 @@ export const scrips = pgTable('scrips', {
|
||||
stage: text('stage').notNull().default('TransactionCreate'),
|
||||
sort_order: integer('sort_order').notNull().default(0),
|
||||
disabled: boolean('disabled').notNull().default(false),
|
||||
applicable_trans_types: text('applicable_trans_types'),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
queueIdIdx: index('scrips_queue_id_idx').on(table.queue_id),
|
||||
@@ -160,6 +164,78 @@ export const dashboards = pgTable('dashboards', {
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
});
|
||||
|
||||
export const transactionAttachments = pgTable('transaction_attachments', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
transaction_id: uuid('transaction_id').references(() => transactions.id, { onDelete: 'cascade' }),
|
||||
filename: text('filename').notNull(),
|
||||
mime_type: text('mime_type').notNull().default('application/octet-stream'),
|
||||
size_bytes: integer('size_bytes').notNull().default(0),
|
||||
storage_path: text('storage_path').notNull(),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
transactionIdIdx: index('transaction_attachments_tx_id_idx').on(table.transaction_id),
|
||||
}));
|
||||
|
||||
export const queuePermissions = pgTable('queue_permissions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
queue_id: uuid('queue_id').notNull().references(() => queues.id, { onDelete: 'cascade' }),
|
||||
team_id: uuid('team_id').notNull().references(() => teams.id, { onDelete: 'cascade' }),
|
||||
right_name: text('right_name').notNull(),
|
||||
}, (table) => ({
|
||||
uniqueRight: unique('queue_permissions_queue_team_right_unique').on(table.queue_id, table.team_id, table.right_name),
|
||||
queueIdIdx: index('queue_permissions_queue_id_idx').on(table.queue_id),
|
||||
teamIdIdx: index('queue_permissions_team_id_idx').on(table.team_id),
|
||||
}));
|
||||
|
||||
export const apiTokens = pgTable('api_tokens', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
token_hash: text('token_hash').notNull().unique(),
|
||||
last_used_at: timestamp('last_used_at', { withTimezone: true }),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
userIdIdx: index('api_tokens_user_id_idx').on(table.user_id),
|
||||
}));
|
||||
|
||||
export const notifications = pgTable('notifications', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
ticket_id: integer('ticket_id').references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
type: text('type').notNull(), // 'assigned', 'mentioned', 'commented', 'scrip_fired'
|
||||
title: text('title').notNull(),
|
||||
body: text('body'),
|
||||
read: boolean('read').notNull().default(false),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
userIdIdx: index('notifications_user_id_idx').on(table.user_id),
|
||||
unreadIdx: index('notifications_user_read_idx').on(table.user_id, table.read),
|
||||
}));
|
||||
|
||||
export const userPermissions = pgTable('user_permissions', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
queue_id: uuid('queue_id').notNull().references(() => queues.id, { onDelete: 'cascade' }),
|
||||
user_id: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
right_name: text('right_name').notNull(),
|
||||
}, (table) => ({
|
||||
uniqueRight: unique('user_permissions_queue_user_right_unique').on(table.queue_id, table.user_id, table.right_name),
|
||||
queueIdIdx: index('user_permissions_queue_id_idx').on(table.queue_id),
|
||||
userIdIdx: index('user_permissions_user_id_idx').on(table.user_id),
|
||||
}));
|
||||
|
||||
export const ticketLinks = pgTable('ticket_links', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
ticket_id: integer('ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
target_ticket_id: integer('target_ticket_id').notNull().references(() => tickets.id, { onDelete: 'cascade' }),
|
||||
link_type: text('link_type').notNull(),
|
||||
creator_id: uuid('creator_id').notNull().references(() => users.id),
|
||||
created_at: timestamp('created_at', { withTimezone: true }).defaultNow(),
|
||||
}, (table) => ({
|
||||
uniqueLink: unique('ticket_links_ticket_target_type_unique').on(table.ticket_id, table.target_ticket_id, table.link_type),
|
||||
ticketIdIdx: index('ticket_links_ticket_id_idx').on(table.ticket_id),
|
||||
targetTicketIdIdx: index('ticket_links_target_ticket_id_idx').on(table.target_ticket_id),
|
||||
}));
|
||||
|
||||
export const dashboardWidgets = pgTable('dashboard_widgets', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
dashboard_id: uuid('dashboard_id').notNull().references(() => dashboards.id, { onDelete: 'cascade' }),
|
||||
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
customFieldValues,
|
||||
lifecycles,
|
||||
queueCustomFields,
|
||||
queuePermissions,
|
||||
queues,
|
||||
scrips,
|
||||
teamMembers,
|
||||
teams,
|
||||
templates,
|
||||
tickets,
|
||||
transactions,
|
||||
@@ -52,7 +55,7 @@ function createSeedDb(pool: Pool) {
|
||||
}
|
||||
|
||||
type Db = ReturnType<typeof createSeedDb>;
|
||||
type UserSeed = { id: string; username: string; email: string };
|
||||
type UserSeed = { id: string; username: string; email: string; role?: string; password_hash?: string };
|
||||
type QueueSeed = { name: string; description: string };
|
||||
type FieldSeed = {
|
||||
key?: string;
|
||||
@@ -73,12 +76,19 @@ function makeFieldKey(value: string): string {
|
||||
}
|
||||
|
||||
async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
|
||||
const setData = {
|
||||
username: seed.username,
|
||||
email: seed.email,
|
||||
role: seed.role ?? 'staff',
|
||||
password_hash: seed.password_hash ?? null,
|
||||
};
|
||||
|
||||
const existingById = await db.query.users.findFirst({
|
||||
where: eq(users.id, seed.id),
|
||||
});
|
||||
if (existingById) {
|
||||
await db.update(users)
|
||||
.set({ username: seed.username, email: seed.email })
|
||||
.set(setData)
|
||||
.where(eq(users.id, seed.id));
|
||||
return existingById.id;
|
||||
}
|
||||
@@ -88,12 +98,12 @@ async function ensureUser(db: Db, seed: UserSeed): Promise<string> {
|
||||
});
|
||||
if (existingByUsername) {
|
||||
await db.update(users)
|
||||
.set({ email: seed.email })
|
||||
.set(setData)
|
||||
.where(eq(users.id, existingByUsername.id));
|
||||
return existingByUsername.id;
|
||||
}
|
||||
|
||||
const [created] = await db.insert(users).values(seed).returning();
|
||||
const [created] = await db.insert(users).values({ ...seed, ...setData }).returning();
|
||||
if (!created) throw new Error(`Failed to seed user ${seed.username}`);
|
||||
return created.id;
|
||||
}
|
||||
@@ -315,6 +325,7 @@ async function ensureTicket(
|
||||
|
||||
async function resetDatabase(db: Db) {
|
||||
await db.delete(customFieldValues);
|
||||
await db.delete(queuePermissions);
|
||||
await db.delete(transactions);
|
||||
await db.delete(queueCustomFields);
|
||||
await db.delete(dashboardWidgets);
|
||||
@@ -346,34 +357,68 @@ async function main() {
|
||||
await resetDatabase(db);
|
||||
}
|
||||
|
||||
const userPassword = await Bun.password.hash('password');
|
||||
const adminPassword = await Bun.password.hash('admin');
|
||||
|
||||
const userIds = {
|
||||
system: await ensureUser(db, {
|
||||
id: SYSTEM_USER_ID,
|
||||
username: 'system',
|
||||
email: 'system@tessera.local',
|
||||
}),
|
||||
admin: await ensureUser(db, {
|
||||
id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
username: 'admin',
|
||||
email: 'admin@tessera.local',
|
||||
role: 'admin',
|
||||
password_hash: adminPassword,
|
||||
}),
|
||||
dispatcher: await ensureUser(db, {
|
||||
id: '11111111-1111-4111-8111-111111111111',
|
||||
username: 'maria.dispatch',
|
||||
email: 'maria.dispatch@tessera.local',
|
||||
password_hash: userPassword,
|
||||
}),
|
||||
technician: await ensureUser(db, {
|
||||
id: '22222222-2222-4222-8222-222222222222',
|
||||
username: 'liam.field',
|
||||
email: 'liam.field@tessera.local',
|
||||
password_hash: userPassword,
|
||||
}),
|
||||
facilities: await ensureUser(db, {
|
||||
id: '33333333-3333-4333-8333-333333333333',
|
||||
username: 'nora.facilities',
|
||||
email: 'nora.facilities@tessera.local',
|
||||
password_hash: userPassword,
|
||||
}),
|
||||
security: await ensureUser(db, {
|
||||
id: '44444444-4444-4444-8444-444444444444',
|
||||
username: 'sam.security',
|
||||
email: 'sam.security@tessera.local',
|
||||
password_hash: userPassword,
|
||||
}),
|
||||
};
|
||||
|
||||
// Create demo team and assign all staff users to it
|
||||
const [supportTeam] = await db.insert(teams).values({
|
||||
name: 'Support team',
|
||||
description: 'Demo support team with full queue access',
|
||||
}).onConflictDoUpdate({
|
||||
target: teams.name,
|
||||
set: { description: 'Demo support team with full queue access' },
|
||||
}).returning();
|
||||
|
||||
if (supportTeam) {
|
||||
// Add all staff users to the team
|
||||
const staffIds = [userIds.dispatcher, userIds.technician, userIds.facilities, userIds.security];
|
||||
for (const userId of staffIds) {
|
||||
await db.insert(teamMembers).values({
|
||||
team_id: supportTeam.id,
|
||||
user_id: userId,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
|
||||
const lifecycle = await ensureLifecycle(db);
|
||||
|
||||
const supportQueue = await ensureQueue(db, lifecycle.id, {
|
||||
@@ -432,6 +477,20 @@ async function main() {
|
||||
await attachFieldToQueue(db, fieldQueue.id, assetField.id, 40);
|
||||
await attachFieldToQueue(db, supportQueue.id, outcomeField.id, 50);
|
||||
|
||||
// Grant the support team full access to all demo queues
|
||||
if (supportTeam) {
|
||||
const allRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify'];
|
||||
for (const queue of [supportQueue, fieldQueue, facilitiesQueue, securityQueue]) {
|
||||
for (const right of allRights) {
|
||||
await db.insert(queuePermissions).values({
|
||||
queue_id: queue.id,
|
||||
team_id: supportTeam.id,
|
||||
right_name: right,
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolveTemplate = await ensureTemplate(
|
||||
db,
|
||||
'Demo resolution note',
|
||||
|
||||
51
src/index.ts
51
src/index.ts
@@ -4,6 +4,7 @@ import { createDb } from './db/index.ts';
|
||||
import type { Db } from './db/index.ts';
|
||||
import { errorHandler } from './middleware/error.ts';
|
||||
import { requestLogger } from './middleware/logging.ts';
|
||||
import { createAuthMiddleware } from './auth/middleware.ts';
|
||||
import healthRouter from './routes/health.ts';
|
||||
import { createTicketsRouter } from './routes/tickets.ts';
|
||||
import { createQueuesRouter } from './routes/queues.ts';
|
||||
@@ -15,6 +16,11 @@ import { createTemplatesRouter } from './routes/templates.ts';
|
||||
import { createViewsRouter } from './routes/views.ts';
|
||||
import { createDashboardsRouter } from './routes/dashboards.ts';
|
||||
import { createTeamsRouter } from './routes/teams.ts';
|
||||
import { createAttachmentsRouter } from './routes/attachments.ts';
|
||||
import { createAuthRouter } from './routes/auth.ts';
|
||||
import { createQueuePermissionsRouter } from './routes/queue-permissions.ts';
|
||||
import { createNotificationsRouter } from './routes/notifications.ts';
|
||||
import { startScheduler } from './scrip/scheduler.ts';
|
||||
|
||||
let db: Db | null = null;
|
||||
|
||||
@@ -30,17 +36,39 @@ const app = new Hono();
|
||||
app.use('*', requestLogger);
|
||||
app.onError(errorHandler);
|
||||
|
||||
const { requireAuth, requireAdmin } = createAuthMiddleware(getDb());
|
||||
|
||||
// Public routes
|
||||
app.route('/health', healthRouter);
|
||||
app.route('/tickets', createTicketsRouter(getDb()));
|
||||
app.route('/queues', createQueuesRouter(getDb()));
|
||||
app.route('/scrips', createScripsRouter(getDb()));
|
||||
app.route('/custom-fields', createCustomFieldsRouter(getDb()));
|
||||
app.route('/lifecycles', createLifecyclesRouter(getDb()));
|
||||
app.route('/users', createUsersRouter(getDb()));
|
||||
app.route('/templates', createTemplatesRouter(getDb()));
|
||||
app.route('/views', createViewsRouter(getDb()));
|
||||
app.route('/dashboards', createDashboardsRouter(getDb()));
|
||||
app.route('/teams', createTeamsRouter(getDb()));
|
||||
app.route('/', createAuthRouter(getDb()));
|
||||
|
||||
// Ticket routes — require authentication
|
||||
const ticketsWithAuth = new Hono();
|
||||
ticketsWithAuth.use('*', requireAuth);
|
||||
ticketsWithAuth.route('/tickets', createTicketsRouter(getDb()));
|
||||
ticketsWithAuth.route('/', createNotificationsRouter(getDb()));
|
||||
app.route('/', ticketsWithAuth);
|
||||
|
||||
// Attachment serving — require authentication
|
||||
const attachmentsWithAuth = new Hono();
|
||||
attachmentsWithAuth.use('*', requireAuth);
|
||||
attachmentsWithAuth.route('/', createAttachmentsRouter(getDb()));
|
||||
app.route('/', attachmentsWithAuth);
|
||||
|
||||
// Admin routes — require admin role
|
||||
const admin = new Hono();
|
||||
admin.use('*', requireAdmin);
|
||||
admin.route('/queues', createQueuesRouter(getDb()));
|
||||
admin.route('/scrips', createScripsRouter(getDb()));
|
||||
admin.route('/custom-fields', createCustomFieldsRouter(getDb()));
|
||||
admin.route('/lifecycles', createLifecyclesRouter(getDb()));
|
||||
admin.route('/users', createUsersRouter(getDb()));
|
||||
admin.route('/templates', createTemplatesRouter(getDb()));
|
||||
admin.route('/views', createViewsRouter(getDb()));
|
||||
admin.route('/dashboards', createDashboardsRouter(getDb()));
|
||||
admin.route('/teams', createTeamsRouter(getDb()));
|
||||
admin.route('/', createQueuePermissionsRouter(getDb()));
|
||||
app.route('/', admin);
|
||||
|
||||
export default app;
|
||||
export { app };
|
||||
@@ -54,4 +82,7 @@ if (Bun.main === import.meta.path) {
|
||||
development: false,
|
||||
});
|
||||
console.log(`Server running at http://${config.SERVER_HOST}:${config.SERVER_PORT}`);
|
||||
|
||||
// Start the scrip scheduler (runs every 5 minutes)
|
||||
startScheduler(getDb());
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ export interface LifecycleDefinition {
|
||||
inactive: string[];
|
||||
};
|
||||
transitions: Record<string, string[]>;
|
||||
transition_rights?: Record<string, string>; // "from→to" → rightName
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
requiredRight?: string; // Named right required for this transition, if any
|
||||
}
|
||||
|
||||
const FALLBACK_RIGHT = 'ticket.modify';
|
||||
|
||||
export class LifecycleValidator {
|
||||
validateTransition(
|
||||
lifecycleDef: LifecycleDefinition,
|
||||
@@ -35,13 +39,15 @@ export class LifecycleValidator {
|
||||
const allowedTransitions = this.getAllowedTransitions(lifecycleDef, fromStatus);
|
||||
|
||||
if (allowedTransitions.includes(toStatus)) {
|
||||
return { valid: true };
|
||||
const right = this.getRequiredRight(lifecycleDef, fromStatus, toStatus);
|
||||
return { valid: true, requiredRight: right ?? FALLBACK_RIGHT };
|
||||
}
|
||||
|
||||
// Also handle wildcard "*" -> any transition
|
||||
const wildcardTransitions = this.getAllowedTransitions(lifecycleDef, '*');
|
||||
if (wildcardTransitions.includes(toStatus)) {
|
||||
return { valid: true };
|
||||
const right = this.getRequiredRight(lifecycleDef, fromStatus, toStatus);
|
||||
return { valid: true, requiredRight: right ?? FALLBACK_RIGHT };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -50,6 +56,37 @@ export class LifecycleValidator {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the required right for a transition using RT's 4-level priority:
|
||||
* 1. exact "from→to"
|
||||
* 2. wildcard from "*→to"
|
||||
* 3. wildcard to "from→*"
|
||||
* 4. full wildcard "*→*"
|
||||
* 5. fallback: ticket.modify
|
||||
*/
|
||||
getRequiredRight(
|
||||
lifecycleDef: LifecycleDefinition,
|
||||
fromStatus: string,
|
||||
toStatus: string,
|
||||
): string | null {
|
||||
const rights = lifecycleDef.transition_rights ?? {};
|
||||
|
||||
// Priority 1: exact match
|
||||
if (rights[`${fromStatus}→${toStatus}`]) return rights[`${fromStatus}→${toStatus}`];
|
||||
|
||||
// Priority 2: wildcard from
|
||||
if (rights[`*→${toStatus}`]) return rights[`*→${toStatus}`];
|
||||
|
||||
// Priority 3: wildcard to
|
||||
if (rights[`${fromStatus}→*`]) return rights[`${fromStatus}→*`];
|
||||
|
||||
// Priority 4: full wildcard
|
||||
if (rights['*→*']) return rights['*→*'];
|
||||
|
||||
// Priority 5: fallback
|
||||
return null;
|
||||
}
|
||||
|
||||
isResolvedStatus(lifecycleDef: LifecycleDefinition, status: string): boolean {
|
||||
return lifecycleDef.statuses.inactive.includes(status);
|
||||
}
|
||||
@@ -58,16 +95,12 @@ export class LifecycleValidator {
|
||||
lifecycleDef: LifecycleDefinition,
|
||||
fromStatus: string,
|
||||
): string[] {
|
||||
// Direct transition
|
||||
if (lifecycleDef.transitions[fromStatus]) {
|
||||
return lifecycleDef.transitions[fromStatus]!;
|
||||
}
|
||||
|
||||
// Wildcard transitions
|
||||
if (lifecycleDef.transitions['*']) {
|
||||
return lifecycleDef.transitions['*']!;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,6 @@ export const CommentSchema = z.object({
|
||||
body: z.string().min(1),
|
||||
creator_id: z.string().optional().default('00000000-0000-0000-0000-000000000000'),
|
||||
internal: z.boolean().optional().default(false),
|
||||
attachment_ids: z.array(z.string()).optional(),
|
||||
time_worked_minutes: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ export const TransactionType = {
|
||||
Comment: 'Comment',
|
||||
CustomField: 'CustomField',
|
||||
Correspond: 'Correspond',
|
||||
LinkCreate: 'LinkCreate',
|
||||
LinkDelete: 'LinkDelete',
|
||||
} as const;
|
||||
|
||||
export type TransactionType = (typeof TransactionType)[keyof typeof TransactionType];
|
||||
|
||||
190
src/routes/attachments.ts
Normal file
190
src/routes/attachments.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { existsSync, mkdirSync, createReadStream } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
import { writeFile, unlink } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { config } from '../config.ts';
|
||||
import { transactionAttachments, transactions, tickets } from '../db/schema.ts';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
|
||||
function ensureDir(dir: string) {
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function storageDir(): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear().toString();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dir = join(config.UPLOAD_DIR, year, month);
|
||||
ensureDir(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
'.txt': 'text/plain',
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml',
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.zip': 'application/zip',
|
||||
'.gz': 'application/gzip',
|
||||
'.tar': 'application/x-tar',
|
||||
'.csv': 'text/csv',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avif': 'image/avif',
|
||||
'.heic': 'image/heic',
|
||||
'.heif': 'image/heif',
|
||||
'.log': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.yaml': 'text/yaml',
|
||||
'.yml': 'text/yaml',
|
||||
};
|
||||
|
||||
function guessMimeType(filename: string): string {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
return MIME_MAP[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function createAttachmentsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// POST /tickets/:id/attachments — upload files (returns metadata, no transaction created yet)
|
||||
router.post('/tickets/:id/attachments', async (c) => {
|
||||
const ticketId = Number(c.req.param('id'));
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
const formData = await c.req.formData();
|
||||
const files = formData.getAll('files') as File[];
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new HTTPException(422, { message: 'No files provided' });
|
||||
}
|
||||
|
||||
const dir = storageDir();
|
||||
const result: Array<{
|
||||
id: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
}> = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!(file instanceof File)) continue;
|
||||
|
||||
const ext = extname(file.name);
|
||||
const storedName = `${randomUUID()}${ext}`;
|
||||
const storagePath = join(dir, storedName);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(storagePath, buffer);
|
||||
|
||||
const [saved] = await db.insert(transactionAttachments).values({
|
||||
filename: file.name,
|
||||
mime_type: file.type || guessMimeType(file.name),
|
||||
size_bytes: buffer.length,
|
||||
storage_path: storagePath,
|
||||
}).returning();
|
||||
|
||||
if (saved) {
|
||||
result.push({
|
||||
id: saved.id,
|
||||
filename: saved.filename,
|
||||
mime_type: saved.mime_type,
|
||||
size_bytes: saved.size_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ attachments: result }, 201);
|
||||
});
|
||||
|
||||
// GET /attachments/:id — serve/download an attachment
|
||||
router.get('/attachments/:id', async (c) => {
|
||||
const attachmentId = c.req.param('id');
|
||||
|
||||
const attachment = await db.query.transactionAttachments.findFirst({
|
||||
where: eq(transactionAttachments.id, attachmentId),
|
||||
});
|
||||
|
||||
if (!attachment) {
|
||||
throw new HTTPException(404, { message: 'Attachment not found' });
|
||||
}
|
||||
|
||||
if (!existsSync(attachment.storage_path)) {
|
||||
throw new HTTPException(404, { message: 'Attachment file not found on disk' });
|
||||
}
|
||||
|
||||
const disposition = c.req.query('download') === 'true' ? 'attachment' : 'inline';
|
||||
const stream = createReadStream(attachment.storage_path);
|
||||
|
||||
return new Response(stream as any, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': attachment.mime_type,
|
||||
'Content-Disposition': `${disposition}; filename="${encodeURIComponent(attachment.filename)}"`,
|
||||
'Content-Length': String(attachment.size_bytes),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// GET /tickets/:id/attachments — list attachments for a ticket
|
||||
router.get('/tickets/:id/attachments', async (c) => {
|
||||
const ticketId = Number(c.req.param('id'));
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
const ticketTransactions = await db.query.transactions.findMany({
|
||||
where: eq(transactions.ticket_id, ticketId),
|
||||
});
|
||||
|
||||
const txIds = ticketTransactions.map((tx) => tx.id);
|
||||
if (txIds.length === 0) {
|
||||
return c.json([]);
|
||||
}
|
||||
|
||||
const attachments = await Promise.all(
|
||||
txIds.map((txId) =>
|
||||
db.query.transactionAttachments.findMany({
|
||||
where: eq(transactionAttachments.transaction_id, txId),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return c.json(attachments.flat());
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
132
src/routes/auth.ts
Normal file
132
src/routes/auth.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { z } from 'zod/v4';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { users, apiTokens } from '../db/schema.ts';
|
||||
import { eq, desc, sql } from 'drizzle-orm';
|
||||
import { createToken, createAuthMiddleware } from '../auth/middleware.ts';
|
||||
|
||||
const LoginSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export function createAuthRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
const { requireAuth } = createAuthMiddleware(db);
|
||||
|
||||
// POST /auth/login
|
||||
router.post('/auth/login', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = LoginSchema.parse(body);
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.username, parsed.username),
|
||||
});
|
||||
|
||||
if (!user || !user.password_hash) {
|
||||
throw new HTTPException(401, { message: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
const valid = await Bun.password.verify(parsed.password, user.password_hash);
|
||||
if (!valid) {
|
||||
throw new HTTPException(401, { message: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
const token = await createToken({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// GET /auth/me — return current user from token
|
||||
router.get('/auth/me', requireAuth, async (c) => {
|
||||
const authUser = c.get('user');
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, authUser.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(404, { message: 'User not found' });
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /auth/tokens — create API token
|
||||
router.post('/auth/tokens', requireAuth, async (c) => {
|
||||
const body = await c.req.json();
|
||||
const name = String(body.name || 'API token').trim();
|
||||
const authUser = c.get('user');
|
||||
|
||||
const rawToken = `tessera_${crypto.randomUUID().replace(/-/g, '')}`;
|
||||
const tokenHash = await Bun.password.hash(rawToken);
|
||||
|
||||
const [token] = await db.insert(apiTokens).values({
|
||||
user_id: authUser.userId,
|
||||
name,
|
||||
token_hash: tokenHash,
|
||||
}).returning();
|
||||
|
||||
if (!token) {
|
||||
throw new HTTPException(500, { message: 'Failed to create token' });
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
token: rawToken,
|
||||
created_at: token.created_at,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// GET /auth/tokens — list tokens
|
||||
router.get('/auth/tokens', requireAuth, async (c) => {
|
||||
const authUser = c.get('user');
|
||||
const result = await db.query.apiTokens.findMany({
|
||||
where: eq(apiTokens.user_id, authUser.userId),
|
||||
orderBy: desc(apiTokens.created_at),
|
||||
});
|
||||
return c.json(result.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
last_used_at: t.last_used_at,
|
||||
created_at: t.created_at,
|
||||
})));
|
||||
});
|
||||
|
||||
// DELETE /auth/tokens/:id — revoke token
|
||||
router.delete('/auth/tokens/:id', requireAuth, async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const authUser = c.get('user');
|
||||
|
||||
// Verify ownership before revoke
|
||||
const allTokens = await db.query.apiTokens.findMany();
|
||||
const existing = allTokens.find((t) => t.id === id && t.user_id === authUser.userId);
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Token not found' });
|
||||
}
|
||||
|
||||
// Raw delete to avoid Drizzle type issue with new apiTokens table
|
||||
await db.execute(sql`DELETE FROM api_tokens WHERE id = ${id}`);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -276,6 +276,30 @@ export function createDashboardsRouter(db: Db): Hono {
|
||||
}
|
||||
}
|
||||
|
||||
// Widget-level filters override or add to view filters
|
||||
const widgetFilters = (widget.config as Record<string, unknown>)?.filters as Array<{ field: string; operator: string; value: string }> | undefined;
|
||||
if (widgetFilters) {
|
||||
for (const f of widgetFilters) {
|
||||
if (f.field === 'status') {
|
||||
if (f.operator === 'is_not') result = result.filter((t) => t.status !== f.value);
|
||||
else result = result.filter((t) => t.status === f.value);
|
||||
} else if (f.field === 'queue') {
|
||||
if (f.operator === 'is_not') result = result.filter((t) => t.queue_id !== f.value);
|
||||
else result = result.filter((t) => t.queue_id === f.value);
|
||||
} else if (f.field === 'owner') {
|
||||
if (f.value === 'unassigned') result = result.filter((t) => !t.owner_id);
|
||||
else result = result.filter((t) => t.owner_id === f.value);
|
||||
} else if (f.field === 'q') {
|
||||
const q = f.value.toLowerCase();
|
||||
result = result.filter((t) =>
|
||||
t.subject.toLowerCase().includes(q) ||
|
||||
String(t.id).includes(q) ||
|
||||
(queueName.get(t.queue_id) ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const limit = (widget.config as Record<string, unknown>)?.limit as number ?? 5;
|
||||
|
||||
// Find lifecycle for status classification
|
||||
@@ -337,6 +361,61 @@ export function createDashboardsRouter(db: Db): Hono {
|
||||
return c.json({ type: 'status_chart', counts, total: result.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'my_tickets': {
|
||||
const authUser = c.get('user');
|
||||
const myTickets = result.filter((t) => t.owner_id === authUser.userId);
|
||||
return c.json({ type: 'my_tickets', total: myTickets.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'trend_chart': {
|
||||
const period = ((widget.config as Record<string, unknown>)?.period as string) ?? 'day';
|
||||
const days = (widget.config as Record<string, unknown>)?.days as number ?? 30;
|
||||
const trendField = ((widget.config as Record<string, unknown>)?.field as string) ?? 'created_at';
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const filtered = result.filter((t) => {
|
||||
const d = trendField === 'updated_at' ? t.updated_at : t.created_at;
|
||||
return d && new Date(d) >= start;
|
||||
});
|
||||
|
||||
const points: Record<string, number> = {};
|
||||
for (const t of filtered) {
|
||||
const d = new Date(trendField === 'updated_at' ? t.updated_at! : t.created_at!);
|
||||
let key: string;
|
||||
if (period === 'week') {
|
||||
const weekStart = new Date(d);
|
||||
weekStart.setDate(d.getDate() - d.getDay());
|
||||
key = weekStart.toISOString().slice(0, 10);
|
||||
} else {
|
||||
key = d.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
}
|
||||
points[key] = (points[key] ?? 0) + 1;
|
||||
}
|
||||
|
||||
return c.json({ type: 'trend_chart', counts: points, total: result.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'overdue': {
|
||||
const dateFieldKey = (widget.config as Record<string, unknown>)?.field_key as string;
|
||||
const now = new Date();
|
||||
const overdue = result.filter((t) => {
|
||||
if (!dateFieldKey) {
|
||||
// No specific field — check if any inactive-adjacent status
|
||||
const lc = lifecycleByQueue.get(t.queue_id);
|
||||
if (lc) {
|
||||
const inactive = lc.statuses.inactive;
|
||||
if (inactive.includes(t.status)) return false; // already resolved
|
||||
}
|
||||
// Check if updated_at is older than 7 days
|
||||
const updated = t.updated_at ? new Date(t.updated_at) : new Date(0);
|
||||
return (now.getTime() - updated.getTime()) > 7 * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
return false; // Would need CF value lookup for date field
|
||||
});
|
||||
return c.json({ type: 'overdue', total: overdue.length, title: widget.title, view_id: view.id });
|
||||
}
|
||||
|
||||
case 'grouped_counts': {
|
||||
const groupBy = (widget.config as Record<string, unknown>)?.group_by as string ?? 'owner';
|
||||
const groups: Record<string, number> = {};
|
||||
|
||||
64
src/routes/notifications.ts
Normal file
64
src/routes/notifications.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { notifications } from '../db/schema.ts';
|
||||
import { and, eq, desc } from 'drizzle-orm';
|
||||
|
||||
export function createNotificationsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// GET /notifications — list notifications for current user
|
||||
router.get('/notifications', async (c) => {
|
||||
const user = c.get('user');
|
||||
const result = await db.query.notifications.findMany({
|
||||
where: eq(notifications.user_id, user.userId),
|
||||
orderBy: desc(notifications.created_at),
|
||||
// Return last 50
|
||||
});
|
||||
return c.json(result.slice(0, 50));
|
||||
});
|
||||
|
||||
// GET /notifications/unread-count
|
||||
router.get('/notifications/unread-count', async (c) => {
|
||||
const user = c.get('user');
|
||||
const result = await db.query.notifications.findMany({
|
||||
where: and(
|
||||
eq(notifications.user_id, user.userId),
|
||||
eq(notifications.read, false),
|
||||
),
|
||||
});
|
||||
return c.json({ count: result.length });
|
||||
});
|
||||
|
||||
// PATCH /notifications/:id/read — mark as read
|
||||
router.patch('/notifications/:id/read', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
await db.update(notifications).set({ read: true }).where(eq(notifications.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// PATCH /notifications/read-all — mark all as read
|
||||
router.patch('/notifications/read-all', async (c) => {
|
||||
const user = c.get('user');
|
||||
await db.update(notifications)
|
||||
.set({ read: true })
|
||||
.where(eq(notifications.user_id, user.userId));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
// Helper to create notifications (used by other routes)
|
||||
export async function createNotification(
|
||||
db: Db,
|
||||
data: { user_id: string; ticket_id?: number; type: string; title: string; body?: string },
|
||||
) {
|
||||
await db.insert(notifications).values({
|
||||
user_id: data.user_id,
|
||||
ticket_id: data.ticket_id ?? null,
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
body: data.body ?? null,
|
||||
});
|
||||
}
|
||||
176
src/routes/queue-permissions.ts
Normal file
176
src/routes/queue-permissions.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { queuePermissions, userPermissions, teams, queues, users } from '../db/schema.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export function createQueuePermissionsRouter(db: Db): Hono {
|
||||
const router = new Hono();
|
||||
|
||||
// GET /queue-permissions — list all permissions (with team + queue names)
|
||||
router.get('/queue-permissions', async (c) => {
|
||||
const all = await db.query.queuePermissions.findMany();
|
||||
|
||||
// Enrich with names
|
||||
const teamIds = [...new Set(all.map((p) => p.team_id))];
|
||||
const queueIds = [...new Set(all.map((p) => p.queue_id))];
|
||||
|
||||
const teamList = teamIds.length > 0
|
||||
? await db.query.teams.findMany({ where: (t, { inArray }) => inArray(t.id, teamIds) })
|
||||
: [];
|
||||
const queueList = queueIds.length > 0
|
||||
? await db.query.queues.findMany({ where: (t, { inArray }) => inArray(t.id, queueIds) })
|
||||
: [];
|
||||
|
||||
const teamById = new Map(teamList.map((t) => [t.id, t]));
|
||||
const queueById = new Map(queueList.map((q) => [q.id, q]));
|
||||
|
||||
const enriched = all.map((p) => ({
|
||||
...p,
|
||||
team_name: teamById.get(p.team_id)?.name ?? p.team_id,
|
||||
queue_name: queueById.get(p.queue_id)?.name ?? p.queue_id,
|
||||
}));
|
||||
|
||||
return c.json(enriched);
|
||||
});
|
||||
|
||||
// GET /queue-permissions/teams-and-queues — return teams and queues for the form
|
||||
router.get('/queue-permissions/teams-and-queues', async (c) => {
|
||||
const [teamList, queueList] = await Promise.all([
|
||||
db.query.teams.findMany(),
|
||||
db.query.queues.findMany(),
|
||||
]);
|
||||
return c.json({ teams: teamList, queues: queueList });
|
||||
});
|
||||
|
||||
// POST /queue-permissions — grant a right
|
||||
router.post('/queue-permissions', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { queue_id, team_id, right_name } = body;
|
||||
|
||||
if (!queue_id || !team_id || !right_name) {
|
||||
throw new HTTPException(422, { message: 'queue_id, team_id, and right_name are required' });
|
||||
}
|
||||
|
||||
const validRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'];
|
||||
if (!validRights.includes(right_name)) {
|
||||
throw new HTTPException(422, { message: `Invalid right. Must be one of: ${validRights.join(', ')}` });
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await db.query.queuePermissions.findFirst({
|
||||
where: (table, { and, eq: eqFn }) =>
|
||||
and(
|
||||
eqFn(table.queue_id, queue_id),
|
||||
eqFn(table.team_id, team_id),
|
||||
eqFn(table.right_name, right_name),
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return c.json(existing); // Idempotent — return existing
|
||||
}
|
||||
|
||||
const [perm] = await db.insert(queuePermissions).values({
|
||||
queue_id,
|
||||
team_id,
|
||||
right_name,
|
||||
}).returning();
|
||||
|
||||
if (!perm) {
|
||||
throw new HTTPException(500, { message: 'Failed to create permission' });
|
||||
}
|
||||
|
||||
return c.json(perm, 201);
|
||||
});
|
||||
|
||||
// DELETE /queue-permissions/:id — revoke a right
|
||||
router.delete('/queue-permissions/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
|
||||
const existing = await db.query.queuePermissions.findFirst({
|
||||
where: eq(queuePermissions.id, id),
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HTTPException(404, { message: 'Permission not found' });
|
||||
}
|
||||
|
||||
await db.delete(queuePermissions).where(eq(queuePermissions.id, id));
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /user-permissions — list all per-user permissions
|
||||
router.get('/user-permissions', async (c) => {
|
||||
const all = await db.query.userPermissions.findMany();
|
||||
|
||||
const userIds = [...new Set(all.map((p) => p.user_id))];
|
||||
const queueIds = [...new Set(all.map((p) => p.queue_id))];
|
||||
|
||||
const userList = userIds.length > 0
|
||||
? await db.query.users.findMany({ where: (t, { inArray }) => inArray(t.id, userIds) })
|
||||
: [];
|
||||
const queueList = queueIds.length > 0
|
||||
? await db.query.queues.findMany({ where: (t, { inArray }) => inArray(t.id, queueIds) })
|
||||
: [];
|
||||
|
||||
const userById = new Map(userList.map((u) => [u.id, u]));
|
||||
const queueById = new Map(queueList.map((q) => [q.id, q]));
|
||||
|
||||
const enriched = all.map((p) => ({
|
||||
...p,
|
||||
username: userById.get(p.user_id)?.username ?? p.user_id,
|
||||
queue_name: queueById.get(p.queue_id)?.name ?? p.queue_id,
|
||||
}));
|
||||
|
||||
return c.json(enriched);
|
||||
});
|
||||
|
||||
// POST /user-permissions — grant a right to a user
|
||||
router.post('/user-permissions', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { queue_id, user_id, right_name } = body;
|
||||
|
||||
if (!queue_id || !user_id || !right_name) {
|
||||
throw new HTTPException(422, { message: 'queue_id, user_id, and right_name are required' });
|
||||
}
|
||||
|
||||
const validRights = ['ticket.view', 'ticket.create', 'ticket.reply', 'ticket.comment', 'ticket.modify', 'queue.admin'];
|
||||
if (!validRights.includes(right_name)) {
|
||||
throw new HTTPException(422, { message: `Invalid right. Must be one of: ${validRights.join(', ')}` });
|
||||
}
|
||||
|
||||
const existing = await db.query.userPermissions.findFirst({
|
||||
where: (table, { and, eq: eqFn }) =>
|
||||
and(
|
||||
eqFn(table.queue_id, queue_id),
|
||||
eqFn(table.user_id, user_id),
|
||||
eqFn(table.right_name, right_name),
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) return c.json(existing);
|
||||
|
||||
const [perm] = await db.insert(userPermissions).values({
|
||||
queue_id,
|
||||
user_id,
|
||||
right_name,
|
||||
}).returning();
|
||||
|
||||
if (!perm) {
|
||||
throw new HTTPException(500, { message: 'Failed to create user permission' });
|
||||
}
|
||||
|
||||
return c.json(perm, 201);
|
||||
});
|
||||
|
||||
// DELETE /user-permissions/:id — revoke a user right
|
||||
router.delete('/user-permissions/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
await db.delete(userPermissions).where(eq(userPermissions.id, id));
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, extname } from 'node:path';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers } from '../db/schema.ts';
|
||||
import { config } from '../config.ts';
|
||||
import { getUserId } from '../auth/middleware.ts';
|
||||
import { requireRight } from '../auth/permissions.ts';
|
||||
import { createNotification } from './notifications.ts';
|
||||
import { tickets, transactions, customFieldValues, customFields, queues, lifecycles, queueCustomFields, teamMembers, transactionAttachments, ticketLinks } from '../db/schema.ts';
|
||||
import { and, eq, asc, or, ilike, isNull, inArray, exists, sql } from 'drizzle-orm';
|
||||
import { CreateTicketSchema, UpdateTicketSchema, CommentSchema } from '../models/ticket.ts';
|
||||
import { ScripEngine } from '../scrip/engine.ts';
|
||||
@@ -22,8 +30,14 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
|
||||
// GET / — list tickets
|
||||
router.get('/', async (c) => {
|
||||
const params = new URL(c.req.url).searchParams;
|
||||
const queueId = c.req.query('queue_id');
|
||||
|
||||
// If filtering by queue, check view permission
|
||||
if (queueId) {
|
||||
await requireRight(c, db, queueId, 'ticket.view');
|
||||
}
|
||||
|
||||
const params = new URL(c.req.url).searchParams;
|
||||
const status = c.req.query('status');
|
||||
const ownerId = c.req.query('owner_id');
|
||||
const teamId = c.req.query('team_id');
|
||||
@@ -63,17 +77,70 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
}
|
||||
}
|
||||
|
||||
// Text search: push to SQL via ilike on ticket columns + queue name join
|
||||
// Subject filter: supports "contains:<text>", "is:<text>", "is_not:<text>", "starts_with:<text>"
|
||||
const subjectFilter = c.req.query('subject');
|
||||
if (subjectFilter) {
|
||||
const idx = subjectFilter.indexOf(':');
|
||||
const op = idx > -1 ? subjectFilter.slice(0, idx) : 'contains';
|
||||
const val = idx > -1 ? subjectFilter.slice(idx + 1) : subjectFilter;
|
||||
if (val) {
|
||||
if (op === 'is') conditions.push(eq(tickets.subject, val));
|
||||
else if (op === 'is_not') conditions.push(sql`${tickets.subject} != ${val}`);
|
||||
else if (op === 'starts_with') conditions.push(ilike(tickets.subject, `${val}%`));
|
||||
else conditions.push(ilike(tickets.subject, `%${val}%`)); // contains
|
||||
}
|
||||
}
|
||||
|
||||
// Date filters: format "before:YYYY-MM-DD" or "after:YYYY-MM-DD"
|
||||
for (const [fieldName, column] of [['created', tickets.created_at], ['updated', tickets.updated_at]] as const) {
|
||||
const dateFilter = c.req.query(fieldName);
|
||||
if (dateFilter) {
|
||||
const idx = dateFilter.indexOf(':');
|
||||
const op = idx > -1 ? dateFilter.slice(0, idx) : 'after';
|
||||
const val = idx > -1 ? dateFilter.slice(idx + 1) : dateFilter;
|
||||
if (val) {
|
||||
if (op === 'before') conditions.push(sql`${column} <= ${val}::timestamptz`);
|
||||
else conditions.push(sql`${column} >= ${val}::timestamptz`); // after
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text search across tickets, transactions, queue names, and custom fields
|
||||
if (query) {
|
||||
const pattern = `%${query}%`;
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(tickets.subject, pattern),
|
||||
ilike(tickets.status, pattern),
|
||||
sql`${tickets.id}::text ILIKE ${pattern}`
|
||||
sql`${tickets.id}::text ILIKE ${pattern}`,
|
||||
// Queue name
|
||||
exists(
|
||||
db.select({ n: sql`1` })
|
||||
.from(queues)
|
||||
.where(and(
|
||||
eq(queues.id, tickets.queue_id),
|
||||
ilike(queues.name, pattern)
|
||||
))
|
||||
),
|
||||
// Transaction bodies (comments, correspondence)
|
||||
exists(
|
||||
db.select({ n: sql`1` })
|
||||
.from(transactions)
|
||||
.where(and(
|
||||
eq(transactions.ticket_id, tickets.id),
|
||||
sql`transactions.data->>'body' ILIKE ${pattern}`
|
||||
))
|
||||
),
|
||||
// Custom field values
|
||||
exists(
|
||||
db.select({ n: sql`1` })
|
||||
.from(customFieldValues)
|
||||
.where(and(
|
||||
eq(customFieldValues.ticket_id, tickets.id),
|
||||
ilike(customFieldValues.value, pattern)
|
||||
))
|
||||
)
|
||||
)!
|
||||
);
|
||||
// Queue name search requires join — keep as post-filter
|
||||
}
|
||||
|
||||
// Custom field filters: use EXISTS subquery
|
||||
@@ -100,19 +167,9 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
limit,
|
||||
});
|
||||
|
||||
// Post-filter for queue name text search (requires in-memory join)
|
||||
let filtered = result;
|
||||
if (query) {
|
||||
const queuesForSearch = await db.query.queues.findMany();
|
||||
const queueNameById = new Map(queuesForSearch.map((queue) => [queue.id, queue.name]));
|
||||
filtered = result.filter((ticket) =>
|
||||
(queueNameById.get(ticket.queue_id) ?? '').toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Attach custom field values to all tickets
|
||||
if (filtered.length > 0) {
|
||||
const ticketIds = filtered.map((t) => t.id);
|
||||
if (result.length > 0) {
|
||||
const ticketIds = result.map((t) => t.id);
|
||||
const allCfValues = await db.query.customFieldValues.findMany({
|
||||
where: (table, { inArray }) => inArray(table.ticket_id, ticketIds),
|
||||
});
|
||||
@@ -124,7 +181,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
: [];
|
||||
const fieldMap = new Map(allFields.map((f) => [f.id, f]));
|
||||
|
||||
const ticketsWithCf = filtered.map((ticket) => {
|
||||
const ticketsWithCf = result.map((ticket) => {
|
||||
const cfs = allCfValues
|
||||
.filter((v) => v.ticket_id === ticket.id)
|
||||
.map((v) => ({
|
||||
@@ -149,14 +206,16 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
return c.json(ticketsWithCf);
|
||||
}
|
||||
|
||||
return c.json(filtered);
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// POST / — create ticket
|
||||
router.post('/', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const parsed = CreateTicketSchema.parse(body);
|
||||
const creatorId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
await requireRight(c, db, parsed.queue_id, 'ticket.create');
|
||||
const creatorId = getUserId(c);
|
||||
const customFieldInput = parsed.custom_fields ?? {};
|
||||
const customFieldEntries = Object.entries(customFieldInput)
|
||||
.map(([fieldId, value]) => [fieldId, value.trim()] as const)
|
||||
@@ -208,6 +267,21 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
throw new HTTPException(422, { message: `${field.name}: value is not an allowed option` });
|
||||
}
|
||||
}
|
||||
if (field.field_type === 'date') {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
throw new HTTPException(422, { message: `${field.name}: value must be a date in YYYY-MM-DD format` });
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
throw new HTTPException(422, { message: `${field.name}: invalid date` });
|
||||
}
|
||||
}
|
||||
if (field.field_type === 'datetime') {
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
throw new HTTPException(422, { message: `${field.name}: value must be a valid ISO 8601 datetime` });
|
||||
}
|
||||
}
|
||||
if (field.pattern) {
|
||||
const regex = new RegExp(field.pattern);
|
||||
if (!regex.test(value)) {
|
||||
@@ -285,6 +359,8 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
await requireRight(c, db, ticket.queue_id, 'ticket.view');
|
||||
|
||||
const cfValues = await db.query.customFieldValues.findMany({
|
||||
where: eq(customFieldValues.ticket_id, id),
|
||||
});
|
||||
@@ -301,10 +377,32 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
custom_field: cfMap.get(v.custom_field_id) ?? null,
|
||||
}));
|
||||
|
||||
return c.json({ ...ticket, custom_fields: customFieldsMapped });
|
||||
});
|
||||
// Blocking dependencies: tickets this one DependsOn that aren't resolved yet
|
||||
const dependsOnLinks = await db.query.ticketLinks.findMany({
|
||||
where: (t, { and, eq: eqFn }) =>
|
||||
and(eqFn(t.ticket_id, id), eqFn(t.link_type, 'DependsOn')),
|
||||
});
|
||||
const blockingIds = dependsOnLinks.map((l) => l.target_ticket_id);
|
||||
let blockedBy: Array<{ id: number; subject: string; status: string }> = [];
|
||||
if (blockingIds.length > 0) {
|
||||
const blockingTickets = await db.query.tickets.findMany({
|
||||
where: (t, { inArray }) => inArray(t.id, blockingIds),
|
||||
});
|
||||
const queue = await db.query.queues.findFirst({
|
||||
where: eq(queues.id, ticket.queue_id),
|
||||
});
|
||||
let inactiveStatuses: string[] = [];
|
||||
if (queue?.lifecycle_id) {
|
||||
const lc = await db.query.lifecycles.findFirst({ where: eq(lifecycles.id, queue.lifecycle_id) });
|
||||
if (lc) inactiveStatuses = (lc.definition as any)?.statuses?.inactive ?? [];
|
||||
}
|
||||
blockedBy = blockingTickets
|
||||
.filter((t) => !inactiveStatuses.includes(t.status))
|
||||
.map((t) => ({ id: t.id, subject: t.subject, status: t.status }));
|
||||
}
|
||||
|
||||
// PATCH /:id — update ticket
|
||||
return c.json({ ...ticket, custom_fields: customFieldsMapped, blocked_by: blockedBy });
|
||||
});
|
||||
router.patch('/:id', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const body = await c.req.json();
|
||||
@@ -318,6 +416,8 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||
|
||||
let lifecycleDef: LifecycleDefinition | null = null;
|
||||
|
||||
// Validate lifecycle transition if status is changing
|
||||
@@ -337,6 +437,34 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
if (!result.valid) {
|
||||
throw new HTTPException(422, { message: result.error ?? 'Invalid transition' });
|
||||
}
|
||||
|
||||
// Check transition-gating right
|
||||
if (result.requiredRight) {
|
||||
await requireRight(c, db, ticket.queue_id, result.requiredRight as any);
|
||||
}
|
||||
|
||||
// Check dependency enforcement: can't resolve/close if this ticket DependsOn unresolved tickets
|
||||
const inactiveStatuses = lifecycleDef.statuses.inactive;
|
||||
if (inactiveStatuses.includes(parsed.status)) {
|
||||
const dependsOnLinks = await db.query.ticketLinks.findMany({
|
||||
where: (t, { and, eq: eqFn }) =>
|
||||
and(
|
||||
eqFn(t.ticket_id, id),
|
||||
eqFn(t.link_type, 'DependsOn'),
|
||||
),
|
||||
});
|
||||
|
||||
for (const link of dependsOnLinks) {
|
||||
const target = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, link.target_ticket_id),
|
||||
});
|
||||
if (target && !inactiveStatuses.includes(target.status)) {
|
||||
throw new HTTPException(422, {
|
||||
message: `Cannot resolve: this ticket depends on ticket ${target.id} (${target.subject}) which is still ${target.status}. Resolve or close that ticket first.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,7 +478,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
field: 'subject',
|
||||
old_value: ticket.subject,
|
||||
new_value: parsed.subject,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
creator_id: getUserId(c),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -361,7 +489,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
field: 'status',
|
||||
old_value: ticket.status,
|
||||
new_value: parsed.status,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
creator_id: getUserId(c),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -372,7 +500,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
field: 'owner_id',
|
||||
old_value: ticket.owner_id ?? null,
|
||||
new_value: parsed.owner_id,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
creator_id: getUserId(c),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -383,7 +511,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
field: 'team_id',
|
||||
old_value: (ticket as any).team_id ?? null,
|
||||
new_value: parsed.team_id,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
creator_id: getUserId(c),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -425,10 +553,24 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
await db.insert(transactions).values(txList as any);
|
||||
}
|
||||
|
||||
// Run scrips
|
||||
const prepared = await scripEngine.prepare(id, txList as any);
|
||||
// Run scrips — use TransactionBatch when multiple changes, TransactionCreate for single
|
||||
const stage = txList.length > 1 ? 'TransactionBatch' as const : 'TransactionCreate' as const;
|
||||
const prepared = await scripEngine.prepare(id, txList as any, stage);
|
||||
const results = await scripEngine.commit(prepared);
|
||||
|
||||
// Notify on assignment change
|
||||
if (parsed.owner_id !== undefined && parsed.owner_id !== ticket.owner_id) {
|
||||
if (parsed.owner_id) {
|
||||
await createNotification(db, {
|
||||
user_id: parsed.owner_id,
|
||||
ticket_id: id,
|
||||
type: 'assigned',
|
||||
title: `You were assigned to ticket ${id}`,
|
||||
body: ticket.subject,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ ticket: updated, scrip_results: results });
|
||||
});
|
||||
|
||||
@@ -446,6 +588,8 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||
|
||||
if (parsed.status) {
|
||||
const queue = await db.query.queues.findFirst({
|
||||
where: eq(queues.id, ticket.queue_id),
|
||||
@@ -470,13 +614,13 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
|
||||
if (parsed.status && parsed.status !== ticket.status) {
|
||||
txList.push({
|
||||
id: '00000000-0000-0000-0000-000000000000',
|
||||
id: getUserId(c),
|
||||
ticket_id: id,
|
||||
transaction_type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: ticket.status,
|
||||
new_value: parsed.status,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
creator_id: getUserId(c),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -487,7 +631,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
return c.json({ prepared_scrips: results });
|
||||
});
|
||||
|
||||
// GET /:id/transactions — list transactions for ticket
|
||||
// GET /:id/transactions — list transactions for ticket (with attachments)
|
||||
router.get('/:id/transactions', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
|
||||
@@ -496,7 +640,105 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
orderBy: asc(transactions.created_at),
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
// Fetch attachments for these transactions
|
||||
const txIds = result.map((tx) => tx.id);
|
||||
if (txIds.length > 0) {
|
||||
const attachments = await db.query.transactionAttachments.findMany({
|
||||
where: inArray(transactionAttachments.transaction_id, txIds),
|
||||
});
|
||||
|
||||
const attachmentsByTxId = new Map<string, typeof attachments>();
|
||||
for (const att of attachments) {
|
||||
if (!att.transaction_id) continue;
|
||||
const list = attachmentsByTxId.get(att.transaction_id);
|
||||
if (list) {
|
||||
list.push(att);
|
||||
} else {
|
||||
attachmentsByTxId.set(att.transaction_id, [att]);
|
||||
}
|
||||
}
|
||||
|
||||
const resultWithAttachments = result.map((tx) => ({
|
||||
...tx,
|
||||
attachments: attachmentsByTxId.get(tx.id) ?? [],
|
||||
}));
|
||||
|
||||
return c.json(resultWithAttachments);
|
||||
}
|
||||
|
||||
return c.json(result.map((tx) => ({ ...tx, attachments: [] })));
|
||||
});
|
||||
|
||||
// POST /:id/attachments — upload file attachments for a ticket
|
||||
router.post('/:id/attachments', async (c) => {
|
||||
const ticketId = Number(c.req.param('id'));
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
await requireRight(c, db, ticket.queue_id, 'ticket.reply');
|
||||
|
||||
const formData = await c.req.formData();
|
||||
const files = formData.getAll('files') as File[];
|
||||
|
||||
if (files.length === 0) {
|
||||
throw new HTTPException(422, { message: 'No files provided' });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const year = now.getFullYear().toString();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const dir = join(config.UPLOAD_DIR, year, month);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
'.txt': 'text/plain', '.html': 'text/html', '.css': 'text/css',
|
||||
'.js': 'application/javascript', '.json': 'application/json', '.xml': 'application/xml',
|
||||
'.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp', '.zip': 'application/zip', '.gz': 'application/gzip',
|
||||
'.csv': 'text/csv', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg',
|
||||
'.md': 'text/markdown', '.yaml': 'text/yaml', '.yml': 'text/yaml',
|
||||
};
|
||||
|
||||
const result: Array<{ id: string; filename: string; mime_type: string; size_bytes: number }> = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!(file instanceof File)) continue;
|
||||
|
||||
const ext = extname(file.name).toLowerCase();
|
||||
const storedName = `${randomUUID()}${ext}`;
|
||||
const storagePath = join(dir, storedName);
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(storagePath, buffer);
|
||||
|
||||
const mimeType = file.type || MIME_MAP[ext] || 'application/octet-stream';
|
||||
|
||||
const [saved] = await db.insert(transactionAttachments).values({
|
||||
filename: file.name,
|
||||
mime_type: mimeType,
|
||||
size_bytes: buffer.length,
|
||||
storage_path: storagePath,
|
||||
}).returning();
|
||||
|
||||
if (saved) {
|
||||
result.push({
|
||||
id: saved.id,
|
||||
filename: saved.filename,
|
||||
mime_type: saved.mime_type,
|
||||
size_bytes: saved.size_bytes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ attachments: result }, 201);
|
||||
});
|
||||
|
||||
// POST /:id/comment — add a comment (reply or internal note)
|
||||
@@ -513,12 +755,23 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
await requireRight(c, db, ticket.queue_id, parsed.internal ? 'ticket.comment' : 'ticket.reply');
|
||||
|
||||
const transactionType = parsed.internal ? 'Comment' : 'Correspond';
|
||||
const attachmentIds = parsed.attachment_ids ?? [];
|
||||
|
||||
const txData: Record<string, unknown> = { body: parsed.body };
|
||||
if (attachmentIds.length > 0) {
|
||||
txData.attachment_ids = attachmentIds;
|
||||
}
|
||||
|
||||
const timeWorked = parsed.time_worked_minutes ?? 0;
|
||||
|
||||
const [tx] = await db.insert(transactions).values({
|
||||
ticket_id: id,
|
||||
transaction_type: transactionType,
|
||||
data: { body: parsed.body },
|
||||
data: txData,
|
||||
time_worked_minutes: timeWorked,
|
||||
creator_id: parsed.creator_id,
|
||||
}).returning();
|
||||
|
||||
@@ -526,14 +779,454 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
throw new HTTPException(500, { message: 'Failed to create comment' });
|
||||
}
|
||||
|
||||
// Link pre-uploaded attachment records to this transaction
|
||||
if (attachmentIds.length > 0) {
|
||||
await db.update(transactionAttachments)
|
||||
.set({ transaction_id: tx.id })
|
||||
.where(inArray(transactionAttachments.id, attachmentIds));
|
||||
}
|
||||
|
||||
// Run scrips
|
||||
const txList = [tx];
|
||||
const prepared = await scripEngine.prepare(id, txList as any);
|
||||
await scripEngine.commit(prepared);
|
||||
|
||||
// Notify ticket owner and creator
|
||||
const commenterId = getUserId(c);
|
||||
const notifyTargets = new Set([ticket.owner_id, ticket.creator_id].filter(Boolean) as string[]);
|
||||
notifyTargets.delete(commenterId);
|
||||
for (const userId of notifyTargets) {
|
||||
await createNotification(db, {
|
||||
user_id: userId,
|
||||
ticket_id: id,
|
||||
type: 'commented',
|
||||
title: `New ${transactionType === 'Comment' ? 'internal note' : 'reply'} on ticket ${id}`,
|
||||
body: parsed.body.slice(0, 200),
|
||||
});
|
||||
}
|
||||
|
||||
return c.json(tx, 201);
|
||||
});
|
||||
|
||||
// GET /:id/links — list links for a ticket (with target ticket info)
|
||||
router.get('/:id/links', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, id),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
await requireRight(c, db, ticket.queue_id, 'ticket.view');
|
||||
|
||||
const links = await db.query.ticketLinks.findMany({
|
||||
where: eq(ticketLinks.ticket_id, id),
|
||||
orderBy: asc(ticketLinks.created_at),
|
||||
});
|
||||
|
||||
// Enrich with target ticket info
|
||||
const targetIds = [...new Set(links.map((l) => l.target_ticket_id))];
|
||||
const targetTickets = targetIds.length > 0
|
||||
? await db.query.tickets.findMany({
|
||||
where: (table, { inArray }) => inArray(table.id, targetIds),
|
||||
})
|
||||
: [];
|
||||
const ticketById = new Map(targetTickets.map((t) => [t.id, t]));
|
||||
|
||||
const enriched = links.map((link) => {
|
||||
const target = ticketById.get(link.target_ticket_id);
|
||||
return {
|
||||
...link,
|
||||
target_ticket: target
|
||||
? { id: target.id, subject: target.subject, status: target.status }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json(enriched);
|
||||
});
|
||||
|
||||
// POST /:id/links — create a link to another ticket
|
||||
router.post('/:id/links', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const body = await c.req.json();
|
||||
const targetTicketId = Number(body.target_ticket_id);
|
||||
const linkType = String(body.link_type || 'RelatedTo');
|
||||
|
||||
if (!targetTicketId || isNaN(targetTicketId)) {
|
||||
throw new HTTPException(422, { message: 'target_ticket_id is required' });
|
||||
}
|
||||
|
||||
if (targetTicketId === id) {
|
||||
throw new HTTPException(422, { message: 'Cannot link a ticket to itself' });
|
||||
}
|
||||
|
||||
const validTypes = ['DependsOn', 'Blocks', 'RefersTo', 'RelatedTo', 'Duplicates', 'MemberOf'];
|
||||
if (!validTypes.includes(linkType)) {
|
||||
throw new HTTPException(422, { message: `Invalid link_type. Must be one of: ${validTypes.join(', ')}` });
|
||||
}
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, id),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||
|
||||
const target = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, targetTicketId),
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new HTTPException(404, { message: 'Target ticket not found' });
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await db.query.ticketLinks.findFirst({
|
||||
where: (table, { and, eq: eqFn }) =>
|
||||
and(
|
||||
eqFn(table.ticket_id, id),
|
||||
eqFn(table.target_ticket_id, targetTicketId),
|
||||
eqFn(table.link_type, linkType),
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new HTTPException(422, { message: 'This link already exists' });
|
||||
}
|
||||
|
||||
const creatorId = body.creator_id || getUserId(c);
|
||||
|
||||
const [link] = await db.insert(ticketLinks).values({
|
||||
ticket_id: id,
|
||||
target_ticket_id: targetTicketId,
|
||||
link_type: linkType,
|
||||
creator_id: creatorId,
|
||||
}).returning();
|
||||
|
||||
if (!link) {
|
||||
throw new HTTPException(500, { message: 'Failed to create link' });
|
||||
}
|
||||
|
||||
// Create transactions on both tickets
|
||||
const linkData = {
|
||||
link_type: linkType,
|
||||
link_id: link.id,
|
||||
target_ticket_id: targetTicketId,
|
||||
target_subject: target.subject,
|
||||
};
|
||||
|
||||
const reverseLinkData = {
|
||||
link_type: linkType,
|
||||
link_id: link.id,
|
||||
target_ticket_id: id,
|
||||
target_subject: ticket.subject,
|
||||
};
|
||||
|
||||
const [txSource] = await db.insert(transactions).values({
|
||||
ticket_id: id,
|
||||
transaction_type: 'LinkCreate',
|
||||
field: linkType,
|
||||
old_value: null,
|
||||
new_value: String(targetTicketId),
|
||||
data: linkData,
|
||||
creator_id: creatorId,
|
||||
}).returning();
|
||||
|
||||
const [txTarget] = await db.insert(transactions).values({
|
||||
ticket_id: targetTicketId,
|
||||
transaction_type: 'LinkCreate',
|
||||
field: linkType,
|
||||
old_value: null,
|
||||
new_value: String(id),
|
||||
data: reverseLinkData,
|
||||
creator_id: creatorId,
|
||||
}).returning();
|
||||
|
||||
// Run scrips on source ticket
|
||||
if (txSource) {
|
||||
const prepared = await scripEngine.prepare(id, [txSource] as any);
|
||||
await scripEngine.commit(prepared);
|
||||
}
|
||||
|
||||
// Run scrips on target ticket
|
||||
if (txTarget) {
|
||||
const prepared = await scripEngine.prepare(targetTicketId, [txTarget] as any);
|
||||
await scripEngine.commit(prepared);
|
||||
}
|
||||
|
||||
// Include target ticket info in response
|
||||
return c.json({
|
||||
...link,
|
||||
target_ticket: { id: target.id, subject: target.subject, status: target.status },
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// DELETE /:id/links/:linkId — remove a link
|
||||
router.delete('/:id/links/:linkId', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const linkId = c.req.param('linkId');
|
||||
|
||||
const ticket = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, id),
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||
|
||||
const link = await db.query.ticketLinks.findFirst({
|
||||
where: eq(ticketLinks.id, linkId),
|
||||
});
|
||||
|
||||
if (!link || link.ticket_id !== id) {
|
||||
throw new HTTPException(404, { message: 'Link not found' });
|
||||
}
|
||||
|
||||
await db.delete(ticketLinks).where(eq(ticketLinks.id, linkId));
|
||||
|
||||
const target = await db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, link.target_ticket_id),
|
||||
});
|
||||
|
||||
const creatorId = getUserId(c);
|
||||
|
||||
const [txSource] = await db.insert(transactions).values({
|
||||
ticket_id: id,
|
||||
transaction_type: 'LinkDelete',
|
||||
field: link.link_type,
|
||||
old_value: String(link.target_ticket_id),
|
||||
new_value: null,
|
||||
data: {
|
||||
link_type: link.link_type,
|
||||
target_ticket_id: link.target_ticket_id,
|
||||
target_subject: target?.subject ?? 'unknown',
|
||||
},
|
||||
creator_id: creatorId,
|
||||
}).returning();
|
||||
|
||||
// Run scrips on source ticket
|
||||
if (txSource) {
|
||||
const prepared = await scripEngine.prepare(id, [txSource] as any);
|
||||
await scripEngine.commit(prepared);
|
||||
}
|
||||
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// POST /:id/merge — merge this ticket into another
|
||||
router.post('/:id/merge', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
const body = await c.req.json();
|
||||
const targetId = Number(body.target_ticket_id);
|
||||
|
||||
if (!targetId || isNaN(targetId) || targetId === id) {
|
||||
throw new HTTPException(422, { message: 'target_ticket_id must be a different ticket ID' });
|
||||
}
|
||||
|
||||
const source = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
|
||||
if (!source) throw new HTTPException(404, { message: 'Source ticket not found' });
|
||||
|
||||
const target = await db.query.tickets.findFirst({ where: eq(tickets.id, targetId) });
|
||||
if (!target) throw new HTTPException(404, { message: 'Target ticket not found' });
|
||||
|
||||
await requireRight(c, db, source.queue_id, 'ticket.modify');
|
||||
await requireRight(c, db, target.queue_id, 'ticket.modify');
|
||||
|
||||
const creatorId = getUserId(c);
|
||||
|
||||
// Move transactions
|
||||
await db.update(transactions)
|
||||
.set({ ticket_id: targetId } as any)
|
||||
.where(eq(transactions.ticket_id, id));
|
||||
|
||||
// Move attachments
|
||||
const sourceTxs = await db.query.transactions.findMany({ where: eq(transactions.ticket_id, id) });
|
||||
// (attachments are linked via transaction_id which stays the same, no-op)
|
||||
|
||||
// Move custom field values
|
||||
const sourceCfs = await db.query.customFieldValues.findMany({
|
||||
where: eq(customFieldValues.ticket_id, id),
|
||||
});
|
||||
for (const cf of sourceCfs) {
|
||||
const existing = await db.query.customFieldValues.findFirst({
|
||||
where: (t, { and, eq: eqFn }) =>
|
||||
and(
|
||||
eqFn(t.custom_field_id, cf.custom_field_id),
|
||||
eqFn(t.ticket_id, targetId),
|
||||
eqFn(t.value, cf.value),
|
||||
),
|
||||
});
|
||||
if (existing) {
|
||||
await db.delete(customFieldValues).where(eq(customFieldValues.id, cf.id));
|
||||
} else {
|
||||
await db.update(customFieldValues)
|
||||
.set({ ticket_id: targetId } as any)
|
||||
.where(eq(customFieldValues.id, cf.id));
|
||||
}
|
||||
}
|
||||
|
||||
// Move ticket links (update source references to target)
|
||||
await db.update(ticketLinks)
|
||||
.set({ ticket_id: targetId } as any)
|
||||
.where(eq(ticketLinks.ticket_id, id));
|
||||
// Update links pointing TO this ticket to point to target instead
|
||||
await db.update(ticketLinks)
|
||||
.set({ target_ticket_id: targetId } as any)
|
||||
.where(eq(ticketLinks.target_ticket_id, id));
|
||||
|
||||
// Close the source ticket
|
||||
await db.update(tickets).set({
|
||||
status: 'closed',
|
||||
updated_at: new Date(),
|
||||
} as any).where(eq(tickets.id, id));
|
||||
|
||||
// Create merge transactions on both tickets
|
||||
await db.insert(transactions).values({
|
||||
ticket_id: targetId,
|
||||
transaction_type: 'Comment',
|
||||
data: { body: `Ticket ${source.id} (${source.subject}) was merged into this ticket.` },
|
||||
creator_id: creatorId,
|
||||
});
|
||||
|
||||
await db.insert(transactions).values({
|
||||
ticket_id: id,
|
||||
transaction_type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: source.status,
|
||||
new_value: 'closed',
|
||||
data: { merged_into: targetId, body: `Merged into ticket ${targetId} (${target.subject}).` },
|
||||
creator_id: creatorId,
|
||||
});
|
||||
|
||||
// Create a duplicate link
|
||||
await db.insert(ticketLinks).values({
|
||||
ticket_id: id,
|
||||
target_ticket_id: targetId,
|
||||
link_type: 'Duplicates',
|
||||
creator_id: creatorId,
|
||||
}).onConflictDoNothing();
|
||||
|
||||
return c.json({ ok: true, target_id: targetId });
|
||||
});
|
||||
|
||||
// POST /batch — bulk update tickets
|
||||
router.post('/batch', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const ticketIds: number[] = (body.ticket_ids ?? []).map(Number).filter((n: number) => !isNaN(n) && n > 0);
|
||||
const { status, owner_id, team_id } = body;
|
||||
|
||||
if (ticketIds.length === 0) {
|
||||
throw new HTTPException(422, { message: 'ticket_ids is required and must be an array of ticket IDs' });
|
||||
}
|
||||
|
||||
if (ticketIds.length > 100) {
|
||||
throw new HTTPException(422, { message: 'Maximum 100 tickets per batch update' });
|
||||
}
|
||||
|
||||
const results: Array<{ id: number; ok: boolean; error?: string }> = [];
|
||||
|
||||
for (const id of ticketIds) {
|
||||
const ticket = await db.query.tickets.findFirst({ where: eq(tickets.id, id) });
|
||||
if (!ticket) {
|
||||
results.push({ id, ok: false, error: 'Ticket not found' });
|
||||
continue;
|
||||
}
|
||||
|
||||
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||
|
||||
try {
|
||||
const txList: any[] = [];
|
||||
const updateData: Record<string, unknown> = { updated_at: new Date() };
|
||||
|
||||
if (status !== undefined && status !== ticket.status) {
|
||||
// Validate lifecycle transition
|
||||
const queue = await db.query.queues.findFirst({ where: eq(queues.id, ticket.queue_id) });
|
||||
if (queue?.lifecycle_id) {
|
||||
const lifecycle = await db.query.lifecycles.findFirst({ where: eq(lifecycles.id, queue.lifecycle_id) });
|
||||
if (lifecycle) {
|
||||
const lifecycleDef = lifecycle.definition as LifecycleDefinition;
|
||||
const result = lifecycleValidator.validateTransition(lifecycleDef, ticket.status, status);
|
||||
if (!result.valid) {
|
||||
results.push({ id, ok: false, error: result.error ?? 'Invalid transition' });
|
||||
continue;
|
||||
}
|
||||
// Dependency enforcement
|
||||
const inactiveStatuses = lifecycleDef.statuses.inactive;
|
||||
if (inactiveStatuses.includes(status)) {
|
||||
const dependsOnLinks = await db.query.ticketLinks.findMany({
|
||||
where: (t, { and, eq: eqFn }) => and(eqFn(t.ticket_id, id), eqFn(t.link_type, 'DependsOn')),
|
||||
});
|
||||
let blocked = false;
|
||||
for (const link of dependsOnLinks) {
|
||||
const target = await db.query.tickets.findFirst({ where: eq(tickets.id, link.target_ticket_id) });
|
||||
if (target && !inactiveStatuses.includes(target.status)) {
|
||||
results.push({ id, ok: false, error: `Blocked by ticket ${target.id} (${target.subject}) — still ${target.status}` });
|
||||
blocked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (blocked) continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
txList.push({
|
||||
ticket_id: id,
|
||||
transaction_type: 'StatusChange',
|
||||
field: 'status',
|
||||
old_value: ticket.status,
|
||||
new_value: status,
|
||||
creator_id: getUserId(c),
|
||||
});
|
||||
updateData.status = status;
|
||||
}
|
||||
|
||||
if (owner_id !== undefined && owner_id !== ticket.owner_id) {
|
||||
txList.push({
|
||||
ticket_id: id,
|
||||
transaction_type: 'SetOwner',
|
||||
field: 'owner_id',
|
||||
old_value: ticket.owner_id ?? null,
|
||||
new_value: owner_id,
|
||||
creator_id: getUserId(c),
|
||||
});
|
||||
updateData.owner_id = owner_id;
|
||||
}
|
||||
|
||||
if (team_id !== undefined && team_id !== (ticket as any).team_id) {
|
||||
txList.push({
|
||||
ticket_id: id,
|
||||
transaction_type: 'SetTeam',
|
||||
field: 'team_id',
|
||||
old_value: (ticket as any).team_id ?? null,
|
||||
new_value: team_id,
|
||||
creator_id: getUserId(c),
|
||||
});
|
||||
updateData.team_id = team_id;
|
||||
}
|
||||
|
||||
await db.update(tickets).set(updateData as any).where(eq(tickets.id, id));
|
||||
if (txList.length > 0) {
|
||||
await db.insert(transactions).values(txList as any);
|
||||
}
|
||||
|
||||
results.push({ id, ok: true });
|
||||
} catch (err) {
|
||||
results.push({ id, ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ results });
|
||||
});
|
||||
|
||||
// PATCH /:id/custom-fields/:fieldId — set or clear a custom field value
|
||||
router.patch('/:id/custom-fields/:fieldId', async (c) => {
|
||||
const id = Number(c.req.param('id'));
|
||||
@@ -549,6 +1242,8 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
throw new HTTPException(404, { message: 'Ticket not found' });
|
||||
}
|
||||
|
||||
await requireRight(c, db, ticket.queue_id, 'ticket.modify');
|
||||
|
||||
const assignment = await db.query.queueCustomFields.findFirst({
|
||||
where: and(
|
||||
eq(queueCustomFields.queue_id, ticket.queue_id),
|
||||
@@ -575,6 +1270,22 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
}
|
||||
}
|
||||
|
||||
if (value && field.field_type === 'date') {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
throw new HTTPException(422, { message: `${field.name}: value must be a date in YYYY-MM-DD format` });
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
throw new HTTPException(422, { message: `${field.name}: invalid date` });
|
||||
}
|
||||
}
|
||||
if (value && field.field_type === 'datetime') {
|
||||
const parsed = new Date(value);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
throw new HTTPException(422, { message: `${field.name}: value must be a valid ISO 8601 datetime` });
|
||||
}
|
||||
}
|
||||
|
||||
if (value && field.pattern) {
|
||||
const regex = new RegExp(field.pattern);
|
||||
if (!regex.test(value)) {
|
||||
@@ -613,7 +1324,7 @@ export function createTicketsRouter(db: Db): Hono {
|
||||
field: field.key,
|
||||
old_value: oldValue || null,
|
||||
new_value: value || null,
|
||||
creator_id: '00000000-0000-0000-0000-000000000000',
|
||||
creator_id: getUserId(c),
|
||||
}).returning();
|
||||
|
||||
const prepared = await scripEngine.prepare(id, [tx] as any);
|
||||
|
||||
@@ -18,6 +18,8 @@ export function createUsersRouter(db: Db): Hono {
|
||||
const body = await c.req.json();
|
||||
const username = String(body.username ?? '').trim();
|
||||
const email = body.email ? String(body.email).trim() : null;
|
||||
const role = body.role ? String(body.role) : 'staff';
|
||||
const password = body.password ? String(body.password).trim() : null;
|
||||
|
||||
if (!username) {
|
||||
throw new HTTPException(400, { message: 'username is required' });
|
||||
@@ -26,6 +28,8 @@ export function createUsersRouter(db: Db): Hono {
|
||||
const [user] = await db.insert(users).values({
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
password_hash: password ? await Bun.password.hash(password) : null,
|
||||
}).returning();
|
||||
|
||||
if (!user) {
|
||||
@@ -50,6 +54,10 @@ export function createUsersRouter(db: Db): Hono {
|
||||
const updateData: Partial<typeof users.$inferInsert> = {};
|
||||
if (body.username !== undefined) updateData.username = String(body.username).trim();
|
||||
if (body.email !== undefined) updateData.email = body.email ? String(body.email).trim() : null;
|
||||
if (body.role !== undefined) updateData.role = String(body.role);
|
||||
if (body.password !== undefined && String(body.password).trim()) {
|
||||
updateData.password_hash = await Bun.password.hash(String(body.password).trim());
|
||||
}
|
||||
|
||||
const [updated] = await db.update(users)
|
||||
.set(updateData)
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { LifecycleDefinition } from '../lifecycle/validator.ts';
|
||||
|
||||
export interface ConditionEvaluateContext {
|
||||
lifecycleDef?: LifecycleDefinition;
|
||||
customFields?: Record<string, string>; // key → value map of CF values
|
||||
}
|
||||
|
||||
export interface ConditionConfig {
|
||||
@@ -16,6 +17,7 @@ export interface ConditionConfig {
|
||||
old_value?: unknown;
|
||||
new_value?: unknown;
|
||||
value?: unknown;
|
||||
link_type?: unknown;
|
||||
}
|
||||
|
||||
export interface ConditionEvaluator {
|
||||
@@ -82,11 +84,52 @@ export class OnCustomFieldChange implements ConditionEvaluator {
|
||||
}
|
||||
}
|
||||
|
||||
export class OnLinkCreate implements ConditionEvaluator {
|
||||
evaluate(_ticket: Ticket, transactions: Transaction[], _context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||
return transactions.some((tx) => {
|
||||
if (tx.transaction_type !== 'LinkCreate') return false;
|
||||
if (config?.link_type) {
|
||||
const linkType = tx.field;
|
||||
if (!matchesStatusFilter(linkType, config.link_type)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class OnOverdue implements ConditionEvaluator {
|
||||
evaluate(_ticket: Ticket, _transactions: Transaction[], context?: ConditionEvaluateContext, config?: ConditionConfig): boolean {
|
||||
const fieldKey = config?.field_key ?? config?.field_id ?? config?.field;
|
||||
if (!fieldKey) return false;
|
||||
|
||||
const cfValue = context?.customFields?.[String(fieldKey)];
|
||||
if (!cfValue) return false;
|
||||
|
||||
// Parse the date value
|
||||
const dueDate = new Date(cfValue);
|
||||
if (isNaN(dueDate.getTime())) return false;
|
||||
|
||||
// Check if overdue (past due date)
|
||||
if (new Date() <= dueDate) return false;
|
||||
|
||||
// Check that ticket is still active (not in inactive state)
|
||||
const lifecycleDef = context?.lifecycleDef;
|
||||
if (lifecycleDef) {
|
||||
const inactiveStates = lifecycleDef.statuses.inactive;
|
||||
if (inactiveStates.includes(_ticket.status)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const conditionRegistry: Record<string, ConditionEvaluator> = {
|
||||
OnCreate: new OnCreate(),
|
||||
OnStatusChange: new OnStatusChange(),
|
||||
OnResolve: new OnResolve(),
|
||||
OnCustomFieldChange: new OnCustomFieldChange(),
|
||||
OnLinkCreate: new OnLinkCreate(),
|
||||
OnOverdue: new OnOverdue(),
|
||||
};
|
||||
|
||||
export function getConditionEvaluator(type: string): ConditionEvaluator | null {
|
||||
|
||||
@@ -37,6 +37,7 @@ export class ScripEngine {
|
||||
async prepare(
|
||||
ticketId: number,
|
||||
transactions: Transaction[],
|
||||
stage: 'TransactionCreate' | 'TransactionBatch' = 'TransactionCreate',
|
||||
): Promise<PreparedScrip[]> {
|
||||
const ticketRecord = await this.db.query.tickets.findFirst({
|
||||
where: eq(tickets.id, ticketId),
|
||||
@@ -53,6 +54,15 @@ export class ScripEngine {
|
||||
const matchingScrips = allScrips.filter((scrip) => {
|
||||
if (scrip.disabled) return false;
|
||||
if (scrip.queue_id !== null && scrip.queue_id !== ticketRecord.queue_id) return false;
|
||||
if (scrip.stage !== stage) return false;
|
||||
// Filter by applicable transaction types — if set, at least one tx must match
|
||||
if (scrip.applicable_trans_types) {
|
||||
const types = scrip.applicable_trans_types.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
if (types.length > 0 && !types.includes('Any')) {
|
||||
const txTypes = new Set(transactions.map((tx) => tx.transaction_type));
|
||||
if (!types.some((t) => txTypes.has(t))) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -70,10 +80,6 @@ export class ScripEngine {
|
||||
}
|
||||
}
|
||||
|
||||
const conditionContext: ConditionEvaluateContext = {
|
||||
lifecycleDef,
|
||||
};
|
||||
|
||||
const cfValues = await this.db.query.customFieldValues.findMany({
|
||||
where: eq(customFieldValues.ticket_id, ticketId),
|
||||
});
|
||||
@@ -94,6 +100,11 @@ export class ScripEngine {
|
||||
}
|
||||
}
|
||||
|
||||
const conditionContext: ConditionEvaluateContext = {
|
||||
lifecycleDef,
|
||||
customFields: customFieldsMap,
|
||||
};
|
||||
|
||||
const prepared: PreparedScrip[] = [];
|
||||
|
||||
for (const scrip of matchingScrips) {
|
||||
|
||||
92
src/scrip/scheduler.ts
Normal file
92
src/scrip/scheduler.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { Db } from '../db/index.ts';
|
||||
import { transactions, tickets, queues, lifecycles } from '../db/schema.ts';
|
||||
import { eq, and, isNull } from 'drizzle-orm';
|
||||
import { ScripEngine } from './engine.ts';
|
||||
|
||||
const SYSTEM_USER = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
/**
|
||||
* Run scheduled scrips against all active tickets.
|
||||
* Creates a synthetic "Scheduled" transaction so conditions like OnOverdue can fire.
|
||||
*/
|
||||
export async function runScheduledScrips(db: Db): Promise<{ checked: number; fired: number }> {
|
||||
const engine = new ScripEngine(db);
|
||||
|
||||
// Get all lifecycles to determine inactive statuses
|
||||
const allLifecycles = await db.query.lifecycles.findMany();
|
||||
const inactiveByQueue = new Map<string, Set<string>>();
|
||||
|
||||
// Get all queues with lifecycles
|
||||
const allQueues = await db.query.queues.findMany();
|
||||
for (const q of allQueues) {
|
||||
if (q.lifecycle_id) {
|
||||
const lc = allLifecycles.find((l) => l.id === q.lifecycle_id);
|
||||
if (lc) {
|
||||
const def = lc.definition as any;
|
||||
inactiveByQueue.set(q.id, new Set(def?.statuses?.inactive ?? ['resolved', 'closed']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find all potentially active tickets
|
||||
const allTickets = await db.query.tickets.findMany();
|
||||
const active = allTickets.filter((t) => {
|
||||
const inactive = inactiveByQueue.get(t.queue_id);
|
||||
if (inactive) return !inactive.has(t.status);
|
||||
return !['resolved', 'closed'].includes(t.status);
|
||||
});
|
||||
|
||||
let fired = 0;
|
||||
|
||||
for (const ticket of active) {
|
||||
try {
|
||||
// Create a synthetic Scheduled transaction
|
||||
const [tx] = await db.insert(transactions).values({
|
||||
ticket_id: ticket.id,
|
||||
transaction_type: 'Comment' as any,
|
||||
field: 'scheduled',
|
||||
data: { body: 'Scheduled scrip evaluation' },
|
||||
creator_id: SYSTEM_USER,
|
||||
} as any).returning();
|
||||
|
||||
if (!tx) continue;
|
||||
|
||||
// Run scrips
|
||||
const prepared = await engine.prepare(ticket.id, [tx as any]);
|
||||
if (prepared.length > 0) {
|
||||
const results = await engine.commit(prepared);
|
||||
const successes = results.filter((r) => r.success);
|
||||
if (successes.length > 0) fired += successes.length;
|
||||
}
|
||||
} catch (err) {
|
||||
// Log and continue — don't let one failing ticket block the scheduler
|
||||
console.error(`[scheduler] Error processing ticket ${ticket.id}:`, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return { checked: active.length, fired };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the background scheduler. Runs every `intervalMinutes` minutes.
|
||||
*/
|
||||
export function startScheduler(db: Db, intervalMinutes = 5) {
|
||||
console.log(`[scheduler] Starting scrip scheduler (every ${intervalMinutes}m)`);
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const result = await runScheduledScrips(db);
|
||||
if (result.fired > 0) {
|
||||
console.log(`[scheduler] Checked ${result.checked} tickets, fired ${result.fired} scrip actions`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[scheduler] Error:', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
// Run once at startup after a short delay
|
||||
setTimeout(run, 10000);
|
||||
|
||||
// Then run on interval
|
||||
setInterval(run, intervalMinutes * 60 * 1000);
|
||||
}
|
||||
Reference in New Issue
Block a user