العربية • Deutsch • English • Español • Français • Italiano • 日本語 • 한국어 • Nederlands • Polski • Português (BR) • Русский • Türkçe • 简体中文
Website: escalated.dev
Embedded helpdesk module for NestJS applications. Drop-in ticketing, SLA management, knowledge base, and more.
- Ticket Management -- Full CRUD with lifecycle tracking, priorities, departments, tags, and custom fields
- SLA Policies -- Configurable response/resolution targets with business hours support
- Automations -- Time-based processing via
@nestjs/schedule(SLA checks, snooze wake-up, webhook retries) - Escalation Rules -- Automatic reassignment and notifications on SLA breach
- Macros & Canned Responses -- One-click multi-action macros and templated replies
- Custom Fields -- Dynamic fields with validation (text, number, select, checkbox, date)
- Knowledge Base -- Articles with categories, search, view tracking, and helpfulness ratings
- Webhooks -- HMAC-signed delivery with exponential backoff retry
- API Tokens -- Bearer token authentication with scoped abilities
- Roles & Permissions -- Granular permission system with NestJS guards
- Audit Logging -- Interceptor-based activity tracking for all mutations
- Import System -- Bulk import for tickets, tags, and departments
- Side Conversations -- Threaded discussions within a ticket
- Ticket Merging & Linking -- Merge duplicates, link related tickets
- Ticket Splitting -- Break a ticket into separate issues
- Ticket Snooze -- Snooze with automatic wake-up via cron
- Saved Views -- Personal and shared filtered views
- Widget API -- Public endpoints for embeddable support widget with rate limiting
- Public Ticket System -- Unauthenticated submission via widget form or inbound email, Contact-based identity with email dedupe, configurable guest policy, signed Reply-To for threaded email conversations
- Workflow Engine -- Admin-configured rules fire on ticket/reply events (conditions + actions: assign, tag, set priority/status/department, insert canned reply, etc.); evaluation logs per execution
- Real-time Broadcasting -- Socket.IO gateway for live updates (opt-in)
- Capacity Management -- Per-agent ticket limits with real-time tracking
- Skill-based Routing -- Assign tickets based on agent skills and availability
- CSAT Ratings -- Post-resolution satisfaction surveys with token-based submission
- 2FA (TOTP) -- Two-factor authentication for agents via
otplib - Guest Access -- Token-based ticket access without authentication
- Internationalization -- Translations sourced from the central
@escalated-dev/localepackage vianestjs-i18n, with a chained overlay loader for plugin-local and host-app overrides (seesrc/i18n/overrides/README.md)
- Node.js 18+
- NestJS 11+
- TypeORM 0.3+
- Any TypeORM-supported database (PostgreSQL, MySQL, SQLite, etc.)
npm install @escalated-dev/escalated-nestjsimport { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EscalatedModule } from '@escalated-dev/escalated-nestjs';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
database: 'myapp',
username: 'user',
password: 'pass',
autoLoadEntities: true,
synchronize: true, // disable in production
}),
EscalatedModule.forRoot({
routePrefix: 'escalated',
appName: 'My App',
appUrl: 'https://myapp.com',
enableWebsockets: false,
enableKnowledgeBase: true,
enableCsat: true,
enable2fa: false,
}),
],
})
export class AppModule {}| Option | Type | Default | Description |
|---|---|---|---|
routePrefix |
string |
'escalated' |
URL prefix for all routes |
enableWebsockets |
boolean |
false |
Enable Socket.IO real-time broadcasting |
enableKnowledgeBase |
boolean |
true |
Enable KB articles and categories |
enableCsat |
boolean |
true |
Enable satisfaction surveys |
enable2fa |
boolean |
false |
Enable TOTP 2FA for agents |
appName |
string |
'Escalated' |
Branding name for emails |
appUrl |
string |
-- | Base URL for links |
maxFileSize |
number |
10485760 |
Max upload size in bytes |
webhookMaxRetries |
number |
3 |
Webhook retry attempts |
widgetOrigins |
string[] |
['*'] |
CORS origins for widget |
adminGuard |
class |
-- | Custom guard for admin routes |
agentGuard |
class |
-- | Custom guard for agent routes |
customerGuard |
class |
-- | Custom guard for customer routes |
userResolver |
function |
-- | Extract user from request |
mail |
object |
-- | Outbound email config (see below) |
inbound |
object |
-- | Inbound email config (see below) |
guestPolicy |
object |
unassigned | Guest identity policy (see below) |
ticketActions |
object |
{actions:[]} |
Custom agent ticket actions (see below) |
ticketSubjects |
object |
{types:[]} |
Host entities a ticket is about (see below) |
EscalatedModule.forRoot({
mail: {
from: 'support@example.com',
transport: {
host: 'smtp.example.com',
port: 587,
auth: { user: 'x', pass: 'y' },
},
},
});When mail is absent, the MailerModule is not registered and EmailService silently no-ops — modules boot cleanly without a transport.
EscalatedModule.forRoot({
inbound: {
replyDomain: 'reply.example.com', // domain in our Message-ID + Reply-To
replySecret: '32-byte-hex-secret', // HMAC key for signed reply-to
webhookSecret: 'shared-secret', // X-Escalated-Inbound-Secret header value
provider: 'postmark', // parser adapter
},
});Webhook endpoint: POST /escalated/webhook/email/inbound. Guard requires X-Escalated-Inbound-Secret header to match webhookSecret (constant-time compare).
Controls the identity assigned to a ticket submitted via the public form or inbound email.
// Default: no host-app user, contact carries identity
{ mode: 'unassigned' }
// Assign all guest tickets to a shared "guest" user in the host app
{ mode: 'guest_user', guestUserId: 42 }
// Ticket stays unassigned until the guest accepts a signup invite
{ mode: 'prompt_signup', signupUrlTemplate: 'https://app.example.com/signup?token={token}' }Admins can override at runtime via PUT /escalated/admin/settings:
{ "key": "guest_policy", "type": "json", "value": { "mode": "guest_user", "guestUserId": 99 } }Escalated stores references to your host app's users (ticket requester,
assignee, reply author, etc.). By default those columns are integers, matching
a classic auto-incrementing user primary key. If your host app's user table
uses a UUID or other string primary key, set ESCALATED_USER_KEY_TYPE
before the app bootstraps so the TypeORM entity columns are created as
varchar(255) instead of int:
# .env — one of: int (default) | bigint | uuid | string
ESCALATED_USER_KEY_TYPE=uuidThis is read from the environment (not the module options) because TypeORM
column decorators are evaluated at class-load time, before
EscalatedModule.forRoot() runs. Existing integer-keyed installs need no
change — the default (int) produces exactly the same schema as before.
uuid and string both map to a portable varchar(255) that can hold a UUID
or a stringified integer id. All Escalated APIs accept a host user id as either
a number or a string (UserId).
With synchronize: true, TypeORM auto-creates tables. For production, generate migrations:
npx typeorm migration:generate -n EscalatedSetup
npx typeorm migration:runAll tables are prefixed with escalated_ to avoid conflicts.
| Method | Path | Description |
|---|---|---|
| GET | /tickets |
List tickets with filters |
| POST | /tickets |
Create ticket |
| GET | /tickets/:id |
Show ticket with replies |
| PUT | /tickets/:id |
Update ticket |
| DELETE | /tickets/:id |
Delete ticket |
| POST | /tickets/:id/replies |
Add reply |
| POST | /tickets/:id/actions/:actionKey |
Trigger custom action |
| POST | /tickets/:id/merge/:targetId |
Merge tickets |
| POST | /tickets/:id/split |
Split ticket |
| POST | /tickets/:id/snooze |
Snooze ticket |
| GET | /tickets/:ticketId/links |
List ticket links |
| POST | /tickets/:ticketId/links |
Link tickets |
| GET | /tickets/:ticketId/side-conversations |
List side conversations |
| POST | /tickets/:ticketId/side-conversations |
Create side conversation |
| GET | /macros |
List macros |
| POST | /macros/:macroId/execute/:ticketId |
Execute macro |
| GET | /canned-responses |
List canned responses |
| GET | /saved-views |
List saved views |
| POST | /saved-views |
Create saved view |
| Method | Path | Description |
|---|---|---|
| GET/PUT | /settings |
Manage settings |
| CRUD | /departments |
Manage departments |
| CRUD | /tags |
Manage tags |
| CRUD | /custom-fields |
Manage custom fields |
| CRUD | /roles |
Manage roles |
| CRUD | /sla/policies |
Manage SLA policies |
| CRUD | /sla/escalation-rules |
Manage escalation rules |
| CRUD | /sla/schedules |
Manage business schedules |
| CRUD | /webhooks |
Manage webhooks |
| CRUD | /api-tokens |
Manage API tokens |
| CRUD | /agents |
Manage agent profiles |
| CRUD | /macros |
Manage macros |
| CRUD | /canned-responses |
Manage canned responses |
| CRUD | /kb/categories |
Manage KB categories |
| CRUD | /kb/articles |
Manage KB articles |
| POST | /import/tickets |
Bulk import tickets |
| POST | /2fa/generate |
Generate 2FA secret |
| POST | /2fa/enable |
Enable 2FA |
| GET | /audit-logs |
View audit logs |
| Method | Path | Description |
|---|---|---|
| GET | /tickets |
List own tickets |
| POST | /tickets |
Create ticket |
| GET | /tickets/:id |
View own ticket |
| POST | /tickets/:id/replies |
Reply to own ticket |
| POST | /tickets/:id/rate |
Submit CSAT rating |
| GET | /kb/categories |
Browse KB categories |
| GET | /kb/articles |
Browse KB articles |
| GET | /kb/search |
Search KB |
| Method | Path | Description |
|---|---|---|
| POST | /tickets |
Create ticket (public) |
| GET | /tickets/:id |
View ticket (guest token) |
| POST | /tickets/:id/replies |
Reply (guest token) |
| GET | /kb/search |
Search KB |
| POST | /rate/:token |
Submit CSAT |
All services are exported and can be injected into your own code:
import { Injectable } from '@nestjs/common';
import { TicketService, AgentService } from '@escalated-dev/escalated-nestjs';
@Injectable()
export class MyService {
constructor(
private readonly ticketService: TicketService,
private readonly agentService: AgentService,
) {}
async assignToAvailableAgent(ticketId: number) {
const agent = await this.agentService.findAvailableAgent();
if (agent) {
await this.ticketService.update(ticketId, { assigneeId: agent.userId }, 0);
}
}
}The module emits events via @nestjs/event-emitter:
import { OnEvent } from '@nestjs/event-emitter';
import { ESCALATED_EVENTS, TicketCreatedEvent } from '@escalated-dev/escalated-nestjs';
@Injectable()
export class NotificationService {
@OnEvent(ESCALATED_EVENTS.TICKET_CREATED)
handleTicketCreated(event: TicketCreatedEvent) {
// Send notification, update external system, etc.
}
}Events: TICKET_CREATED, TICKET_UPDATED, TICKET_ASSIGNED, TICKET_STATUS_CHANGED, TICKET_REPLY_CREATED, TICKET_MERGED, TICKET_SPLIT, SLA_BREACHED, TICKET_CUSTOM_ACTION_TRIGGERED.
A ticket has a requester (the person who raised it) and a subject line (free text). Sometimes a ticket is also about one or more host-app entities — a Project, a Customer, an asset — that are not people. Attach them as ticket subjects so agents see what the ticket concerns and can jump straight to it in your app.
Implement the TicketSubject contract on any host model you want attachable:
import { TicketSubject } from '@escalated-dev/escalated-nestjs';
export class Project implements TicketSubject {
ticketSubjectTitle(): string {
return this.name;
}
ticketSubjectSubtitle(): string | null {
return `Project · ${this.customer.name}`;
}
ticketSubjectUrl(): string | null {
return `/projects/${this.id}`;
}
ticketSubjectColor(): string | null {
return '#2563eb';
}
ticketSubjectIcon(): string | null {
return 'folder';
}
}Register an allowlist and optional resolver when importing the module. NestJS
does not own your host models — the resolver maps a stored type + id to a
TicketSubject for API serialization:
EscalatedModule.forRoot({
ticketSubjects: {
types: ['Project', 'Customer'],
resolver: async (type, id) => {
if (type === 'Project') return projectRepo.findOne({ where: { id } });
if (type === 'Customer') return customerRepo.findOne({ where: { id } });
return null;
},
},
});Each attached subject is serialized on the ticket as:
{
"type": "Project",
"id": "7",
"role": "project",
"title": "Acme Redesign",
"subtitle": "Project · Acme",
"url": "/projects/7",
"color": "#2563eb",
"icon": "folder",
"missing": false
}subjectId is stored as a string so integer, UUID, or string-keyed host models
all work. When the resolver is absent or returns null, title falls back to
type#id, presentation fields are null, and missing is true.
Agent API (types must be allowlisted):
POST /escalated/agent/tickets/:id/subjects— body{ type, id, role? }DELETE /escalated/agent/tickets/:id/subjects/:linkId
Programmatic attach via TicketSubjectService works for any type when the
allowlist is empty; the agent API only accepts allowlisted types.
Host applications can add custom buttons to the agent ticket screen and handle clicks with normal event listeners. Register actions when importing the module:
EscalatedModule.forRoot({
ticketActions: {
actions: [
{
key: 'sync-crm',
label: 'Sync CRM',
variant: 'primary',
confirmation: 'Sync this ticket to the CRM?',
metadata: { icon: 'refresh-cw' },
},
],
},
});label, visible, enabled, confirmation, and metadata may each be a
value or a (ticket, user) => value function for dynamic behavior. For richer
logic you can instead provide an object implementing the TicketAction
interface (key(), label(), visible(), enabled(), variant(),
confirmation(), metadata()).
The agent ticket show response exposes the visible actions as customActions
(each with a url and method). When the agent triggers one
(POST /escalated/agent/tickets/:id/actions/:actionKey), Escalated dispatches
TicketCustomActionTriggered:
import { OnEvent } from '@nestjs/event-emitter';
import {
ESCALATED_EVENTS,
TicketCustomActionTriggeredEvent,
} from '@escalated-dev/escalated-nestjs';
@Injectable()
export class CrmSyncListener {
@OnEvent(ESCALATED_EVENTS.TICKET_CUSTOM_ACTION_TRIGGERED)
handle(event: TicketCustomActionTriggeredEvent) {
if (event.action !== 'sync-crm') return;
// event.ticket, event.userId, event.payload, event.metadata
}
}The event exposes ticket, action, userId, payload, and metadata.
Escalated also records an internal note on the ticket whenever an action fires,
for auditability.
Enable WebSocket broadcasting for live ticket updates:
EscalatedModule.forRoot({
enableWebsockets: true,
});Client-side (Socket.IO):
const socket = io('/escalated');
socket.emit('join:ticket', { ticketId: 1 });
socket.on('ticket:updated', (data) => console.log('Updated:', data));
socket.on('ticket:reply', (data) => console.log('New reply:', data));git clone https://github.com/escalated-dev/escalated-nestjs.git
cd escalated-nestjs
npm install --legacy-peer-deps
npm test
npm run buildAll 32 entities are exported and prefixed with escalated_:
Core: Ticket, TicketStatus, Reply, Attachment, TicketActivity, Tag, Department, TicketLink, SatisfactionRating
SLA: SlaPolicy, EscalationRule, BusinessSchedule, Holiday
Agents: AgentProfile, AgentCapacity, Skill
Messaging: CannedResponse, Macro, SideConversation, SideConversationReply
Admin: Role, Permission, ApiToken, Webhook, WebhookDelivery, AuditLog
Custom: CustomField, CustomFieldValue
Config: EscalatedSettings, SavedView
Knowledge Base: KbCategory, KbArticle
Newsletters (optional): NewsletterList, NewsletterListMember, NewsletterTemplate, Newsletter, NewsletterDelivery
Admin-only broadcast feature for sending Markdown emails to contacts. Off by default — when disabled, NewsletterModule is not imported and its entities are not registered.
EscalatedModule.forRoot({
appUrl: 'https://support.example.com',
enableNewsletters: true,
newsletters: {
defaultFrom: 'hi@example.com',
defaultTheme: 'default',
rateLimitPerMinute: 60,
trackingEnabled: true,
brand: {
name: 'Acme',
accent: '#2563eb',
physicalAddress: 'Acme Inc. · 123 Main St · Springfield USA',
},
},
mail: {
from: 'hi@example.com',
transport: { service: 'postmark', auth: { user: '...', pass: '...' } },
},
})Schedule the dispatcher every minute. The cron tick is currently exposed via NewsletterDispatcherService.dispatchBatch() — wire it into your host's scheduler module (or call from a controller for testing):
@Cron(CronExpression.EVERY_MINUTE)
async runNewsletterDispatch() {
await this.newsletterDispatcher.dispatchBatch();
}Custom themes are Handlebars files placed in the directory pointed to by newsletters.themesDir. Each theme receives subject, body (pre-rendered HTML), unsubscribe_url, view_in_browser_url, and brand.*.
MIT