openvibers/openvibe.chat Commits

Support inboxes: records (finished conversations kept as long as the owner says, swept hourly; waiting and open ones never) and the owner's export (CSV, JSON) (#158)

OP OpenVibers committed 19 days ago · 37d2a60175 · 9 files, +182 −7

Changes

ddiff --git a/README.md b/README.md
iindex 802f576..506d6ac 100644
---- a/README.md
++++ b/README.md
@@@ -495,6 +495,13 @@ for people and for companies, with "direct live support with an agent", "fully m
`{ "text": "…" }` or plain text within 8 seconds (4 KB), goes back to Vibe as data, never instructions. `GET/POST
/api/chat/inboxes/:id/actions { name, description, url, params }`, `POST /:id/actions/:actionId/test { arguments }`
("Try it" in the settings), `DELETE /:id/actions/:actionId`.
++- **Records** (`server/inbox/records.js`): how long finished conversations stay, set in the settings ("Keep finished
++ conversations": forever, 30 or 90 days, 6 months, 1 or 2 years) or with `PATCH /api/chat/inboxes/:id { retention_days:
++ 7…3650 | null }`. Every hour, the conversations that are resolved or that only Vibe answered and that have been quiet
++ longer than that go, with their messages and translations; a visitor waiting for a person or a conversation a person
++ has open never does. The owner downloads everything (the 5,000 newest conversations with their messages): `GET
++ /api/chat/inboxes/:id/export?format=csv|json`, or the settings page's links. In the CSV, one row per message, a cell
++ that would run as a spreadsheet formula starts with `'`.
- **Pictures:** one of the inbox's people sends a picture from the reply box (`POST
/api/chat/inboxes/:id/conversations/:cid/picture`, the body is the picture, a line in `X-Caption`); it goes to
OpenVibe.Pics as them, like a room's picture, and the message carries its link, which the visitor's panel and the
ddiff --git a/docs/platform.md b/docs/platform.md
iindex cd903cb..ce8e10f 100644
---- a/docs/platform.md
++++ b/docs/platform.md
@@@ -65,5 +65,6 @@ center (MagicBrowse privacy), getstream.io (Stream vs Sendbird), rumbletalk.com,
5. **Enterprise**: built: verified visitors (an HMAC of the site's user id, like Intercom's identity verification),
saved replies (the canned replies of other tools), ratings (CSAT), page context for Vibe, notifications,
translation, business hours, routing (assigning, taking turns) and actions (Vibe calls the team's own endpoints,
-- signed, with the verified visitor, to look up an order: Intercom's "data connectors", Zendesk's "actions"). Next:
-- retention and export settings, sharing the page with the visitor's permission (co-browsing-lite).
++ signed, with the verified visitor, to look up an order: Intercom's "data connectors", Zendesk's "actions"),
++ retention (finished conversations kept 30 days to 2 years, or forever) and export (CSV, JSON). Next: sharing the page
++ with the visitor's permission (co-browsing-lite).
ddiff --git a/migrations/0026_inbox_retention.sql b/migrations/0026_inbox_retention.sql
nnew file mode 100644
iindex 0000000..a59fb8b
---- /dev/null
++++ b/migrations/0026_inbox_retention.sql
@@@ -0,0 +1,4 @@
++-- phase: expand
++-- How long an inbox keeps finished conversations (server/inbox/records.js): null keeps them; otherwise a resolved
++-- conversation, or one only Vibe answered, quiet for longer than this many days goes with its messages.
++ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS retention_days integer CHECK (retention_days IS NULL OR retention_days BETWEEN 7 AND 3650);
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 8bb44b7..7ae4c8e 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -112,6 +112,7 @@ class ChatServer {
/** @type {Map<number, {count: number, windowStart: number}>} streamId → stream-level soundboard rate window */
this.soundboardStreamLimits = new Map();
this._autoDeleteSweepInterval = null;
++ this._inboxRetentionInterval = null;
}
normalizeIp(ip) {
@@@ -234,6 +235,12 @@ class ChatServer {
await this._sweepExpiredChatMessages();
}, CHAT_AUTO_DELETE_SWEEP_MS);
await this._sweepExpiredChatMessages();
++ // Support inboxes keep finished conversations as long as their owners say (server/inbox/records.js): hourly.
++ if (this._inboxRetentionInterval) clearInterval(this._inboxRetentionInterval);
++ this._inboxRetentionInterval = setInterval(() => {
++ require('../inbox/records').sweep().then((r) => { if (r.deleted) console.log(`[Inbox] retention: ${r.deleted} finished conversations removed`); }).catch((e) => console.warn('[Inbox] retention sweep:', e && e.message));
++ }, 60 * 60 * 1000);
++ if (this._inboxRetentionInterval.unref) this._inboxRetentionInterval.unref();
this.wss.on('connection', (ws, req) => {
this.handleConnection(ws, req);
@@@ -2953,6 +2960,10 @@ class ChatServer {
clearInterval(this._autoDeleteSweepInterval);
this._autoDeleteSweepInterval = null;
}
++ if (this._inboxRetentionInterval) {
++ clearInterval(this._inboxRetentionInterval);
++ this._inboxRetentionInterval = null;
++ }
// The queue's work in flight (a clip being made or delivered) ends before the sockets and the database close.
await audioQueue.stop();
this.wss.clients.forEach(ws => ws.close());
ddiff --git a/server/inbox/inbox.js b/server/inbox/inbox.js
iindex 3708328..3c306e5 100644
---- a/server/inbox/inbox.js
++++ b/server/inbox/inbox.js
@@@ -72,6 +72,11 @@ function validate(input = {}, current = null) {
if (input.ai_enabled !== undefined) out.ai_enabled = bool(input.ai_enabled) ? 1 : 0;
if (input.handoff !== undefined) out.handoff = bool(input.handoff) ? 1 : 0;
if (input.hours !== undefined) out.hours = require('./hours').normalize(input.hours, fail);
++ if (input.retention_days !== undefined) {
++ const d = input.retention_days === null || input.retention_days === '' ? null : Number(input.retention_days);
++ if (d !== null && !(Number.isInteger(d) && d >= 7 && d <= 3650)) fail(422, 'inbox.retention', 'Keep conversations for 7 to 3650 days, or null to keep them');
++ out.retention_days = d;
++ }
if (input.route !== undefined) {
if (!['none', 'round_robin'].includes(String(input.route))) fail(422, 'inbox.route', 'Routing is none or round_robin');
out.route = String(input.route);
@@@ -130,8 +135,8 @@ async function update(inbox, user, input = {}) {
const v = validate(input, inbox);
const next = { ...inbox, ...v };
const hoursJson = (h) => (h == null ? null : typeof h === 'string' ? h : JSON.stringify(h));
-- await db.run(`UPDATE inboxes SET name = ?, greeting = ?, color = ?, ai_enabled = ?, handoff = ?, ai_instructions = ?, knowledge_urls = ?, allowed_origins = ?, hours = ?, route = ? WHERE id = ?`,
-- [next.name, next.greeting, next.color, Number(next.ai_enabled) ? 1 : 0, Number(next.handoff ?? 1) ? 1 : 0, next.ai_instructions, JSON.stringify(json(next.knowledge_urls, [])), JSON.stringify(json(next.allowed_origins, [])), hoursJson(next.hours), next.route || 'none', inbox.id]);
++ await db.run(`UPDATE inboxes SET name = ?, greeting = ?, color = ?, ai_enabled = ?, handoff = ?, ai_instructions = ?, knowledge_urls = ?, allowed_origins = ?, hours = ?, route = ?, retention_days = ? WHERE id = ?`,
++ [next.name, next.greeting, next.color, Number(next.ai_enabled) ? 1 : 0, Number(next.handoff ?? 1) ? 1 : 0, next.ai_instructions, JSON.stringify(json(next.knowledge_urls, [])), JSON.stringify(json(next.allowed_origins, [])), hoursJson(next.hours), next.route || 'none', next.retention_days == null ? null : Number(next.retention_days), inbox.id]);
return await get(inbox.id);
}
@@@ -154,7 +159,7 @@ async function mine(user) {
function shape(r) {
return {
id: r.id, name: r.name, greeting: r.greeting, color: r.color, ai_enabled: !!Number(r.ai_enabled), handoff: peopleAnswer(r), ai_instructions: r.ai_instructions,
-- verified_visitors: !!r.identity_secret, hours: hoursOf(r), route: r.route || 'none',
++ verified_visitors: !!r.identity_secret, hours: hoursOf(r), route: r.route || 'none', retention_days: r.retention_days == null ? null : Number(r.retention_days),
knowledge_urls: json(r.knowledge_urls, []), allowed_origins: json(r.allowed_origins, []), created_at: r.created_at,
};
}
ddiff --git a/server/inbox/records.js b/server/inbox/records.js
nnew file mode 100644
iindex 0000000..a9f073f
---- /dev/null
++++ b/server/inbox/records.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * An inbox's records (migrations/0026_inbox_retention.sql): how long its finished conversations are kept, and the
++ * owner's export of them.
++ *
++ * sweep({ now? }) → { deleted } hourly (server/chat/chat-server.js): in each inbox with retention_days, conversations
++ * that are resolved, or that only Vibe ever answered, and quiet for longer than that,
++ * go with their messages and translations, 500 an inbox a pass. A visitor waiting for a
++ * person, or a conversation a person has open, is never swept.
++ * conversations(inbox) → [{ ...convShape, messages }] newest first, at most 5,000 (the JSON export)
++ * csv(inbox) → text the same, one row per message
++ */
++const db = require('../db/database');
++
++const RETENTION_CHOICES = [30, 90, 180, 365, 730]; // the settings' choices; the API takes 7 to 3650
++const BATCH = 500;
++const EXPORT_MAX = 5000;
++
++/** ov_now()'s format, for comparing with the conversations' text timestamps. */
++const stamp = (d) => d.toISOString().replace('T', ' ').slice(0, 19);
++
++async function sweep({ now = new Date() } = {}) {
++ let deleted = 0;
++ const kept = await db.all('SELECT id, retention_days FROM inboxes WHERE retention_days IS NOT NULL');
++ for (const ib of kept) {
++ const cutoff = stamp(new Date(now.getTime() - Number(ib.retention_days) * 86_400_000));
++ const ids = (await db.all(`SELECT id FROM inbox_conversations WHERE inbox_id = ? AND status IN ('resolved', 'bot') AND COALESCE(last_message_at, created_at) < ?
++ ORDER BY COALESCE(last_message_at, created_at) LIMIT ${BATCH}`, [ib.id, cutoff])).map((r) => r.id);
++ if (!ids.length) continue;
++ await db.run('DELETE FROM inbox_translations WHERE message_id IN (SELECT id FROM inbox_messages WHERE conversation_id = ANY(?))', [ids]);
++ await db.run('DELETE FROM inbox_messages WHERE conversation_id = ANY(?)', [ids]);
++ await db.run('DELETE FROM inbox_conversations WHERE id = ANY(?)', [ids]);
++ deleted += ids.length;
++ }
++ return { deleted };
++}
++
++async function conversations(inbox) {
++ const { convShape } = require('./inbox');
++ const convs = await db.all(`SELECT * FROM inbox_conversations WHERE inbox_id = ? ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ${EXPORT_MAX}`, [inbox.id]);
++ const byConv = new Map(convs.map((c) => [c.id, []]));
++ if (convs.length) {
++ for (const m of await db.all('SELECT id, conversation_id, author, author_name, body, created_at FROM inbox_messages WHERE conversation_id = ANY(?) ORDER BY id', [convs.map((c) => c.id)])) {
++ byConv.get(m.conversation_id).push({ id: Number(m.id), author: m.author, author_name: m.author_name || null, body: m.body, created_at: m.created_at });
++ }
++ }
++ return convs.map((c) => ({ ...convShape(c), created_at: c.created_at, last_message_at: c.last_message_at || null, messages: byConv.get(c.id) }));
++}
++
++// A spreadsheet runs a cell that starts with = + - @ (or a tab or return before one) as a formula: such cells start with '.
++const cell = (v) => { const s = v == null ? '' : String(v); return `"${(/^[=+\-@\t\r]/.test(s) ? `'${s}` : s).replace(/"/g, '""')}"`; };
++const COLUMNS = ['conversation_id', 'status', 'visitor_name', 'visitor_email', 'verified', 'page_url', 'rating', 'rating_comment', 'message_id', 'author', 'author_name', 'body', 'created_at'];
++
++async function csv(inbox) {
++ const lines = [COLUMNS.join(',')];
++ for (const c of await conversations(inbox)) {
++ for (const m of c.messages) {
++ lines.push([c.id, c.status, c.visitor_name, c.visitor_email, c.verified ? 'yes' : 'no', c.page_url, c.rating, c.rating_comment, m.id, m.author, m.author_name, m.body, m.created_at].map(cell).join(','));
++ }
++ }
++ return `${lines.join('\r\n')}\r\n`;
++}
++
++module.exports = { RETENTION_CHOICES, EXPORT_MAX, sweep, conversations, csv };
ddiff --git a/server/inbox/routes.js b/server/inbox/routes.js
iindex 8435ab9..6a2d202 100644
---- a/server/inbox/routes.js
++++ b/server/inbox/routes.js
@@@ -31,6 +31,7 @@
* POST /:id/conversations/:cid/picture the body is a picture, an optional line in X-Caption → 201 { message, picture }
* GET /:id/webhooks (owner) { webhooks } POST /:id/webhooks { url } → 201 { webhook, secret }
* POST /:id/webhooks/:hookId/test → { ok, status, error } DELETE /:id/webhooks/:hookId
++ * GET /:id/export ?format=json|csv (owner): every conversation with its messages (5,000 newest), as a download
* GET /:id/actions (owner) { actions } POST /:id/actions { name, description, url, params? } → 201 { action, secret }
* POST /:id/actions/:actionId/test { arguments } → { ok, status, error, result } DELETE /:id/actions/:actionId
* GET /:id/conversations ?status=active|mine|bot|waiting|open|resolved
@@@ -227,6 +228,22 @@ agents.delete('/:id/webhooks/:hookId', requireAuth, manageLimit, async (req, res
const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
await webhooks.remove(inbox, req.params.hookId); res.status(204).end();
});
++agents.get('/:id/export', requireAuth, manageLimit, async (req, res) => {
++ const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
++ await sendExport(res, inbox, req.query.format);
++});
++/** The inbox's conversations as a download (the API and the settings page's links). */
++async function sendExport(res, inbox, format) {
++ const records = require('./records');
++ const name = `${inbox.name.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'inbox'}-conversations`;
++ res.set('Cache-Control', 'no-store');
++ if (format === 'csv') {
++ res.set('Content-Disposition', `attachment; filename="${name}.csv"`);
++ return res.type('text/csv; charset=utf-8').send(await records.csv(inbox));
++ }
++ res.set('Content-Disposition', `attachment; filename="${name}.json"`);
++ return res.json({ inbox: { id: inbox.id, name: inbox.name }, exported_at: new Date().toISOString(), conversations: await records.conversations(inbox) });
++}
agents.get('/:id/actions', requireAuth, async (req, res) => {
const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
noStore(res).json({ actions: await actions.list(inbox) });
@@@ -302,4 +319,4 @@ support.post('/:cid/messages', requireAuth, postLimit, async (req, res) => {
try { res.status(201).json(await inboxes.accountPost(req.user, conv, req.body && req.body.message)); } catch (err) { send(res, err); }
});
--module.exports = { widget, agents, support, embedOf };
++module.exports = { widget, agents, support, embedOf, sendExport };
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex 29307be..6f2ed85 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -18,7 +18,8 @@ const inboxes = require('../inbox/inbox');
const webhooks = require('../inbox/webhooks');
const actions = require('../inbox/actions');
const pictures = require('../rooms/pictures');
--const { embedOf } = require('../inbox/routes');
++const { embedOf, sendExport } = require('../inbox/routes');
++const records = require('../inbox/records');
const { esc, asset } = require('./layout');
const WIDGET = path.join(__dirname, '..', '..', 'public', 'web', 'widget.js');
@@@ -241,6 +242,12 @@ ${acts.length < actions.MAX_ACTIONS ? `<form class="oc-form" method="post" actio
<label>What it needs, one a line (name: what it is) <textarea name="params" maxlength="1500" rows="2" placeholder="order_id: the order number from the confirmation email"></textarea></label>
<label>Address (https) <input name="url" type="url" required maxlength="1000" placeholder="https://example.com/api/vibe/order"></label>
<p><button type="submit">Add the action</button></p></form>` : ''}
++${owner ? `<h2>Records</h2>
++<p class="oc-muted">How long finished conversations stay: resolved ones, and ones only Vibe answered, go once they have been quiet this long, with their messages. A visitor waiting for a person or a conversation one of your people has open never goes.</p>
++<form class="oc-form" method="post" action="/inbox/${esc(inbox.id)}/retention"><label>Keep finished conversations <select name="retention_days">
++<option value=""${shape.retention_days == null ? ' selected' : ''}>forever</option>${records.RETENTION_CHOICES.map((d) => `<option value="${d}"${shape.retention_days === d ? ' selected' : ''}>${d >= 365 ? `${d / 365} year${d >= 730 ? 's' : ''}` : `${d} days`}</option>`).join('')}${shape.retention_days != null && !records.RETENTION_CHOICES.includes(shape.retention_days) ? `<option value="${shape.retention_days}" selected>${shape.retention_days} days</option>` : ''}
++</select></label><p><button type="submit">Save</button></p></form>
++<p>Download every conversation with its messages (the ${records.EXPORT_MAX.toLocaleString('en-US')} newest): <a href="/inbox/${esc(inbox.id)}/export.csv">a spreadsheet (CSV)</a> · <a href="/inbox/${esc(inbox.id)}/export.json">JSON</a></p>` : ''}
<h2>How it looks and what Vibe knows</h2>
<form class="oc-form" method="post" action="/inbox/${esc(inbox.id)}/settings">
<label>Name <input name="name" required minlength="2" maxlength="60" value="${esc(shape.name)}"></label>
@@@ -332,6 +339,17 @@ ${notice('ok', 'Here is its signing secret. Copy it now: it is shown only this o
if (a.role === 'owner') await webhooks.remove(a.inbox, req.params.wid);
res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`);
});
++ router.post('/inbox/:id/retention', formLimit, sameSite, async (req, res) => {
++ const a = await agentInbox(req, res); if (!a) return;
++ if (a.role !== 'owner') return res.redirect(303, `/inbox/${a.inbox.id}/settings`);
++ try { await inboxes.update(a.inbox, a.actor.user, { retention_days: (req.body && req.body.retention_days) || null }); res.redirect(303, `/inbox/${a.inbox.id}/settings?saved=1`); }
++ catch (err) { res.redirect(303, `/inbox/${a.inbox.id}/settings?error=${encodeURIComponent(err.message || 'That did not save')}`); }
++ });
++ router.get('/inbox/:id/export.:format(csv|json)', async (req, res) => {
++ const a = await agentInbox(req, res); if (!a) return;
++ if (a.role !== 'owner') return res.redirect(303, `/inbox/${a.inbox.id}`);
++ await sendExport(res, a.inbox, req.params.format);
++ });
router.post('/inbox/:id/actions', formLimit, sameSite, async (req, res) => {
const a = await agentInbox(req, res); if (!a) return;
if (a.role !== 'owner') return res.redirect(303, `/inbox/${a.inbox.id}/settings`);
ddiff --git a/test/inbox.test.js b/test/inbox.test.js
iindex be15ac1..4bb8cb6 100644
---- a/test/inbox.test.js
++++ b/test/inbox.test.js
@@@ -458,6 +458,54 @@ t('a signed-in visitor sends a picture from the panel (through OpenVibe.Pics as
assert.ok((await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text())).includes('id="em-attach"'), 'the panel has the picture button');
});
++t('records: finished conversations go after the owner\'s retention, waiting and open ones never; the owner exports CSV and JSON', async () => {
++ const records = require('../server/inbox/records');
++ const fen = h.addUser('fen', { subject: ids.newId('user') });
++ await h.ctx.upsertUser(h.live.users.get(fen.id));
++ const ib = (await agentsApi('POST', '', fen, { name: 'Records Co', ai_enabled: false })).body.inbox;
++ await agentsApi('POST', `/${ib.id}/members`, fen, { username: 'bob' });
++ assert.strictEqual((await agentsApi('PATCH', `/${ib.id}`, fen, { retention_days: 3 })).status, 422, 'a week at least');
++ assert.strictEqual((await agentsApi('PATCH', `/${ib.id}`, bob, { retention_days: 30 })).status, 403, 'the owner\'s call');
++ assert.strictEqual((await agentsApi('PATCH', `/${ib.id}`, fen, { retention_days: 30 })).body.inbox.retention_days, 30);
++ const say = (path, body) => fetch(`${h.base}/api/chat/widget/inbox/${ib.id}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then((r) => r.json());
++ const made = {};
++ for (const [label, line] of [['resolved', '=HYPERLINK("http://evil.example","click")'], ['bot', 'only Vibe'], ['waiting', 'still waiting'], ['open', 'a person has it'], ['recent', 'resolved yesterday']]) {
++ const token = (await say('/visit', {})).token;
++ made[label] = (await say('/messages', { token, message: line })).conversation.id;
++ }
++ const old = '2026-01-01 00:00:00';
++ await h.db.run("UPDATE inbox_conversations SET status = 'resolved', last_message_at = ? WHERE id = ?", [old, made.resolved]);
++ await h.db.run("UPDATE inbox_conversations SET status = 'bot', last_message_at = ? WHERE id = ?", [old, made.bot]);
++ await h.db.run("UPDATE inbox_conversations SET status = 'waiting', last_message_at = ? WHERE id = ?", [old, made.waiting]);
++ await h.db.run("UPDATE inbox_conversations SET status = 'open', last_message_at = ? WHERE id = ?", [old, made.open]);
++ await h.db.run("UPDATE inbox_conversations SET status = 'resolved', last_message_at = ? WHERE id = ?", ['2026-03-01 00:00:00', made.recent]);
++
++ // Exports first, while everything is there: the owner's, a spreadsheet cell can never run as a formula.
++ assert.strictEqual((await agentsApi('GET', `/${ib.id}/export?format=csv`, bob)).status, 403, 'the owner\'s');
++ const csvRes = await fetch(`${h.base}/api/chat/inboxes/${ib.id}/export?format=csv`, { headers: { authorization: `Bearer ${fen.token}` } });
++ assert.ok(/text\/csv/.test(csvRes.headers.get('content-type')) && /attachment; filename="Records-Co-conversations\.csv"/.test(csvRes.headers.get('content-disposition')));
++ const csv = await csvRes.text();
++ assert.ok(csv.startsWith('conversation_id,status,visitor_name,visitor_email,verified,page_url,rating,rating_comment,message_id,author,author_name,body,created_at\r\n'));
++ assert.ok(csv.includes(`"'=HYPERLINK(""http://evil.example"",""click"")"`) && !csv.includes('",=HYPERLINK'), 'a formula is quoted and defused');
++ const json = (await agentsApi('GET', `/${ib.id}/export?format=json`, fen)).body;
++ assert.strictEqual(json.inbox.id, ib.id);
++ assert.strictEqual(json.conversations.length, 5);
++ assert.ok(json.conversations.every((c) => Array.isArray(c.messages) && c.messages.length >= 1));
++ const page = await fetch(`${h.base}/inbox/${ib.id}/export.json`, { headers: { cookie: `ov_token=${fen.token}` } });
++ assert.ok(page.status === 200 && /attachment/.test(page.headers.get('content-disposition')), 'the settings page\'s link');
++ const settings = await fetch(`${h.base}/inbox/${ib.id}/settings`, { headers: { cookie: `ov_token=${fen.token}` } }).then((r) => r.text());
++ assert.ok(settings.includes('<h2>Records</h2>') && settings.includes('<option value="30" selected>30 days</option>') && settings.includes(`/inbox/${ib.id}/export.csv`));
++
++ // The sweep, on 2026-03-15 with 30 days: the old resolved and Vibe-only ones go, with their messages.
++ const out = await records.sweep({ now: new Date('2026-03-15T12:00:00Z') });
++ assert.ok(out.deleted >= 2);
++ const left = (await h.db.all('SELECT id FROM inbox_conversations WHERE inbox_id = ?', [ib.id])).map((r) => r.id).sort();
++ assert.deepStrictEqual(left, [made.waiting, made.open, made.recent].sort(), 'waiting and open stay, and the recent one');
++ assert.strictEqual(Number((await h.db.get('SELECT COUNT(*) AS n FROM inbox_messages WHERE conversation_id = ANY(?)', [[made.resolved, made.bot]])).n), 0, 'their messages go too');
++ await agentsApi('PATCH', `/${ib.id}`, fen, { retention_days: null });
++ assert.strictEqual((await records.sweep({ now: new Date('2027-01-01T00:00:00Z') })).deleted, 0, 'forever: nothing goes');
++});
++
t('business hours: outside them the panel says when the team is back, a handoff says so, and Vibe knows', async () => {
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'Mars/Olympus', days: [1], start: '09:00', end: '17:00' } })).status, 422);
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { hours: { tz: 'UTC', days: [1], start: '17:00', end: '09:00' } })).status, 422, 'a start before an end');