diff --git a/front/admin/db.ts b/front/admin/db.ts index 97534959b7520..ee920bdee0aab 100644 --- a/front/admin/db.ts +++ b/front/admin/db.ts @@ -65,6 +65,7 @@ import { } from "@app/lib/models/workspace"; import { ContentFragmentModel } from "@app/lib/resources/storage/models/content_fragment"; import { FileModel } from "@app/lib/resources/storage/models/files"; +import { GroupMembershipModel } from "@app/lib/resources/storage/models/group_memberships"; import { GroupModel } from "@app/lib/resources/storage/models/groups"; import { KeyModel } from "@app/lib/resources/storage/models/keys"; // Labs - Can be removed at all times if a solution is dropped @@ -92,6 +93,7 @@ async function main() { await MembershipModel.sync({ alter: true }); await MembershipInvitation.sync({ alter: true }); await GroupModel.sync({ alter: true }); + await GroupMembershipModel.sync({ alter: true }); await App.sync({ alter: true }); await Dataset.sync({ alter: true }); diff --git a/front/lib/resources/group_resource.ts b/front/lib/resources/group_resource.ts index bbfd9b3deee0c..a858247bb6678 100644 --- a/front/lib/resources/group_resource.ts +++ b/front/lib/resources/group_resource.ts @@ -9,9 +9,13 @@ import type { import type { Authenticator } from "@app/lib/auth"; import { BaseResource } from "@app/lib/resources/base_resource"; +import { MembershipResource } from "@app/lib/resources/membership_resource"; +import { GroupMembershipModel } from "@app/lib/resources/storage/models/group_memberships"; import { GroupModel } from "@app/lib/resources/storage/models/groups"; import type { ReadonlyAttributesType } from "@app/lib/resources/storage/types"; import { getResourceIdFromSId, makeSId } from "@app/lib/resources/string_ids"; +import { UserResource } from "@app/lib/resources/user_resource"; +import { renderLightWorkspaceType } from "@app/lib/workspace"; // Attributes are marked as read-only to reflect the stateless nature of our Resource. // This design will be moved up to BaseResource once we transition away from Sequelize. @@ -177,6 +181,51 @@ export class GroupResource extends BaseResource { return new this(GroupModel, group.get()); } + static async addMember( + auth: Authenticator, + groupId: string, + userId: string, + transaction?: Transaction + ): Promise { + const owner = auth.getNonNullableWorkspace(); + const [group, user] = await Promise.all([ + this.fetchById(auth, groupId), + UserResource.fetchById(userId), + ]); + + if (!group) { + throw new Error("Group not found."); + } + if (group.type !== "regular") { + throw new Error("Cannot add members to non-regular groups."); + } + if (!user) { + throw new Error("User not found."); + } + + const workspace = renderLightWorkspaceType({ workspace: owner }); + const membership = + await MembershipResource.getActiveMembershipOfUserInWorkspace({ + user, + workspace, + transaction, + }); + + if (!membership) { + throw new Error("User is not a member of the workspace."); + } + + await GroupMembershipModel.create( + { + groupId: group.id, + userId: user.id, + workspaceId: owner.id, + startAt: new Date(), + }, + { transaction } + ); + } + toJSON() { return { id: this.id, diff --git a/front/lib/resources/storage/models/group_memberships.ts b/front/lib/resources/storage/models/group_memberships.ts new file mode 100644 index 0000000000000..be6a817edac62 --- /dev/null +++ b/front/lib/resources/storage/models/group_memberships.ts @@ -0,0 +1,112 @@ +import type { + CreationOptional, + ForeignKey, + InferAttributes, + InferCreationAttributes, +} from "sequelize"; +import { DataTypes, Model, Op } from "sequelize"; + +import { User } from "@app/lib/models/user"; +import { Workspace } from "@app/lib/models/workspace"; +import { frontSequelize } from "@app/lib/resources/storage"; +import { GroupModel } from "@app/lib/resources/storage/models/groups"; + +export class GroupMembershipModel extends Model< + InferAttributes, + InferCreationAttributes +> { + declare id: CreationOptional; + declare createdAt: CreationOptional; + declare updatedAt: CreationOptional; + + declare startAt: Date; + declare endAt: Date | null; + + declare groupId: ForeignKey; + declare userId: ForeignKey; + declare workspaceId: ForeignKey; + + static async checkOverlap( + userId: number, + groupId: number, + startAt: Date, + endAt: Date | null, + excludeId?: number + ) { + const where: any = { + userId, + groupId, + [Op.or]: [{ endAt: null }, { endAt: { [Op.gt]: startAt } }], + startAt: { [Op.lt]: endAt || new Date(8640000000000000) }, // Max date if endAt is null + }; + + if (excludeId) { + where.id = { [Op.ne]: excludeId }; + } + + const overlapping = await this.findOne({ where }); + return !!overlapping; + } +} +GroupMembershipModel.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updatedAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + startAt: { + type: DataTypes.DATE, + allowNull: false, + }, + endAt: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + modelName: "group_memberships", + sequelize: frontSequelize, + indexes: [{ fields: ["userId", "groupId"] }], + validate: { + async noOverlap() { + if ( + await GroupMembershipModel.checkOverlap( + this.userId as number, + this.groupId as number, + this.startAt as Date, + this.endAt as Date | null, + this.id as number + ) + ) { + throw new Error("Overlapping group membership period"); + } + }, + }, + } +); +User.hasMany(GroupMembershipModel, { + foreignKey: { allowNull: false }, + onDelete: "CASCADE", +}); +GroupModel.hasMany(GroupMembershipModel, { + foreignKey: { allowNull: false }, + onDelete: "CASCADE", +}); +Workspace.hasMany(GroupMembershipModel, { + foreignKey: { allowNull: false }, + onDelete: "CASCADE", +}); +GroupMembershipModel.belongsTo(User); +GroupMembershipModel.belongsTo(GroupModel); +GroupMembershipModel.belongsTo(Workspace); diff --git a/front/migrations/db/migration_47.sql b/front/migrations/db/migration_47.sql new file mode 100644 index 0000000000000..f2235847c8683 --- /dev/null +++ b/front/migrations/db/migration_47.sql @@ -0,0 +1,13 @@ +-- Migration created on Jul 25, 2024 +CREATE TABLE IF NOT EXISTS "group_memberships" ( + "id" SERIAL , + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "startAt" TIMESTAMP WITH TIME ZONE NOT NULL, + "endAt" TIMESTAMP WITH TIME ZONE, + "userId" INTEGER NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "groupId" INTEGER NOT NULL REFERENCES "groups" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + "workspaceId" INTEGER NOT NULL REFERENCES "workspaces" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + PRIMARY KEY ("id") +); +CREATE INDEX "group_memberships_user_id_group_id" ON "group_memberships" ("userId", "groupId");