@classytic/support
v0.2.0
Published
Customer-support + community engine — tickets, threaded responses, business-hours SLA, warranty claims, CSAT/NPS, macros, agent routing, and a community Q&A forum. MongoDB-backed via @classytic/mongokit; mongokit repositories ARE the domain layer (no serv
Readme
@classytic/support
The headless customer-support + community engine for Node.js + Bun —
tickets, business-hours SLA, warranty claims, CSAT/NPS, macros, agent
routing, and a customer-to-customer Q&A forum. MongoDB-backed via
@classytic/mongokit; the mongokit repositories ARE the domain layer
(no service wrapper), so Arc's BaseController binds to them directly.
Build a Zendesk / Intercom / Freshdesk clone, an e-commerce warranty portal, a field-service desk, or a Discourse-style community — from one engine.
Install
npm install @classytic/supportPeers: @classytic/mongokit, @classytic/repo-core, @classytic/primitives,
mongoose, zod.
Engine-driven — the host wires what it needs
You never construct repositories by hand. Call createSupport(config) and use
engine.repositories.*. Module gating means a host registers only the
collections it uses:
import { createSupport } from '@classytic/support';
const support = await createSupport({
connection: mongoose.connection,
tenant: { fieldType: 'objectId' }, // organizationId = branchId
bridges: { order: orderAdapter, agents: agentDirectory },
// Everything defaults ON — turn a module off to skip its collections.
modules: { warranty: true, csat: true, macros: true, community: true },
});
support.repositories.ticket; // always present (core)
support.repositories.communityPost; // undefined if modules.community === false| Repository | Module | Domain verbs |
|---|---|---|
| ticket | core | open, triage, assignTo, autoAssign, escalate, markInProgress, pause, resume, resolve, close, reopen, cancel, respond, applyMacro, addParticipant, removeParticipant, link, merge, recordSatisfaction, markSlaBreached |
| ticketResponse | core | addResponse, listByTicket |
| slaPolicy | core | createPolicy, archive |
| warrantyClaim | warranty | submit, startReview, approve, startFulfillment, complete, reject, withdraw |
| satisfactionRating | csat | rate |
| macro | macros | createMacro, archive, recordUsage |
| communityPost | community | ask, edit, vote, reply, acceptReply, resolve, close, reopen, pin/unpin, lock/unlock, hide/unhide, recordView, convertToTicket |
| communityReply | community | reply, vote, edit, hide, show, listByPost |
Everything else — getById, getAll, create, update, delete,
aggregate, withTransaction — is inherited from mongokit Repository<TDoc>.
No proxy wrappers.
Tickets + business-hours SLA
const policy = await support.repositories.slaPolicy.createPolicy({
name: 'Business Hours',
// Elapsed time is measured in WORKING hours only — nights/weekends/holidays
// don't tick. Omit for a 24×7 policy.
businessCalendar: { weekdays: [1, 2, 3, 4, 5], startMinute: 540, endMinute: 1020,
timezone: 'Asia/Dhaka', holidays: ['2026-12-16'] },
targets: { /* per-priority firstResponse + resolution SLAs */ default: {/*…*/} },
}, ctx);
const ticket = await support.repositories.ticket.open({
customerId: 'cust_1',
subject: 'Checkout is broken',
channel: 'email',
priority: 'high',
slaPolicyId: policy.policyCode,
slaTargets: policy.targets.high, // snapshot — immune to later policy edits
businessCalendar: policy.businessCalendar, // precomputes firstResponseDueAt / resolutionDueAt
}, ctx);
await support.repositories.ticket.respond({
ticketId: ticket.id, authorId: 'agent_1', authorType: 'agent',
body: "On it — update in 30 min.", // first agent reply stops the first-response clock
}, ctx);computeSlaStatus(ticket, policy, now) is pure — safe in a cron/webhook.
When the policy carries a businessCalendar, elapsed math is working-hours only:
import { computeSlaStatus, businessElapsedMs } from '@classytic/support';
const sla = computeSlaStatus(ticket, policy, new Date());
if (sla.resolution?.breached) escalate(ticket);Macros, routing, participants, merge
// Canned response with structured actions.
const macro = await support.repositories.macro.createMacro({
title: 'Ask for order id',
bodyTemplate: 'Hi {{name}}, please share your order number.',
actions: { setPriority: 'high', addTags: ['awaiting-customer'] },
}, ctx);
await support.repositories.ticket.applyMacro(ticket.id, macro.macroCode, ctx,
{ variables: { name: 'Sam' } }); // renders reply + applies actions + bumps usage
// Auto-route to the least-loaded eligible agent (needs a bridges.agents directory).
await support.repositories.ticket.autoAssign(ticket.id, ctx, { strategy: 'least_loaded' });
// CC/watchers, links, and duplicate merge.
await support.repositories.ticket.addParticipant(ticket.id, { actorId: 'mgr_1', role: 'cc' }, ctx);
await support.repositories.ticket.merge(dupeId, ticket.id, ctx); // returns the survivorCSAT / NPS
await support.repositories.satisfactionRating.rate({
ticketId: ticket.id, customerId: 'cust_1', kind: 'csat', score: 5, comment: 'Fast!',
}, ctx);
// → one rating per ticket, denormalized onto ticket.satisfactionScore,
// emits support:ticket.ratedCommunity forum
const post = await support.repositories.communityPost.ask({
authorId: 'cust_1', kind: 'question',
title: 'How do I reset my device?', body: 'It loops on boot.', topic: 'devices',
}, ctx);
const { reply } = await support.repositories.communityPost.reply(post.id,
{ authorId: 'agent_1', authorKind: 'agent', body: 'Factory reset via Settings.', isOfficial: true }, ctx);
await support.repositories.communityReply.vote(reply.id, 'cust_2', 1, ctx); // idempotent per voter
await support.repositories.communityPost.acceptReply(post.id, reply.id, ctx); // open → answered
// Escalate an unresolved thread into a private ticket.
const { ticket } = await support.repositories.communityPost.convertToTicket(post.id, ctx,
{ priority: 'high', closePost: true }); // ticket.channel === 'community'Votes are counted with an atomic aggregation-pipeline update, so the score never drifts under concurrent voters.
Events
Every state change emits an arc-compatible DomainEvent on the support:
prefix — subscribe glob-style via any arc EventTransport (Memory / Redis /
Kafka / BullMQ). 39 events across ticket.*, warranty.*, sla_policy.*,
macro.*, and community.*. The Zod-source catalog
(supportEventDefinitions, subpath ./events) drives publish-time
validation + OpenAPI introspection with zero arc imports.
await support.events.subscribe('support:community.*', (e) => notify(e));
await support.events.subscribe('support:ticket.rated', (e) => analytics.csat(e));Host-owned durability: pass an outbox (P8 transactional-outbox contract) and
ctx.session threads through so the event row commits with the write.
Subpaths
| Import | What |
|---|---|
| @classytic/support | engine + all repos/types (engine-driven API) |
| @classytic/support/events | Zod event catalog (supportEventDefinitions) |
| @classytic/support/calendar | pure business-hours integrators (businessElapsedMs, businessDeadline) |
| @classytic/support/routing | pure agent selectors (selectAgent) |
Cross-package bridges
OrderPort— verify warranty purchase info against an order system.AgentDirectory— supply the agent roster forautoAssign.
Both optional; the package never imports sibling @classytic/* packages
(rule: OrderPort / AgentDirectory are host-implemented).
Tests
86 tests (unit + integration via mongodb-memory-server):
npm testLicense
MIT
