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
+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),
};