diff --git a/packages/core/src/queries/application.ts b/packages/core/src/queries/application.ts index d09153883..660351fc5 100644 --- a/packages/core/src/queries/application.ts +++ b/packages/core/src/queries/application.ts @@ -1,5 +1,6 @@ import type { Application, CreateApplication } from '@logto/schemas'; import { ApplicationType, Applications, SearchJointMode } from '@logto/schemas'; +import { pick } from '@silverhand/essentials'; import type { CommonQueryMethods, SqlSqlToken } from '@silverhand/slonik'; import { sql } from '@silverhand/slonik'; @@ -23,22 +24,31 @@ import { const { table, fields } = convertToIdentifiers(Applications); -const buildApplicationConditions = (search: Search) => { - const hasSearch = search.matches.length > 0; - const searchFields = [ - Applications.fields.id, - Applications.fields.name, - Applications.fields.description, - ]; +/** + * The schema field keys that can be used for searching apps. For the actual field names, + * see {@link Applications.fields} and {@link applicationSearchFields}. + */ +export const applicationSearchKeys = Object.freeze(['id', 'name', 'description'] satisfies Array< + keyof Application +>); +/** + * The actual database field names that can be used for searching apps. For the schema field + * keys, see {@link userSearchKeys}. + */ +const applicationSearchFields = Object.freeze( + Object.values(pick(Applications.fields, ...applicationSearchKeys)) +); + +const buildApplicationConditions = (search: Search) => { return conditionalSql( - hasSearch, + search.matches.length > 0, () => /** * Avoid specifying the DB column type when calling the API (which is meaningless). * Should specify the DB column type of enum type. */ - sql`${buildConditionsFromSearch(search, searchFields)}` + sql`${buildConditionsFromSearch(search, applicationSearchFields)}` ); }; diff --git a/packages/core/src/queries/organization/application-relations.ts b/packages/core/src/queries/organization/application-relations.ts new file mode 100644 index 000000000..8d4df5624 --- /dev/null +++ b/packages/core/src/queries/organization/application-relations.ts @@ -0,0 +1,70 @@ +import { + Organizations, + Applications, + OrganizationApplicationRelations, + type Application, + OrganizationRoles, + OrganizationRoleApplicationRelations, + type ApplicationWithOrganizationRoles, +} from '@logto/schemas'; +import { type CommonQueryMethods, sql } from '@silverhand/slonik'; + +import { type SearchOptions, buildSearchSql } from '#src/database/utils.js'; +import { TwoRelationsQueries, type GetEntitiesOptions } from '#src/utils/RelationQueries.js'; +import { convertToIdentifiers } from '#src/utils/sql.js'; + +import { type applicationSearchKeys } from '../application.js'; + +import { aggregateRoles } from './utils.js'; + +export class ApplicationRelationQueries extends TwoRelationsQueries< + typeof Organizations, + typeof Applications +> { + constructor(pool: CommonQueryMethods) { + super(pool, OrganizationApplicationRelations.table, Organizations, Applications); + } + + /** Get the applications of an organization with their organization roles. */ + async getApplicationsByOrganizationId( + organizationId: string, + { limit, offset }: GetEntitiesOptions, + search?: SearchOptions<(typeof applicationSearchKeys)[number]> + ): Promise<[totalCount: number, applications: readonly Application[]]> { + const roles = convertToIdentifiers(OrganizationRoles, true); + const applications = convertToIdentifiers(Applications, true); + const { fields } = convertToIdentifiers(OrganizationApplicationRelations, true); + const relations = convertToIdentifiers(OrganizationRoleApplicationRelations, true); + + const [{ count }, entities] = await Promise.all([ + this.pool.one<{ count: string }>(sql` + select count(*) + from ${this.table} + left join ${applications.table} + on ${fields.applicationId} = ${applications.fields.id} + where ${fields.organizationId} = ${organizationId} + ${buildSearchSql(Applications, search, sql`and `)} + `), + this.pool.any(sql` + select + ${sql.join(Object.values(applications.fields), sql`, `)}, + ${aggregateRoles()} + from ${this.table} + left join ${applications.table} + on ${fields.applicationId} = ${applications.fields.id} + left join ${relations.table} + on ${relations.fields.applicationId} = ${applications.fields.id} + and ${relations.fields.organizationId} = ${fields.organizationId} + left join ${roles.table} + on ${relations.fields.organizationRoleId} = ${roles.fields.id} + where ${fields.organizationId} = ${organizationId} + ${buildSearchSql(Applications, search, sql`and `)} + group by ${applications.fields.id} + limit ${limit} + offset ${offset} + `), + ]); + + return [Number(count), entities]; + } +} diff --git a/packages/core/src/queries/organization/index.ts b/packages/core/src/queries/organization/index.ts index 1d4d161c0..6897db46b 100644 --- a/packages/core/src/queries/organization/index.ts +++ b/packages/core/src/queries/organization/index.ts @@ -22,8 +22,6 @@ import { Resources, Users, OrganizationJitRoles, - OrganizationApplicationRelations, - Applications, } from '@logto/schemas'; import { sql, type CommonQueryMethods } from '@silverhand/slonik'; @@ -32,6 +30,7 @@ import { TwoRelationsQueries } from '#src/utils/RelationQueries.js'; import SchemaQueries from '#src/utils/SchemaQueries.js'; import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js'; +import { ApplicationRelationQueries } from './application-relations.js'; import { ApplicationRoleRelationQueries } from './application-role-relations.js'; import { EmailDomainQueries } from './email-domains.js'; import { SsoConnectorQueries } from './sso-connectors.js'; @@ -288,12 +287,7 @@ export default class OrganizationQueries extends SchemaQueries< /** Queries for organization - organization role - user relations. */ usersRoles: new UserRoleRelationQueries(this.pool), /** Queries for organization - application relations. */ - apps: new TwoRelationsQueries( - this.pool, - OrganizationApplicationRelations.table, - Organizations, - Applications - ), + apps: new ApplicationRelationQueries(this.pool), /** Queries for organization - organization role - application relations. */ appsRoles: new ApplicationRoleRelationQueries(this.pool), invitationsRoles: new TwoRelationsQueries( diff --git a/packages/core/src/queries/organization/user-relations.ts b/packages/core/src/queries/organization/user-relations.ts index 142783a0a..75bfe7d4a 100644 --- a/packages/core/src/queries/organization/user-relations.ts +++ b/packages/core/src/queries/organization/user-relations.ts @@ -7,6 +7,7 @@ import { type OrganizationWithRoles, type UserWithOrganizationRoles, type FeaturedUser, + userInfoSelectFields, } from '@logto/schemas'; import { sql, type CommonQueryMethods } from '@silverhand/slonik'; @@ -16,6 +17,8 @@ import { convertToIdentifiers } from '#src/utils/sql.js'; import { type userSearchKeys } from '../user.js'; +import { aggregateRoles } from './utils.js'; + /** The query class for the organization - user relation. */ export class UserRelationQueries extends TwoRelationsQueries { constructor(pool: CommonQueryMethods) { @@ -81,7 +84,7 @@ export class UserRelationQueries extends TwoRelationsQueries(sql` select ${expandFields(Organizations, true)}, - ${this.#aggregateRoles()} + ${aggregateRoles()} from ${this.table} left join ${organizations.table} on ${fields.organizationId} = ${organizations.fields.id} @@ -95,7 +98,7 @@ export class UserRelationQueries extends TwoRelationsQueries(sql` select - ${users.table}.*, - ${this.#aggregateRoles()} + ${sql.join( + userInfoSelectFields.map((field) => users.fields[field]), + sql`, ` + )}, + ${aggregateRoles()} from ${this.table} left join ${users.table} on ${fields.userId} = ${users.fields.id} left join ${relations.table} - on ${fields.userId} = ${relations.fields.userId} + on ${relations.fields.userId} = ${users.fields.id} and ${fields.organizationId} = ${relations.fields.organizationId} left join ${roles.table} on ${relations.fields.organizationRoleId} = ${roles.fields.id} where ${fields.organizationId} = ${organizationId} ${buildSearchSql(Users, search, sql`and `)} - group by ${users.table}.id + group by ${users.fields.id} limit ${limit} offset ${offset} `), @@ -137,26 +143,4 @@ export class UserRelationQueries extends TwoRelationsQueries { + const roles = convertToIdentifiers(OrganizationRoles, true); + + return sql` + coalesce( + json_agg( + json_build_object( + 'id', ${roles.fields.id}, + 'name', ${roles.fields.name} + ) order by ${roles.fields.name} + ) filter (where ${roles.fields.id} is not null), -- left join could produce nulls as roles + '[]' + ) as ${sql.identifier([as])} + `; +}; diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index a7c6a556c..4adc9ccd4 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -51,7 +51,7 @@ export const userSearchKeys = Object.freeze([ 'primaryPhone', 'username', 'name', -] as const); +] satisfies Array); /** * The actual database field names that can be used for searching users. For the schema field diff --git a/packages/core/src/routes/organization/application/index.ts b/packages/core/src/routes/organization/application/index.ts index 63da85806..7a02bb6af 100644 --- a/packages/core/src/routes/organization/application/index.ts +++ b/packages/core/src/routes/organization/application/index.ts @@ -1,8 +1,18 @@ -import { type OrganizationKeys, type CreateOrganization, type Organization } from '@logto/schemas'; +import { + type OrganizationKeys, + type CreateOrganization, + type Organization, + applicationWithOrganizationRolesGuard, +} from '@logto/schemas'; +import { z } from 'zod'; import { EnvSet } from '#src/env-set/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import { applicationSearchKeys } from '#src/queries/application.js'; import type OrganizationQueries from '#src/queries/organization/index.js'; import type SchemaRouter from '#src/utils/SchemaRouter.js'; +import { parseSearchOptions } from '#src/utils/search.js'; import applicationRoleRelationRoutes from './role-relations.js'; @@ -14,9 +24,36 @@ export default function applicationRoutes( if (EnvSet.values.isDevFeaturesEnabled) { // MARK: Organization - application relation routes router.addRelationRoutes(organizations.relations.apps, undefined, { + disabled: { get: true }, hookEvent: 'Organization.Membership.Updated', }); + router.get( + '/:id/applications', + koaPagination(), + koaGuard({ + query: z.object({ q: z.string().optional() }), + params: z.object({ id: z.string().min(1) }), + response: applicationWithOrganizationRolesGuard.array(), + status: [200, 404], + }), + async (ctx, next) => { + const search = parseSearchOptions(applicationSearchKeys, ctx.guard.query); + + const [totalCount, entities] = + await organizations.relations.apps.getApplicationsByOrganizationId( + ctx.guard.params.id, + ctx.pagination, + search + ); + + ctx.pagination.totalCount = totalCount; + ctx.body = entities; + + return next(); + } + ); + // MARK: Organization - application role relation routes applicationRoleRelationRoutes(router, organizations); } diff --git a/packages/core/src/routes/organization/user/index.openapi.json b/packages/core/src/routes/organization/user/index.openapi.json index 174469303..c1396516c 100644 --- a/packages/core/src/routes/organization/user/index.openapi.json +++ b/packages/core/src/routes/organization/user/index.openapi.json @@ -182,7 +182,7 @@ } } }, - "/api/organizations/{id}/users/{userId}/roles/{roleId}": { + "/api/organizations/{id}/users/{userId}/roles/{organizationRoleId}": { "delete": { "summary": "Remove a role from a user in an organization", "description": "Remove a role assignment from a user in the specified organization.", diff --git a/packages/core/src/routes/organization/user/role-relations.ts b/packages/core/src/routes/organization/user/role-relations.ts index 67ace52d1..597592c3c 100644 --- a/packages/core/src/routes/organization/user/role-relations.ts +++ b/packages/core/src/routes/organization/user/role-relations.ts @@ -106,17 +106,17 @@ export default function userRoleRelationRoutes( ); router.delete( - `${pathname}/:roleId`, + `${pathname}/:organizationRoleId`, koaGuard({ - params: z.object({ ...params, roleId: z.string().min(1) }), + params: z.object({ ...params, organizationRoleId: z.string().min(1) }), status: [204, 422, 404], }), async (ctx, next) => { - const { id, roleId, userId } = ctx.guard.params; + const { id, organizationRoleId, userId } = ctx.guard.params; await organizations.relations.usersRoles.delete({ organizationId: id, - organizationRoleId: roleId, + organizationRoleId, userId, }); diff --git a/packages/integration-tests/src/api/factory.ts b/packages/integration-tests/src/api/factory.ts index 2a619ab2d..ba5dcd71d 100644 --- a/packages/integration-tests/src/api/factory.ts +++ b/packages/integration-tests/src/api/factory.ts @@ -86,8 +86,13 @@ export class RelationApiFactory> return this.config.relationKey; } - async getList(id: string, page?: number, pageSize?: number): Promise { - const searchParams = new URLSearchParams(); + async getList( + id: string, + page?: number, + pageSize?: number, + extraParams?: ConstructorParameters[0] + ): Promise { + const searchParams = new URLSearchParams(extraParams); if (page) { searchParams.append('page', String(page)); diff --git a/packages/integration-tests/src/tests/api/organization/organization-application.test.ts b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts index 36ea50566..ae1f949a4 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-application.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-application.test.ts @@ -1,6 +1,10 @@ import assert from 'node:assert'; -import { ApplicationType, type Application } from '@logto/schemas'; +import { + ApplicationType, + type ApplicationWithOrganizationRoles, + type Application, +} from '@logto/schemas'; import { HTTPError } from 'ky'; import { @@ -12,6 +16,82 @@ import { devFeatureTest, generateTestName } from '#src/utils.js'; // TODO: Remove this prefix devFeatureTest.describe('organization application APIs', () => { + describe('organization get applications', () => { + const organizationApi = new OrganizationApiTest(); + const applications: Application[] = []; + const createApplication = async (...args: Parameters) => { + const created = await createApplicationApi(...args); + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + applications.push(created); + return created; + }; + + beforeAll(async () => { + const organization = await organizationApi.create({ name: 'test' }); + const createdApplications = await Promise.all( + Array.from({ length: 30 }).map(async () => + createApplication(generateTestName(), ApplicationType.MachineToMachine) + ) + ); + await organizationApi.applications.add( + organization.id, + createdApplications.map(({ id }) => id) + ); + }); + + afterAll(async () => { + await Promise.all([ + organizationApi.cleanUp(), + // eslint-disable-next-line @typescript-eslint/no-empty-function + ...applications.map(async ({ id }) => deleteApplication(id).catch(() => {})), + ]); + }); + + it('should be able to get organization applications with pagination', async () => { + const organizationId = organizationApi.organizations[0]!.id; + const fetchedApps1 = await organizationApi.applications.getList(organizationId, 1, 20); + const fetchedApps2 = await organizationApi.applications.getList(organizationId, 2, 10); + expect(fetchedApps2.length).toBe(10); + expect(fetchedApps2[0]?.id).not.toBeFalsy(); + expect(fetchedApps2[0]?.id).toBe(fetchedApps1[10]?.id); + }); + + it('should be able to get organization applications with search', async () => { + const organizationId = organizationApi.organizations[0]!.id; + const fetchedApps = await organizationApi.applications.getList(organizationId, 1, 20, { + q: applications[0]!.name, + }); + expect(fetchedApps.length).toBe(1); + expect(fetchedApps[0]!.id).toBe(applications[0]!.id); + expect(fetchedApps[0]).toMatchObject(applications[0]!); + }); + + it('should be able to get organization applications with their roles', async () => { + const organizationId = organizationApi.organizations[0]!.id; + const app = applications[0]!; + const roles = await Promise.all([ + organizationApi.roleApi.create({ name: generateTestName() }), + organizationApi.roleApi.create({ name: generateTestName() }), + ]); + const roleIds = roles.map(({ id }) => id); + await organizationApi.addApplicationRoles(organizationId, app.id, roleIds); + + const [fetchedApp] = await organizationApi.applications.getList( + organizationId, + undefined, + undefined, + { + q: app.name, + } + ); + expect(fetchedApp).toMatchObject(app); + expect((fetchedApp as ApplicationWithOrganizationRoles).organizationRoles).toHaveLength(2); + expect((fetchedApp as ApplicationWithOrganizationRoles).organizationRoles).toEqual( + expect.arrayContaining(roles.map(({ id }) => expect.objectContaining({ id }))) + ); + }); + }); + describe('organization - application relations', () => { const organizationApi = new OrganizationApiTest(); const applications: Application[] = []; @@ -57,14 +137,16 @@ devFeatureTest.describe('organization application APIs', () => { ); await organizationApi.applications.add(organization.id, [application.id]); - expect(await organizationApi.applications.getList(organization.id)).toContainEqual( - application - ); + expect(await organizationApi.applications.getList(organization.id)).toContainEqual({ + ...application, + organizationRoles: [], + }); await organizationApi.applications.delete(organization.id, application.id); - expect(await organizationApi.applications.getList(organization.id)).not.toContainEqual( - application - ); + expect(await organizationApi.applications.getList(organization.id)).not.toContainEqual({ + ...application, + organizationRoles: [], + }); }); it('should fail when try to delete application from an organization that does not exist', async () => { diff --git a/packages/integration-tests/src/tests/api/organization/organization-user.test.ts b/packages/integration-tests/src/tests/api/organization/organization-user.test.ts index 3154f487d..a7d882db3 100644 --- a/packages/integration-tests/src/tests/api/organization/organization-user.test.ts +++ b/packages/integration-tests/src/tests/api/organization/organization-user.test.ts @@ -36,14 +36,14 @@ describe('organization user APIs', () => { page: 2, page_size: 10, }); - expect(users2.length).toBeGreaterThanOrEqual(10); + expect(users2.length).toBe(10); expect(users2[0]?.id).not.toBeFalsy(); expect(users2[0]?.id).toBe(users1[10]?.id); expect(total1).toBe(30); expect(total2).toBe(30); }); - it('should be able to get organization users with search keyword', async () => { + it('should be able to get organization users with search', async () => { const organizationId = organizationApi.organizations[0]!.id; const username = generateTestName(); const createdUser = await userApi.create({ username }); @@ -73,11 +73,8 @@ describe('organization user APIs', () => { expect(usersWithRoles).toHaveLength(1); expect(usersWithRoles[0]).toMatchObject(user); expect(usersWithRoles[0]!.organizationRoles).toHaveLength(2); - expect(usersWithRoles[0]!.organizationRoles).toContainEqual( - expect.objectContaining({ id: roles[0].id }) - ); - expect(usersWithRoles[0]!.organizationRoles).toContainEqual( - expect.objectContaining({ id: roles[1].id }) + expect(usersWithRoles[0]!.organizationRoles).toEqual( + expect.arrayContaining(roles.map(({ id }) => expect.objectContaining({ id }))) ); }); }); diff --git a/packages/schemas/src/types/organization.ts b/packages/schemas/src/types/organization.ts index 51407c45a..8dbbac9da 100644 --- a/packages/schemas/src/types/organization.ts +++ b/packages/schemas/src/types/organization.ts @@ -7,6 +7,8 @@ import { Organizations, type OrganizationInvitation, OrganizationInvitations, + type Application, + Applications, } from '../db-entries/index.js'; import { type ToZodObject } from '../utils/zod.js'; @@ -87,10 +89,10 @@ export const organizationWithOrganizationRolesGuard: ToZodObject = + Applications.guard.extend({ + organizationRoles: organizationRoleEntityGuard.array(), + }); + /** * The organization invitation with additional fields: *