From 3875576ef69badde1594949770c6cde09c75d6aa Mon Sep 17 00:00:00 2001 From: Iida Kainu Date: Fri, 22 Oct 2021 11:40:50 +0300 Subject: [PATCH] Rework backend invitations api Add timestamp and unique constraint (combination of roadmapId & email) to invitations. Add route for getting invitations. --- server/package.json | 1 + .../api/invitations/invitations.controller.ts | 42 +++++++++---------- .../src/api/invitations/invitations.model.ts | 20 ++++++++- .../src/api/invitations/invitations.routes.ts | 19 ++++++--- server/src/api/users/users.controller.ts | 6 ++- ...mestampAndUniqueConstraintToInvitations.ts | 15 +++++++ server/yarn.lock | 5 +++ 7 files changed, 79 insertions(+), 29 deletions(-) create mode 100644 server/src/migrations/20211021141414_addTimestampAndUniqueConstraintToInvitations.ts diff --git a/server/package.json b/server/package.json index 8683cbf3d..c5ea1388e 100644 --- a/server/package.json +++ b/server/package.json @@ -55,6 +55,7 @@ "koa-bodyparser": "^4.3.0", "koa-passport": "^4.1.3", "koa-session": "^6.0.0", + "moment": "^2.29.1", "node-rsa": "^1.1.1", "oauth": "^0.9.15", "objection": "^2.2.15", diff --git a/server/src/api/invitations/invitations.controller.ts b/server/src/api/invitations/invitations.controller.ts index b8952c18e..9888e0938 100644 --- a/server/src/api/invitations/invitations.controller.ts +++ b/server/src/api/invitations/invitations.controller.ts @@ -1,4 +1,5 @@ import { RouteHandlerFnc } from 'src/types/customTypes'; +import moment from 'moment'; import Invitation from './invitations.model'; import User from '../users/users.model'; import uuid from 'uuid'; @@ -7,7 +8,15 @@ import { sendEmail } from '../../utils/sendEmail'; // should FRONTEND_BASE_URL and CORS_ORIGIN be same variable? const BASE_URL = process.env.FRONTEND_BASE_URL!; -export const addInvitations: RouteHandlerFnc = async (ctx) => { +export const getInvitations: RouteHandlerFnc = async (ctx) => { + ctx.body = await Invitation.query() + .where({ + roadmapId: Number(ctx.params.roadmapId), + }) + .where('updatedAt', '>=', moment().subtract(1, 'month').toDate()); +}; + +export const postInvitation: RouteHandlerFnc = async (ctx) => { const { type, email, ...others } = ctx.request.body; if (Object.keys(others).length) return void (ctx.status = 400); @@ -20,24 +29,15 @@ export const addInvitations: RouteHandlerFnc = async (ctx) => { if (existingRole) throw new Error('Invitee is already a team member'); - const previousInvitation = await Invitation.query() - .where({ email, roadmapId }) - .first(); - - if (previousInvitation) { - const updated = await Invitation.query().patchAndFetchById( - previousInvitation.id, - { type }, - ); - return void (ctx.body = updated); - } - - const created = await Invitation.query().insertAndFetch({ - id: uuid.v4(), - roadmapId, - type, - email, - }); + const created = await Invitation.query() + .insertAndFetch({ + id: uuid.v4(), + roadmapId, + type, + email, + }) + .onConflict(['roadmapId', 'email']) + .merge(); await sendEmail( email, 'Invitation to roadmap', @@ -46,7 +46,7 @@ export const addInvitations: RouteHandlerFnc = async (ctx) => { return void (ctx.body = created); }; -export const patchInvitations: RouteHandlerFnc = async (ctx) => { +export const patchInvitation: RouteHandlerFnc = async (ctx) => { const { id, type, email, roadmapId, ...others } = ctx.request.body; if (Object.keys(others).length) return void (ctx.status = 400); @@ -62,7 +62,7 @@ export const patchInvitations: RouteHandlerFnc = async (ctx) => { } }; -export const deleteInvitations: RouteHandlerFnc = async (ctx) => { +export const deleteInvitation: RouteHandlerFnc = async (ctx) => { const numDeleted = await Invitation.query() .where({ id: ctx.params.invitationId, diff --git a/server/src/api/invitations/invitations.model.ts b/server/src/api/invitations/invitations.model.ts index 7f2ab8d84..631475cfc 100644 --- a/server/src/api/invitations/invitations.model.ts +++ b/server/src/api/invitations/invitations.model.ts @@ -1,4 +1,5 @@ -import { Model } from 'objection'; +import { Model, Pojo } from 'objection'; +import moment from 'moment'; import { RoleType } from '../../../../shared/types/customTypes'; export default class Invitation extends Model { @@ -6,6 +7,9 @@ export default class Invitation extends Model { roadmapId!: number; type!: RoleType; email!: string; + updatedAt!: Date; + + valid?: boolean; static tableName = 'invitations'; @@ -18,4 +22,18 @@ export default class Invitation extends Model { email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, }, }; + + $beforeInsert() { + this.updatedAt = new Date(); + } + $beforeUpdate() { + this.updatedAt = new Date(); + } + $parseDatabaseJson(json: Pojo) { + json = super.$parseDatabaseJson(json); + const date = json.updatedAt && new Date(json.updatedAt); + json.updatedAt = date; + json.valid = date >= moment().subtract(48, 'hours').toDate(); + return json; + } } diff --git a/server/src/api/invitations/invitations.routes.ts b/server/src/api/invitations/invitations.routes.ts index 86f711c09..605c57149 100644 --- a/server/src/api/invitations/invitations.routes.ts +++ b/server/src/api/invitations/invitations.routes.ts @@ -4,29 +4,36 @@ import { Context } from 'koa'; import { IKoaState } from '../../types/customTypes'; import { Permission } from '../../../../shared/types/customTypes'; import { - addInvitations, - patchInvitations, - deleteInvitations, + getInvitations, + postInvitation, + patchInvitation, + deleteInvitation, } from './invitations.controller'; const invitationsRouter = new KoaRouter(); +invitationsRouter.get( + '/invitations', + requirePermission(Permission.RoadmapInvite), + getInvitations, +); + invitationsRouter.post( '/invitations', requirePermission(Permission.RoadmapInvite), - addInvitations, + postInvitation, ); invitationsRouter.patch( '/invitations/:invitationId', requirePermission(Permission.RoadmapInvite), - patchInvitations, + patchInvitation, ); invitationsRouter.delete( '/invitations/:invitationId', requirePermission(Permission.RoadmapInvite), - deleteInvitations, + deleteInvitation, ); export default invitationsRouter; diff --git a/server/src/api/users/users.controller.ts b/server/src/api/users/users.controller.ts index 4cff952e6..68d2a3b0d 100644 --- a/server/src/api/users/users.controller.ts +++ b/server/src/api/users/users.controller.ts @@ -1,5 +1,6 @@ import passport from 'passport'; import uuid from 'uuid'; +import moment from 'moment'; import { RouteHandlerFnc } from '../../types/customTypes'; import User from './users.model'; import Invitation from '../invitations/invitations.model'; @@ -152,7 +153,10 @@ export const getUserRoles: RouteHandlerFnc = async (ctx) => { export const joinRoadmap: RouteHandlerFnc = async (ctx) => { if (!ctx.state.user) throw new Error('User is required'); const { ...others } = ctx.request.body; - const invitation = await Invitation.query().findById(ctx.params.invitationId); + + const invitation = await Invitation.query() + .findById(ctx.params.invitationId) + .where('updatedAt', '>=', moment().subtract(48, 'hours').toDate()); if (Object.keys(others).length || !invitation) return void (ctx.status = 400); if (invitation.email !== ctx.state.user.email) return void (ctx.status = 403); diff --git a/server/src/migrations/20211021141414_addTimestampAndUniqueConstraintToInvitations.ts b/server/src/migrations/20211021141414_addTimestampAndUniqueConstraintToInvitations.ts new file mode 100644 index 000000000..9176776d1 --- /dev/null +++ b/server/src/migrations/20211021141414_addTimestampAndUniqueConstraintToInvitations.ts @@ -0,0 +1,15 @@ +import * as Knex from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.alterTable('invitations', (table) => { + table.timestamp('updatedAt'); + table.unique(['roadmapId', 'email']); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.alterTable('invitations', (table) => { + table.dropColumn('updatedAt'); + table.dropUnique(['roadmapId', 'email']); + }); +} diff --git a/server/yarn.lock b/server/yarn.lock index 957c7079e..57454b4bc 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -3722,6 +3722,11 @@ mocha@^9.0.2: yargs-parser "20.2.4" yargs-unparser "2.0.0" +moment@^2.29.1: + version "2.29.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"