From 43a655ba675ee9f92bfe36462d519dfaf1cf87c3 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 23 Oct 2023 12:18:24 +0800 Subject: [PATCH] feat(core): organization apis --- .../core/src/database/find-all-entities.ts | 2 +- packages/core/src/database/row-count.ts | 17 +- packages/core/src/database/utils.ts | 26 ++- packages/core/src/include.d/koa-router.d.ts | 5 + packages/core/src/queries/organizations.ts | 161 ++++++++++++------ packages/core/src/queries/user.ts | 99 +++++------ .../src/routes/admin-user/organization.ts | 4 +- .../core/src/routes/admin-user/search.test.ts | 6 +- packages/core/src/routes/admin-user/search.ts | 60 +++++-- packages/core/src/routes/dashboard.test.ts | 2 +- packages/core/src/routes/dashboard.ts | 2 +- .../core/src/routes/organization/index.ts | 110 ++++++++++-- .../core/src/routes/organization/utils.ts | 2 +- packages/core/src/routes/role.user.ts | 20 ++- packages/core/src/utils/RelationQueries.ts | 7 +- packages/core/src/utils/SchemaQueries.ts | 2 +- packages/core/src/utils/SchemaRouter.ts | 62 ++++--- packages/core/src/utils/search.ts | 4 +- packages/schemas/src/types/organization.ts | 51 +++++- 19 files changed, 456 insertions(+), 186 deletions(-) diff --git a/packages/core/src/database/find-all-entities.ts b/packages/core/src/database/find-all-entities.ts index cabcdd77e..0fd1cebe0 100644 --- a/packages/core/src/database/find-all-entities.ts +++ b/packages/core/src/database/find-all-entities.ts @@ -28,7 +28,7 @@ export const buildFindAllEntitiesWithPool = pool.query(sql` select ${sql.join(Object.values(fields), sql`, `)} from ${table} - ${buildSearchSql(search)} + ${buildSearchSql(schema, search)} ${conditionalSql(orderBy, (orderBy) => { const orderBySql = orderBy.map(({ field, order }) => // Note: 'desc' and 'asc' are keywords, so we don't pass them as values diff --git a/packages/core/src/database/row-count.ts b/packages/core/src/database/row-count.ts index ceff0437e..a56fe4489 100644 --- a/packages/core/src/database/row-count.ts +++ b/packages/core/src/database/row-count.ts @@ -1,17 +1,26 @@ +import { type GeneratedSchema } from '@logto/schemas'; +import { type SchemaLike } from '@logto/shared'; import type { CommonQueryMethods, IdentifierSqlToken } from 'slonik'; import { sql } from 'slonik'; import { type SearchOptions, buildSearchSql } from './utils.js'; export const buildGetTotalRowCountWithPool = - (pool: CommonQueryMethods, table: string) => - async (search?: SearchOptions) => { + < + Keys extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, + >( + pool: CommonQueryMethods, + schema: GeneratedSchema + ) => + async (search?: SearchOptions) => { // Postgres returns a bigint for count(*), which is then converted to a string by query library. // We need to convert it to a number. const { count } = await pool.one<{ count: string }>(sql` select count(*) - from ${sql.identifier([table])} - ${buildSearchSql(search)} + from ${sql.identifier([schema.table])} + ${buildSearchSql(schema, search)} `); return { count: Number(count) }; diff --git a/packages/core/src/database/utils.ts b/packages/core/src/database/utils.ts index 9f8075ee1..53ed5700d 100644 --- a/packages/core/src/database/utils.ts +++ b/packages/core/src/database/utils.ts @@ -1,23 +1,33 @@ -import { conditionalSql } from '@logto/shared'; -import { sql } from 'slonik'; +import { type GeneratedSchema } from '@logto/schemas'; +import { type SchemaLike, conditionalSql, convertToIdentifiers } from '@logto/shared'; +import { type SqlSqlToken, sql } from 'slonik'; /** * Options for searching for a string within a set of fields (case-insensitive). - * - * Note: `id` is excluded from the fields since it should be unique. */ export type SearchOptions = { - fields: ReadonlyArray>; + fields: readonly Keys[]; keyword: string; }; -export const buildSearchSql = (search?: SearchOptions) => { +export const buildSearchSql = < + Keys extends string, + CreateSchema extends Partial>, + Schema extends SchemaLike, + SearchKeys extends Keys, +>( + schema: GeneratedSchema, + search?: SearchOptions, + prefixSql: SqlSqlToken = sql`where ` +) => { + const { fields } = convertToIdentifiers(schema, true); + return conditionalSql(search, (search) => { const { fields: searchFields, keyword } = search; const searchSql = sql.join( - searchFields.map((field) => sql`${sql.identifier([field])} ilike ${`%${keyword}%`}`), + searchFields.map((field) => sql`${fields[field]} ilike ${`%${keyword}%`}`), sql` or ` ); - return sql`where ${searchSql}`; + return sql`${prefixSql}(${searchSql})`; }); }; diff --git a/packages/core/src/include.d/koa-router.d.ts b/packages/core/src/include.d/koa-router.d.ts index 231ee4b88..bb0668fa7 100644 --- a/packages/core/src/include.d/koa-router.d.ts +++ b/packages/core/src/include.d/koa-router.d.ts @@ -203,6 +203,11 @@ declare module 'koa-router' { path: string | string[] | RegExp, ...middleware: Array> ): Router; + use( + path: string | RegExp | Array, + middleware: Koa.Middleware, + routeHandler: Router.IMiddleware + ): Router; /** * HTTP get method diff --git a/packages/core/src/queries/organizations.ts b/packages/core/src/queries/organizations.ts index 8b292efd6..7de2ad55e 100644 --- a/packages/core/src/queries/organizations.ts +++ b/packages/core/src/queries/organizations.ts @@ -13,45 +13,19 @@ import { type CreateOrganizationRole, type OrganizationRole, type OrganizationRoleWithScopes, + type OrganizationWithRoles, + type UserWithOrganizationRoles, } from '@logto/schemas'; import { conditionalSql, convertToIdentifiers } from '@logto/shared'; import { sql, type CommonQueryMethods } from 'slonik'; -import { z } from 'zod'; +import { type SearchOptions, buildSearchSql } from '#src/database/utils.js'; import RelationQueries, { TwoRelationsQueries } 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(), - }); +import { type userSearchKeys } from './user.js'; +/** The query class for the organization - user relation. */ class UserRelationQueries extends TwoRelationsQueries { constructor(pool: CommonQueryMethods) { super(pool, OrganizationUserRelations.table, Organizations, Users); @@ -63,18 +37,11 @@ class UserRelationQueries extends TwoRelationsQueries(sql` select ${organizations.table}.*, - coalesce( - json_agg( - json_build_object( - 'id', ${roles.fields.id}, - 'name', ${roles.fields.name} - ) - ) filter (where ${roles.fields.id} is not null), -- left join could produce nulls - '[]' - ) as roles + ${this.#aggregateRoles()} from ${this.table} left join ${organizations.table} on ${fields.organizationId} = ${organizations.fields.id} @@ -87,6 +54,49 @@ class UserRelationQueries extends TwoRelationsQueries + ): Promise> { + const roles = convertToIdentifiers(OrganizationRoles, true); + const users = convertToIdentifiers(Users, true); + const { fields } = convertToIdentifiers(OrganizationUserRelations, true); + const relations = convertToIdentifiers(OrganizationRoleUserRelations, true); + + return this.pool.any(sql` + select + ${users.table}.*, + ${this.#aggregateRoles('organization_roles')} + from ${this.table} + left join ${users.table} + on ${fields.userId} = ${users.fields.id} + left join ${relations.table} + on ${fields.userId} = ${relations.fields.userId} + 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 + `); + } + + #aggregateRoles(as = 'roles') { + 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])} + `; + } } class OrganizationRolesQueries extends SchemaQueries< @@ -100,15 +110,21 @@ class OrganizationRolesQueries extends SchemaQueries< override async findAll( limit: number, - offset: number + offset: number, + search?: SearchOptions ): Promise<[totalNumber: number, rows: Readonly]> { return Promise.all([ - this.findTotalNumber(), - this.pool.any(this.#findWithScopesSql(undefined, limit, offset)), + this.findTotalNumber(search), + this.pool.any(this.#findWithScopesSql(undefined, limit, offset, search)), ]); } - #findWithScopesSql(roleId?: string, limit = 1, offset = 0) { + #findWithScopesSql( + roleId?: string, + limit = 1, + offset = 0, + search?: SearchOptions + ) { const { table, fields } = convertToIdentifiers(OrganizationRoles, true); const relations = convertToIdentifiers(OrganizationRoleScopeRelations, true); const scopes = convertToIdentifiers(OrganizationScopes, true); @@ -133,6 +149,7 @@ class OrganizationRolesQueries extends SchemaQueries< ${conditionalSql(roleId, (id) => { return sql`where ${fields.id} = ${id}`; })} + ${buildSearchSql(OrganizationRoles, search)} group by ${fields.id} ${conditionalSql(this.orderBy, ({ field, order }) => { return sql`order by ${fields[field]} ${order === 'desc' ? sql`desc` : sql`asc`}`; @@ -143,6 +160,54 @@ class OrganizationRolesQueries extends SchemaQueries< } } +class RoleUserRelationQueries extends RelationQueries< + [typeof Organizations, typeof OrganizationRoles, typeof Users] +> { + constructor(pool: CommonQueryMethods) { + super(pool, OrganizationRoleUserRelations.table, Organizations, OrganizationRoles, Users); + } + + /** Replace the roles of a user in an organization. */ + async replace(organizationId: string, userId: string, roleIds: string[]) { + const users = convertToIdentifiers(Users); + const relations = convertToIdentifiers(OrganizationRoleUserRelations); + + return this.pool.transaction(async (transaction) => { + // Lock user + await transaction.query(sql` + select id + from ${users.table} + where ${users.fields.id} = ${userId} + for update + `); + + // Delete old relations + await transaction.query(sql` + delete from ${relations.table} + where ${relations.fields.userId} = ${userId} + and ${relations.fields.organizationId} = ${organizationId} + `); + + // Insert new relations + if (roleIds.length === 0) { + return; + } + + await transaction.query(sql` + insert into ${relations.table} ( + ${relations.fields.userId}, + ${relations.fields.organizationId}, + ${relations.fields.organizationRoleId} + ) + values ${sql.join( + roleIds.map((roleId) => sql`(${userId}, ${organizationId}, ${roleId})`), + sql`, ` + )} + `); + }); + } +} + export default class OrganizationQueries extends SchemaQueries< OrganizationKeys, CreateOrganization, @@ -169,13 +234,7 @@ export default class OrganizationQueries extends SchemaQueries< /** Queries for organization - user relations. */ users: new UserRelationQueries(this.pool), /** Queries for organization - organization role - user relations. */ - rolesUsers: new RelationQueries( - this.pool, - OrganizationRoleUserRelations.table, - Organizations, - OrganizationRoles, - Users - ), + rolesUsers: new RoleUserRelationQueries(this.pool), }; constructor(pool: CommonQueryMethods) { diff --git a/packages/core/src/queries/user.ts b/packages/core/src/queries/user.ts index 5c6ad244b..c339c5cfc 100644 --- a/packages/core/src/queries/user.ts +++ b/packages/core/src/queries/user.ts @@ -1,7 +1,8 @@ import type { User, CreateUser } from '@logto/schemas'; -import { SearchJointMode, Users } from '@logto/schemas'; +import { Users } from '@logto/schemas'; import type { OmitAutoSetFields } from '@logto/shared'; import { conditionalSql, convertToIdentifiers } from '@logto/shared'; +import { conditionalArray, pick } from '@silverhand/essentials'; import type { CommonQueryMethods } from 'slonik'; import { sql } from 'slonik'; @@ -12,6 +13,26 @@ import { buildConditionsFromSearch } from '#src/utils/search.js'; const { table, fields } = convertToIdentifiers(Users); +export type UserConditions = { + search?: Search; + relation?: { + table: string; + field: string; + value: string; + type: 'exists' | 'not exists'; + }; +}; + +export const userSearchKeys = Object.freeze([ + 'id', + 'primaryEmail', + 'primaryPhone', + 'username', + 'name', +] as const); + +export const userSearchFields = Object.freeze(Object.values(pick(Users.fields, ...userSearchKeys))); + export const createUserQueries = (pool: CommonQueryMethods) => { const findUserByUsername = async (username: string) => pool.maybeOne(sql` @@ -91,64 +112,46 @@ export const createUserQueries = (pool: CommonQueryMethods) => { ` ); - const buildUserConditions = (search: Search, excludeUserIds: string[], userIds?: string[]) => { - const hasSearch = search.matches.length > 0; - const searchFields = [ - Users.fields.id, - Users.fields.primaryEmail, - Users.fields.primaryPhone, - Users.fields.username, - Users.fields.name, - ]; + const buildUserConditions = ({ search, relation }: UserConditions) => { + const hasSearch = search?.matches.length; + const id = sql.identifier; + const buildRelationCondition = () => { + if (!relation) { + return; + } + + const { table, field, type, value } = relation; - if (excludeUserIds.length > 0) { - // FIXME @sijie temp solution to filter out admin users, - // It is too complex to use join return sql` - where ${fields.id} not in (${sql.join(excludeUserIds, sql`, `)}) - ${conditionalSql( - hasSearch, - () => sql`and (${buildConditionsFromSearch(search, searchFields)})` - )} + ${type === 'exists' ? sql`exists` : sql`not exists`} ( + select 1 + from ${id([table])} + where ${id([table, field])} = ${value} + and ${id([table, 'user_id'])} = ${id([Users.table, Users.fields.id])} + ) `; - } + }; - if (userIds) { - return sql` - where ${fields.id} in (${userIds.length > 0 ? sql.join(userIds, sql`, `) : sql`null`}) - ${conditionalSql( - hasSearch, - () => sql`and (${buildConditionsFromSearch(search, searchFields)})` - )} - `; - } - - return conditionalSql( - hasSearch, - () => sql`where ${buildConditionsFromSearch(search, searchFields)}` + const conditions = conditionalArray( + buildRelationCondition(), + hasSearch && sql`(${buildConditionsFromSearch(search, userSearchFields)})` ); + + if (conditions.length === 0) { + return sql``; + } + + return sql`where ${sql.join(conditions, sql` and `)}`; }; - const defaultUserSearch = { matches: [], isCaseSensitive: false, joint: SearchJointMode.Or }; - - const countUsers = async ( - search: Search = defaultUserSearch, - excludeUserIds: string[] = [], - userIds?: string[] - ) => + const countUsers = async (conditions: UserConditions) => pool.one<{ count: number }>(sql` select count(*) from ${table} - ${buildUserConditions(search, excludeUserIds, userIds)} + ${buildUserConditions(conditions)} `); - const findUsers = async ( - limit: number, - offset: number, - search: Search, - excludeUserIds: string[] = [], - userIds?: string[] - ) => + const findUsers = async (limit: number, offset: number, conditions: UserConditions) => pool.any( sql` select ${sql.join( @@ -156,7 +159,7 @@ export const createUserQueries = (pool: CommonQueryMethods) => { sql`,` )} from ${table} - ${buildUserConditions(search, excludeUserIds, userIds)} + ${buildUserConditions(conditions)} order by ${fields.createdAt} desc limit ${limit} offset ${offset} diff --git a/packages/core/src/routes/admin-user/organization.ts b/packages/core/src/routes/admin-user/organization.ts index 6715d55c6..aaa2298a2 100644 --- a/packages/core/src/routes/admin-user/organization.ts +++ b/packages/core/src/routes/admin-user/organization.ts @@ -1,7 +1,7 @@ +import { organizationWithOrganizationRolesGuard } from '@logto/schemas'; 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'; @@ -12,7 +12,7 @@ export default function adminUserOrganizationRoutes( '/users/:userId/organizations', koaGuard({ params: z.object({ userId: z.string() }), - response: organizationWithRolesGuard.array(), + response: organizationWithOrganizationRolesGuard.array(), status: [200, 404], }), async (ctx, next) => { diff --git a/packages/core/src/routes/admin-user/search.test.ts b/packages/core/src/routes/admin-user/search.test.ts index 55e2ee941..6ea88f740 100644 --- a/packages/core/src/routes/admin-user/search.test.ts +++ b/packages/core/src/routes/admin-user/search.test.ts @@ -20,15 +20,13 @@ const filterUsersWithSearch = (users: User[], search: string) => const mockedQueries = { users: { - countUsers: jest.fn(async (search) => ({ + countUsers: jest.fn(async ({ search }) => ({ count: search ? filterUsersWithSearch(mockUserList, String(search)).length : mockUserList.length, })), findUsers: jest.fn( - async (limit, offset, search): Promise => - // For testing, type should be `Search` but we use `string` in `filterUsersWithSearch()` here - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + async (limit, offset, { search }): Promise => search ? filterUsersWithSearch(mockUserList, String(search)) : mockUserList ), }, diff --git a/packages/core/src/routes/admin-user/search.ts b/packages/core/src/routes/admin-user/search.ts index 7bc7a0d31..92d995c93 100644 --- a/packages/core/src/routes/admin-user/search.ts +++ b/packages/core/src/routes/admin-user/search.ts @@ -1,19 +1,49 @@ -import { userInfoSelectFields, userProfileResponseGuard } from '@logto/schemas'; -import { pick, tryThat } from '@silverhand/essentials'; +import { + OrganizationUserRelations, + UsersRoles, + userInfoSelectFields, + userProfileResponseGuard, +} from '@logto/schemas'; +import { type Nullable, pick, tryThat } from '@silverhand/essentials'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; +import { type UserConditions } from '#src/queries/user.js'; import { parseSearchParamsForSearch } from '#src/utils/search.js'; import type { AuthedRouter, RouterInitArgs } from '../types.js'; +const getQueryRelation = ( + excludeRoleId: Nullable, + excludeOrganizationId: Nullable +): UserConditions['relation'] => { + if (excludeRoleId) { + return { + table: UsersRoles.table, + field: UsersRoles.fields.roleId, + value: excludeRoleId, + type: 'not exists', + }; + } + + if (excludeOrganizationId) { + return { + table: OrganizationUserRelations.table, + field: OrganizationUserRelations.fields.organizationId, + value: excludeOrganizationId, + type: 'not exists', + }; + } + + return undefined; +}; + export default function adminUserSearchRoutes( ...[router, { queries }]: RouterInitArgs ) { const { users: { findUsers, countUsers }, - usersRoles: { findUsersRolesByRoleId }, } = queries; router.get( @@ -29,16 +59,26 @@ export default function adminUserSearchRoutes( return tryThat( async () => { - const search = parseSearchParamsForSearch(searchParams); const excludeRoleId = searchParams.get('excludeRoleId'); - const excludeUsersRoles = excludeRoleId - ? await findUsersRolesByRoleId(excludeRoleId) - : []; - const excludeUserIds = excludeUsersRoles.map(({ userId }) => userId); + const excludeOrganizationId = searchParams.get('excludeOrganizationId'); + + if (excludeRoleId && excludeOrganizationId) { + throw new RequestError({ + code: 'request.invalid_input', + status: 422, + details: + 'Parameter `excludeRoleId` and `excludeOrganizationId` cannot be used at the same time.', + }); + } + + const conditions: UserConditions = { + search: parseSearchParamsForSearch(searchParams), + relation: getQueryRelation(excludeRoleId, excludeOrganizationId), + }; const [{ count }, users] = await Promise.all([ - countUsers(search, excludeUserIds), - findUsers(limit, offset, search, excludeUserIds), + countUsers(conditions), + findUsers(limit, offset, conditions), ]); ctx.pagination.totalCount = count; diff --git a/packages/core/src/routes/dashboard.test.ts b/packages/core/src/routes/dashboard.test.ts index ea0dfd6a7..ae7ef9ae1 100644 --- a/packages/core/src/routes/dashboard.test.ts +++ b/packages/core/src/routes/dashboard.test.ts @@ -60,7 +60,7 @@ describe('dashboardRoutes', () => { describe('GET /dashboard/users/total', () => { it('should call countUsers with no parameters', async () => { await logRequest.get('/dashboard/users/total'); - expect(countUsers).toHaveBeenCalledWith(); + expect(countUsers).toHaveBeenCalledWith({}); }); it('/dashboard/users/total should return correct response', async () => { diff --git a/packages/core/src/routes/dashboard.ts b/packages/core/src/routes/dashboard.ts index 6d42f6362..b5c29d5ff 100644 --- a/packages/core/src/routes/dashboard.ts +++ b/packages/core/src/routes/dashboard.ts @@ -30,7 +30,7 @@ export default function dashboardRoutes( status: [200], }), async (ctx, next) => { - const { count: totalUserCount } = await countUsers(); + const { count: totalUserCount } = await countUsers({}); ctx.body = { totalUserCount }; return next(); diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index 560cc0dcd..9c175b42d 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -1,9 +1,12 @@ -import { OrganizationRoles, Organizations } from '@logto/schemas'; +import { OrganizationRoles, Organizations, userWithOrganizationRolesGuard } from '@logto/schemas'; +import { type Optional, cond } from '@silverhand/essentials'; import { z } from 'zod'; +import { type SearchOptions } from '#src/database/utils.js'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; +import { userSearchKeys } from '#src/queries/user.js'; import SchemaRouter from '#src/utils/SchemaRouter.js'; import { type AuthedRouter, type RouterInitArgs } from '../types.js'; @@ -16,7 +19,7 @@ export default function organizationRoutes(...args: Rout const [ originalRouter, { - queries: { organizations, users }, + queries: { organizations }, }, ] = args; const router = new SchemaRouter(Organizations, organizations, { @@ -24,27 +27,86 @@ export default function organizationRoutes(...args: Rout searchFields: ['name'], }); - router.addRelationRoutes(organizations.relations.users); + // MARK: Organization - user relation routes + router.addRelationRoutes(organizations.relations.users, undefined, { disabled: { get: true } }); + + router.get( + '/:id/users', + // KoaPagination(), + koaGuard({ + query: z.object({ q: z.string().optional() }), + params: z.object({ id: z.string().min(1) }), + response: userWithOrganizationRolesGuard.array(), + status: [200, 404], + }), + async (ctx, next) => { + const { q } = ctx.guard.query; + const search: Optional> = cond( + q && { + fields: userSearchKeys, + keyword: q, + } + ); + ctx.body = await organizations.relations.users.getUsersByOrganizationId( + ctx.guard.params.id, + search + ); + return next(); + } + ); + + router.post( + '/:id/users/roles', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + body: z.object({ + userIds: z.string().min(1).array().nonempty(), + roleIds: z.string().min(1).array().nonempty(), + }), + status: [201, 422], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; + const { userIds, roleIds } = ctx.guard.body; + + await organizations.relations.rolesUsers.insert( + ...roleIds.flatMap<[string, string, string]>((roleId) => + userIds.map<[string, string, string]>((userId) => [id, roleId, userId]) + ) + ); + + ctx.status = 201; + return next(); + } + ); // Manually add these routes since I don't want to over-engineer the `SchemaRouter` // MARK: Organization - user - organization role relation routes const params = Object.freeze({ id: z.string().min(1), userId: z.string().min(1) } as const); const pathname = '/:id/users/:userId/roles'; + router.use(pathname, koaGuard({ params: z.object(params) }), async (ctx, next) => { + const { id, userId } = ctx.guard.params; + + // Ensure membership + if (!(await organizations.relations.users.exists(id, userId))) { + throw new RequestError({ code: 'organization.require_membership', status: 422 }); + } + + return next(); + }); + router.get( pathname, koaPagination(), koaGuard({ params: z.object(params), response: OrganizationRoles.guard.array(), - status: [200, 404], + status: [200, 422], }), async (ctx, next) => { const { id, userId } = ctx.guard.params; - // Ensure both the organization and the role exist - await Promise.all([organizations.findById(id), users.findUserById(userId)]); - const [totalCount, entities] = await organizations.relations.rolesUsers.getEntities( OrganizationRoles, { @@ -60,24 +122,37 @@ export default function organizationRoutes(...args: Rout } ); + router.put( + pathname, + koaGuard({ + params: z.object(params), + body: z.object({ organizationRoleIds: z.string().min(1).array() }), + status: [204, 422], + }), + async (ctx, next) => { + const { id, userId } = ctx.guard.params; + const { organizationRoleIds } = ctx.guard.body; + + await organizations.relations.rolesUsers.replace(id, userId, organizationRoleIds); + + ctx.status = 204; + return next(); + } + ); + router.post( pathname, koaGuard({ params: z.object(params), - body: z.object({ roleIds: z.string().min(1).array().nonempty() }), - status: [201, 404, 422], + body: z.object({ organizationRoleIds: z.string().min(1).array().nonempty() }), + status: [201, 422], }), async (ctx, next) => { const { id, userId } = ctx.guard.params; - const { roleIds } = ctx.guard.body; - - // Ensure membership - if (!(await organizations.relations.users.exists(id, userId))) { - throw new RequestError({ code: 'organization.require_membership', status: 422 }); - } + const { organizationRoleIds } = ctx.guard.body; await organizations.relations.rolesUsers.insert( - ...roleIds.map<[string, string, string]>((roleId) => [id, roleId, userId]) + ...organizationRoleIds.map<[string, string, string]>((roleId) => [id, roleId, userId]) ); ctx.status = 201; @@ -85,11 +160,12 @@ export default function organizationRoutes(...args: Rout } ); + // TODO: check if membership is required in this route router.delete( `${pathname}/:roleId`, koaGuard({ params: z.object({ ...params, roleId: z.string().min(1) }), - status: [204, 404], + status: [204, 422, 404], }), async (ctx, next) => { const { id, roleId, userId } = ctx.guard.params; diff --git a/packages/core/src/routes/organization/utils.ts b/packages/core/src/routes/organization/utils.ts index cc8d2c63c..009f76230 100644 --- a/packages/core/src/routes/organization/utils.ts +++ b/packages/core/src/routes/organization/utils.ts @@ -7,7 +7,7 @@ import RequestError from '#src/errors/RequestError/index.js'; export const errorHandler = (error: unknown) => { if (error instanceof UniqueIntegrityConstraintViolationError) { - throw new RequestError({ code: 'entity.duplicate_value_of_unique_field', field: 'name' }); + throw new RequestError({ code: 'entity.duplicate_value_of_unique_field', field: 'name' }); // TODO: specify field } if (error instanceof ForeignKeyIntegrityConstraintViolationError) { diff --git a/packages/core/src/routes/role.user.ts b/packages/core/src/routes/role.user.ts index 698f0f362..c62baa94a 100644 --- a/packages/core/src/routes/role.user.ts +++ b/packages/core/src/routes/role.user.ts @@ -1,4 +1,4 @@ -import { userInfoSelectFields, userProfileResponseGuard } from '@logto/schemas'; +import { UsersRoles, userInfoSelectFields, userProfileResponseGuard } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { pick, tryThat } from '@silverhand/essentials'; import { object, string } from 'zod'; @@ -6,6 +6,7 @@ import { object, string } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; +import { type UserConditions } from '#src/queries/user.js'; import { parseSearchParamsForSearch } from '#src/utils/search.js'; import type { AuthedRouter, RouterInitArgs } from './types.js'; @@ -19,7 +20,6 @@ export default function roleUserRoutes( usersRoles: { deleteUsersRolesByUserIdAndRoleId, findFirstUsersRolesByRoleIdAndUserIds, - findUsersRolesByRoleId, insertUsersRoles, }, } = queries; @@ -43,13 +43,19 @@ export default function roleUserRoutes( return tryThat( async () => { - const search = parseSearchParamsForSearch(searchParams); - const usersRoles = await findUsersRolesByRoleId(id); - const userIds = usersRoles.map(({ userId }) => userId); + const conditions: UserConditions = { + search: parseSearchParamsForSearch(searchParams), + relation: { + table: UsersRoles.table, + field: UsersRoles.fields.roleId, + value: id, + type: 'exists', + }, + }; const [{ count }, users] = await Promise.all([ - countUsers(search, undefined, userIds), - findUsers(limit, offset, search, undefined, userIds), + countUsers(conditions), + findUsers(limit, offset, conditions), ]); ctx.pagination.totalCount = count; diff --git a/packages/core/src/utils/RelationQueries.ts b/packages/core/src/utils/RelationQueries.ts index 9d5a0642f..14790a195 100644 --- a/packages/core/src/utils/RelationQueries.ts +++ b/packages/core/src/utils/RelationQueries.ts @@ -201,6 +201,7 @@ export default class RelationQueries< select count(*) ${mainSql} `), + // TODO: replace `.*` with explicit fields this.pool.query>(sql` select ${forTable}.* ${mainSql} ${conditionalSql(limit, (limit) => sql`limit ${limit}`)} @@ -264,21 +265,23 @@ export class TwoRelationsQueries< return this.pool.transaction(async (transaction) => { // Lock schema1 row await transaction.query(sql` - select * + select id from ${sql.identifier([this.schemas[0].table])} where id = ${schema1Id} for update `); + // Delete old relations await transaction.query(sql` delete from ${this.table} where ${sql.identifier([this.schemas[0].tableSingular + '_id'])} = ${schema1Id} `); + // Insert new relations if (schema2Ids.length === 0) { return; } - // Insert new relations + await transaction.query(sql` insert into ${this.table} ( ${sql.identifier([this.schemas[0].tableSingular + '_id'])}, diff --git a/packages/core/src/utils/SchemaQueries.ts b/packages/core/src/utils/SchemaQueries.ts index cdde170ec..6322e2b39 100644 --- a/packages/core/src/utils/SchemaQueries.ts +++ b/packages/core/src/utils/SchemaQueries.ts @@ -44,7 +44,7 @@ export default class SchemaQueries< public readonly schema: GeneratedSchema, protected readonly orderBy?: { field: Key | 'id'; order: 'asc' | 'desc' } ) { - this.#findTotalNumber = buildGetTotalRowCountWithPool(this.pool, this.schema.table); + this.#findTotalNumber = buildGetTotalRowCountWithPool(this.pool, this.schema); this.#findAll = buildFindAllEntitiesWithPool(this.pool)(this.schema, orderBy && [orderBy]); this.#findById = buildFindEntityByIdWithPool(this.pool)(this.schema); this.#insert = buildInsertIntoWithPool(this.pool)(this.schema, { returning: true }); diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index 0b2fd0bf4..2c7564ffe 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -55,6 +55,14 @@ type SchemaRouterConfig = { searchFields: SearchOptions['fields']; }; +type RelationRoutesConfig = { + /** Disable certain routes for the relation. */ + disabled: { + /** Disable `GET /:id/[pathname]` route. */ + get: boolean; + }; +}; + /** * A standard RESTful router for a schema. * @@ -242,7 +250,8 @@ export default class SchemaRouter< typeof this.schema, GeneratedSchema >, - pathname = tableToPathname(relationQueries.schemas[1].table) + pathname = tableToPathname(relationQueries.schemas[1].table), + { disabled }: Partial = {} ) { const relationSchema = relationQueries.schemas[1]; const columns = { @@ -251,33 +260,35 @@ export default class SchemaRouter< relationSchemaIds: camelCaseSchemaId(relationSchema) + 's', }; - this.get( - `/:id/${pathname}`, - koaPagination(), - koaGuard({ - params: z.object({ id: z.string().min(1) }), - response: relationSchema.guard.array(), - status: [200, 404], - }), - async (ctx, next) => { - const { id } = ctx.guard.params; + if (!disabled?.get) { + this.get( + `/:id/${pathname}`, + koaPagination(), + koaGuard({ + params: z.object({ id: z.string().min(1) }), + response: relationSchema.guard.array(), + status: [200, 404], + }), + async (ctx, next) => { + const { id } = ctx.guard.params; - // Ensure that the main entry exists - await this.queries.findById(id); + // Ensure that the main entry exists + await this.queries.findById(id); - const [totalCount, entities] = await relationQueries.getEntities( - relationSchema, - { - [columns.schemaId]: id, - }, - ctx.pagination - ); + const [totalCount, entities] = await relationQueries.getEntities( + relationSchema, + { + [columns.schemaId]: id, + }, + ctx.pagination + ); - ctx.pagination.totalCount = totalCount; - ctx.body = entities; - return next(); - } - ); + ctx.pagination.totalCount = totalCount; + ctx.body = entities; + return next(); + } + ); + } this.post( `/:id/${pathname}`, @@ -295,6 +306,7 @@ export default class SchemaRouter< await relationQueries.insert( ...(relationIds?.map<[string, string]>((relationId) => [id, relationId]) ?? []) ); + ctx.status = 201; return next(); } diff --git a/packages/core/src/utils/search.ts b/packages/core/src/utils/search.ts index c23d6edf6..6a526db38 100644 --- a/packages/core/src/utils/search.ts +++ b/packages/core/src/utils/search.ts @@ -239,8 +239,8 @@ const showLowercase = ( */ export const buildConditionsFromSearch = ( search: Search, - searchFields: string[], - fieldsTypeMapping?: Record + searchFields: readonly string[], + fieldsTypeMapping?: Readonly> ) => { assertThat(searchFields.length > 0, new TypeError('No search field found.')); diff --git a/packages/schemas/src/types/organization.ts b/packages/schemas/src/types/organization.ts index 7531f0c0a..0f015063b 100644 --- a/packages/schemas/src/types/organization.ts +++ b/packages/schemas/src/types/organization.ts @@ -1,6 +1,13 @@ import { z } from 'zod'; -import { type OrganizationRole, OrganizationRoles } from '../db-entries/index.js'; +import { + type OrganizationRole, + OrganizationRoles, + type Organization, + type User, + Organizations, + Users, +} from '../db-entries/index.js'; export type OrganizationRoleWithScopes = OrganizationRole & { scopes: Array<{ @@ -18,3 +25,45 @@ export const organizationRoleWithScopesGuard: z.ZodType = z.object({ + id: z.string(), + name: z.string(), +}); + +/** + * The organization entity with the `organizationRoles` field that contains the + * roles of the current member of the organization. + */ +export type OrganizationWithRoles = Organization & { + /** The roles of the current member of the organization. */ + organizationRoles: OrganizationRoleEntity[]; +}; + +export const organizationWithOrganizationRolesGuard: z.ZodType = + Organizations.guard.extend({ + organizationRoles: organizationRoleEntityGuard.array(), + }); + +/** + * The user entity with the `organizationRoles` field that contains the roles of + * the user in a specific organization. + */ +export type UserWithOrganizationRoles = User & { + /** The roles of the user in a specific organization. */ + organizationRoles: OrganizationRoleEntity[]; +}; + +export const userWithOrganizationRolesGuard: z.ZodType = + Users.guard.extend({ + organizationRoles: organizationRoleEntityGuard.array(), + });