@open-mercato/channel-discord 0.7.1-develop.7130.1.fef2396fd8 → 0.7.1-develop.7131.1.20abf9e885
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js +5 -0
- package/dist/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.js.map +2 -2
- package/package.json +8 -8
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/__tests__/route.interceptor.test.ts +149 -0
- package/src/modules/channel_discord/api/channels/[id]/ai-auto-reply/route.ts +5 -0
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
5
|
import { isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
6
|
+
import { getCommandInterceptorHttpRejection } from "@open-mercato/shared/lib/commands/errors";
|
|
6
7
|
import { validateRouteMutationGuard } from "@open-mercato/core/modules/communication_channels/lib/route-mutation-guard";
|
|
7
8
|
import { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from "../../../../ai-agents.js";
|
|
8
9
|
import {
|
|
@@ -168,6 +169,10 @@ async function PUT(req, context) {
|
|
|
168
169
|
if (isCrudHttpError(err)) {
|
|
169
170
|
return NextResponse.json(err.body, { status: err.status });
|
|
170
171
|
}
|
|
172
|
+
const interceptorRejection = getCommandInterceptorHttpRejection(err);
|
|
173
|
+
if (interceptorRejection) {
|
|
174
|
+
return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status });
|
|
175
|
+
}
|
|
171
176
|
throw err;
|
|
172
177
|
}
|
|
173
178
|
if (result.status === "not_found") {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../src/modules/channel_discord/api/channels/%5Bid%5D/ai-auto-reply/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { validateRouteMutationGuard } from '@open-mercato/core/modules/communication_channels/lib/route-mutation-guard'\nimport { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from '../../../../ai-agents'\nimport {\n CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,\n CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,\n discordAiAutoReplySettingsSchema,\n type UpdateAiAutoReplyInput,\n type UpdateAiAutoReplyResult,\n} from '../../../../commands/update-ai-auto-reply'\nimport {\n findDiscordEligibleAgent,\n listDiscordEligibleAgents,\n missingAgentFeatures,\n type AgentInvokingPrincipal,\n} from '../../../../lib/ai-agent-directory'\nimport { CHANNEL_DISCORD_CONFIGURE_FEATURE, CHANNEL_DISCORD_VIEW_FEATURE } from '../../../../lib/ai-features'\nimport { resolveDiscordAiPrincipal } from '../../../../lib/ai-service-principal'\nimport { loadDiscordChannelForRequest } from '../../../../lib/channel-access'\nimport { discordChannelStateSchema } from '../../../../lib/credentials'\n\nexport const metadata = {\n GET: {\n requireAuth: true,\n requireFeatures: [CHANNEL_DISCORD_VIEW_FEATURE],\n },\n PUT: {\n requireAuth: true,\n requireFeatures: [CHANNEL_DISCORD_CONFIGURE_FEATURE],\n },\n}\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\nfunction fieldError(field: string, message: string, extra?: Record<string, unknown>): Response {\n return NextResponse.json(\n { error: message, fieldErrors: { [field]: message }, ...extra },\n { status: 400 },\n )\n}\n\n/**\n * The identity the auto-reply subscriber will run the chosen agent under, so both\n * methods can answer \"would the runtime accept this?\" with the same grants the\n * subscriber will present \u2014 the tenant's channel-bot user when it exists, the\n * provider service principal otherwise.\n */\nasync function loadInvokingPrincipal(\n container: AwilixContainer,\n scope: { tenantId: string; organizationId: string | null },\n): Promise<AgentInvokingPrincipal> {\n const em = (container.resolve('em') as EntityManager).fork()\n return resolveDiscordAiPrincipal({\n em,\n resolver: { resolve: <T,>(name: string): T => container.resolve(name) as T },\n scope,\n })\n}\n\n/**\n * Read a Discord channel's AI auto-reply settings plus everything the settings\n * form needs to render itself: the eligible agents, whether the optional AI peer\n * is installed at all, and the channel's `updatedAt` so the form can attach the\n * optimistic-lock header on save.\n */\nexport async function GET(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'read' })\n if ('response' in access) return access.response\n const { channel } = access\n\n const state = discordChannelStateSchema.safeParse(channel.channelState ?? {})\n const directory = await listDiscordEligibleAgents()\n\n // Every offered agent carries whether the auto-reply principal could actually\n // invoke it. Without this the picker offers agents from other modules that the\n // runtime will refuse, and the refusal only ever happens later in a background\n // subscriber \u2014 so the operator sees \"Auto-reply on\" and silence.\n const principal = directory.available\n ? await loadInvokingPrincipal(container, {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n })\n : null\n const agents = directory.available && principal\n ? directory.agents.map((agent) => {\n const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)\n return { ...agent, invocable: missingFeatures.length === 0, missingFeatures }\n })\n : []\n\n return NextResponse.json({\n id: channel.id,\n channelId: channel.id,\n displayName: channel.displayName,\n updatedAt: channel.updatedAt ? channel.updatedAt.toISOString() : null,\n aiAutoReplyEnabled: state.success ? Boolean(state.data.aiAutoReplyEnabled) : false,\n aiAgentId: (state.success ? state.data.aiAgentId : undefined) ?? null,\n // Why the last attempt produced nothing, so an armed channel that answers\n // nothing says so instead of looking healthy.\n aiAutoReplyLastError: (state.success ? state.data.aiAutoReplyLastError : undefined) ?? null,\n aiAutoReplyLastErrorAt: (state.success ? state.data.aiAutoReplyLastErrorAt : undefined) ?? null,\n defaultAgentId: CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID,\n aiAvailable: directory.available,\n agents,\n })\n}\n\n/**\n * Write a Discord channel's AI auto-reply settings \u2014 the configuration path the\n * subscriber was missing (issue #4778).\n *\n * The route is deliberately strict about *enabling*: it refuses to arm a channel\n * whose AI peer is absent, one pointed at an agent the runtime would reject on\n * shape, and one pointed at an agent the auto-reply principal is not authorized\n * to invoke. All three would store a setting that can only fail later, inside a\n * background subscriber where nobody sees the error \u2014 which is precisely how the\n * feature ended up dormant in the first place. The three checks mirror, in order,\n * what `runAiAgentObject` \u2192 `checkAgentPolicy` will ask at run time.\n */\nexport async function PUT(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n\n const rawBody = await req.json().catch(() => null)\n if (rawBody == null || typeof rawBody !== 'object') {\n return NextResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 })\n }\n\n const parsed = discordAiAutoReplySettingsSchema.safeParse(rawBody)\n if (!parsed.success) {\n const issue = parsed.error.issues[0]\n const field = issue?.path.join('.') || 'aiAutoReplyEnabled'\n return fieldError(field, issue?.message ?? 'Invalid AI auto-reply settings')\n }\n\n const container = await createRequestContainer()\n // Authorize before the guard so a caller who may not touch this channel gets a\n // masked 404 rather than a guard verdict about a record they cannot see. It\n // also comes before the arming checks below, which need the channel's resolved\n // organization to build the principal they judge the agent against.\n const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'manage' })\n if ('response' in access) return access.response\n\n if (parsed.data.aiAutoReplyEnabled) {\n const agentId = parsed.data.aiAgentId as string\n const agent = await findDiscordEligibleAgent(agentId)\n if (!agent) {\n // No eligible agent under this id: either the optional AI peer is absent, or\n // the agent is not object-mode and `runAiAgentObject` would reject it.\n const directory = await listDiscordEligibleAgents()\n return directory.available\n ? fieldError('aiAgentId', 'channel_discord.aiAutoReply.errors.agentNotEligible')\n : fieldError('aiAutoReplyEnabled', 'channel_discord.aiAutoReply.errors.aiUnavailable')\n }\n\n // Shape is not authorization. `checkAgentPolicy` enforces the agent's\n // `requiredFeatures` against the auto-reply principal at run time, so arming a\n // channel against an agent that principal cannot invoke stores a setting that\n // can only fail later \u2014 inside a background subscriber, where the operator\n // never sees it. Refuse here, naming the grants the tenant's channel-bot user\n // is missing, rather than accepting a channel that would answer nothing.\n const principal = await loadInvokingPrincipal(container, {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n })\n const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)\n if (missingFeatures.length > 0) {\n return fieldError(\n 'aiAgentId',\n 'channel_discord.aiAutoReply.errors.agentFeaturesMissing',\n { missingFeatures },\n )\n }\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,\n resourceId: id,\n operation: 'update',\n mutationPayload: { ...parsed.data },\n },\n })\n if ('response' in guard) return guard.response\n\n const commandBus = container.resolve('commandBus') as CommandBus\n const input: UpdateAiAutoReplyInput = {\n channelId: id,\n settings: parsed.data,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n organizationIds: access.organizationIds,\n },\n }\n\n let result: UpdateAiAutoReplyResult\n try {\n const executed = await commandBus.execute<UpdateAiAutoReplyInput, UpdateAiAutoReplyResult>(\n CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,\n {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: organizationId,\n organizationIds: organizationId ? [organizationId] : null,\n request: req,\n },\n },\n )\n result = executed.result\n } catch (err) {\n // The optimistic-lock guard throws the shared structured 409 so the form's\n // conflict bar renders the same way it does anywhere else in the product.\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n throw err\n }\n\n if (result.status === 'not_found') {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n\n await guard.afterSuccess()\n return NextResponse.json(\n {\n channelId: result.channelId,\n aiAutoReplyEnabled: result.aiAutoReplyEnabled,\n aiAgentId: result.aiAgentId,\n updatedAt: result.updatedAt,\n },\n { status: 200 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n GET: {\n summary: \"Read a Discord channel's AI auto-reply configuration\",\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Current settings plus the agents this channel may be pointed at' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Channel not found, not a Discord channel in scope, or not the caller\u2019s to see' },\n ],\n },\n PUT: {\n summary: \"Update a Discord channel's AI auto-reply configuration\",\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Settings stored' },\n {\n status: 400,\n description:\n 'Invalid payload, AI module absent, agent not eligible, or the auto-reply principal lacks the agent\u2019s required features',\n },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Channel not found, or not a Discord channel in this scope' },\n { status: 409, description: 'The channel changed since the form was loaded' },\n ],\n },\n },\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { CommandBus } from '@open-mercato/shared/lib/commands'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\nimport { validateRouteMutationGuard } from '@open-mercato/core/modules/communication_channels/lib/route-mutation-guard'\nimport { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from '../../../../ai-agents'\nimport {\n CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,\n CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,\n discordAiAutoReplySettingsSchema,\n type UpdateAiAutoReplyInput,\n type UpdateAiAutoReplyResult,\n} from '../../../../commands/update-ai-auto-reply'\nimport {\n findDiscordEligibleAgent,\n listDiscordEligibleAgents,\n missingAgentFeatures,\n type AgentInvokingPrincipal,\n} from '../../../../lib/ai-agent-directory'\nimport { CHANNEL_DISCORD_CONFIGURE_FEATURE, CHANNEL_DISCORD_VIEW_FEATURE } from '../../../../lib/ai-features'\nimport { resolveDiscordAiPrincipal } from '../../../../lib/ai-service-principal'\nimport { loadDiscordChannelForRequest } from '../../../../lib/channel-access'\nimport { discordChannelStateSchema } from '../../../../lib/credentials'\n\nexport const metadata = {\n GET: {\n requireAuth: true,\n requireFeatures: [CHANNEL_DISCORD_VIEW_FEATURE],\n },\n PUT: {\n requireAuth: true,\n requireFeatures: [CHANNEL_DISCORD_CONFIGURE_FEATURE],\n },\n}\n\ntype RouteContext = {\n params: Promise<{ id: string }> | { id: string }\n}\n\nfunction fieldError(field: string, message: string, extra?: Record<string, unknown>): Response {\n return NextResponse.json(\n { error: message, fieldErrors: { [field]: message }, ...extra },\n { status: 400 },\n )\n}\n\n/**\n * The identity the auto-reply subscriber will run the chosen agent under, so both\n * methods can answer \"would the runtime accept this?\" with the same grants the\n * subscriber will present \u2014 the tenant's channel-bot user when it exists, the\n * provider service principal otherwise.\n */\nasync function loadInvokingPrincipal(\n container: AwilixContainer,\n scope: { tenantId: string; organizationId: string | null },\n): Promise<AgentInvokingPrincipal> {\n const em = (container.resolve('em') as EntityManager).fork()\n return resolveDiscordAiPrincipal({\n em,\n resolver: { resolve: <T,>(name: string): T => container.resolve(name) as T },\n scope,\n })\n}\n\n/**\n * Read a Discord channel's AI auto-reply settings plus everything the settings\n * form needs to render itself: the eligible agents, whether the optional AI peer\n * is installed at all, and the channel's `updatedAt` so the form can attach the\n * optimistic-lock header on save.\n */\nexport async function GET(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'read' })\n if ('response' in access) return access.response\n const { channel } = access\n\n const state = discordChannelStateSchema.safeParse(channel.channelState ?? {})\n const directory = await listDiscordEligibleAgents()\n\n // Every offered agent carries whether the auto-reply principal could actually\n // invoke it. Without this the picker offers agents from other modules that the\n // runtime will refuse, and the refusal only ever happens later in a background\n // subscriber \u2014 so the operator sees \"Auto-reply on\" and silence.\n const principal = directory.available\n ? await loadInvokingPrincipal(container, {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n })\n : null\n const agents = directory.available && principal\n ? directory.agents.map((agent) => {\n const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)\n return { ...agent, invocable: missingFeatures.length === 0, missingFeatures }\n })\n : []\n\n return NextResponse.json({\n id: channel.id,\n channelId: channel.id,\n displayName: channel.displayName,\n updatedAt: channel.updatedAt ? channel.updatedAt.toISOString() : null,\n aiAutoReplyEnabled: state.success ? Boolean(state.data.aiAutoReplyEnabled) : false,\n aiAgentId: (state.success ? state.data.aiAgentId : undefined) ?? null,\n // Why the last attempt produced nothing, so an armed channel that answers\n // nothing says so instead of looking healthy.\n aiAutoReplyLastError: (state.success ? state.data.aiAutoReplyLastError : undefined) ?? null,\n aiAutoReplyLastErrorAt: (state.success ? state.data.aiAutoReplyLastErrorAt : undefined) ?? null,\n defaultAgentId: CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID,\n aiAvailable: directory.available,\n agents,\n })\n}\n\n/**\n * Write a Discord channel's AI auto-reply settings \u2014 the configuration path the\n * subscriber was missing (issue #4778).\n *\n * The route is deliberately strict about *enabling*: it refuses to arm a channel\n * whose AI peer is absent, one pointed at an agent the runtime would reject on\n * shape, and one pointed at an agent the auto-reply principal is not authorized\n * to invoke. All three would store a setting that can only fail later, inside a\n * background subscriber where nobody sees the error \u2014 which is precisely how the\n * feature ended up dormant in the first place. The three checks mirror, in order,\n * what `runAiAgentObject` \u2192 `checkAgentPolicy` will ask at run time.\n */\nexport async function PUT(req: Request, context: RouteContext): Promise<Response> {\n const { id } = await context.params\n if (!z.string().uuid().safeParse(id).success) {\n return NextResponse.json({ error: 'Invalid channel id' }, { status: 400 })\n }\n\n const auth = await getAuthFromRequest(req)\n if (!auth?.sub || !auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const organizationId = (auth as { orgId?: string | null }).orgId ?? null\n\n const rawBody = await req.json().catch(() => null)\n if (rawBody == null || typeof rawBody !== 'object') {\n return NextResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 })\n }\n\n const parsed = discordAiAutoReplySettingsSchema.safeParse(rawBody)\n if (!parsed.success) {\n const issue = parsed.error.issues[0]\n const field = issue?.path.join('.') || 'aiAutoReplyEnabled'\n return fieldError(field, issue?.message ?? 'Invalid AI auto-reply settings')\n }\n\n const container = await createRequestContainer()\n // Authorize before the guard so a caller who may not touch this channel gets a\n // masked 404 rather than a guard verdict about a record they cannot see. It\n // also comes before the arming checks below, which need the channel's resolved\n // organization to build the principal they judge the agent against.\n const access = await loadDiscordChannelForRequest({ container, req, auth, channelId: id, mode: 'manage' })\n if ('response' in access) return access.response\n\n if (parsed.data.aiAutoReplyEnabled) {\n const agentId = parsed.data.aiAgentId as string\n const agent = await findDiscordEligibleAgent(agentId)\n if (!agent) {\n // No eligible agent under this id: either the optional AI peer is absent, or\n // the agent is not object-mode and `runAiAgentObject` would reject it.\n const directory = await listDiscordEligibleAgents()\n return directory.available\n ? fieldError('aiAgentId', 'channel_discord.aiAutoReply.errors.agentNotEligible')\n : fieldError('aiAutoReplyEnabled', 'channel_discord.aiAutoReply.errors.aiUnavailable')\n }\n\n // Shape is not authorization. `checkAgentPolicy` enforces the agent's\n // `requiredFeatures` against the auto-reply principal at run time, so arming a\n // channel against an agent that principal cannot invoke stores a setting that\n // can only fail later \u2014 inside a background subscriber, where the operator\n // never sees it. Refuse here, naming the grants the tenant's channel-bot user\n // is missing, rather than accepting a channel that would answer nothing.\n const principal = await loadInvokingPrincipal(container, {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n })\n const missingFeatures = missingAgentFeatures(agent.requiredFeatures, principal)\n if (missingFeatures.length > 0) {\n return fieldError(\n 'aiAgentId',\n 'channel_discord.aiAutoReply.errors.agentFeaturesMissing',\n { missingFeatures },\n )\n }\n }\n\n const guard = await validateRouteMutationGuard({\n container,\n req,\n auth,\n input: {\n resourceKind: CHANNEL_DISCORD_CHANNEL_RESOURCE_KIND,\n resourceId: id,\n operation: 'update',\n mutationPayload: { ...parsed.data },\n },\n })\n if ('response' in guard) return guard.response\n\n const commandBus = container.resolve('commandBus') as CommandBus\n const input: UpdateAiAutoReplyInput = {\n channelId: id,\n settings: parsed.data,\n scope: {\n tenantId: auth.tenantId as string,\n organizationId: access.rbacOrganizationId,\n organizationIds: access.organizationIds,\n },\n }\n\n let result: UpdateAiAutoReplyResult\n try {\n const executed = await commandBus.execute<UpdateAiAutoReplyInput, UpdateAiAutoReplyResult>(\n CHANNEL_DISCORD_UPDATE_AI_AUTO_REPLY_COMMAND_ID,\n {\n input,\n ctx: {\n container,\n auth: auth as never,\n organizationScope: null,\n selectedOrganizationId: organizationId,\n organizationIds: organizationId ? [organizationId] : null,\n request: req,\n },\n },\n )\n result = executed.result\n } catch (err) {\n // The optimistic-lock guard throws the shared structured 409 so the form's\n // conflict bar renders the same way it does anywhere else in the product.\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n throw err\n }\n\n if (result.status === 'not_found') {\n return NextResponse.json({ error: 'Channel not found' }, { status: 404 })\n }\n\n await guard.afterSuccess()\n return NextResponse.json(\n {\n channelId: result.channelId,\n aiAutoReplyEnabled: result.aiAutoReplyEnabled,\n aiAgentId: result.aiAgentId,\n updatedAt: result.updatedAt,\n },\n { status: 200 },\n )\n}\n\nexport const openApi = {\n tags: ['CommunicationChannels'],\n methods: {\n GET: {\n summary: \"Read a Discord channel's AI auto-reply configuration\",\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Current settings plus the agents this channel may be pointed at' },\n { status: 400, description: 'Invalid channel id' },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Channel not found, not a Discord channel in scope, or not the caller\u2019s to see' },\n ],\n },\n PUT: {\n summary: \"Update a Discord channel's AI auto-reply configuration\",\n tags: ['CommunicationChannels'],\n responses: [\n { status: 200, description: 'Settings stored' },\n {\n status: 400,\n description:\n 'Invalid payload, AI module absent, agent not eligible, or the auto-reply principal lacks the agent\u2019s required features',\n },\n { status: 401, description: 'Unauthorized' },\n { status: 404, description: 'Channel not found, or not a Discord channel in this scope' },\n { status: 409, description: 'The channel changed since the form was loaded' },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/channel-discord",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7131.1.20abf9e885",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -62,14 +62,14 @@
|
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
|
-
"@open-mercato/core": "0.7.1-develop.
|
|
66
|
-
"@open-mercato/queue": "0.7.1-develop.
|
|
67
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
65
|
+
"@open-mercato/core": "0.7.1-develop.7131.1.20abf9e885",
|
|
66
|
+
"@open-mercato/queue": "0.7.1-develop.7131.1.20abf9e885",
|
|
67
|
+
"@open-mercato/ui": "0.7.1-develop.7131.1.20abf9e885"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
70
70
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
71
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
72
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
71
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7131.1.20abf9e885",
|
|
72
|
+
"@open-mercato/shared": "0.7.1-develop.7131.1.20abf9e885",
|
|
73
73
|
"react": "^19.0.0",
|
|
74
74
|
"react-dom": "^19.0.0"
|
|
75
75
|
},
|
|
@@ -79,8 +79,8 @@
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
"devDependencies": {
|
|
82
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
83
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
82
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7131.1.20abf9e885",
|
|
83
|
+
"@open-mercato/shared": "0.7.1-develop.7131.1.20abf9e885",
|
|
84
84
|
"@types/jest": "^30.0.0",
|
|
85
85
|
"@types/react": "^19.2.17",
|
|
86
86
|
"@types/react-dom": "^19.2.3",
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** @jest-environment node */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Command-interceptor HTTP-status coverage for the Discord AI auto-reply route (#5097).
|
|
5
|
+
*
|
|
6
|
+
* This route reached `develop` after the transport rollout branch opened, and it is
|
|
7
|
+
* the first command-bus route in `packages/channel-discord` — a package the core
|
|
8
|
+
* coverage guard does not scan. Its `catch` maps `isCrudHttpError` and rethrows
|
|
9
|
+
* everything else, so without the interceptor branch a deliberate 422 business block
|
|
10
|
+
* would leave the handler as an unhandled error rather than reaching the caller.
|
|
11
|
+
*
|
|
12
|
+
* The two cases mirror the other rethrow-shaped routes (`warranty_claims` portal
|
|
13
|
+
* withdraw, `messages` reply): a rejection carrying a status is answered verbatim,
|
|
14
|
+
* and a statusless one keeps the historical rethrow.
|
|
15
|
+
*/
|
|
16
|
+
const tenantId = '11111111-1111-4111-8111-111111111111'
|
|
17
|
+
const organizationId = '22222222-2222-4222-8222-222222222222'
|
|
18
|
+
const channelId = '33333333-3333-4333-8333-333333333333'
|
|
19
|
+
const userId = '44444444-4444-4444-8444-444444444444'
|
|
20
|
+
|
|
21
|
+
const PROVIDER_AGENT = {
|
|
22
|
+
id: 'channel_discord.auto_reply',
|
|
23
|
+
label: 'Discord auto-reply',
|
|
24
|
+
description: '',
|
|
25
|
+
requiredFeatures: ['channel_discord.ai_auto_reply.run'],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const getAuthFromRequestMock = jest.fn()
|
|
29
|
+
const loadDiscordChannelForRequestMock = jest.fn()
|
|
30
|
+
const validateRouteMutationGuardMock = jest.fn()
|
|
31
|
+
const resolveDiscordAiPrincipalMock = jest.fn()
|
|
32
|
+
const listDiscordEligibleAgentsMock = jest.fn()
|
|
33
|
+
const commandBusExecuteMock = jest.fn()
|
|
34
|
+
const afterSuccessMock = jest.fn(async () => {})
|
|
35
|
+
|
|
36
|
+
const em = { fork: () => em }
|
|
37
|
+
const container = {
|
|
38
|
+
resolve: jest.fn((name: string) => {
|
|
39
|
+
if (name === 'em') return em
|
|
40
|
+
if (name === 'commandBus') return { execute: commandBusExecuteMock }
|
|
41
|
+
throw new Error(`Unexpected container resolve: ${name}`)
|
|
42
|
+
}),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
jest.mock('@open-mercato/shared/lib/auth/server', () => ({
|
|
46
|
+
getAuthFromRequest: (...args: unknown[]) => getAuthFromRequestMock(...args),
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
50
|
+
createRequestContainer: jest.fn(async () => container),
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
jest.mock('@open-mercato/core/modules/communication_channels/lib/route-mutation-guard', () => ({
|
|
54
|
+
validateRouteMutationGuard: (...args: unknown[]) => validateRouteMutationGuardMock(...args),
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
jest.mock('../../../../../lib/channel-access', () => ({
|
|
58
|
+
loadDiscordChannelForRequest: (...args: unknown[]) => loadDiscordChannelForRequestMock(...args),
|
|
59
|
+
}))
|
|
60
|
+
|
|
61
|
+
jest.mock('../../../../../lib/ai-service-principal', () => ({
|
|
62
|
+
resolveDiscordAiPrincipal: (...args: unknown[]) => resolveDiscordAiPrincipalMock(...args),
|
|
63
|
+
}))
|
|
64
|
+
|
|
65
|
+
jest.mock('../../../../../lib/ai-agent-directory', () => {
|
|
66
|
+
const actual = jest.requireActual('../../../../../lib/ai-agent-directory')
|
|
67
|
+
return {
|
|
68
|
+
...actual,
|
|
69
|
+
listDiscordEligibleAgents: (...args: unknown[]) => listDiscordEligibleAgentsMock(...args),
|
|
70
|
+
findDiscordEligibleAgent: async (agentId: string) => {
|
|
71
|
+
const directory = await listDiscordEligibleAgentsMock()
|
|
72
|
+
if (!directory.available) return null
|
|
73
|
+
return directory.agents.find((agent: { id: string }) => agent.id === agentId) ?? null
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
import { CommandInterceptorError } from '@open-mercato/shared/lib/commands/errors'
|
|
79
|
+
import { PUT } from '../route'
|
|
80
|
+
|
|
81
|
+
const context = { params: { id: channelId } }
|
|
82
|
+
|
|
83
|
+
function putRequest(): Request {
|
|
84
|
+
return new Request(`http://localhost/api/channel_discord/channels/${channelId}/ai-auto-reply`, {
|
|
85
|
+
method: 'PUT',
|
|
86
|
+
body: JSON.stringify({ aiAutoReplyEnabled: true, aiAgentId: PROVIDER_AGENT.id }),
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('channel_discord ai-auto-reply route — command interceptor HTTP status', () => {
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
jest.clearAllMocks()
|
|
93
|
+
getAuthFromRequestMock.mockResolvedValue({ sub: userId, tenantId, orgId: organizationId })
|
|
94
|
+
loadDiscordChannelForRequestMock.mockResolvedValue({
|
|
95
|
+
channel: {
|
|
96
|
+
id: channelId,
|
|
97
|
+
displayName: 'Discord bot',
|
|
98
|
+
updatedAt: new Date('2026-08-01T00:00:00.000Z'),
|
|
99
|
+
channelState: { aiAutoReplyEnabled: false },
|
|
100
|
+
},
|
|
101
|
+
organizationIds: [organizationId],
|
|
102
|
+
rbacOrganizationId: organizationId,
|
|
103
|
+
})
|
|
104
|
+
validateRouteMutationGuardMock.mockResolvedValue({ afterSuccess: afterSuccessMock })
|
|
105
|
+
listDiscordEligibleAgentsMock.mockResolvedValue({ available: true, agents: [PROVIDER_AGENT] })
|
|
106
|
+
resolveDiscordAiPrincipalMock.mockResolvedValue({
|
|
107
|
+
tenantId,
|
|
108
|
+
organizationId,
|
|
109
|
+
userId,
|
|
110
|
+
features: ['channel_discord.ai_auto_reply.run'],
|
|
111
|
+
isSuperAdmin: false,
|
|
112
|
+
source: 'provider_service_principal',
|
|
113
|
+
})
|
|
114
|
+
commandBusExecuteMock.mockResolvedValue({
|
|
115
|
+
result: {
|
|
116
|
+
status: 'updated',
|
|
117
|
+
channelId,
|
|
118
|
+
aiAutoReplyEnabled: true,
|
|
119
|
+
aiAgentId: PROVIDER_AGENT.id,
|
|
120
|
+
updatedAt: '2026-08-02T00:00:00.000Z',
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('surfaces the status and body of an interceptor rejection that carries one', async () => {
|
|
126
|
+
commandBusExecuteMock.mockRejectedValueOnce(
|
|
127
|
+
new CommandInterceptorError('AI auto-reply is disabled for this tenant', {
|
|
128
|
+
status: 422,
|
|
129
|
+
body: { error: 'AI auto-reply is disabled for this tenant' },
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const response = await PUT(putRequest(), context)
|
|
134
|
+
|
|
135
|
+
expect(response.status).toBe(422)
|
|
136
|
+
await expect(response.json()).resolves.toEqual({
|
|
137
|
+
error: 'AI auto-reply is disabled for this tenant',
|
|
138
|
+
})
|
|
139
|
+
// The route's success side effect must not run for a blocked mutation.
|
|
140
|
+
expect(afterSuccessMock).not.toHaveBeenCalled()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('still rethrows a rejection that carries no status', async () => {
|
|
144
|
+
const rejection = new CommandInterceptorError('Blocked without a status')
|
|
145
|
+
commandBusExecuteMock.mockRejectedValueOnce(rejection)
|
|
146
|
+
|
|
147
|
+
await expect(PUT(putRequest(), context)).rejects.toBe(rejection)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -6,6 +6,7 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
|
6
6
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
7
|
import type { CommandBus } from '@open-mercato/shared/lib/commands'
|
|
8
8
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
9
|
+
import { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'
|
|
9
10
|
import { validateRouteMutationGuard } from '@open-mercato/core/modules/communication_channels/lib/route-mutation-guard'
|
|
10
11
|
import { CHANNEL_DISCORD_AUTO_REPLY_AGENT_ID } from '../../../../ai-agents'
|
|
11
12
|
import {
|
|
@@ -248,6 +249,10 @@ export async function PUT(req: Request, context: RouteContext): Promise<Response
|
|
|
248
249
|
if (isCrudHttpError(err)) {
|
|
249
250
|
return NextResponse.json(err.body, { status: err.status })
|
|
250
251
|
}
|
|
252
|
+
const interceptorRejection = getCommandInterceptorHttpRejection(err)
|
|
253
|
+
if (interceptorRejection) {
|
|
254
|
+
return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })
|
|
255
|
+
}
|
|
251
256
|
throw err
|
|
252
257
|
}
|
|
253
258
|
|