Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions config/escalated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,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
Expand Down
27 changes: 27 additions & 0 deletions database/migrations/0017_create_escalated_api_tokens.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ 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'
// Plugin system
export { default as HookManager } from './src/support/hook_manager.js'
export { default as HookRegistry } from './src/services/hook_registry.js'
Expand Down
2 changes: 2 additions & 0 deletions providers/escalated_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,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 ?? {}
Expand Down
127 changes: 127 additions & 0 deletions src/controllers/admin_api_tokens_controller.ts
Original file line number Diff line number Diff line change
@@ -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 []
}
}
}
36 changes: 36 additions & 0 deletions src/controllers/api/api_auth_controller.ts
Original file line number Diff line number Diff line change
@@ -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,
})
}
}
107 changes: 107 additions & 0 deletions src/controllers/api/api_dashboard_controller.ts
Original file line number Diff line number Diff line change
@@ -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),
},
})
}
}
Loading