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:
parent
eed73303d9
commit
43a655ba67
19 changed files with 456 additions and 186 deletions
|
@ -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
|
||||
|
|
|
@ -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) };
|
||||
|
|
|
@ -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})`;
|
||||
});
|
||||
};
|
||||
|
|
5
packages/core/src/include.d/koa-router.d.ts
vendored
5
packages/core/src/include.d/koa-router.d.ts
vendored
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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
|
||||
),
|
||||
},
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'])},
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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.'));
|
||||
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue