Add NTE floor on WO open and refine close-out digest rules.

Raise NTE to $400 when a new WO space opens, align /createWO defaults with SC_WO_OPEN_MIN_NTE, limit the daily digest to all non-invoiced COMPLETED WOs, and add unit tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-08-27 10:57:42 -04:00
parent abd6423218
commit 6ecdf6ee69
11 changed files with 250 additions and 23 deletions

View file

@ -41,13 +41,12 @@ CS_API_BASE=https://bot.joesjavajoint.com/CollabSupport
# --- Space cleanup (optional) ---
# Webex room for daily consolidated close-out digest and /confirmed ops notifications.
# COMPLETED_OPERATIONS_ROOM_ID=Y2lzY29zcGFyazovL3VzL1JPT00vNjdmZmYxZTAtZmM3Ny0xMWYwLWE2MzUtZGY0ZmQ4NWUwMGMz
# Days after any non-terminal COMPLETED status before including in the daily digest.
# Daily digest lists all non-invoiced COMPLETED work orders (any age).
# Auto-remove still applies only to COMPLETED/CONFIRMED, COMPLETED/CANCELLED, and COMPLETED/NO CHARGE.
# Only work orders for this service provider are included in cleanup (default: Pro-Motion).
# SPACE_CLEANUP_PROVIDER_NAME=Pro-Motion Technology Group, LLC
# SPACE_CLEANUP_PROVIDER_ID=2000002215
# SPACE_CLEANUP_REMINDER_DAYS=60
# Cron for daily consolidated digest (default 14:00 UTC). Digest only — no auto-delete.
# Cron for daily consolidated digest (default 14:00 UTC / 10am Eastern). Digest only — no auto-delete.
# SPACE_CLEANUP_REMINDER_CRON=0 0 14 * * *
# --- Admin endpoints (/cleanup-test, /stale-workorders, /track-backfill) ---
@ -125,7 +124,10 @@ SC_WEBHOOK_AUTH_MODE=off # off | log | enforce
# SC_CREATE_DEFAULT_PROVIDER_NAME=Pro-Motion Technology Group, LLC
# SC_CREATE_DEFAULT_CATEGORY=REPAIR
# SC_CREATE_DEFAULT_PRIORITY=P24
# SC_CREATE_DEFAULT_NTE=500
# SC_CREATE_DEFAULT_NTE=400
# Optional override for /createWO NTE default; if unset, uses SC_WO_OPEN_MIN_NTE (default 400).
# When a new Webex WO space opens (first webhook), raise NTE to this floor if lower (0 = off).
# SC_WO_OPEN_MIN_NTE=400
# SC_CREATE_CATEGORIES=REPAIR,MAINTENANCE,PROJECT
# SC_CREATE_PRIORITIES=P24,P1 - 4 Hours,P2 - 8 Hours
# SC_CREATE_PROBLEM_CODES=Music completely out,Needs service,Partial Music

View file

