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
22 changes: 22 additions & 0 deletions .changeset/1974-case-sla-calendar-hours.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'hotcrm': patch
---

The case **SLA Due Date** field now says its unit: calendar hours

The SLA deadline on a case has always been counted in calendar (wall-clock)
hours. Nights, weekends and holidays count, because this app has no
business-hours calendar. Until now the form never said so. The **SLA Due Date**
field now carries a help text in all four languages:

> Set from the case priority and the account’s Customer Tier, in calendar
> hours: nights, weekends and holidays count.

No deadline changes. The matrix, the stamping rule and every published field
and hook name are the same as before. For maintainers: the unit is now carried
in code names (`CASE_SLA_CALENDAR_HOURS`, `caseSlaCalendarHours` and the
hook-body `slaCalendarHours` table). The repeated warning comments are reduced
to one, in `src/service/objects/_case-sla.ts`. The test now drives the shipped
hook body with the clock fixed at Friday 17:00. A Critical case is due Friday
21:00. On an SMB account, a Medium case is due Sunday 17:00 and a Low case the
next Friday 17:00.
2 changes: 1 addition & 1 deletion src/sales/translations/en/objects.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const service: Record<string, ObjectTranslationData> = {
closed_date: { label: 'Closed Date' },
first_response_date: { label: 'First Response Date' },
resolution_time_hours: { label: 'Resolution Time (Hours)' },
sla_due_date: { label: 'SLA Due Date' },
sla_due_date: { label: 'SLA Due Date', help: 'Set from the case priority and the account’s Customer Tier, in calendar hours: nights, weekends and holidays count.' },
is_sla_violated: { label: 'SLA Violated' },
is_escalated: { label: 'Escalated' },
escalation_reason: { label: 'Escalation Reason' },
Expand Down
2 changes: 1 addition & 1 deletion src/sales/translations/es-ES/objects.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export const service: Record<string, ObjectTranslationData> = {
closed_date: { label: 'Fecha de Cierre' },
first_response_date: { label: 'Fecha de Primera Respuesta' },
resolution_time_hours: { label: 'Tiempo de Resolución (Horas)' },
sla_due_date: { label: 'Fecha Límite SLA' },
sla_due_date: { label: 'Fecha Límite SLA', help: 'Se fija según la prioridad del caso y el Nivel de Cliente de la cuenta, en horas naturales: las noches, los fines de semana y los festivos cuentan.' },
is_sla_violated: { label: 'SLA Incumplido' },
is_escalated: { label: 'Escalado' },
escalated_date: { label: 'Fecha de Escalación' },
Expand Down
2 changes: 1 addition & 1 deletion src/sales/translations/ja-JP/objects.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const service: Record<string, ObjectTranslationData> = {
closed_date: { label: 'クローズ日' },
first_response_date: { label: '初回応答日' },
resolution_time_hours: { label: '解決時間(時間)' },
sla_due_date: { label: 'SLA期限' },
sla_due_date: { label: 'SLA期限', help: 'ケースの優先度と取引先の顧客ランクから設定され、暦時間で数えます。夜間・週末・祝日もすべて含まれます。' },
is_sla_violated: { label: 'SLA違反' },
is_escalated: { label: 'エスカレーション済' },
escalated_date: { label: 'エスカレーション日' },
Expand Down
2 changes: 1 addition & 1 deletion src/sales/translations/zh-CN/objects.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const service: Record<string, ObjectTranslationData> = {
closed_date: { label: '关闭日期' },
first_response_date: { label: '首次响应日期' },
resolution_time_hours: { label: '解决耗时(小时)' },
sla_due_date: { label: 'SLA 到期' },
sla_due_date: { label: 'SLA 到期', help: '按工单优先级与所属客户的客户分层设定,以日历小时计:夜间、周末与节假日都计入。' },
is_sla_violated: { label: 'SLA 违反' },
is_escalated: { label: '已升级' },
escalation_reason: { label: '升级原因' },
Expand Down
9 changes: 3 additions & 6 deletions src/service/data/service.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { defineSeed } from '@objectstack/spec/data';
import { cel } from '@objectstack/spec';
import { Case } from '../objects/case.object';
import { KnowledgeArticle } from '../objects/knowledge_article.object';
import { CASE_SLA_DEFAULT_TIER, caseSlaHours } from '../objects/_case-sla';
import { CASE_SLA_DEFAULT_TIER, caseSlaCalendarHours } from '../objects/_case-sla';
import { celDaysAgo } from '../../sales/data/_shared';
import { accounts } from '../../sales/data/sales.seed';

Expand All @@ -46,10 +46,7 @@ const TIER_BY_ACCOUNT = new Map<string, string>(
* `created_date + matrix(priority, tier)`, as a CEL expression.
*
* `daysAgo(n)` is a UTC midnight, so the hour offset has to be added on top of
* it — hence `+ duration('Nh')` rather than a second day-granular helper. The
* matrix is stated in CALENDAR hours (see `src/objects/_case-sla.ts`), which is
* exactly what this arithmetic does: no working-day skipping, because the app
* has no working-day calendar to skip by.
* it — hence `+ duration('Nh')` rather than a second day-granular helper.
*
* A due date that lands in the past is expected and correct on an old open case
* — that is what a breach IS, and `case_sla_monitor` is the thing that notices
Expand All @@ -58,7 +55,7 @@ const TIER_BY_ACCOUNT = new Map<string, string>(
* where the demo wants a breach visible before the first sweep runs.
*/
const celCaseSlaDue = (createdDaysAgo: number, priority: string, accountName?: string) => {
const hours = caseSlaHours(priority, accountName ? TIER_BY_ACCOUNT.get(accountName) : undefined);
const hours = caseSlaCalendarHours(priority, accountName ? TIER_BY_ACCOUNT.get(accountName) : undefined);
if (hours === undefined) throw new Error(`seed: no SLA matrix row for priority "${priority}"`);
return cel`daysAgo(${createdDaysAgo}) + duration('${hours}h')`;
};
Expand Down
15 changes: 11 additions & 4 deletions src/service/objects/_case-sla.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
* time means teaching this module a calendar — it is not a matter of changing
* a cell.
*
* This header is the ONE place the app says it in prose (#1974). Everywhere
* else the unit rides in a name — `CASE_SLA_CALENDAR_HOURS` and
* `caseSlaCalendarHours` below, `slaCalendarHours` in the hook body — and in
* `test/case-sla-matrix.test.ts`, which drives the hook on a fixed Friday
* 17:00 clock and pins the deadlines that land on the weekend. The operator
* reads it in the `sla_due_date` field description.
*
* # Why the `critical` row is flat
*
* Every cell in the `critical` row is 4 — deliberately, and it is the one
Expand Down Expand Up @@ -85,8 +92,8 @@ export type CaseSlaPriority = (typeof CASE_SLA_PRIORITIES)[number];
*/
export const CASE_SLA_DEFAULT_TIER: CaseSlaTier = 'smb';

/** Hours from case creation to `sla_due_date`, by priority and account tier. */
export const CASE_SLA_HOURS: Record<CaseSlaPriority, Record<CaseSlaTier, number>> = {
/** Calendar hours from case creation to `sla_due_date`, by priority and account tier. */
export const CASE_SLA_CALENDAR_HOURS: Record<CaseSlaPriority, Record<CaseSlaTier, number>> = {
// strategic enterprise mid_market smb
critical: { strategic: 4, enterprise: 4, mid_market: 4, smb: 4 },
high: { strategic: 6, enterprise: 8, mid_market: 8, smb: 8 },
Expand All @@ -95,8 +102,8 @@ export const CASE_SLA_HOURS: Record<CaseSlaPriority, Record<CaseSlaTier, number>
};

/** Look a cell up with the documented fallbacks. Unknown priority ⇒ no clock. */
export function caseSlaHours(priority: string, tier?: string | null): number | undefined {
const row = CASE_SLA_HOURS[priority as CaseSlaPriority];
export function caseSlaCalendarHours(priority: string, tier?: string | null): number | undefined {
const row = CASE_SLA_CALENDAR_HOURS[priority as CaseSlaPriority];
if (!row) return undefined;
return row[tier as CaseSlaTier] ?? row[CASE_SLA_DEFAULT_TIER];
}
18 changes: 6 additions & 12 deletions src/service/objects/case.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ import {
* Case SLA & escalation hook.
*
* - Stamps `sla_due_date` on a case that has none, from the priority × account
* tier matrix in `_case-sla.ts` (⚠️ CALENDAR hours — this app has no
* business-hours calendar and the deadline does not skip nights, weekends or
* holidays).
* tier matrix in `_case-sla.ts`.
* - On escalation: creates a follow-up task OWNED BY the account owner (the
* single owner of escalation tasks — flows must not create their own).
* Owning it, not merely labelling it: `owner_id` is the one ownership column,
Expand Down Expand Up @@ -213,14 +211,10 @@ const caseValidation: Hook = {
input.priority_rank = rank[priority] ?? 0;
}

// ── SLA policy matrix: priority × account tier, in CALENDAR HOURS ──
// ── SLA policy matrix: priority × account tier ──
//
// ⚠️ CALENDAR hours, not business hours. Every number below is added to the
// wall clock: this app ships no business-hours calendar, no working-day
// definition and no holiday list, so a P1 raised at 5pm on a Friday is due
// at 9pm that same Friday. Stated here rather than hidden because it is the
// one way these numbers get misread. The canonical write-up — including why
// the `critical` row is flat at 4 — lives in `_case-sla.ts`.
// The canonical write-up — the unit, and why the `critical` row is flat at
// 4 — lives in `_case-sla.ts`.
//
// ⚠️ The table is declared INLINE and duplicated in `_case-sla.ts` on
// purpose, for the same reason as the `rank` map above: L2 hook bodies run
Expand All @@ -229,7 +223,7 @@ const caseValidation: Hook = {
// real constant; this body cannot. `test/case-sla-matrix.test.ts` pins all
// sixteen cells by driving THIS handler, so the copies cannot drift.
if (priority && !input.sla_due_date && !ctx.previous?.sla_due_date) {
const slaHours: Record<string, Record<string, number>> = {
const slaCalendarHours: Record<string, Record<string, number>> = {
critical: { strategic: 4, enterprise: 4, mid_market: 4, smb: 4 },
high: { strategic: 6, enterprise: 8, mid_market: 8, smb: 8 },
medium: { strategic: 24, enterprise: 36, mid_market: 48, smb: 48 },
Expand All @@ -240,7 +234,7 @@ const caseValidation: Hook = {
// Erring loose is the safe direction: a tighter deadline invented out of
// a permission error would manufacture breaches.
const DEFAULT_TIER = 'smb';
const row = slaHours[priority];
const row = slaCalendarHours[priority];
// An unrecognised priority gets no clock at all rather than a guessed
// one — the same refusal-to-invent as the `0` unranked sentinel above.
if (row) {
Expand Down
7 changes: 3 additions & 4 deletions src/service/objects/case.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,12 @@ export const Case = ObjectSchema.create({
}),

// Stamped once, on the first write that gives the case a priority, from the
// priority × account-tier matrix in `src/objects/_case-sla.ts`
// (`case_sla_defaults`). The clock runs on CALENDAR hours — this app has no
// business-hours calendar, so nights, weekends and holidays count. Not
// `readonly`: a service manager may legitimately renegotiate a deadline,
// priority × account-tier matrix in `./_case-sla.ts` (`case_sla_defaults`).
// Not `readonly`: a service manager may legitimately renegotiate a deadline,
// and the hook never overwrites a value that is already there.
sla_due_date: Field.datetime({
label: 'SLA Due Date',
description: 'Set from the case priority and the account’s Customer Tier, in calendar hours: nights, weekends and holidays count.',
group: 'sla',
}),

Expand Down
67 changes: 42 additions & 25 deletions test/case-sla-matrix.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import caseHooks from '../src/service/objects/case.hook';
import {
CASE_SLA_HOURS, CASE_SLA_DEFAULT_TIER, CASE_SLA_PRIORITIES, CASE_SLA_TIERS, caseSlaHours,
CASE_SLA_CALENDAR_HOURS, CASE_SLA_DEFAULT_TIER, CASE_SLA_PRIORITIES, CASE_SLA_TIERS, caseSlaCalendarHours,
} from '../src/service/objects/_case-sla';
import { makeHarness, makeDeniedApi, makeCtx, hookNamed, type Rec } from './helpers/hook-harness';
import { extractSandboxBody, runHookBody } from './helpers/action-sandbox';

/**
* The SLA policy matrix, pinned cell by cell (#595).
Expand Down Expand Up @@ -37,7 +38,7 @@ const HOUR = 3_600_000;
/**
* The matrix, written out longhand.
*
* Deliberately NOT derived from `CASE_SLA_HOURS` — a test that recomputes its
* Deliberately NOT derived from `CASE_SLA_CALENDAR_HOURS` — a test that recomputes its
* expectation from the thing under test proves only that the code is
* self-consistent. These sixteen numbers are the policy, spelled out where a
* reviewer reads them.
Expand Down Expand Up @@ -96,9 +97,9 @@ describe('the matrix covers every declared priority and tier', () => {
it('has a row per case priority and a cell per account tier', () => {
// A new tier option on `crm_account` or a new case priority must be given a
// policy, not silently inherit one.
expect(Object.keys(CASE_SLA_HOURS).sort()).toEqual([...CASE_SLA_PRIORITIES].sort());
expect(Object.keys(CASE_SLA_CALENDAR_HOURS).sort()).toEqual([...CASE_SLA_PRIORITIES].sort());
for (const priority of CASE_SLA_PRIORITIES) {
expect(Object.keys(CASE_SLA_HOURS[priority]).sort(), `row ${priority}`).toEqual(
expect(Object.keys(CASE_SLA_CALENDAR_HOURS[priority]).sort(), `row ${priority}`).toEqual(
[...CASE_SLA_TIERS].sort(),
);
}
Expand All @@ -122,7 +123,7 @@ describe.each(Object.keys(EXPECTED))('%s priority', (priority) => {
async (tier) => {
const hours = EXPECTED[priority][tier];
// Both copies of the table, and then the behaviour itself.
expect(CASE_SLA_HOURS[priority as never][tier as never], `constant cell ${priority}×${tier}`).toBe(hours);
expect(CASE_SLA_CALENDAR_HOURS[priority as never][tier as never], `constant cell ${priority}×${tier}`).toBe(hours);
expectHours(await stampFor(priority, tier), hours, `${priority}×${tier}`);
},
);
Expand Down Expand Up @@ -192,7 +193,7 @@ describe('the rules the matrix does not change', () => {
// wrote a policy for gets no deadline rather than a guessed one.
const { input } = await stampFor('blocker', 'strategic');
expect(input.sla_due_date).toBeUndefined();
expect(caseSlaHours('blocker', 'strategic')).toBeUndefined();
expect(caseSlaCalendarHours('blocker', 'strategic')).toBeUndefined();
});

it('stamps nothing when the write already carries a due date', async () => {
Expand Down Expand Up @@ -259,7 +260,7 @@ describe('the rules the matrix does not change', () => {
});
});

describe('the clock is calendar hours, stated out loud', () => {
describe('the clock is calendar hours, carried by the code', () => {
it('adds elapsed milliseconds, so a DST transition cannot shorten an SLA', async () => {
// `setHours(getHours() + n)` does LOCAL calendar arithmetic: across a
// transition "+4 hours" becomes 3 or 5 real hours, and the 168h Low clock
Expand All @@ -269,22 +270,38 @@ describe('the clock is calendar hours, stated out loud', () => {
expect(res.dueMs! - res.atMs).toBeLessThan(168 * HOUR + 60_000);
});

it('says so in the source, where the numbers are', async () => {
// The one thing a reader of this table must not have to infer. There is no
// business-hours calendar on the platform, so a Friday-5pm P1 is due at
// 9pm the same Friday — documenting that is part of the deliverable, not
// decoration around it.
const { readFileSync } = await import('node:fs');
const { join } = await import('node:path');
const { REPO_ROOT } = await import('./helpers/repo-root');
for (const file of ['src/service/objects/_case-sla.ts', 'src/service/objects/case.hook.ts']) {
const source = readFileSync(join(REPO_ROOT, file), 'utf8');
expect(source, `${file} must state the calendar-hours assumption`).toMatch(
/CALENDAR HOURS|CALENDAR hours/,
);
expect(source, `${file} must say the app has no business-hours calendar`).toMatch(
/business-hours calendar/,
);
}
describe('on a fixed Friday 17:00 clock, in the shipped body', () => {
// The deadline is computed where production computes it: the lowered
// `body.source`, run in QuickJS. Only `Date` is faked — the bare
// `vi.useFakeTimers()` deadlocks this runner (`helpers/action-sandbox.ts`).
// UTC on purpose: the stamp is elapsed time, so the host zone cannot move it.
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-10-02T17:00:00.000Z')); // a Friday
});
afterEach(() => {
vi.useRealTimers();
});

it.each([
// A business-hours clock would push every one of these past the weekend.
['critical', '2026-10-02T21:00:00.000Z'], // 4 h — the same Friday night
['medium', '2026-10-04T17:00:00.000Z'], // 48 h — due on the Sunday
['low', '2026-10-09T17:00:00.000Z'], // 168 h — the weekend counted in full
])('%s, no account (the smb column), is due at %s', async (priority, due) => {
const { input } = await runHookBody(hook, {
event: 'beforeInsert',
input: { subject: 'Something broke', priority },
user: { id: 'user_1' },
});
expect(input.sla_due_date).toBe(due);
});
});

it('names the unit in the identifier the shipped body carries', () => {
// The body cannot import `CASE_SLA_CALENDAR_HOURS` (imported above), so it
// carries its own name for the unit — in the source `objectstack build` ships.
const { source } = extractSandboxBody(hook.handler, `hook '${String(hook.name)}'`);
expect(source).toMatch(/\bconst slaCalendarHours\b/);
});
});
6 changes: 3 additions & 3 deletions test/seed-consistency.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { describe, it, expect } from 'vitest';
import { CrmSeedData } from '../objectstack.composition';
import { CASE_SLA_DEFAULT_TIER, caseSlaHours } from '../src/service/objects/_case-sla';
import { CASE_SLA_DEFAULT_TIER, caseSlaCalendarHours } from '../src/service/objects/_case-sla';
import { HIGH_VALUE_DEAL_AMOUNT, LARGE_DEAL_AMOUNT } from '../src/sales/objects/_thresholds';

/**
Expand Down Expand Up @@ -510,7 +510,7 @@ describe('seeded case SLA due dates match the policy matrix (#595)', () => {
problems.push(`${label}: created_date is not a daysAgo() expression — ${created}`);
continue;
}
const hours = caseSlaHours(String(c.priority), tierOf.get(String(c.crm_account)));
const hours = caseSlaCalendarHours(String(c.priority), tierOf.get(String(c.crm_account)));
if (hours === undefined) {
problems.push(`${label}: no SLA matrix row for priority "${String(c.priority)}"`);
continue;
Expand All @@ -531,7 +531,7 @@ describe('seeded case SLA due dates match the policy matrix (#595)', () => {
const problems: string[] = [];
for (const c of cases.filter((r) => r.is_sla_violated === true)) {
const age = /^daysAgo\((\d+)\)$/.exec(celSource(c.created_date) ?? '');
const hours = caseSlaHours(String(c.priority), tierOf.get(String(c.crm_account)));
const hours = caseSlaCalendarHours(String(c.priority), tierOf.get(String(c.crm_account)));
const label = `${String(c.subject)} (${String(c.priority)})`;
if (!age || hours === undefined) {
problems.push(`${label}: cannot check a breach without a daysAgo() creation and a matrix row`);
Expand Down
Loading