From 045c0346d04b8dc75b97b8ff73981ef9a8a15f4a Mon Sep 17 00:00:00 2001 From: clr-li <111320104+clr-li@users.noreply.github.com> Date: Fri, 9 Feb 2024 17:36:58 -0800 Subject: [PATCH] Added email and custom data --- .badges/coverage.svg | 8 +- public/components/Table.js | 230 ++++++++++++++++++++++++++++--- public/styles/inputs.css | 2 + server/Auth.js | 12 +- server/Business.js | 52 ++++++- server/databaseSchema.sql | 2 + server/server.js | 2 +- server/super_admin/settings.json | 64 +++++++++ test/server.test.js | 67 +++++---- 9 files changed, 387 insertions(+), 52 deletions(-) diff --git a/.badges/coverage.svg b/.badges/coverage.svg index 03dda92..1b9c734 100644 --- a/.badges/coverage.svg +++ b/.badges/coverage.svg @@ -1,6 +1,6 @@ - - Coverage: 82% + + Coverage: 81% @@ -10,8 +10,8 @@ Coverage - - 82% + + 81% \ No newline at end of file diff --git a/public/components/Table.js b/public/components/Table.js index 612a0c3..38e9498 100644 --- a/public/components/Table.js +++ b/public/components/Table.js @@ -1,7 +1,8 @@ import { Component } from '../util/Component.js'; import { Popup } from './Popup.js'; import { calcSimilarity, sanitizeText } from '../util/util.js'; -import { GET } from '../util/Client.js'; +import { GET, POST } from '../util/Client.js'; +import { useURL } from '../util/StateManager.js'; /** * The Table component represents a table to store data @@ -13,6 +14,7 @@ export class Table extends Component { +

@@ -23,25 +25,42 @@ export class Table extends Component {
-

- - - - - - - -
+
+ + + +
+
+ + + + + + + + +
+ + +

role changed

success

- `; } @@ -52,6 +71,60 @@ export class Table extends Component { }, 3000); } + activateAlter( + alterTab, + importTab, + exportTab, + alterContainer, + importContainer, + exportContainer, + setActiveTab, + ) { + alterTab.classList.add('active'); + importTab.classList.remove('active'); + exportTab.classList.remove('active'); + alterContainer.style.display = 'block'; + importContainer.style.display = 'none'; + exportContainer.style.display = 'none'; + setActiveTab('alter'); + } + + activateImport( + alterTab, + importTab, + exportTab, + alterContainer, + importContainer, + exportContainer, + setActiveTab, + ) { + alterTab.classList.remove('active'); + importTab.classList.add('active'); + exportTab.classList.remove('active'); + alterContainer.style.display = 'none'; + importContainer.style.display = 'block'; + exportContainer.style.display = 'none'; + setActiveTab('import'); + } + + activateExport( + alterTab, + importTab, + exportTab, + alterContainer, + importContainer, + exportContainer, + setActiveTab, + ) { + alterTab.classList.remove('active'); + importTab.classList.remove('active'); + exportTab.classList.add('active'); + alterContainer.style.display = 'none'; + importContainer.style.display = 'none'; + exportContainer.style.display = 'block'; + setActiveTab('export'); + } + async updateTable(attendancearr, events, businessID) { this.businessID = businessID; let map = new Map(); @@ -71,7 +144,15 @@ export class Table extends Component { } map.get(attendancearr[i].id).push(attendancearr[i]); } - let html = `Name (id)`; + let html = `Name (id)Email`; + + // Custom data headers + const customHeaders = attendancearr[0].custom_data; + for (const [key, value] of Object.entries(JSON.parse(customHeaders))) { + html += `${sanitizeText(key)}`; + } + + // Event headers for (let i = 0; i < events.length; i++) { var startDate = new Date(events[i].starttimestamp * 1000); var endDate = new Date(events[i].endtimestamp * 1000); @@ -112,7 +193,16 @@ export class Table extends Component { } else { html += ` - owner)`; } - html += ''; + html += `${sanitizeText( + records[0].email, + )}`; + + // Custom data + let customData = JSON.parse(records[0].custom_data); + for (const [key, value] of Object.entries(customData)) { + html += `${sanitizeText(value)}`; + } + for (let j = 0; j < events.length; j++) { let statusupdate = false; let color = 'lightgray'; @@ -150,8 +240,8 @@ export class Table extends Component { } html += `

${status}

`; } } @@ -199,6 +289,114 @@ export class Table extends Component { button_index++; } } + const { get: getActiveTab, set: setActiveTab } = useURL('active', 'alter'); + const alterTab = this.shadowRoot.getElementById('alter-tab'); + const importTab = this.shadowRoot.getElementById('import-tab'); + const exportTab = this.shadowRoot.getElementById('export-tab'); + const alterContainer = this.shadowRoot.getElementById('alter-container'); + const importContainer = this.shadowRoot.getElementById('import-container'); + const exportContainer = this.shadowRoot.getElementById('export-container'); + this.shadowRoot.getElementById('alter-tab').addEventListener('click', e => { + this.activateAlter( + alterTab, + importTab, + exportTab, + alterContainer, + importContainer, + exportContainer, + setActiveTab, + ); + }); + this.shadowRoot.getElementById('import-tab').addEventListener('click', e => { + this.activateImport( + alterTab, + importTab, + exportTab, + alterContainer, + importContainer, + exportContainer, + setActiveTab, + ); + }); + this.shadowRoot.getElementById('export-tab').addEventListener('click', e => { + this.activateExport( + alterTab, + importTab, + exportTab, + alterContainer, + importContainer, + exportContainer, + setActiveTab, + ); + }); + if (getActiveTab() === 'alter') { + this.activateAlter( + alterTab, + importTab, + exportTab, + alterContainer, + importContainer, + exportContainer, + setActiveTab, + ); + } else if (getActiveTab() === 'import') { + this.activateImport( + alterTab, + importTab, + exportTab, + alterContainer, + importContainer, + exportContainer, + setActiveTab, + ); + } else { + this.activateExport( + alterTab, + importTab, + exportTab, + alterContainer, + importContainer, + exportContainer, + setActiveTab, + ); + } + this.options = ['Email', 'Id', 'Name']; + this.mergeOptions = this.shadowRoot.getElementById('merge-col'); + for (let i = 0; i < this.options.length; i++) { + let option = document.createElement('option'); + option.value = this.options[i]; + this.mergeOptions.appendChild(option); + } + + this.mergeCol = ''; + this.mergeOptions.addEventListener('select', e => { + this.mergeCol = e.detail.value; + }); + + const csvFile = this.shadowRoot.getElementById('csv-file'); + this.shadowRoot.getElementById('import-merge').addEventListener('click', () => { + // Get file text + const file = csvFile.files[0]; + let reader = new FileReader(); + reader.addEventListener( + 'load', + async () => { + const res = await POST(`/importCustomData?businessId=${businessID}`, { + data: reader.result, + mergeCol: this.mergeCol.toLowerCase(), + }); + if (res.ok) { + this.showSuccessDialog('success'); + } else { + Popup.alert(sanitizeText(await res.text()), 'var(--error)'); + } + }, + false, + ); + if (file) { + reader.readAsText(file); + } + }); } sortStudents(searchword) { @@ -362,7 +560,6 @@ export class Table extends Component { ids_to_alter.push(checkbox.id.split('-')[1]); } } - console.log('ids to alter: ' + ids_to_alter); if (ids_to_alter.length === 0) { Popup.alert('Please select the users/rows to alter first.', 'var(--warning)'); return; @@ -417,7 +614,6 @@ export class Table extends Component { } // combine each row data with new line character csv_data = csv_data.join('\n'); - // console.log(csv_data); this.downloadCSVFile(csv_data); }; } diff --git a/public/styles/inputs.css b/public/styles/inputs.css index 2968122..2b84df0 100644 --- a/public/styles/inputs.css +++ b/public/styles/inputs.css @@ -219,6 +219,8 @@ input[type='radio'] + .overlay .text { background: transparent; border: none; min-width: 3rem; + padding-left: 5px; + padding-right: 5px; } .button-tab.active { border-bottom: 2px solid var(--accent); diff --git a/server/Auth.js b/server/Auth.js index 3c468de..0babdb0 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -41,10 +41,10 @@ function parseJwt(token) { module.exports.verifyIdToken = async function verifyIdToken(idToken) { if (TOKEN_VERIFICATION) { const decodedToken = await admin.auth().verifyIdToken(idToken, true); - return [decodedToken.uid, decodedToken.name]; + return [decodedToken.uid, decodedToken.name, decodedToken.email]; } else { const decodedToken = parseJwt(idToken); // development purposes, don't require idToken to be valid - return [decodedToken.user_id, decodedToken.name]; + return [decodedToken.user_id, decodedToken.name, decodedToken.email]; } }; @@ -57,11 +57,15 @@ module.exports.verifyIdToken = async function verifyIdToken(idToken) { async function getUID(idToken, registerIfNewUser = true) { if (typeof idToken !== 'string' || idToken === 'null') return false; try { - const [uid, truename] = await module.exports.verifyIdToken(idToken); + const [uid, truename, email] = await module.exports.verifyIdToken(idToken); if (registerIfNewUser) { let name = await asyncGet(`SELECT name FROM Users WHERE id = ?`, [uid]); if (!name) { - await asyncRun(`INSERT INTO Users (id, name) VALUES (?, ?)`, [uid, truename]); + await asyncRun(`INSERT INTO Users (id, name, email) VALUES (?, ?, ?)`, [ + uid, + truename, + email, + ]); } } return uid; diff --git a/server/Business.js b/server/Business.js index 62b8e1a..c9c2235 100644 --- a/server/Business.js +++ b/server/Business.js @@ -159,7 +159,7 @@ router.get('/attendancedata', async function (request, response) { const attendanceinfo = await asyncAll( ` SELECT - UserData.name, Records.*, UserData.role + UserData.name, Records.*, UserData.role, UserData.email, UserData.custom_data FROM Records INNER JOIN (SELECT * FROM Users INNER JOIN Members ON Members.user_id = Users.id WHERE Members.business_id = ?) as UserData ON Records.user_id = UserData.id @@ -177,7 +177,7 @@ router.get('/attendancedata', async function (request, response) { response.send( attendanceinfo.concat( await asyncAll( - `SELECT Users.name, Users.id, role FROM Members LEFT JOIN Users ON Members.user_id = Users.id WHERE business_id = ? ORDER BY Members.role`, + `SELECT Users.name, Users.id, Users.email, role, Members.custom_data FROM Members LEFT JOIN Users ON Members.user_id = Users.id WHERE business_id = ? ORDER BY Members.role`, [businessid], ), ), @@ -251,6 +251,54 @@ router.get('/assignRole', async (request, response) => { }); // ============================ USER ROUTES ============================ +router.post('/importCustomData', async (request, response) => { + const uid = await handleAuth(request, response, request.query.businessId, { write: true }); + if (!uid) return; + + const businessId = request.query.businessId; + const data = request.body.data; + const mergeCol = request.body.mergeCol; + const lines = data.split('\n'); + + if (!['name', 'email', 'id'].includes(mergeCol)) { + response.sendStatus(400); + return; + } + + // Headers + const headers = lines[0].split(',').map(header => header.trim()); + for (const header of headers) { + if (header.toLowerCase() === mergeCol) { + headers[headers.indexOf(header)] = mergeCol; + break; + } + } + const mergeColToJson = new Map(); + for (let i = 1; i < lines.length; i++) { + const line = lines[i].split(','); + let headerToLine = {}; + for (let j = 0; j < headers.length; j++) { + headerToLine[headers[j]] = line[j]; + } + const key = headerToLine[mergeCol]; + delete headerToLine[mergeCol]; + mergeColToJson.set(key, JSON.stringify(headerToLine)); + } + for (const [key, value] of mergeColToJson.entries()) { + await asyncRun( + `UPDATE Members + SET custom_data = ? + FROM (SELECT id + FROM Users + WHERE Users.${mergeCol} = ?) AS U + WHERE business_id = ? AND U.id = user_id`, + [value, key, businessId], + ); + } + + response.sendStatus(200); +}); + /** * Updates the name of the authenticated user. * @queryParams name - the new name of the user. diff --git a/server/databaseSchema.sql b/server/databaseSchema.sql index 0261215..22b172f 100644 --- a/server/databaseSchema.sql +++ b/server/databaseSchema.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS "Users" ( "id" TEXT NOT NULL UNIQUE, "name" TEXT NOT NULL, "customer_id" TEXT, + "email" TEXT NOT NULL UNIQUE, PRIMARY KEY("id") ); CREATE TABLE IF NOT EXISTS "Businesses" ( @@ -27,6 +28,7 @@ CREATE TABLE IF NOT EXISTS "Members" ( "business_id" INTEGER NOT NULL, "user_id" TEXT NOT NULL, "role" TEXT NOT NULL, + "custom_data" TEXT DEFAULT "{}" NOT NULL, UNIQUE("business_id", "user_id") ON CONFLICT REPLACE, FOREIGN KEY("business_id") REFERENCES "Businesses"("id"), FOREIGN KEY("user_id") REFERENCES "Events"("id") diff --git a/server/server.js b/server/server.js index 2f43400..2e28ba7 100644 --- a/server/server.js +++ b/server/server.js @@ -7,7 +7,7 @@ const app = express(); // parsing post bodies const bodyParser = require('body-parser'); app.use(bodyParser.urlencoded({ extended: true })); -app.use(bodyParser.json()); +app.use(bodyParser.json({ limit: '10mb' })); // cors - make server endpoints available on firebase domain const cors = require('cors'); diff --git a/server/super_admin/settings.json b/server/super_admin/settings.json index 89e4049..d315462 100644 --- a/server/super_admin/settings.json +++ b/server/super_admin/settings.json @@ -54,6 +54,38 @@ "editview": { "show": true } + }, + { + "name": "email", + "verbose": "email", + "control": { + "text": true + }, + "type": "TEXT", + "allowNull": false, + "defaultValue": null, + "listview": { + "show": true + }, + "editview": { + "show": true + } + }, + { + "name": "custom_data", + "verbose": "custom_data", + "control": { + "text": true + }, + "type": "TEXT", + "allowNull": true, + "defaultValue": null, + "listview": { + "show": true + }, + "editview": { + "show": true + } } ], "mainview": { @@ -297,6 +329,22 @@ "editview": { "show": true } + }, + { + "name": "custom_data", + "verbose": "custom_data", + "control": { + "text": true + }, + "type": "TEXT", + "allowNull": true, + "defaultValue": null, + "listview": { + "show": true + }, + "editview": { + "show": true + } } ], "mainview": { @@ -418,6 +466,22 @@ "editview": { "show": true } + }, + { + "name": "customData", + "verbose": "customData", + "control": { + "text": true + }, + "type": "TEXT", + "allowNull": true, + "defaultValue": null, + "listview": { + "show": true + }, + "editview": { + "show": true + } } ], "mainview": { diff --git a/test/server.test.js b/test/server.test.js index a3eec19..33c3f99 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -42,7 +42,8 @@ describe('Server', () => { auth, 'verifyIdToken', idToken => { - if (idToken === VALID_TOKEN) return [VALID_AUTH.user_id, VALID_AUTH.name]; + if (idToken === VALID_TOKEN) + return [VALID_AUTH.user_id, VALID_AUTH.name, VALID_AUTH.email]; else return _verifyIdToken(idToken); }, { times: times }, @@ -56,12 +57,12 @@ describe('Server', () => { * @param {string} name the name of the user to return when the token is verified * @param {number} times the number of times to mock the method */ - function skipTokenVerification(t, uid, name, times = 1) { + function skipTokenVerification(t, uid, name, email, times = 1) { t.mock.method( auth, 'verifyIdToken', idToken => { - return [uid, name]; + return [uid, name, email]; }, { times: times }, ); @@ -85,8 +86,8 @@ describe('Server', () => { it('Should not return a value when asyncRun called', async () => { const result = await asyncRun( - 'INSERT INTO Users (id, name, customer_id) VALUES (?, ?, ?)', - ['testid', 'testname', 'testcustomerid'], + 'INSERT INTO Users (id, name, email, customer_id) VALUES (?, ?, ?, ?)', + ['testid', 'testname', 'testemail', 'testcustomerid'], ); assert.strictEqual(result, undefined); }); @@ -96,25 +97,25 @@ describe('Server', () => { }); it('Should return the rowid when asyncRunWithID called', async () => { const result1 = await asyncRunWithID( - 'INSERT INTO Users (id, name, customer_id) VALUES (?, ?, ?)', - ['testid1', 'testname1', 'testcustomerid1'], + 'INSERT INTO Users (id, name, email, customer_id) VALUES (?, ?, ?, ?)', + ['testid1', 'testname1', 'testemail1', 'testcustomerid1'], ); assert.strictEqual(result1, 1); const result2 = await asyncRunWithID( - 'INSERT INTO Users (id, name, customer_id) VALUES (?, ?, ?)', - ['testid2', 'testname2', 'testcustomerid2'], + 'INSERT INTO Users (id, name, email, customer_id) VALUES (?, ?, ?, ?)', + ['testid2', 'testname2', 'testemail2', 'testcustomerid2'], ); assert.strictEqual(result2, 2); }); it('Should return the number of rows changed when asyncRunWithChanges called', async () => { const result1 = await asyncRunWithChanges( - 'INSERT INTO Users (id, name, customer_id) VALUES (?, ?, ?)', - ['testid1', 'testname', 'testcustomerid1'], + 'INSERT INTO Users (id, name, email, customer_id) VALUES (?, ?, ?, ?)', + ['testid1', 'testname', 'testemail1', 'testcustomerid1'], ); assert.strictEqual(result1, 1); const result2 = await asyncRunWithChanges( - 'INSERT INTO Users (id, name, customer_id) VALUES (?, ?, ?)', - ['testid2', 'testname', 'testcustomerid2'], + 'INSERT INTO Users (id, name, email, customer_id) VALUES (?, ?, ?, ?)', + ['testid2', 'testname', 'testemail2', 'testcustomerid2'], ); assert.strictEqual(result2, 1); const result3 = await asyncRunWithChanges('UPDATE Users SET name = ? WHERE name = ?', [ @@ -126,14 +127,16 @@ describe('Server', () => { it('Should get all the correct rows when asyncAll called', async () => { const result1 = await asyncAll('SELECT * FROM Users'); assert.strictEqual(result1.length, 0); - await asyncRun('INSERT INTO Users (id, name, customer_id) VALUES (?, ?, ?)', [ + await asyncRun('INSERT INTO Users (id, name, email, customer_id) VALUES (?, ?, ?, ?)', [ 'testid1', 'testname1', + 'testemail1', 'testcustomerid1', ]); - await asyncRun('INSERT INTO Users (id, name, customer_id) VALUES (?, ?, ?)', [ + await asyncRun('INSERT INTO Users (id, name, email, customer_id) VALUES (?, ?, ?, ?)', [ 'testid2', 'testname2', + 'testemail2', 'testcustomerid2', ]); const result2 = await asyncAll('SELECT * FROM Users'); @@ -172,9 +175,10 @@ describe('Server', () => { .end(done); }); it('Should correctly enforce single privileges when getAccess is called', async () => { - await asyncRun('INSERT INTO Users (id, name, customer_id) VALUES (?, ?, ?)', [ + await asyncRun('INSERT INTO Users (id, name, email, customer_id) VALUES (?, ?, ?, ?)', [ VALID_AUTH.user_id, VALID_AUTH.name, + VALID_AUTH.email, VALID_AUTH.user_id, ]); await asyncRun( @@ -208,9 +212,10 @@ describe('Server', () => { ); }); it('Should correctly enforce multiple privileges when getAccess is called', async () => { - await asyncRun('INSERT INTO Users (id, name, customer_id) VALUES (?, ?, ?)', [ + await asyncRun('INSERT INTO Users (id, name, email, customer_id) VALUES (?, ?, ?, ?)', [ VALID_AUTH.user_id, VALID_AUTH.name, + VALID_AUTH.email, VALID_AUTH.user_id, ]); await asyncRun( @@ -240,9 +245,10 @@ describe('Server', () => { ); }); it('Should correctly return false when getAccess is called with invalid privileges', async () => { - await asyncRun('INSERT INTO Users (id, name, customer_id) VALUES (?, ?, ?)', [ + await asyncRun('INSERT INTO Users (id, name, email, customer_id) VALUES (?, ?, ?, ?)', [ VALID_AUTH.user_id, VALID_AUTH.name, + VALID_AUTH.email, VALID_AUTH.user_id, ]); await asyncRun( @@ -265,9 +271,10 @@ describe('Server', () => { }); it('Should return 403 Access denied when /joincode is called as a user', async t => { mockToken(t); - await asyncRun('INSERT INTO Users (id, name, customer_id) VALUES (?, ?, ?)', [ + await asyncRun('INSERT INTO Users (id, name, email, customer_id) VALUES (?, ?, ?, ?)', [ VALID_AUTH.user_id, VALID_AUTH.name, + VALID_AUTH.email, VALID_AUTH.user_id, ]); await asyncRun( @@ -418,7 +425,7 @@ describe('Server', () => { await createBusiness('testuid', 'testname', 'testsubscriptionid1'); const businessId = await createBusiness('testuid', 'testname', 'testsubscriptionid2'); await createBusiness('testuid', 'testname', 'testsubscriptionid3'); - skipTokenVerification(t, 'testuid', 'testname'); + skipTokenVerification(t, 'testuid', 'testname', 'testemail'); const res = await request(app) .get('/joincode') .set('idToken', 'testtoken') @@ -442,7 +449,7 @@ describe('Server', () => { it('Should not join when /join is requested with the joincode of another business', async t => { const businessId1 = await createBusiness('testuid', 'testname', 'testsubscriptionid1'); const businessId2 = await createBusiness('testuid', 'testname', 'testsubscriptionid2'); - skipTokenVerification(t, 'testuid', 'testname'); + skipTokenVerification(t, 'testuid', 'testname', 'testemail'); const res = await request(app) .get('/joincode') .set('idToken', 'testtoken') @@ -523,7 +530,11 @@ describe('Server', () => { VALID_AUTH.name, 'testsubscriptionid1', ); - await asyncRun('INSERT INTO Users (id, name) VALUES (?, ?)', ['testuid', 'testname']); + await asyncRun('INSERT INTO Users (id, name, email) VALUES (?, ?, ?)', [ + 'testuid', + 'testname', + 'testemail', + ]); await asyncRun('INSERT INTO Members (user_id, business_id, role) VALUES (?, ?, ?)', [ 'testuid', businessId, @@ -586,7 +597,11 @@ describe('Server', () => { VALID_AUTH.name, 'testsubscriptionid2', ); - await asyncRun('INSERT INTO Users (id, name) VALUES (?, ?)', ['testuid', 'testname']); + await asyncRun('INSERT INTO Users (id, name, email) VALUES (?, ?, ?)', [ + 'testuid', + 'testname', + 'testemail', + ]); await asyncRun('INSERT INTO Members (user_id, business_id, role) VALUES (?, ?, ?)', [ 'testuid', businessId1, @@ -640,7 +655,11 @@ describe('Server', () => { VALID_AUTH.name, 'testsubscriptionid1', ); - await asyncRun('INSERT INTO Users (id, name) VALUES (?, ?)', ['testuid', 'testname']); + await asyncRun('INSERT INTO Users (id, name, email) VALUES (?, ?, ?)', [ + 'testuid', + 'testname', + 'testemail', + ]); await asyncRun('INSERT INTO Members (user_id, business_id, role) VALUES (?, ?, ?)', [ 'testuid', businessId,