From 2b46db90ce0e9d2391f68f480b57885473865f53 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Tue, 10 Feb 2026 00:41:31 -0500 Subject: [PATCH] feat: add REST API layer with token auth, rate limiting, and full ticket CRUD Adds SHA-256 hashed API tokens, Bearer auth middleware, per-token rate limiting, and complete JSON API for tickets, dashboard, agents, departments, tags, canned responses, and macros. Includes Inertia admin UI for token management. All business logic reuses existing service classes. --- config/escalated.ts | 16 + .../0017_create_escalated_api_tokens.ts | 27 ++ index.ts | 5 + providers/escalated_provider.ts | 2 + .../admin_api_tokens_controller.ts | 127 ++++++ src/controllers/api/api_auth_controller.ts | 36 ++ .../api/api_dashboard_controller.ts | 107 +++++ .../api/api_resource_controller.ts | 127 ++++++ src/controllers/api/api_ticket_controller.ts | 387 ++++++++++++++++++ src/middleware/api_rate_limit.ts | 77 ++++ src/middleware/authenticate_api_token.ts | 67 +++ src/models/api_token.ts | 129 ++++++ src/types.ts | 7 + start/routes.ts | 68 +++ 14 files changed, 1182 insertions(+) create mode 100644 database/migrations/0017_create_escalated_api_tokens.ts create mode 100644 src/controllers/admin_api_tokens_controller.ts create mode 100644 src/controllers/api/api_auth_controller.ts create mode 100644 src/controllers/api/api_dashboard_controller.ts create mode 100644 src/controllers/api/api_resource_controller.ts create mode 100644 src/controllers/api/api_ticket_controller.ts create mode 100644 src/middleware/api_rate_limit.ts create mode 100644 src/middleware/authenticate_api_token.ts create mode 100644 src/models/api_token.ts diff --git a/config/escalated.ts b/config/escalated.ts index aa867c2..ece4ef7 100644 --- a/config/escalated.ts +++ b/config/escalated.ts @@ -163,6 +163,22 @@ const escalatedConfig: EscalatedConfig = { retentionDays: 90, }, + /* + |-------------------------------------------------------------------------- + | REST API + |-------------------------------------------------------------------------- + | + | Enable the REST API to allow external integrations and automation. + | Tokens are managed via the admin panel. + | + */ + api: { + enabled: !!env.get('ESCALATED_API_ENABLED', false), + rateLimit: Number(env.get('ESCALATED_API_RATE_LIMIT', '60')), + tokenExpiryDays: null as number | null, + prefix: 'support/api/v1', + }, + /* |-------------------------------------------------------------------------- | Inbound Email diff --git a/database/migrations/0017_create_escalated_api_tokens.ts b/database/migrations/0017_create_escalated_api_tokens.ts new file mode 100644 index 0000000..9862546 --- /dev/null +++ b/database/migrations/0017_create_escalated_api_tokens.ts @@ -0,0 +1,27 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class CreateEscalatedApiTokens extends BaseSchema { + protected tableName = 'escalated_api_tokens' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.increments('id') + table.string('tokenable_type').notNullable() + table.integer('tokenable_id').unsigned().notNullable() + table.string('name').notNullable() + table.string('token', 64).unique().notNullable() + table.json('abilities').nullable() + table.timestamp('last_used_at', { useTz: true }).nullable() + table.string('last_used_ip', 45).nullable() + table.timestamp('expires_at', { useTz: true }).nullable() + table.timestamp('created_at', { useTz: true }).notNullable() + table.timestamp('updated_at', { useTz: true }).notNullable() + + table.index(['tokenable_type', 'tokenable_id']) + }) + } + + async down() { + this.schema.dropTableIfExists(this.tableName) + } +} diff --git a/index.ts b/index.ts index 04060c6..684277b 100644 --- a/index.ts +++ b/index.ts @@ -15,3 +15,8 @@ export * from './src/types.js' // Re-export events export * from './src/events/index.js' + +// Re-export API model and middleware +export { default as ApiToken } from './src/models/api_token.js' +export { default as AuthenticateApiToken } from './src/middleware/authenticate_api_token.js' +export { default as ApiRateLimit } from './src/middleware/api_rate_limit.js' diff --git a/providers/escalated_provider.ts b/providers/escalated_provider.ts index 7c2280d..9a8da11 100644 --- a/providers/escalated_provider.ts +++ b/providers/escalated_provider.ts @@ -82,6 +82,8 @@ export default class EscalatedProvider { /** * Register routes if enabled. + * API routes are conditionally loaded within registerRoutes() when + * the `api.enabled` config flag is true. */ protected async registerRoutes() { const config: EscalatedConfig = (globalThis as any).__escalated_config ?? {} diff --git a/src/controllers/admin_api_tokens_controller.ts b/src/controllers/admin_api_tokens_controller.ts new file mode 100644 index 0000000..deb3009 --- /dev/null +++ b/src/controllers/admin_api_tokens_controller.ts @@ -0,0 +1,127 @@ +import { DateTime } from 'luxon' +import type { HttpContext } from '@adonisjs/core/http' +import ApiToken from '../models/api_token.js' +import { getConfig } from '../helpers/config.js' + +export default class AdminApiTokensController { + /** + * GET /support/admin/api-tokens — List all API tokens (Inertia page) + */ + async index({ inertia }: HttpContext) { + const tokens = await ApiToken.query().orderBy('created_at', 'desc') + + const tokenData = await Promise.all( + tokens.map(async (token) => { + const owner = await token.loadTokenable() + return { + id: token.id, + name: token.name, + user_name: owner?.name ?? owner?.fullName ?? null, + user_email: owner?.email ?? null, + abilities: token.abilities, + last_used_at: token.lastUsedAt?.toISO() ?? null, + last_used_ip: token.lastUsedIp, + expires_at: token.expiresAt?.toISO() ?? null, + is_expired: token.isExpired(), + created_at: token.createdAt.toISO(), + } + }) + ) + + const users = await this.getAgentUsers() + const config = getConfig() as any + + return inertia.render('Escalated/Admin/ApiTokens/Index', { + tokens: tokenData, + users, + api_enabled: config.api?.enabled ?? false, + }) + } + + /** + * POST /support/admin/api-tokens — Create a new API token + */ + async store(ctx: HttpContext) { + const { name, user_id, abilities, expires_in_days } = ctx.request.only([ + 'name', 'user_id', 'abilities', 'expires_in_days', + ]) + + if (!name || !user_id || !abilities || !Array.isArray(abilities)) { + ctx.session.flash('error', 'Name, user, and abilities are required.') + return ctx.response.redirect().back() + } + + // Load the user model + const config = (globalThis as any).__escalated_config + const userModelPath = config?.userModel ?? '#models/user' + const { default: UserModel } = await import(userModelPath) + const user = await UserModel.findOrFail(user_id) + + const expiresAt = expires_in_days + ? DateTime.now().plus({ days: Number(expires_in_days) }) + : null + + const result = await ApiToken.createToken(user, name, abilities, expiresAt) + + ctx.session.flash('success', 'API token created.') + ctx.session.flash('plain_text_token', result.plainTextToken) + return ctx.response.redirect().back() + } + + /** + * PUT /support/admin/api-tokens/:id — Update a token's name/abilities + */ + async update(ctx: HttpContext) { + const token = await ApiToken.findOrFail(ctx.params.id) + const data = ctx.request.only(['name', 'abilities']) + + if (data.name !== undefined) { + token.name = data.name + } + if (data.abilities !== undefined && Array.isArray(data.abilities)) { + token.abilities = data.abilities + } + + await token.save() + + ctx.session.flash('success', 'Token updated.') + return ctx.response.redirect().back() + } + + /** + * DELETE /support/admin/api-tokens/:id — Revoke (delete) a token + */ + async destroy(ctx: HttpContext) { + const token = await ApiToken.findOrFail(ctx.params.id) + await token.delete() + + ctx.session.flash('success', 'Token revoked.') + return ctx.response.redirect().back() + } + + // ---- Private Helpers ---- + + /** + * Get all users who pass the agent or admin gate. + */ + protected async getAgentUsers(): Promise<{ id: number; name: string; email: string }[]> { + const config = (globalThis as any).__escalated_config + try { + const userModelPath = config?.userModel ?? '#models/user' + const { default: UserModel } = await import(userModelPath) + const users = await UserModel.all() + + const agents: { id: number; name: string; email: string }[] = [] + for (const user of users) { + const isAgent = config?.authorization?.isAgent ? await config.authorization.isAgent(user) : false + const isAdmin = config?.authorization?.isAdmin ? await config.authorization.isAdmin(user) : false + if (isAgent || isAdmin) { + agents.push({ id: user.id, name: user.name ?? user.fullName ?? '', email: user.email ?? '' }) + } + } + return agents + } catch { + return [] + } + } +} diff --git a/src/controllers/api/api_auth_controller.ts b/src/controllers/api/api_auth_controller.ts new file mode 100644 index 0000000..b439978 --- /dev/null +++ b/src/controllers/api/api_auth_controller.ts @@ -0,0 +1,36 @@ +import type { HttpContext } from '@adonisjs/core/http' + +export default class ApiAuthController { + /** + * POST /auth/validate — Validate token and return user info + */ + async validate(ctx: HttpContext) { + const user = (ctx as any).auth?.user + const apiToken = (ctx as any).apiToken + + const config = (globalThis as any).__escalated_config + + let isAgent = false + let isAdmin = false + + if (config?.authorization?.isAgent) { + isAgent = await config.authorization.isAgent(user) + } + if (config?.authorization?.isAdmin) { + isAdmin = await config.authorization.isAdmin(user) + } + + return ctx.response.json({ + user: { + id: user.id, + name: user.name ?? user.fullName ?? '', + email: user.email ?? '', + }, + abilities: apiToken.abilities ?? [], + is_agent: isAgent, + is_admin: isAdmin, + token_name: apiToken.name, + expires_at: apiToken.expiresAt?.toISO() ?? null, + }) + } +} diff --git a/src/controllers/api/api_dashboard_controller.ts b/src/controllers/api/api_dashboard_controller.ts new file mode 100644 index 0000000..f3f6de5 --- /dev/null +++ b/src/controllers/api/api_dashboard_controller.ts @@ -0,0 +1,107 @@ +import { DateTime } from 'luxon' +import type { HttpContext } from '@adonisjs/core/http' +import Ticket from '../../models/ticket.js' + +export default class ApiDashboardController { + /** + * GET /dashboard — Agent dashboard stats as JSON + */ + async handle(ctx: HttpContext) { + const userId = (ctx as any).auth.user.id + const startOfDay = DateTime.now().startOf('day').toSQL()! + const startOfWeek = DateTime.now().startOf('week').toSQL()! + + const openCount = await Ticket.query() + .whereNotIn('status', ['resolved', 'closed']) + .count('* as total') + .first() + + const myAssignedCount = await Ticket.query() + .where('assigned_to', userId) + .whereNotIn('status', ['resolved', 'closed']) + .count('* as total') + .first() + + const unassignedCount = await Ticket.query() + .whereNull('assigned_to') + .whereNotIn('status', ['resolved', 'closed']) + .count('* as total') + .first() + + const slaBreachedCount = await Ticket.query() + .whereNotIn('status', ['resolved', 'closed']) + .where((q) => { + q.where('sla_first_response_breached', true) + .orWhere('sla_resolution_breached', true) + }) + .count('* as total') + .first() + + const resolvedTodayCount = await Ticket.query() + .where('resolved_at', '>=', startOfDay) + .count('* as total') + .first() + + const recentTickets = await Ticket.query() + .preload('department') + .orderBy('created_at', 'desc') + .limit(10) + + const slaBreaching = await Ticket.query() + .whereNotIn('status', ['resolved', 'closed']) + .where((q) => { + q.where('sla_first_response_breached', true) + .orWhere('sla_resolution_breached', true) + }) + .limit(5) + + const unassignedUrgent = await Ticket.query() + .whereNull('assigned_to') + .whereNotIn('status', ['resolved', 'closed']) + .whereIn('priority', ['urgent', 'critical']) + .limit(5) + + const resolvedThisWeekCount = await Ticket.query() + .where('assigned_to', userId) + .where('resolved_at', '>=', startOfWeek) + .count('* as total') + .first() + + return ctx.response.json({ + stats: { + open: Number((openCount as any)?.$extras?.total ?? 0), + my_assigned: Number((myAssignedCount as any)?.$extras?.total ?? 0), + unassigned: Number((unassignedCount as any)?.$extras?.total ?? 0), + sla_breached: Number((slaBreachedCount as any)?.$extras?.total ?? 0), + resolved_today: Number((resolvedTodayCount as any)?.$extras?.total ?? 0), + }, + recent_tickets: recentTickets.map((t) => ({ + id: t.id, + reference: t.reference, + subject: t.subject, + status: t.status, + priority: t.priority, + requester_name: t.requesterName, + assignee_name: null, // Assignee loaded separately in full implementation + created_at: t.createdAt.toISO(), + })), + needs_attention: { + sla_breaching: slaBreaching.map((t) => ({ + reference: t.reference, + subject: t.subject, + priority: t.priority, + requester_name: t.requesterName, + })), + unassigned_urgent: unassignedUrgent.map((t) => ({ + reference: t.reference, + subject: t.subject, + priority: t.priority, + requester_name: t.requesterName, + })), + }, + my_performance: { + resolved_this_week: Number((resolvedThisWeekCount as any)?.$extras?.total ?? 0), + }, + }) + } +} diff --git a/src/controllers/api/api_resource_controller.ts b/src/controllers/api/api_resource_controller.ts new file mode 100644 index 0000000..4b95d91 --- /dev/null +++ b/src/controllers/api/api_resource_controller.ts @@ -0,0 +1,127 @@ +import type { HttpContext } from '@adonisjs/core/http' +import Department from '../../models/department.js' +import Tag from '../../models/tag.js' +import CannedResponse from '../../models/canned_response.js' +import Macro from '../../models/macro.js' + +export default class ApiResourceController { + /** + * GET /agents — List all users who are agents or admins + */ + async agents(ctx: HttpContext) { + const agents = await this.getAgents() + + return ctx.response.json({ + data: agents.map((a) => ({ + id: a.id, + name: a.name, + email: a.email, + })), + }) + } + + /** + * GET /departments — List active departments + */ + async departments(ctx: HttpContext) { + const departments = await Department.query() + .withScopes((scopes) => scopes.active()) + + return ctx.response.json({ + data: departments.map((d) => ({ + id: d.id, + name: d.name, + description: d.description, + is_active: d.isActive, + })), + }) + } + + /** + * GET /tags — List all tags + */ + async tags(ctx: HttpContext) { + const tags = await Tag.query() + + return ctx.response.json({ + data: tags.map((t) => ({ + id: t.id, + name: t.name, + color: t.color, + })), + }) + } + + /** + * GET /canned-responses — List canned responses for the authenticated agent + */ + async cannedResponses(ctx: HttpContext) { + const userId = (ctx as any).auth.user.id + + const responses = await CannedResponse.query() + .withScopes((scopes) => scopes.forAgent(userId)) + + return ctx.response.json({ + data: responses.map((r) => ({ + id: r.id, + title: r.title, + body: r.body, + })), + }) + } + + /** + * GET /macros — List macros for the authenticated agent + */ + async macros(ctx: HttpContext) { + const userId = (ctx as any).auth.user.id + + const macros = await Macro.query() + .withScopes((scopes) => scopes.forAgent(userId)) + .orderBy('order') + + return ctx.response.json({ + data: macros.map((m) => ({ + id: m.id, + name: m.name, + actions: m.actions, + order: m.order, + })), + }) + } + + /** + * GET /realtime/config — Return WebSocket configuration if available + */ + async realtimeConfig(ctx: HttpContext) { + // In Adonis, broadcasting config would need to come from the host app + // Return null if not configured + return ctx.response.json(null) + } + + // ---- Private Helpers ---- + + /** + * Get all users who pass the agent or admin gate. + */ + protected async getAgents(): Promise<{ id: number; name: string; email: string }[]> { + const config = (globalThis as any).__escalated_config + try { + const userModelPath = config?.userModel ?? '#models/user' + const { default: UserModel } = await import(userModelPath) + const users = await UserModel.all() + + const agents: { id: number; name: string; email: string }[] = [] + for (const user of users) { + const isAgent = config?.authorization?.isAgent ? await config.authorization.isAgent(user) : false + const isAdmin = config?.authorization?.isAdmin ? await config.authorization.isAdmin(user) : false + if (isAgent || isAdmin) { + agents.push({ id: user.id, name: user.name ?? user.fullName ?? '', email: user.email ?? '' }) + } + } + return agents + } catch { + return [] + } + } +} diff --git a/src/controllers/api/api_ticket_controller.ts b/src/controllers/api/api_ticket_controller.ts new file mode 100644 index 0000000..6ee4406 --- /dev/null +++ b/src/controllers/api/api_ticket_controller.ts @@ -0,0 +1,387 @@ +import type { HttpContext } from '@adonisjs/core/http' +import Ticket from '../../models/ticket.js' +import Tag from '../../models/tag.js' +import Macro from '../../models/macro.js' +import TicketService from '../../services/ticket_service.js' +import AssignmentService from '../../services/assignment_service.js' +import MacroService from '../../services/macro_service.js' +import { STATUS_LABELS, PRIORITY_LABELS } from '../../types.js' +import type { TicketStatus, TicketPriority } from '../../types.js' + +export default class ApiTicketController { + protected ticketService = new TicketService() + protected assignmentService = new AssignmentService() + + /** + * GET /tickets — List tickets with pagination and filtering + */ + async index(ctx: HttpContext) { + const filters = ctx.request.only([ + 'status', 'priority', 'assigned_to', 'unassigned', 'department_id', + 'search', 'sla_breached', 'tag_ids', 'sort_by', 'sort_dir', 'per_page', 'following', + ]) + + const user = (ctx as any).auth?.user ?? null + const tickets = await this.ticketService.list( + filters, + filters.following ? user : null, + ) + + return ctx.response.json({ + data: tickets.all().map((t: Ticket) => this.formatTicketCollection(t)), + meta: { + current_page: tickets.currentPage, + last_page: tickets.lastPage, + per_page: tickets.perPage, + total: tickets.total, + }, + }) + } + + /** + * GET /tickets/:ticket — Show ticket detail + */ + async show(ctx: HttpContext) { + const ticket = (ctx as any).escalatedTicket as Ticket + + await ticket.load('department') + await ticket.load('tags') + await ticket.load('satisfactionRating') + await ticket.load((loader: any) => { + loader.load('replies', (query: any) => { + query.orderBy('created_at', 'desc') + }) + loader.load('activities', (query: any) => { + query.orderBy('created_at', 'desc').limit(20) + }) + }) + + return ctx.response.json({ + data: this.formatTicketDetail(ticket), + }) + } + + /** + * POST /tickets — Create a new ticket + */ + async store(ctx: HttpContext) { + const user = (ctx as any).auth.user + const data = ctx.request.only(['subject', 'description', 'priority', 'department_id', 'tags']) + + if (!data.subject || !data.description) { + return ctx.response.unprocessableEntity({ + message: 'Validation failed.', + errors: { + ...(!data.subject ? { subject: ['The subject field is required.'] } : {}), + ...(!data.description ? { description: ['The description field is required.'] } : {}), + }, + }) + } + + const ticket = await this.ticketService.create(user, { + subject: data.subject, + description: data.description, + priority: data.priority, + departmentId: data.department_id ? Number(data.department_id) : null, + tags: data.tags ? data.tags.map(Number) : undefined, + }) + + await ticket.load('department') + await ticket.load('tags') + + return ctx.response.created({ + data: this.formatTicketDetail(ticket), + message: 'Ticket created.', + }) + } + + /** + * POST /tickets/:reference/reply — Reply or add internal note + */ + async reply(ctx: HttpContext) { + const ticket = (ctx as any).escalatedTicket as Ticket + const user = (ctx as any).auth.user + const { body, is_internal_note } = ctx.request.only(['body', 'is_internal_note']) + + if (!body) { + return ctx.response.unprocessableEntity({ + message: 'Validation failed.', + errors: { body: ['The body field is required.'] }, + }) + } + + const isNote = !!is_internal_note + + let reply + if (isNote) { + reply = await this.ticketService.addNote(ticket, user, body) + } else { + reply = await this.ticketService.reply(ticket, user, body) + } + + return ctx.response.created({ + data: { + id: reply.id, + body: reply.body, + is_internal_note: reply.isInternalNote, + author: { id: user.id, name: user.name ?? user.fullName ?? '' }, + created_at: reply.createdAt.toISO(), + }, + message: isNote ? 'Note added.' : 'Reply sent.', + }) + } + + /** + * PATCH /tickets/:reference/status — Change ticket status + */ + async status(ctx: HttpContext) { + const ticket = (ctx as any).escalatedTicket as Ticket + const user = (ctx as any).auth.user + const { status } = ctx.request.only(['status']) + + if (!status) { + return ctx.response.unprocessableEntity({ + message: 'Validation failed.', + errors: { status: ['The status field is required.'] }, + }) + } + + try { + await this.ticketService.changeStatus(ticket, status as TicketStatus, user) + } catch (error: any) { + return ctx.response.unprocessableEntity({ + message: error.message, + }) + } + + return ctx.response.json({ message: 'Status updated.', status }) + } + + /** + * PATCH /tickets/:reference/priority — Change ticket priority + */ + async priority(ctx: HttpContext) { + const ticket = (ctx as any).escalatedTicket as Ticket + const user = (ctx as any).auth.user + const { priority } = ctx.request.only(['priority']) + + if (!priority) { + return ctx.response.unprocessableEntity({ + message: 'Validation failed.', + errors: { priority: ['The priority field is required.'] }, + }) + } + + const validPriorities = ['low', 'medium', 'high', 'urgent', 'critical'] + if (!validPriorities.includes(priority)) { + return ctx.response.unprocessableEntity({ + message: 'Validation failed.', + errors: { priority: ['The priority must be one of: low, medium, high, urgent, critical.'] }, + }) + } + + await this.ticketService.changePriority(ticket, priority as TicketPriority, user) + + return ctx.response.json({ message: 'Priority updated.', priority }) + } + + /** + * POST /tickets/:reference/assign — Assign ticket to an agent + */ + async assign(ctx: HttpContext) { + const ticket = (ctx as any).escalatedTicket as Ticket + const user = (ctx as any).auth.user + const { agent_id } = ctx.request.only(['agent_id']) + + if (!agent_id) { + return ctx.response.unprocessableEntity({ + message: 'Validation failed.', + errors: { agent_id: ['The agent_id field is required.'] }, + }) + } + + await this.assignmentService.assign(ticket, Number(agent_id), user) + + return ctx.response.json({ message: 'Ticket assigned.' }) + } + + /** + * POST /tickets/:reference/follow — Toggle follow/unfollow + */ + async follow(ctx: HttpContext) { + const ticket = (ctx as any).escalatedTicket as Ticket + const userId = (ctx as any).auth.user.id + + if (await ticket.isFollowedBy(userId)) { + await ticket.unfollow(userId) + return ctx.response.json({ message: 'Unfollowed ticket.', following: false }) + } + + await ticket.follow(userId) + return ctx.response.json({ message: 'Following ticket.', following: true }) + } + + /** + * POST /tickets/:reference/macro — Apply a macro + */ + async applyMacro(ctx: HttpContext) { + const ticket = (ctx as any).escalatedTicket as Ticket + const user = (ctx as any).auth.user + const { macro_id } = ctx.request.only(['macro_id']) + + if (!macro_id) { + return ctx.response.unprocessableEntity({ + message: 'Validation failed.', + errors: { macro_id: ['The macro_id field is required.'] }, + }) + } + + const macro = await Macro.query() + .withScopes((scopes) => scopes.forAgent(user.id)) + .where('id', macro_id) + .firstOrFail() + + const macroService = new MacroService() + await macroService.apply(macro, ticket, user) + + return ctx.response.json({ message: `Macro "${macro.name}" applied.` }) + } + + /** + * POST /tickets/:reference/tags — Update tags + */ + async tags(ctx: HttpContext) { + const ticket = (ctx as any).escalatedTicket as Ticket + const user = (ctx as any).auth.user + const { tag_ids } = ctx.request.only(['tag_ids']) + + if (!tag_ids || !Array.isArray(tag_ids)) { + return ctx.response.unprocessableEntity({ + message: 'Validation failed.', + errors: { tag_ids: ['The tag_ids field is required and must be an array.'] }, + }) + } + + const newTagIds = tag_ids.map(Number) + + await ticket.load('tags') + const currentTagIds = ticket.tags.map((t: Tag) => t.id) + + const toAdd = newTagIds.filter((id: number) => !currentTagIds.includes(id)) + const toRemove = currentTagIds.filter((id: number) => !newTagIds.includes(id)) + + if (toAdd.length) await this.ticketService.addTags(ticket, toAdd, user) + if (toRemove.length) await this.ticketService.removeTags(ticket, toRemove, user) + + return ctx.response.json({ message: 'Tags updated.' }) + } + + /** + * DELETE /tickets/:reference — Soft-delete a ticket + */ + async destroy(ctx: HttpContext) { + const ticket = (ctx as any).escalatedTicket as Ticket + + ticket.deletedAt = (await import('luxon')).DateTime.now() + await ticket.save() + + return ctx.response.json({ message: 'Ticket deleted.' }) + } + + // ---- Private Formatters ---- + + /** + * Format a ticket for collection (list) responses. + * Matches the Laravel TicketCollectionResource output. + */ + protected formatTicketCollection(ticket: Ticket): Record { + return { + id: ticket.id, + reference: ticket.reference, + subject: ticket.subject, + status: ticket.status, + status_label: STATUS_LABELS[ticket.status as TicketStatus] ?? ticket.status, + priority: ticket.priority, + priority_label: PRIORITY_LABELS[ticket.priority as TicketPriority] ?? ticket.priority, + requester: { + name: ticket.requesterName, + email: ticket.requesterEmail, + }, + assignee: null, // Assignee is loaded separately via user model + department: ticket.department ? { + id: ticket.department.id, + name: ticket.department.name, + } : null, + sla_breached: ticket.slaFirstResponseBreached || ticket.slaResolutionBreached, + created_at: ticket.createdAt.toISO(), + updated_at: ticket.updatedAt.toISO(), + } + } + + /** + * Format a ticket for detail (show) responses. + * Matches the Laravel TicketResource output. + */ + protected formatTicketDetail(ticket: Ticket): Record { + const data: Record = { + id: ticket.id, + reference: ticket.reference, + subject: ticket.subject, + description: ticket.description, + status: ticket.status, + status_label: STATUS_LABELS[ticket.status as TicketStatus] ?? ticket.status, + priority: ticket.priority, + priority_label: PRIORITY_LABELS[ticket.priority as TicketPriority] ?? ticket.priority, + channel: ticket.channel, + metadata: ticket.metadata, + requester: { + name: ticket.requesterName, + email: ticket.requesterEmail, + }, + assignee: null, + department: ticket.department ? { + id: ticket.department.id, + name: ticket.department.name, + } : null, + tags: ticket.tags?.map((tag: Tag) => ({ + id: tag.id, + name: tag.name, + color: tag.color, + })) ?? [], + replies: ticket.replies?.map((r: any) => ({ + id: r.id, + body: r.body, + is_internal_note: r.isInternalNote, + is_pinned: r.isPinned ?? false, + author: null, // Author loaded separately via user model + attachments: r.attachments?.map((a: any) => ({ + id: a.id, + filename: a.filename, + mime_type: a.mimeType, + size: a.size, + url: a.url, + })) ?? [], + created_at: r.createdAt.toISO(), + })) ?? [], + activities: ticket.activities?.map((a: any) => ({ + id: a.id, + type: a.type, + causer: null, // Causer loaded separately via user model + created_at: a.createdAt.toISO(), + })) ?? [], + sla: { + first_response_due_at: ticket.firstResponseDueAt?.toISO() ?? null, + first_response_at: ticket.firstResponseAt?.toISO() ?? null, + first_response_breached: ticket.slaFirstResponseBreached, + resolution_due_at: ticket.resolutionDueAt?.toISO() ?? null, + resolution_breached: ticket.slaResolutionBreached, + }, + resolved_at: ticket.resolvedAt?.toISO() ?? null, + closed_at: ticket.closedAt?.toISO() ?? null, + created_at: ticket.createdAt.toISO(), + updated_at: ticket.updatedAt.toISO(), + } + + return data + } +} diff --git a/src/middleware/api_rate_limit.ts b/src/middleware/api_rate_limit.ts new file mode 100644 index 0000000..3ea67a8 --- /dev/null +++ b/src/middleware/api_rate_limit.ts @@ -0,0 +1,77 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import { getConfig } from '../helpers/config.js' + +/** + * Simple in-memory rate limiter for the Escalated API. + * + * Tracks requests per token (or per IP for unauthenticated requests) + * within a sliding 60-second window. Configurable via + * `escalated.api.rateLimit` (default: 60 requests per minute). + * + * In production, consider replacing this with a Redis-backed limiter. + */ + +interface RateLimitEntry { + timestamps: number[] +} + +const rateLimitStore: Map = new Map() + +// Clean up stale entries every 5 minutes +setInterval(() => { + const cutoff = Date.now() - 120_000 + for (const [key, entry] of rateLimitStore.entries()) { + entry.timestamps = entry.timestamps.filter((t) => t > cutoff) + if (entry.timestamps.length === 0) { + rateLimitStore.delete(key) + } + } +}, 300_000).unref() + +export default class ApiRateLimit { + async handle(ctx: HttpContext, next: NextFn) { + const apiToken = (ctx as any).apiToken + const key = `escalated_api:${apiToken ? apiToken.id : ctx.request.ip()}` + + const config = getConfig() as any + const maxAttempts = config.api?.rateLimit ?? 60 + const windowMs = 60_000 + + const now = Date.now() + const entry = rateLimitStore.get(key) ?? { timestamps: [] } + + // Remove timestamps outside the window + entry.timestamps = entry.timestamps.filter((t) => t > now - windowMs) + + if (entry.timestamps.length >= maxAttempts) { + // Calculate retry-after in seconds + const oldestInWindow = entry.timestamps[0] + const retryAfter = Math.ceil((oldestInWindow + windowMs - now) / 1000) + + rateLimitStore.set(key, entry) + + ctx.response.header('Retry-After', String(retryAfter)) + ctx.response.header('X-RateLimit-Limit', String(maxAttempts)) + ctx.response.header('X-RateLimit-Remaining', '0') + + return ctx.response.tooManyRequests({ + message: 'Too many requests.', + retry_after: retryAfter, + }) + } + + // Record this request + entry.timestamps.push(now) + rateLimitStore.set(key, entry) + + const remaining = maxAttempts - entry.timestamps.length + + // Process the request + await next() + + // Add rate limit headers to the response + ctx.response.header('X-RateLimit-Limit', String(maxAttempts)) + ctx.response.header('X-RateLimit-Remaining', String(remaining)) + } +} diff --git a/src/middleware/authenticate_api_token.ts b/src/middleware/authenticate_api_token.ts new file mode 100644 index 0000000..efbd4cd --- /dev/null +++ b/src/middleware/authenticate_api_token.ts @@ -0,0 +1,67 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' +import ApiToken from '../models/api_token.js' + +/** + * Middleware to authenticate API requests using Bearer token authentication. + * + * Extracts the token from the Authorization header, validates it against + * the database (SHA-256 hashed), checks expiration and abilities, then + * sets the token owner as the authenticated user on the HTTP context. + */ +export default class AuthenticateApiToken { + async handle(ctx: HttpContext, next: NextFn, guards?: string[]) { + const ability = guards?.[0] ?? null + + const plainToken = this.extractToken(ctx) + + if (!plainToken) { + return ctx.response.unauthorized({ message: 'Unauthenticated.' }) + } + + const apiToken = await ApiToken.findByPlainText(plainToken) + + if (!apiToken) { + return ctx.response.unauthorized({ message: 'Invalid token.' }) + } + + if (apiToken.isExpired()) { + return ctx.response.unauthorized({ message: 'Token has expired.' }) + } + + if (ability && !apiToken.hasAbility(ability)) { + return ctx.response.forbidden({ message: 'Insufficient permissions.' }) + } + + // Load the token owner + const user = await apiToken.loadTokenable() + + if (!user) { + return ctx.response.unauthorized({ message: 'Token owner not found.' }) + } + + // Update last usage + apiToken.lastUsedAt = (await import('luxon')).DateTime.now() + apiToken.lastUsedIp = ctx.request.ip() ?? null + await apiToken.save() + + // Set the authenticated user and token on the context + ;(ctx as any).auth = { user, isAuthenticated: true } + ;(ctx as any).apiToken = apiToken + + return next() + } + + /** + * Extract Bearer token from the Authorization header. + */ + protected extractToken(ctx: HttpContext): string | null { + const header = ctx.request.header('Authorization') ?? '' + + if (header.startsWith('Bearer ')) { + return header.substring(7) + } + + return null + } +} diff --git a/src/models/api_token.ts b/src/models/api_token.ts new file mode 100644 index 0000000..af9c54e --- /dev/null +++ b/src/models/api_token.ts @@ -0,0 +1,129 @@ +import { DateTime } from 'luxon' +import { BaseModel, column, scope } from '@adonisjs/lucid/orm' +import { randomBytes, createHash } from 'node:crypto' + +export default class ApiToken extends BaseModel { + static table = 'escalated_api_tokens' + + @column({ isPrimary: true }) + declare id: number + + @column() + declare tokenableType: string + + @column() + declare tokenableId: number + + @column() + declare name: string + + @column() + declare token: string + + @column({ + prepare: (value: any) => (value ? JSON.stringify(value) : null), + consume: (value: any) => (value ? (typeof value === 'string' ? JSON.parse(value) : value) : null), + }) + declare abilities: string[] | null + + @column.dateTime() + declare lastUsedAt: DateTime | null + + @column() + declare lastUsedIp: string | null + + @column.dateTime() + declare expiresAt: DateTime | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime + + // ---- Relationships ---- + + /** + * Load the tokenable (owner) model dynamically. + * Since this is a morph relationship and we rely on the host app's user model, + * we resolve it manually via the config's userModel path. + */ + async loadTokenable(): Promise { + const config = (globalThis as any).__escalated_config + try { + const userModelPath = config?.userModel ?? '#models/user' + const { default: UserModel } = await import(userModelPath) + return UserModel.find(this.tokenableId) + } catch { + return null + } + } + + // ---- Methods ---- + + /** + * Check if the token has a given ability. + */ + hasAbility(ability: string): boolean { + const abilities = this.abilities ?? [] + return abilities.includes('*') || abilities.includes(ability) + } + + /** + * Check if the token has expired. + */ + isExpired(): boolean { + if (this.expiresAt === null) { + return false + } + return this.expiresAt < DateTime.now() + } + + // ---- Scopes ---- + + static active = scope((query) => { + query.where((q) => { + q.whereNull('expires_at').orWhere('expires_at', '>', DateTime.now().toSQL()!) + }) + }) + + static expired = scope((query) => { + query.whereNotNull('expires_at').where('expires_at', '<=', DateTime.now().toSQL()!) + }) + + // ---- Static Factory Methods ---- + + /** + * Create a new API token for a user. + * Returns the model instance and the plain text token (shown only once). + */ + static async createToken( + user: { id: number; constructor: { name: string } }, + name: string, + abilities: string[] = ['*'], + expiresAt?: DateTime | null + ): Promise<{ token: ApiToken; plainTextToken: string }> { + const plainText = randomBytes(32).toString('hex') + const hashed = createHash('sha256').update(plainText).digest('hex') + + const token = await ApiToken.create({ + tokenableType: user.constructor.name, + tokenableId: user.id, + name, + token: hashed, + abilities, + expiresAt: expiresAt ?? null, + }) + + return { token, plainTextToken: plainText } + } + + /** + * Find a token by its plain text value. + * Hashes the plain text with SHA-256 and looks up in the database. + */ + static async findByPlainText(plainText: string): Promise { + const hashed = createHash('sha256').update(plainText).digest('hex') + return ApiToken.query().where('token', hashed).first() + } +} diff --git a/src/types.ts b/src/types.ts index d8a5def..e0eb57c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -197,6 +197,13 @@ export interface EscalatedConfig { isAdmin: (user: any) => boolean | Promise } + api?: { + enabled: boolean + rateLimit: number + tokenExpiryDays: number | null + prefix: string + } + activityLog: { retentionDays: number } diff --git a/start/routes.ts b/start/routes.ts index 12bfa82..1b97661 100644 --- a/start/routes.ts +++ b/start/routes.ts @@ -28,11 +28,20 @@ const BulkActionsController = () => import('../src/controllers/bulk_actions_cont const SatisfactionRatingController = () => import('../src/controllers/satisfaction_rating_controller.js') const GuestTicketsController = () => import('../src/controllers/guest_tickets_controller.js') const InboundEmailController = () => import('../src/controllers/inbound_email_controller.js') +const AdminApiTokensController = () => import('../src/controllers/admin_api_tokens_controller.js') + +// API controllers +const ApiAuthController = () => import('../src/controllers/api/api_auth_controller.js') +const ApiDashboardController = () => import('../src/controllers/api/api_dashboard_controller.js') +const ApiTicketController = () => import('../src/controllers/api/api_ticket_controller.js') +const ApiResourceController = () => import('../src/controllers/api/api_resource_controller.js') // Middleware imports const EnsureIsAgent = () => import('../src/middleware/ensure_is_agent.js') const EnsureIsAdmin = () => import('../src/middleware/ensure_is_admin.js') const ResolveTicket = () => import('../src/middleware/resolve_ticket.js') +const AuthenticateApiToken = () => import('../src/middleware/authenticate_api_token.js') +const ApiRateLimit = () => import('../src/middleware/api_rate_limit.js') export function registerRoutes() { const config = getConfig() @@ -161,6 +170,12 @@ export function registerRoutes() { router.post('/macros', [AdminMacrosController, 'store']).as('escalated.admin.macros.store') router.put('/macros/:macro', [AdminMacrosController, 'update']).as('escalated.admin.macros.update') router.delete('/macros/:macro', [AdminMacrosController, 'destroy']).as('escalated.admin.macros.destroy') + + // API Tokens CRUD + router.get('/api-tokens', [AdminApiTokensController, 'index']).as('escalated.admin.api-tokens.index') + router.post('/api-tokens', [AdminApiTokensController, 'store']).as('escalated.admin.api-tokens.store') + router.put('/api-tokens/:id', [AdminApiTokensController, 'update']).as('escalated.admin.api-tokens.update') + router.delete('/api-tokens/:id', [AdminApiTokensController, 'destroy']).as('escalated.admin.api-tokens.destroy') }) .prefix(`${prefix}/admin`) .use([...adminMiddleware, EnsureIsAdmin]) @@ -188,4 +203,57 @@ export function registerRoutes() { }) .prefix(`${prefix}/inbound`) } + + // ---- API Routes ---- + if ((config as any).api?.enabled) { + registerApiRoutes(config) + } +} + +/** + * Register REST API routes for the Escalated support ticket system. + * These routes use Bearer token authentication and rate limiting. + */ +export function registerApiRoutes(config: any) { + const apiPrefix = config.api?.prefix ?? 'support/api/v1' + + router + .group(() => { + // Auth + router.post('/auth/validate', [ApiAuthController, 'validate']).as('escalated.api.auth.validate') + + // Dashboard + router.get('/dashboard', [ApiDashboardController, 'handle']).as('escalated.api.dashboard') + + // Tickets — collection + router.get('/tickets', [ApiTicketController, 'index']).as('escalated.api.tickets.index') + router.post('/tickets', [ApiTicketController, 'store']).as('escalated.api.tickets.store') + + // Tickets — single (with ticket resolution by reference) + router + .group(() => { + router.get('/tickets/:ticket', [ApiTicketController, 'show']).as('escalated.api.tickets.show') + router.post('/tickets/:ticket/reply', [ApiTicketController, 'reply']).as('escalated.api.tickets.reply') + router.patch('/tickets/:ticket/status', [ApiTicketController, 'status']).as('escalated.api.tickets.status') + router.patch('/tickets/:ticket/priority', [ApiTicketController, 'priority']).as('escalated.api.tickets.priority') + router.post('/tickets/:ticket/assign', [ApiTicketController, 'assign']).as('escalated.api.tickets.assign') + router.post('/tickets/:ticket/follow', [ApiTicketController, 'follow']).as('escalated.api.tickets.follow') + router.post('/tickets/:ticket/macro', [ApiTicketController, 'applyMacro']).as('escalated.api.tickets.macro') + router.post('/tickets/:ticket/tags', [ApiTicketController, 'tags']).as('escalated.api.tickets.tags') + router.delete('/tickets/:ticket', [ApiTicketController, 'destroy']).as('escalated.api.tickets.destroy') + }) + .use([ResolveTicket]) + + // Resources + router.get('/agents', [ApiResourceController, 'agents']).as('escalated.api.agents') + router.get('/departments', [ApiResourceController, 'departments']).as('escalated.api.departments') + router.get('/tags', [ApiResourceController, 'tags']).as('escalated.api.tags') + router.get('/canned-responses', [ApiResourceController, 'cannedResponses']).as('escalated.api.canned-responses') + router.get('/macros', [ApiResourceController, 'macros']).as('escalated.api.macros') + + // Realtime + router.get('/realtime/config', [ApiResourceController, 'realtimeConfig']).as('escalated.api.realtime') + }) + .prefix(apiPrefix) + .use([AuthenticateApiToken, ApiRateLimit]) }