From e747f9d4aa2adc94e1e71889f9181ff994abde03 Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Tue, 27 Feb 2024 15:16:52 +0800 Subject: [PATCH] feat: Refresh invite (#6469) --- .../__tests__/invites/resend-invite.spec.ts | 67 +++++++++++++++++++ packages/core-flows/src/invite/steps/index.ts | 2 + .../src/invite/steps/refresh-invite-tokens.ts | 18 +++++ .../core-flows/src/invite/workflows/index.ts | 1 + .../invite/workflows/refresh-invite-tokens.ts | 14 ++++ .../api-v2/admin/invites/[id]/resend/route.ts | 15 +++++ packages/types/src/user/service.ts | 7 +- packages/types/src/workflow/invite/index.ts | 1 + .../src/workflow/invite/resend-invite.ts | 3 + .../__tests__/services/module/invite.spec.ts | 12 ++++ packages/user/src/services/invite.ts | 51 +++++++++++++- packages/user/src/services/user-module.ts | 63 ++++++++++++++++- packages/utils/src/user/events.ts | 1 + 13 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 integration-tests/plugins/__tests__/invites/resend-invite.spec.ts create mode 100644 packages/core-flows/src/invite/steps/refresh-invite-tokens.ts create mode 100644 packages/core-flows/src/invite/workflows/refresh-invite-tokens.ts create mode 100644 packages/medusa/src/api-v2/admin/invites/[id]/resend/route.ts create mode 100644 packages/types/src/workflow/invite/resend-invite.ts diff --git a/integration-tests/plugins/__tests__/invites/resend-invite.spec.ts b/integration-tests/plugins/__tests__/invites/resend-invite.spec.ts new file mode 100644 index 0000000000..5a1237d160 --- /dev/null +++ b/integration-tests/plugins/__tests__/invites/resend-invite.spec.ts @@ -0,0 +1,67 @@ +import { IAuthModuleService, IUserModuleService } from "@medusajs/types" +import { initDb, useDb } from "../../../environment-helpers/use-db" + +import { AxiosInstance } from "axios" +import { ModuleRegistrationName } from "@medusajs/modules-sdk" +import { createAdminUser } from "../../helpers/create-admin-user" +import { getContainer } from "../../../environment-helpers/use-container" +import path from "path" +import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app" +import { useApi } from "../../../environment-helpers/use-api" + +jest.setTimeout(50000) + +const env = { MEDUSA_FF_MEDUSA_V2: true } +const adminHeaders = { + headers: { "x-medusa-access-token": "test_token" }, +} + +describe("POST /admin/invites/:id/resend", () => { + let dbConnection + let appContainer + let shutdownServer + let userModuleService: IUserModuleService + + beforeAll(async () => { + const cwd = path.resolve(path.join(__dirname, "..", "..")) + dbConnection = await initDb({ cwd, env } as any) + shutdownServer = await startBootstrapApp({ cwd, env }) + appContainer = getContainer() + userModuleService = appContainer.resolve(ModuleRegistrationName.USER) + }) + + beforeEach(async () => { + await createAdminUser(dbConnection, adminHeaders) + }) + + afterAll(async () => { + const db = useDb() + await db.shutdown() + await shutdownServer() + }) + + afterEach(async () => { + const db = useDb() + await db.teardown() + }) + + it("should resend a single invite", async () => { + const invite = await userModuleService.createInvites({ + email: "potential_member@test.com", + }) + + const api = useApi()! as AxiosInstance + + const response = await api.post( + `/admin/invites/${invite.id}/resend`, + {}, + adminHeaders + ) + + expect(response.status).toEqual(200) + expect(response.data.invite.token).not.toEqual(invite.token) + expect(response.data.invite).toEqual( + expect.objectContaining({ email: "potential_member@test.com" }) + ) + }) +}) diff --git a/packages/core-flows/src/invite/steps/index.ts b/packages/core-flows/src/invite/steps/index.ts index 1f17f0e332..63ccad53ce 100644 --- a/packages/core-flows/src/invite/steps/index.ts +++ b/packages/core-flows/src/invite/steps/index.ts @@ -1,2 +1,4 @@ export * from "./create-invites" export * from "./delete-invites" +export * from "./refresh-invite-tokens" +export * from "./validate-token" diff --git a/packages/core-flows/src/invite/steps/refresh-invite-tokens.ts b/packages/core-flows/src/invite/steps/refresh-invite-tokens.ts new file mode 100644 index 0000000000..bbc9329ca4 --- /dev/null +++ b/packages/core-flows/src/invite/steps/refresh-invite-tokens.ts @@ -0,0 +1,18 @@ +import { IUserModuleService, InviteDTO } from "@medusajs/types" +import { StepResponse, createStep } from "@medusajs/workflows-sdk" + +import { ModuleRegistrationName } from "@medusajs/modules-sdk" + +export const refreshInviteTokensStepId = "refresh-invite-tokens-step" +export const refreshInviteTokensStep = createStep( + refreshInviteTokensStepId, + async (input: string[], { container }) => { + const service: IUserModuleService = container.resolve( + ModuleRegistrationName.USER + ) + + const invites = await service.refreshInviteTokens(input) + + return new StepResponse(invites) + } +) diff --git a/packages/core-flows/src/invite/workflows/index.ts b/packages/core-flows/src/invite/workflows/index.ts index db6489b250..dbed38dec8 100644 --- a/packages/core-flows/src/invite/workflows/index.ts +++ b/packages/core-flows/src/invite/workflows/index.ts @@ -1,3 +1,4 @@ export * from "./create-invites" export * from "./delete-invites" export * from "./accept-invite" +export * from "./refresh-invite-tokens" diff --git a/packages/core-flows/src/invite/workflows/refresh-invite-tokens.ts b/packages/core-flows/src/invite/workflows/refresh-invite-tokens.ts new file mode 100644 index 0000000000..129563be18 --- /dev/null +++ b/packages/core-flows/src/invite/workflows/refresh-invite-tokens.ts @@ -0,0 +1,14 @@ +import { InviteDTO, InviteWorkflow } from "@medusajs/types" +import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk" + +import { refreshInviteTokensStep } from "../steps/refresh-invite-tokens" + +export const refreshInviteTokensWorkflowId = "refresh-invite-tokens-workflow" +export const refreshInviteTokensWorkflow = createWorkflow( + refreshInviteTokensWorkflowId, + ( + input: WorkflowData + ): WorkflowData => { + return refreshInviteTokensStep(input.invite_ids) + } +) diff --git a/packages/medusa/src/api-v2/admin/invites/[id]/resend/route.ts b/packages/medusa/src/api-v2/admin/invites/[id]/resend/route.ts new file mode 100644 index 0000000000..962644173e --- /dev/null +++ b/packages/medusa/src/api-v2/admin/invites/[id]/resend/route.ts @@ -0,0 +1,15 @@ +import { MedusaRequest, MedusaResponse } from "../../../../../types/routing" + +import { refreshInviteTokensWorkflow } from "@medusajs/core-flows" + +export const POST = async (req: MedusaRequest, res: MedusaResponse) => { + const workflow = refreshInviteTokensWorkflow(req.scope) + + const input = { + invite_ids: [req.params.id], + } + + const { result: invites } = await workflow.run({ input }) + + res.status(200).json({ invite: invites[0] }) +} diff --git a/packages/types/src/user/service.ts b/packages/types/src/user/service.ts index ed55201934..38eccf8b15 100644 --- a/packages/types/src/user/service.ts +++ b/packages/types/src/user/service.ts @@ -5,11 +5,11 @@ import { UpdateUserDTO, } from "./mutations" import { FilterableUserProps, InviteDTO, UserDTO } from "./common" +import { RestoreReturn, SoftDeleteReturn } from "../dal" import { Context } from "../shared-context" import { FindConfig } from "../common" import { IModuleService } from "../modules-sdk" -import { RestoreReturn, SoftDeleteReturn } from "../dal" /** * The main service interface for the user module. @@ -27,6 +27,11 @@ export interface IUserModuleService extends IModuleService { sharedContext?: Context ): Promise + refreshInviteTokens( + inviteIds: string[], + sharedContext?: Context + ): Promise + /** * This method retrieves a user by its ID. * diff --git a/packages/types/src/workflow/invite/index.ts b/packages/types/src/workflow/invite/index.ts index b4703692ee..b47e51e21b 100644 --- a/packages/types/src/workflow/invite/index.ts +++ b/packages/types/src/workflow/invite/index.ts @@ -1,3 +1,4 @@ export * from "./create-invite" export * from "./delete-invite" export * from "./accept-invite" +export * from "./resend-invite" diff --git a/packages/types/src/workflow/invite/resend-invite.ts b/packages/types/src/workflow/invite/resend-invite.ts new file mode 100644 index 0000000000..e24ee8e13e --- /dev/null +++ b/packages/types/src/workflow/invite/resend-invite.ts @@ -0,0 +1,3 @@ +export interface ResendInvitesWorkflowInputDTO { + invite_ids: string[] +} diff --git a/packages/user/integration-tests/__tests__/services/module/invite.spec.ts b/packages/user/integration-tests/__tests__/services/module/invite.spec.ts index 7eab2e5bdb..2b82c08112 100644 --- a/packages/user/integration-tests/__tests__/services/module/invite.spec.ts +++ b/packages/user/integration-tests/__tests__/services/module/invite.spec.ts @@ -234,6 +234,18 @@ describe("UserModuleService - Invite", () => { }), eventName: UserEvents.invite_created, }), + expect.objectContaining({ + body: expect.objectContaining({ + data: { id: "1" }, + }), + eventName: "invite.token_generated", + }), + expect.objectContaining({ + body: expect.objectContaining({ + data: { id: "2" }, + }), + eventName: "invite.token_generated", + }), ]) }) }) diff --git a/packages/user/src/services/invite.ts b/packages/user/src/services/invite.ts index f837e595f4..553735bad7 100644 --- a/packages/user/src/services/invite.ts +++ b/packages/user/src/services/invite.ts @@ -1,8 +1,11 @@ +import * as crypto from "crypto" + import { Context, DAL } from "@medusajs/types" import { InjectTransactionManager, MedusaError, ModulesSdkUtils, + arrayDifference, } from "@medusajs/utils" import jwt, { JwtPayload } from "jsonwebtoken" @@ -13,8 +16,8 @@ type InjectedDependencies = { inviteRepository: DAL.RepositoryService } -// 7 days -const DEFAULT_VALID_INVITE_DURATION = 1000 * 60 * 60 * 24 * 7 +// 1 day +const DEFAULT_VALID_INVITE_DURATION = 60 * 60 * 24 export default class InviteService< TEntity extends Invite = Invite @@ -85,6 +88,48 @@ export default class InviteService< return await super.update(updates, context) } + @InjectTransactionManager("inviteRepository_") + async refreshInviteTokens( + inviteIds: string[], + context: Context = {} + ): Promise { + const [invites, count] = await super.listAndCount( + { id: inviteIds }, + {}, + context + ) + + if (count !== inviteIds.length) { + const missing = arrayDifference( + inviteIds, + invites.map((invite) => invite.id) + ) + + if (missing.length > 0) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `The following invites do not exist: ${missing.join(", ")}` + ) + } + } + + const expiresIn: number = + parseInt(this.getOption("valid_duration")) || + DEFAULT_VALID_INVITE_DURATION + + const updates = invites.map((invite) => { + return { + id: invite.id, + expires_at: new Date().setMilliseconds( + new Date().getMilliseconds() + expiresIn + ), + token: this.generateToken({ id: invite.id }), + } + }) + + return await super.update(updates, context) + } + @InjectTransactionManager("inviteRepository_") async validateInviteToken( token: string, @@ -119,8 +164,10 @@ export default class InviteService< return jwt.sign(data, jwtSecret, { expiresIn, + jwtid: crypto.randomUUID(), }) } + private validateToken(data: any): JwtPayload { const jwtSecret = this.getOption("jwt_secret") diff --git a/packages/user/src/services/user-module.ts b/packages/user/src/services/user-module.ts index ed3f8aad93..018ebdef69 100644 --- a/packages/user/src/services/user-module.ts +++ b/packages/user/src/services/user-module.ts @@ -13,7 +13,6 @@ import { MedusaContext, ModulesSdkUtils, InjectManager, - buildEventMessages, CommonEvents, UserEvents, } from "@medusajs/utils" @@ -74,7 +73,53 @@ export default class UserModuleService< token: string, @MedusaContext() sharedContext: Context = {} ): Promise { - return await this.inviteService_.validateInviteToken(token, sharedContext) + const invite = await this.inviteService_.validateInviteToken( + token, + sharedContext + ) + + return await this.baseRepository_.serialize(invite, { + populate: true, + }) + } + + @InjectManager("baseRepository_") + @EmitEvents() + async refreshInviteTokens( + inviteIds: string[], + @MedusaContext() sharedContext: Context = {} + ): Promise { + const invites = await this.refreshInviteTokens_(inviteIds, sharedContext) + + sharedContext.messageAggregator?.saveRawMessageData( + invites.map((invite) => ({ + eventName: UserEvents.invite_token_generated, + metadata: { + service: this.constructor.name, + action: "token_generated", + object: "invite", + }, + data: invite.id, + })) + ) + + return await this.baseRepository_.serialize( + invites, + { + populate: true, + } + ) + } + + @InjectTransactionManager("baseRepository_") + async refreshInviteTokens_( + inviteIds: string[], + @MedusaContext() sharedContext: Context = {} + ) { + return await this.inviteService_.refreshInviteTokens( + inviteIds, + sharedContext + ) } create( @@ -194,6 +239,18 @@ export default class UserModuleService< })) ) + sharedContext.messageAggregator?.saveRawMessageData( + invites.map((invite) => ({ + eventName: UserEvents.invite_token_generated, + metadata: { + service: this.constructor.name, + action: "token_generated", + object: "invite", + }, + data: { id: invite.id }, + })) + ) + return Array.isArray(data) ? serializedInvites : serializedInvites[0] } @@ -210,7 +267,7 @@ export default class UserModuleService< } }) - return await this.inviteService_.create(toCreate) + return await this.inviteService_.create(toCreate, sharedContext) } updateInvites( diff --git a/packages/utils/src/user/events.ts b/packages/utils/src/user/events.ts index 3a6b80d028..467ad769de 100644 --- a/packages/utils/src/user/events.ts +++ b/packages/utils/src/user/events.ts @@ -5,4 +5,5 @@ export const UserEvents = { updated: "user." + CommonEvents.UPDATED, invite_created: "invite." + CommonEvents.CREATED, invite_updated: "invite." + CommonEvents.UPDATED, + invite_token_generated: "invite.token_generated", }