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 });
+});