feat(medusa, types, core-flows): Add invite endpoints for api-v2 (#6395)

**What**
- Add invite endpoints for simple operations on invites
This commit is contained in:
Philip Korsholm
2024-02-14 23:33:26 +08:00
committed by GitHub
parent 1ed5f918c3
commit 02c53ec93f
26 changed files with 649 additions and 9 deletions

View File

@@ -0,0 +1,55 @@
import { initDb, useDb } from "../../../environment-helpers/use-db"
import path from "path"
import { startBootstrapApp } from "../../../environment-helpers/bootstrap-app"
import { useApi } from "../../../environment-helpers/use-api"
import adminSeeder from "../../../helpers/admin-seeder"
import { AxiosInstance } from "axios"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
describe("POST /admin/invites", () => {
let dbConnection
let shutdownServer
beforeAll(async () => {
const cwd = path.resolve(path.join(__dirname, "..", ".."))
dbConnection = await initDb({ cwd, env } as any)
shutdownServer = await startBootstrapApp({ cwd, env })
})
beforeEach(async () => {
await adminSeeder(dbConnection)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("create an invite", async () => {
const api = useApi()! as AxiosInstance
const body = {
email: "test_member@test.com",
}
const response = await api.post(`/admin/invites`, body, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data).toEqual({
invite: expect.objectContaining(body),
})
})
})

View File

@@ -0,0 +1,76 @@
import { initDb, useDb } from "../../../environment-helpers/use-db"
import { IUserModuleService } from "@medusajs/types"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
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"
import adminSeeder from "../../../helpers/admin-seeder"
import { AxiosInstance } from "axios"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
describe("DELETE /admin/invites/:id", () => {
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 adminSeeder(dbConnection)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should delete a single invite", async () => {
const invite = await userModuleService.createInvites({
email: "potential_member@test.com",
token: "test",
expires_at: new Date(),
})
const api = useApi()! as AxiosInstance
const response = await api.delete(
`/admin/invites/${invite.id}`,
adminHeaders
)
expect(response.status).toEqual(200)
expect(response.data).toEqual({
id: invite.id,
object: "invite",
deleted: true,
})
const { response: deletedResponse } = await api
.get(`/admin/invites/${invite.id}`, adminHeaders)
.catch((e) => e)
expect(deletedResponse.status).toEqual(404)
expect(deletedResponse.data.type).toEqual("not_found")
})
})

View File

@@ -0,0 +1,69 @@
import { initDb, useDb } from "../../../environment-helpers/use-db"
import { IUserModuleService } from "@medusajs/types"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
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"
import adminSeeder from "../../../helpers/admin-seeder"
import { AxiosInstance } from "axios"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
describe("GET /admin/invites", () => {
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 adminSeeder(dbConnection)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should list invites", async () => {
await userModuleService.createInvites({
email: "potential_member@test.com",
token: "test",
expires_at: new Date(),
})
const api = useApi()! as AxiosInstance
const response = await api.get(`/admin/invites`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data).toEqual({
invites: [
expect.objectContaining({ email: "potential_member@test.com" }),
],
count: 1,
offset: 0,
limit: 50,
})
})
})

View File

@@ -0,0 +1,64 @@
import { initDb, useDb } from "../../../environment-helpers/use-db"
import { IUserModuleService } from "@medusajs/types"
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
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"
import adminSeeder from "../../../helpers/admin-seeder"
import { AxiosInstance } from "axios"
jest.setTimeout(50000)
const env = { MEDUSA_FF_MEDUSA_V2: true }
const adminHeaders = {
headers: { "x-medusa-access-token": "test_token" },
}
describe("GET /admin/invites/:id", () => {
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 adminSeeder(dbConnection)
})
afterAll(async () => {
const db = useDb()
await db.shutdown()
await shutdownServer()
})
afterEach(async () => {
const db = useDb()
await db.teardown()
})
it("should retrieve a single invite", async () => {
const invite = await userModuleService.createInvites({
email: "potential_member@test.com",
token: "test",
expires_at: new Date(),
})
const api = useApi()! as AxiosInstance
const response = await api.get(`/admin/invites/${invite.id}`, adminHeaders)
expect(response.status).toEqual(200)
expect(response.data.invite).toEqual(
expect.objectContaining({ email: "potential_member@test.com" })
)
})
})

View File

@@ -5,3 +5,4 @@ export * from "./promotion"
export * from "./customer" export * from "./customer"
export * from "./customer-group" export * from "./customer-group"
export * from "./user" export * from "./user"
export * from "./invite"

View File

