From 75b643ad2f2e2a13f4b74b7503782f10a310aa83 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Thu, 11 Jan 2024 18:08:20 +0800 Subject: [PATCH] feat(schemas, core): init organization invitation apis --- packages/core/src/database/utils.ts | 53 +++++++++- packages/core/src/queries/organizations.ts | 99 ++++++++++++++++++- .../core/src/routes/organization/index.ts | 6 ++ .../src/routes/organization/invitations.ts | 40 ++++++++ packages/schemas/src/types/organization.ts | 21 ++++ packages/schemas/tables/magic_links.sql | 6 +- 6 files changed, 216 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/routes/organization/invitations.ts diff --git a/packages/core/src/database/utils.ts b/packages/core/src/database/utils.ts index 300d430f6..578d42eef 100644 --- a/packages/core/src/database/utils.ts +++ b/packages/core/src/database/utils.ts @@ -1,5 +1,11 @@ import { type GeneratedSchema } from '@logto/schemas'; -import { type SchemaLike, conditionalSql, convertToIdentifiers, type Table } from '@logto/shared'; +import { + type SchemaLike, + conditionalSql, + convertToIdentifiers, + type Table, + type FieldIdentifiers, +} from '@logto/shared'; import { type SqlSqlToken, sql } from 'slonik'; /** @@ -56,3 +62,48 @@ export const expandFields = (schema: Table, tablePref const { fields } = convertToIdentifiers(schema, tablePrefix); return sql.join(Object.values(fields), sql`, `); }; + +/** + * Given a set of identifiers, build a SQL that converts them into a JSON object by mapping + * the keys to the values. + * + * @example + * ```ts + * buildJsonObjectSql({ + * id: sql.identifier(['id']), + * firstName: sql.identifier(['first_name']), + * lastName: sql.identifier(['last_name']), + * createdAt: sql.identifier(['created_at']), + * ); + * ``` + * + * will generate + * + * ```sql + * json_build_object( + * 'id', "id", + * 'firstName', "first_name", + * 'lastName', "last_name", + * 'createdAt', trunc(extract(epoch from "created_at") * 1000) + * ) + * ``` + * + * @remarks The values will be converted to epoch milliseconds if the key ends with `At` since + * slonik has a default parser that converts timestamps to epoch milliseconds, but it does not + * work for JSON objects. + */ +export const buildJsonObjectSql = >( + identifiers: Identifiers +) => sql` + json_build_object( + ${sql.join( + Object.entries(identifiers).map( + ([key, value]) => + sql`${sql.literalValue(key)}, ${ + key.endsWith('At') ? sql`trunc(extract(epoch from ${value}) * 1000)` : value + }` + ), + sql`, ` + )} + ) +`; diff --git a/packages/core/src/queries/organizations.ts b/packages/core/src/queries/organizations.ts index e690a5c57..c38800c02 100644 --- a/packages/core/src/queries/organizations.ts +++ b/packages/core/src/queries/organizations.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines -- refactor in the next pull */ import { type Organization, type CreateOrganization, @@ -17,11 +18,23 @@ import { type UserWithOrganizationRoles, type FeaturedUser, type OrganizationScopeEntity, + OrganizationInvitations, + type OrganizationInvitationKeys, + type CreateOrganizationInvitation, + type OrganizationInvitation, + type OrganizationInvitationEntity, + MagicLinks, + OrganizationInvitationRoleRelations, } from '@logto/schemas'; import { conditionalSql, convertToIdentifiers } from '@logto/shared'; import { sql, type CommonQueryMethods } from 'slonik'; -import { type SearchOptions, buildSearchSql, expandFields } from '#src/database/utils.js'; +import { + type SearchOptions, + buildSearchSql, + expandFields, + buildJsonObjectSql, +} from '#src/database/utils.js'; import RelationQueries, { type GetEntitiesOptions, TwoRelationsQueries, @@ -292,20 +305,99 @@ class RoleUserRelationQueries extends RelationQueries< } } +class OrganizationInvitationsQueries extends SchemaQueries< + OrganizationInvitationKeys, + CreateOrganizationInvitation, + OrganizationInvitation +> { + override async findById(id: string): Promise> { + return this.pool.one(this.#findEntity(id)); + } + + override async findAll( + limit: number, + offset: number, + search?: SearchOptions + ): Promise<[totalNumber: number, rows: Readonly]> { + return Promise.all([ + this.findTotalNumber(search), + this.pool.any(this.#findEntity(undefined, limit, offset, search)), + ]); + } + + #findEntity( + invitationId?: string, + limit = 1, + offset = 0, + search?: SearchOptions + ) { + const { table, fields } = convertToIdentifiers(OrganizationInvitations, true); + const magicLinks = convertToIdentifiers(MagicLinks, true); + const roleRelations = convertToIdentifiers(OrganizationInvitationRoleRelations, true); + const roles = convertToIdentifiers(OrganizationRoles, true); + + return sql` + select + ${table}.*, + 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), + '[]' + ) as "organizationRoles", -- left join could produce nulls + ${buildJsonObjectSql(magicLinks.fields)} as "magicLink" + from ${table} + left join ${roleRelations.table} + on ${roleRelations.fields.invitationId} = ${fields.id} + left join ${roles.table} + on ${roles.fields.id} = ${roleRelations.fields.organizationRoleId} + left join ${magicLinks.table} + on ${magicLinks.fields.id} = ${fields.magicLinkId} + ${conditionalSql(invitationId, (id) => { + return sql`where ${fields.id} = ${id}`; + })} + ${buildSearchSql(OrganizationInvitations, search)} + group by ${fields.id}, ${magicLinks.fields.id} + ${conditionalSql(this.orderBy, ({ field, order }) => { + return sql`order by ${fields[field]} ${order === 'desc' ? sql`desc` : sql`asc`}`; + })} + limit ${limit} + offset ${offset} + `; + } +} export default class OrganizationQueries extends SchemaQueries< OrganizationKeys, CreateOrganization, Organization > { - /** Queries for roles in the organization template. */ + /** + * Queries for roles in the organization template. + * @see {@link OrganizationRoles} + */ roles = new OrganizationRolesQueries(this.pool, OrganizationRoles, { field: 'name', order: 'asc', }); - /** Queries for scopes in the organization template. */ + /** + * Queries for scopes in the organization template. + * @see {@link OrganizationScopes} + */ scopes = new SchemaQueries(this.pool, OrganizationScopes, { field: 'name', order: 'asc' }); + /** + * Queries for organization invitations. + * @see {@link OrganizationInvitations} + */ + invitations = new OrganizationInvitationsQueries(this.pool, OrganizationInvitations, { + field: 'createdAt', + order: 'desc', + }); + /** Queries for relations that connected with organization-related entities. */ relations = { /** Queries for organization role - organization scope relations. */ @@ -325,3 +417,4 @@ export default class OrganizationQueries extends SchemaQueries< super(pool, Organizations); } } +/* eslint-enable max-lines */ diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index ee34b9076..9efc735ee 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -8,6 +8,7 @@ import { import { yes } from '@silverhand/essentials'; import { z } from 'zod'; +import { EnvSet } from '#src/env-set/index.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'; @@ -18,6 +19,7 @@ import { parseSearchOptions } from '#src/utils/search.js'; import { type AuthedRouter, type RouterInitArgs } from '../types.js'; +import organizationInvitationRoutes from './invitations.js'; import organizationRoleRoutes from './roles.js'; import organizationScopeRoutes from './scopes.js'; import { errorHandler } from './utils.js'; @@ -237,6 +239,10 @@ export default function organizationRoutes(...args: Rout organizationRoleRoutes(...args); organizationScopeRoutes(...args); + if (EnvSet.values.isDevFeaturesEnabled) { + organizationInvitationRoutes(...args); + } + router.use(koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] })); // Add routes to the router diff --git a/packages/core/src/routes/organization/invitations.ts b/packages/core/src/routes/organization/invitations.ts new file mode 100644 index 000000000..fa134f94d --- /dev/null +++ b/packages/core/src/routes/organization/invitations.ts @@ -0,0 +1,40 @@ +import { OrganizationInvitations, organizationInvitationEntityGuard } from '@logto/schemas'; +import Router, { type IRouterParamContext } from 'koa-router'; + +import koaGuard from '#src/middleware/koa-guard.js'; +import koaPagination from '#src/middleware/koa-pagination.js'; +import { tableToPathname } from '#src/utils/SchemaRouter.js'; + +import { type AuthedRouter, type RouterInitArgs } from '../types.js'; + +export default function organizationInvitationRoutes( + ...[ + originalRouter, + { + queries: { + organizations: { invitations }, + }, + }, + ]: RouterInitArgs +) { + const router = new Router({ + prefix: '/' + tableToPathname(OrganizationInvitations.table), + }); + + router.get( + '/', + koaPagination(), + koaGuard({ response: organizationInvitationEntityGuard.array(), status: [200] }), + async (ctx, next) => { + const { limit, offset } = ctx.pagination; + const [count, entities] = await invitations.findAll(limit, offset); + + ctx.pagination.totalCount = count; + ctx.body = entities; + ctx.status = 200; + return next(); + } + ); + + originalRouter.use(router.routes()); +} diff --git a/packages/schemas/src/types/organization.ts b/packages/schemas/src/types/organization.ts index 450dab765..62ff95733 100644 --- a/packages/schemas/src/types/organization.ts +++ b/packages/schemas/src/types/organization.ts @@ -5,6 +5,10 @@ import { OrganizationRoles, type Organization, Organizations, + type OrganizationInvitation, + type MagicLink, + OrganizationInvitations, + MagicLinks, } from '../db-entries/index.js'; import { type UserInfo, type FeaturedUser, userInfoGuard } from './user.js'; @@ -81,3 +85,20 @@ export type OrganizationWithFeatured = Organization & { usersCount?: number; featuredUsers?: FeaturedUser[]; }; + +/** + * The organization invitation with additional fields: + * + * - `magicLink`: The magic link that can be used to accept the invitation. + * - `organizationRoles`: The roles to be assigned to the user when accepting the invitation. + */ +export type OrganizationInvitationEntity = OrganizationInvitation & { + magicLink: MagicLink; + organizationRoles: OrganizationRoleEntity[]; +}; + +export const organizationInvitationEntityGuard: z.ZodType = + OrganizationInvitations.guard.extend({ + magicLink: MagicLinks.guard, + organizationRoles: organizationRoleEntityGuard.array(), + }); diff --git a/packages/schemas/tables/magic_links.sql b/packages/schemas/tables/magic_links.sql index 5b6f66b2e..70edd37c0 100644 --- a/packages/schemas/tables/magic_links.sql +++ b/packages/schemas/tables/magic_links.sql @@ -1,10 +1,6 @@ /* init_order = 1 */ -/** - * Link that can be used to perform certain actions by verifying the token. The expiration time - * of the link should be determined by the action it performs, thus there is no `expires_at` - * column in this table. - */ +/** Link that can be used to perform certain actions by verifying the token. The expiration time of the link should be determined by the action it performs, thus there is no `expires_at` column in this table. */ create table magic_links ( tenant_id varchar(21) not null references tenants (id) on update cascade on delete cascade,