Skip to content

Commit

Permalink
feat: voting API (#2419)
Browse files Browse the repository at this point in the history
* add voting API endpoint WIP

* polls API middleware

* linting

* specs for votes

* restructure votes data

* update TODO with new criteria

* fix parenthesis

* remove vote endpoint

* fix specs

* remove unuised voteRow variable

* update varibles/queries for naming changes

* use feature toggle for enabling postgres

* fix: correct import of remove vote module

* use params in /votes request

* fix: split candidate query param

* feat: hook feature ideas component up to API

* feat: authorize voting via keycloak

* fix: don't send empty auth header

* feat: use keycloak error response handler in voting

* refactor: use v-if (to prevent flickering change)

* use identifier, not sys-id

* refactor: make vote/unvote methods more RESTful

* chore: return error code

* test: update FeatureIdeas tests for refactor

* refactor: move db logic to model file

* refactor: export router for polls and events

* refactor: use express next for error handling

* test: update event/jira unit tests

* test: spec polls API

* fix: keycloak url and import

* fix: shared error logging

* do NOT respond with error when response is finished

---------

Co-authored-by: Richard Doe <[email protected]>
  • Loading branch information
lbiedinger and rwd authored Aug 28, 2024
1 parent ca53b3c commit 1c5b57d
Show file tree
Hide file tree
Showing 33 changed files with 1,141 additions and 392 deletions.
1 change: 0 additions & 1 deletion packages/portal/nuxt.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ const redisConfig = () => {
const postgresConfig = () => {
// see https://node-postgres.com/apis/pool
const postgresOptions = {
enabled: featureIsEnabled('eventLogging'),
connectionString: process.env.POSTGRES_URL,
connectionTimeoutMillis: Number(process.env.POSTGRES_POOL_CONNECTION_TIMEOUT || 0),
idleTimeoutMillis: Number(process.env.POSTGRES_POOL_IDLE_TIMEOUT || 10000),
Expand Down
78 changes: 49 additions & 29 deletions packages/portal/src/components/generic/FeatureIdeas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,18 @@
>
<template #footer>
<b-button
v-if="!$fetchState.pending"
class="vote-button d-inline-flex align-items-center text-uppercase mt-auto mr-auto"
:class="{ voted: hasVotedOnFeature(feature.sys.id) }"
:class="{ voted: hasVotedOnFeature(feature.identifier) }"
variant="light-flat"
:aria-label="$t('actions.vote')"
@click="voteOnFeature(feature.sys.id)"
@click="voteOnFeature(feature.identifier)"
>
<span
class="mr-1"
:class="feature.voted ? 'icon-thumbsup' : 'icon-thumbsup-outlined'"
/>
{{ $tc('likes.count', voteCountOnFeature(feature.sys.id)) }}
{{ $tc('likes.count', voteCountOnFeature(feature.identifier)) }}
</b-button>
</template>
</ContentCard>
Expand All @@ -43,7 +44,9 @@
</template>

<script>
import axios from 'axios';
import keycloakMixin from '@/mixins/keycloak';
import { keycloakResponseErrorHandler } from '@/plugins/europeana/auth';
import ContentCard from '@/components/content/ContentCard';
export default {
Expand All @@ -65,54 +68,71 @@
data() {
return {
axiosInstance: null,
votesOnFeatures: {}
};
},
fetch() {
async fetch() {
this.axiosInstance = axios.create({
baseURL: this.$config.app.baseUrl
});
this.axiosInstance.interceptors.response.use(
(response) => response,
(error) => keycloakResponseErrorHandler(this.$nuxt.context, error)
);
if (this.features.length < 1) {
const error = new Error('No feature ideas');
error.code = 'noFeatureIdeas';
this.$error(error);
return;
}
// TODO: fetch the votes for each feature and remove mock functionality
// const featureIds = this.features.map((feature) => feature.sys.id).join(',');
// this.votesOnFeatures = await this.$axios.$get(`/api/votes/${featureIds}`);
for (const feature of this.features) {
const featureVotes = { count: Math.floor(Math.random() * 99), currentlyVotedOn: false };
this.votesOnFeatures[feature.sys.id] = featureVotes;
}
},
const params = { candidate: this.features.map((feature) => feature.identifier).join(',') };
const votesResponse = await this.axiosInstance({
url: '/_api/votes',
method: 'get',
headers: this.headersForAuthorization(),
params
});
computed: {
userId() {
return this.$auth.user?.sub;
}
this.votesOnFeatures = votesResponse.data;
},
// client-side only for oAuth authorization
fetchOnServer: false,
methods: {
voteOnFeature(featureId) {
// TODO: Implement voting on feature and remove mock functionality
// await this.$axios.$post(`/api/vote/${featureId}`);
console.log('Voting on feature', featureId);
headersForAuthorization() {
if (this.$auth.loggedIn) {
if (this.hasVotedOnFeature(featureId)) {
this.votesOnFeatures[featureId].count = (this.votesOnFeatures[featureId]?.count || 0) - 1;
this.votesOnFeatures[featureId].currentlyVotedOn = false;
} else {
this.votesOnFeatures[featureId].count = (this.votesOnFeatures[featureId]?.count || 0) + 1;
this.votesOnFeatures[featureId].currentlyVotedOn = true;
}
return {
authorization: this.$auth.getToken(this.$auth.strategy?.name)
};
} else {
return {};
}
},
async voteOnFeature(featureId) {
if (this.$auth.loggedIn) {
const method = this.hasVotedOnFeature(featureId) ? 'delete' : 'put';
await this.axiosInstance({
url: `/_api/votes/${featureId}`,
method,
headers: this.headersForAuthorization()
});
this.$fetch();
} else {
this.keycloakLogin();
}
},
voteCountOnFeature(featureId) {
return this.votesOnFeatures[featureId]?.count;
return this.votesOnFeatures[featureId]?.total || 0;
},
hasVotedOnFeature(featureId) {
return this.votesOnFeatures[featureId]?.currentlyVotedOn;
return this.votesOnFeatures[featureId]?.votedByCurrentVoter || false;
}
}
};
Expand Down
3 changes: 2 additions & 1 deletion packages/portal/src/i18n/lang/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ export default {
"transcribeNow": "Transcribe now",
"unlike": "Unlike",
"viewAt": "View at {link}",
"viewDocument": "View document"
"viewDocument": "View document",
"vote": "Vote"
},
"attribution": {
"country": "Country:",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@ query featureIdeasPage($locale: String!, $preview: Boolean = false) {
hasPartCollection {
items {
... on VoteableFeature {
sys {
id
}
name
identifier
text
image {
... on Illustration {
Expand Down
5 changes: 5 additions & 0 deletions packages/portal/src/plugins/europeana/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const refreshAccessToken = async({ $auth, $axios, redirect, route }, requestConf
// Refresh token is no longer valid; clear tokens and try again
$auth.logout();
delete requestConfig.headers['Authorization'];
delete requestConfig.headers['authorization'];
return $axios.request(requestConfig);
}

Expand All @@ -18,6 +19,7 @@ const refreshAccessToken = async({ $auth, $axios, redirect, route }, requestConf
updateRefreshToken($auth, refreshAccessTokenResponse);

// Retry request with new access token
console.log('retrying request with new access token', requestConfig);
return $axios.request(requestConfig);
};

Expand Down Expand Up @@ -58,6 +60,9 @@ const updateAccessToken = ($auth, requestConfig, refreshAccessTokenResponse) =>
$auth.strategy._setToken(newAccessToken); // eslint-disable-line no-underscore-dangle

delete requestConfig.headers['Authorization'];
delete requestConfig.headers['authorization'];
// TODO: use axios instead of $axios, and set new Authorization header here
// from newAccessToken?

return newAccessToken;
};
Expand Down
5 changes: 2 additions & 3 deletions packages/portal/src/server-middleware/api/cache/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createClient } from 'redis';
import { errorHandler } from '../utils.js';

const cacheKey = (id) => `@europeana:portal.js:${id.replace(/\//g, ':')}`;

Expand Down Expand Up @@ -30,13 +29,13 @@ export const cached = async(ids, config = {}) => {
return values;
};

export default (config = {}) => (req, res) => {
export default (config = {}) => (req, res, next) => {
const ids = req.params[0] ? req.params[0] : req.query.id;

return cached(ids, config)
.then(data => {
res.set('Content-Type', 'application/json');
res.send(data);
})
.catch(error => errorHandler(res, error));
.catch(next);
};
17 changes: 17 additions & 0 deletions packages/portal/src/server-middleware/api/events/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// TODO: move to a standalone express micro-service, so the portal.js app does
// not have a direct dependency on postgres, indeed need not know what
// back-end storage is used.

import express from 'express';

import logEvent from './log.js';
import eventTrending from './trending.js';
import eventViews from './views.js';

const router = express.Router();

router.post('/', logEvent);
router.get('/trending', eventTrending);
router.get('/views', eventViews);

export default router;
125 changes: 60 additions & 65 deletions packages/portal/src/server-middleware/api/events/log.js
Original file line number Diff line number Diff line change
@@ -1,83 +1,78 @@
import isbot from 'isbot';
import pg from './pg.js';
import pg from '../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;
export default async(req, res, next) => {
try {
// Respond early as clients don't need to wait for the results of this logging
res.sendStatus(204);

return async(req, res) => {
try {
// Respond early as clients don't need to wait for the results of this logging
res.sendStatus(204);
if (isbot(req.get('user-agent'))) {
return;
}

if (!pg.enabled || isbot(req.get('user-agent'))) {
return;
}
const { actionType, objectUri, sessionId } = req.body;
const url = new URL(objectUri);
// Ignore any search query or hash
const uri = `${url.origin}${url.pathname}`;

const { actionType, objectUri, sessionId } = req.body;
const url = new URL(objectUri);
// Ignore any search query or hash
const uri = `${url.origin}${url.pathname}`;
let objectRow;
const selectObjectResult = await pg.query(
'SELECT id FROM events.objects WHERE uri=$1',
[uri]
);

let objectRow;
const selectObjectResult = await pg.query(
'SELECT id FROM events.objects WHERE uri=$1',
if (selectObjectResult.rowCount > 0) {
objectRow = selectObjectResult.rows[0];
} else {
const insertObjectResult = await pg.query(
'INSERT INTO events.objects (uri) VALUES($1) RETURNING id',
[uri]
);
objectRow = insertObjectResult.rows[0];
}

if (selectObjectResult.rowCount > 0) {
objectRow = selectObjectResult.rows[0];
} else {
const insertObjectResult = await pg.query(
'INSERT INTO events.objects (uri) VALUES($1) RETURNING id',
[uri]
);
objectRow = insertObjectResult.rows[0];
}
let sessionRow;
const selectSessionResult = await pg.query(
'SELECT id FROM events.sessions WHERE uuid=$1',
[sessionId]
);

let sessionRow;
const selectSessionResult = await pg.query(
'SELECT id FROM events.sessions WHERE uuid=$1',
if (selectSessionResult.rowCount > 0) {
sessionRow = selectSessionResult.rows[0];
} else {
const insertSessionResult = await pg.query(
'INSERT INTO events.sessions (uuid) VALUES($1) RETURNING id',
[sessionId]
);
sessionRow = insertSessionResult.rows[0];
}

if (selectSessionResult.rowCount > 0) {
sessionRow = selectSessionResult.rows[0];
} else {
const insertSessionResult = await pg.query(
'INSERT INTO events.sessions (uuid) VALUES($1) RETURNING id',
[sessionId]
);
sessionRow = insertSessionResult.rows[0];
}

const selectActionResult = await pg.query(`
SELECT a.id FROM events.actions a LEFT JOIN events.action_types at
ON a.action_type_id=at.id
WHERE a.object_id=$1
AND at.name=$2
AND a.session_id=$3
`,
[objectRow.id, actionType, sessionRow.id]
);
if (selectActionResult.rowCount > 0) {
// this session has already logged this action type for this object; don't log it again
return;
}

await pg.query(`
INSERT INTO events.actions (object_id, action_type_id, session_id, occurred_at)
SELECT $1, at.id, $2, CURRENT_TIMESTAMP
FROM events.action_types at
WHERE at.name=$3
`,
[objectRow.id, sessionRow.id, actionType]
);
} catch (err) {
console.error(err);
const selectActionResult = await pg.query(`
SELECT a.id FROM events.actions a LEFT JOIN events.action_types at
ON a.action_type_id=at.id
WHERE a.object_id=$1
AND at.name=$2
AND a.session_id=$3
`,
[objectRow.id, actionType, sessionRow.id]
);
if (selectActionResult.rowCount > 0) {
// this session has already logged this action type for this object; don't log it again
return;
}
};

await pg.query(`
INSERT INTO events.actions (object_id, action_type_id, session_id, occurred_at)
SELECT $1, at.id, $2, CURRENT_TIMESTAMP
FROM events.action_types at
WHERE at.name=$3
`,
[objectRow.id, sessionRow.id, actionType]
);
} catch (err) {
next(err);
}
};
Loading

0 comments on commit 1c5b57d

Please sign in to comment.