@@ -0,0 +1,2 @@
export * from "./steps"
export * from "./workflows"

View File

@@ -0,0 +1,31 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { CreateInviteDTO, IUserModuleService, InviteDTO } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const createInviteStepId = "create-invite-step"
export const createInviteStep = createStep(
createInviteStepId,
async (input: CreateInviteDTO[], { container }) => {
const service: IUserModuleService = container.resolve(
ModuleRegistrationName.USER
)
const createdInvites = await service.createInvites(input)
return new StepResponse(
createdInvites,
createdInvites.map((inv) => inv.id)
)
},
async (createdInvitesIds, { container }) => {
if (!createdInvitesIds?.length) {
return
}
const service: IUserModuleService = container.resolve(
ModuleRegistrationName.USER
)
await service.deleteInvites(createdInvitesIds)
}
)

View File

@@ -0,0 +1,28 @@
import { ModuleRegistrationName } from "@medusajs/modules-sdk"
import { IUserModuleService } from "@medusajs/types"
import { StepResponse, createStep } from "@medusajs/workflows-sdk"
export const deleteInvitesStepId = "delete-invites-step"
export const deleteInvitesStep = createStep(
deleteInvitesStepId,
async (input: string[], { container }) => {
const service: IUserModuleService = container.resolve(
ModuleRegistrationName.USER
)
await service.softDeleteInvites(input)
return new StepResponse(void 0, input)
},
async (deletedInviteIds, { container }) => {
if (!deletedInviteIds?.length) {
return
}
const service: IUserModuleService = container.resolve(
ModuleRegistrationName.USER
)
await service.restoreInvites(deletedInviteIds)
}
)

View File

@@ -0,0 +1,2 @@
export * from "./create-invites"
export * from "./delete-invites"

View File

@@ -0,0 +1,13 @@
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { createInviteStep } from "../steps"
import { InviteDTO, InviteWorkflow } from "@medusajs/types"
export const createInvitesWorkflowId = "create-invite-step"
export const createInvitesWorkflow = createWorkflow(
createInvitesWorkflowId,
(
input: WorkflowData<InviteWorkflow.CreateInvitesWorkflowInputDTO>
): WorkflowData<InviteDTO[]> => {
return createInviteStep(input.invites)
}
)

View File

@@ -0,0 +1,13 @@
import { WorkflowData, createWorkflow } from "@medusajs/workflows-sdk"
import { deleteInvitesStep } from "../steps"
import { InviteWorkflow, UserWorkflow } from "@medusajs/types"
export const deleteInvitesWorkflowId = "delete-invites-workflow"
export const deleteInvitesWorkflow = createWorkflow(
deleteInvitesWorkflowId,
(
input: WorkflowData<InviteWorkflow.DeleteInvitesWorkflowInput>
): WorkflowData<void> => {
return deleteInvitesStep(input.ids)
}
)

View File

@@ -0,0 +1,2 @@
export * from "./create-invites"
export * from "./delete-invites"

View File

@@ -0,0 +1,55 @@
import {
ContainerRegistrationKeys,
MedusaError,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../../types/routing"
import { deleteInvitesWorkflow } from "@medusajs/core-flows"
import { IUserModuleService, UpdateUserDTO } from "@medusajs/types"
import { ModuleRegistrationName } from "../../../../../../modules-sdk/dist"
// Get invite
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { id } = req.params
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const query = remoteQueryObjectFromString({
entryPoint: "invite",
variables: {
id,
},
fields: req.retrieveConfig.select as string[],
})
const [invite] = await remoteQuery(query)
if (!invite) {
throw new MedusaError(
MedusaError.Types.NOT_FOUND,
`Invite with id: ${id} was not found`
)
}
res.status(200).json({ invite })
}
// delete invite
export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
const { id } = req.params
const workflow = deleteInvitesWorkflow(req.scope)
const { errors } = await workflow.run({
input: { ids: [id] },
throwOnError: false,
})
if (Array.isArray(errors) && errors[0]) {
throw errors[0].error
}
res.status(200).json({
id,
object: "invite",
deleted: true,
})
}

View File

@@ -0,0 +1,36 @@
import { transformBody, transformQuery } from "../../../api/middlewares"
import {
AdminCreateInviteRequest,
AdminGetInvitesParams,
AdminGetInvitesInviteParams,
} from "./validators"
import * as QueryConfig from "./query-config"
import { MiddlewareRoute } from "../../../types/middlewares"
export const adminInviteRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ["GET"],
matcher: "/admin/invites",
middlewares: [
transformQuery(
AdminGetInvitesParams,
QueryConfig.listTransformQueryConfig
),
],
},
{
method: ["POST"],
matcher: "/admin/invites",
middlewares: [transformBody(AdminCreateInviteRequest)],
},
{
method: ["GET"],
matcher: "/admin/invites/:id",
middlewares: [
transformQuery(
AdminGetInvitesInviteParams,
QueryConfig.retrieveTransformQueryConfig
),
],
},
]

