diff --git a/app/models/Group.ts b/app/models/Group.ts index d59c20d217..328f308c10 100644 --- a/app/models/Group.ts +++ b/app/models/Group.ts @@ -10,6 +10,9 @@ class Group extends Model { @observable name: string; + @observable + externalId: string | undefined; + @observable memberCount: number; diff --git a/server/commands/groupCreator.ts b/server/commands/groupCreator.ts index 6151cd1b7b..052deaef4b 100644 --- a/server/commands/groupCreator.ts +++ b/server/commands/groupCreator.ts @@ -3,6 +3,7 @@ import { Event, Group, type User } from "@server/models"; type Props = { name: string; + externalId: string | undefined; actor: User; ip: string; transaction?: Transaction; @@ -10,6 +11,7 @@ type Props = { export default async function groupCreator({ name, + externalId, actor, ip, transaction, @@ -17,6 +19,7 @@ export default async function groupCreator({ const group = await Group.create( { name, + externalId, teamId: actor.teamId, createdById: actor.id, }, diff --git a/server/commands/groupUpdater.ts b/server/commands/groupUpdater.ts index 98ddb966fc..b694604212 100644 --- a/server/commands/groupUpdater.ts +++ b/server/commands/groupUpdater.ts @@ -3,7 +3,8 @@ import { Event, type Group, type User } from "@server/models"; type Props = { group: Group; - name: string; + name: string | undefined; + externalId: string | undefined; actor: User; ip: string; transaction?: Transaction; @@ -12,11 +13,17 @@ type Props = { export default async function groupUpdater({ group, name, + externalId, actor, ip, transaction, }: Props): Promise { - group.name = name; + if (name) { + group.name = name; + } + if (externalId) { + group.externalId = externalId; + } if (group.changed()) { await group.save({ transaction }); diff --git a/server/migrations/20241219023150-group-external-id.js b/server/migrations/20241219023150-group-external-id.js new file mode 100644 index 0000000000..b4f239a203 --- /dev/null +++ b/server/migrations/20241219023150-group-external-id.js @@ -0,0 +1,24 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async transaction => { + await queryInterface.addColumn("groups", "externalId", { + type: Sequelize.STRING, + }, { transaction }); + await queryInterface.addIndex("groups", ["externalId"], { transaction }); + await queryInterface.addIndex("group_permissions", ["documentId"], { transaction }); + await queryInterface.addIndex("group_permissions", ["sourceId"], { transaction }); + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.sequelize.transaction(async transaction => { + await queryInterface.removeIndex("group_permissions", ["sourceId"], { transaction }); + await queryInterface.removeIndex("group_permissions", ["documentId"], { transaction }); + await queryInterface.removeIndex("groups", ["externalId"], { transaction }); + await queryInterface.removeColumn("groups", "externalId", { transaction }); + }); + }, +}; diff --git a/server/models/Group.ts b/server/models/Group.ts index 5b69ad13c7..dee4873bf1 100644 --- a/server/models/Group.ts +++ b/server/models/Group.ts @@ -65,6 +65,9 @@ class Group extends ParanoidModel< @Column name: string; + @Column + externalId: string; + static filterByMember(userId: string | undefined) { return userId ? this.scope({ method: ["withMembership", userId] }) diff --git a/server/presenters/group.ts b/server/presenters/group.ts index 91a8057add..a7819c31f3 100644 --- a/server/presenters/group.ts +++ b/server/presenters/group.ts @@ -4,6 +4,7 @@ export default async function presentGroup(group: Group) { return { id: group.id, name: group.name, + externalId: group.externalId, memberCount: await group.memberCount, createdAt: group.createdAt, updatedAt: group.updatedAt, diff --git a/server/routes/api/groups/groups.test.ts b/server/routes/api/groups/groups.test.ts index a0a0a8d0a6..415e5f102a 100644 --- a/server/routes/api/groups/groups.test.ts +++ b/server/routes/api/groups/groups.test.ts @@ -12,11 +12,13 @@ describe("#groups.create", () => { body: { token: user.getJwtToken(), name, + externalId: "123", }, }); const body = await res.json(); expect(res.status).toEqual(200); expect(body.data.name).toEqual(name); + expect(body.data.externalId).toEqual("123"); }); }); @@ -67,12 +69,14 @@ describe("#groups.update", () => { teamId: user.teamId, }); }); + it("allows admin to edit a group", async () => { const res = await server.post("/api/groups.update", { body: { token: user.getJwtToken(), id: group.id, name: "Test", + externalId: "123", }, }); const events = await Event.findAll({ @@ -84,7 +88,9 @@ describe("#groups.update", () => { const body = await res.json(); expect(res.status).toEqual(200); expect(body.data.name).toBe("Test"); + expect(body.data.externalId).toBe("123"); }); + it("does not create an event if the update is a noop", async () => { const res = await server.post("/api/groups.update", { body: { @@ -103,6 +109,7 @@ describe("#groups.update", () => { expect(res.status).toEqual(200); expect(body.data.name).toBe(group.name); }); + it("fails with validation error when name already taken", async () => { await buildGroup({ teamId: user.teamId, @@ -275,6 +282,23 @@ describe("#groups.list", () => { expect(body.data.groups.length).toEqual(1); expect(body.data.groups[0].id).toEqual(group.id); }); + + it("should allow to find a group by its externalId", async () => { + const user = await buildUser(); + const group = await buildGroup({ teamId: user.teamId, externalId: "123" }); + await buildGroup({ teamId: user.teamId }); + + const res = await server.post("/api/groups.list", { + body: { + externalId: "123", + token: user.getJwtToken(), + }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.groups.length).toEqual(1); + expect(body.data.groups[0].id).toEqual(group.id); + }); }); describe("#groups.info", () => { diff --git a/server/routes/api/groups/groups.ts b/server/routes/api/groups/groups.ts index 623183b0c7..4764c52e01 100644 --- a/server/routes/api/groups/groups.ts +++ b/server/routes/api/groups/groups.ts @@ -31,7 +31,7 @@ router.post( pagination(), validate(T.GroupsListSchema), async (ctx: APIContext) => { - const { sort, direction, query, userId, name } = ctx.input.body; + const { sort, direction, query, userId, externalId, name } = ctx.input.body; const { user } = ctx.state.auth; authorize(user, "listGroups", user.team); @@ -55,6 +55,13 @@ router.post( }; } + if (externalId) { + where = { + ...where, + externalId, + }; + } + const groups = await Group.filterByMember(userId).findAll({ where, order: [[sort, direction]], @@ -110,18 +117,19 @@ router.post( router.post( "groups.create", - rateLimiter(RateLimiterStrategy.TenPerHour), + rateLimiter(RateLimiterStrategy.TenPerMinute), auth(), validate(T.GroupsCreateSchema), transaction(), async (ctx: APIContext) => { - const { name } = ctx.input.body; + const { name, externalId } = ctx.input.body; const { user } = ctx.state.auth; const { transaction } = ctx.state; authorize(user, "createGroup", user.team); const group = await groupCreator({ name, + externalId, actor: user, ip: ctx.request.ip, transaction, @@ -140,7 +148,7 @@ router.post( validate(T.GroupsUpdateSchema), transaction(), async (ctx: APIContext) => { - const { id, name } = ctx.input.body; + const { id, name, externalId } = ctx.input.body; const { user } = ctx.state.auth; const { transaction } = ctx.state; @@ -150,6 +158,7 @@ router.post( group = await groupUpdater({ group, name, + externalId, actor: user, ip: ctx.request.ip, transaction, diff --git a/server/routes/api/groups/schema.ts b/server/routes/api/groups/schema.ts index 4022282aee..74c6d0ac7a 100644 --- a/server/routes/api/groups/schema.ts +++ b/server/routes/api/groups/schema.ts @@ -13,7 +13,6 @@ export const GroupsListSchema = z.object({ .string() .optional() .transform((val) => (val !== "ASC" ? "DESC" : val)), - /** Groups sorting column */ sort: z .string() @@ -21,13 +20,12 @@ export const GroupsListSchema = z.object({ message: "Invalid sort parameter", }) .default("updatedAt"), - /** Only list groups where this user is a member */ userId: z.string().uuid().optional(), - + /** Find group matching externalId */ + externalId: z.string().optional(), /** @deprecated Find group with matching name */ name: z.string().optional(), - /** Find group matching query */ query: z.string().optional(), }), @@ -45,6 +43,8 @@ export const GroupsCreateSchema = z.object({ body: z.object({ /** Group name */ name: z.string(), + /** Optionally link this group to an external source. */ + externalId: z.string().optional(), }), }); @@ -53,7 +53,9 @@ export type GroupsCreateReq = z.infer; export const GroupsUpdateSchema = z.object({ body: BaseIdSchema.extend({ /** Group name */ - name: z.string(), + name: z.string().optional(), + /** Optionally link this group to an external source. */ + externalId: z.string().optional(), }), });