0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

feat(schemas, core): init organization invitation apis

This commit is contained in:
Gao Sun 2024-01-11 18:08:20 +08:00
parent 41f7b4d8ad
commit 75b643ad2f
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
6 changed files with 216 additions and 9 deletions

View file

@ -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 = <Keys extends string>(schema: Table<Keys>, 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 extends FieldIdentifiers<string>>(
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`, `
)}
)
`;

View file

@ -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<Readonly<OrganizationInvitationEntity>> {
return this.pool.one(this.#findEntity(id));
}
override async findAll(
limit: number,
offset: number,
search?: SearchOptions<OrganizationInvitationKeys>
): Promise<[totalNumber: number, rows: Readonly<OrganizationInvitationEntity[]>]> {
return Promise.all([
this.findTotalNumber(search),
this.pool.any(this.#findEntity(undefined, limit, offset, search)),
]);
}
#findEntity(
invitationId?: string,
limit = 1,
offset = 0,
search?: SearchOptions<OrganizationInvitationKeys>
) {
const { table, fields } = convertToIdentifiers(OrganizationInvitations, true);
const magicLinks = convertToIdentifiers(MagicLinks, true);
const roleRelations = convertToIdentifiers(OrganizationInvitationRoleRelations, true);
const roles = convertToIdentifiers(OrganizationRoles, true);
return sql<OrganizationInvitationEntity>`
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 */

View file

@ -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<T extends AuthedRouter>(...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

View file

@ -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<T extends AuthedRouter>(
...[
originalRouter,
{
queries: {
organizations: { invitations },
},
},
]: RouterInitArgs<T>
) {
const router = new Router<unknown, IRouterParamContext>({
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());
}

View file

@ -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<OrganizationInvitationEntity> =
OrganizationInvitations.guard.extend({
magicLink: MagicLinks.guard,
organizationRoles: organizationRoleEntityGuard.array(),
});

View file

@ -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,