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:
+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