0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

refactor: update invitation api payload for email

This commit is contained in:
Gao Sun 2024-01-29 21:27:04 +08:00
parent 6d0f95739c
commit 46c550bf43
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
7 changed files with 83 additions and 131 deletions

View file

@ -1,4 +1,4 @@
import { ConnectorType, TemplateType } from '@logto/connector-kit';
import { ConnectorType, type SendMessagePayload, TemplateType } from '@logto/connector-kit';
import {
OrganizationInvitationStatus,
type CreateOrganizationInvitation,
@ -46,14 +46,15 @@ export class OrganizationInvitationLibrary {
* @param data.organizationId The ID of the organization to invite to.
* @param data.expiresAt The epoch time in milliseconds when the invitation expires.
* @param data.organizationRoleIds The IDs of the organization roles to assign to the invitee.
* @param skipEmail Whether to skip sending the invitation email. Defaults to `false`.
* @param messagePayload The payload to send in the email. If it is `false`, the email will be
* skipped.
*/
async insert(
data: Pick<
CreateOrganizationInvitation,
'inviterId' | 'invitee' | 'organizationId' | 'expiresAt'
> & { organizationRoleIds?: string[] },
skipEmail = false
messagePayload: SendMessagePayload | false
) {
const { inviterId, invitee, organizationId, expiresAt, organizationRoleIds } = data;
@ -74,8 +75,8 @@ export class OrganizationInvitationLibrary {
);
}
if (!skipEmail) {
await this.sendEmail(invitee);
if (messagePayload) {
await this.sendEmail(invitee, messagePayload);
}
// Additional query to get the full invitation data
@ -185,14 +186,12 @@ export class OrganizationInvitationLibrary {
});
}
protected async sendEmail(to: string) {
protected async sendEmail(to: string, payload: SendMessagePayload) {
const emailConnector = await this.connector.getMessageConnector(ConnectorType.Email);
return emailConnector.sendMessage({
to,
type: TemplateType.OrganizationInvitation,
payload: {
link: 'TODO',
},
payload,
});
}
}

View file

@ -13,24 +13,13 @@
"parameters": [],
"responses": {
"200": {
"description": "A list of organization invitations, each item also contains the organization roles to be assigned to the user when they accept the invitation, and the corresponding magic link data."
"description": "A list of organization invitations, each item also contains the organization roles to be assigned to the user when they accept the invitation."
}
}
},
"post": {
"summary": "Create organization invitation",
"description": "Create an organization invitation and optionally send it via email. The tenant should have an email connector configured if you want to send the invitation via email at this point.",
"parameters": [
{
"in": "query",
"name": "skipEmail",
"description": "If true, the invitation will not be sent via email; otherwise, the invitation will be sent via email when it is created. If the email is failed to send, the invitation will not be created.",
"required": false,
"schema": {
"default": false
}
}
],
"requestBody": {
"description": "The organization invitation to create.",
"required": true,
@ -52,6 +41,9 @@
},
"organizationRoleIds": {
"description": "The IDs of the organization roles to assign to the user when they accept the invitation."
},
"messagePayload": {
"description": "The message payload for the \"OrganizationInvitation\" template to use when sending the invitation via email. If it is `false`, the invitation will not be sent via email."
}
}
}
@ -60,7 +52,7 @@
},
"responses": {
"201": {
"description": "The organization invitation was created successfully, and the corresponding magic link data."
"description": "The organization invitation was created successfully."
},
"501": {
"description": "No email connector is configured for the tenant."
@ -74,7 +66,7 @@
"description": "Get an organization invitation by ID.",
"responses": {
"200": {
"description": "The organization invitation, also contains the organization roles to be assigned to the user when they accept the invitation, and the corresponding magic link data."
"description": "The organization invitation, also contains the organization roles to be assigned to the user when they accept the invitation."
}
}
},

View file

@ -1,9 +1,9 @@
import { sendMessagePayloadGuard } from '@logto/connector-kit';
import {
OrganizationInvitationStatus,
OrganizationInvitations,
organizationInvitationEntityGuard,
} from '@logto/schemas';
import { yes } from '@silverhand/essentials';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
@ -38,9 +38,6 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
router.post(
'/',
koaGuard({
query: z.object({
skipEmail: z.string().optional(),
}),
body: OrganizationInvitations.createGuard
.pick({
inviterId: true,
@ -51,12 +48,15 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
.extend({
invitee: z.string().email(),
organizationRoleIds: z.string().array().optional(),
messagePayload: sendMessagePayloadGuard.or(z.literal(false)).default(false),
}),
response: organizationInvitationEntityGuard,
status: [201],
}),
async (ctx, next) => {
const { query, body } = ctx.guard;
const {
body: { messagePayload, ...body },
} = ctx.guard;
assertThat(
body.expiresAt > Date.now(),
@ -66,7 +66,7 @@ export default function organizationInvitationRoutes<T extends AuthedRouter>(
})
);
ctx.body = await organizationInvitations.insert(body, yes(query.skipEmail));
ctx.body = await organizationInvitations.insert(body, messagePayload);
ctx.status = 201;
return next();
}

View file

@ -1,3 +1,4 @@
import { type SendMessagePayload } from '@logto/connector-kit';
import {
type OrganizationInvitationStatus,
type OrganizationInvitationEntity,
@ -12,6 +13,7 @@ export type PostOrganizationInvitationData = {
organizationId: string;
expiresAt: number;
organizationRoleIds?: string[];
emailPayload?: SendMessagePayload | false;
};
export class OrganizationInvitationApi extends ApiFactory<
@ -22,15 +24,8 @@ export class OrganizationInvitationApi extends ApiFactory<
super('organization-invitations');
}
override async create(data: PostOrganizationInvitationData, skipEmail = false) {
return authedAdminApi
.post(this.path, {
searchParams: {
skipEmail: skipEmail.toString(),
},
json: data,
})
.json<OrganizationInvitationEntity>();
override async create(json: PostOrganizationInvitationData) {
return authedAdminApi.post(this.path, { json }).json<OrganizationInvitationEntity>();
}
async updateStatus(id: string, status: OrganizationInvitationStatus, acceptedUserId?: string) {

View file

@ -27,10 +27,9 @@ export class OrganizationInvitationApiTest extends OrganizationInvitationApi {
}
override async create(
data: PostOrganizationInvitationData,
skipEmail = false
data: PostOrganizationInvitationData
): Promise<OrganizationInvitationEntity> {
const created = await super.create(data, skipEmail);
const created = await super.create(data);
this.invitations.push(created);
return created;
}

View file

@ -29,27 +29,21 @@ describe('organization invitation creation', () => {
it('should be able to create an invitation without sending email', async () => {
const organization = await organizationApi.create({ name: 'test' });
await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
},
true
);
await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
});
it('should not be able to create invitations with the same email', async () => {
const organization = await organizationApi.create({ name: 'test' });
const email = `${randomId()}@example.com`;
await invitationApi.create(
{
organizationId: organization.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
},
true
);
await invitationApi.create({
organizationId: organization.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
});
const error = await invitationApi
.create({
organizationId: organization.id,
@ -68,22 +62,16 @@ describe('organization invitation creation', () => {
]);
const email = `${randomId()}@example.com`;
await Promise.all([
invitationApi.create(
{
organizationId: organization1.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
},
true
),
invitationApi.create(
{
organizationId: organization2.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
},
true
),
invitationApi.create({
organizationId: organization1.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
}),
invitationApi.create({
organizationId: organization2.id,
invitee: email,
expiresAt: Date.now() + 1_000_000,
}),
]);
});
@ -128,15 +116,12 @@ describe('organization invitation creation', () => {
it('should be able to create invitations with organization role ids', async () => {
const organization = await organizationApi.create({ name: 'test' });
const role = await organizationApi.roleApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
organizationRoleIds: [role.id],
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
organizationRoleIds: [role.id],
});
expect(invitation.organizationRoles.map((role) => role.id)).toEqual([role.id]);
// Test if get invitation by id works

View file

@ -33,14 +33,11 @@ describe('organization invitation status update', () => {
it('should expire invitations and disable update after the expiration date', async () => {
const organization = await organizationApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 100,
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 100,
});
expect(invitation.status).toBe('Pending');
await new Promise((resolve) => {
@ -58,14 +55,11 @@ describe('organization invitation status update', () => {
it('should be able to accept an invitation', async () => {
const organization = await organizationApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
expect(invitation.status).toBe('Pending');
const user = await userApi.create({
@ -91,15 +85,12 @@ describe('organization invitation status update', () => {
it('should be able to accept an invitation with roles', async () => {
const organization = await organizationApi.create({ name: 'test' });
const role = await organizationApi.roleApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
organizationRoleIds: [role.id],
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
organizationRoleIds: [role.id],
});
expect(invitation.status).toBe('Pending');
const user = await userApi.create({
@ -125,14 +116,11 @@ describe('organization invitation status update', () => {
it('should not be able to accept an invitation with a different email', async () => {
const organization = await organizationApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
expect(invitation.status).toBe('Pending');
const user = await userApi.create({
@ -147,14 +135,11 @@ describe('organization invitation status update', () => {
it('should not be able to accept an invitation with an invalid user id', async () => {
const organization = await organizationApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
expect(invitation.status).toBe('Pending');
const error = await invitationApi
@ -166,14 +151,11 @@ describe('organization invitation status update', () => {
it('should not be able to update the status of an ended invitation', async () => {
const organization = await organizationApi.create({ name: 'test' });
const invitation = await invitationApi.create(
{
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
},
true
);
const invitation = await invitationApi.create({
organizationId: organization.id,
invitee: `${randomId()}@example.com`,
expiresAt: Date.now() + 1_000_000,
});
expect(invitation.status).toBe('Pending');
await invitationApi.updateStatus(invitation.id, OrganizationInvitationStatus.Revoked);