From 9f9d135785de64ddbe59ba3e4a9031a9df6e0ba5 Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Tue, 13 Aug 2024 11:28:21 +0200 Subject: [PATCH 01/32] add voting API endpoint WIP --- .../src/server-middleware/api/events/log.js | 2 +- .../src/server-middleware/api/events/pg.js | 26 -------------- .../server-middleware/api/events/trending.js | 2 +- .../src/server-middleware/api/events/views.js | 2 +- .../portal/src/server-middleware/api/index.js | 5 +++ .../server-middleware/api/events/pg.spec.js | 36 ------------------- 6 files changed, 8 insertions(+), 65 deletions(-) delete mode 100644 packages/portal/src/server-middleware/api/events/pg.js delete mode 100644 packages/portal/tests/unit/server-middleware/api/events/pg.spec.js diff --git a/packages/portal/src/server-middleware/api/events/log.js b/packages/portal/src/server-middleware/api/events/log.js index b2598b211c..b0000bba9f 100644 --- a/packages/portal/src/server-middleware/api/events/log.js +++ b/packages/portal/src/server-middleware/api/events/log.js @@ -1,5 +1,5 @@ import isbot from 'isbot'; -import pg from './pg.js'; +import pg from '../pg/pg.js'; // TODO: use `next` for error handling // TODO: accept multiple uris for the same action diff --git a/packages/portal/src/server-middleware/api/events/pg.js b/packages/portal/src/server-middleware/api/events/pg.js deleted file mode 100644 index 636478f1cc..0000000000 --- a/packages/portal/src/server-middleware/api/events/pg.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Pool } from 'pg'; - -let pool; - -export default { - config: {}, - - get enabled() { - return this.config.enabled; - }, - - get pool() { - if (!pool) { - pool = new Pool(this.config); - pool.on('error', (err) => { - console.error('PostgreSQL pool error', err); - pool = null; - }); - } - return pool; - }, - - query() { - return this.pool.query(...arguments); - } -}; diff --git a/packages/portal/src/server-middleware/api/events/trending.js b/packages/portal/src/server-middleware/api/events/trending.js index f46d49f136..13828e5fd9 100644 --- a/packages/portal/src/server-middleware/api/events/trending.js +++ b/packages/portal/src/server-middleware/api/events/trending.js @@ -1,4 +1,4 @@ -import pg from './pg.js'; +import pg from '../pg/pg.js'; // TODO: use `next` for error handling export default (config = {}) => { diff --git a/packages/portal/src/server-middleware/api/events/views.js b/packages/portal/src/server-middleware/api/events/views.js index 39b7703ab2..42946fa509 100644 --- a/packages/portal/src/server-middleware/api/events/views.js +++ b/packages/portal/src/server-middleware/api/events/views.js @@ -1,4 +1,4 @@ -import pg from './pg.js'; +import pg from '../pg/pg.js'; // TODO: use `next` for error handling export default (config = {}) => { diff --git a/packages/portal/src/server-middleware/api/index.js b/packages/portal/src/server-middleware/api/index.js index 3597077f0a..ba79ddd35f 100644 --- a/packages/portal/src/server-middleware/api/index.js +++ b/packages/portal/src/server-middleware/api/index.js @@ -59,6 +59,11 @@ app.post('/jira-service-desk/galleries', jiraServiceDeskGalleries(runtimeConfig. import version from './version.js'; app.get('/version', version); +import votes from './polls/votes.js'; +app.get('/votes', votes); +import vote from './polls/vote.js'; +app.post('/vote', vote); + app.all('/*', (req, res) => res.sendStatus(404)); export default app; diff --git a/packages/portal/tests/unit/server-middleware/api/events/pg.spec.js b/packages/portal/tests/unit/server-middleware/api/events/pg.spec.js deleted file mode 100644 index 17c2af88b5..0000000000 --- a/packages/portal/tests/unit/server-middleware/api/events/pg.spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import pg from 'pg'; -import sinon from 'sinon'; - -import pgEvents from '@/server-middleware/api/events/pg'; - -const pgPoolOn = sinon.stub(); -const pgPoolQuery = sinon.stub(); - -describe('@/server-middleware/api/events/pg', () => { - beforeAll(() => { - sinon.replace(pg.Pool.prototype, 'on', pgPoolOn); - sinon.replace(pg.Pool.prototype, 'query', pgPoolQuery); - }); - afterEach(sinon.resetHistory); - afterAll(sinon.resetBehavior); - - describe('pool', () => { - it('creates and returns a pg pool, with error handler', () => { - const pool = pgEvents.pool; - - expect(pool instanceof pg.Pool).toBe(true); - expect(pgPoolOn.calledWith('error', sinon.match.func)).toBe(true); - }); - }); - - describe('query', () => { - it('delegates with all args to pg pool', () => { - const sql = 'SELECT * FROM table WHERE type=$1'; - const params = ['like']; - - pgEvents.query(sql, params); - - expect(pgPoolQuery.calledWith(sql, params)).toBe(true); - }); - }); -}); From a5086c78f8e7035eaee7bbe5f18f902c92adce26 Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Tue, 13 Aug 2024 13:23:16 +0200 Subject: [PATCH 02/32] polls API middleware --- .../portal/src/server-middleware/api/pg/pg.js | 26 ++++++++ .../src/server-middleware/api/polls/vote.js | 61 +++++++++++++++++++ .../src/server-middleware/api/polls/votes.js | 39 ++++++++++++ .../unit/server-middleware/api/pg/pg.spec.js | 36 +++++++++++ .../server-middleware/api/polls/votes.spec.js | 51 ++++++++++++++++ 5 files changed, 213 insertions(+) create mode 100644 packages/portal/src/server-middleware/api/pg/pg.js create mode 100644 packages/portal/src/server-middleware/api/polls/vote.js create mode 100644 packages/portal/src/server-middleware/api/polls/votes.js create mode 100644 packages/portal/tests/unit/server-middleware/api/pg/pg.spec.js create mode 100644 packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js diff --git a/packages/portal/src/server-middleware/api/pg/pg.js b/packages/portal/src/server-middleware/api/pg/pg.js new file mode 100644 index 0000000000..636478f1cc --- /dev/null +++ b/packages/portal/src/server-middleware/api/pg/pg.js @@ -0,0 +1,26 @@ +import { Pool } from 'pg'; + +let pool; + +export default { + config: {}, + + get enabled() { + return this.config.enabled; + }, + + get pool() { + if (!pool) { + pool = new Pool(this.config); + pool.on('error', (err) => { + console.error('PostgreSQL pool error', err); + pool = null; + }); + } + return pool; + }, + + query() { + return this.pool.query(...arguments); + } +}; diff --git a/packages/portal/src/server-middleware/api/polls/vote.js b/packages/portal/src/server-middleware/api/polls/vote.js new file mode 100644 index 0000000000..9512e2f38b --- /dev/null +++ b/packages/portal/src/server-middleware/api/polls/vote.js @@ -0,0 +1,61 @@ +import pg from '../pg/pg.js'; + +// TODO: validate user login +export default (config = {}) => { + pg.config = config; + + return async(req, res) => { + try { + if (!pg.enabled) { + return; + } + + const { userExternalId, optionExternalId } = req.body; + + // if(notAuthorized) { + // res.sendStatus(401); + // } + + let userRow; + const selectUserResult = await pg.query( + 'SELECT id FROM polls.users WHERE external_id=$1', + [userExternalId] + ); + if (selectUserResult.rowCount > 0) { + userRow = selectUserResult.rows[0]; + } else { + const insertUserResult = await pg.query( + 'INSERT INTO polls.users (external_id) VALUES($1) RETURNING id', + [userExternalId] + ); + userRow = insertUserResult.rows[0]; + } + + let optionRow; + const selectOptionResult = await pg.query( + 'SELECT id FROM polls.options WHERE external_id=$1', + [optionExternalId] + ); + if (selectOptionResult.rowCount > 0) { + optionRow =selectOptionResult.rows[0]; + } else { + const insertOptionResult = await pg.query( + 'INSERT INTO polls.options (external_id) VALUES($1) RETURNING id', + [optionExternalId] + ); + optionRow = insertOptionResult.rows[0]; + }; + + await pg.query(` + INSERT INTO polls.votes (user_id, option_id, occurred_at) + VALUES($1, $2, CURRENT_TIMESTAMP) + `, + [userRow.id, optionRow.id] + ); + res.sendStatus(204); + } catch (err) { + res.sendStatus(409); + console.error(err); + } + }; +}; \ No newline at end of file diff --git a/packages/portal/src/server-middleware/api/polls/votes.js b/packages/portal/src/server-middleware/api/polls/votes.js new file mode 100644 index 0000000000..ace4f370ae --- /dev/null +++ b/packages/portal/src/server-middleware/api/polls/votes.js @@ -0,0 +1,39 @@ +import pg from '../pg/pg.js'; + +// TODO: use `next` for error handling +// TODO: accept multiple uris for the same action +// TODO: log user agent? +// TODO: validate action_types +export default (config = {}) => { + pg.config = config; + + return async(req, res) => { + try { + if (!pg.enabled) { + return; + } + + const { optionIDs } = req.body; + + const votesForOptions = await pg.query(` + SELECT o.external_id, COUNT(*) FROM polls.votes v LEFT JOIN polls.options o + ON v.option_id=o.id + WHERE o.external_id LIKE ANY('{$1}') + GROUP BY (o.id) + `, + [optionIDs] + ); + if (votesForOptions.rowCount < 0) { + // Nobody has voted on anything yet + res.json([]); + } + + res.json(votesForOptions.rows.reduce((memo, row) => { + memo[row.id] = row.count; + return memo; + }, {})); + } catch (err) { + console.error(err); + } + }; +}; diff --git a/packages/portal/tests/unit/server-middleware/api/pg/pg.spec.js b/packages/portal/tests/unit/server-middleware/api/pg/pg.spec.js new file mode 100644 index 0000000000..5c993b843b --- /dev/null +++ b/packages/portal/tests/unit/server-middleware/api/pg/pg.spec.js @@ -0,0 +1,36 @@ +import pg from 'pg'; +import sinon from 'sinon'; + +import pgEvents from '@/server-middleware/api/pg/pg.js'; + +const pgPoolOn = sinon.stub(); +const pgPoolQuery = sinon.stub(); + +describe('@/server-middleware/api/pg/pg', () => { + beforeAll(() => { + sinon.replace(pg.Pool.prototype, 'on', pgPoolOn); + sinon.replace(pg.Pool.prototype, 'query', pgPoolQuery); + }); + afterEach(sinon.resetHistory); + afterAll(sinon.resetBehavior); + + describe('pool', () => { + it('creates and returns a pg pool, with error handler', () => { + const pool = pgEvents.pool; + + expect(pool instanceof pg.Pool).toBe(true); + expect(pgPoolOn.calledWith('error', sinon.match.func)).toBe(true); + }); + }); + + describe('query', () => { + it('delegates with all args to pg pool', () => { + const sql = 'SELECT * FROM table WHERE type=$1'; + const params = ['like']; + + pgEvents.query(sql, params); + + expect(pgPoolQuery.calledWith(sql, params)).toBe(true); + }); + }); +}); diff --git a/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js new file mode 100644 index 0000000000..f324cc6191 --- /dev/null +++ b/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js @@ -0,0 +1,51 @@ +import pg from 'pg'; +import sinon from 'sinon'; + +import votesEventsHandler from '@/server-middleware/api/polls/votes'; + +const pgPoolQuery = sinon.stub().resolves({ + rows: [ + { + 'id': '5Ca2xEt6t10fxqMbvrw9aO', + 'count': 40 + } + ] +}); + +const reqBody = { optionIDs: ['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2'] }; + +const expressReqStub = { + body: reqBody, + get: sinon.spy() +}; +const expressResStub = { + json: sinon.spy(), + sendStatus: sinon.spy() +}; + +describe('@/server-middleware/api/polls/votes', () => { + beforeAll(() => { + sinon.replace(pg.Pool.prototype, 'query', pgPoolQuery); + }); + afterEach(sinon.resetHistory); + afterAll(sinon.resetBehavior); + + describe('when some of the options have been voted on', () => { + const options = { enabled: true }; + + it('queries postgres for the votes', async() => { + await votesEventsHandler(options)(expressReqStub, expressResStub); + + expect(pgPoolQuery.calledWith( + sinon.match((sql) => sql.trim().startsWith('SELECT ')), + [['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2']] + )).toBe(true); + }); + + it('responds with the view count as json', async() => { + await votesEventsHandler(options)(expressReqStub, expressResStub); + + expect(expressResStub.json.calledWith({ '5Ca2xEt6t10fxqMbvrw9aO': 40 })).toBe(true); + }); + }); +}); From 1624f81d284f87523591aeb03d17aad1fcfa0292 Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Tue, 13 Aug 2024 14:21:28 +0200 Subject: [PATCH 03/32] linting --- packages/portal/src/server-middleware/api/polls/vote.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/portal/src/server-middleware/api/polls/vote.js b/packages/portal/src/server-middleware/api/polls/vote.js index 9512e2f38b..bf23558bc1 100644 --- a/packages/portal/src/server-middleware/api/polls/vote.js +++ b/packages/portal/src/server-middleware/api/polls/vote.js @@ -37,14 +37,14 @@ export default (config = {}) => { [optionExternalId] ); if (selectOptionResult.rowCount > 0) { - optionRow =selectOptionResult.rows[0]; + optionRow = selectOptionResult.rows[0]; } else { const insertOptionResult = await pg.query( 'INSERT INTO polls.options (external_id) VALUES($1) RETURNING id', [optionExternalId] ); optionRow = insertOptionResult.rows[0]; - }; + } await pg.query(` INSERT INTO polls.votes (user_id, option_id, occurred_at) @@ -58,4 +58,4 @@ export default (config = {}) => { console.error(err); } }; -}; \ No newline at end of file +}; From 1221e96e165977b4f331e2839b14a81447224489 Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Tue, 13 Aug 2024 15:05:38 +0200 Subject: [PATCH 04/32] specs for votes --- .../src/server-middleware/api/polls/vote.js | 3 + .../server-middleware/api/polls/vote.spec.js | 100 ++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js diff --git a/packages/portal/src/server-middleware/api/polls/vote.js b/packages/portal/src/server-middleware/api/polls/vote.js index bf23558bc1..07259b0bfd 100644 --- a/packages/portal/src/server-middleware/api/polls/vote.js +++ b/packages/portal/src/server-middleware/api/polls/vote.js @@ -7,6 +7,7 @@ export default (config = {}) => { return async(req, res) => { try { if (!pg.enabled) { + res.sendStatus(503); return; } @@ -46,6 +47,8 @@ export default (config = {}) => { optionRow = insertOptionResult.rows[0]; } + // TODO: should this query the DB as to whether the user has already voted on this option first? + await pg.query(` INSERT INTO polls.votes (user_id, option_id, occurred_at) VALUES($1, $2, CURRENT_TIMESTAMP) diff --git a/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js new file mode 100644 index 0000000000..85c5b31a29 --- /dev/null +++ b/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js @@ -0,0 +1,100 @@ +import pg from 'pg'; +import sinon from 'sinon'; + +import voteEventsHandler from '@/server-middleware/api/polls/vote'; + +const fixtures = { + db: { + userId: 2, + optionId: 1 + }, + reqBody: { + userExternalId: 'keycloak_uuid', + optionExternalId: 'contentful_sys_id' + } +}; + +const pgPoolQuery = sinon.stub(); +pgPoolQuery.withArgs( + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.users ')), + [fixtures.reqBody.userExternalId] +) + .resolves({ rowCount: 0 }); +pgPoolQuery.withArgs( + sinon.match((sql) => sql.startsWith('INSERT INTO polls.users ')), + [fixtures.reqBody.userExternalId] +) + .resolves({ rows: [{ id: fixtures.db.userId }] }); +pgPoolQuery.withArgs( + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.options ')), + [fixtures.reqBody.optionExternalId] +) + .resolves({ rowCount: 0 }); +pgPoolQuery.withArgs( + sinon.match((sql) => sql.startsWith('INSERT INTO polls.options ')), + [fixtures.reqBody.optionExternalId] +) + .resolves({ rows: [{ id: fixtures.db.optionId }] }); +pgPoolQuery.withArgs( + sinon.match((sql) => sql.trim().startsWith('INSERT INTO polls.votes ')), + [fixtures.db.userId, fixtures.db.optionId] +) + .resolves({}); + +const expressReqStub = { + body: fixtures.reqBody, + get: sinon.spy() +}; +const expressResStub = { + json: sinon.spy(), + sendStatus: sinon.spy() +}; + +describe('@/server-middleware/api/polls/vote', () => { + beforeAll(() => { + sinon.replace(pg.Pool.prototype, 'query', pgPoolQuery); + }); + afterEach(sinon.resetHistory); + afterAll(sinon.resetBehavior); + + describe('when not explicitly enabled', () => { + const options = {}; + + it('does not query postgres', async() => { + await voteEventsHandler(options)(expressReqStub, expressResStub); + + expect(pgPoolQuery.called).toBe(false); + }); + + it('responds with 503 status', async() => { + await voteEventsHandler(options)(expressReqStub, expressResStub); + + expect(expressResStub.sendStatus.calledWith(503)).toBe(true); + }); + }); + + describe('when explicitly enabled', () => { + const options = { enabled: true }; + + const expressReqStub = { + body: fixtures.reqBody, + get: sinon.spy() + }; + + describe('when for voting on behalf of a user with a valid session', () => { + // TODO: authorisation + it('runs all postgres queries to vote on the option', async() => { + await voteEventsHandler(options)(expressReqStub, expressResStub); + + expect(pgPoolQuery.getCalls().length).toBe(5); + }); + + it('responds with 204 status', async() => { + await voteEventsHandler(options)(expressReqStub, expressResStub); + + expect(expressResStub.sendStatus.calledWith(204)).toBe(true); + }); + }); + }); +}); + From d624cd958e3c615a8ef0f2ef7006b5b5c5554fcb Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Tue, 13 Aug 2024 16:06:07 +0200 Subject: [PATCH 05/32] restructure votes data --- .../src/server-middleware/api/polls/votes.js | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/portal/src/server-middleware/api/polls/votes.js b/packages/portal/src/server-middleware/api/polls/votes.js index ace4f370ae..963847f876 100644 --- a/packages/portal/src/server-middleware/api/polls/votes.js +++ b/packages/portal/src/server-middleware/api/polls/votes.js @@ -13,15 +13,27 @@ export default (config = {}) => { return; } - const { optionIDs } = req.body; + const { optionIDs, userExternalId } = req.body; + + let userRow = { id: null }; + if (userExternalId) { + const selectUserResult = await pg.query( + 'SELECT id FROM polls.users WHERE external_id=$1', + [userExternalId] + ); + if (selectUserResult.rowCount > 0) { + userRow = selectUserResult.rows[0]; + } + } const votesForOptions = await pg.query(` - SELECT o.external_id, COUNT(*) FROM polls.votes v LEFT JOIN polls.options o + SELECT o.external_id, COUNT(*) AS total, ((SELECT COUNT(*) FROM polls.votes WHERE user_id=$2 AND option_id=o.id) AS votedByCurrentUser + FROM polls.votes v LEFT JOIN polls.options o ON v.option_id=o.id WHERE o.external_id LIKE ANY('{$1}') GROUP BY (o.id) `, - [optionIDs] + [optionIDs, userRow.id] ); if (votesForOptions.rowCount < 0) { // Nobody has voted on anything yet @@ -29,7 +41,10 @@ export default (config = {}) => { } res.json(votesForOptions.rows.reduce((memo, row) => { - memo[row.id] = row.count; + memo[row.id] = { + total: row.total, + votedByCurrentUser: row.votedByCurrentUser + }; return memo; }, {})); } catch (err) { From 66869fa8f82a480b93009adeb780c6dcb47734bc Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Tue, 13 Aug 2024 16:35:31 +0200 Subject: [PATCH 06/32] update TODO with new criteria --- packages/portal/src/server-middleware/api/polls/vote.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/portal/src/server-middleware/api/polls/vote.js b/packages/portal/src/server-middleware/api/polls/vote.js index 07259b0bfd..4a0abb3c44 100644 --- a/packages/portal/src/server-middleware/api/polls/vote.js +++ b/packages/portal/src/server-middleware/api/polls/vote.js @@ -47,7 +47,7 @@ export default (config = {}) => { optionRow = insertOptionResult.rows[0]; } - // TODO: should this query the DB as to whether the user has already voted on this option first? + // TODO: This needs to check if the vote already exists, then conditionally do the insert or a delete. await pg.query(` INSERT INTO polls.votes (user_id, option_id, occurred_at) From 71f7d5a79ba015134bd4dbb1e391cc747a7759fb Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Fri, 16 Aug 2024 14:35:25 +0200 Subject: [PATCH 07/32] fix parenthesis --- packages/portal/src/server-middleware/api/polls/votes.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/portal/src/server-middleware/api/polls/votes.js b/packages/portal/src/server-middleware/api/polls/votes.js index 963847f876..d80515ce54 100644 --- a/packages/portal/src/server-middleware/api/polls/votes.js +++ b/packages/portal/src/server-middleware/api/polls/votes.js @@ -27,7 +27,7 @@ export default (config = {}) => { } const votesForOptions = await pg.query(` - SELECT o.external_id, COUNT(*) AS total, ((SELECT COUNT(*) FROM polls.votes WHERE user_id=$2 AND option_id=o.id) AS votedByCurrentUser + SELECT o.external_id, COUNT(*) AS total, (SELECT COUNT(*) FROM polls.votes WHERE user_id=$2 AND option_id=o.id) AS votedByCurrentUser FROM polls.votes v LEFT JOIN polls.options o ON v.option_id=o.id WHERE o.external_id LIKE ANY('{$1}') From 4d4ed86014378b90078e74fae441088b4483cf39 Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Fri, 16 Aug 2024 16:26:34 +0200 Subject: [PATCH 08/32] remove vote endpoint --- .../portal/src/server-middleware/api/index.js | 2 + .../api/polls/remove-vote.js | 69 +++++++++++++++++++ .../src/server-middleware/api/polls/vote.js | 25 ++++--- .../src/server-middleware/api/polls/votes.js | 2 + .../server-middleware/api/polls/votes.spec.js | 33 +++++---- 5 files changed, 110 insertions(+), 21 deletions(-) create mode 100644 packages/portal/src/server-middleware/api/polls/remove-vote.js diff --git a/packages/portal/src/server-middleware/api/index.js b/packages/portal/src/server-middleware/api/index.js index ba79ddd35f..414ab61c00 100644 --- a/packages/portal/src/server-middleware/api/index.js +++ b/packages/portal/src/server-middleware/api/index.js @@ -63,6 +63,8 @@ import votes from './polls/votes.js'; app.get('/votes', votes); import vote from './polls/vote.js'; app.post('/vote', vote); +import removeVote from './polls/removeVote.js'; +app.delete('/vote', removeVote); app.all('/*', (req, res) => res.sendStatus(404)); diff --git a/packages/portal/src/server-middleware/api/polls/remove-vote.js b/packages/portal/src/server-middleware/api/polls/remove-vote.js new file mode 100644 index 0000000000..7bfdfe1032 --- /dev/null +++ b/packages/portal/src/server-middleware/api/polls/remove-vote.js @@ -0,0 +1,69 @@ +import pg from '../pg/pg.js'; + +// TODO: validate user login +export default (config = {}) => { + pg.config = config; + + return async(req, res) => { + try { + if (!pg.enabled) { + res.sendStatus(503); + return; + } + + const { userExternalId, optionExternalId } = req.body; + + // if(notAuthorized) { + // res.sendStatus(401); + // } + + let userRow; + const selectUserResult = await pg.query( + 'SELECT id FROM polls.users WHERE external_id=$1', + [userExternalId] + ); + if (selectUserResult.rowCount > 0) { + userRow = selectUserResult.rows[0]; + } else { + // user doesn't exist, can't have voted on anything + res.sendStatus(204); + return; + } + + let optionRow; + const selectOptionResult = await pg.query( + 'SELECT id FROM polls.options WHERE external_id=$1', + [optionExternalId] + ); + if (selectOptionResult.rowCount > 0) { + optionRow = selectOptionResult.rows[0]; + } else { + // option doesn't exist, can't have been voted on + res.sendStatus(204); + return; + } + + let voteRow; + const selectVoteResult = await pg.query( + 'SELECT id FROM polls.votes WHERE user_id=$1 AND option_id=$2', + [userRow.id, optionRow.id] + ); + if (selectVoteResult.rowCount > 0) { + voteRow = selectVoteResult.rows[0]; + } else { + // vote doesn't exist, no need to remove + res.sendStatus(204); + return; + } + + await pg.query( + 'DELETE FROM polls.votes WHERE id=$1', + [voteRow.id] + ); + res.sendStatus(200); + } catch (err) { + res.sendStatus(409); + console.error(err); + } + }; +}; diff --git a/packages/portal/src/server-middleware/api/polls/vote.js b/packages/portal/src/server-middleware/api/polls/vote.js index 4a0abb3c44..178cd69e2f 100644 --- a/packages/portal/src/server-middleware/api/polls/vote.js +++ b/packages/portal/src/server-middleware/api/polls/vote.js @@ -47,15 +47,24 @@ export default (config = {}) => { optionRow = insertOptionResult.rows[0]; } - // TODO: This needs to check if the vote already exists, then conditionally do the insert or a delete. - - await pg.query(` - INSERT INTO polls.votes (user_id, option_id, occurred_at) - VALUES($1, $2, CURRENT_TIMESTAMP) - `, - [userRow.id, optionRow.id] + let voteRow; + const selectVoteResult = await pg.query( + 'SELECT id FROM polls.votes WHERE user_id=$1 AND option_id=$2', + [userRow.id, optionRow.id] ); - res.sendStatus(204); + + if (selectVoteResult.rowCount > 0) { + // No need to insert new vote, user has already voted for this option + // Return 409 error? + } else { + await pg.query(` + INSERT INTO polls.votes (user_id, option_id, occurred_at) + VALUES($1, $2, CURRENT_TIMESTAMP) + `, + [userRow.id, optionRow.id] + ); + } + res.sendStatus(200); } catch (err) { res.sendStatus(409); console.error(err); diff --git a/packages/portal/src/server-middleware/api/polls/votes.js b/packages/portal/src/server-middleware/api/polls/votes.js index d80515ce54..51f09770d0 100644 --- a/packages/portal/src/server-middleware/api/polls/votes.js +++ b/packages/portal/src/server-middleware/api/polls/votes.js @@ -10,6 +10,7 @@ export default (config = {}) => { return async(req, res) => { try { if (!pg.enabled) { + res.json([]); return; } @@ -38,6 +39,7 @@ export default (config = {}) => { if (votesForOptions.rowCount < 0) { // Nobody has voted on anything yet res.json([]); + return; } res.json(votesForOptions.rows.reduce((memo, row) => { diff --git a/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js index f324cc6191..82cdaabf39 100644 --- a/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js +++ b/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js @@ -12,7 +12,7 @@ const pgPoolQuery = sinon.stub().resolves({ ] }); -const reqBody = { optionIDs: ['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2'] }; +const reqBody = { optionIDs: ['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2'], userExternalId: 'External-UUID' }; const expressReqStub = { body: reqBody, @@ -30,22 +30,29 @@ describe('@/server-middleware/api/polls/votes', () => { afterEach(sinon.resetHistory); afterAll(sinon.resetBehavior); - describe('when some of the options have been voted on', () => { - const options = { enabled: true }; + describe('when there is an external user ID', () => { + // TODO: mock user SQL request + describe('when some of the options have been voted on', () => { + const options = { enabled: true }; - it('queries postgres for the votes', async() => { - await votesEventsHandler(options)(expressReqStub, expressResStub); + it('queries postgres for the votes', async() => { + await votesEventsHandler(options)(expressReqStub, expressResStub); - expect(pgPoolQuery.calledWith( - sinon.match((sql) => sql.trim().startsWith('SELECT ')), - [['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2']] - )).toBe(true); - }); + expect(pgPoolQuery.calledWith( + sinon.match((sql) => sql.trim().startsWith('SELECT ')), + [['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2'], 1] + )).toBe(true); + }); - it('responds with the view count as json', async() => { - await votesEventsHandler(options)(expressReqStub, expressResStub); + it('responds with the view count as json', async() => { + await votesEventsHandler(options)(expressReqStub, expressResStub); - expect(expressResStub.json.calledWith({ '5Ca2xEt6t10fxqMbvrw9aO': 40 })).toBe(true); + expect(expressResStub.json.calledWith({ '5Ca2xEt6t10fxqMbvrw9aO': 40 })).toBe(true); + }); }); }); + + describe('when there is NO external user ID', () => { + // TODO: Add tests for anonymous retrieval + }); }); From 1974bb77c282b7444108fdd50dd9dffe966b2b2f Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Mon, 19 Aug 2024 13:05:08 +0200 Subject: [PATCH 09/32] fix specs --- .../api/polls/remove-vote.js | 4 +- .../src/server-middleware/api/polls/vote.js | 1 - .../src/server-middleware/api/polls/votes.js | 10 +- .../api/polls/remove-vote.spec.js | 96 +++++++++++++++++++ .../server-middleware/api/polls/vote.spec.js | 16 +++- .../server-middleware/api/polls/votes.spec.js | 39 ++++---- 6 files changed, 137 insertions(+), 29 deletions(-) create mode 100644 packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js diff --git a/packages/portal/src/server-middleware/api/polls/remove-vote.js b/packages/portal/src/server-middleware/api/polls/remove-vote.js index 7bfdfe1032..b5a7c859ba 100644 --- a/packages/portal/src/server-middleware/api/polls/remove-vote.js +++ b/packages/portal/src/server-middleware/api/polls/remove-vote.js @@ -60,9 +60,9 @@ export default (config = {}) => { 'DELETE FROM polls.votes WHERE id=$1', [voteRow.id] ); - res.sendStatus(200); + res.sendStatus(204); } catch (err) { - res.sendStatus(409); + res.sendStatus(409); // 409 doesn't make sense here, what should this be? console.error(err); } }; diff --git a/packages/portal/src/server-middleware/api/polls/vote.js b/packages/portal/src/server-middleware/api/polls/vote.js index 178cd69e2f..c2aea5a922 100644 --- a/packages/portal/src/server-middleware/api/polls/vote.js +++ b/packages/portal/src/server-middleware/api/polls/vote.js @@ -55,7 +55,6 @@ export default (config = {}) => { if (selectVoteResult.rowCount > 0) { // No need to insert new vote, user has already voted for this option - // Return 409 error? } else { await pg.query(` INSERT INTO polls.votes (user_id, option_id, occurred_at) diff --git a/packages/portal/src/server-middleware/api/polls/votes.js b/packages/portal/src/server-middleware/api/polls/votes.js index 51f09770d0..f58d12e847 100644 --- a/packages/portal/src/server-middleware/api/polls/votes.js +++ b/packages/portal/src/server-middleware/api/polls/votes.js @@ -1,9 +1,6 @@ import pg from '../pg/pg.js'; -// TODO: use `next` for error handling -// TODO: accept multiple uris for the same action -// TODO: log user agent? -// TODO: validate action_types +// TODO: authorisation for current user before retrieving the userId export default (config = {}) => { pg.config = config; @@ -14,7 +11,7 @@ export default (config = {}) => { return; } - const { optionIDs, userExternalId } = req.body; + const { optionIds, userExternalId } = req.body; let userRow = { id: null }; if (userExternalId) { @@ -34,8 +31,9 @@ export default (config = {}) => { WHERE o.external_id LIKE ANY('{$1}') GROUP BY (o.id) `, - [optionIDs, userRow.id] + [optionIds, userRow.id] ); + if (votesForOptions.rowCount < 0) { // Nobody has voted on anything yet res.json([]); diff --git a/packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js new file mode 100644 index 0000000000..3d7b59fc8c --- /dev/null +++ b/packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js @@ -0,0 +1,96 @@ +import pg from 'pg'; +import sinon from 'sinon'; + +import removeVoteEventsHandler from '@/server-middleware/api/polls/remove-vote'; + +const fixtures = { + db: { + userId: 1, + optionId: 2, + voteId: 3 + }, + reqBody: { + userExternalId: 'keycloak_uuid', + optionExternalId: 'contentful_sys_id' + } +}; + +const pgPoolQuery = sinon.stub(); +pgPoolQuery.withArgs( + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.users ')), + [fixtures.reqBody.userExternalId] +).resolves({ rowCount: 1, rows: [{ id: fixtures.db.userId }] }); + +pgPoolQuery.withArgs( + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.options ')), + [fixtures.reqBody.optionExternalId] +).resolves({ rowCount: 1, rows: [{ id: fixtures.db.optionId }] }); + +pgPoolQuery.withArgs( + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.votes ')), + [fixtures.db.userId, fixtures.db.optionId] +).resolves({ rowCount: 1, rows: [{ id: fixtures.db.voteId }] }); + +pgPoolQuery.withArgs( + sinon.match((sql) => sql.startsWith('DELETE FROM polls.votes ')), + [fixtures.db.voteId] +).resolves(); + +const expressReqStub = { + body: fixtures.reqBody, + get: sinon.spy() +}; + +const expressResStub = { + json: sinon.spy(), + sendStatus: sinon.spy() +}; + +describe('@/server-middleware/api/polls/vote', () => { + beforeAll(() => { + sinon.replace(pg.Pool.prototype, 'query', pgPoolQuery); + }); + afterEach(sinon.resetHistory); + afterAll(sinon.resetBehavior); + + describe('when not explicitly enabled', () => { + const options = {}; + + it('does not query postgres', async() => { + await removeVoteEventsHandler(options)(expressReqStub, expressResStub); + + expect(pgPoolQuery.called).toBe(false); + }); + + it('responds with 503 status', async() => { + await removeVoteEventsHandler(options)(expressReqStub, expressResStub); + + expect(expressResStub.sendStatus.calledWith(503)).toBe(true); + }); + }); + + describe('when explicitly enabled', () => { + const options = { enabled: true }; + + const expressReqStub = { + body: fixtures.reqBody, + get: sinon.spy() + }; + + describe('when voting on behalf of a user with a valid session', () => { + // TODO: authorisation + it('runs all postgres queries to vote on the option', async() => { + await removeVoteEventsHandler(options)(expressReqStub, expressResStub); + + expect(pgPoolQuery.getCalls().length).toBe(4); + }); + + it('responds with 204 status', async() => { + await removeVoteEventsHandler(options)(expressReqStub, expressResStub); + + expect(expressResStub.sendStatus.calledWith(204)).toBe(true); + }); + }); + }); +}); + diff --git a/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js index 85c5b31a29..2929781e63 100644 --- a/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js +++ b/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js @@ -15,6 +15,7 @@ const fixtures = { }; const pgPoolQuery = sinon.stub(); +// Stub user queries pgPoolQuery.withArgs( sinon.match((sql) => sql.startsWith('SELECT id FROM polls.users ')), [fixtures.reqBody.userExternalId] @@ -25,6 +26,8 @@ pgPoolQuery.withArgs( [fixtures.reqBody.userExternalId] ) .resolves({ rows: [{ id: fixtures.db.userId }] }); + +// Stub option queries pgPoolQuery.withArgs( sinon.match((sql) => sql.startsWith('SELECT id FROM polls.options ')), [fixtures.reqBody.optionExternalId] @@ -35,6 +38,13 @@ pgPoolQuery.withArgs( [fixtures.reqBody.optionExternalId] ) .resolves({ rows: [{ id: fixtures.db.optionId }] }); + +// Stub vote queries +pgPoolQuery.withArgs( + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.votes ')), + [fixtures.db.userId, fixtures.db.optionId] +) + .resolves({ rowCount: 0 }); pgPoolQuery.withArgs( sinon.match((sql) => sql.trim().startsWith('INSERT INTO polls.votes ')), [fixtures.db.userId, fixtures.db.optionId] @@ -86,13 +96,13 @@ describe('@/server-middleware/api/polls/vote', () => { it('runs all postgres queries to vote on the option', async() => { await voteEventsHandler(options)(expressReqStub, expressResStub); - expect(pgPoolQuery.getCalls().length).toBe(5); + expect(pgPoolQuery.getCalls().length).toBe(6); }); - it('responds with 204 status', async() => { + it('responds with 200 status', async() => { await voteEventsHandler(options)(expressReqStub, expressResStub); - expect(expressResStub.sendStatus.calledWith(204)).toBe(true); + expect(expressResStub.sendStatus.calledWith(200)).toBe(true); }); }); }); diff --git a/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js index 82cdaabf39..fb37a37194 100644 --- a/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js +++ b/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js @@ -3,19 +3,29 @@ import sinon from 'sinon'; import votesEventsHandler from '@/server-middleware/api/polls/votes'; -const pgPoolQuery = sinon.stub().resolves({ - rows: [ - { - 'id': '5Ca2xEt6t10fxqMbvrw9aO', - 'count': 40 - } - ] -}); +const fixtures = { + db: { + userId: 1 + }, + reqBody: { + optionIds: ['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2'], + userExternalId: 'keycloak_uuid' + } +}; -const reqBody = { optionIDs: ['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2'], userExternalId: 'External-UUID' }; +const pgPoolQuery = sinon.stub(); +pgPoolQuery.withArgs( + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.users ')), + [fixtures.reqBody.userExternalId] +).resolves({ rowCount: 1, rows: [{ id: fixtures.db.userId }] }); + +pgPoolQuery.withArgs( + sinon.match((sql) => sql.trim().startsWith('SELECT o.external_id, COUNT(*) AS total, ')), + [fixtures.reqBody.optionIds, fixtures.db.userId] +).resolves({ rowCount: 1, rows: [{ 'id': '5Ca2xEt6t10fxqMbvrw9aO', 'total': 40, votedByCurrentUser: 1 }] }); const expressReqStub = { - body: reqBody, + body: fixtures.reqBody, get: sinon.spy() }; const expressResStub = { @@ -31,23 +41,18 @@ describe('@/server-middleware/api/polls/votes', () => { afterAll(sinon.resetBehavior); describe('when there is an external user ID', () => { - // TODO: mock user SQL request describe('when some of the options have been voted on', () => { const options = { enabled: true }; it('queries postgres for the votes', async() => { await votesEventsHandler(options)(expressReqStub, expressResStub); - expect(pgPoolQuery.calledWith( - sinon.match((sql) => sql.trim().startsWith('SELECT ')), - [['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2'], 1] - )).toBe(true); + expect(pgPoolQuery.getCalls().length).toBe(2); }); it('responds with the view count as json', async() => { await votesEventsHandler(options)(expressReqStub, expressResStub); - - expect(expressResStub.json.calledWith({ '5Ca2xEt6t10fxqMbvrw9aO': 40 })).toBe(true); + expect(expressResStub.json.calledWith({ '5Ca2xEt6t10fxqMbvrw9aO': { total: 40, votedByCurrentUser: 1 } })).toBe(true); }); }); }); From 4519652961efec801b591fa9e65b070c87d5b3d9 Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Mon, 19 Aug 2024 13:15:26 +0200 Subject: [PATCH 10/32] remove unuised voteRow variable --- packages/portal/src/server-middleware/api/polls/vote.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/portal/src/server-middleware/api/polls/vote.js b/packages/portal/src/server-middleware/api/polls/vote.js index c2aea5a922..8ca1f745cd 100644 --- a/packages/portal/src/server-middleware/api/polls/vote.js +++ b/packages/portal/src/server-middleware/api/polls/vote.js @@ -47,7 +47,6 @@ export default (config = {}) => { optionRow = insertOptionResult.rows[0]; } - let voteRow; const selectVoteResult = await pg.query( 'SELECT id FROM polls.votes WHERE user_id=$1 AND option_id=$2', [userRow.id, optionRow.id] From 621d4a4655a6fc1d6c6d60ab66d2e3c188c4b165 Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Mon, 19 Aug 2024 15:32:48 +0200 Subject: [PATCH 11/32] update varibles/queries for naming changes --- packages/portal/nuxt.config.js | 2 +- .../api/polls/remove-vote.js | 34 ++++++------ .../src/server-middleware/api/polls/vote.js | 52 +++++++++---------- .../src/server-middleware/api/polls/votes.js | 34 ++++++------ .../api/polls/remove-vote.spec.js | 26 +++++----- .../server-middleware/api/polls/vote.spec.js | 40 +++++++------- .../server-middleware/api/polls/votes.spec.js | 24 ++++----- 7 files changed, 106 insertions(+), 106 deletions(-) diff --git a/packages/portal/nuxt.config.js b/packages/portal/nuxt.config.js index 985f380a8f..981b2b3a6d 100644 --- a/packages/portal/nuxt.config.js +++ b/packages/portal/nuxt.config.js @@ -43,7 +43,7 @@ const redisConfig = () => { const postgresConfig = () => { // see https://node-postgres.com/apis/pool const postgresOptions = { - enabled: featureIsEnabled('eventLogging'), + enabled: featureIsEnabled('eventLogging'), // TODO: Split out option(s) for toggling event Logging and feature voting separately connectionString: process.env.POSTGRES_URL, connectionTimeoutMillis: Number(process.env.POSTGRES_POOL_CONNECTION_TIMEOUT || 0), idleTimeoutMillis: Number(process.env.POSTGRES_POOL_IDLE_TIMEOUT || 10000), diff --git a/packages/portal/src/server-middleware/api/polls/remove-vote.js b/packages/portal/src/server-middleware/api/polls/remove-vote.js index b5a7c859ba..47ee4b5482 100644 --- a/packages/portal/src/server-middleware/api/polls/remove-vote.js +++ b/packages/portal/src/server-middleware/api/polls/remove-vote.js @@ -11,42 +11,42 @@ export default (config = {}) => { return; } - const { userExternalId, optionExternalId } = req.body; + const { voterExternalId, candidateExternalId } = req.body; // if(notAuthorized) { // res.sendStatus(401); // } - let userRow; - const selectUserResult = await pg.query( - 'SELECT id FROM polls.users WHERE external_id=$1', - [userExternalId] + let voterRow; + const selectVoterResult = await pg.query( + 'SELECT id FROM polls.voters WHERE external_id=$1', + [voterExternalId] ); - if (selectUserResult.rowCount > 0) { - userRow = selectUserResult.rows[0]; + if (selectVoterResult.rowCount > 0) { + voterRow = selectVoterResult.rows[0]; } else { - // user doesn't exist, can't have voted on anything + // voter doesn't exist, can't have voted on anything res.sendStatus(204); return; } - let optionRow; - const selectOptionResult = await pg.query( - 'SELECT id FROM polls.options WHERE external_id=$1', - [optionExternalId] + let candidateRow; + const selectCandidateResult = await pg.query( + 'SELECT id FROM polls.candidates WHERE external_id=$1', + [candidateExternalId] ); - if (selectOptionResult.rowCount > 0) { - optionRow = selectOptionResult.rows[0]; + if (selectCandidateResult.rowCount > 0) { + candidateRow = selectCandidateResult.rows[0]; } else { - // option doesn't exist, can't have been voted on + // candidate doesn't exist, can't have been voted on res.sendStatus(204); return; } let voteRow; const selectVoteResult = await pg.query( - 'SELECT id FROM polls.votes WHERE user_id=$1 AND option_id=$2', - [userRow.id, optionRow.id] + 'SELECT id FROM polls.votes WHERE voter_id=$1 AND candidate_id=$2', + [voterRow.id, candidateRow.id] ); if (selectVoteResult.rowCount > 0) { voteRow = selectVoteResult.rows[0]; diff --git a/packages/portal/src/server-middleware/api/polls/vote.js b/packages/portal/src/server-middleware/api/polls/vote.js index 8ca1f745cd..17cc80b785 100644 --- a/packages/portal/src/server-middleware/api/polls/vote.js +++ b/packages/portal/src/server-middleware/api/polls/vote.js @@ -11,55 +11,55 @@ export default (config = {}) => { return; } - const { userExternalId, optionExternalId } = req.body; + const { voterExternalId, candidateExternalId } = req.body; // if(notAuthorized) { // res.sendStatus(401); // } - let userRow; - const selectUserResult = await pg.query( - 'SELECT id FROM polls.users WHERE external_id=$1', - [userExternalId] + let voterRow; + const selectVoterResult = await pg.query( + 'SELECT id FROM polls.voters WHERE external_id=$1', + [voterExternalId] ); - if (selectUserResult.rowCount > 0) { - userRow = selectUserResult.rows[0]; + if (selectVoterResult.rowCount > 0) { + voterRow = selectVoterResult.rows[0]; } else { - const insertUserResult = await pg.query( - 'INSERT INTO polls.users (external_id) VALUES($1) RETURNING id', - [userExternalId] + const insertVoterResult = await pg.query( + 'INSERT INTO polls.voters (external_id) VALUES($1) RETURNING id', + [voterExternalId] ); - userRow = insertUserResult.rows[0]; + voterRow = insertVoterResult.rows[0]; } - let optionRow; - const selectOptionResult = await pg.query( - 'SELECT id FROM polls.options WHERE external_id=$1', - [optionExternalId] + let candidateRow; + const selectCandidateResult = await pg.query( + 'SELECT id FROM polls.candidates WHERE external_id=$1', + [candidateExternalId] ); - if (selectOptionResult.rowCount > 0) { - optionRow = selectOptionResult.rows[0]; + if (selectCandidateResult.rowCount > 0) { + candidateRow = selectCandidateResult.rows[0]; } else { - const insertOptionResult = await pg.query( - 'INSERT INTO polls.options (external_id) VALUES($1) RETURNING id', - [optionExternalId] + const insertCandidateResult = await pg.query( + 'INSERT INTO polls.candidates (external_id) VALUES($1) RETURNING id', + [candidateExternalId] ); - optionRow = insertOptionResult.rows[0]; + candidateRow = insertCandidateResult.rows[0]; } const selectVoteResult = await pg.query( - 'SELECT id FROM polls.votes WHERE user_id=$1 AND option_id=$2', - [userRow.id, optionRow.id] + 'SELECT id FROM polls.votes WHERE voter_id=$1 AND candidate_id=$2', + [voterRow.id, candidateRow.id] ); if (selectVoteResult.rowCount > 0) { - // No need to insert new vote, user has already voted for this option + // No need to insert new vote, voter has already voted for this candidate } else { await pg.query(` - INSERT INTO polls.votes (user_id, option_id, occurred_at) + INSERT INTO polls.votes (voter_id, candidate_id, occurred_at) VALUES($1, $2, CURRENT_TIMESTAMP) `, - [userRow.id, optionRow.id] + [voterRow.id, candidateRow.id] ); } res.sendStatus(200); diff --git a/packages/portal/src/server-middleware/api/polls/votes.js b/packages/portal/src/server-middleware/api/polls/votes.js index f58d12e847..bfc578fa08 100644 --- a/packages/portal/src/server-middleware/api/polls/votes.js +++ b/packages/portal/src/server-middleware/api/polls/votes.js @@ -1,6 +1,6 @@ import pg from '../pg/pg.js'; -// TODO: authorisation for current user before retrieving the userId +// TODO: authorisation for current user before retrieving the voterId export default (config = {}) => { pg.config = config; @@ -11,39 +11,39 @@ export default (config = {}) => { return; } - const { optionIds, userExternalId } = req.body; + const { candidateIds, voterExternalId } = req.body; - let userRow = { id: null }; - if (userExternalId) { - const selectUserResult = await pg.query( - 'SELECT id FROM polls.users WHERE external_id=$1', - [userExternalId] + let voterRow = { id: null }; + if (voterExternalId) { + const selectVoterResult = await pg.query( + 'SELECT id FROM polls.voters WHERE external_id=$1', + [voterExternalId] ); - if (selectUserResult.rowCount > 0) { - userRow = selectUserResult.rows[0]; + if (selectVoterResult.rowCount > 0) { + voterRow = selectVoterResult.rows[0]; } } - const votesForOptions = await pg.query(` - SELECT o.external_id, COUNT(*) AS total, (SELECT COUNT(*) FROM polls.votes WHERE user_id=$2 AND option_id=o.id) AS votedByCurrentUser - FROM polls.votes v LEFT JOIN polls.options o - ON v.option_id=o.id + const votesForCandidates = await pg.query(` + SELECT o.external_id, COUNT(*) AS total, (SELECT COUNT(*) FROM polls.votes WHERE voter_id=$2 AND candidate_id=o.id) AS votedByCurrentVoter + FROM polls.votes v LEFT JOIN polls.candidates o + ON v.candidate_id=o.id WHERE o.external_id LIKE ANY('{$1}') GROUP BY (o.id) `, - [optionIds, userRow.id] + [candidateIds, voterRow.id] ); - if (votesForOptions.rowCount < 0) { + if (votesForCandidates.rowCount < 0) { // Nobody has voted on anything yet res.json([]); return; } - res.json(votesForOptions.rows.reduce((memo, row) => { + res.json(votesForCandidates.rows.reduce((memo, row) => { memo[row.id] = { total: row.total, - votedByCurrentUser: row.votedByCurrentUser + votedByCurrentVoter: row.votedByCurrentVoter }; return memo; }, {})); diff --git a/packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js index 3d7b59fc8c..3f7de5818b 100644 --- a/packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js +++ b/packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js @@ -5,30 +5,30 @@ import removeVoteEventsHandler from '@/server-middleware/api/polls/remove-vote'; const fixtures = { db: { - userId: 1, - optionId: 2, + voterId: 1, + candidateId: 2, voteId: 3 }, reqBody: { - userExternalId: 'keycloak_uuid', - optionExternalId: 'contentful_sys_id' + voterExternalId: 'keycloak_uuid', + candidateExternalId: 'contentful_sys_id' } }; const pgPoolQuery = sinon.stub(); pgPoolQuery.withArgs( - sinon.match((sql) => sql.startsWith('SELECT id FROM polls.users ')), - [fixtures.reqBody.userExternalId] -).resolves({ rowCount: 1, rows: [{ id: fixtures.db.userId }] }); + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.voters ')), + [fixtures.reqBody.voterExternalId] +).resolves({ rowCount: 1, rows: [{ id: fixtures.db.voterId }] }); pgPoolQuery.withArgs( - sinon.match((sql) => sql.startsWith('SELECT id FROM polls.options ')), - [fixtures.reqBody.optionExternalId] -).resolves({ rowCount: 1, rows: [{ id: fixtures.db.optionId }] }); + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.candidates ')), + [fixtures.reqBody.candidateExternalId] +).resolves({ rowCount: 1, rows: [{ id: fixtures.db.candidateId }] }); pgPoolQuery.withArgs( sinon.match((sql) => sql.startsWith('SELECT id FROM polls.votes ')), - [fixtures.db.userId, fixtures.db.optionId] + [fixtures.db.voterId, fixtures.db.candidateId] ).resolves({ rowCount: 1, rows: [{ id: fixtures.db.voteId }] }); pgPoolQuery.withArgs( @@ -77,9 +77,9 @@ describe('@/server-middleware/api/polls/vote', () => { get: sinon.spy() }; - describe('when voting on behalf of a user with a valid session', () => { + describe('when voting on behalf of a voter with a valid session', () => { // TODO: authorisation - it('runs all postgres queries to vote on the option', async() => { + it('runs all postgres queries to vote on the candidate', async() => { await removeVoteEventsHandler(options)(expressReqStub, expressResStub); expect(pgPoolQuery.getCalls().length).toBe(4); diff --git a/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js index 2929781e63..df319330c6 100644 --- a/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js +++ b/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js @@ -5,49 +5,49 @@ import voteEventsHandler from '@/server-middleware/api/polls/vote'; const fixtures = { db: { - userId: 2, - optionId: 1 + voterId: 2, + candidateId: 1 }, reqBody: { - userExternalId: 'keycloak_uuid', - optionExternalId: 'contentful_sys_id' + voterExternalId: 'keycloak_uuid', + candidateExternalId: 'contentful_sys_id' } }; const pgPoolQuery = sinon.stub(); -// Stub user queries +// Stub voter queries pgPoolQuery.withArgs( - sinon.match((sql) => sql.startsWith('SELECT id FROM polls.users ')), - [fixtures.reqBody.userExternalId] + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.voters ')), + [fixtures.reqBody.voterExternalId] ) .resolves({ rowCount: 0 }); pgPoolQuery.withArgs( - sinon.match((sql) => sql.startsWith('INSERT INTO polls.users ')), - [fixtures.reqBody.userExternalId] + sinon.match((sql) => sql.startsWith('INSERT INTO polls.voters ')), + [fixtures.reqBody.voterExternalId] ) - .resolves({ rows: [{ id: fixtures.db.userId }] }); + .resolves({ rows: [{ id: fixtures.db.voterId }] }); -// Stub option queries +// Stub candidate queries pgPoolQuery.withArgs( - sinon.match((sql) => sql.startsWith('SELECT id FROM polls.options ')), - [fixtures.reqBody.optionExternalId] + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.candidates ')), + [fixtures.reqBody.candidateExternalId] ) .resolves({ rowCount: 0 }); pgPoolQuery.withArgs( - sinon.match((sql) => sql.startsWith('INSERT INTO polls.options ')), - [fixtures.reqBody.optionExternalId] + sinon.match((sql) => sql.startsWith('INSERT INTO polls.candidates ')), + [fixtures.reqBody.candidateExternalId] ) - .resolves({ rows: [{ id: fixtures.db.optionId }] }); + .resolves({ rows: [{ id: fixtures.db.candidateId }] }); // Stub vote queries pgPoolQuery.withArgs( sinon.match((sql) => sql.startsWith('SELECT id FROM polls.votes ')), - [fixtures.db.userId, fixtures.db.optionId] + [fixtures.db.voterId, fixtures.db.candidateId] ) .resolves({ rowCount: 0 }); pgPoolQuery.withArgs( sinon.match((sql) => sql.trim().startsWith('INSERT INTO polls.votes ')), - [fixtures.db.userId, fixtures.db.optionId] + [fixtures.db.voterId, fixtures.db.candidateId] ) .resolves({}); @@ -91,9 +91,9 @@ describe('@/server-middleware/api/polls/vote', () => { get: sinon.spy() }; - describe('when for voting on behalf of a user with a valid session', () => { + describe('when for voting on behalf of a voter with a valid session', () => { // TODO: authorisation - it('runs all postgres queries to vote on the option', async() => { + it('runs all postgres queries to vote on the candidate', async() => { await voteEventsHandler(options)(expressReqStub, expressResStub); expect(pgPoolQuery.getCalls().length).toBe(6); diff --git a/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js index fb37a37194..f7d8bf7462 100644 --- a/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js +++ b/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js @@ -5,24 +5,24 @@ import votesEventsHandler from '@/server-middleware/api/polls/votes'; const fixtures = { db: { - userId: 1 + voterId: 1 }, reqBody: { - optionIds: ['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2'], - userExternalId: 'keycloak_uuid' + candidateIds: ['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2'], + voterExternalId: 'keycloak_uuid' } }; const pgPoolQuery = sinon.stub(); pgPoolQuery.withArgs( - sinon.match((sql) => sql.startsWith('SELECT id FROM polls.users ')), - [fixtures.reqBody.userExternalId] -).resolves({ rowCount: 1, rows: [{ id: fixtures.db.userId }] }); + sinon.match((sql) => sql.startsWith('SELECT id FROM polls.voters ')), + [fixtures.reqBody.voterExternalId] +).resolves({ rowCount: 1, rows: [{ id: fixtures.db.voterId }] }); pgPoolQuery.withArgs( sinon.match((sql) => sql.trim().startsWith('SELECT o.external_id, COUNT(*) AS total, ')), - [fixtures.reqBody.optionIds, fixtures.db.userId] -).resolves({ rowCount: 1, rows: [{ 'id': '5Ca2xEt6t10fxqMbvrw9aO', 'total': 40, votedByCurrentUser: 1 }] }); + [fixtures.reqBody.candidateIds, fixtures.db.voterId] +).resolves({ rowCount: 1, rows: [{ 'id': '5Ca2xEt6t10fxqMbvrw9aO', 'total': 40, votedByCurrentVoter: 1 }] }); const expressReqStub = { body: fixtures.reqBody, @@ -40,8 +40,8 @@ describe('@/server-middleware/api/polls/votes', () => { afterEach(sinon.resetHistory); afterAll(sinon.resetBehavior); - describe('when there is an external user ID', () => { - describe('when some of the options have been voted on', () => { + describe('when there is an external voter ID', () => { + describe('when some of the candidates have been voted on', () => { const options = { enabled: true }; it('queries postgres for the votes', async() => { @@ -52,12 +52,12 @@ describe('@/server-middleware/api/polls/votes', () => { it('responds with the view count as json', async() => { await votesEventsHandler(options)(expressReqStub, expressResStub); - expect(expressResStub.json.calledWith({ '5Ca2xEt6t10fxqMbvrw9aO': { total: 40, votedByCurrentUser: 1 } })).toBe(true); + expect(expressResStub.json.calledWith({ '5Ca2xEt6t10fxqMbvrw9aO': { total: 40, votedByCurrentVoter: 1 } })).toBe(true); }); }); }); - describe('when there is NO external user ID', () => { + describe('when there is NO external voter ID', () => { // TODO: Add tests for anonymous retrieval }); }); From e1017ccb5a9a8df0eaffe58d1677ff2e433f4274 Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Tue, 20 Aug 2024 13:53:14 +0200 Subject: [PATCH 12/32] use feature toggle for enabling postgres --- packages/portal/nuxt.config.js | 2 +- packages/portal/src/server-middleware/api/index.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/portal/nuxt.config.js b/packages/portal/nuxt.config.js index 981b2b3a6d..574aa0d051 100644 --- a/packages/portal/nuxt.config.js +++ b/packages/portal/nuxt.config.js @@ -43,7 +43,7 @@ const redisConfig = () => { const postgresConfig = () => { // see https://node-postgres.com/apis/pool const postgresOptions = { - enabled: featureIsEnabled('eventLogging'), // TODO: Split out option(s) for toggling event Logging and feature voting separately + enabled: featureIsEnabled('eventLogging') || featureIsEnabled('featureIdeas'), connectionString: process.env.POSTGRES_URL, connectionTimeoutMillis: Number(process.env.POSTGRES_POOL_CONNECTION_TIMEOUT || 0), idleTimeoutMillis: Number(process.env.POSTGRES_POOL_IDLE_TIMEOUT || 10000), diff --git a/packages/portal/src/server-middleware/api/index.js b/packages/portal/src/server-middleware/api/index.js index 414ab61c00..dc605cb605 100644 --- a/packages/portal/src/server-middleware/api/index.js +++ b/packages/portal/src/server-middleware/api/index.js @@ -60,11 +60,11 @@ import version from './version.js'; app.get('/version', version); import votes from './polls/votes.js'; -app.get('/votes', votes); +app.get('/votes', votes(runtimeConfig.postgres)); import vote from './polls/vote.js'; -app.post('/vote', vote); +app.post('/vote', vote(runtimeConfig.postgres)); import removeVote from './polls/removeVote.js'; -app.delete('/vote', removeVote); +app.delete('/vote', removeVote(runtimeConfig.postgres)); app.all('/*', (req, res) => res.sendStatus(404)); From e8f7c13eaf7f1c06d315b6ef53393ebc5a3f48de Mon Sep 17 00:00:00 2001 From: Richard Doe Date: Tue, 20 Aug 2024 13:38:33 +0100 Subject: [PATCH 13/32] fix: correct import of remove vote module --- packages/portal/src/server-middleware/api/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/portal/src/server-middleware/api/index.js b/packages/portal/src/server-middleware/api/index.js index dc605cb605..91e48c6782 100644 --- a/packages/portal/src/server-middleware/api/index.js +++ b/packages/portal/src/server-middleware/api/index.js @@ -63,7 +63,7 @@ import votes from './polls/votes.js'; app.get('/votes', votes(runtimeConfig.postgres)); import vote from './polls/vote.js'; app.post('/vote', vote(runtimeConfig.postgres)); -import removeVote from './polls/removeVote.js'; +import removeVote from './polls/remove-vote.js'; app.delete('/vote', removeVote(runtimeConfig.postgres)); app.all('/*', (req, res) => res.sendStatus(404)); From 6f5ddb36ead7484e88fd9d63fdfde340319f0cb8 Mon Sep 17 00:00:00 2001 From: lbiedinger Date: Tue, 20 Aug 2024 15:01:33 +0200 Subject: [PATCH 14/32] use params in /votes request --- .../src/server-middleware/api/polls/votes.js | 15 ++++++++------- .../api/polls/remove-vote.spec.js | 2 +- .../server-middleware/api/polls/vote.spec.js | 2 +- .../server-middleware/api/polls/votes.spec.js | 18 +++++++++--------- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/portal/src/server-middleware/api/polls/votes.js b/packages/portal/src/server-middleware/api/polls/votes.js index bfc578fa08..39c36d431f 100644 --- a/packages/portal/src/server-middleware/api/polls/votes.js +++ b/packages/portal/src/server-middleware/api/polls/votes.js @@ -11,7 +11,8 @@ export default (config = {}) => { return; } - const { candidateIds, voterExternalId } = req.body; + const candidateExternalIds = req.query?.candidate || []; + const voterExternalId = req.query?.voter; let voterRow = { id: null }; if (voterExternalId) { @@ -25,13 +26,13 @@ export default (config = {}) => { } const votesForCandidates = await pg.query(` - SELECT o.external_id, COUNT(*) AS total, (SELECT COUNT(*) FROM polls.votes WHERE voter_id=$2 AND candidate_id=o.id) AS votedByCurrentVoter - FROM polls.votes v LEFT JOIN polls.candidates o - ON v.candidate_id=o.id - WHERE o.external_id LIKE ANY('{$1}') - GROUP BY (o.id) + SELECT c.external_id, COUNT(*) AS total, (SELECT COUNT(*) FROM polls.votes WHERE voter_id=$2 AND candidate_id=c.id) AS votedByCurrentVoter + FROM polls.votes v LEFT JOIN polls.candidates c + ON v.candidate_id=c.id + WHERE c.external_id LIKE ANY('{$1}') + GROUP BY (c.id) `, - [candidateIds, voterRow.id] + [candidateExternalIds, voterRow.id] ); if (votesForCandidates.rowCount < 0) { diff --git a/packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js index 3f7de5818b..f1324ce4c7 100644 --- a/packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js +++ b/packages/portal/tests/unit/server-middleware/api/polls/remove-vote.spec.js @@ -11,7 +11,7 @@ const fixtures = { }, reqBody: { voterExternalId: 'keycloak_uuid', - candidateExternalId: 'contentful_sys_id' + candidateExternalId: 'contentful_id' } }; diff --git a/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js index df319330c6..7b346f917e 100644 --- a/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js +++ b/packages/portal/tests/unit/server-middleware/api/polls/vote.spec.js @@ -10,7 +10,7 @@ const fixtures = { }, reqBody: { voterExternalId: 'keycloak_uuid', - candidateExternalId: 'contentful_sys_id' + candidateExternalId: 'contentful_id' } }; diff --git a/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js b/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js index f7d8bf7462..b6de260580 100644 --- a/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js +++ b/packages/portal/tests/unit/server-middleware/api/polls/votes.spec.js @@ -7,25 +7,25 @@ const fixtures = { db: { voterId: 1 }, - reqBody: { - candidateIds: ['5Ca2xEt6t10fxqMbvrw9aO', '5Ca2xEt6t10fxqMbvrw9a1', '5Ca2xEt6t10fxqMbvrw9a2'], - voterExternalId: 'keycloak_uuid' + reqQuery: { + candidate: ['feature-id-one', 'feature-id-two', 'feature-id-three'], + voter: 'keycloak_uuid' } }; const pgPoolQuery = sinon.stub(); pgPoolQuery.withArgs( sinon.match((sql) => sql.startsWith('SELECT id FROM polls.voters ')), - [fixtures.reqBody.voterExternalId] + [fixtures.reqQuery.voter] ).resolves({ rowCount: 1, rows: [{ id: fixtures.db.voterId }] }); pgPoolQuery.withArgs( - sinon.match((sql) => sql.trim().startsWith('SELECT o.external_id, COUNT(*) AS total, ')), - [fixtures.reqBody.candidateIds, fixtures.db.voterId] -).resolves({ rowCount: 1, rows: [{ 'id': '5Ca2xEt6t10fxqMbvrw9aO', 'total': 40, votedByCurrentVoter: 1 }] }); + sinon.match((sql) => sql.trim().startsWith('SELECT c.external_id, COUNT(*) AS total, ')), + [fixtures.reqQuery.candidate, fixtures.db.voterId] +).resolves({ rowCount: 1, rows: [{ 'id': 'feature-id-one', 'total': 40, votedByCurrentVoter: 1 }] }); const expressReqStub = { - body: fixtures.reqBody, + query: fixtures.reqQuery, get: sinon.spy() }; const expressResStub = { @@ -52,7 +52,7 @@ describe('@/server-middleware/api/polls/votes', () => { it('responds with the view count as json', async() => { await votesEventsHandler(options)(expressReqStub, expressResStub); - expect(expressResStub.json.calledWith({ '5Ca2xEt6t10fxqMbvrw9aO': { total: 40, votedByCurrentVoter: 1 } })).toBe(true); + expect(expressResStub.json.calledWith({ 'feature-id-one': { total: 40, votedByCurrentVoter: 1 } })).toBe(true); }); }); }); From 2b5c87aa62ae5392b1df506f6f08d6380d9250c5 Mon Sep 17 00:00:00 2001 From: Richard Doe Date: Tue, 20 Aug 2024 14:25:23 +0100 Subject: [PATCH 15/32] fix: split candidate query param --- packages/portal/src/server-middleware/api/polls/votes.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/portal/src/server-middleware/api/polls/votes.js b/packages/portal/src/server-middleware/api/polls/votes.js index 39c36d431f..f64abda97f 100644 --- a/packages/portal/src/server-middleware/api/polls/votes.js +++ b/packages/portal/src/server-middleware/api/polls/votes.js @@ -11,7 +11,7 @@ export default (config = {}) => { return; } - const candidateExternalIds = req.query?.candidate || []; + const candidateExternalIds = req.query?.candidate?.split(',') || []; const voterExternalId = req.query?.voter; let voterRow = { id: null }; @@ -29,7 +29,7 @@ export default (config = {}) => { SELECT c.external_id, COUNT(*) AS total, (SELECT COUNT(*) FROM polls.votes WHERE voter_id=$2 AND candidate_id=c.id) AS votedByCurrentVoter FROM polls.votes v LEFT JOIN polls.candidates c ON v.candidate_id=c.id - WHERE c.external_id LIKE ANY('{$1}') + WHERE c.external_id LIKE ANY($1) GROUP BY (c.id) `, [candidateExternalIds, voterRow.id] @@ -50,6 +50,7 @@ export default (config = {}) => { }, {})); } catch (err) { console.error(err); + res.sendStatus(500); } }; }; From 2eef9a951be09e42c7ef30355bd466d18e0f5384 Mon Sep 17 00:00:00 2001 From: Richard Doe Date: Tue, 20 Aug 2024 14:49:31 +0100 Subject: [PATCH 16/32] feat: hook feature ideas component up to API --- .../src/components/generic/FeatureIdeas.vue | 34 +++++++++---------- .../api/polls/remove-vote.js | 3 +- .../src/server-middleware/api/polls/vote.js | 3 +- .../src/server-middleware/api/polls/votes.js | 6 ++-- 4 files changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/portal/src/components/generic/FeatureIdeas.vue b/packages/portal/src/components/generic/FeatureIdeas.vue index 945797e03b..24fb6a8859 100644 --- a/packages/portal/src/components/generic/FeatureIdeas.vue +++ b/packages/portal/src/components/generic/FeatureIdeas.vue @@ -43,6 +43,7 @@