From 6ecdf6ee695d04292ff99feb8a3ada6a76482d8e Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Thu, 27 Aug 2026 10:57:42 -0400 Subject: [PATCH] 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 --- .env.example | 10 ++- package.json | 2 +- src/config/workOrderCreateConfig.js | 14 +++- src/server/app.js | 4 +- src/services/closeoutDigestEligibility.js | 11 +++ src/services/spaceCleanupService.js | 24 +++--- src/services/webhookProcessor.js | 18 +++++ src/services/workOrderNteService.js | 43 ++++++++++ src/services/workOrderNteServiceDefault.js | 28 +++++++ tests/spaceCleanupDigest.test.js | 28 +++++++ tests/workOrderNteService.test.js | 91 ++++++++++++++++++++++ 11 files changed, 250 insertions(+), 23 deletions(-) create mode 100644 src/services/closeoutDigestEligibility.js create mode 100644 src/services/workOrderNteService.js create mode 100644 src/services/workOrderNteServiceDefault.js create mode 100644 tests/spaceCleanupDigest.test.js create mode 100644 tests/workOrderNteService.test.js diff --git a/.env.example b/.env.example index 23f79a0..30ccbfc 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/package.json b/package.json index 488659d..1a6ed78 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/config/workOrderCreateConfig.js b/src/config/workOrderCreateConfig.js index d02ffef..95010e0 100644 --- a/src/config/workOrderCreateConfig.js +++ b/src/config/workOrderCreateConfig.js @@ -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', diff --git a/src/server/app.js b/src/server/app.js index cb7477e..0ce1f1b 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -205,8 +205,8 @@ export function createApp({ (ID ${esc(process.env.SPACE_CLEANUP_PROVIDER_ID || '2000002215')}). Auto-remove (prune ≥14d / delete ≥60d) for COMPLETED / CONFIRMED, COMPLETED / CANCELLED, and COMPLETED / NO CHARGE. - All other COMPLETED 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 COMPLETED variants are held until one of those terminal statuses. + A consolidated daily digest posts to the ops room for all non-invoiced COMPLETED work orders. Invoice PDFs trigger an approval card in the WO room; use /confirmed after invoice (sets SC CONFIRMED when allowed, otherwise records close-out via SC note + ServChan DB). diff --git a/src/services/closeoutDigestEligibility.js b/src/services/closeoutDigestEligibility.js new file mode 100644 index 0000000..bac5f6d --- /dev/null +++ b/src/services/closeoutDigestEligibility.js @@ -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; +} diff --git a/src/services/spaceCleanupService.js b/src/services/spaceCleanupService.js index 3351a69..db71fd6 100644 --- a/src/services/spaceCleanupService.js +++ b/src/services/spaceCleanupService.js @@ -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'; diff --git a/src/services/webhookProcessor.js b/src/services/webhookProcessor.js index b232b63..c12b3de 100644 --- a/src/services/webhookProcessor.js +++ b/src/services/webhookProcessor.js @@ -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, diff --git a/src/services/workOrderNteService.js b/src/services/workOrderNteService.js new file mode 100644 index 0000000..f75bf27 --- /dev/null +++ b/src/services/workOrderNteService.js @@ -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 }; +} diff --git a/src/services/workOrderNteServiceDefault.js b/src/services/workOrderNteServiceDefault.js new file mode 100644 index 0000000..9352a8d --- /dev/null +++ b/src/services/workOrderNteServiceDefault.js @@ -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'; diff --git a/tests/spaceCleanupDigest.test.js b/tests/spaceCleanupDigest.test.js new file mode 100644 index 0000000..96e7e5e --- /dev/null +++ b/tests/spaceCleanupDigest.test.js @@ -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); +}); diff --git a/tests/workOrderNteService.test.js b/tests/workOrderNteService.test.js new file mode 100644 index 0000000..7880bea --- /dev/null +++ b/tests/workOrderNteService.test.js @@ -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 }); +});