Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: voting API #2419

Merged
merged 36 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
9f9d135
add voting API endpoint WIP
lbiedinger Aug 13, 2024
a5086c7
polls API middleware
lbiedinger Aug 13, 2024
1624f81
linting
lbiedinger Aug 13, 2024
1221e96
specs for votes
lbiedinger Aug 13, 2024
d624cd9
restructure votes data
lbiedinger Aug 13, 2024
66869fa
update TODO with new criteria
lbiedinger Aug 13, 2024
71f7d5a
fix parenthesis
lbiedinger Aug 16, 2024
4d4ed86
remove vote endpoint
lbiedinger Aug 16, 2024
1974bb7
fix specs
lbiedinger Aug 19, 2024
4519652
remove unuised voteRow variable
lbiedinger Aug 19, 2024
be4e754
Merge branch 'master' into feat/EC-6893-feature-vote-api
lbiedinger Aug 19, 2024
621d4a4
update varibles/queries for naming changes
lbiedinger Aug 19, 2024
e1017cc
use feature toggle for enabling postgres
lbiedinger Aug 20, 2024
98988d0
Merge branch 'master' into feat/EC-6893-feature-vote-api
rwd Aug 20, 2024
e8f7c13
fix: correct import of remove vote module
rwd Aug 20, 2024
6f5ddb3
use params in /votes request
lbiedinger Aug 20, 2024
2b5c87a
fix: split candidate query param
rwd Aug 20, 2024
2eef9a9
feat: hook feature ideas component up to API
rwd Aug 20, 2024
a8dac16
feat: authorize voting via keycloak
rwd Aug 21, 2024
57e6f31
fix: don't send empty auth header
rwd Aug 21, 2024
0aa36a2
Merge branch 'master' into feat/EC-6893-feature-vote-api
lbiedinger Aug 21, 2024
41265f3
feat: use keycloak error response handler in voting
rwd Aug 21, 2024
cfda1bd
refactor: use v-if (to prevent flickering change)
rwd Aug 21, 2024
32d169c
use identifier, not sys-id
lbiedinger Aug 21, 2024
9e2a3d2
use identifers, not sys.ids
lbiedinger Aug 21, 2024
e316f0f
refactor: make vote/unvote methods more RESTful
rwd Aug 22, 2024
3f84651
chore: return error code
rwd Aug 22, 2024
23e2da1
test: update FeatureIdeas tests for refactor
rwd Aug 22, 2024
f15dd00
refactor: move db logic to model file
rwd Aug 22, 2024
53c99a4
refactor: export router for polls and events
rwd Aug 22, 2024
96fa7b6
refactor: use express next for error handling
rwd Aug 22, 2024
d66118a
test: update event/jira unit tests
rwd Aug 22, 2024
5ef092a
test: spec polls API
rwd Aug 23, 2024
69f0aca
fix: keycloak url and import
rwd Aug 23, 2024
d3b7777
fix: shared error logging
rwd Aug 23, 2024
e32b788
do NOT respond with error when response is finished
lbiedinger Aug 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
lbiedinger marked this conversation as resolved.
Show resolved Hide resolved
}
};
Loading
Loading