Add consolidated close-out digest, /completed command, and proposal NTE fix.
Post daily non-terminal COMPLETED digest to ops room, let /completed confirm in WO spaces with SC note and ops notification, and default approval NTE to proposal total instead of adding to current NTE. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7ea64b5f24
commit
bd02d51111
10 changed files with 302 additions and 95 deletions
|
|
@ -39,11 +39,12 @@ CS_API_BASE=https://bot.joesjavajoint.com/CollabSupport
|
||||||
# OPTISIGN_API_KEY=...
|
# OPTISIGN_API_KEY=...
|
||||||
|
|
||||||
# --- Space cleanup (optional) ---
|
# --- Space cleanup (optional) ---
|
||||||
# Days after any non-terminal COMPLETED status before posting a daily
|
# Webex room for daily consolidated close-out digest and /completed ops notifications.
|
||||||
# close-out reminder in the Webex room. Auto-remove still applies only to
|
# COMPLETED_OPERATIONS_ROOM_ID=Y2lzY29zcGFyazovL3VzL1JPT00vNjdmZmYxZTAtZmM3Ny0xMWYwLWE2MzUtZGY0ZmQ4NWUwMGMz
|
||||||
# COMPLETED/CONFIRMED, COMPLETED/CANCELLED, and COMPLETED/NO CHARGE.
|
# Days after any non-terminal COMPLETED status before including in the daily digest.
|
||||||
|
# Auto-remove still applies only to COMPLETED/CONFIRMED, COMPLETED/CANCELLED, and COMPLETED/NO CHARGE.
|
||||||
# SPACE_CLEANUP_REMINDER_DAYS=60
|
# SPACE_CLEANUP_REMINDER_DAYS=60
|
||||||
# Cron for daily close-out reminders (default 14:00 UTC). Reminders only — no auto-delete.
|
# Cron for daily consolidated digest (default 14:00 UTC). Digest only — no auto-delete.
|
||||||
# SPACE_CLEANUP_REMINDER_CRON=0 0 14 * * *
|
# SPACE_CLEANUP_REMINDER_CRON=0 0 14 * * *
|
||||||
|
|
||||||
# --- Admin endpoints (/cleanup-test, /stale-workorders) ---
|
# --- Admin endpoints (/cleanup-test, /stale-workorders) ---
|
||||||
|
|
|
||||||
6
index.js
6
index.js
|
|
@ -86,9 +86,9 @@ db.serialize(() => {
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS space_cleanup_reminders (
|
CREATE TABLE IF NOT EXISTS space_cleanup_digest (
|
||||||
workOrderId INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
lastReminderAt TEXT NOT NULL
|
lastPostedAt TEXT NOT NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
console.log(`[DB] Connected to ${DB_PATH}`);
|
console.log(`[DB] Connected to ${DB_PATH}`);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { handleWoAttachments } from '../commands/woAttachments.js';
|
||||||
import { handleWoHistory } from '../commands/woHistory.js';
|
import { handleWoHistory } from '../commands/woHistory.js';
|
||||||
import { handleAvStatus } from '../commands/avStatus.js';
|
import { handleAvStatus } from '../commands/avStatus.js';
|
||||||
import { handleWoApprove } from '../commands/woApprove.js';
|
import { handleWoApprove } from '../commands/woApprove.js';
|
||||||
|
import { handleWoCompleted } from '../commands/woCompleted.js';
|
||||||
import approvalService from '../services/approvalService.js';
|
import approvalService from '../services/approvalService.js';
|
||||||
import { installMercuryGuard } from './mercuryGuard.js';
|
import { installMercuryGuard } from './mercuryGuard.js';
|
||||||
import db from '../db/mappings.js';
|
import db from '../db/mappings.js';
|
||||||
|
|
@ -121,6 +122,9 @@ export function initializeBot({ webexConfig }) {
|
||||||
case 'requestapproval':
|
case 'requestapproval':
|
||||||
await handleWoApprove(bot, trigger);
|
await handleWoApprove(bot, trigger);
|
||||||
break;
|
break;
|
||||||
|
case 'completed':
|
||||||
|
await handleWoCompleted(bot, trigger);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
await handleUnknown(bot, trigger);
|
await handleUnknown(bot, trigger);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export async function handleHelp(bot, trigger) {
|
||||||
text += `- **/woAttachments** — download attachments for the workorder in this space\n`;
|
text += `- **/woAttachments** — download attachments for the workorder in this space\n`;
|
||||||
text += `- **/avStatus** — AV device status for the store linked to this space\n`;
|
text += `- **/avStatus** — AV device status for the store linked to this space\n`;
|
||||||
text += `- **/woApprove** — (re)post proposal approval card for current WO (when WAITING FOR APPROVAL)\n`;
|
text += `- **/woApprove** — (re)post proposal approval card for current WO (when WAITING FOR APPROVAL)\n`;
|
||||||
|
text += `- **/completed** — confirm WO resolution verified and notify ops space for close-out\n`;
|
||||||
} else {
|
} else {
|
||||||
text += `- **/woSummary <WO-number>** — status of any work order\n`;
|
text += `- **/woSummary <WO-number>** — status of any work order\n`;
|
||||||
text += `- **/woHistory <store-number>** — History of AV issues.\n`;
|
text += `- **/woHistory <store-number>** — History of AV issues.\n`;
|
||||||
|
|
|
||||||
113
src/commands/woCompleted.js
Normal file
113
src/commands/woCompleted.js
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// src/commands/woCompleted.js
|
||||||
|
import botClient from '../integrations/webex/botClient.js';
|
||||||
|
import { addWorkOrderNote } from '../integrations/serviceChannel/client.js';
|
||||||
|
import db from '../db/mappings.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import {
|
||||||
|
getCompletedOperationsRoomId,
|
||||||
|
parseServChanRoomTitle,
|
||||||
|
} from '../utils/servchanRoomTitle.js';
|
||||||
|
|
||||||
|
function lookupWorkOrderIdByRoom(roomId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(
|
||||||
|
'SELECT workOrderId FROM mappings WHERE roomId = ?',
|
||||||
|
[roomId],
|
||||||
|
(err, row) => (err ? reject(err) : resolve(row?.workOrderId ?? null))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConfirmationText(displayName) {
|
||||||
|
return `${displayName} has confirmed this WO resolution verified and approves closing it out.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleWoCompleted(bot, trigger) {
|
||||||
|
logger('wo:completed', 'HANDLER ENTERED');
|
||||||
|
|
||||||
|
const roomId = trigger.message?.roomId;
|
||||||
|
const isGroup = trigger.message?.roomType === 'group';
|
||||||
|
|
||||||
|
if (!isGroup || !roomId) {
|
||||||
|
await bot.say('This command can only be used in a ServChan work order space.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedWorkOrderId = await lookupWorkOrderIdByRoom(roomId);
|
||||||
|
if (!mappedWorkOrderId) {
|
||||||
|
await bot.say('This command can only be used in a ServChan work order space.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let room;
|
||||||
|
try {
|
||||||
|
room = await botClient.getRoom(roomId);
|
||||||
|
} catch (err) {
|
||||||
|
logger('wo:completed', `Failed to fetch room ${roomId}: ${err.message}`, 'warn');
|
||||||
|
await bot.say('Could not load this Webex space. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseServChanRoomTitle(room?.title);
|
||||||
|
if (!parsed) {
|
||||||
|
await bot.say('This command can only be used in a ServChan work order space.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opsRoomId = getCompletedOperationsRoomId();
|
||||||
|
if (!opsRoomId) {
|
||||||
|
logger('wo:completed', 'COMPLETED_OPERATIONS_ROOM_ID not configured', 'warn');
|
||||||
|
await bot.say('The close-out ops space is not configured on this bot (`COMPLETED_OPERATIONS_ROOM_ID`).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const personId = trigger.personId;
|
||||||
|
const person = personId
|
||||||
|
? await botClient.getPersonDetails(personId)
|
||||||
|
: { displayName: 'Unknown User' };
|
||||||
|
const displayName = person.displayName || 'Unknown User';
|
||||||
|
const confirmationText = buildConfirmationText(displayName);
|
||||||
|
|
||||||
|
await bot.say({ markdown: `**${confirmationText}**` });
|
||||||
|
|
||||||
|
let scNotePosted = false;
|
||||||
|
try {
|
||||||
|
await addWorkOrderNote(mappedWorkOrderId, confirmationText);
|
||||||
|
scNotePosted = true;
|
||||||
|
logger(
|
||||||
|
'wo:completed',
|
||||||
|
`Posted close-out note to SC WO ${mappedWorkOrderId} by ${displayName}`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger('wo:completed', `Failed to post SC note for WO ${mappedWorkOrderId}: ${err.message}`, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const opsMessage = `${parsed.headerLine}\n${confirmationText}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await botClient.sendMarkdown(opsRoomId, opsMessage);
|
||||||
|
logger(
|
||||||
|
'wo:completed',
|
||||||
|
`Posted close-out approval for WO-${parsed.woNumber} to ops room ${opsRoomId} by ${displayName}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (scNotePosted) {
|
||||||
|
await bot.say('Close-out approval posted to ServiceChannel and the ServChan ops space.');
|
||||||
|
} else {
|
||||||
|
await bot.say(
|
||||||
|
'Close-out approval posted to the ServChan ops space, but adding the ServiceChannel note failed — please add it manually in SC.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('wo:completed', `Failed to post to ops room: ${err.message}`, 'error');
|
||||||
|
if (scNotePosted) {
|
||||||
|
await bot.say(
|
||||||
|
`Your confirmation was posted here and noted on the work order in ServiceChannel, but notifying the ops space failed: ${err.message}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await bot.say(
|
||||||
|
`Your confirmation was posted here, but the ServiceChannel note and ops space notification failed: ${err.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -398,21 +398,32 @@ export async function updateWorkOrderNte(woId, nteValue) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DEPRECATED. ServiceChannel v3 has no public POST /workorders/{id}/notes
|
* Add a note/comment to a work order.
|
||||||
* endpoint, so this call always returns 400. Notes must be attached to a
|
* POST /workorders/{workorderId}/notes — body requires `Note` (string).
|
||||||
* status/approve action (via the `Note` / `Comments` / `ReasonString` fields
|
*/
|
||||||
* on those requests). Left here only to preserve the export shape; will be
|
export async function addWorkOrderNote(woId, noteText, options = {}) {
|
||||||
* removed once no external code references it.
|
const text = String(noteText || '').trim();
|
||||||
|
if (!woId || !text) {
|
||||||
|
throw new Error('woId and noteText are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
Note: text,
|
||||||
|
ActionRequired: options.actionRequired === true,
|
||||||
|
};
|
||||||
|
if (options.mailedTo) body.MailedTo = options.mailedTo;
|
||||||
|
|
||||||
|
await scAxios.post(`/workorders/${woId}/notes`, body);
|
||||||
|
logger('sc:addWorkOrderNote', `Added note to WO ${woId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEPRECATED. Use addWorkOrderNote() instead.
|
||||||
*
|
*
|
||||||
* @deprecated pass audit text via `Comments`/`ReasonString` on approveProposal.
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
export async function addApprovalNote(woId, text) {
|
export async function addApprovalNote(woId, text) {
|
||||||
logger(
|
return addWorkOrderNote(woId, text);
|
||||||
'sc:addNote',
|
|
||||||
`addApprovalNote() is deprecated and does nothing (SC has no POST /workorders/${woId}/notes). ` +
|
|
||||||
`Attach note text via /approve or /status. Discarded text: "${String(text).substring(0, 80)}"`,
|
|
||||||
'warn'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,7 @@ export function createApp({
|
||||||
.archive { background-color: #fff3cd; }
|
.archive { background-color: #fff3cd; }
|
||||||
.delete { background-color: #f8d7da; }
|
.delete { background-color: #f8d7da; }
|
||||||
.remind { background-color: #d1ecf1; }
|
.remind { background-color: #d1ecf1; }
|
||||||
|
.digest { background-color: #d4edda; }
|
||||||
.skipped { color: #666; }
|
.skipped { color: #666; }
|
||||||
h1 { color: #333; }
|
h1 { color: #333; }
|
||||||
.banner { padding: 10px; border-radius: 4px; margin: 10px 0; }
|
.banner { padding: 10px; border-radius: 4px; margin: 10px 0; }
|
||||||
|
|
@ -199,7 +200,8 @@ export function createApp({
|
||||||
Auto-remove (prune ≥14d / delete ≥60d) for <code>COMPLETED / CONFIRMED</code>,
|
Auto-remove (prune ≥14d / delete ≥60d) for <code>COMPLETED / CONFIRMED</code>,
|
||||||
<code>COMPLETED / CANCELLED</code>, and <code>COMPLETED / NO CHARGE</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;
|
All other <code>COMPLETED</code> variants are held until one of those terminal statuses;
|
||||||
close-out reminders post in-room after ${esc(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60')} days (daily cron).
|
a consolidated daily digest posts to the ops room after ${esc(process.env.SPACE_CLEANUP_REMINDER_DAYS || '60')} days.
|
||||||
|
Use <code>/completed</code> in a ServChan WO space to confirm resolution verified.
|
||||||
</div>
|
</div>
|
||||||
<h2>Summary</h2>
|
<h2>Summary</h2>
|
||||||
<pre>${esc(JSON.stringify(summary, null, 2))}</pre>
|
<pre>${esc(JSON.stringify(summary, null, 2))}</pre>
|
||||||
|
|
@ -222,6 +224,7 @@ export function createApp({
|
||||||
results.forEach(r => {
|
results.forEach(r => {
|
||||||
const rowClass = r.action.includes('archive') || r.action.includes('remove') ? 'archive' :
|
const rowClass = r.action.includes('archive') || r.action.includes('remove') ? 'archive' :
|
||||||
r.action.includes('delete') ? 'delete' :
|
r.action.includes('delete') ? 'delete' :
|
||||||
|
r.action.includes('digest') ? 'digest' :
|
||||||
r.action.includes('remind') ? 'remind' : 'skipped';
|
r.action.includes('remind') ? 'remind' : 'skipped';
|
||||||
html += `
|
html += `
|
||||||
<tr class="${rowClass}">
|
<tr class="${rowClass}">
|
||||||
|
|
@ -433,12 +436,12 @@ export function setupCron({ db, runSpaceCleanup } = {}) {
|
||||||
if (db && runSpaceCleanup) {
|
if (db && runSpaceCleanup) {
|
||||||
const reminderCron = process.env.SPACE_CLEANUP_REMINDER_CRON || '0 0 14 * * *';
|
const reminderCron = process.env.SPACE_CLEANUP_REMINDER_CRON || '0 0 14 * * *';
|
||||||
cron.schedule(reminderCron, () => {
|
cron.schedule(reminderCron, () => {
|
||||||
logger('cron:spaceCleanupReminders', 'Starting daily close-out reminders');
|
logger('cron:spaceCleanupReminders', 'Starting daily consolidated close-out digest');
|
||||||
runSpaceCleanup(false, { db, remindersOnly: true }).catch(err => {
|
runSpaceCleanup(false, { db, remindersOnly: true }).catch(err => {
|
||||||
logger('cron:spaceCleanupReminders', `Unhandled: ${err.message}`, 'error');
|
logger('cron:spaceCleanupReminders', `Unhandled: ${err.message}`, 'error');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
logger('cron', `Space cleanup close-out reminders scheduled (${reminderCron})`);
|
logger('cron', `Space cleanup consolidated digest scheduled (${reminderCron})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -289,7 +289,7 @@ export function buildApprovalAdaptiveCard(wo, proposals = [], currentNte = 0, op
|
||||||
proposalAmount = items.reduce((sum, it) => sum + Number(it.Amount || it.Cost || it.Total || it.Value || 0), 0);
|
proposalAmount = items.reduce((sum, it) => sum + Number(it.Amount || it.Cost || it.Total || it.Value || 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const suggestedNte = (Number(currentNte) || 0) + proposalAmount;
|
const suggestedNte = proposalAmount;
|
||||||
|
|
||||||
const body = [
|
const body = [
|
||||||
{
|
{
|
||||||
|
|
@ -348,7 +348,7 @@ export function buildApprovalAdaptiveCard(wo, proposals = [], currentNte = 0, op
|
||||||
if (proposalAmount > 0) {
|
if (proposalAmount > 0) {
|
||||||
body.push({
|
body.push({
|
||||||
type: 'TextBlock',
|
type: 'TextBlock',
|
||||||
text: `Amount to add to NTE: **${_formatMoney(proposalAmount)}**`,
|
text: `Proposal total (new NTE): **${_formatMoney(proposalAmount)}**`,
|
||||||
weight: 'bolder',
|
weight: 'bolder',
|
||||||
color: 'attention',
|
color: 'attention',
|
||||||
});
|
});
|
||||||
|
|
@ -395,14 +395,14 @@ export function buildApprovalAdaptiveCard(wo, proposals = [], currentNte = 0, op
|
||||||
body.push(
|
body.push(
|
||||||
{
|
{
|
||||||
type: 'TextBlock',
|
type: 'TextBlock',
|
||||||
text: 'New NTE Amount (current NTE + proposal amount + any additional work)',
|
text: 'New NTE Amount (defaults to proposal total; adjust if needed)',
|
||||||
weight: 'bolder',
|
weight: 'bolder',
|
||||||
spacing: 'medium',
|
spacing: 'medium',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'Input.Number',
|
type: 'Input.Number',
|
||||||
id: 'newNte',
|
id: 'newNte',
|
||||||
value: suggestedNte || currentNte || 0,
|
value: suggestedNte || 0,
|
||||||
placeholder: 'Enter final approved NTE',
|
placeholder: 'Enter final approved NTE',
|
||||||
min: 0,
|
min: 0,
|
||||||
},
|
},
|
||||||
|
|
@ -529,7 +529,7 @@ export async function postApprovalPackage(webexClient, roomId, woObj, noteData =
|
||||||
? Number(selectedProposal.Amount || selectedProposal.Total || selectedProposal.TotalAmount || 0)
|
? Number(selectedProposal.Amount || selectedProposal.Total || selectedProposal.TotalAmount || 0)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const fallback = `Approval card for WO-${woObj.Number || woId}. Proposal amount: ${_formatMoney(proposalAmountForFallback)}. Suggested NTE: ${_formatMoney((Number(currentNte) || 0) + proposalAmountForFallback)}.`;
|
const fallback = `Approval card for WO-${woObj.Number || woId}. Proposal amount: ${_formatMoney(proposalAmountForFallback)}. Suggested NTE: ${_formatMoney(proposalAmountForFallback)}.`;
|
||||||
|
|
||||||
if (db) {
|
if (db) {
|
||||||
const existing = await getPendingApprovalCard(db, woId);
|
const existing = await getPendingApprovalCard(db, woId);
|
||||||
|
|
@ -883,7 +883,7 @@ export async function handleApprovalSubmit(bot, trigger, { db = null } = {}) {
|
||||||
? (nteSucceeded
|
? (nteSucceeded
|
||||||
? `NTE overridden to **${_formatMoney(numericNte)}**.`
|
? `NTE overridden to **${_formatMoney(numericNte)}**.`
|
||||||
: `NTE stays at SC's auto value (${_formatMoney(suggestedNte)}) — direct override was rejected. Adjust manually in SC if needed.`)
|
: `NTE stays at SC's auto value (${_formatMoney(suggestedNte)}) — direct override was rejected. Adjust manually in SC if needed.`)
|
||||||
: `NTE will be raised to **${_formatMoney(suggestedNte)}** by ServiceChannel automatically.`;
|
: `New NTE will be **${_formatMoney(suggestedNte)}** (proposal total) by ServiceChannel automatically.`;
|
||||||
|
|
||||||
let rejectLine = '';
|
let rejectLine = '';
|
||||||
if (rejectedList.length > 0) {
|
if (rejectedList.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { getWorkOrderStatus } from '../integrations/serviceChannel/client.js';
|
||||||
import botClient from '../integrations/webex/botClient.js';
|
import botClient from '../integrations/webex/botClient.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import defaultDb from '../db/mappings.js';
|
import defaultDb from '../db/mappings.js';
|
||||||
|
import { getCompletedOperationsRoomId, parseServChanRoomTitle } from '../utils/servchanRoomTitle.js';
|
||||||
|
|
||||||
const REMOVE_OTHERS_AFTER_DAYS = 14;
|
const REMOVE_OTHERS_AFTER_DAYS = 14;
|
||||||
const DELETE_AFTER_DAYS = 60;
|
const DELETE_AFTER_DAYS = 60;
|
||||||
|
|
@ -51,39 +52,57 @@ function daysSince(dateStr) {
|
||||||
return Math.floor((Date.now() - new Date(dateStr)) / (1000 * 3600 * 24));
|
return Math.floor((Date.now() - new Date(dateStr)) / (1000 * 3600 * 24));
|
||||||
}
|
}
|
||||||
|
|
||||||
function alreadyRemindedToday(lastReminderAt) {
|
function alreadyPostedToday(lastPostedAt) {
|
||||||
if (!lastReminderAt) return false;
|
if (!lastPostedAt) return false;
|
||||||
return new Date(lastReminderAt).toDateString() === new Date().toDateString();
|
return new Date(lastPostedAt).toDateString() === new Date().toDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCloseoutReminderMessage({ woNumber, workOrderId, daysSinceUpdate, statusDisplay }) {
|
function formatDigestDate() {
|
||||||
const label = woNumber || workOrderId;
|
return new Date().toLocaleDateString('en-US', {
|
||||||
const statusText = statusDisplay || 'COMPLETED';
|
weekday: 'short',
|
||||||
return (
|
month: 'short',
|
||||||
`**Close-out reminder:** Work order **${label}** is **${statusText}** in ServiceChannel ` +
|
day: 'numeric',
|
||||||
`and has been in that state for **${daysSinceUpdate} days**.\n\n` +
|
year: 'numeric',
|
||||||
`This space will not be removed until the work order reaches **Confirmed**, **Cancelled**, or **No Charge**. ` +
|
});
|
||||||
`Please review in ServiceChannel and close out this Webex space when appropriate.`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLastReminder(db, workOrderId) {
|
function buildConsolidatedDigestMessage(digestRows) {
|
||||||
|
const dateLabel = formatDigestDate();
|
||||||
|
const count = digestRows.length;
|
||||||
|
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`;
|
||||||
|
|
||||||
|
for (const row of digestRows) {
|
||||||
|
const header = row.headerLine || `WO-${row.woNumber || row.workOrderId}`;
|
||||||
|
md += `- **${header}** — ${row.statusDisplay} — ${row.ageDays} days\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
md +=
|
||||||
|
'\nTerminal statuses (Confirmed, Cancelled, No Charge) trigger auto-remove. ' +
|
||||||
|
'Use `/completed` in the WO space when verified working.';
|
||||||
|
|
||||||
|
return md;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLastDigestPostedAt(db) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
db.get(
|
db.get(
|
||||||
'SELECT lastReminderAt FROM space_cleanup_reminders WHERE workOrderId = ?',
|
'SELECT lastPostedAt FROM space_cleanup_digest WHERE id = 1',
|
||||||
[workOrderId],
|
[],
|
||||||
(err, row) => (err ? reject(err) : resolve(row?.lastReminderAt || null))
|
(err, row) => (err ? reject(err) : resolve(row?.lastPostedAt || null))
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordReminder(db, workOrderId) {
|
async function recordDigestPosted(db) {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO space_cleanup_reminders (workOrderId, lastReminderAt) VALUES (?, ?)
|
`INSERT INTO space_cleanup_digest (id, lastPostedAt) VALUES (1, ?)
|
||||||
ON CONFLICT(workOrderId) DO UPDATE SET lastReminderAt = excluded.lastReminderAt`,
|
ON CONFLICT(id) DO UPDATE SET lastPostedAt = excluded.lastPostedAt`,
|
||||||
[workOrderId, now],
|
[now],
|
||||||
(err) => (err ? reject(err) : resolve())
|
(err) => (err ? reject(err) : resolve())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -95,24 +114,25 @@ async function recordReminder(db, workOrderId) {
|
||||||
* @param {boolean} dryRun
|
* @param {boolean} dryRun
|
||||||
* @param {object} [options]
|
* @param {object} [options]
|
||||||
* @param {object} [options.db] - Optional sqlite3 database instance.
|
* @param {object} [options.db] - Optional sqlite3 database instance.
|
||||||
* @param {boolean} [options.remindersOnly] - When true, only post close-out reminders (no prune/delete).
|
* @param {boolean} [options.remindersOnly] - When true, only post consolidated digest (no prune/delete).
|
||||||
*/
|
*/
|
||||||
export async function runSpaceCleanup(dryRun = true, options = {}) {
|
export async function runSpaceCleanup(dryRun = true, options = {}) {
|
||||||
const { db = defaultDb, remindersOnly = false } = options;
|
const { db = defaultDb, remindersOnly = false } = options;
|
||||||
|
|
||||||
const mode = dryRun ? '[DRY-RUN]' : '[LIVE]';
|
const mode = dryRun ? '[DRY-RUN]' : '[LIVE]';
|
||||||
const scope = remindersOnly ? 'reminders only' : 'full cleanup';
|
const scope = remindersOnly ? 'digest only' : 'full cleanup';
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`[SPACE-CLEANUP] ${mode} Starting job (${scope} | auto-remove ≥${REMOVE_OTHERS_AFTER_DAYS}d prune / ≥${DELETE_AFTER_DAYS}d delete | reminders ≥${REMINDER_AFTER_DAYS}d)...`
|
`[SPACE-CLEANUP] ${mode} Starting job (${scope} | auto-remove ≥${REMOVE_OTHERS_AFTER_DAYS}d prune / ≥${DELETE_AFTER_DAYS}d delete | digest ≥${REMINDER_AFTER_DAYS}d)...`
|
||||||
);
|
);
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
|
const digestRows = [];
|
||||||
let autoRemoveEligible = 0;
|
let autoRemoveEligible = 0;
|
||||||
let remindEligible = 0;
|
let remindEligible = 0;
|
||||||
let removedOthers = 0;
|
let removedOthers = 0;
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
let reminded = 0;
|
let digestPosted = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
|
|
||||||
|
|
@ -168,60 +188,41 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
|
||||||
action: 'skipped',
|
action: 'skipped',
|
||||||
days: ageDays,
|
days: ageDays,
|
||||||
status: statusDisplay,
|
status: statusDisplay,
|
||||||
reason: `${statusDisplay} — ${ageDays} days (reminder at ${REMINDER_AFTER_DAYS})`,
|
reason: `${statusDisplay} — ${ageDays} days (digest at ${REMINDER_AFTER_DAYS})`,
|
||||||
});
|
});
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastReminderAt = await getLastReminder(db, mapping.workOrderId);
|
let headerLine = null;
|
||||||
if (alreadyRemindedToday(lastReminderAt)) {
|
try {
|
||||||
results.push({
|
const room = await botClient.getRoom(mapping.roomId);
|
||||||
woId: mapping.workOrderId,
|
const parsed = parseServChanRoomTitle(room?.title);
|
||||||
roomId: mapping.roomId,
|
headerLine = parsed?.headerLine || room?.title || null;
|
||||||
action: 'skipped',
|
} catch (err) {
|
||||||
days: ageDays,
|
|
||||||
status: statusDisplay,
|
|
||||||
reason: 'reminder already posted today',
|
|
||||||
});
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reminderText = buildCloseoutReminderMessage({
|
|
||||||
woNumber: statusInfo.woNumber,
|
|
||||||
workOrderId: mapping.workOrderId,
|
|
||||||
daysSinceUpdate: ageDays,
|
|
||||||
statusDisplay,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
results.push({
|
|
||||||
woId: mapping.workOrderId,
|
|
||||||
roomId: mapping.roomId,
|
|
||||||
action: 'would_remind',
|
|
||||||
days: ageDays,
|
|
||||||
status: statusDisplay,
|
|
||||||
reason: `${ageDays} days → post close-out reminder`,
|
|
||||||
});
|
|
||||||
reminded++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await botClient.sendMarkdown(mapping.roomId, reminderText);
|
|
||||||
await recordReminder(db, mapping.workOrderId);
|
|
||||||
logger(
|
logger(
|
||||||
'SPACE-CLEANUP',
|
'SPACE-CLEANUP',
|
||||||
`Posted close-out reminder in room ${mapping.roomId} for WO ${mapping.workOrderId} [${statusDisplay}]`
|
`Could not fetch room title for ${mapping.roomId}: ${err.message}`,
|
||||||
|
'warn'
|
||||||
);
|
);
|
||||||
reminded++;
|
}
|
||||||
|
|
||||||
|
digestRows.push({
|
||||||
|
workOrderId: mapping.workOrderId,
|
||||||
|
woNumber: statusInfo.woNumber,
|
||||||
|
statusDisplay,
|
||||||
|
ageDays,
|
||||||
|
roomId: mapping.roomId,
|
||||||
|
headerLine,
|
||||||
|
});
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
woId: mapping.workOrderId,
|
woId: mapping.workOrderId,
|
||||||
roomId: mapping.roomId,
|
roomId: mapping.roomId,
|
||||||
action: 'remind',
|
action: dryRun ? 'would_post_digest' : 'pending_digest',
|
||||||
days: ageDays,
|
days: ageDays,
|
||||||
status: statusDisplay,
|
status: statusDisplay,
|
||||||
reason: `${ageDays} days → posted close-out reminder`,
|
reason: `${ageDays} days → include in consolidated digest`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -234,7 +235,7 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
|
||||||
action: 'skipped',
|
action: 'skipped',
|
||||||
days: ageDays,
|
days: ageDays,
|
||||||
status: statusDisplay,
|
status: statusDisplay,
|
||||||
reason: 'auto-remove skipped (reminders-only run)',
|
reason: 'auto-remove skipped (digest-only run)',
|
||||||
});
|
});
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -318,13 +319,67 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let digestWouldPost = false;
|
||||||
|
let digestSkippedReason = null;
|
||||||
|
|
||||||
|
if (digestRows.length > 0) {
|
||||||
|
const opsRoomId = getCompletedOperationsRoomId();
|
||||||
|
|
||||||
|
if (!opsRoomId) {
|
||||||
|
digestSkippedReason = 'COMPLETED_OPERATIONS_ROOM_ID not configured';
|
||||||
|
logger('SPACE-CLEANUP', digestSkippedReason, 'warn');
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.action === 'would_post_digest' || r.action === 'pending_digest') {
|
||||||
|
r.action = 'skipped';
|
||||||
|
r.reason = digestSkippedReason;
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const lastDigestAt = await getLastDigestPostedAt(db);
|
||||||
|
if (alreadyPostedToday(lastDigestAt)) {
|
||||||
|
digestSkippedReason = 'consolidated digest already posted today';
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.action === 'would_post_digest' || r.action === 'pending_digest') {
|
||||||
|
r.action = 'skipped';
|
||||||
|
r.reason = digestSkippedReason;
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
digestWouldPost = true;
|
||||||
|
|
||||||
|
if (!dryRun) {
|
||||||
|
const digestMarkdown = buildConsolidatedDigestMessage(digestRows);
|
||||||
|
await botClient.sendMarkdown(opsRoomId, digestMarkdown);
|
||||||
|
await recordDigestPosted(db);
|
||||||
|
digestPosted = 1;
|
||||||
|
logger(
|
||||||
|
'SPACE-CLEANUP',
|
||||||
|
`Posted consolidated digest (${digestRows.length} WOs) to ops room ${opsRoomId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.action === 'pending_digest') {
|
||||||
|
r.action = 'post_digest';
|
||||||
|
r.reason = `${r.days} days → included in consolidated digest`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const summary = {
|
const summary = {
|
||||||
totalChecked: mappings.length,
|
totalChecked: mappings.length,
|
||||||
autoRemoveEligible,
|
autoRemoveEligible,
|
||||||
remindEligible,
|
remindEligible,
|
||||||
|
digestEligible: digestRows.length,
|
||||||
|
digestPosted,
|
||||||
|
digestWouldPost: dryRun && digestWouldPost && digestRows.length > 0,
|
||||||
|
digestSkippedReason,
|
||||||
removedOthers,
|
removedOthers,
|
||||||
deleted,
|
deleted,
|
||||||
reminded,
|
|
||||||
skipped,
|
skipped,
|
||||||
failed,
|
failed,
|
||||||
dryRun,
|
dryRun,
|
||||||
|
|
@ -341,9 +396,6 @@ export async function runSpaceCleanup(dryRun = true, options = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the bot's own identity so we never accidentally kick ourselves out of
|
|
||||||
// a room during "remove other members". We resolve it once from the Webex API,
|
|
||||||
// which is authoritative regardless of what env vars are set.
|
|
||||||
let _cachedBotPerson = null;
|
let _cachedBotPerson = null;
|
||||||
async function getBotIdentity(axiosInstance) {
|
async function getBotIdentity(axiosInstance) {
|
||||||
if (_cachedBotPerson) return _cachedBotPerson;
|
if (_cachedBotPerson) return _cachedBotPerson;
|
||||||
|
|
|
||||||
22
src/utils/servchanRoomTitle.js
Normal file
22
src/utils/servchanRoomTitle.js
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* Parse ServChan WO room titles: "ServChan WO-{n} | Store {id} | {location}"
|
||||||
|
*/
|
||||||
|
const SERVCHAN_ROOM_TITLE_RE = /^ServChan WO-(\d+)\s*\|\s*Store\s*([^|]+)\|\s*(.+)$/i;
|
||||||
|
|
||||||
|
export function parseServChanRoomTitle(title) {
|
||||||
|
if (!title || typeof title !== 'string') return null;
|
||||||
|
|
||||||
|
const m = title.trim().match(SERVCHAN_ROOM_TITLE_RE);
|
||||||
|
if (!m) return null;
|
||||||
|
|
||||||
|
const woNumber = m[1];
|
||||||
|
const storeId = m[2].trim();
|
||||||
|
const locationName = m[3].trim();
|
||||||
|
const headerLine = `ServChan WO-${woNumber} | Store ${storeId} | ${locationName}`;
|
||||||
|
|
||||||
|
return { woNumber, storeId, locationName, headerLine };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCompletedOperationsRoomId() {
|
||||||
|
return (process.env.COMPLETED_OPERATIONS_ROOM_ID || '').trim() || null;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue