From dc795604a4a53d8bca69e08930c0463c7c2d6948 Mon Sep 17 00:00:00 2001 From: Mohamed ELIDRISSI <67818913+elidrissidev@users.noreply.github.com> Date: Sat, 31 Dec 2022 22:56:37 +0100 Subject: [PATCH] refactor: add server-side validation schema for events (#4622) * refactor: move files to subfolder * refactor: schema for events.list * refactor: update nullable fields in Event model * fix: event actor not nullable * fix: team not nullable --- server/models/Event.ts | 16 +-- server/routes/api/events.ts | 101 ----------------- .../__snapshots__/events.test.ts.snap | 0 server/routes/api/{ => events}/events.test.ts | 0 server/routes/api/events/events.ts | 102 ++++++++++++++++++ server/routes/api/events/index.ts | 1 + server/routes/api/events/schema.ts | 32 ++++++ 7 files changed, 143 insertions(+), 109 deletions(-) delete mode 100644 server/routes/api/events.ts rename server/routes/api/{ => events}/__snapshots__/events.test.ts.snap (100%) rename server/routes/api/{ => events}/events.test.ts (100%) create mode 100644 server/routes/api/events/events.ts create mode 100644 server/routes/api/events/index.ts create mode 100644 server/routes/api/events/schema.ts diff --git a/server/models/Event.ts b/server/models/Event.ts index e4d6ed1fe..86baaa344 100644 --- a/server/models/Event.ts +++ b/server/models/Event.ts @@ -25,7 +25,7 @@ import Fix from "./decorators/Fix"; class Event extends IdModel { @IsUUID(4) @Column(DataType.UUID) - modelId: string; + modelId: string | null; @Length({ max: 255, @@ -39,7 +39,7 @@ class Event extends IdModel { ip: string | null; @Column(DataType.JSONB) - data: Record; + data: Record | null; // hooks @@ -63,18 +63,18 @@ class Event extends IdModel { // associations @BelongsTo(() => User, "userId") - user: User; + user: User | null; @ForeignKey(() => User) @Column(DataType.UUID) - userId: string; + userId: string | null; @BelongsTo(() => Document, "documentId") - document: Document; + document: Document | null; @ForeignKey(() => Document) @Column(DataType.UUID) - documentId: string; + documentId: string | null; @BelongsTo(() => User, "actorId") actor: User; @@ -84,11 +84,11 @@ class Event extends IdModel { actorId: string; @BelongsTo(() => Collection, "collectionId") - collection: Collection; + collection: Collection | null; @ForeignKey(() => Collection) @Column(DataType.UUID) - collectionId: string; + collectionId: string | null; @BelongsTo(() => Team, "teamId") team: Team; diff --git a/server/routes/api/events.ts b/server/routes/api/events.ts deleted file mode 100644 index 52a3dd53d..000000000 --- a/server/routes/api/events.ts +++ /dev/null @@ -1,101 +0,0 @@ -import Router from "koa-router"; -import { Op, WhereOptions } from "sequelize"; -import auth from "@server/middlewares/authentication"; -import { Event, User, Collection } from "@server/models"; -import { authorize } from "@server/policies"; -import { presentEvent } from "@server/presenters"; -import { assertSort, assertUuid } from "@server/validation"; -import pagination from "./middlewares/pagination"; - -const router = new Router(); - -router.post("events.list", auth(), pagination(), async (ctx) => { - const { user } = ctx.state; - let { direction } = ctx.request.body; - const { - sort = "createdAt", - actorId, - documentId, - collectionId, - name, - auditLog = false, - } = ctx.request.body; - if (direction !== "ASC") { - direction = "DESC"; - } - assertSort(sort, Event); - - let where: WhereOptions = { - name: Event.ACTIVITY_EVENTS, - teamId: user.teamId, - }; - - if (actorId) { - assertUuid(actorId, "actorId must be a UUID"); - where = { ...where, actorId }; - } - - if (documentId) { - assertUuid(documentId, "documentId must be a UUID"); - where = { ...where, documentId }; - } - - if (auditLog) { - authorize(user, "manage", user.team); - where.name = Event.AUDIT_EVENTS; - } - - if (name && (where.name as string[]).includes(name)) { - where.name = name; - } - - if (collectionId) { - assertUuid(collectionId, "collection must be a UUID"); - where = { ...where, collectionId }; - - const collection = await Collection.scope({ - method: ["withMembership", user.id], - }).findByPk(collectionId); - authorize(user, "read", collection); - } else { - const collectionIds = await user.collectionIds({ - paranoid: false, - }); - where = { - ...where, - [Op.or]: [ - { - collectionId: collectionIds, - }, - { - collectionId: { - [Op.is]: null, - }, - }, - ], - }; - } - - const events = await Event.findAll({ - where, - order: [[sort, direction]], - include: [ - { - model: User, - as: "actor", - paranoid: false, - }, - ], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }); - - ctx.body = { - pagination: ctx.state.pagination, - data: await Promise.all( - events.map((event) => presentEvent(event, auditLog)) - ), - }; -}); - -export default router; diff --git a/server/routes/api/__snapshots__/events.test.ts.snap b/server/routes/api/events/__snapshots__/events.test.ts.snap similarity index 100% rename from server/routes/api/__snapshots__/events.test.ts.snap rename to server/routes/api/events/__snapshots__/events.test.ts.snap diff --git a/server/routes/api/events.test.ts b/server/routes/api/events/events.test.ts similarity index 100% rename from server/routes/api/events.test.ts rename to server/routes/api/events/events.test.ts diff --git a/server/routes/api/events/events.ts b/server/routes/api/events/events.ts new file mode 100644 index 000000000..089c89f9b --- /dev/null +++ b/server/routes/api/events/events.ts @@ -0,0 +1,102 @@ +import Router from "koa-router"; +import { Op, WhereOptions } from "sequelize"; +import auth from "@server/middlewares/authentication"; +import validate from "@server/middlewares/validate"; +import { Event, User, Collection } from "@server/models"; +import { authorize } from "@server/policies"; +import { presentEvent } from "@server/presenters"; +import { APIContext } from "@server/types"; +import pagination from "../middlewares/pagination"; +import * as T from "./schema"; + +const router = new Router(); + +router.post( + "events.list", + auth(), + pagination(), + validate(T.EventsListSchema), + async (ctx: APIContext) => { + const { user } = ctx.state; + const { + sort, + direction, + actorId, + documentId, + collectionId, + name, + auditLog, + } = ctx.input; + + let where: WhereOptions = { + name: Event.ACTIVITY_EVENTS, + teamId: user.teamId, + }; + + if (actorId) { + where = { ...where, actorId }; + } + + if (documentId) { + where = { ...where, documentId }; + } + + if (auditLog) { + authorize(user, "manage", user.team); + where.name = Event.AUDIT_EVENTS; + } + + if (name && (where.name as string[]).includes(name)) { + where.name = name; + } + + if (collectionId) { + where = { ...where, collectionId }; + + const collection = await Collection.scope({ + method: ["withMembership", user.id], + }).findByPk(collectionId); + authorize(user, "read", collection); + } else { + const collectionIds = await user.collectionIds({ + paranoid: false, + }); + where = { + ...where, + [Op.or]: [ + { + collectionId: collectionIds, + }, + { + collectionId: { + [Op.is]: null, + }, + }, + ], + }; + } + + const events = await Event.findAll({ + where, + order: [[sort, direction]], + include: [ + { + model: User, + as: "actor", + paranoid: false, + }, + ], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + ctx.body = { + pagination: ctx.state.pagination, + data: await Promise.all( + events.map((event) => presentEvent(event, auditLog)) + ), + }; + } +); + +export default router; diff --git a/server/routes/api/events/index.ts b/server/routes/api/events/index.ts new file mode 100644 index 000000000..0df91d8e7 --- /dev/null +++ b/server/routes/api/events/index.ts @@ -0,0 +1 @@ +export { default } from "./events"; diff --git a/server/routes/api/events/schema.ts b/server/routes/api/events/schema.ts new file mode 100644 index 000000000..9462a7061 --- /dev/null +++ b/server/routes/api/events/schema.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +export const EventsListSchema = z.object({ + /** Id of the user who performed the action */ + actorId: z.string().uuid().optional(), + + /** Id of the document to filter the events for */ + documentId: z.string().uuid().optional(), + + /** Id of the collection to filter the events for */ + collectionId: z.string().uuid().optional(), + + /** Whether to include audit events */ + auditLog: z.boolean().default(false), + + /** Name of the event to retrieve */ + name: z.string().optional(), + + /** The attribute to sort the events by */ + sort: z + .string() + .refine((val) => ["name", "createdAt"].includes(val)) + .default("createdAt"), + + /** The direction to sort the events */ + direction: z + .string() + .optional() + .transform((val) => (val !== "ASC" ? "DESC" : val)), +}); + +export type EventsListReq = z.infer;