feat(meetings): email invites + add external participants by email + share link (batch69)

#4: scheduled meetings can now invite people who aren't on Connect.
- SMTP config (config.js, env-gated: SMTP_HOST/PORT/USER/PASS/FROM/SECURE,
  PUBLIC_BASE_URL) + a small nodemailer wrapper (mailer.js) with a branded
  meeting-invite template carrying the guest join link. No-op until SMTP is set.
- /api/meetings/schedule + /update accept participantEmails; external emails are
  persisted (scheduled_meetings.guest_emails migration) and emailed the guest
  link (plus any invited Connect users with an email on file). Fire-and-forget —
  a mail outage never fails scheduling.
- Meetings list DTO returns `link`; schedule form gains an "Invite by email"
  chip input; each scheduled-meeting card gets a "Copy link" action.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 16:02:45 +05:30
parent 466c0d5d70
commit 7bc40d8397
6 changed files with 175 additions and 18 deletions
+20
View File
@@ -26,6 +26,18 @@ const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || '';
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || '';
const LIVEKIT_ENABLED = !!(LIVEKIT_URL && LIVEKIT_API_KEY && LIVEKIT_API_SECRET);
// SMTP for outbound email (meeting invites to external participants, #4). Entirely optional and
// config-gated: email is only sent when SMTP_HOST/USER/PASS are set. Credentials stay server-side.
// PUBLIC_BASE_URL is the origin used to build guest meeting links in emails (e.g. https://remote.bizgaze.com).
const SMTP_HOST = process.env.SMTP_HOST || '';
const SMTP_PORT = Number(process.env.SMTP_PORT || 587);
const SMTP_SECURE = String(process.env.SMTP_SECURE || '').toLowerCase() === 'true' || SMTP_PORT === 465; // TLS on connect (465) vs STARTTLS
const SMTP_USER = process.env.SMTP_USER || '';
const SMTP_PASS = process.env.SMTP_PASS || '';
const SMTP_FROM = process.env.SMTP_FROM || (SMTP_USER ? ('Biz Connect <' + SMTP_USER + '>') : '');
const SMTP_ENABLED = !!(SMTP_HOST && SMTP_USER && SMTP_PASS);
const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'https://remote.bizgaze.com').replace(/\/+$/, '');
module.exports = {
PORT: process.env.PORT || 8090,
HTTPS_PORT: process.env.HTTPS_PORT || 8443,
@@ -33,6 +45,14 @@ module.exports = {
LIVEKIT_API_KEY,
LIVEKIT_API_SECRET,
LIVEKIT_ENABLED,
SMTP_HOST,
SMTP_PORT,
SMTP_SECURE,
SMTP_USER,
SMTP_PASS,
SMTP_FROM,
SMTP_ENABLED,
PUBLIC_BASE_URL,
PUBLIC_DIR,
REC_DIR,
TRANS_DIR,
+3
View File
@@ -225,6 +225,9 @@ try { db.exec("ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active
// their mobile number, so this — not the typed login identifier — is the stable identity key.
// Provisioning matches on it to keep one Biz Connect account per person (#2 account merge).
try { db.exec('ALTER TABLE users ADD COLUMN bizgaze_user_id TEXT'); } catch (e) { /* exists */ }
// External (non-Connect) invitee emails on a scheduled meeting (#4) — JSON array. They get an emailed
// guest join link instead of an in-app invite.
try { db.exec('ALTER TABLE scheduled_meetings ADD COLUMN guest_emails TEXT'); } catch (e) { /* exists */ }
try { db.exec('CREATE INDEX IF NOT EXISTS idx_users_bizgaze_user_id ON users(bizgaze_user_id)'); } catch (e) { /* exists */ }
// When two accounts merge (#2), the merged-away row is deleted. This records old_id -> survivor so
// any lingering reference to the old id (a cached contact, an in-flight DM) resolves to the survivor
+82
View File
@@ -0,0 +1,82 @@
// Outbound email (meeting invites to external participants — #4). Config-gated: does nothing unless
// SMTP_* env vars are set (SMTP_ENABLED). Credentials come from config (env), never the client.
//
// Kept intentionally small: one lazily-created nodemailer transport + a couple of helpers. Sending is
// fire-and-forget from the caller's perspective (we log failures but never throw into request handlers,
// so a mail outage can't break scheduling).
const cfg = require('./config');
let _transport = null;
let _nodemailer = null;
function transport() {
if (!cfg.SMTP_ENABLED) return null;
if (_transport) return _transport;
try {
_nodemailer = _nodemailer || require('nodemailer');
_transport = _nodemailer.createTransport({
host: cfg.SMTP_HOST,
port: cfg.SMTP_PORT,
secure: cfg.SMTP_SECURE, // true for 465, false for 587/STARTTLS
auth: { user: cfg.SMTP_USER, pass: cfg.SMTP_PASS },
});
} catch (e) {
console.warn('[mailer] transport init failed:', e && e.message);
_transport = null;
}
return _transport;
}
const isEnabled = () => cfg.SMTP_ENABLED;
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
}
// Send one email. Returns a promise that resolves to true/false — never rejects (callers shouldn't
// have to try/catch around scheduling). `to` may be a string or an array of addresses.
function send({ to, subject, html, text }) {
return new Promise((resolve) => {
const t = transport();
if (!t) { console.warn('[mailer] SMTP not configured — skipping email:', subject); return resolve(false); }
const list = Array.isArray(to) ? to.filter(Boolean) : [to].filter(Boolean);
if (!list.length) return resolve(false);
t.sendMail({ from: cfg.SMTP_FROM, to: list.join(', '), subject, text: text || '', html: html || undefined }, (err) => {
if (err) { console.warn('[mailer] sendMail failed:', err && err.message); return resolve(false); }
resolve(true);
});
});
}
// Branded meeting-invite email. `link` is the guest join URL; `when` a human-readable time string.
function meetingInviteEmail({ title, when, link, host, description }) {
const subject = 'Meeting invite: ' + title;
const text = [
(host ? host + ' invited you to a meeting.' : 'You have a meeting invite.'),
'',
'Title: ' + title,
when ? ('When: ' + when) : '',
description ? ('Details: ' + description) : '',
'',
'Join: ' + link,
'',
'No account needed — just open the link and enter your name.',
].filter(Boolean).join('\n');
const html = `<div style="font-family:'Segoe UI',system-ui,sans-serif;max-width:520px;margin:0 auto;color:#0f172a">
<div style="background:#1F3B73;border-radius:14px 14px 0 0;padding:20px 24px;color:#fff">
<div style="font-size:18px;font-weight:700">Biz Connect</div>
<div style="opacity:.85;font-size:13px;margin-top:2px">Meeting invitation</div>
</div>
<div style="border:1px solid #e3e8f2;border-top:none;border-radius:0 0 14px 14px;padding:22px 24px">
<p style="margin:0 0 14px;font-size:14px">${host ? esc(host) + ' invited you to a meeting.' : 'You have a meeting invite.'}</p>
<div style="font-size:17px;font-weight:700;margin-bottom:6px">${esc(title)}</div>
${when ? `<div style="font-size:14px;color:#475569;margin-bottom:4px">🗓 ${esc(when)}</div>` : ''}
${description ? `<div style="font-size:13px;color:#64748b;margin:10px 0 0;line-height:1.5">${esc(description)}</div>` : ''}
<a href="${esc(link)}" style="display:inline-block;margin:18px 0 8px;background:#1F3B73;color:#fff;text-decoration:none;font-weight:600;font-size:14px;padding:11px 22px;border-radius:9px">Join the meeting</a>
<div style="font-size:12px;color:#94a3b8;margin-top:8px">No account needed — open the link and enter your name.</div>
<div style="font-size:11px;color:#cbd5e1;margin-top:14px;word-break:break-all">${esc(link)}</div>
</div>
</div>`;
return { subject, text, html };
}
module.exports = { send, meetingInviteEmail, isEnabled };
+27 -4
View File
@@ -728,6 +728,15 @@
.flbl .opt{color:var(--muted);font-weight:400;}
.finput{width:100%;border:1px solid var(--line);border-radius:9px;padding:.55rem .65rem;font-size:.92rem;font-family:inherit;background:#fbfcfe;color:var(--ink);box-sizing:border-box;}
.finput:focus{outline:none;border-color:var(--blue);}
.email-invite{display:flex;gap:.5rem;align-items:stretch;}
.email-invite .finput{flex:1;}
.email-add{flex:0 0 auto;display:inline-flex;align-items:center;gap:.3rem;border:1px solid var(--line);background:var(--blue-soft);color:var(--blue);border-radius:9px;padding:0 .8rem;font-size:.82rem;font-weight:600;cursor:pointer;white-space:nowrap;}
.email-add:hover{background:var(--blue);color:#fff;}
.email-chips{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem;}
.email-chips:empty{margin-top:0;}
.echip{display:inline-flex;align-items:center;gap:.3rem;background:#eef2fa;border:1px solid var(--line);color:var(--ink);border-radius:99px;padding:.2rem .3rem .2rem .6rem;font-size:.78rem;}
.echip button{border:none;background:transparent;color:var(--muted);cursor:pointer;display:grid;place-items:center;padding:.1rem;border-radius:50%;}
.echip button:hover{color:#dc2626;}
.iconbtn{border:none;background:transparent;color:var(--muted);cursor:pointer;width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:0 0 auto;}
.iconbtn:hover{background:#f1f5f9;color:var(--blue);}
.iconbtn.rm:hover{color:var(--red);background:#fee2e2;}
@@ -875,7 +884,7 @@
<body>
<script src="/icons.js?v=5"></script>
<script src="https://cdn.jsdelivr.net/npm/@twemoji/api@15.1.0/dist/twemoji.min.js" crossorigin="anonymous"></script>
<script>window.__BUILD='2026-07-10-batch68';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
<script>window.__BUILD='2026-07-10-batch69';console.log('%cBiz Connect','color:#1F3B73;font-weight:bold','build '+window.__BUILD);
// Render modern (Twemoji) emojis in place of the OS's flat ones. No-op if the CDN didn't load
// (emojis stay as plain Unicode). (#5)
function twemojify(el){ try{ if(el && window.twemoji) window.twemoji.parse(el, { folder:'svg', ext:'.svg' }); }catch(_){} }</script>
@@ -3094,6 +3103,7 @@ async function loadScheduledMeetings(){
+(m.invited&&m.invited.length?'<div class="si-invited" title="'+pEsc(m.invited.join(', '))+'">'+ic('users',12)+' '+pEsc(m.invited.slice(0,3).join(', '))+(m.invited.length>3?(' +'+(m.invited.length-3)):'')+'</div>':'')
+(m.recordings&&m.recordings.length?'<div class="si-recs">'+m.recordings.map(r=>'<a class="rec-dl '+(r.kind==='video'?'vid':'txt')+'" href="'+pEsc(r.url)+'" title="Download '+(r.kind==='video'?'recording':'transcript')+'">'+ic('download',14)+'<span>'+(r.kind==='video'?'Recording':'Transcript')+'</span>'+(r.kind==='video'&&r.durationMs?'<span class="rd-dur">'+fmtElapsed(r.durationMs)+'</span>':'')+'</a>').join('')+'</div>':'')+'</div>'
+'<div class="si-actions">'
+((m.status!=='past'&&!cancelled)?'<button class="iconbtn copylink" data-link="'+pEsc(m.link||'')+'" title="Copy invite link" aria-label="Copy invite link">'+ic('link',14)+'</button>':'')
+((m.status!=='past'&&!cancelled&&canStart)?'<button class="btn sm join" data-code="'+pEsc(m.roomCode)+'">'+(m.status==='running'?'Join':'Start')+'</button>':'')
+(canCancel?'<button class="iconbtn edit" data-edit="'+pEsc(m.id)+'" title="Edit meeting" aria-label="Edit meeting">'+ic('pencil',14)+'</button>':'')
+(canCancel?'<button class="iconbtn cancel-ic" data-cancel="'+pEsc(m.id)+'" title="Cancel meeting" aria-label="Cancel meeting">'+ic('calendarX',14)+'</button>':'')
@@ -3103,6 +3113,7 @@ async function loadScheduledMeetings(){
const sec=(title,arr)=>arr.length?('<div class="sched-sec"><div class="sched-h">'+title+'</div><div class="sched-list">'+arr.map(card).join('')+'</div></div>'):'';
wrap.innerHTML=sec('Ongoing now',running)+sec('Upcoming meetings',upcoming)+sec('Past meetings',past);
wrap.querySelectorAll('[data-code]').forEach(b=>b.onclick=()=>enterMeeting(b.dataset.code));
wrap.querySelectorAll('[data-link]').forEach(b=>b.onclick=()=>{ const u=b.dataset.link; if(!u){ toast('No link'); return; } const done=()=>toast('Invite link copied'); try{ if(navigator.clipboard&&navigator.clipboard.writeText) return void navigator.clipboard.writeText(u).then(done).catch(()=>fallbackCopy(u,done)); }catch(_){} fallbackCopy(u,done); });
wrap.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>{ const m=byId[b.dataset.edit]; if(m) openScheduleModal(m.groupId||null, m); });
wrap.querySelectorAll('[data-cancel]').forEach(b=>b.onclick=()=>cancelMeeting(byId[b.dataset.cancel]));
}
@@ -3158,7 +3169,10 @@ function openScheduleModal(gid, editMtg){
+'<div class="sch-days hidden" id="schDays">'+DAY1.map((d,i)=>'<button type="button" class="day-chip" data-d="'+i+'" title="'+DAYW[i]+'">'+d+'</button>').join('')+'<button type="button" class="day-all" data-all="1">Everyday</button></div>'
+'<label class="flbl">Description <span class="opt">(optional)</span></label><textarea id="schDesc" class="finput" rows="2" placeholder="What\'s this call about?"></textarea>'
+'<label class="flbl">Invite participants</label>'
+'<div class="gi-list" id="schPeople" style="max-height:24vh;overflow:auto">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"'+(invitedIds.has(c.id)?' checked':'')+'><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No contacts to invite</div>')+'</div>'
+'<div class="gi-list" id="schPeople" style="max-height:20vh;overflow:auto">'+(CONTACTS.length?CONTACTS.map(c=>'<label class="chk"><input type="checkbox" value="'+pEsc(c.id)+'"'+(invitedIds.has(c.id)?' checked':'')+'><span class="mini-av" style="background:'+avColor(c.name)+'">'+pEsc(initials(c.name))+'</span><span class="mn">'+pEsc(c.name)+'</span></label>').join(''):'<div class="gi-noresult">No contacts to invite</div>')+'</div>'
+'<label class="flbl" style="margin-top:.7rem">Invite by email <span class="opt">(guests — no Connect account needed)</span></label>'
+'<div class="email-invite"><input id="schEmail" class="finput" type="email" placeholder="name@example.com" autocomplete="off"><button type="button" class="email-add" id="schEmailAdd">'+ic('userPlus',15)+' Add</button></div>'
+'<div class="email-chips" id="schEmailChips"></div>'
+'<button class="gobtn" id="schSave" style="width:100%;margin-top:.9rem;background:var(--blue);color:#fff">'+(editing?'Save changes':'Schedule & invite')+'</button>'
+'<div class="hint" id="schErr"></div></div>';
document.body.appendChild(ov);
@@ -3203,11 +3217,20 @@ function openScheduleModal(gid, editMtg){
daysWrap.querySelector('.day-all').onclick=()=>{ const allOn=daysWrap.querySelectorAll('.day-chip.on').length===7; daysWrap.querySelectorAll('.day-chip').forEach(x=>x.classList.toggle('on', !allOn)); };
$('schTitle').addEventListener('input',clearErrAll);
setTimeout(()=>$('schTitle').focus(),0);
// External (guest) email invitees — chips you can add/remove; each gets an emailed join link (#4).
const EMAIL_RE=/^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const emailList=(editing&&Array.isArray(editMtg.guestEmails))?editMtg.guestEmails.slice():[];
function renderEmailChips(){ const w=$('schEmailChips'); if(!w) return; w.innerHTML=emailList.map((e,i)=>'<span class="echip">'+pEsc(e)+'<button type="button" data-i="'+i+'" title="Remove">'+ic('x',12)+'</button></span>').join(''); w.querySelectorAll('.echip button').forEach(b=>b.onclick=()=>{ emailList.splice(+b.dataset.i,1); renderEmailChips(); }); }
function addEmail(){ const inp=$('schEmail'); const v=(inp.value||'').trim().toLowerCase(); if(!v) return; if(!EMAIL_RE.test(v)){ err.textContent='Enter a valid email address.'; return; } if(!emailList.includes(v)) emailList.push(v); inp.value=''; err.textContent=''; renderEmailChips(); }
$('schEmailAdd').onclick=addEmail;
$('schEmail').addEventListener('keydown',e=>{ if(e.key==='Enter'){ e.preventDefault(); addEmail(); } });
renderEmailChips();
$('schSave').onclick=async()=>{
const title=$('schTitle').value.trim();
const desc=$('schDesc').value.trim();
const durationMins=parseInt($('schDur').value,10)||30;
const participants=[...ov.querySelectorAll('#schPeople input:checked')].map(i=>i.value);
{ const pend=($('schEmail').value||'').trim().toLowerCase(); if(pend && EMAIL_RE.test(pend) && !emailList.includes(pend)) emailList.push(pend); } // include a typed-but-not-added email
clearErrAll();
if(!title){ err.textContent='Please add a title.'; $('schTitle').classList.add('field-err'); $('schTitle').focus(); return; }
const ts=new Date(selDate.getFullYear(),selDate.getMonth(),selDate.getDate(),Math.floor(selMin/60),selMin%60,0,0).getTime();
@@ -3215,8 +3238,8 @@ function openScheduleModal(gid, editMtg){
let recurrence=[]; if(repeat.checked){ recurrence=[...daysWrap.querySelectorAll('.day-chip.on')].map(b=>+b.dataset.d); if(!recurrence.length) recurrence=[new Date(ts).getDay()]; }
const whenText=new Date(ts).toLocaleString([],{weekday:'short',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
try{
if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, recurrence }); toast('Meeting updated'); }
else { await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, durationMins, recurrence }); toast('Meeting scheduled'+(participants.length?' · '+participants.length+' invited':'')); }
if(editing){ await postJSON('/api/meetings/update',{ id:editMtg.id, title, description:desc, scheduledAt:ts, durationMins, participants, participantEmails:emailList, recurrence }); toast('Meeting updated'); }
else { const invn=participants.length+emailList.length; await postJSON('/api/meetings/schedule',{ group:gid||undefined, title, description:desc, scheduledAt:ts, whenText, participants, participantEmails:emailList, durationMins, recurrence }); toast('Meeting scheduled'+(invn?' · '+invn+' invited':'')); }
ov.remove(); switchTab('meeting'); loadScheduledMeetings();
}catch(e){ err.textContent=e.message||'Could not save'; }
};
+6 -6
View File
@@ -297,9 +297,9 @@ const attachments = {
};
const scheduledMeetings = {
create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence }) =>
db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null),
create: ({ id, teamId, groupId, roomCode, title, description, scheduledAt, createdBy, participants, durationMins, recurrence, guestEmails }) =>
db.prepare('INSERT INTO scheduled_meetings (id,team_id,group_id,room_code,title,description,scheduled_at,created_by,created_at,participants,duration_mins,recurrence,guest_emails) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)')
.run(id, teamId, groupId || null, roomCode, title, description || null, scheduledAt, createdBy, now(), (participants && participants.length) ? JSON.stringify(participants) : null, durationMins || null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null),
byId: (id) => db.prepare('SELECT * FROM scheduled_meetings WHERE id=?').get(id),
byCode: (code) => db.prepare('SELECT * FROM scheduled_meetings WHERE room_code=? ORDER BY created_at DESC LIMIT 1').get(code),
// Meetings a user can see: created by them, a member of the group, or an invited participant.
@@ -315,9 +315,9 @@ const scheduledMeetings = {
end: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId),
cancel: (id, teamId) => db.prepare('UPDATE scheduled_meetings SET cancelled=1, ended_at=? WHERE id=? AND team_id=?').run(now(), id, teamId),
reschedule: (id, teamId, ts) => db.prepare('UPDATE scheduled_meetings SET scheduled_at=?, reminded=0 WHERE id=? AND team_id=?').run(ts, id, teamId), // recurrence: roll to next occurrence
update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence }) =>
db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, reminded=0 WHERE id=? AND team_id=?')
.run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, id, teamId),
update: (id, teamId, { title, description, scheduledAt, durationMins, participants, recurrence, guestEmails }) =>
db.prepare('UPDATE scheduled_meetings SET title=?, description=?, scheduled_at=?, duration_mins=?, participants=?, recurrence=?, guest_emails=?, reminded=0 WHERE id=? AND team_id=?')
.run(title, description || null, scheduledAt, durationMins || null, (participants && participants.length) ? JSON.stringify(participants) : null, (recurrence && recurrence.length) ? JSON.stringify(recurrence) : null, (guestEmails && guestEmails.length) ? JSON.stringify(guestEmails) : null, id, teamId),
remove: (id, teamId) => db.prepare('DELETE FROM scheduled_meetings WHERE id=? AND team_id=?').run(id, teamId),
};
+37 -8
View File
@@ -83,7 +83,10 @@ const API_KEY_SCOPES = ['report:read', 'audit:read'];
const { onlineAgents, meetingRooms, groupCalls, dmCalls } = require('./presence');
const CALLS = require('./calls');
require('./reminders'); // start the 10-minute meeting-reminder loop
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED } = require('./config');
const { REC_DIR, TRANS_DIR, UPLOADS_DIR, SESSION_TTL, REFRESH_TTL, LIVEKIT_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_ENABLED, PUBLIC_BASE_URL } = require('./config');
const mailer = require('./mailer');
// Basic email validation for external meeting invitees (#4).
const isEmail = (s) => typeof s === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
const crypto = require('crypto');
const MAX_FILE_BYTES = 25 * 1024 * 1024; // 25 MB per chat attachment
@@ -1062,7 +1065,7 @@ route('POST', '/api/groups/remove', async (req, res) => {
route('POST', '/api/meetings/schedule', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { group, title, description, scheduledAt, whenText, participants, durationMins, recurrence } = await readBody(req);
const { group, title, description, scheduledAt, whenText, participants, participantEmails, durationMins, recurrence } = await readBody(req);
const t = String(title || '').trim().slice(0, 120);
if (!t) return json(res, 400, { error: 'title required' });
const when = Number(scheduledAt);
@@ -1078,9 +1081,11 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
const desc = String(description || '').trim().slice(0, 1000);
// Invited participants: tenant users, excluding the host (creator).
const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))];
// External invitees by email (#4): people not on Connect — they get an emailed guest link.
const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100);
let code; do { code = A.numericCode(6); } while (R.scheduledMeetings.byCode(code) || meetingRooms.has(code));
const id = A.id();
R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur });
R.scheduledMeetings.create({ id, teamId: u.team_id, groupId, roomCode: code, title: t, description: desc, scheduledAt: when, createdBy: u.id, participants: invited, durationMins: dur, recurrence: recur, guestEmails });
audit({ team_id: u.team_id, user_id: u.id, user_email: u.email, action: 'meeting_scheduled', detail: t });
const label = (typeof whenText === 'string' && whenText.trim()) ? whenText.trim() : new Date(when).toLocaleString();
if (groupId) {
@@ -1092,7 +1097,21 @@ route('POST', '/api/meetings/schedule', async (req, res) => {
// Invitation notification to each invited participant.
const inviteEvt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: code, by: u.name || u.email } };
for (const pid of invited) { try { CHAT.pushToUser(pid, inviteEvt); } catch (_) {} }
json(res, 200, { id, roomCode: code, title: t, description: desc, scheduledAt: when, groupId, participants: invited });
// Email invites (#4): the guest join link goes to external invitees, plus any invited Connect users
// who have an email on file. Fire-and-forget — a mail outage never fails scheduling. No-op if SMTP off.
try {
if (mailer.isEnabled() && (guestEmails.length || invited.length)) {
const link = PUBLIC_BASE_URL + '/home?meet=' + code;
const nameByEmail = {}; const emails = new Set(guestEmails);
for (const x of R.users.listByTenant(u.team_id)) { if (x.email) nameByEmail[x.id] = x.email; }
for (const pid of invited) { const em = nameByEmail[pid]; if (em && isEmail(em)) emails.add(em.toLowerCase()); }
if (emails.size) {
const tpl = mailer.meetingInviteEmail({ title: t, when: label, link, host: u.name || u.email, description: desc });
mailer.send({ to: [...emails], subject: tpl.subject, html: tpl.html, text: tpl.text });
}
}
} catch (e) { console.warn('[meetings] invite email failed:', e && e.message); }
json(res, 200, { id, roomCode: code, title: t, description: desc, scheduledAt: when, groupId, participants: invited, guestEmails, link: PUBLIC_BASE_URL + '/home?meet=' + code });
});
// List the meetings this user can see, bucketed into running / upcoming / past.
@@ -1118,12 +1137,13 @@ route('GET', '/api/meetings', async (req, res) => {
else if (s.ended_at) status = 'past';
else if (nowTs > endTime) status = 'past'; // its scheduled window has fully passed
let invited = []; try { invited = JSON.parse(s.participants || '[]'); } catch (_) {}
let guestEmails = []; try { guestEmails = JSON.parse(s.guest_emails || '[]'); } catch (_) {}
return {
id: s.id, roomCode: s.room_code, title: s.title, description: s.description || '',
scheduledAt: schedAt, groupId: s.group_id,
scheduledAt: schedAt, groupId: s.group_id, link: PUBLIC_BASE_URL + '/home?meet=' + s.room_code,
groupName: s.group_id ? ((R.conversations.byId(s.group_id) || {}).name || 'Group') : null,
createdBy: s.created_by, createdByName: names[s.created_by] || '', canManage: s.created_by === u.id, isHost: s.created_by === u.id,
invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited,
invited: invited.map((pid) => names[pid] || 'Someone'), invitedIds: invited, guestEmails,
durationMins: s.duration_mins || null, recurrence: recur, recurrenceLabel: recurrenceLabel(recur),
status, inCall: running ? live.size : 0, recordings: [],
};
@@ -1229,7 +1249,7 @@ route('POST', '/api/meetings/cancel', async (req, res) => {
route('POST', '/api/meetings/update', async (req, res) => {
const u = currentUser(req);
if (!u) return json(res, 401, { error: 'unauthorized' });
const { id, title, description, scheduledAt, durationMins, participants, recurrence } = await readBody(req);
const { id, title, description, scheduledAt, durationMins, participants, participantEmails, recurrence } = await readBody(req);
const s = id && R.scheduledMeetings.byId(id);
if (!s || s.team_id !== u.team_id) return json(res, 404, { error: 'not found' });
if (s.created_by !== u.id) return json(res, 403, { error: 'only the organizer can edit' });
@@ -1239,8 +1259,17 @@ route('POST', '/api/meetings/update', async (req, res) => {
const dur = [15, 30, 45, 60, 90, 120].includes(Number(durationMins)) ? Number(durationMins) : (s.duration_mins || 30);
const recur = Array.isArray(recurrence) ? [...new Set(recurrence.map(Number).filter((d) => d >= 0 && d <= 6))] : [];
const invited = [...new Set((Array.isArray(participants) ? participants : []).filter((x) => typeof x === 'string' && x !== u.id && R.users.inTenant(x, u.team_id)))];
R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur });
const guestEmails = [...new Set((Array.isArray(participantEmails) ? participantEmails : []).map((e) => String(e || '').trim().toLowerCase()).filter(isEmail))].slice(0, 100);
R.scheduledMeetings.update(id, u.team_id, { title: t, description: String(description || '').trim().slice(0, 1000), scheduledAt: when, durationMins: dur, participants: invited, recurrence: recur, guestEmails });
const label = new Date(when).toLocaleString();
// Email the updated details to external invitees (new + existing) so their link/time stays current.
try {
if (mailer.isEnabled() && guestEmails.length) {
const link = PUBLIC_BASE_URL + '/home?meet=' + s.room_code;
const tpl = mailer.meetingInviteEmail({ title: t, when: label, link, host: u.name || u.email, description: String(description || '').trim().slice(0, 1000) });
mailer.send({ to: guestEmails, subject: tpl.subject, html: tpl.html, text: tpl.text });
}
} catch (_) {}
const evt = { type: 'meeting-invite', meeting: { id, title: t, scheduledAt: when, whenText: label, room: s.room_code, by: u.name || u.email, updated: true } };
const recips = new Set(invited); if (s.group_id) for (const mid of R.conversations.members(s.group_id)) recips.add(mid);
recips.forEach((rid) => { if (rid !== u.id) { try { CHAT.pushToUser(rid, evt); } catch (_) {} } });