Skip to content

Commit

Permalink
Merge pull request #6083 from logto-io/gao-update-org-app-fetch
Browse files Browse the repository at this point in the history
refactor(core): return roles in organization app get api
  • Loading branch information
gao-sun authored Jun 23, 2024
2 parents 097dfca + b839f6c commit cbab5af
Show file tree
Hide file tree
Showing 13 changed files with 291 additions and 70 deletions.
28 changes: 19 additions & 9 deletions packages/core/src/queries/application.ts
Original file line number Diff line number Diff line change
@@ -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';

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

Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/queries/organization/application-relations.ts
Original file line number Diff line number Diff line change
@@ -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];
}
}
10 changes: 2 additions & 8 deletions packages/core/src/queries/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ import {
Resources,
Users,
OrganizationJitRoles,
OrganizationApplicationRelations,
Applications,
} from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';

Expand All @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
40 changes: 12 additions & 28 deletions packages/core/src/queries/organization/user-relations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type OrganizationWithRoles,
type UserWithOrganizationRoles,
type FeaturedUser,
userInfoSelectFields,
} from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';

Expand All @@ -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) {
Expand Down Expand Up @@ -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}
Expand All @@ -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,
Expand All @@ -117,46 +120,27 @@ 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}
`),
]);

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])}
`;
}
}
26 changes: 26 additions & 0 deletions packages/core/src/queries/organization/utils.ts
Original file line number Diff line number Diff line change
@@ -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])}
`;
};
2 changes: 1 addition & 1 deletion packages/core/src/queries/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 38 additions & 1 deletion packages/core/src/routes/organization/application/index.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/routes/organization/user/role-relations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
9 changes: 7 additions & 2 deletions packages/integration-tests/src/api/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading

0 comments on commit cbab5af

Please sign in to comment.