View File

@@ -0,0 +1,25 @@
export const defaultAdminInviteRelations = []
export const allowedAdminInviteRelations = []
export const defaultAdminInviteFields = [
"id",
"email",
"accepted",
"token",
"expires_at",
"metadata",
"created_at",
"updated_at",
"deleted_at",
]
export const retrieveTransformQueryConfig = {
defaultFields: defaultAdminInviteFields,
defaultRelations: defaultAdminInviteRelations,
allowedRelations: allowedAdminInviteRelations,
isList: false,
}
export const listTransformQueryConfig = {
...retrieveTransformQueryConfig,
isList: true,
}

View File

@@ -0,0 +1,50 @@
import {
ContainerRegistrationKeys,
remoteQueryObjectFromString,
} from "@medusajs/utils"
import { MedusaRequest, MedusaResponse } from "../../../types/routing"
import { createInvitesWorkflow } from "@medusajs/core-flows"
import { CreateInviteDTO, CreateUserDTO } from "@medusajs/types"
// List invites
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
const query = remoteQueryObjectFromString({
entryPoint: "invite",
variables: {
filters: req.filterableFields,
order: req.listConfig.order,
skip: req.listConfig.skip,
take: req.listConfig.take,
},
fields: req.listConfig.select as string[],
})
const { rows: invites, metadata } = await remoteQuery({
...query,
})
res.status(200).json({
invites,
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
})
}
// Create invite
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const workflow = createInvitesWorkflow(req.scope)
const input = {
input: {
invites: [req.validatedBody as CreateInviteDTO],
},
}
const { result } = await workflow.run(input)
const [invite] = result
res.status(200).json({ invite })
}

View File

@@ -0,0 +1,72 @@
import { Type } from "class-transformer"
import { IsEmail, IsOptional, IsString, ValidateNested } from "class-validator"
import {
DateComparisonOperator,
FindParams,
extendedFindParamsMixin,
} from "../../../types/common"
import { IsType } from "../../../utils"
export class AdminGetInvitesInviteParams extends FindParams {}
export class AdminGetInvitesParams extends extendedFindParamsMixin({
limit: 50,
offset: 0,
}) {
/**
* IDs to filter invites by.
*/
@IsOptional()
@IsType([String, [String]])
id?: string | string[]
/**
* The field to sort the data by. By default, the sort order is ascending. To change the order to descending, prefix the field name with `-`.
*/
@IsString()
@IsOptional()
order?: string
/**
* Date filters to apply on the invites' `update_at` date.
*/
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
updated_at?: DateComparisonOperator
/**
* Date filters to apply on the customer invites' `created_at` date.
*/
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
created_at?: DateComparisonOperator
/**
* Date filters to apply on the invites' `deleted_at` date.
*/
@IsOptional()
@ValidateNested()
@Type(() => DateComparisonOperator)
deleted_at?: DateComparisonOperator
/**
* Filter to apply on the invites' `email` field.
*/
@IsOptional()
@IsString()
email?: string
/**
* Comma-separated fields that should be included in the returned invites.
*/
@IsOptional()
@IsString()
fields?: string
}
export class AdminCreateInviteRequest {
@IsEmail()
email: string
}

View File

