Skip to content

Commit

Permalink
email invites and notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
SanderGi committed Feb 12, 2024
1 parent 6136de9 commit 614964d
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 373 deletions.
402 changes: 49 additions & 353 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"express": "^4.18.2",
"express-admin": "^2.0.0",
"firebase-admin": "^11.3.0",
"firebase-tools": "^12.9.1",
"firebase-tools": "^13.2.1",
"node-fetch": "^2.7.0",
"node-persist": "^3.1.3",
"sqlite3": "^5.1.2",
"uuid": "^9.0.0"
Expand Down
34 changes: 24 additions & 10 deletions public/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<script type="module" src="/components/Navigation.js"></script>
<script type="module" src="/components/TypeSelect.js"></script>
<script type="module" src="/components/Table.js"></script>
<script type="module" src="/components/Popup.js"></script>

<style>
/* smooth load */
Expand All @@ -32,7 +33,9 @@
<h1>Dashboard</h1>
<type-select id="businessId" name="businesses" label="Group:"></type-select>
<div id="qrcode" class="img"></div>
<button id="joinlink" class="button">Copy Join Link</button><br />
<button id="joinlink" class="button">Copy Join Link</button>
<button id="emailInvite" class="button">Invite by Email</button>
<br />
</section>
<section class="light-section">
<h1>Events</h1>
Expand Down Expand Up @@ -185,15 +188,26 @@ <h1>Event Table</h1>
<attendance-table id="table"></attendance-table>
</section>
<section class="dark-section">
<h1>Business Settings</h1>
<div style="display: flex; justify-content: center; margin-bottom: 12px">
<label style="font-size: 1.5rem; padding: 0.2rem"
>Require joining business before attendance can be taken</label
>
<div class="checkbox-wrapper-6">
<input id="require-join" class="tgl tgl-light" type="checkbox" />
<label class="tgl-btn" style="font-size: 16px" for="require-join"></label>
</div>
<h1>Group Settings</h1>
<div class="form" style="padding-right: 1em;">
<label for="require-join" style="display: flex; justify-content: space-evenly;">
Only take attendance for members
<div class="checkbox-wrapper-6">
<input id="require-join" class="tgl tgl-light" type="checkbox" />
<label class="tgl-btn" style="font-size: 16px" for="require-join"></label>
</div>
</label>
<hr>
<label for="email-notification"
>Send email notification to group members</label
><br>
<textarea
id="email-notification"
style="resize: vertical"
rows="7"
name="email-notification"
></textarea><br>
<button id="sent-email" class="button">Send Email to Everyone</button>
</div>
</section>
</navigation-manager>
Expand Down
93 changes: 89 additions & 4 deletions public/admin.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { GET } from './util/Client.js';
import { requireLogin } from './util/Auth.js';
import { GET, sendEmail } from './util/Client.js';
import { requireLogin, requestGoogleCredential, getCurrentUser } from './util/Auth.js';
import { Popup } from './components/Popup.js';
import { initBusinessSelector, initEventSelector } from './util/selectors.js';
import { sanitizeText } from './util/util.js';
const QRCode = window.QRCode;
await requireLogin();
const user = await getCurrentUser();

const attendanceTable = document.getElementById('table');
attendanceTable.addEventListener('reloadTable', () => {
Expand Down Expand Up @@ -49,18 +50,103 @@ async function updateJoinLink() {
document.getElementById('joinlink').classList.remove('success');
}, 5000);
};

document.getElementById('emailInvite').onclick = async () => {
const emails = await Popup.prompt(
'Enter comma separated email addresses of the people you want to invite:',
'var(--primary)',
);
if (!emails) {
Popup.alert('No emails entered.', 'var(--error)');
return;
}
const credential = await requestGoogleCredential([
'https://www.googleapis.com/auth/gmail.send',
]);
let success = true;
for (const email of emails.split(',')) {
const res = await sendEmail(
email.trim(),
'Attendance Scanner Invitation',
`
Hi!
You have been invited to join my group on Attendance Scanner QR.
Please click this link to join: ${joinlink}.
Best,
${sanitizeText(credential.name)}
(automatically sent via Attendance Scanner QR)
`.trim(),
credential,
);
if (!res.ok) {
success = false;
const obj = await res.json();
const message = obj.error.message;
Popup.alert(
`Email to ${sanitizeText(email)} failed to send. ` + message,
'var(--error)',
);
}
}
if (success) {
Popup.alert('Emails sent successfully!', 'var(--success)');
}
};
}
updateJoinLink();

const members = new Set();
async function runTable() {
let attendancearr = await (await GET(`/attendancedata?businessId=${getBusinessId()}`)).json();
for (const user of attendancearr) {
members.add({ name: user.name, email: user.email });
}
attendanceTable.updateTable(attendancearr, events, getBusinessId());
}

