From b85218e587f445201719158557480f6ac248808b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:03:57 +0000 Subject: [PATCH 1/3] fix(service): carry the case SLA clock in identifiers and one test, not four prose copies The case SLA matrix is wall-clock (calendar) hours. Say it once, in the _case-sla.ts header; carry the unit in CASE_SLA_CALENDAR_HOURS, caseSlaCalendarHours and the hook-body slaCalendarHours literal; pin it with a fixed Friday 17:00 clock run through the shipped body; and tell the operator in the sla_due_date field description (and its four pack help strings). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- src/sales/translations/en/objects.service.ts | 2 +- .../translations/es-ES/objects.service.ts | 2 +- .../translations/ja-JP/objects.service.ts | 2 +- .../translations/zh-CN/objects.service.ts | 2 +- src/service/data/service.seed.ts | 9 +-- src/service/objects/_case-sla.ts | 15 +++-- src/service/objects/case.hook.ts | 18 ++--- src/service/objects/case.object.ts | 7 +- test/case-sla-matrix.test.ts | 67 ++++++++++++------- test/seed-consistency.test.ts | 6 +- 10 files changed, 72 insertions(+), 58 deletions(-) diff --git a/src/sales/translations/en/objects.service.ts b/src/sales/translations/en/objects.service.ts index 433591faa..db0acab3c 100644 --- a/src/sales/translations/en/objects.service.ts +++ b/src/sales/translations/en/objects.service.ts @@ -56,7 +56,7 @@ export const service: Record = { 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' }, diff --git a/src/sales/translations/es-ES/objects.service.ts b/src/sales/translations/es-ES/objects.service.ts index 96efc3e49..1fb068a87 100644 --- a/src/sales/translations/es-ES/objects.service.ts +++ b/src/sales/translations/es-ES/objects.service.ts @@ -72,7 +72,7 @@ export const service: Record = { 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' }, diff --git a/src/sales/translations/ja-JP/objects.service.ts b/src/sales/translations/ja-JP/objects.service.ts index 0760d7a13..488718550 100644 --- a/src/sales/translations/ja-JP/objects.service.ts +++ b/src/sales/translations/ja-JP/objects.service.ts @@ -70,7 +70,7 @@ export const service: Record = { 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: 'エスカレーション日' }, diff --git a/src/sales/translations/zh-CN/objects.service.ts b/src/sales/translations/zh-CN/objects.service.ts index 9b39bdd44..2775c00c4 100644 --- a/src/sales/translations/zh-CN/objects.service.ts +++ b/src/sales/translations/zh-CN/objects.service.ts @@ -76,7 +76,7 @@ export const service: Record = { 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: '升级原因' }, diff --git a/src/service/data/service.seed.ts b/src/service/data/service.seed.ts index 63750e1ef..283b80add 100644 --- a/src/service/data/service.seed.ts +++ b/src/service/data/service.seed.ts @@ -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'; @@ -46,10 +46,7 @@ const TIER_BY_ACCOUNT = new Map( * `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 @@ -58,7 +55,7 @@ const TIER_BY_ACCOUNT = new Map( * 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')`; }; diff --git a/src/service/objects/_case-sla.ts b/src/service/objects/_case-sla.ts index dcfdfdefa..b16745d22 100644 --- a/src/service/objects/_case-sla.ts +++ b/src/service/objects/_case-sla.ts @@ -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 @@ -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> = { +/** Calendar hours from case creation to `sla_due_date`, by priority and account tier. */ +export const CASE_SLA_CALENDAR_HOURS: Record> = { // 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 }, @@ -95,8 +102,8 @@ export const CASE_SLA_HOURS: Record }; /** 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]; } diff --git a/src/service/objects/case.hook.ts b/src/service/objects/case.hook.ts index 39236f35c..6cd14f0e3 100644 --- a/src/service/objects/case.hook.ts +++ b/src/service/objects/case.hook.ts @@ -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, @@ -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 @@ -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> = { + const slaCalendarHours: Record> = { 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 }, @@ -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) { diff --git a/src/service/objects/case.object.ts b/src/service/objects/case.object.ts index f0ab47803..c0a9d146e 100644 --- a/src/service/objects/case.object.ts +++ b/src/service/objects/case.object.ts @@ -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', }), diff --git a/test/case-sla-matrix.test.ts b/test/case-sla-matrix.test.ts index e03c88963..420e26d78 100644 --- a/test/case-sla-matrix.test.ts +++ b/test/case-sla-matrix.test.ts @@ -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). @@ -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. @@ -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(), ); } @@ -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}`); }, ); @@ -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 () => { @@ -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 @@ -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 into next week. + ['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 on an smb account 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/); }); }); diff --git a/test/seed-consistency.test.ts b/test/seed-consistency.test.ts index 5de22b344..fba1c813b 100644 --- a/test/seed-consistency.test.ts +++ b/test/seed-consistency.test.ts @@ -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'; /** @@ -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; @@ -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`); From fb65fbf5eb691ab12c1b4783b35f0f8c3216f697 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:05:20 +0000 Subject: [PATCH 2/3] chore(changeset): note the SLA Due Date help text (patch) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- .changeset/1974-case-sla-calendar-hours.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/1974-case-sla-calendar-hours.md diff --git a/.changeset/1974-case-sla-calendar-hours.md b/.changeset/1974-case-sla-calendar-hours.md new file mode 100644 index 000000000..e163818aa --- /dev/null +++ b/.changeset/1974-case-sla-calendar-hours.md @@ -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. From 1e00b183e821c263a36d7e2333b01ec5669e6d8a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:05:37 +0000 Subject: [PATCH 3/3] test(case-sla): name the fixed-clock rows by the column they use Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01ER8ntXZhYebyQ66aXWdjfT --- test/case-sla-matrix.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/case-sla-matrix.test.ts b/test/case-sla-matrix.test.ts index 420e26d78..0ffdfe95f 100644 --- a/test/case-sla-matrix.test.ts +++ b/test/case-sla-matrix.test.ts @@ -284,11 +284,11 @@ describe('the clock is calendar hours, carried by the code', () => { }); it.each([ - // A business-hours clock would push every one of these into next week. + // 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 on an smb account is due at %s', async (priority, due) => { + ])('%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 },