0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-03 21:48:55 -05:00

refactor(core): return roles in organization app get api

This commit is contained in:
Gao Sun 2024-06-22 08:03:02 +08:00
parent 6dd487269d
commit b839f6c46f
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
13 changed files with 291 additions and 70 deletions

View file

@ -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)}`
);
};

View file

@ -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<ApplicationWithOrganizationRoles>(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];
}
}

View file

@ -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(

View file

@ -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<typeof Organizations, typeof Users> {
constructor(pool: CommonQueryMethods) {
@ -81,7 +84,7 @@ export class UserRelationQueries extends TwoRelationsQueries<typeof Organization
return this.pool.any<OrganizationWithRoles>(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<typeof Organization
`);
}
/** Get the users in an organization and their roles. */
/** Get the users in an organization with their organization roles. */
async getUsersByOrganizationId(
organizationId: string,
{ limit, offset }: GetEntitiesOptions,
@ -117,19 +120,22 @@ export class UserRelationQueries extends TwoRelationsQueries<typeof Organization
`),
this.pool.any<UserWithOrganizationRoles>(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<typeof Organization
return [Number(count), entities];
}
/**
* Build the SQL for aggregating the organization roles with basic information (id and name)
* into a JSON array.
*
* @param as The alias of the aggregated roles. Defaults to `organizationRoles`.
*/
#aggregateRoles(as = 'organizationRoles') {
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])}
`;
}
}

View file

@ -0,0 +1,26 @@
import { OrganizationRoles } from '@logto/schemas';
import { sql } from '@silverhand/slonik';
import { convertToIdentifiers } from '#src/utils/sql.js';
/**
* Build the SQL for aggregating the organization roles with basic information (id and name)
* into a JSON array.
*
* @param as The alias of the aggregated roles. Defaults to `organizationRoles`.
*/
export const aggregateRoles = (as = 'organizationRoles') => {
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])}
`;
};

View file

@ -51,7 +51,7 @@ export const userSearchKeys = Object.freeze([
'primaryPhone',
'username',
'name',
] as const);
] satisfies Array<keyof User>);
/**
* The actual database field names that can be used for searching users. For the schema field

View file

@ -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);
}

View file

@ -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.",

View file

@ -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,
});

View file

@ -86,8 +86,13 @@ export class RelationApiFactory<RelationSchema extends Record<string, unknown>>
return this.config.relationKey;
}
async getList(id: string, page?: number, pageSize?: number): Promise<RelationSchema[]> {
const searchParams = new URLSearchParams();
async getList(
id: string,
page?: number,
pageSize?: number,
extraParams?: ConstructorParameters<typeof URLSearchParams>[0]
): Promise<RelationSchema[]> {
const searchParams = new URLSearchParams(extraParams);
if (page) {
searchParams.append('page', String(page));

View file

@ -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<typeof createApplicationApi>) => {
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 () => {

View file

@ -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 })))
);
});
});

View file

@ -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<OrganizationWit
/**
* The user entity with the `organizationRoles` field that contains the roles of
* the user in a specific organization.
* the user in the organization.
*/
export type UserWithOrganizationRoles = UserInfo & {
/** The roles of the user in a specific organization. */
/** The roles of the user in the organization. */
organizationRoles: OrganizationRoleEntity[];
};
@ -108,6 +110,20 @@ export type OrganizationWithFeatured = Organization & {
featuredUsers?: FeaturedUser[];
};
/**
* The application entity with the `organizationRoles` field that contains the roles
* of the application in the organization.
*/
export type ApplicationWithOrganizationRoles = Application & {
/** The roles of the application in the organization. */
organizationRoles: OrganizationRoleEntity[];
};
export const applicationWithOrganizationRolesGuard: ToZodObject<ApplicationWithOrganizationRoles> =
Applications.guard.extend({
organizationRoles: organizationRoleEntityGuard.array(),
});
/**
* The organization invitation with additional fields:
*