import { Context, DAL, InferEntityType, InternalModuleDeclaration, ModulesSdkTypes, ProjectConfigOptions, UserTypes, } from "@medusajs/framework/types" import { arrayDifference, CommonEvents, EmitEvents, generateEntityId, generateJwtToken, InjectManager, InjectTransactionManager, MedusaContext, MedusaError, MedusaService, moduleEventBuilderFactory, Modules, UserEvents, } from "@medusajs/framework/utils" import jwt, { JwtPayload, SignOptions, VerifyOptions } from "jsonwebtoken" import crypto from "node:crypto" import { Invite, User } from "@models" import { getExpiresAt } from "../utils/utils" type InjectedDependencies = { baseRepository: DAL.RepositoryService userService: ModulesSdkTypes.IMedusaInternalService inviteService: ModulesSdkTypes.IMedusaInternalService } const DEFAULT_VALID_INVITE_DURATION_SECONDS = 60 * 60 * 24 export default class UserModuleService extends MedusaService<{ User: { dto: UserTypes.UserDTO } Invite: { dto: UserTypes.InviteDTO } }>({ User, Invite }) implements UserTypes.IUserModuleService { protected baseRepository_: DAL.RepositoryService protected readonly userService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > protected readonly inviteService_: ModulesSdkTypes.IMedusaInternalService< InferEntityType > protected readonly config: { jwtSecret: string jwtPublicKey?: string jwt_verify_options: ProjectConfigOptions["http"]["jwtVerifyOptions"] jwtOptions: ProjectConfigOptions["http"]["jwtOptions"] & { expiresIn: number } } constructor( { userService, inviteService, baseRepository }: InjectedDependencies, moduleDeclaration: InternalModuleDeclaration ) { // @ts-ignore super(...arguments) this.baseRepository_ = baseRepository this.userService_ = userService this.inviteService_ = inviteService this.config = { jwtSecret: moduleDeclaration["jwt_secret"], jwtPublicKey: moduleDeclaration["jwt_public_key"], jwt_verify_options: moduleDeclaration["jwt_verify_options"], jwtOptions: { ...moduleDeclaration["jwt_options"], expiresIn: moduleDeclaration["valid_duration"] ?? moduleDeclaration["jwt_options"]?.expiresIn ?? DEFAULT_VALID_INVITE_DURATION_SECONDS, }, } if (!this.config.jwtSecret) { throw new MedusaError( MedusaError.Types.INVALID_DATA, "No jwt_secret was provided in the UserModule's options. Please add one." ) } } @InjectTransactionManager() async validateInviteToken( token: string, @MedusaContext() sharedContext: Context = {} ): Promise { const options = { ...(this.config.jwt_verify_options ?? this.config.jwtOptions), complete: true, } as VerifyOptions & SignOptions if (!options.algorithms && options.algorithm) { options.algorithms = [options.algorithm] delete options.algorithm } const decoded = jwt.verify( token, this.config.jwtPublicKey ?? this.config.jwtSecret, options ) as JwtPayload const invite = await this.inviteService_.retrieve( decoded.payload.id, {}, sharedContext ) if (invite.expires_at < new Date()) { throw new MedusaError( MedusaError.Types.INVALID_DATA, "The invite has expired" ) } return await this.baseRepository_.serialize(invite, { populate: true, }) } @InjectManager() @EmitEvents() async refreshInviteTokens( inviteIds: string[], @MedusaContext() sharedContext: Context = {} ): Promise { const invites = await this.refreshInviteTokens_(inviteIds, sharedContext) moduleEventBuilderFactory({ eventName: UserEvents.INVITE_TOKEN_GENERATED, source: Modules.USER, action: "token_generated", object: "invite", })({ data: invites, sharedContext, }) return await this.baseRepository_.serialize( invites, { populate: true, } ) } @InjectTransactionManager() async refreshInviteTokens_( inviteIds: string[], @MedusaContext() sharedContext: Context = {} ) { const [invites, count] = await this.inviteService_.listAndCount( { id: inviteIds }, {}, sharedContext ) 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 expiresAt = getExpiresAt(this.config.jwtOptions.expiresIn) const updates = invites.map((invite) => { return { id: invite.id, expires_at: expiresAt, token: this.generateToken({ id: invite.id, email: invite.email }), } }) return await this.inviteService_.update(updates, sharedContext) } // @ts-expect-error createUsers( data: UserTypes.CreateUserDTO[], sharedContext?: Context ): Promise // @ts-expect-error createUsers( data: UserTypes.CreateUserDTO, sharedContext?: Context ): Promise @InjectManager() @EmitEvents() // @ts-expect-error async createUsers( data: UserTypes.CreateUserDTO[] | UserTypes.CreateUserDTO, @MedusaContext() sharedContext: Context = {} ): Promise { const input = Array.isArray(data) ? data : [data] const users = await this.userService_.create(input, sharedContext) const serializedUsers = await this.baseRepository_.serialize< UserTypes.UserDTO[] | UserTypes.UserDTO >(users, { populate: true, }) moduleEventBuilderFactory({ eventName: UserEvents.USER_CREATED, source: Modules.USER, action: CommonEvents.CREATED, object: "user", })({ data: serializedUsers, sharedContext, }) return Array.isArray(data) ? serializedUsers : serializedUsers[0] } // @ts-expect-error updateUsers( data: UserTypes.UpdateUserDTO[], sharedContext?: Context ): Promise // @ts-expect-error updateUsers( data: UserTypes.UpdateUserDTO, sharedContext?: Context ): Promise @InjectManager() @EmitEvents() // @ts-expect-error async updateUsers( data: UserTypes.UpdateUserDTO | UserTypes.UpdateUserDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const input = Array.isArray(data) ? data : [data] const updatedUsers = await this.userService_.update(input, sharedContext) const serializedUsers = await this.baseRepository_.serialize< UserTypes.UserDTO[] >(updatedUsers, { populate: true, }) moduleEventBuilderFactory({ eventName: UserEvents.USER_UPDATED, source: Modules.USER, action: CommonEvents.UPDATED, object: "user", })({ data: serializedUsers, sharedContext, }) return Array.isArray(data) ? serializedUsers : serializedUsers[0] } // @ts-expect-error createInvites( data: UserTypes.CreateInviteDTO[], sharedContext?: Context ): Promise // @ts-expect-error createInvites( data: UserTypes.CreateInviteDTO, sharedContext?: Context ): Promise @InjectManager() @EmitEvents() // @ts-expect-error async createInvites( data: UserTypes.CreateInviteDTO[] | UserTypes.CreateInviteDTO, @MedusaContext() sharedContext: Context = {} ): Promise { const input = Array.isArray(data) ? data : [data] const invites = await this.createInvites_(input, sharedContext) const serializedInvites = await this.baseRepository_.serialize< UserTypes.InviteDTO[] | UserTypes.InviteDTO >(invites, { populate: true, }) moduleEventBuilderFactory({ eventName: UserEvents.INVITE_CREATED, source: Modules.USER, action: CommonEvents.CREATED, object: "invite", })({ data: serializedInvites, sharedContext, }) moduleEventBuilderFactory({ eventName: UserEvents.INVITE_TOKEN_GENERATED, source: Modules.USER, action: "token_generated", object: "invite", })({ data: serializedInvites, sharedContext, }) return Array.isArray(data) ? serializedInvites : serializedInvites[0] } @InjectTransactionManager() private async createInvites_( data: UserTypes.CreateInviteDTO[], @MedusaContext() sharedContext: Context = {} ): Promise[]> { const alreadyExistingUsers = await this.listUsers({ email: data.map((d) => d.email), }) if (alreadyExistingUsers.length) { throw new MedusaError( MedusaError.Types.INVALID_DATA, `User account for following email(s) already exist: ${alreadyExistingUsers .map((u) => u.email) .join(", ")}` ) } const expiresAt = getExpiresAt(this.config.jwtOptions.expiresIn) const toCreate = data.map((invite) => { const id = generateEntityId((invite as { id?: string }).id, "invite") return { ...invite, id, expires_at: expiresAt, token: this.generateToken({ id, email: invite.email }), } }) return await this.inviteService_.create(toCreate, sharedContext) } // @ts-ignore updateInvites( data: UserTypes.UpdateInviteDTO[], sharedContext?: Context ): Promise // @ts-expect-error updateInvites( data: UserTypes.UpdateInviteDTO, sharedContext?: Context ): Promise @InjectManager() @EmitEvents() // @ts-expect-error async updateInvites( data: UserTypes.UpdateInviteDTO | UserTypes.UpdateInviteDTO[], @MedusaContext() sharedContext: Context = {} ): Promise { const input = Array.isArray(data) ? data : [data] const updatedInvites = await this.inviteService_.update( input, sharedContext ) const serializedInvites = await this.baseRepository_.serialize< UserTypes.InviteDTO[] >(updatedInvites, { populate: true, }) moduleEventBuilderFactory({ eventName: UserEvents.INVITE_UPDATED, source: Modules.USER, action: CommonEvents.UPDATED, object: "invite", })({ data: serializedInvites, sharedContext, }) return Array.isArray(data) ? serializedInvites : serializedInvites[0] } private generateToken(data: any): string { const jwtId = this.config.jwtOptions.jwtid ?? crypto.randomUUID() const token = generateJwtToken(data, { secret: this.config.jwtSecret, jwtOptions: { ...this.config.jwtOptions, jwtid: jwtId, }, }) return token } }