0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(core): organization apis

This commit is contained in:
Gao Sun 2023-10-23 12:18:24 +08:00
parent eed73303d9
commit 43a655ba67
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
19 changed files with 456 additions and 186 deletions

View file

@ -28,7 +28,7 @@ export const buildFindAllEntitiesWithPool =
pool.query<Schema>(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

View file

@ -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 <SearchKeys extends string>(search?: SearchOptions<SearchKeys>) => {
<
Keys extends string,
CreateSchema extends Partial<SchemaLike<Keys>>,
Schema extends SchemaLike<Keys>,
>(
pool: CommonQueryMethods,
schema: GeneratedSchema<Keys, CreateSchema, Schema>
) =>
async <SearchKeys extends Keys>(search?: SearchOptions<SearchKeys>) => {
// 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) };

View file

@ -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<Keys extends string> = {
fields: ReadonlyArray<Exclude<Keys, 'id'>>;
fields: readonly Keys[];
keyword: string;
};
export const buildSearchSql = <SearchKeys extends string>(search?: SearchOptions<SearchKeys>) => {
export const buildSearchSql = <
Keys extends string,
CreateSchema extends Partial<SchemaLike<Keys>>,
Schema extends SchemaLike<Keys>,
SearchKeys extends Keys,
>(
schema: GeneratedSchema<Keys, CreateSchema, Schema>,
search?: SearchOptions<SearchKeys>,
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})`;
});
};

View file

@ -203,6 +203,11 @@ declare module 'koa-router' {
path: string | string[] | RegExp,
...middleware: Array<Router.IMiddleware<StateT, CustomT>>
): Router<StateT, CustomT>;
use<T, U>(
path: string | RegExp | Array<string | RegExp>,
middleware: Koa.Middleware<T, U>,
routeHandler: Router.IMiddleware<StateT & T, CustomT & U>
): Router<StateT & T, CustomT & U>;
/**
* HTTP get method

View file

@ -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<OrganizationWithRoles> =
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<typeof Organizations, typeof Users> {
constructor(pool: CommonQueryMethods) {
super(pool, OrganizationUserRelations.table, Organizations, Users);
@ -63,18 +37,11 @@ class UserRelationQueries extends TwoRelationsQueries<typeof Organizations, type
const { fields } = convertToIdentifiers(OrganizationUserRelations, true);
const relations = convertToIdentifiers(OrganizationRoleUserRelations, true);
// TODO: replace `.*` with explicit fields
return this.pool.any<OrganizationWithRoles>(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<typeof Organizations, type
group by ${organizations.table}.id
`);
}
async getUsersByOrganizationId(
organizationId: string,
search?: SearchOptions<(typeof userSearchKeys)[number]>
): Promise<Readonly<UserWithOrganizationRoles[]>> {
const roles = convertToIdentifiers(OrganizationRoles, true);
const users = convertToIdentifiers(Users, true);
const { fields } = convertToIdentifiers(OrganizationUserRelations, true);
const relations = convertToIdentifiers(OrganizationRoleUserRelations, true);
return this.pool.any<UserWithOrganizationRoles>(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<OrganizationRoleKeys>
): Promise<[totalNumber: number, rows: Readonly<OrganizationRoleWithScopes[]>]> {
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<OrganizationRoleKeys>
) {
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) {

View file

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

View file

@ -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<T extends AuthedRouter>(
'/users/:userId/organizations',
koaGuard({
params: z.object({ userId: z.string() }),
response: organizationWithRolesGuard.array(),
response: organizationWithOrganizationRolesGuard.array(),
status: [200, 404],
}),
async (ctx, next) => {

View file

@ -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<User[]> =>
// 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<User[]> =>
search ? filterUsersWithSearch(mockUserList, String(search)) : mockUserList
),
},

View file

@ -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<string>,
excludeOrganizationId: Nullable<string>
): 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<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
const {
users: { findUsers, countUsers },
usersRoles: { findUsersRolesByRoleId },
} = queries;
router.get(
@ -29,16 +59,26 @@ export default function adminUserSearchRoutes<T extends AuthedRouter>(
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;

View file

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

View file

@ -30,7 +30,7 @@ export default function dashboardRoutes<T extends AuthedRouter>(
status: [200],
}),
async (ctx, next) => {
const { count: totalUserCount } = await countUsers();
const { count: totalUserCount } = await countUsers({});
ctx.body = { totalUserCount };
return next();

View file

@ -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<T extends AuthedRouter>(...args: Rout
const [
originalRouter,
{
queries: { organizations, users },
queries: { organizations },
},
] = args;
const router = new SchemaRouter(Organizations, organizations, {
@ -24,27 +27,86 @@ export default function organizationRoutes<T extends AuthedRouter>(...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<SearchOptions<(typeof userSearchKeys)[number]>> = 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<T extends AuthedRouter>(...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<T extends AuthedRouter>(...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;

View file

@ -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) {

View file

@ -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<T extends AuthedRouter>(
usersRoles: {
deleteUsersRolesByUserIdAndRoleId,
findFirstUsersRolesByRoleIdAndUserIds,
findUsersRolesByRoleId,
insertUsersRoles,
},
} = queries;
@ -43,13 +43,19 @@ export default function roleUserRoutes<T extends AuthedRouter>(
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;

View file

@ -201,6 +201,7 @@ export default class RelationQueries<
select count(*)
${mainSql}
`),
// TODO: replace `.*` with explicit fields
this.pool.query<InferSchema<S>>(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'])},

View file

@ -44,7 +44,7 @@ export default class SchemaQueries<
public readonly schema: GeneratedSchema<Key | 'id', CreateSchema, Schema>,
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 });

View file

@ -55,6 +55,14 @@ type SchemaRouterConfig<Key extends string> = {
searchFields: SearchOptions<Key>['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<string, RelationCreateSchema, RelationSchema>
>,
pathname = tableToPathname(relationQueries.schemas[1].table)
pathname = tableToPathname(relationQueries.schemas[1].table),
{ disabled }: Partial<RelationRoutesConfig> = {}
) {
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();
}

View file

@ -239,8 +239,8 @@ const showLowercase = (
*/
export const buildConditionsFromSearch = (
search: Search,
searchFields: string[],
fieldsTypeMapping?: Record<string, string>
searchFields: readonly string[],
fieldsTypeMapping?: Readonly<Record<string, string>>
) => {
assertThat(searchFields.length > 0, new TypeError('No search field found.'));

View file

@ -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<OrganizationRoleWithScop
})
.array(),
});
/**
* The simplified organization role entity that is returned in the `roles` field
* of the organization.
*/
export type OrganizationRoleEntity = {
id: string;
name: string;
};
const organizationRoleEntityGuard: z.ZodType<OrganizationRoleEntity> = 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<OrganizationWithRoles> =
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<UserWithOrganizationRoles> =
Users.guard.extend({
organizationRoles: organizationRoleEntityGuard.array(),
});