diff --git a/.env.example b/.env.example index 60e7436..e070581 100644 --- a/.env.example +++ b/.env.example @@ -39,11 +39,12 @@ CS_API_BASE=https://bot.joesjavajoint.com/CollabSupport # OPTISIGN_API_KEY=... # --- Space cleanup (optional) --- -# Days after any non-terminal COMPLETED status before posting a daily -# close-out reminder in the Webex room. Auto-remove still applies only to -# COMPLETED/CONFIRMED, COMPLETED/CANCELLED, and COMPLETED/NO CHARGE. +# Webex room for daily consolidated close-out digest and /completed ops notifications. +# COMPLETED_OPERATIONS_ROOM_ID=Y2lzY29zcGFyazovL3VzL1JPT00vNjdmZmYxZTAtZmM3Ny0xMWYwLWE2MzUtZGY0ZmQ4NWUwMGMz +# 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 -# 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 * * * # --- Admin endpoints (/cleanup-test, /stale-workorders) --- diff --git a/index.js b/index.js index 509fe1e..8be4afa 100644 --- a/index.js +++ b/index.js @@ -86,9 +86,9 @@ db.serialize(() => { ) `); db.run(` - CREATE TABLE IF NOT EXISTS space_cleanup_reminders ( - workOrderId INTEGER PRIMARY KEY, - lastReminderAt TEXT NOT NULL + CREATE TABLE IF NOT EXISTS space_cleanup_digest ( + id INTEGER PRIMARY KEY CHECK (id = 1), + lastPostedAt TEXT NOT NULL ) `); console.log(`[DB] Connected to ${DB_PATH}`); diff --git a/src/bot/index.js b/src/bot/index.js index d6052eb..4754015 100644 --- a/src/bot/index.js +++ b/src/bot/index.js @@ -16,6 +16,7 @@ import { handleWoAttachments } from '../commands/woAttachments.js'; import { handleWoHistory } from '../commands/woHistory.js'; import { handleAvStatus } from '../commands/avStatus.js'; import { handleWoApprove } from '../commands/woApprove.js'; +import { handleWoCompleted } from '../commands/woCompleted.js'; import approvalService from '../services/approvalService.js'; import { installMercuryGuard } from './mercuryGuard.js'; import db from '../db/mappings.js'; @@ -121,6 +122,9 @@ export function initializeBot({ webexConfig }) { case 'requestapproval': await handleWoApprove(bot, trigger); break; + case 'completed': + await handleWoCompleted(bot, trigger); + break; default: await handleUnknown(bot, trigger); break; diff --git a/src/commands/help.js b/src/commands/help.js index ba52fee..207fbc1 100644 --- a/src/commands/help.js +++ b/src/commands/help.js @@ -10,6 +10,7 @@ export async function handleHelp(bot, trigger) { 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 += `- **/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 { text += `- **/woSummary ** — status of any work order\n`; text += `- **/woHistory ** — History of AV issues.\n`; diff --git a/src/commands/woCompleted.js b/src/commands/woCompleted.js new file mode 100644 index 0000000..12c36a2 --- /dev/null +++ b/src/commands/woCompleted.js @@ -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}` + ); + } + } +} diff --git a/src/integrations/serviceChannel/client.js b/src/integrations/serviceChannel/client.js index 3cec836..d0818bb 100644 --- a/src/integrations/serviceChannel/client.js +++ b/src/integrations/serviceChannel/client.js @@ -398,21 +398,32 @@ export async function updateWorkOrderNte(woId, nteValue) { } /** - * DEPRECATED. ServiceChannel v3 has no public POST /workorders/{id}/notes - * endpoint, so this call always returns 400. Notes must be attached to a - * status/approve action (via the `Note` / `Comments` / `ReasonString` fields - * on those requests). Left here only to preserve the export shape; will be - * removed once no external code references it. + * Add a note/comment to a work order. + * POST /workorders/{workorderId}/notes — body requires `Note` (string). + */ +export async function addWorkOrderNote(woId, noteText, options = {}) { + 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) { - logger( - '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' - ); + return addWorkOrderNote(woId, text); } /** diff --git a/src/server/app.js b/src/server/app.js index 42b82dc..5067592 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -180,6 +180,7 @@ export function createApp({ .archive { background-color: #fff3cd; } .delete { background-color: #f8d7da; } .remind { background-color: #d1ecf1; } + .digest { background-color: #d4edda; } .skipped { color: #666; } h1 { color: #333; } .banner { padding: 10px; border-radius: 4px; margin: 10px 0; } @@ -199,7 +200,8 @@ export function createApp({ 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; - 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 /completed in a ServChan WO space to confirm resolution verified.

