openvibers/openvibe.chat Commits

Support inboxes: who answers (assign to anyone of the people, a Mine tab) and taking turns (a waiting visitor goes to the next person, only they are told) (#155)

OP OpenVibers committed 19 days ago · 5a519d40db · 9 files, +132 −18

Changes

ddiff --git a/README.md b/README.md
iindex 16e4a2f..8a44ef7 100644
---- a/README.md
++++ b/README.md
@@@ -461,6 +461,10 @@ for people and for companies, with "direct live support with an agent", "fully m
/api/chat/support/:cid/messages`). The account comes only from the request's credentials. A
site's verified identity, when there is one, still decides the token. Export lists these conversations; deletion
leaves them with the inbox's team, without the person.
++- **Who answers** (`server/inbox/routing.js`): replying takes a conversation, and any of the inbox's people can assign
++ it to another ("Answered by", `PATCH /api/chat/inboxes/:id/conversations/:cid { assignee_id }`). With **take turns**
++ on (`route: round_robin`), a visitor who starts waiting goes to the person assigned longest ago, and only they are
++ notified. The list has a **Mine** tab (`?status=mine`) and shows who has each conversation.
- **Business hours** (`server/inbox/hours.js`; settings, or `PATCH { hours: { tz, days, start, end } }`, null for
always): outside them the panel says when the team is back ("tomorrow at 9 AM (Los Angeles time)"), a handoff says
so, and Vibe tells visitors a person replies when the team is back. Notifications still go out.
ddiff --git a/migrations/0024_inbox_routing.sql b/migrations/0024_inbox_routing.sql
nnew file mode 100644
iindex 0000000..116bc05
---- /dev/null
++++ b/migrations/0024_inbox_routing.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- Who answers a support conversation (server/inbox/routing.js): with turns on, a conversation that starts waiting goes
++-- to the person whose turn it is (the one assigned longest ago); a teammate can assign one to anyone in the inbox.
++ALTER TABLE inboxes ADD COLUMN IF NOT EXISTS route text COLLATE "C" NOT NULL DEFAULT 'none' CHECK (route IN ('none', 'round_robin'));
++ALTER TABLE inbox_members ADD COLUMN IF NOT EXISTS last_assigned_at text COLLATE "C";
ddiff --git a/public/web/inbox.js b/public/web/inbox.js
iindex ae28616..e636533 100644
---- a/public/web/inbox.js
++++ b/public/web/inbox.js
@@@ -97,6 +97,10 @@
else if (m.type === 'inbox_message') {
touchList(m.conversation, m.message.body, null);
if (m.conversation === cfg.conversation) appendToThread(m.message);
++ } else if (m.type === 'inbox_assigned') {
++ var row2 = listItem(m.conversation); var prev = row2 && row2.querySelector('.ib-prev');
++ if (prev) { var who = prev.querySelector('.ib-who-has'); if (who) who.textContent = m.assignee === cfg.me ? 'You' : 'Someone'; }
++ if (m.conversation === cfg.conversation) { var sel = document.querySelector('.ib-assign select'); if (sel) sel.value = m.assignee == null ? '' : String(m.assignee); }
} else if (m.type === 'inbox_rating') {
var item = listItem(m.conversation), row = item && item.querySelector('.ib-row');
if (row) { var old = row.querySelector('.ib-rate'); if (old) old.remove(); var r = el('span', 'ib-rate', m.rating > 0 ? '👍' : '👎'); if (m.comment) r.title = m.comment; row.insertBefore(r, row.querySelector('time')); }
@@@ -214,6 +218,9 @@
var scrim = app.querySelector('.vb-scrim'); if (scrim) scrim.hidden = !open;
var btn = app.querySelector('[data-vb-open]'); if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
++ // "Answered by": choosing a person assigns at once.
++ var assignSel = document.querySelector('.ib-assign select[data-autosubmit]');
++ if (assignSel) assignSel.addEventListener('change', function () { assignSel.form.requestSubmit ? assignSel.form.requestSubmit() : assignSel.form.submit(); });
app.addEventListener('click', function (e) { if (e.target.closest('[data-vb-open]')) setRail(true); else if (e.target.closest('[data-vb-close]')) setRail(false); });
bottom();
connect();
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex dbc2d9f..fb03b9e 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -218,6 +218,9 @@ body.oc-app-body { overflow: hidden; }
.ib-row .vb-st { margin-left: 4px; }
.ib-rate { font-size: .85rem; line-height: 1; }
.ib-ok { color: var(--oc-ok, #22c55e); font-weight: 800; margin-left: 4px; font-size: .9em; }
++.ib-assign { display: inline-flex; align-items: center; margin-right: 8px; font-size: .85rem; color: var(--oc-muted); }
++.ib-assign select { margin-left: 6px; font-size: .85rem; padding: 4px 8px; }
++.ib-who-has { color: var(--oc-accent); font-weight: 600; }
.ib-pic-btn { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; color: var(--oc-muted); flex: none; font-size: 1.1rem; }
.ib-pic-btn:hover { color: var(--oc-accent); background: color-mix(in srgb, var(--oc-accent) 12%, transparent); }
.ib-pic-btn:focus-within { outline: 2px solid var(--oc-accent); }
ddiff --git a/server/inbox/inbox.js b/server/inbox/inbox.js
iindex b95440d..d33835d 100644
---- a/server/inbox/inbox.js
++++ b/server/inbox/inbox.js
@@@ -72,6 +72,10 @@ 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.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);
++ }
if (input.ai_instructions !== undefined) out.ai_instructions = clean(input.ai_instructions, LIMITS.instructions);
const list = (v) => (Array.isArray(v) ? v : String(v || '').split(/[\s,]+/)).map((x) => String(x).trim()).filter(Boolean);
if (input.knowledge_urls !== undefined) {
@@@ -126,8 +130,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 = ? 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), inbox.id]);
++ 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]);
return await get(inbox.id);
}
@@@ -150,7 +154,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),
++ verified_visitors: !!r.identity_secret, hours: hoursOf(r), route: r.route || 'none',
knowledge_urls: json(r.knowledge_urls, []), allowed_origins: json(r.allowed_origins, []), created_at: r.created_at,
};
}
@@@ -204,11 +208,27 @@ async function addMessage(conv, { author, body, userId = null, name = null }, de
return message;
}
++/** Turns: a waiting conversation nobody has yet goes to the person whose turn it is (they are then the one told). */
++async function takeTurn(conv, deps = {}) {
++ const fresh = await db.get('SELECT assignee_id FROM inbox_conversations WHERE id = ?', [conv.id]);
++ if (!fresh || fresh.assignee_id != null) return;
++ const routing = require('./routing');
++ const next = await routing.nextInTurn(await get(conv.inbox_id) || {});
++ if (next == null) return;
++ await db.run('UPDATE inbox_conversations SET assignee_id = ? WHERE id = ? AND assignee_id IS NULL', [next, conv.id]);
++ await routing.noteAssigned(conv.inbox_id, next);
++ broadcaster(deps)(conv.inbox_id, null, { type: 'inbox_assigned', inbox: conv.inbox_id, conversation: conv.id, assignee: next });
++}
++
async function setStatusRow(conv, status, deps = {}, extra = {}) {
await db.run(`UPDATE inbox_conversations SET status = ?${extra.assignee ? ', assignee_id = COALESCE(assignee_id, ?)' : ''} WHERE id = ?`,
extra.assignee ? [status, extra.assignee, conv.id] : [status, conv.id]);
broadcaster(deps)(conv.inbox_id, conv.id, { type: 'inbox_status', inbox: conv.inbox_id, conversation: conv.id, status });
-- if (status === 'waiting' && conv.status !== 'waiting') { require('./notify').later(conv, 'waiting'); require('./webhooks').later(conv.inbox_id, 'conversation.waiting', { conversationId: conv.id }); }
++ if (status === 'waiting' && conv.status !== 'waiting') {
++ await takeTurn(conv, deps);
++ require('./notify').later(conv, 'waiting');
++ require('./webhooks').later(conv.inbox_id, 'conversation.waiting', { conversationId: conv.id });
++ }
if (status === 'resolved' && conv.status !== 'resolved') require('./webhooks').later(conv.inbox_id, 'conversation.resolved', { conversationId: conv.id });
return { ...conv, status };
}
@@@ -341,6 +361,7 @@ async function visitorPost(inbox, token, { message, page_url = null, name = null
const filtered = wordFilter.check(body);
const posted = await addMessage(conv, { author: 'visitor', body: filtered.safe ? body : filtered.filtered, name: conv.visitor_name }, deps);
// A person is needed: a new conversation with Vibe off starts waiting, and a visitor writing to people is news.
++ if (started && conv.status === 'waiting') await takeTurn(conv, deps);
if (conv.status === 'waiting' || conv.status === 'open') require('./notify').later(conv, started ? 'waiting' : 'message');
if (conv.status === 'bot') {
const run = () => aiAnswer(inbox, conv.id, deps).catch((e) => console.warn(`[Inbox] ${inbox.id}: ${e && e.message}`));
@@@ -410,9 +431,10 @@ async function accountPost(account, conv, text, deps = {}) {
// ── Agents ─────────────────────────────────────────────────────────────────────────────────────────────────────────
--async function conversations(inbox, { status = null, limit = 100 } = {}) {
-- const where = status === 'active' ? "AND c.status IN ('bot', 'waiting', 'open')" : STATUSES.includes(status) ? 'AND c.status = ?' : '';
-- const params = [inbox.id, ...(STATUSES.includes(status) ? [status] : []), Math.min(200, Number(limit) || 100)];
++async function conversations(inbox, { status = null, limit = 100, user = null } = {}) {
++ const mine = status === 'mine' && user;
++ const where = status === 'active' ? "AND c.status IN ('bot', 'waiting', 'open')" : mine ? "AND c.status IN ('waiting', 'open') AND c.assignee_id = ?" : STATUSES.includes(status) ? 'AND c.status = ?' : '';
++ const params = [inbox.id, ...(mine ? [user.id] : STATUSES.includes(status) ? [status] : []), Math.min(200, Number(limit) || 100)];
const rows = await db.all(`SELECT c.*, (SELECT body FROM inbox_messages m WHERE m.conversation_id = c.id AND m.author <> 'system' ORDER BY m.id DESC LIMIT 1) AS last_body
FROM inbox_conversations c WHERE c.inbox_id = ? ${where}
ORDER BY CASE c.status WHEN 'waiting' THEN 0 WHEN 'open' THEN 1 WHEN 'bot' THEN 2 ELSE 3 END, COALESCE(c.last_message_at, c.created_at) DESC LIMIT ?`, params);
@@@ -443,6 +465,17 @@ async function setStatus(inbox, conv, user, status, deps = {}) {
return convShape(next);
}
++/** One of the inbox's people (or nobody: null) answers this conversation; any of them may assign it. */
++async function assign(inbox, conv, user, assigneeId, deps = {}) {
++ if (!(await access(inbox, user)).role) fail(403, 'inbox.not_agent', 'Only the inbox\'s people assign conversations');
++ const id = assigneeId == null || assigneeId === '' ? null : Number(assigneeId);
++ if (id != null && !(await db.get('SELECT 1 FROM inbox_members WHERE inbox_id = ? AND user_id = ?', [inbox.id, id]))) fail(422, 'inbox.assignee', 'Assign it to one of the inbox\'s people');
++ await db.run('UPDATE inbox_conversations SET assignee_id = ? WHERE id = ?', [id, conv.id]);
++ if (id != null) await require('./routing').noteAssigned(inbox.id, id);
++ broadcaster(deps)(inbox.id, null, { type: 'inbox_assigned', inbox: inbox.id, conversation: conv.id, assignee: id });
++ return convShape(await db.get('SELECT * FROM inbox_conversations WHERE id = ?', [conv.id]));
++}
++
async function markRead(inbox, conv) { await db.run('UPDATE inbox_conversations SET agent_unread = 0 WHERE id = ?', [conv.id]); }
// ── Vibe, the first responder ─────────────────────────────────────────────────────────────────────────────────────
@@@ -543,6 +576,6 @@ function _reset() { inFlight.clear(); }
module.exports = {
InboxError, INBOX_RE, CONV_RE, LIMITS, MAX_INBOXES, AI_PER_CONVERSATION, hashToken,
create, update, remove, get, access, mine, members, addMember, removeMember, shape, publicConfig, plainText, peopleAnswer,
-- visit, visitorPost, handoff, rate, setIdentity, verifiedIdentity, savedReplies, saveReply, deleteReply, mySupport, myConversation, accountPost, conversations, conversation, messages, agentPost, setStatus, markRead, convShape,
++ visit, visitorPost, handoff, rate, assign, setIdentity, verifiedIdentity, savedReplies, saveReply, deleteReply, mySupport, myConversation, accountPost, conversations, conversation, messages, agentPost, setStatus, markRead, convShape,
aiAnswer, inboxPrompt, _reset,
};
ddiff --git a/server/inbox/routes.js b/server/inbox/routes.js
iindex 19bcc24..9207635 100644
---- a/server/inbox/routes.js
++++ b/server/inbox/routes.js
@@@ -29,10 +29,10 @@
* 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/conversations ?status=active|bot|waiting|open|resolved
++ * GET /:id/conversations ?status=active|mine|bot|waiting|open|resolved
* GET /:id/conversations/:cid/messages ?after=
* POST /:id/conversations/:cid/messages { message } → 201: a person's answer (the conversation becomes open)
-- * PATCH /:id/conversations/:cid { status: open | resolved | bot }
++ * PATCH /:id/conversations/:cid { status: open | resolved | bot, assignee_id: a person's id | null }
* POST /:id/conversations/:cid/read
*/
const express = require('express');
@@@ -219,7 +219,7 @@ agents.delete('/:id/members/:userId', requireAuth, manageLimit, async (req, res)
});
agents.get('/:id/conversations', requireAuth, async (req, res) => {
const inbox = await agentInbox(req, res); if (!inbox) return;
-- noStore(res).json({ conversations: await inboxes.conversations(inbox, { status: req.query.status || 'active', limit: req.query.limit }) });
++ noStore(res).json({ conversations: await inboxes.conversations(inbox, { status: req.query.status || 'active', limit: req.query.limit, user: req.user }) });
});
agents.get('/:id/conversations/:cid/messages', requireAuth, async (req, res) => {
const conv = await agentConversation(req, res); if (!conv) return;
@@@ -231,7 +231,14 @@ agents.post('/:id/conversations/:cid/messages', requireAuth, replyLimit, async (
});
agents.patch('/:id/conversations/:cid', requireAuth, manageLimit, async (req, res) => {
const conv = await agentConversation(req, res); if (!conv) return;
-- try { res.json({ conversation: await inboxes.setStatus(req.inbox, conv, req.user, req.body && req.body.status) }); } catch (err) { send(res, err); }
++ const b = req.body || {};
++ try {
++ let out = null;
++ if (Object.prototype.hasOwnProperty.call(b, 'assignee_id')) out = await inboxes.assign(req.inbox, conv, req.user, b.assignee_id);
++ if (b.status !== undefined) out = await inboxes.setStatus(req.inbox, { ...conv, assignee_id: out ? out.assignee_id : conv.assignee_id }, req.user, b.status);
++ if (!out) return res.status(422).json({ error: 'Send a status or an assignee_id', code: 'inbox.nothing' });
++ res.json({ conversation: out });
++ } catch (err) { send(res, err); }
});
agents.post('/:id/conversations/:cid/read', requireAuth, async (req, res) => {
const conv = await agentConversation(req, res); if (!conv) return;
ddiff --git a/server/inbox/routing.js b/server/inbox/routing.js
nnew file mode 100644
iindex 0000000..64c9d25
---- /dev/null
++++ b/server/inbox/routing.js
@@@ -0,0 +1,23 @@
++'use strict';
++/**
++ * Who answers a support conversation (server/inbox/inbox.js): one of the inbox's people takes it by answering, is
++ * assigned by a teammate, or, with turns on (inboxes.route = 'round_robin'), a conversation that starts waiting goes
++ * to the person whose turn it is: the one assigned longest ago (inbox_members.last_assigned_at). Notifications then go
++ * to that person only (server/inbox/notify.js already tells the assignee first).
++ */
++const db = require('../db/database');
++
++/** The person whose turn it is, or null (turns off, or nobody in the inbox). */
++async function nextInTurn(inbox) {
++ if (inbox.route !== 'round_robin') return null;
++ const row = await db.get(`SELECT user_id FROM inbox_members WHERE inbox_id = ?
++ ORDER BY last_assigned_at IS NOT NULL, last_assigned_at, role DESC, user_id LIMIT 1`, [inbox.id]);
++ return row ? Number(row.user_id) : null;
++}
++
++async function noteAssigned(inboxId, userId) {
++ if (userId == null) return;
++ await db.run('UPDATE inbox_members SET last_assigned_at = ov_now_iso() WHERE inbox_id = ? AND user_id = ?', [inboxId, userId]);
++}
++
++module.exports = { nextInTurn, noteAssigned };
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex e62309e..40e0ba3 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -120,22 +120,25 @@ ${list}
router.get('/inbox/:id', async (req, res) => {
const a = await agentInbox(req, res); if (!a) return;
const { actor, inbox } = a;
-- const filter = ['active', 'waiting', 'resolved'].includes(req.query.f) ? req.query.f : 'active';
-- const list = await inboxes.conversations(inbox, { status: filter });
++ const filter = ['active', 'mine', 'waiting', 'resolved'].includes(req.query.f) ? req.query.f : 'active';
++ const list = await inboxes.conversations(inbox, { status: filter, user: actor.user });
++ const team = await inboxes.members(inbox);
++ const nameOf = (id) => { const m = team.find((x) => x.user_id === Number(id)); return m ? (m.display_name || m.username) : null; };
const current = req.query.c ? await inboxes.conversation(inbox, req.query.c) : null;
const saved = await inboxes.savedReplies(inbox);
const msgs = current ? await inboxes.messages(current) : [];
if (current) await inboxes.markRead(inbox, current);
const item = (c) => `<li><a class="vb-chat ib-conv" href="/inbox/${esc(inbox.id)}?c=${esc(c.id)}&amp;f=${filter}"${current && c.id === current.id ? ' aria-current="page"' : ''} data-conv="${esc(c.id)}">
<span class="ib-row"><b>${esc(c.visitor_name || 'Visitor')}</b>${c.verified ? '<span class="ib-ok" title="Verified by your site">✓</span>' : ''}${statusChip(c.status)}${c.rating ? `<span class="ib-rate" title="${esc(c.rating_comment || '')}">${c.rating > 0 ? '👍' : '👎'}</span>` : ''}<time>${esc(ago(c.last_message_at))}</time></span>
--<span class="ib-prev">${esc(c.preview || '')}</span></a></li>`;
++<span class="ib-prev">${c.assignee_id && c.status !== 'resolved' ? `<span class="ib-who-has">${esc(Number(c.assignee_id) === Number(actor.user.id) ? 'You' : nameOf(c.assignee_id) || 'Someone')}</span> · ` : ''}${esc(c.preview || '')}</span></a></li>`;
const turn = (m) => {
if (m.author === 'system') return `<li class="ib-sys" data-id="${m.id}">${esc(m.body)}</li>`;
if (m.author === 'visitor') return `<li class="vb-turn vb-ai ib-visitor" data-id="${m.id}"><span class="ib-av" aria-hidden="true">${esc((current.visitor_name || 'V').slice(0, 1).toUpperCase())}</span><div class="vb-body"><p class="ib-who">${esc(current.visitor_name || 'Visitor')}</p><div class="vb-md">${esc(m.body).replace(/\n/g, '<br>')}</div></div></li>`;
return `<li class="vb-turn vb-me" data-id="${m.id}"><div class="vb-bubble${m.author === 'ai' ? ' ib-ai' : ''}"><p class="ib-who">${m.author === 'ai' ? 'Vibe (AI)' : esc(m.author_name || 'Agent')}</p>${m.author === 'ai' ? `<div class="vb-md">${md.render(m.body)}</div>` : esc(m.body).replace(/\n/g, '<br>')}${m.author === 'agent' ? pictures.pageHtml(m.body) : ''}</div></li>`;
};
-- const tabs = [['active', 'Active'], ['waiting', 'Waiting'], ['resolved', 'Resolved']].map(([k, l]) => `<a href="/inbox/${esc(inbox.id)}?f=${k}"${k === filter ? ' aria-current="page"' : ''}>${l}</a>`).join('');
-- const actions = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/status" class="ib-actions">
++ const tabs = [['active', 'Active'], ['mine', 'Mine'], ['waiting', 'Waiting'], ['resolved', 'Resolved']].map(([k, l]) => `<a href="/inbox/${esc(inbox.id)}?f=${k}"${k === filter ? ' aria-current="page"' : ''}>${l}</a>`).join('');
++ const assignForm = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/assign" class="ib-assign"><label>Answered by <select name="assignee" data-autosubmit><option value="">nobody yet</option>${team.map((m) => `<option value="${m.user_id}"${Number(current.assignee_id) === m.user_id ? ' selected' : ''}>${esc(m.user_id === Number(actor.user.id) ? 'me' : (m.display_name || m.username))}</option>`).join('')}</select></label><noscript><button type="submit">Assign</button></noscript></form>` : '';
++ const actions = current ? `${assignForm}<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/status" class="ib-actions">
${current.status !== 'resolved' ? '<button name="status" value="resolved">Resolve</button>' : '<button name="status" value="open">Reopen</button>'}
${Number(inbox.ai_enabled) && current.status !== 'bot' && current.status !== 'resolved' ? '<button name="status" value="bot">Back to Vibe</button>' : ''}</form>` : '';
const body = `<div class="vb ib" id="vb" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}">
@@@ -172,6 +175,12 @@ ${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input
try { await inboxes.agentPost(a.inbox, conv, a.actor.user, req.body && req.body.message); } catch { /* the page shows the thread as it is */ }
res.redirect(303, `/inbox/${a.inbox.id}?c=${conv.id}`);
});
++ router.post('/inbox/:id/c/:cid/assign', formLimit, sameSite, async (req, res) => {
++ const a = await agentInbox(req, res); if (!a) return;
++ const conv = await inboxes.conversation(a.inbox, req.params.cid);
++ if (conv) { try { await inboxes.assign(a.inbox, conv, a.actor.user, req.body && req.body.assignee); } catch { /* unchanged */ } }
++ res.redirect(303, `/inbox/${a.inbox.id}${conv ? `?c=${conv.id}` : ''}`);
++ });
router.post('/inbox/:id/c/:cid/status', formLimit, sameSite, async (req, res) => {
const a = await agentInbox(req, res); if (!a) return;
const conv = await inboxes.conversation(a.inbox, req.params.cid);
@@@ -222,6 +231,7 @@ ${hooks.length < webhooks.MAX_WEBHOOKS ? `<form class="oc-form" method="post" ac
<label>Greeting <input name="greeting" maxlength="300" value="${esc(shape.greeting)}"></label>
<label>Colour <input name="color" type="color" value="${esc(shape.color)}"></label>
<label class="oc-check"><input type="checkbox" name="ai_enabled" value="1"${shape.ai_enabled ? ' checked' : ''}> Vibe answers first</label>
++<label class="oc-check"><input type="checkbox" name="route" value="round_robin"${shape.route === 'round_robin' ? ' checked' : ''}> Take turns: a visitor waiting for a person goes to the next of your people, and only they are told</label>
<label class="oc-check"><input type="checkbox" name="handoff" value="1"${shape.handoff ? ' checked' : ''}> People answer here too (visitors can ask for a person; off: a site assistant where only Vibe answers)</label>
<fieldset class="oc-fieldset"><legend>Hours your people answer</legend>
<label class="oc-check"><input type="checkbox" name="hours_on" value="1"${shape.hours ? ' checked' : ''}> Set hours: outside them, visitors see when you are back, and Vibe tells them</label>
@@@ -250,7 +260,7 @@ ${saved.length ? `<ul class="oc-members">${saved.map((r) => `<li><b>/${esc(r.sho
const b = req.body || {};
try {
const hours = b.hours_on === '1' ? { tz: b.hours_tz, days: [].concat(b.hours_days || []), start: b.hours_start, end: b.hours_end } : null;
-- await inboxes.update(a.inbox, a.actor.user, { name: b.name, greeting: b.greeting, color: b.color, ai_enabled: b.ai_enabled === '1', handoff: b.handoff === '1', hours, ai_instructions: b.ai_instructions, knowledge_urls: b.knowledge_urls, allowed_origins: b.allowed_origins });
++ await inboxes.update(a.inbox, a.actor.user, { name: b.name, greeting: b.greeting, color: b.color, ai_enabled: b.ai_enabled === '1', handoff: b.handoff === '1', route: b.route === 'round_robin' ? 'round_robin' : 'none', hours, ai_instructions: b.ai_instructions, knowledge_urls: b.knowledge_urls, allowed_origins: b.allowed_origins });
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')}`); }
});
ddiff --git a/test/inbox.test.js b/test/inbox.test.js
iindex 4ef055d..670a581 100644
---- a/test/inbox.test.js
++++ b/test/inbox.test.js
@@@ -386,6 +386,28 @@ t('business hours: outside them the panel says when the team is back, a handoff
assert.strictEqual((await widget('GET', '')).body.inbox.away, null, 'no hours: always open');
});
++t('routing: taking turns hands each waiting visitor to the next person; anyone of the people assigns; Mine lists theirs', async () => {
++ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { route: 'random' })).status, 422);
++ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}`, ann, { route: 'round_robin' })).status, 200);
++ await h.db.run('UPDATE inbox_members SET last_assigned_at = NULL WHERE inbox_id = ?', [inbox.id]);
++ const waiting = async (msg) => {
++ const v = (await widget('POST', '/visit', {})).body.token;
++ const cid = (await widget('POST', '/messages', { token: v, message: msg })).body.conversation.id;
++ await widget('POST', '/handoff', { token: v });
++ return (await h.db.get('SELECT id, assignee_id FROM inbox_conversations WHERE id = ?', [cid]));
++ };
++ const one = await waiting('First in line'), two = await waiting('Second in line'), three = await waiting('Third in line');
++ assert.deepStrictEqual([one, two, three].map((c) => Number(c.assignee_id)), [ann.id, bob.id, ann.id], 'turns go round the people');
++ const mine = (await agentsApi('GET', `/${inbox.id}/conversations?status=mine`, bob)).body.conversations.map((c) => c.id);
++ assert.ok(mine.includes(two.id) && !mine.includes(one.id), 'Mine is what was handed to them');
++ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${one.id}`, bob, { assignee_id: bob.id })).body.conversation.assignee_id, bob.id, 'one of the people takes it');
++ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${one.id}`, bob, { assignee_id: 424242 })).status, 422, 'only to one of the people');
++ assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${one.id}`, ann, { assignee_id: null })).body.conversation.assignee_id, null);
++ const app = await fetch(`${h.base}/inbox/${inbox.id}?f=mine&c=${two.id}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());
++ assert.ok(app.includes('Answered by') && app.includes(`data-conv="${two.id}"`) && app.includes('aria-current="page">Mine</a>'));
++ await agentsApi('PATCH', `/${inbox.id}`, ann, { route: 'none' });
++});
++
t('the embed page and the loader: frame-ancestors follow the allowed sites', async () => {
let r = await fetch(`${h.base}/embed/inbox/${inbox.id}`);
assert.strictEqual(r.status, 200);