@ -8,7 +8,7 @@
"dev": "node --watch index.js",
"start": "node index.js",
"lint": "echo \"No linter configured\"",
"test": "echo \"No tests configured\"",
"test": "node --test tests/**/*.test.js",
"test:collab": "node scripts/smoke-collab-support.js"
},
"dependencies": {

View file

@ -4,6 +4,8 @@
* (admin) to inspect recent tenant-specific field samples.
*/
import { getWoOpenMinNte } from '../services/workOrderNteService.js';
function parseCsvEnv(name, fallback = []) {
const raw = process.env[name];
if (!raw || !String(raw).trim()) return [...fallback];
@ -30,6 +32,16 @@ export function isWorkOrderCreateEnabled() {
return parseBoolEnv('SC_CREATE_ENABLED', true);
}
/** Default NTE on /createWO form — SC_CREATE_DEFAULT_NTE, else SC_WO_OPEN_MIN_NTE, else 400. */
export function getDefaultCreateNte() {
const explicit = process.env.SC_CREATE_DEFAULT_NTE;
if (explicit != null && String(explicit).trim() !== '') {
return parseNumberEnv('SC_CREATE_DEFAULT_NTE', 400);
}
const openMin = getWoOpenMinNte();
return openMin > 0 ? openMin : 400;
}
export function getWorkOrderCreateConfig() {
const providerId = parseNumberEnv(
'SC_CREATE_DEFAULT_PROVIDER_ID',
@ -48,7 +60,7 @@ export function getWorkOrderCreateConfig() {
storePadLength: parseNumberEnv('SC_CREATE_STORE_PAD_LENGTH', 6),
defaultCategory: process.env.SC_CREATE_DEFAULT_CATEGORY || 'REPAIR',
defaultPriority: process.env.SC_CREATE_DEFAULT_PRIORITY || 'P24',
defaultNte: parseNumberEnv('SC_CREATE_DEFAULT_NTE', 500),
defaultNte: getDefaultCreateNte(),
categories: parseCsvEnv('SC_CREATE_CATEGORIES', ['REPAIR', 'MAINTENANCE', 'PROJECT']),
priorities: parseCsvEnv('SC_CREATE_PRIORITIES', [
'P24',

View file

@ -205,8 +205,8 @@ export function createApp({
(ID <code>${esc(process.env.SPACE_CLEANUP_PROVIDER_ID || '2000002215')}</code>).
Auto-remove (prune 14d / delete 60d) for <code>COMPLETED / CONFIRMED</code>,
<code>COMPLETED / CANCELLED</code>, and <code>COMPLETED / NO CHARGE</code>.
All other <code>COMPLETED</code> variants are held until one of those terminal statuses;
a consolidated daily digest posts to the ops room after ${esc(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60')} days.
All other <code>COMPLETED</code> variants are held until one of those terminal statuses.
A consolidated daily digest posts to the ops room for all <strong>non-invoiced</strong> COMPLETED work orders.
Invoice PDFs trigger an approval card in the WO room; use <code>/confirmed</code> after invoice
(sets SC CONFIRMED when allowed, otherwise records close-out via SC note + ServChan DB).
</div>

View file

@ -0,0 +1,11 @@
/**
* Eligibility rules for the daily close-out digest (no heavy imports safe for unit tests).
*/
/** True when a non-terminal COMPLETED WO should appear in the daily close-out digest. */
export function isCloseoutDigestCandidate(statusInfo) {
if (!statusInfo) return false;
if (statusInfo.isInvoiced === true) return false;
if (statusInfo.invoiceId) return false;
return true;
}

View file

@ -5,10 +5,10 @@ import { logger } from '../utils/logger.js';
import defaultDb from '../db/mappings.js';
import { getCompletedOperationsRoomId, parseServChanRoomTitle } from '../utils/servchanRoomTitle.js';
import { getWoCloseout } from './woCloseoutService.js';
import { isCloseoutDigestCandidate } from './closeoutDigestEligibility.js';
const REMOVE_OTHERS_AFTER_DAYS = 14;
const DELETE_AFTER_DAYS = 60;
const REMINDER_AFTER_DAYS = parseInt(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60', 10);
const CLEANUP_PROVIDER_NAME = (process.env.SPACE_CLEANUP_PROVIDER_NAME || 'Pro-Motion Technology Group, LLC').trim();
const CLEANUP_PROVIDER_ID = parseInt(process.env.SPACE_CLEANUP_PROVIDER_ID || '2000002215', 10);
@ -91,21 +91,16 @@ function buildConsolidatedDigestMessage(digestRows) {
const noun = count === 1 ? 'work order is' : 'work orders are';
let md = `**Daily close-out report** — ${dateLabel}\n\n`;
md += `${count} ${noun} COMPLETED (non-terminal) for ${REMINDER_AFTER_DAYS}+ days:\n\n`;
md += `${count} ${noun} COMPLETED without invoice:\n\n`;
for (const row of digestRows) {
const header = row.headerLine || `WO-${row.woNumber || row.workOrderId}`;
let line = `- **${header}** — ${row.statusDisplay}`;
if (row.isInvoiced) {
line += row.invoiceNumber ? ` — invoiced #${row.invoiceNumber}` : ' — invoiced';
}
line += `${row.ageDays} days\n`;
md += line;
md += `- **${header}** — ${row.statusDisplay}${row.ageDays} days\n`;
}
md +=
'\nTerminal statuses (Confirmed, Cancelled, No Charge) trigger auto-remove. ' +
'After an invoice is posted, use `/confirmed` in the WO space when resolution is verified.';
'Invoiced work orders are excluded from this digest — use `/confirmed` in the WO space when resolution is verified.';
return md;
}
@ -147,7 +142,7 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
const scope = remindersOnly ? 'digest only' : 'full cleanup';
console.log(
`[SPACE-CLEANUP] ${mode} Starting job (${scope} | auto-remove ≥${REMOVE_OTHERS_AFTER_DAYS}d prune / ≥${DELETE_AFTER_DAYS}d delete | digest${REMINDER_AFTER_DAYS}d)...`
`[SPACE-CLEANUP] ${mode} Starting job (${scope} | auto-remove ≥${REMOVE_OTHERS_AFTER_DAYS}d prune / ≥${DELETE_AFTER_DAYS}d delete | digest: all non-invoiced COMPLETED)...`
);
const results = [];
@ -234,14 +229,14 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
if (classification.category === 'remind') {
remindEligible++;
if (ageDays < REMINDER_AFTER_DAYS) {
if (!isCloseoutDigestCandidate(statusInfo)) {
results.push({
woId: mapping.workOrderId,
roomId: mapping.roomId,
action: 'skipped',
days: ageDays,
status: statusDisplay,
reason: `${statusDisplay}${ageDays} days (digest at ${REMINDER_AFTER_DAYS})`,
reason: 'invoiced — excluded from close-out digest (use /confirmed)',
});
skipped++;
continue;
@ -267,8 +262,6 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
ageDays,
roomId: mapping.roomId,
headerLine,
isInvoiced: statusInfo.isInvoiced === true,
invoiceNumber: statusInfo.invoiceNumber ?? null,
});
results.push({
@ -442,7 +435,6 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
failed,
dryRun,
remindersOnly,
reminderAfterDays: REMINDER_AFTER_DAYS,
};
console.log(`[SPACE-CLEANUP] ${mode} Job completed:`, summary);
logger('SPACE-CLEANUP', `${mode} Job completed: ${JSON.stringify(summary)}`);
@ -508,3 +500,5 @@ async function removeAllOtherMembers(roomId) {
logger('SPACE-CLEANUP', `removeAllOtherMembers failed for ${roomId}: ${err.message}`, 'error');
}
}
export { isCloseoutDigestCandidate } from './closeoutDigestEligibility.js';

View file

@ -41,6 +41,10 @@ import {
shouldSuppressAttachmentPost,
shouldSuppressWebhookNote,
} from '../db/uploadEchoSuppress.js';
import {
ensureWorkOrderMinimumNte,
getWoOpenMinNte,
} from './workOrderNteServiceDefault.js';
export function createWebhookProcessor(options = {}) {
const {
@ -286,6 +290,20 @@ export function createWebhookProcessor(options = {}) {
}
if (roomJustCreated) {
const minNte = getWoOpenMinNte();
if (minNte > 0) {
ensureWorkOrderMinimumNte(workOrderId, {
minNte,
hintNte: obj.Nte,
}).catch((e) => {
logger(
'webhookProcessor:nte',
`Min NTE error for ${workOrderId}: ${e.message}`,
'warn'
);
});
}
scheduleAttachmentPost({
roomId,
workOrderId,

View file

@ -0,0 +1,43 @@
/**
* Minimum NTE floor when a new Webex WO space is opened (first webhook).
* Pure logic no ServiceChannel client import (safe for unit tests).
*/
function parseNumberEnv(name, fallback) {
const raw = process.env[name];
if (raw == null || raw === '') return fallback;
const n = Number(raw);
return Number.isFinite(n) ? n : fallback;
}
/** Minimum NTE applied on new space open; 0 disables the feature. */
export function getWoOpenMinNte() {
return parseNumberEnv('SC_WO_OPEN_MIN_NTE', 400);
}
export function createWorkOrderNteService({
getWorkOrderForNte: getWo,
updateWorkOrderNte: updateNte,
} = {}) {
if (typeof getWo !== 'function' || typeof updateNte !== 'function') {
throw new Error('createWorkOrderNteService requires getWorkOrderForNte and updateWorkOrderNte');
}
async function ensureWorkOrderMinimumNte(woId, { minNte = 400, hintNte = null } = {}) {
if (!woId || minNte <= 0) return { updated: false, skipped: true };
let current = hintNte != null ? Number(hintNte) : NaN;
if (!Number.isFinite(current)) {
const wo = await getWo(woId);
current = Number(wo?.Nte);
}
if (!Number.isFinite(current) || current >= minNte) {
return { updated: false, nte: current };
}
await updateNte(woId, minNte);
return { updated: true, previousNte: current, nte: minNte };
}
return { ensureWorkOrderMinimumNte };
}

View file

@ -0,0 +1,28 @@
/**
* Production wiring for workOrderNteService (loads ServiceChannel client).
*/
import {
getWorkOrderForNte,
updateWorkOrderNte,
} from '../integrations/serviceChannel/client.js';
import { logger } from '../utils/logger.js';
import { createWorkOrderNteService } from './workOrderNteService.js';
const { ensureWorkOrderMinimumNte: ensureBase } = createWorkOrderNteService({
getWorkOrderForNte,
updateWorkOrderNte,
});
export async function ensureWorkOrderMinimumNte(woId, options = {}) {
const result = await ensureBase(woId, options);
if (result.updated) {
logger(
'workOrderNteService',
`WO ${woId} NTE raised from ${result.previousNte} to ${result.nte}`
);
}
return result;
}
export { getWoOpenMinNte } from './workOrderNteService.js';

View file

@ -0,0 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { isCloseoutDigestCandidate } from '../src/services/closeoutDigestEligibility.js';
test('isCloseoutDigestCandidate rejects when isInvoiced is true', () => {
assert.equal(
isCloseoutDigestCandidate({ isInvoiced: true, invoiceId: null }),
false
);
});
test('isCloseoutDigestCandidate rejects when invoiceId is set', () => {
assert.equal(
isCloseoutDigestCandidate({ isInvoiced: false, invoiceId: 12345 }),
false
);
});
test('isCloseoutDigestCandidate accepts non-invoiced WO', () => {
assert.equal(
isCloseoutDigestCandidate({ isInvoiced: false, invoiceId: null }),
true
);
});
test('isCloseoutDigestCandidate rejects null statusInfo', () => {
assert.equal(isCloseoutDigestCandidate(null), false);
});

View file

@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createWorkOrderNteService,
getWoOpenMinNte,
} from '../src/services/workOrderNteService.js';
test('getWoOpenMinNte defaults to 400', () => {
const prev = process.env.SC_WO_OPEN_MIN_NTE;
delete process.env.SC_WO_OPEN_MIN_NTE;
assert.equal(getWoOpenMinNte(), 400);
if (prev !== undefined) process.env.SC_WO_OPEN_MIN_NTE = prev;
});
test('getWoOpenMinNte returns 0 when disabled', () => {
const prev = process.env.SC_WO_OPEN_MIN_NTE;
process.env.SC_WO_OPEN_MIN_NTE = '0';
assert.equal(getWoOpenMinNte(), 0);
if (prev !== undefined) process.env.SC_WO_OPEN_MIN_NTE = prev;
else delete process.env.SC_WO_OPEN_MIN_NTE;
});
test('ensureWorkOrderMinimumNte skips when NTE is at or above floor', async () => {
const { ensureWorkOrderMinimumNte } = createWorkOrderNteService({
getWorkOrderForNte: async () => ({ Nte: 500 }),
updateWorkOrderNte: async () => {
throw new Error('should not PATCH');
},
});
const result = await ensureWorkOrderMinimumNte(123, { minNte: 400, hintNte: 500 });
assert.deepEqual(result, { updated: false, nte: 500 });
});
test('ensureWorkOrderMinimumNte skips when hintNte equals floor', async () => {
let patched = false;
const { ensureWorkOrderMinimumNte } = createWorkOrderNteService({
getWorkOrderForNte: async () => {
throw new Error('should not GET');
},
updateWorkOrderNte: async () => {
patched = true;
},
});
const result = await ensureWorkOrderMinimumNte(123, { minNte: 400, hintNte: 400 });
assert.equal(patched, false);
assert.deepEqual(result, { updated: false, nte: 400 });
});
test('ensureWorkOrderMinimumNte bumps when hintNte is below floor', async () => {
let patchedTo = null;
const { ensureWorkOrderMinimumNte } = createWorkOrderNteService({
getWorkOrderForNte: async () => {
throw new Error('should not GET');
},
updateWorkOrderNte: async (_woId, nte) => {
patchedTo = nte;
},
});
const result = await ensureWorkOrderMinimumNte(456, { minNte: 400, hintNte: 250 });
assert.equal(patchedTo, 400);
assert.deepEqual(result, { updated: true, previousNte: 250, nte: 400 });
});
test('ensureWorkOrderMinimumNte bumps null/zero NTE via GET fallback', async () => {
let patchedTo = null;
const { ensureWorkOrderMinimumNte } = createWorkOrderNteService({
getWorkOrderForNte: async () => ({ Nte: 0 }),
updateWorkOrderNte: async (_woId, nte) => {
patchedTo = nte;
},
});
const result = await ensureWorkOrderMinimumNte(789, { minNte: 400 });
assert.equal(patchedTo, 400);
assert.deepEqual(result, { updated: true, previousNte: 0, nte: 400 });
});
test('ensureWorkOrderMinimumNte skips when minNte is 0', async () => {
const { ensureWorkOrderMinimumNte } = createWorkOrderNteService({
getWorkOrderForNte: async () => ({ Nte: 100 }),
updateWorkOrderNte: async () => {
throw new Error('should not PATCH');
},
});
const result = await ensureWorkOrderMinimumNte(123, { minNte: 0, hintNte: 100 });
assert.deepEqual(result, { updated: false, skipped: true });
});