mirror of
https://github.com/outline/outline.git
synced 2025-12-30 07:19:52 -06:00
Add externalId property on groups (#8127)
* Add 'externalId' property on groups * Remove clientside Field decorator * Allow querying by externalId
This commit is contained in:
@@ -10,6 +10,9 @@ class Group extends Model {
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@observable
|
||||
externalId: string | undefined;
|
||||
|
||||
@observable
|
||||
memberCount: number;
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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> {
|
||||
group.name = name;
|
||||
if (name) {
|
||||
group.name = name;
|
||||
}
|
||||
if (externalId) {
|
||||
group.externalId = externalId;
|
||||
}
|
||||
|
||||
if (group.changed()) {
|
||||
await group.save({ transaction });
|
||||
|
||||
24
server/migrations/20241219023150-group-external-id.js
Normal file
24
server/migrations/20241219023150-group-external-id.js
Normal file
@@ -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 });
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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] })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ router.post(
|
||||
pagination(),
|
||||
validate(T.GroupsListSchema),
|
||||
async (ctx: APIContext<T.GroupsListReq>) => {
|
||||
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<T.GroupsCreateReq>) => {
|
||||
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<T.GroupsUpdateReq>) => {
|
||||
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,
|
||||
|
||||
@@ -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<typeof GroupsCreateSchema>;
|
||||
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(),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user