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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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
@@ -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
@@ -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
@@ -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 (_) {} } });
|
||||
|
||||
Reference in New Issue
Block a user