diff --git a/packages/core/src/database/delete-by-id.ts b/packages/core/src/database/delete-by-id.ts new file mode 100644 index 000000000..36d073e26 --- /dev/null +++ b/packages/core/src/database/delete-by-id.ts @@ -0,0 +1,15 @@ +import { type CommonQueryMethods, sql } from 'slonik'; + +import { DeletionError } from '#src/errors/SlonikError/index.js'; + +export const buildDeleteByIdWithPool = + (pool: CommonQueryMethods, table: string) => async (id: string) => { + const { rowCount } = await pool.query(sql` + delete from ${sql.identifier([table])} + where ${sql.identifier(['id'])}=${id}; + `); + + if (rowCount < 1) { + throw new DeletionError(table, id); + } + }; diff --git a/packages/core/src/database/row-count.ts b/packages/core/src/database/row-count.ts index 3d3c43921..940c7c44f 100644 --- a/packages/core/src/database/row-count.ts +++ b/packages/core/src/database/row-count.ts @@ -1,6 +1,18 @@ import type { CommonQueryMethods, IdentifierSqlToken } from 'slonik'; import { sql } from 'slonik'; +export const buildGetTotalRowCountWithPool = + (pool: CommonQueryMethods, table: string) => async () => { + // Postgres returns a biging 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])} + `); + + return { count: Number(count) }; + }; + export const getTotalRowCountWithPool = (pool: CommonQueryMethods) => async (table: IdentifierSqlToken) => { // Postgres returns a biging for count(*), which is then converted to a string by query library. diff --git a/packages/core/src/middleware/koa-guard.ts b/packages/core/src/middleware/koa-guard.ts index 3a45f36ce..6373ed421 100644 --- a/packages/core/src/middleware/koa-guard.ts +++ b/packages/core/src/middleware/koa-guard.ts @@ -23,7 +23,7 @@ export type GuardConfig = { * // e.g. parse '?key1=foo' * z.object({ key1: z.string() }) */ - query?: ZodType; + query?: ZodType; /** * Guard JSON request body. You can treat the body like a normal object. * @@ -33,7 +33,7 @@ export type GuardConfig = { * key2: z.object({ key3: z.number() }).array(), * }) */ - body?: ZodType; + body?: ZodType; /** * Guard `koa-router` path parameters (i.e. `ctx.params`). * @@ -41,19 +41,19 @@ export type GuardConfig = { * // e.g. parse '/foo/:key1' * z.object({ key1: z.string() }) */ - params?: ZodType; + params?: ZodType; /** * Guard response body. * * @example z.object({ key1: z.string() }) */ - response?: ZodType; + response?: ZodType; /** * Guard response status code. It produces a `ServerError` (500) * if the response does not satisfy any of the given value(s). */ status?: number | number[]; - files?: ZodType; + files?: ZodType; }; export type GuardedRequest = { diff --git a/packages/core/src/middleware/koa-pagination.ts b/packages/core/src/middleware/koa-pagination.ts index 940f8e1dc..29e53266f 100644 --- a/packages/core/src/middleware/koa-pagination.ts +++ b/packages/core/src/middleware/koa-pagination.ts @@ -5,7 +5,7 @@ import { number } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import { buildLink } from '#src/utils/pagination.js'; -type Pagination = { +export type Pagination = { offset: number; limit: number; totalCount?: number; diff --git a/packages/core/src/queries/organizations.ts b/packages/core/src/queries/organizations.ts new file mode 100644 index 000000000..fb55d2b36 --- /dev/null +++ b/packages/core/src/queries/organizations.ts @@ -0,0 +1,48 @@ +import { type Organization, Organizations } from '@logto/schemas'; +import { type OmitAutoSetFields } from '@logto/shared'; + +import { buildDeleteByIdWithPool } from '#src/database/delete-by-id.js'; +import { buildFindAllEntitiesWithPool } from '#src/database/find-all-entities.js'; +import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js'; +import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; +import { buildGetTotalRowCountWithPool } from '#src/database/row-count.js'; +import { buildUpdateWhereWithPool } from '#src/database/update-where.js'; +import TenantQueries from '#src/utils/TenantQueries.js'; + +export default class OrganizationQueries extends TenantQueries { + #findTotalNumber = buildGetTotalRowCountWithPool(this.pool, Organizations.table); + #findAll = buildFindAllEntitiesWithPool(this.pool)(Organizations); + #findById = buildFindEntityByIdWithPool(this.pool)(Organizations); + #insert = buildInsertIntoWithPool(this.pool)(Organizations, { returning: true }); + #updateById = buildUpdateWhereWithPool(this.pool)(Organizations, true); + #deleteById = buildDeleteByIdWithPool(this.pool, Organizations.table); + + async findTotalNumber(): Promise { + const { count } = await this.#findTotalNumber(); + return count; + } + + async findAll(limit: number, offset: number): Promise { + return this.#findAll(limit, offset); + } + + async findById(id: string): Promise> { + return this.#findById(id); + } + + async insert(data: OmitAutoSetFields): Promise> { + return this.#insert(data); + } + + async updateById( + id: string, + data: Partial>, + jsonbMode: 'replace' | 'merge' = 'replace' + ): Promise> { + return this.#updateById({ set: data, where: { id }, jsonbMode }); + } + + async deleteById(id: string): Promise { + await this.#deleteById(id); + } +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 2c2a03e65..730ea2589 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -25,6 +25,7 @@ import hookRoutes from './hook.js'; import interactionRoutes from './interaction/index.js'; import logRoutes from './log.js'; import logtoConfigRoutes from './logto-config.js'; +import organizationRoutes from './organizations.js'; import resourceRoutes from './resource.js'; import roleRoutes from './role.js'; import roleScopeRoutes from './role.scope.js'; @@ -63,6 +64,7 @@ const createRouters = (tenant: TenantContext) => { verificationCodeRoutes(managementRouter, tenant); userAssetsRoutes(managementRouter, tenant); domainRoutes(managementRouter, tenant); + organizationRoutes(managementRouter, tenant); const anonymousRouter: AnonymousRouter = new Router(); wellKnownRoutes(anonymousRouter, tenant); diff --git a/packages/core/src/routes/organizations.ts b/packages/core/src/routes/organizations.ts new file mode 100644 index 000000000..9d691ded8 --- /dev/null +++ b/packages/core/src/routes/organizations.ts @@ -0,0 +1,26 @@ +import { Organizations } from '@logto/schemas'; + +import SchemaRouter from '#src/utils/SchemaRouter.js'; + +import { type AuthedRouter, type RouterInitArgs } from './types.js'; + +export default function organizationRoutes( + ...[ + originalRouter, + { + queries: { organizations }, + }, + ]: RouterInitArgs +) { + const router = new SchemaRouter(Organizations, { + findAll: async ({ limit, offset }) => + Promise.all([organizations.findTotalNumber(), organizations.findAll(limit, offset)]), + findById: organizations.findById, + insert: organizations.insert, + updateGuard: Organizations.guard.omit({ id: true, createdAt: true }).partial(), + updateById: organizations.updateById, + deleteById: organizations.deleteById, + }); + + originalRouter.use(router.routes()); +} diff --git a/packages/core/src/routes/types.ts b/packages/core/src/routes/types.ts index 3beb572b6..0550f4f99 100644 --- a/packages/core/src/routes/types.ts +++ b/packages/core/src/routes/types.ts @@ -8,10 +8,9 @@ import type TenantContext from '#src/tenants/TenantContext.js'; export type AnonymousRouter = Router; -export type AuthedRouter = Router< - unknown, - WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext ->; +type AuthedRouterContext = WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext; + +export type AuthedRouter = Router; type RouterInit = (router: T, tenant: TenantContext) => void; export type RouterInitArgs = Parameters>; diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index 49c5cb118..577c4053c 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -11,6 +11,7 @@ import { createHooksQueries } from '#src/queries/hooks.js'; import { createLogQueries } from '#src/queries/log.js'; import { createLogtoConfigQueries } from '#src/queries/logto-config.js'; import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js'; +import OrganizationQueries from '#src/queries/organizations.js'; import { createPasscodeQueries } from '#src/queries/passcode.js'; import { createResourceQueries } from '#src/queries/resource.js'; import { createRolesScopesQueries } from '#src/queries/roles-scopes.js'; @@ -41,6 +42,7 @@ export default class Queries { hooks = createHooksQueries(this.pool); domains = createDomainsQueries(this.pool); dailyActiveUsers = createDailyActiveUsersQueries(this.pool); + organizations = new OrganizationQueries(this.pool); constructor( public readonly pool: CommonQueryMethods, diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts new file mode 100644 index 000000000..7413cc40a --- /dev/null +++ b/packages/core/src/utils/SchemaRouter.ts @@ -0,0 +1,100 @@ +import { type SchemaLike, type GeneratedSchema } from '@logto/schemas'; +import Router, { type IRouterParamContext } from 'koa-router'; +import { z } from 'zod'; + +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination, { type Pagination } from '#src/middleware/koa-pagination.js'; + +type SchemaActions< + CreateSchema extends SchemaLike, + Schema extends CreateSchema, + UpdateSchema extends Partial, +> = { + findAll: (pagination: Pagination) => Promise<[count: number, entities: readonly Schema[]]>; + findById: (id: string) => Promise>; + insert: (data: CreateSchema) => Promise>; + updateGuard: z.ZodType; + updateById: (id: string, data: UpdateSchema) => Promise>; + deleteById: (id: string) => Promise; +}; + +export default class SchemaRouter< + CreateSchema extends SchemaLike, + Schema extends CreateSchema, + UpdateSchema extends Partial = Partial, + StateT = unknown, + CustomT extends IRouterParamContext = IRouterParamContext, +> extends Router { + constructor( + public readonly schema: GeneratedSchema, + public readonly actions: SchemaActions + ) { + super({ prefix: '/' + schema.table.replaceAll('_', '-') }); + + this.get( + '/', + koaPagination({ isOptional: true }), + koaGuard({ response: schema.guard.array(), status: [200] }), + async (ctx, next) => { + const [count, entities] = await actions.findAll(ctx.pagination); + ctx.pagination.totalCount = count; + ctx.body = entities; + return next(); + } + ); + + this.post( + '/', + koaGuard({ + body: schema.createGuard, + response: schema.guard, + status: [201, 422], + }), + async (ctx, next) => { + ctx.body = await actions.insert(ctx.guard.body); + ctx.status = 201; + return next(); + } + ); + + this.get( + '/:id', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + response: schema.guard, + status: [200, 404], + }), + async (ctx, next) => { + ctx.body = await actions.findById(ctx.guard.params.id); + return next(); + } + ); + + this.patch( + '/:id', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + body: actions.updateGuard, + response: schema.guard, + status: [200, 404], + }), + async (ctx, next) => { + void actions.updateById(ctx.guard.params.id, ctx.guard.body); + return next(); + } + ); + + this.delete( + '/:id', + koaGuard({ + params: z.object({ id: z.string().min(1) }), + status: [204, 404], + }), + async (ctx, next) => { + await actions.deleteById(ctx.guard.params.id); + ctx.status = 204; + return next(); + } + ); + } +} diff --git a/packages/core/src/utils/TenantQueries.ts b/packages/core/src/utils/TenantQueries.ts new file mode 100644 index 000000000..b4e5b0c8e --- /dev/null +++ b/packages/core/src/utils/TenantQueries.ts @@ -0,0 +1,5 @@ +import { type CommonQueryMethods } from 'slonik'; + +export default abstract class TenantQueries { + constructor(public readonly pool: CommonQueryMethods) {} +}