diff --git a/server/middlewares/transaction.ts b/server/middlewares/transaction.ts new file mode 100644 index 000000000..9291b3e9d --- /dev/null +++ b/server/middlewares/transaction.ts @@ -0,0 +1,25 @@ +import { Context, Next } from "koa"; +import { Transaction } from "sequelize"; +import { sequelize } from "@server/database/sequelize"; + +export type TransactionContext = Context & { + state: Context["state"] & { + transaction: Transaction; + }; +}; + +/** + * Middleware that wraps a route in a database transaction, useful for mutations + * The transaction is available on the context as `ctx.state.transaction` and + * should be passed to all database calls within the route. + * + * @returns The middleware function. + */ +export function transaction() { + return async function transactionMiddleware(ctx: Context, next: Next) { + await sequelize.transaction(async (t: Transaction) => { + ctx.state.transaction = t; + return next(); + }); + }; +} diff --git a/server/routes/api/collections.ts b/server/routes/api/collections.ts index 5a11723db..00b580069 100644 --- a/server/routes/api/collections.ts +++ b/server/routes/api/collections.ts @@ -12,6 +12,10 @@ import { sequelize } from "@server/database/sequelize"; import { AuthorizationError, ValidationError } from "@server/errors"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; +import { + TransactionContext, + transaction, +} from "@server/middlewares/transaction"; import { Collection, CollectionUser, @@ -357,96 +361,123 @@ router.post( } ); -router.post("collections.add_user", auth(), async (ctx) => { - const { id, userId, permission } = ctx.request.body; - assertUuid(id, "id is required"); - assertUuid(userId, "userId is required"); +router.post( + "collections.add_user", + auth(), + transaction(), + async (ctx: TransactionContext) => { + const { transaction } = ctx.state; + const { id, userId, permission } = ctx.request.body; + assertUuid(id, "id is required"); + assertUuid(userId, "userId is required"); - const collection = await Collection.scope({ - method: ["withMembership", ctx.state.user.id], - }).findByPk(id); - authorize(ctx.state.user, "update", collection); + const collection = await Collection.scope({ + method: ["withMembership", ctx.state.user.id], + }).findByPk(id); + authorize(ctx.state.user, "update", collection); - const user = await User.findByPk(userId); - authorize(ctx.state.user, "read", user); + const user = await User.findByPk(userId); + authorize(ctx.state.user, "read", user); - let membership = await CollectionUser.findOne({ - where: { - collectionId: id, - userId, - }, - }); - - if (userId === ctx.state.user.id) { - throw AuthorizationError("You cannot add yourself to a collection"); - } - - if (permission) { - assertCollectionPermission(permission); - } - - if (!membership) { - membership = await CollectionUser.create({ - collectionId: id, - userId, - permission: permission || user.defaultCollectionPermission, - createdById: ctx.state.user.id, + let membership = await CollectionUser.findOne({ + where: { + collectionId: id, + userId, + }, + transaction, + lock: transaction.LOCK.UPDATE, }); - } else if (permission) { - membership.permission = permission; - await membership.save(); + + if (userId === ctx.state.user.id) { + throw AuthorizationError("You cannot add yourself to a collection"); + } + + if (permission) { + assertCollectionPermission(permission); + } + + if (!membership) { + membership = await CollectionUser.create( + { + collectionId: id, + userId, + permission: permission || user.defaultCollectionPermission, + createdById: ctx.state.user.id, + }, + { + transaction, + } + ); + } else if (permission) { + membership.permission = permission; + await membership.save({ transaction }); + } + + await Event.create( + { + name: "collections.add_user", + userId, + collectionId: collection.id, + teamId: collection.teamId, + actorId: ctx.state.user.id, + data: { + name: user.name, + }, + ip: ctx.request.ip, + }, + { + transaction, + } + ); + + ctx.body = { + data: { + users: [presentUser(user)], + memberships: [presentMembership(membership)], + }, + }; } +); - await Event.create({ - name: "collections.add_user", - userId, - collectionId: collection.id, - teamId: collection.teamId, - actorId: ctx.state.user.id, - data: { - name: user.name, - }, - ip: ctx.request.ip, - }); +router.post( + "collections.remove_user", + auth(), + transaction(), + async (ctx: TransactionContext) => { + const { transaction } = ctx.state; + const { id, userId } = ctx.request.body; + assertUuid(id, "id is required"); + assertUuid(userId, "userId is required"); - ctx.body = { - data: { - users: [presentUser(user)], - memberships: [presentMembership(membership)], - }, - }; -}); + const collection = await Collection.scope({ + method: ["withMembership", ctx.state.user.id], + }).findByPk(id); + authorize(ctx.state.user, "update", collection); -router.post("collections.remove_user", auth(), async (ctx) => { - const { id, userId } = ctx.request.body; - assertUuid(id, "id is required"); - assertUuid(userId, "userId is required"); + const user = await User.findByPk(userId); + authorize(ctx.state.user, "read", user); - const collection = await Collection.scope({ - method: ["withMembership", ctx.state.user.id], - }).findByPk(id); - authorize(ctx.state.user, "update", collection); + await collection.$remove("user", user, { transaction }); + await Event.create( + { + name: "collections.remove_user", + userId, + collectionId: collection.id, + teamId: collection.teamId, + actorId: ctx.state.user.id, + data: { + name: user.name, + }, + ip: ctx.request.ip, + }, + { transaction } + ); - const user = await User.findByPk(userId); - authorize(ctx.state.user, "read", user); - - await collection.$remove("user", user); - await Event.create({ - name: "collections.remove_user", - userId, - collectionId: collection.id, - teamId: collection.teamId, - actorId: ctx.state.user.id, - data: { - name: user.name, - }, - ip: ctx.request.ip, - }); - - ctx.body = { - success: true, - }; -}); + ctx.body = { + success: true, + }; + } +); router.post("collections.memberships", auth(), pagination(), async (ctx) => { const { id, query, permission } = ctx.request.body;