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
+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 (_) {} } });