Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): organization email domains apis #5995

Merged
merged 3 commits into from
Jun 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions packages/core/src/queries/organization/email-domains.ts
Original file line number Diff line number Diff line change
@@ -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];
}

Check warning on line 50 in packages/core/src/queries/organization/email-domains.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/queries/organization/email-domains.ts#L27-L50

Added lines #L27 - L50 were not covered by tests

async insert(organizationId: string, emailDomain: string): Promise<OrganizationEmailDomain> {
return this.#insert({
organizationId,
emailDomain,
});
}

Check warning on line 57 in packages/core/src/queries/organization/email-domains.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/queries/organization/email-domains.ts#L53-L57

Added lines #L53 - L57 were not covered by tests

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);
}
}

Check warning on line 69 in packages/core/src/queries/organization/email-domains.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/queries/organization/email-domains.ts#L60-L69

Added lines #L60 - L69 were not covered by tests

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`, `
)}
`);
});
}

Check warning on line 103 in packages/core/src/queries/organization/email-domains.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/queries/organization/email-domains.ts#L72-L103

Added lines #L72 - L103 were not covered by tests
}
4 changes: 4 additions & 0 deletions packages/core/src/queries/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
{
"tags": [
{
"name": "Organizations"
}
],
"paths": {
"/api/organizations/{id}/email-domains": {
"get": {
"summary": "Get organization email domains",
"description": "Get email domains for just-in-time provisioning of users in the organization.",
"responses": {
"200": {
"description": "A list of email domains."
}
}
},
"post": {
"summary": "Add organization email domain",
"description": "Add a new email domain for just-in-time provisioning of users in the organization.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"emailDomain": {
"description": "The email domain to add."
}
}
}
}
}
},
"responses": {
"201": {
"description": "The email domain was added successfully."
},
"422": {
"description": "The email domain is already in use."
}
}
},
"put": {
"summary": "Replace organization email domains",
"description": "Replace all just-in-time provisioning email domains for the organization with the given data.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"properties": {
"emailDomains": {
"description": "An array of email domains to replace existing email domains."
}
}
}
}
}
},
"responses": {
"204": {
"description": "The email domains were replaced successfully."
}
}
}
},
"/api/organizations/{id}/email-domains/{emailDomain}": {
"delete": {
"summary": "Remove organization email domain",
"description": "Remove an email domain for just-in-time provisioning of users in the organization.",
"parameters": [
{
"schema": {
"type": "string"
},
"name": "emailDomain",
"in": "path",
"required": true,
"description": "The email domain to remove."
}
],
"responses": {
"204": {
"description": "The email domain was removed successfully."
},
"404": {
"description": "The email domain was not found."
}
}
}
}
}
}
85 changes: 85 additions & 0 deletions packages/core/src/routes/organization/index.email-domains.ts
Original file line number Diff line number Diff line change
@@ -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(
gao-sun marked this conversation as resolved.
Show resolved Hide resolved
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();
}

Check warning on line 33 in packages/core/src/routes/organization/index.email-domains.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/organization/index.email-domains.ts#L26-L33

Added lines #L26 - L33 were not covered by tests
);

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();
}

Check warning on line 51 in packages/core/src/routes/organization/index.email-domains.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/organization/index.email-domains.ts#L45-L51

Added lines #L45 - L51 were not covered by tests
);

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();
}

Check warning on line 68 in packages/core/src/routes/organization/index.email-domains.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/organization/index.email-domains.ts#L62-L68

Added lines #L62 - L68 were not covered by tests
);

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();
}

Check warning on line 83 in packages/core/src/routes/organization/index.email-domains.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/organization/index.email-domains.ts#L78-L83

Added lines #L78 - L83 were not covered by tests
);
}
7 changes: 6 additions & 1 deletion packages/core/src/routes/organization/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions packages/integration-tests/src/api/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type UserWithOrganizationRoles,
type OrganizationWithFeatured,
type OrganizationScope,
type OrganizationEmailDomain,
} from '@logto/schemas';

import { authedAdminApi } from './api.js';
Expand Down Expand Up @@ -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 } });
}
}
Loading
Loading