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:
parent
41f7b4d8ad
commit
75b643ad2f
6 changed files with 216 additions and 9 deletions
|
@ -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`, `
|
||||
)}
|
||||
)
|
||||
`;
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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
|
||||
|
|
40
packages/core/src/routes/organization/invitations.ts
Normal file
40
packages/core/src/routes/organization/invitations.ts
Normal 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());
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Reference in a new issue