From d9cfe6d01ce405523578690fefb407c586c6f4b7 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. --- .../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 | 4 +- ...mestampAndUniqueConstraintToInvitations.ts | 15 +++++++ server/src/utils/date.ts | 5 +++ 6 files changed, 76 insertions(+), 29 deletions(-) create mode 100644 server/src/migrations/20211021141414_addTimestampAndUniqueConstraintToInvitations.ts create mode 100644 server/src/utils/date.ts diff --git a/server/src/api/invitations/invitations.controller.ts b/server/src/api/invitations/invitations.controller.ts index b8952c18e..1fcd60547 100644 --- a/server/src/api/invitations/invitations.controller.ts +++ b/server/src/api/invitations/invitations.controller.ts @@ -3,11 +3,20 @@ import Invitation from './invitations.model'; import User from '../users/users.model'; import uuid from 'uuid'; import { sendEmail } from '../../utils/sendEmail'; +import { daysAgo } from '../../utils/date'; // 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', '>=', daysAgo(30)); +}; + +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..c859df884 100644 --- a/server/src/api/invitations/invitations.model.ts +++ b/server/src/api/invitations/invitations.model.ts @@ -1,11 +1,15 @@ -import { Model } from 'objection'; +import { Model, Pojo } from 'objection'; import { RoleType } from '../../../../shared/types/customTypes'; +import { daysAgo } from '../../utils/date'; export default class Invitation extends Model { id!: string; 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); + json.updatedAt = json.updatedAt && new Date(json.updatedAt); + + json.valid = json.updatedAt >= daysAgo(2); + 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..433ba2b4f 100644 --- a/server/src/api/users/users.controller.ts +++ b/server/src/api/users/users.controller.ts @@ -152,9 +152,11 @@ 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); - if (Object.keys(others).length || !invitation) return void (ctx.status = 400); + if (Object.keys(others).length || !invitation || !invitation.valid) + return void (ctx.status = 400); if (invitation.email !== ctx.state.user.email) return void (ctx.status = 403); const role = await Invitation.transaction(async (trx) => { 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/src/utils/date.ts b/server/src/utils/date.ts new file mode 100644 index 000000000..7f702b91c --- /dev/null +++ b/server/src/utils/date.ts @@ -0,0 +1,5 @@ +export const daysAgo = (days: number) => { + const dateNow = new Date(); + dateNow.setDate(dateNow.getDate() - days); + return dateNow; +};