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:
jmcqueen 2026-08-25 08:59:15 -04:00
parent 7ea64b5f24
commit bd02d51111
10 changed files with 302 additions and 95 deletions

View file

@ -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) ---

View file

@ -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}`);

View file

@ -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;

View file

@ -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 <WO-number>** — status of any work order\n`;
text += `- **/woHistory <store-number>** — History of AV issues.\n`;

113
src/commands/woCompleted.js Normal file
View 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}`
);
}
}
}

View file

@ -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);
}
/**

View file

@ -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 <code>COMPLETED / CONFIRMED</code>,
<code>COMPLETED / CANCELLED</code>, and <code>COMPLETED / NO CHARGE</code>.
All other <code>COMPLETED</code> variants are held until one of those terminal statuses;
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>
<h2>Summary</h2>
<pre>${esc(JSON.stringify(summary, null, 2))}</pre>
@ -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 += `
<tr class="${rowClass}">
@ -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})`);
}
}

View file

@ -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) {

View file

@ -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;

View 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;
}