Summary

${esc(JSON.stringify(summary, null, 2))}
@@ -222,6 +224,7 @@ export function createApp({ results.forEach(r => { const rowClass = r.action.includes('archive') || r.action.includes('remove') ? 'archive' : r.action.includes('delete') ? 'delete' : + r.action.includes('digest') ? 'digest' : r.action.includes('remind') ? 'remind' : 'skipped'; html += ` @@ -433,12 +436,12 @@ export function setupCron({ db, runSpaceCleanup } = {}) { if (db && runSpaceCleanup) { const reminderCron = process.env.SPACE_CLEANUP_REMINDER_CRON || '0 0 14 * * *'; 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 => { logger('cron:spaceCleanupReminders', `Unhandled: ${err.message}`, 'error'); }); }); - logger('cron', `Space cleanup close-out reminders scheduled (${reminderCron})`); + logger('cron', `Space cleanup consolidated digest scheduled (${reminderCron})`); } } diff --git a/src/services/approvalService.js b/src/services/approvalService.js index 4c9fb65..f5eece8 100644 --- a/src/services/approvalService.js +++ b/src/services/approvalService.js @@ -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); } - const suggestedNte = (Number(currentNte) || 0) + proposalAmount; + const suggestedNte = proposalAmount; const body = [ { @@ -348,7 +348,7 @@ export function buildApprovalAdaptiveCard(wo, proposals = [], currentNte = 0, op if (proposalAmount > 0) { body.push({ type: 'TextBlock', - text: `Amount to add to NTE: **${_formatMoney(proposalAmount)}**`, + text: `Proposal total (new NTE): **${_formatMoney(proposalAmount)}**`, weight: 'bolder', color: 'attention', }); @@ -395,14 +395,14 @@ export function buildApprovalAdaptiveCard(wo, proposals = [], currentNte = 0, op body.push( { 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', spacing: 'medium', }, { type: 'Input.Number', id: 'newNte', - value: suggestedNte || currentNte || 0, + value: suggestedNte || 0, placeholder: 'Enter final approved NTE', min: 0, }, @@ -529,7 +529,7 @@ export async function postApprovalPackage(webexClient, roomId, woObj, noteData = ? Number(selectedProposal.Amount || selectedProposal.Total || selectedProposal.TotalAmount || 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) { const existing = await getPendingApprovalCard(db, woId); @@ -883,7 +883,7 @@ export async function handleApprovalSubmit(bot, trigger, { db = null } = {}) { ? (nteSucceeded ? `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 will be raised to **${_formatMoney(suggestedNte)}** by ServiceChannel automatically.`; + : `New NTE will be **${_formatMoney(suggestedNte)}** (proposal total) by ServiceChannel automatically.`; let rejectLine = ''; if (rejectedList.length > 0) { diff --git a/src/services/spaceCleanupService.js b/src/services/spaceCleanupService.js index 564f65c..0b44e70 100644 --- a/src/services/spaceCleanupService.js +++ b/src/services/spaceCleanupService.js @@ -3,6 +3,7 @@ import { getWorkOrderStatus } from '../integrations/serviceChannel/client.js'; import botClient from '../integrations/webex/botClient.js'; import { logger } from '../utils/logger.js'; import defaultDb from '../db/mappings.js'; +import { getCompletedOperationsRoomId, parseServChanRoomTitle } from '../utils/servchanRoomTitle.js'; const REMOVE_OTHERS_AFTER_DAYS = 14; const DELETE_AFTER_DAYS = 60; @@ -51,39 +52,57 @@ function daysSince(dateStr) { return Math.floor((Date.now() - new Date(dateStr)) / (1000 * 3600 * 24)); } -function alreadyRemindedToday(lastReminderAt) { - if (!lastReminderAt) return false; - return new Date(lastReminderAt).toDateString() === new Date().toDateString(); +function alreadyPostedToday(lastPostedAt) { + if (!lastPostedAt) return false; + return new Date(lastPostedAt).toDateString() === new Date().toDateString(); } -function buildCloseoutReminderMessage({ woNumber, workOrderId, daysSinceUpdate, statusDisplay }) { - const label = woNumber || workOrderId; - const statusText = statusDisplay || 'COMPLETED'; - return ( - `**Close-out reminder:** Work order **${label}** is **${statusText}** in ServiceChannel ` + - `and has been in that state for **${daysSinceUpdate} days**.\n\n` + - `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.` - ); +function formatDigestDate() { + return new Date().toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); } -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) => { db.get( - 'SELECT lastReminderAt FROM space_cleanup_reminders WHERE workOrderId = ?', - [workOrderId], - (err, row) => (err ? reject(err) : resolve(row?.lastReminderAt || null)) + 'SELECT lastPostedAt FROM space_cleanup_digest WHERE id = 1', + [], + (err, row) => (err ? reject(err) : resolve(row?.lastPostedAt || null)) ); }); } -async function recordReminder(db, workOrderId) { +async function recordDigestPosted(db) { const now = new Date().toISOString(); return new Promise((resolve, reject) => { db.run( - `INSERT INTO space_cleanup_reminders (workOrderId, lastReminderAt) VALUES (?, ?) - ON CONFLICT(workOrderId) DO UPDATE SET lastReminderAt = excluded.lastReminderAt`, - [workOrderId, now], + `INSERT INTO space_cleanup_digest (id, lastPostedAt) VALUES (1, ?) + ON CONFLICT(id) DO UPDATE SET lastPostedAt = excluded.lastPostedAt`, + [now], (err) => (err ? reject(err) : resolve()) ); }); @@ -95,24 +114,25 @@ async function recordReminder(db, workOrderId) { * @param {boolean} dryRun * @param {object} [options] * @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 = {}) { const { db = defaultDb, remindersOnly = false } = options; const mode = dryRun ? '[DRY-RUN]' : '[LIVE]'; - const scope = remindersOnly ? 'reminders only' : 'full cleanup'; + 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 | 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 digestRows = []; let autoRemoveEligible = 0; let remindEligible = 0; let removedOthers = 0; let deleted = 0; - let reminded = 0; + let digestPosted = 0; let skipped = 0; let failed = 0; @@ -168,60 +188,41 @@ export async function runSpaceCleanup(dryRun = true, options = {}) { action: 'skipped', days: ageDays, status: statusDisplay, - reason: `${statusDisplay} — ${ageDays} days (reminder at ${REMINDER_AFTER_DAYS})`, + reason: `${statusDisplay} — ${ageDays} days (digest at ${REMINDER_AFTER_DAYS})`, }); skipped++; continue; } - const lastReminderAt = await getLastReminder(db, mapping.workOrderId); - if (alreadyRemindedToday(lastReminderAt)) { - results.push({ - woId: mapping.workOrderId, - roomId: mapping.roomId, - action: 'skipped', - days: ageDays, - status: statusDisplay, - reason: 'reminder already posted today', - }); - skipped++; - continue; + let headerLine = null; + try { + const room = await botClient.getRoom(mapping.roomId); + const parsed = parseServChanRoomTitle(room?.title); + headerLine = parsed?.headerLine || room?.title || null; + } catch (err) { + logger( + 'SPACE-CLEANUP', + `Could not fetch room title for ${mapping.roomId}: ${err.message}`, + 'warn' + ); } - const reminderText = buildCloseoutReminderMessage({ - woNumber: statusInfo.woNumber, + digestRows.push({ workOrderId: mapping.workOrderId, - daysSinceUpdate: ageDays, + woNumber: statusInfo.woNumber, statusDisplay, + ageDays, + roomId: mapping.roomId, + headerLine, }); - 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( - 'SPACE-CLEANUP', - `Posted close-out reminder in room ${mapping.roomId} for WO ${mapping.workOrderId} [${statusDisplay}]` - ); - reminded++; results.push({ woId: mapping.workOrderId, roomId: mapping.roomId, - action: 'remind', + action: dryRun ? 'would_post_digest' : 'pending_digest', days: ageDays, status: statusDisplay, - reason: `${ageDays} days → posted close-out reminder`, + reason: `${ageDays} days → include in consolidated digest`, }); continue; } @@ -234,7 +235,7 @@ export async function runSpaceCleanup(dryRun = true, options = {}) { action: 'skipped', days: ageDays, status: statusDisplay, - reason: 'auto-remove skipped (reminders-only run)', + reason: 'auto-remove skipped (digest-only run)', }); skipped++; 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 = { totalChecked: mappings.length, autoRemoveEligible, remindEligible, + digestEligible: digestRows.length, + digestPosted, + digestWouldPost: dryRun && digestWouldPost && digestRows.length > 0, + digestSkippedReason, removedOthers, deleted, - reminded, skipped, failed, 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; async function getBotIdentity(axiosInstance) { if (_cachedBotPerson) return _cachedBotPerson; diff --git a/src/utils/servchanRoomTitle.js b/src/utils/servchanRoomTitle.js new file mode 100644 index 0000000..e837017 --- /dev/null +++ b/src/utils/servchanRoomTitle.js @@ -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; +}