0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): organization email domains apis

This commit is contained in:
Gao Sun 2024-06-06 10:33:24 +08:00
parent d66d8c2286
commit b85ecb6c93
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
6 changed files with 311 additions and 1 deletions

View file

@ -0,0 +1,104 @@
import {
type OrganizationEmailDomain,
OrganizationEmailDomains,
type CreateOrganizationEmailDomain,
} from '@logto/schemas';
import { type CommonQueryMethods, sql } from '@silverhand/slonik';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { DeletionError } from '#src/errors/SlonikError/index.js';
import { type GetEntitiesOptions } from '#src/utils/RelationQueries.js';
import { type OmitAutoSetFields, conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
const { table, fields } = convertToIdentifiers(OrganizationEmailDomains);
export class EmailDomainQueries {
readonly #insert: (
data: OmitAutoSetFields<CreateOrganizationEmailDomain>
) => Promise<Readonly<OrganizationEmailDomain>>;
constructor(protected pool: CommonQueryMethods) {
this.#insert = buildInsertIntoWithPool(this.pool)(OrganizationEmailDomains, {
returning: true,
});
}
async getEntities(
organizationId: string,
options: GetEntitiesOptions
): Promise<[number, readonly OrganizationEmailDomain[]]> {
const { limit, offset } = options;
const mainSql = sql`
from ${table}
where ${fields.organizationId} = ${organizationId}
`;
const [{ count }, rows] = await Promise.all([
this.pool.one<{ count: string }>(sql`
select count(*)
${mainSql}
`),
this.pool.any<OrganizationEmailDomain>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
${mainSql}
${conditionalSql(limit, (limit) => sql`limit ${limit}`)}
${conditionalSql(offset, (offset) => sql`offset ${offset}`)}
`),
]);
return [Number(count), rows];
}
async insert(organizationId: string, emailDomain: string): Promise<OrganizationEmailDomain> {
return this.#insert({
organizationId,
emailDomain,
});
}
async delete(organizationId: string, emailDomain: string): Promise<void> {
const { rowCount } = await this.pool.query(sql`
delete from ${table}
where ${fields.organizationId} = ${organizationId}
and ${fields.emailDomain} = ${emailDomain}
`);
if (rowCount < 1) {
throw new DeletionError(OrganizationEmailDomains.table);
}
}
async replace(organizationId: string, emailDomains: readonly string[]): Promise<void> {
return this.pool.transaction(async (transaction) => {
// Lock organization
await transaction.query(sql`
select ${fields.organizationId}
from ${table}
where ${fields.organizationId} = ${organizationId}
for update
`);
// Delete old email domains
await transaction.query(sql`
delete from ${table}
where ${fields.organizationId} = ${organizationId}
`);
// Insert new email domains
if (emailDomains.length === 0) {
return;
}
await transaction.query(sql`
insert into ${table} (
${fields.organizationId},
${fields.emailDomain}
)
values ${sql.join(
emailDomains.map((emailDomain) => sql`(${organizationId}, ${emailDomain})`),
sql`, `
)}
`);
});
}
}

View file

@ -28,6 +28,7 @@ import { TwoRelationsQueries } from '#src/utils/RelationQueries.js';
import SchemaQueries from '#src/utils/SchemaQueries.js';
import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
import { EmailDomainQueries } from './email-domains.js';
import { RoleUserRelationQueries } from './role-user-relations.js';
import { UserRelationQueries } from './user-relations.js';
@ -288,6 +289,9 @@ export default class OrganizationQueries extends SchemaQueries<
),
};
/** Queries for email domains that will be automatically provisioned. */
emailDomains = new EmailDomainQueries(this.pool);
constructor(pool: CommonQueryMethods) {
super(pool, Organizations);
}

View file

@ -0,0 +1,85 @@
import { OrganizationEmailDomains } from '@logto/schemas';
import { type IRouterParamContext } from 'koa-router';
import type Router from 'koa-router';
import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import type OrganizationQueries from '#src/queries/organization/index.js';
export default function emailDomainRoutes(
router: Router<unknown, IRouterParamContext>,
organizations: OrganizationQueries
) {
const params = Object.freeze({ id: z.string().min(1) });
const pathname = '/:id/email-domains';
router.get(
pathname,
koaPagination(),
koaGuard({
params: z.object(params),
response: OrganizationEmailDomains.guard.array(),
status: [200],
}),
async (ctx, next) => {
const { id } = ctx.guard.params;
const { limit, offset } = ctx.pagination;
const [count, rows] = await organizations.emailDomains.getEntities(id, { limit, offset });
ctx.pagination.totalCount = count;
ctx.body = rows;
return next();
}
);
router.post(
pathname,
koaGuard({
params: z.object(params),
body: z.object({ emailDomain: z.string().min(1) }),
response: OrganizationEmailDomains.guard,
status: [201],
}),
async (ctx, next) => {
const { id } = ctx.guard.params;
const { emailDomain } = ctx.guard.body;
ctx.body = await organizations.emailDomains.insert(id, emailDomain);
ctx.status = 201;
return next();
}
);
router.put(
pathname,
koaGuard({
params: z.object(params),
body: z.object({ emailDomains: z.string().min(1).array().nonempty() }),
status: [204],
}),
async (ctx, next) => {
const { id } = ctx.guard.params;
const { emailDomains } = ctx.guard.body;
await organizations.emailDomains.replace(id, emailDomains);
ctx.status = 204;
return next();
}
);
router.delete(
`${pathname}/:emailDomain`,
koaGuard({
params: z.object({ ...params, emailDomain: z.string().min(1) }),
status: [204],
}),
async (ctx, next) => {
const { id, emailDomain } = ctx.guard.params;
await organizations.emailDomains.delete(id, emailDomain);
ctx.status = 204;
return next();
}
);
}

View file

@ -7,6 +7,7 @@ import {
import { yes } from '@silverhand/essentials';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
@ -16,6 +17,7 @@ import { parseSearchOptions } from '#src/utils/search.js';
import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
import emailDomainRoutes from './index.email-domains.js';
import userRoleRelationRoutes from './index.user-role-relations.js';
import organizationInvitationRoutes from './invitations.js';
import organizationRoleRoutes from './roles.js';
@ -133,9 +135,12 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
}
);
// MARK: Organization - user - organization role relation routes
userRoleRelationRoutes(router, organizations);
if (EnvSet.values.isDevFeaturesEnabled) {
emailDomainRoutes(router, organizations);
}
// MARK: Mount sub-routes
organizationRoleRoutes(...args);
organizationScopeRoutes(...args);

View file

@ -5,6 +5,7 @@ import {
type UserWithOrganizationRoles,
type OrganizationWithFeatured,
type OrganizationScope,
type OrganizationEmailDomain,
} from '@logto/schemas';
import { authedAdminApi } from './api.js';
@ -74,4 +75,36 @@ export class OrganizationApi extends ApiFactory<
.get(`${this.path}/${id}/users/${userId}/scopes`)
.json<OrganizationScope[]>();
}
async getEmailDomains(
id: string,
page?: number,
pageSize?: number
): Promise<OrganizationEmailDomain[]> {
const searchParams = new URLSearchParams();
if (page) {
searchParams.append('page', String(page));
}
if (pageSize) {
searchParams.append('page_size', String(pageSize));
}
return authedAdminApi
.get(`${this.path}/${id}/email-domains`, { searchParams })
.json<OrganizationEmailDomain[]>();
}
async addEmailDomain(id: string, emailDomain: string): Promise<void> {
await authedAdminApi.post(`${this.path}/${id}/email-domains`, { json: { emailDomain } });
}
async deleteEmailDomain(id: string, emailDomain: string): Promise<void> {
await authedAdminApi.delete(`${this.path}/${id}/email-domains/${emailDomain}`);
}
async replaceEmailDomains(id: string, emailDomains: string[]): Promise<void> {
await authedAdminApi.put(`${this.path}/${id}/email-domains`, { json: { emailDomains } });
}
}

View file

@ -0,0 +1,79 @@
import { generateStandardId } from '@logto/shared';
import { OrganizationApiTest } from '#src/helpers/organization.js';
const randomId = () => generateStandardId(6);
describe('organization email domains', () => {
const organizationApi = new OrganizationApiTest();
afterEach(async () => {
await organizationApi.cleanUp();
});
it('should add and delete email domains', async () => {
const organization = await organizationApi.create({ name: 'foo' });
const emailDomain = `${randomId()}.com`;
await organizationApi.addEmailDomain(organization.id, emailDomain);
await expect(organizationApi.getEmailDomains(organization.id)).resolves.toMatchObject([
{ emailDomain },
]);
await organizationApi.deleteEmailDomain(organization.id, emailDomain);
await expect(organizationApi.getEmailDomains(organization.id)).resolves.toEqual([]);
});
it('should have default pagination', async () => {
const organization = await organizationApi.create({ name: 'foo' });
const emailDomains = Array.from({ length: 30 }, () => `${randomId()}.com`);
await organizationApi.replaceEmailDomains(organization.id, emailDomains);
const emailDomainsPage1 = await organizationApi.getEmailDomains(organization.id);
const emailDomainsPage2 = await organizationApi.getEmailDomains(organization.id, 2);
expect(emailDomainsPage1).toHaveLength(20);
expect(emailDomainsPage2).toHaveLength(10);
expect(emailDomainsPage1).toMatchObject(
emailDomains.slice(0, 20).map((emailDomain) => expect.objectContaining({ emailDomain }))
);
expect(emailDomainsPage2).toEqual(
emailDomains.slice(20).map((emailDomain) => expect.objectContaining({ emailDomain }))
);
});
it('should return 404 when deleting a non-existent email domain', async () => {
const organization = await organizationApi.create({ name: 'foo' });
const emailDomain = `${randomId()}.com`;
await expect(
organizationApi.deleteEmailDomain(organization.id, emailDomain)
).rejects.toMatchInlineSnapshot('[HTTPError: Request failed with status code 404 Not Found]');
});
it('should return 400 when adding an email domain that already exists', async () => {
const organization = await organizationApi.create({ name: 'foo' });
const emailDomain = `${randomId()}.com`;
await organizationApi.addEmailDomain(organization.id, emailDomain);
await expect(
organizationApi.addEmailDomain(organization.id, emailDomain)
).rejects.toMatchInlineSnapshot(
'[HTTPError: Request failed with status code 422 Unprocessable Entity]'
);
});
it('should be able to replace email domains', async () => {
const organization = await organizationApi.create({ name: 'foo' });
await organizationApi.addEmailDomain(organization.id, `${randomId()}.com`);
const emailDomains = [`${randomId()}.com`, `${randomId()}.com`];
await organizationApi.replaceEmailDomains(organization.id, emailDomains);
await expect(organizationApi.getEmailDomains(organization.id)).resolves.toMatchObject(
emailDomains.map((emailDomain) => expect.objectContaining({ emailDomain }))
);
});
});