@@ -10,6 +10,7 @@ import { storeCartRoutesMiddlewares } from "./store/carts/middlewares"
import { storeCustomerRoutesMiddlewares } from "./store/customers/middlewares" import { storeCustomerRoutesMiddlewares } from "./store/customers/middlewares"
import { adminUserRoutesMiddlewares } from "./admin/users/middlewares" import { adminUserRoutesMiddlewares } from "./admin/users/middlewares"
import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares" import { storeRegionRoutesMiddlewares } from "./store/regions/middlewares"
import { adminInviteRoutesMiddlewares } from "./admin/invites/middlewares"
export const config: MiddlewaresConfig = { export const config: MiddlewaresConfig = {
routes: [ routes: [
@@ -25,5 +26,6 @@ export const config: MiddlewaresConfig = {
...storeRegionRoutesMiddlewares, ...storeRegionRoutesMiddlewares,
...adminRegionRoutesMiddlewares, ...adminRegionRoutesMiddlewares,
...adminUserRoutesMiddlewares, ...adminUserRoutesMiddlewares,
...adminInviteRoutesMiddlewares,
], ],
} }

View File

@@ -12,8 +12,6 @@ export interface UpdateUserDTO extends Partial<Omit<CreateUserDTO, "email">> {
export interface CreateInviteDTO { export interface CreateInviteDTO {
email: string email: string
accepted?: boolean accepted?: boolean
token: string
expires_at: Date
metadata?: Record<string, unknown> | null metadata?: Record<string, unknown> | null
} }

View File

@@ -4,3 +4,4 @@ export * as ProductWorkflow from "./product"
export * as InventoryWorkflow from "./inventory" export * as InventoryWorkflow from "./inventory"
export * as PriceListWorkflow from "./price-list" export * as PriceListWorkflow from "./price-list"
export * as UserWorkflow from "./user" export * as UserWorkflow from "./user"
export * as InviteWorkflow from "./invite"

View File

@@ -0,0 +1,5 @@
import { CreateInviteDTO } from "../../user"
export interface CreateInvitesWorkflowInputDTO {
invites: CreateInviteDTO[]
}

View File

@@ -0,0 +1,3 @@
export interface DeleteInvitesWorkflowInput {
ids: string[]
}

View File

@@ -0,0 +1,3 @@
export * from "./create-invite"
export * from "./update-invite"
export * from "./delete-invite"

View File

@@ -0,0 +1,5 @@
import { UpdateInviteDTO } from "../../user"
export interface UpdateInvitesWorkflowInputDTO {
updates: UpdateInviteDTO[]
}

View File

@@ -1,10 +1,11 @@
import { User } from "@models" import { Invite, User } from "@models"
import { MapToConfig } from "@medusajs/utils" import { MapToConfig } from "@medusajs/utils"
import { ModuleJoinerConfig } from "@medusajs/types" import { ModuleJoinerConfig } from "@medusajs/types"
import { Modules } from "@medusajs/modules-sdk" import { Modules } from "@medusajs/modules-sdk"
export const LinkableKeys = { export const LinkableKeys = {
user_id: User.name, user_id: User.name,
invite_id: Invite.name,
} }
const entityLinkableKeysMap: MapToConfig = {} const entityLinkableKeysMap: MapToConfig = {}
@@ -22,10 +23,19 @@ export const joinerConfig: ModuleJoinerConfig = {
serviceName: Modules.USER, serviceName: Modules.USER,
primaryKeys: ["id"], primaryKeys: ["id"],
linkableKeys: LinkableKeys, linkableKeys: LinkableKeys,
alias: { alias: [
name: ["user", "users"], {
args: { name: ["user", "users"],
entity: User.name, args: {
entity: User.name,
},
}, },
}, {
name: ["invite", "invites"],
args: {
entity: Invite.name,
methodSuffix: "Invites",
},
},
],
} }

View File

@@ -130,7 +130,7 @@ export default class UserModuleService<
): Promise<UserTypes.InviteDTO | UserTypes.InviteDTO[]> { ): Promise<UserTypes.InviteDTO | UserTypes.InviteDTO[]> {
const input = Array.isArray(data) ? data : [data] const input = Array.isArray(data) ? data : [data]
const invites = await this.inviteService_.create(input, sharedContext) const invites = await this.createInvites_(input, sharedContext)
const serializedInvites = await this.baseRepository_.serialize< const serializedInvites = await this.baseRepository_.serialize<
UserTypes.InviteDTO[] | UserTypes.InviteDTO UserTypes.InviteDTO[] | UserTypes.InviteDTO
@@ -141,6 +141,25 @@ export default class UserModuleService<
return Array.isArray(data) ? serializedInvites : serializedInvites[0] return Array.isArray(data) ? serializedInvites : serializedInvites[0]
} }
@InjectTransactionManager("baseRepository_")
private async createInvites_(
data: UserTypes.CreateInviteDTO[],
@MedusaContext() sharedContext: Context = {}
): Promise<TInvite[]> {
// expiration date in 10 days
const expirationDate = new Date().setDate(new Date().getDate() + 10)
const toCreate = data.map((invite) => {
return {
...invite,
expires_at: new Date(expirationDate),
token: "placeholder", // TODO: generate token
}
})
return await this.inviteService_.create(toCreate)
}
updateInvites( updateInvites(
data: UserTypes.UpdateInviteDTO[], data: UserTypes.UpdateInviteDTO[],
sharedContext?: Context sharedContext?: Context