const email_notification = document.getElementById('email-notification');
email_notification.textContent = `
Hi [MEMBER_NAME],
We'll be having an extra rehearsal on Monday at XXXX. We hope to see you there!
Best,
${user.name}
(automatically sent via Attendance Scanner QR)
`.trim();
document.getElementById('sent-email').onclick = async () => {
const credential = await requestGoogleCredential([
'https://www.googleapis.com/auth/gmail.send',
]);
let success = true;
for (const member of members) {
const res = await sendEmail(
member.email,
'Attendance Scanner Notification',
email_notification.textContent.replace('[MEMBER_NAME]', member.name),
credential,
);
if (!res.ok) {
success = false;
const obj = await res.json();
const message = obj.error.message;
Popup.alert(
`Email to ${sanitizeText(member[1])} failed to send. ` + message,
'var(--error)',
);
}
}
if (success) {
Popup.alert('Emails sent successfully!', 'var(--success)');
}
};

async function setRecordSettings() {
const res = await (await GET(`/getRecordSettings?businessId=${getBusinessId()}`)).json();
const requireJoin = res.requireJoin;
console.log(requireJoin);
if (requireJoin) {
document.getElementById('require-join').checked = true;
}
Expand All @@ -69,7 +155,6 @@ async function setRecordSettings() {

document.getElementById('require-join').addEventListener('change', async e => {
const requireJoin = e.target.checked ? 1 : 0;
console.log(requireJoin);
await GET(`/changeRecordSettings?businessId=${getBusinessId()}&newStatus=${requireJoin}`);
});

Expand Down
5 changes: 0 additions & 5 deletions public/components/ContactForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,6 @@ class ContactForm extends Component {
font-size: 3rem;
padding: 0.2em;
}
hr {
width: 3rem;
border: 2px solid var(--accent);
margin: 0 auto;
}
div {
width: 100%;
max-width: var(--max-width-small);
Expand Down
6 changes: 6 additions & 0 deletions public/styles/inputs.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
hr {
width: 3rem;
border: 2px solid var(--accent);
margin: 0 auto;
}

.form label {
font-size: 1.5rem;
padding: 0.2rem;
Expand Down
15 changes: 15 additions & 0 deletions public/util/Auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,18 @@ export async function logout() {
console.error(e);
}
}

/**
* Prompts the user to log in to a google account and give permission for the specified scopes.
* @param {string[]} scopes Google API scopes to request permission for (if we already have them, the user will not be prompted for them)
* @returns the user's Google credential (with the user's name and email) if the user has logged in and given permission, otherwise null.
*/
export async function requestGoogleCredential(scopes) {
for (const scope of scopes) {
googleProvider.addScope(scope);
}
const signinResult = await signInWithPopup(auth, googleProvider);
const credential = GoogleAuthProvider.credentialFromResult(signinResult);

return { ...credential, name: signinResult.user.displayName, email: signinResult.user.email };
}
11 changes: 11 additions & 0 deletions public/util/Client.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,14 @@ export async function POST(url, data) {
body: JSON.stringify(data),
});
}

export async function sendEmail(to_email, subject, text, credential) {
const message = {
to_email: to_email,
from_email: credential.email,
subject: subject,
text: text,
};

return await POST('/sendEmail', { message: message, accessToken: credential.accessToken });
}
53 changes: 53 additions & 0 deletions server/Google.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
const express = require('express'),
router = express.Router();
const fetch = require('node-fetch');
const { handleAuth } = require('./Auth');

// ===================== Gmail API =====================
/**
* Sends an email using the Gmail API.
* @param {Object} message the email message to send
* @param {string} accessToken the access token to use to send the email
* @requiredPrivileges the user to be logged in
* @returns the response from the Gmail API
*/
router.post('/sendEmail', async (req, res) => {
await handleAuth(req, res);

const message = req.body.message;
const accessToken = req.body.accessToken;
const response = await callGmailAPI(message, accessToken);
res.status(response.status).send(await response.json());
});

function callGmailAPI(message, accessToken) {
const emailLines = [];
emailLines.push(`From: ${message.from_email}`);
emailLines.push(`To: ${message.to_email}`);
emailLines.push(`Subject: ${message.subject}`);
emailLines.push('');
emailLines.push(message.text);

const email = emailLines.join('\r\n');
const encodedEmail = encode(email);

return fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
raw: encodedEmail,
}),
});
}

function encode(str, urlSafe = false) {
const encoded = Buffer.from(str).toString('base64');
return urlSafe ? encoded.replace(/\+/g, '-').replace(/\//g, '_') : encoded;
}

// ===================== Router =====================
exports.googleRouter = router;
4 changes: 4 additions & 0 deletions server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ app.use('/', attendanceRouter);
const { eventRouter } = require('./Event');
app.use('/', eventRouter);

// ============================ Google APIs ============================
const { googleRouter } = require('./Google');
app.use('/', googleRouter);

// ============================ SERVER ============================
// listen for requests :)
module.exports.app = app;
Expand Down

0 comments on commit 614964d

Please sign in to comment.