From 406e54e84fce2e365e2085da006d643d2a9cda53 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 15 Oct 2023 23:11:52 +0800 Subject: [PATCH] feat(core): get organizations for user api --- packages/core/src/queries/organizations.ts | 71 ++++++++++++++++++- packages/core/src/routes/admin-user/index.ts | 2 + .../src/routes/admin-user/organization.ts | 30 ++++++++ packages/core/src/utils/SchemaRouter.ts | 2 +- .../integration-tests/src/api/organization.ts | 10 +++ .../src/tests/api/organization.test.ts | 50 +++++++++++++ 6 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 packages/core/src/routes/admin-user/organization.ts diff --git a/packages/core/src/queries/organizations.ts b/packages/core/src/queries/organizations.ts index 30ad9db0a..b5b220110 100644 --- a/packages/core/src/queries/organizations.ts +++ b/packages/core/src/queries/organizations.ts @@ -10,11 +10,78 @@ import { OrganizationUserRelations, OrganizationRoleUserRelations, } from '@logto/schemas'; -import { type CommonQueryMethods } from 'slonik'; +import { convertToIdentifiers } from '@logto/shared'; +import { sql, type CommonQueryMethods } from 'slonik'; +import { z } from 'zod'; import RelationQueries from '#src/utils/RelationQueries.js'; import SchemaQueries from '#src/utils/SchemaQueries.js'; +/** + * The simplified organization role entity that is returned in the `roles` field + * of the organization. + * + * @remarks + * The type MUST be kept in sync with the query in {@link UserRelationQueries.getOrganizationsByUserId}. + */ +type RoleEntity = { + id: string; + name: string; +}; + +/** + * The organization entity with the `roles` field that contains the roles of the + * current member of the organization. + */ +type OrganizationWithRoles = Organization & { + /** The roles of the current member of the organization. */ + roles: RoleEntity[]; +}; + +export const organizationWithRolesGuard: z.ZodType = + Organizations.guard.extend({ + roles: z + .object({ + id: z.string(), + name: z.string(), + }) + .array(), + }); + +class UserRelationQueries extends RelationQueries<[typeof Organizations, typeof Users]> { + constructor(pool: CommonQueryMethods) { + super(pool, OrganizationUserRelations.table, Organizations, Users); + } + + async getOrganizationsByUserId(userId: string): Promise> { + const organizationRoles = convertToIdentifiers(OrganizationRoles, true); + const organizations = convertToIdentifiers(Organizations, true); + const { fields } = convertToIdentifiers(OrganizationUserRelations, true); + const oruRelations = convertToIdentifiers(OrganizationRoleUserRelations, true); + + return this.pool.any(sql` + select + ${organizations.table}.*, + json_agg( + json_build_object( + 'id', ${organizationRoles.fields.id}, + 'name', ${organizationRoles.fields.name}) + ) + as roles + from ${this.table} + join ${organizations.table} + on ${fields.organizationId} = ${organizations.fields.id} + left join ${oruRelations.table} + on ${fields.userId} = ${oruRelations.fields.userId} + and ${fields.organizationId} = ${oruRelations.fields.organizationId} + left join ${organizationRoles.table} + on ${oruRelations.fields.organizationRoleId} = ${organizationRoles.fields.id} + where ${fields.userId} = ${userId} + group by ${organizations.table}.id + `); + } +} + export default class OrganizationQueries extends SchemaQueries< OrganizationKeys, CreateOrganization, @@ -35,7 +102,7 @@ export default class OrganizationQueries extends SchemaQueries< OrganizationScopes ), /** Queries for organization - user relations. */ - users: new RelationQueries(this.pool, OrganizationUserRelations.table, Organizations, Users), + users: new UserRelationQueries(this.pool), /** Queries for organization - organization role - user relations. */ rolesUsers: new RelationQueries( this.pool, diff --git a/packages/core/src/routes/admin-user/index.ts b/packages/core/src/routes/admin-user/index.ts index 0951b8c03..d83acda33 100644 --- a/packages/core/src/routes/admin-user/index.ts +++ b/packages/core/src/routes/admin-user/index.ts @@ -1,6 +1,7 @@ import type { AuthedRouter, RouterInitArgs } from '../types.js'; import adminUserBasicsRoutes from './basics.js'; +import adminUserOrganizationRoutes from './organization.js'; import adminUserRoleRoutes from './role.js'; import adminUserSearchRoutes from './search.js'; import adminUserSocialRoutes from './social.js'; @@ -10,4 +11,5 @@ export default function adminUserRoutes(...args: RouterI adminUserRoleRoutes(...args); adminUserSearchRoutes(...args); adminUserSocialRoutes(...args); + adminUserOrganizationRoutes(...args); } diff --git a/packages/core/src/routes/admin-user/organization.ts b/packages/core/src/routes/admin-user/organization.ts new file mode 100644 index 000000000..6715d55c6 --- /dev/null +++ b/packages/core/src/routes/admin-user/organization.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import koaGuard from '#src/middleware/koa-guard.js'; +import { organizationWithRolesGuard } from '#src/queries/organizations.js'; + +import { type AuthedRouter, type RouterInitArgs } from '../types.js'; + +export default function adminUserOrganizationRoutes( + ...[router, { queries }]: RouterInitArgs +) { + router.get( + '/users/:userId/organizations', + koaGuard({ + params: z.object({ userId: z.string() }), + response: organizationWithRolesGuard.array(), + status: [200, 404], + }), + async (ctx, next) => { + const { userId } = ctx.guard.params; + + // Ensure that the user exists. + await queries.users.findUserById(userId); + + // No pagination for now since it aligns with the current issuing of organizations + // to ID tokens. + ctx.body = await queries.organizations.relations.users.getOrganizationsByUserId(userId); + return next(); + } + ); +} diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index d4439dff1..62896d52a 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -252,7 +252,7 @@ export default class SchemaRouter< * * The routes are: * - * - `GET /:id/[pathname]`: Get the entities of the relation. + * - `GET /:id/[pathname]`: Get the entities of the relation with pagination. * - `POST /:id/[pathname]`: Add entities to the relation. * - `DELETE /:id/[pathname]/:relationSchemaId`: Remove an entity from the relation set. * The `:relationSchemaId` is the entity ID in the relation schema. diff --git a/packages/integration-tests/src/api/organization.ts b/packages/integration-tests/src/api/organization.ts index d71a4cd55..d654b7ae6 100644 --- a/packages/integration-tests/src/api/organization.ts +++ b/packages/integration-tests/src/api/organization.ts @@ -3,6 +3,12 @@ import { type Role, type Organization } from '@logto/schemas'; import { authedAdminApi } from './api.js'; import { ApiFactory } from './factory.js'; +type RoleEntity = { + id: string; + name: string; +}; +type OrganizationWithRoles = Organization & { roles: RoleEntity[] }; + class OrganizationApi extends ApiFactory { constructor() { super('organizations'); @@ -31,6 +37,10 @@ class OrganizationApi extends ApiFactory { await authedAdminApi.delete(`${this.path}/${id}/users/${userId}/roles/${roleId}`); } + + async getUserOrganizations(userId: string): Promise { + return authedAdminApi.get(`users/${userId}/organizations`).json(); + } } /** API methods for operating organizations. */ diff --git a/packages/integration-tests/src/tests/api/organization.test.ts b/packages/integration-tests/src/tests/api/organization.test.ts index 608469f33..231f111be 100644 --- a/packages/integration-tests/src/tests/api/organization.test.ts +++ b/packages/integration-tests/src/tests/api/organization.test.ts @@ -158,5 +158,55 @@ describe('organization APIs', () => { roleApi.delete(role2.id), ]); }); + + it('should be able to get all organizations with roles for a user', async () => { + const [organization1, organization2] = await Promise.all([ + organizationApi.create({ name: 'test' }), + organizationApi.create({ name: 'test' }), + ]); + const user = await createUser({ username: 'test' + randomId() }); + const [role1, role2] = await Promise.all([ + roleApi.create({ name: 'test' + randomId() }), + roleApi.create({ name: 'test' + randomId() }), + ]); + + await organizationApi.addUsers(organization1.id, [user.id]); + await organizationApi.addUserRoles(organization1.id, user.id, [role1.id]); + await organizationApi.addUsers(organization2.id, [user.id]); + await organizationApi.addUserRoles(organization2.id, user.id, [role1.id, role2.id]); + + const organizations = await organizationApi.getUserOrganizations(user.id); + + // Check organization 1 and ensure it only has role 1 + const organization1WithRoles = organizations.find((org) => org.id === organization1.id); + assert(organization1WithRoles); + expect(organization1WithRoles.id).toBe(organization1.id); + expect(organization1WithRoles.roles).toContainEqual( + expect.objectContaining({ id: role1.id }) + ); + expect(organization1WithRoles.roles).not.toContainEqual( + expect.objectContaining({ id: role2.id }) + ); + + // Check organization 2 and ensure it has both role 1 and role 2 + const organization2WithRoles = organizations.find((org) => org.id === organization2.id); + assert(organization2WithRoles); + expect(organization2WithRoles.id).toBe(organization2.id); + expect(organization2WithRoles.roles).toContainEqual( + expect.objectContaining({ id: role1.id }) + ); + expect(organization2WithRoles.roles).toContainEqual( + expect.objectContaining({ id: role2.id }) + ); + + // Clean up + await Promise.all([ + organizationApi.delete(organization1.id), + organizationApi.delete(organization2.id), + deleteUser(user.id), + roleApi.delete(role1.id), + roleApi.delete(role2.id), + ]); + }); }); });