@open-mercato/core 0.7.1-develop.7150.1.c1941e0c22 → 0.7.1-develop.7151.1.00d0391847
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/.turbo/turbo-build.log +1 -1
- package/dist/helpers/integration/communicationChannelsFixtures.js +77 -1
- package/dist/helpers/integration/communicationChannelsFixtures.js.map +2 -2
- package/dist/helpers/integration/crmFixtures.js +3 -0
- package/dist/helpers/integration/crmFixtures.js.map +2 -2
- package/dist/modules/auth/api/reset.js +7 -1
- package/dist/modules/auth/api/reset.js.map +2 -2
- package/dist/modules/auth/api/users/resend-invite/route.js +7 -1
- package/dist/modules/auth/api/users/resend-invite/route.js.map +2 -2
- package/dist/modules/auth/commands/users.js +7 -1
- package/dist/modules/auth/commands/users.js.map +2 -2
- package/dist/modules/communication_channels/api/post/test-seed/route.js +101 -26
- package/dist/modules/communication_channels/api/post/test-seed/route.js.map +3 -3
- package/dist/modules/communication_channels/di.js +8 -0
- package/dist/modules/communication_channels/di.js.map +2 -2
- package/dist/modules/communication_channels/lib/ensure-system-email-channel.js +57 -0
- package/dist/modules/communication_channels/lib/ensure-system-email-channel.js.map +7 -0
- package/dist/modules/communication_channels/lib/system-email-provider-config.js +32 -0
- package/dist/modules/communication_channels/lib/system-email-provider-config.js.map +7 -0
- package/dist/modules/communication_channels/lib/system-email.js +190 -0
- package/dist/modules/communication_channels/lib/system-email.js.map +7 -0
- package/dist/modules/communication_channels/lib/test-seed.js +130 -1
- package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
- package/dist/modules/communication_channels/setup.js +56 -1
- package/dist/modules/communication_channels/setup.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users-invite.js +1 -0
- package/dist/modules/customer_accounts/api/admin/users-invite.js.map +2 -2
- package/dist/modules/customer_accounts/api/portal/users-invite.js +1 -0
- package/dist/modules/customer_accounts/api/portal/users-invite.js.map +2 -2
- package/dist/modules/customer_accounts/api/signup.js +8 -4
- package/dist/modules/customer_accounts/api/signup.js.map +2 -2
- package/dist/modules/customer_accounts/lib/invitationEmail.js +3 -1
- package/dist/modules/customer_accounts/lib/invitationEmail.js.map +2 -2
- package/dist/modules/customers/api/interactions/route.js +3 -0
- package/dist/modules/customers/api/interactions/route.js.map +2 -2
- package/dist/modules/messages/lib/email-sender.js +16 -14
- package/dist/modules/messages/lib/email-sender.js.map +2 -2
- package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js +3 -1
- package/dist/modules/notifications/lib/strategies/email-delivery-strategy.js.map +2 -2
- package/dist/modules/sales/api/quotes/accept/route.js +3 -1
- package/dist/modules/sales/api/quotes/accept/route.js.map +2 -2
- package/dist/modules/sales/api/quotes/send/route.js +13 -4
- package/dist/modules/sales/api/quotes/send/route.js.map +3 -3
- package/package.json +7 -7
- package/src/helpers/integration/communicationChannelsFixtures.ts +129 -0
- package/src/helpers/integration/crmFixtures.ts +4 -1
- package/src/modules/auth/api/reset.ts +7 -1
- package/src/modules/auth/api/users/resend-invite/route.ts +7 -1
- package/src/modules/auth/commands/users.ts +7 -1
- package/src/modules/auth/i18n/de.json +4 -0
- package/src/modules/auth/i18n/en.json +4 -0
- package/src/modules/auth/i18n/es.json +4 -0
- package/src/modules/auth/i18n/ko.json +4 -0
- package/src/modules/auth/i18n/pl.json +4 -0
- package/src/modules/communication_channels/api/post/test-seed/route.ts +121 -29
- package/src/modules/communication_channels/di.ts +8 -0
- package/src/modules/communication_channels/lib/ensure-system-email-channel.ts +85 -0
- package/src/modules/communication_channels/lib/system-email-provider-config.ts +62 -0
- package/src/modules/communication_channels/lib/system-email.ts +316 -0
- package/src/modules/communication_channels/lib/test-seed.ts +198 -0
- package/src/modules/communication_channels/setup.ts +91 -1
- package/src/modules/customer_accounts/api/admin/users-invite.ts +1 -0
- package/src/modules/customer_accounts/api/portal/users-invite.ts +1 -0
- package/src/modules/customer_accounts/api/signup.ts +6 -2
- package/src/modules/customer_accounts/lib/invitationEmail.ts +3 -0
- package/src/modules/customers/api/interactions/route.ts +4 -0
- package/src/modules/messages/lib/email-sender.ts +18 -16
- package/src/modules/notifications/lib/strategies/email-delivery-strategy.ts +3 -1
- package/src/modules/sales/api/quotes/accept/route.ts +2 -0
- package/src/modules/sales/api/quotes/send/route.ts +16 -3
package/.turbo/turbo-build.log
CHANGED
|
@@ -2,6 +2,14 @@ import { expect } from "@playwright/test";
|
|
|
2
2
|
import { apiRequest } from "./api.js";
|
|
3
3
|
import { expectId, readJsonSafe } from "./generalFixtures.js";
|
|
4
4
|
const TEST_SEED_PATH = "/api/communication_channels/test-seed";
|
|
5
|
+
function resolveSystemCaptureCredentials() {
|
|
6
|
+
const accessToken = process.env.OM_TEST_EMAIL_CAPTURE_ACCESS_TOKEN?.trim();
|
|
7
|
+
const captureCorrelationToken = process.env.OM_TEST_EMAIL_CAPTURE_CORRELATION_TOKEN?.trim();
|
|
8
|
+
if (!accessToken || !captureCorrelationToken) {
|
|
9
|
+
throw new Error("[internal] System email capture credentials are not configured by the integration harness");
|
|
10
|
+
}
|
|
11
|
+
return { accessToken, captureCorrelationToken };
|
|
12
|
+
}
|
|
5
13
|
async function isChannelSeedingAvailable(request, token) {
|
|
6
14
|
const response = await apiRequest(request, "POST", TEST_SEED_PATH, {
|
|
7
15
|
token,
|
|
@@ -21,6 +29,70 @@ async function seedConnectedChannel(request, token, input = {}) {
|
|
|
21
29
|
const body = await readJsonSafe(response);
|
|
22
30
|
return expectId(body?.channelId, "connect-channel response should include channelId");
|
|
23
31
|
}
|
|
32
|
+
async function seedSystemEmailChannel(request, token, input = {}) {
|
|
33
|
+
const response = await apiRequest(request, "POST", TEST_SEED_PATH, {
|
|
34
|
+
token,
|
|
35
|
+
data: { action: "seed-system-channel", ...input }
|
|
36
|
+
});
|
|
37
|
+
expect(
|
|
38
|
+
response.status(),
|
|
39
|
+
"POST /api/communication_channels/test-seed (seed-system-channel) should return 201"
|
|
40
|
+
).toBe(201);
|
|
41
|
+
const body = await readJsonSafe(response);
|
|
42
|
+
return expectId(body?.channelId, "seed-system-channel response should include channelId");
|
|
43
|
+
}
|
|
44
|
+
async function clearCapturedSystemEmails(request, token, options = {}) {
|
|
45
|
+
const captureCredentials = options.systemRecipient ? resolveSystemCaptureCredentials() : null;
|
|
46
|
+
const response = await apiRequest(request, "POST", TEST_SEED_PATH, {
|
|
47
|
+
token,
|
|
48
|
+
data: {
|
|
49
|
+
action: "clear-capture",
|
|
50
|
+
...options,
|
|
51
|
+
...captureCredentials ? { captureCorrelationToken: captureCredentials.captureCorrelationToken } : {}
|
|
52
|
+
},
|
|
53
|
+
headers: captureCredentials ? { "x-om-test-email-capture-access-token": captureCredentials.accessToken } : void 0
|
|
54
|
+
});
|
|
55
|
+
expect(response.ok(), "POST /api/communication_channels/test-seed (clear-capture) should succeed").toBeTruthy();
|
|
56
|
+
}
|
|
57
|
+
async function listCapturedSystemEmails(request, token, options = {}) {
|
|
58
|
+
const captureCredentials = options.systemRecipient ? resolveSystemCaptureCredentials() : null;
|
|
59
|
+
const response = await apiRequest(request, "POST", TEST_SEED_PATH, {
|
|
60
|
+
token,
|
|
61
|
+
data: {
|
|
62
|
+
action: "list-capture",
|
|
63
|
+
...options,
|
|
64
|
+
...captureCredentials ? { captureCorrelationToken: captureCredentials.captureCorrelationToken } : {}
|
|
65
|
+
},
|
|
66
|
+
headers: captureCredentials ? { "x-om-test-email-capture-access-token": captureCredentials.accessToken } : void 0
|
|
67
|
+
});
|
|
68
|
+
expect(response.ok(), "POST /api/communication_channels/test-seed (list-capture) should succeed").toBeTruthy();
|
|
69
|
+
const body = await readJsonSafe(response);
|
|
70
|
+
return Array.isArray(body?.items) ? body.items : [];
|
|
71
|
+
}
|
|
72
|
+
async function waitForCapturedSystemEmail(request, token, predicate, options = {}) {
|
|
73
|
+
const timeoutMs = options.timeoutMs ?? 2e4;
|
|
74
|
+
const intervalMs = options.intervalMs ?? 1e3;
|
|
75
|
+
const deadline = Date.now() + timeoutMs;
|
|
76
|
+
let lastItems = [];
|
|
77
|
+
while (Date.now() < deadline) {
|
|
78
|
+
lastItems = await listCapturedSystemEmails(request, token, {
|
|
79
|
+
systemRecipient: options.systemRecipient
|
|
80
|
+
});
|
|
81
|
+
const found = lastItems.find(predicate);
|
|
82
|
+
if (found) return found;
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
84
|
+
}
|
|
85
|
+
const summary = lastItems.map((item) => ({
|
|
86
|
+
to: item.metadata?.to,
|
|
87
|
+
from: item.metadata?.from,
|
|
88
|
+
subject: item.metadata?.subject,
|
|
89
|
+
scope: item.scope,
|
|
90
|
+
bodyFormat: item.content?.bodyFormat
|
|
91
|
+
}));
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Timed out waiting for captured system email${options.description ? `: ${options.description}` : ""}. Captured ${lastItems.length} message(s): ${JSON.stringify(summary)}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
24
96
|
async function seedInboundMessage(request, token, input) {
|
|
25
97
|
const response = await apiRequest(request, "POST", TEST_SEED_PATH, {
|
|
26
98
|
token,
|
|
@@ -68,10 +140,14 @@ async function deleteChannelIfExists(request, token, channelId) {
|
|
|
68
140
|
).catch(() => void 0);
|
|
69
141
|
}
|
|
70
142
|
export {
|
|
143
|
+
clearCapturedSystemEmails,
|
|
71
144
|
deleteChannelIfExists,
|
|
72
145
|
ingestInboundChatMessage,
|
|
73
146
|
isChannelSeedingAvailable,
|
|
147
|
+
listCapturedSystemEmails,
|
|
74
148
|
seedConnectedChannel,
|
|
75
|
-
seedInboundMessage
|
|
149
|
+
seedInboundMessage,
|
|
150
|
+
seedSystemEmailChannel,
|
|
151
|
+
waitForCapturedSystemEmail
|
|
76
152
|
};
|
|
77
153
|
//# sourceMappingURL=communicationChannelsFixtures.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/helpers/integration/communicationChannelsFixtures.ts"],
|
|
4
|
-
"sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from './api';\nimport { expectId, readJsonSafe } from './generalFixtures';\n\n/**\n * Communication-channels integration fixtures.\n *\n * These drive the TEST-ONLY seed endpoint `POST /api/communication_channels/test-seed`,\n * which is gated by `OM_ENABLE_TEST_CHANNEL_SEEDING` (inert/404 in production). Use\n * {@link isChannelSeedingAvailable} to skip tests when the gate is off rather than\n * failing them.\n */\n\nexport type SeedAddressField =\n | string\n | { address: string; name?: string }\n | Array<string | { address: string; name?: string }>;\n\nconst TEST_SEED_PATH = '/api/communication_channels/test-seed';\n\n/**\n * Probe whether the env-gated test-seed endpoint is enabled in the target app.\n * Returns false when the route answers 404 (flag off) so callers can `test.skip`.\n * The caller's token must hold `communication_channels.connect_user_channel`.\n */\nexport async function isChannelSeedingAvailable(\n request: APIRequestContext,\n token: string,\n): Promise<boolean> {\n // A malformed body returns 422 when the gate is ON and 404 when it is OFF.\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: '__probe__' },\n });\n return response.status() !== 404;\n}\n\n/**\n * Seed a connected, network-free `__test_seed__` channel owned by the caller.\n * Returns the new channel id. Tear down with {@link deleteChannelIfExists}.\n */\nexport async function seedConnectedChannel(\n request: APIRequestContext,\n token: string,\n input: {\n displayName?: string;\n externalIdentifier?: string;\n /**\n * `chat` connects the non-email stub instead: a channel whose senders carry\n * an opaque handle and no address, and whose `externalIdentifier` is NULL \u2014\n * the shape a real Discord/Slack channel has. Use it for anything asserting\n * the hub's sender-identity contract (#4975); the default `email` flavor\n * cannot prove it, because it can only ever supply email-shaped data.\n */\n providerFlavor?: 'email' | 'chat';\n /**\n * Relabel the connected stub channel's `provider_key`. Provider packages own\n * routes that filter on their own key, and a real channel for those providers\n * cannot be connected in CI (the Discord adapter validates its bot token\n * against the live API), so without this a provider-scoped listing can only\n * be asserted against an empty result \u2014 the assertion that stayed green\n * through #5602.\n *\n * The row is relabelled, nothing is registered: do not drive an outbound send\n * through a relabelled channel, because delivery would resolve the real\n * provider's adapter.\n */\n labelAsProviderKey?: string;\n } = {},\n): Promise<string> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'connect-channel', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (connect-channel) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{ channelId?: string }>(response);\n return expectId(body?.channelId, 'connect-channel response should include channelId');\n}\n\n/**\n * Seed an inbound `MessageChannelLink` for `channelId` and emit\n * `communication_channels.message.received` through the real event bus. The\n * persistent customers link-channel-message-received subscriber is enqueued to\n * the `events` queue \u2014 drain it with `drainIntegrationQueue('events')`.\n *\n * Returns the created link + message ids (the message id is the platform\n * `messages.message` id, usable as `messageThreadId` to thread a follow-up).\n *\n * Pass `createThreadMapping: true` to also seed a `ChannelThreadMapping` for the\n * thread \u2014 required before exercising the reaction (`/messages/[id]/reactions`)\n * and thread-assign (`/threads/[id]/assign`) routes, which resolve the owning\n * channel through that mapping.\n */\nexport async function seedInboundMessage(\n request: APIRequestContext,\n token: string,\n input: {\n channelId: string;\n from?: SeedAddressField;\n to?: SeedAddressField;\n cc?: SeedAddressField;\n subject?: string;\n bodyText?: string;\n messageId?: string;\n inReplyTo?: string;\n references?: string[];\n messageThreadId?: string;\n providerKey?: string;\n createThreadMapping?: boolean;\n },\n): Promise<{ channelLinkId: string; messageId: string; conversationId: string }> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'emit-inbound', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (emit-inbound) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{\n channelLinkId?: string;\n messageId?: string;\n conversationId?: string;\n }>(response);\n return {\n channelLinkId: expectId(body?.channelLinkId, 'emit-inbound response should include channelLinkId'),\n messageId: expectId(body?.messageId, 'emit-inbound response should include messageId'),\n conversationId: expectId(\n body?.conversationId,\n 'emit-inbound response should include conversationId',\n ),\n };\n}\n\n/**\n * Best-effort delete of a seeded channel via the owner-scoped DELETE route.\n * Safe to call with a null id in `finally`.\n */\n/**\n * Ingest an inbound chat message through the REAL `ingest_inbound_message`\n * command: the stub adapter normalizes the frame and the hub composes the\n * platform message via `messages.messages.compose`.\n *\n * Unlike {@link seedInboundMessage}, nothing here is short-circuited \u2014 no row is\n * inserted behind the compose path \u2014 so a hub contract the provider cannot\n * satisfy fails the test instead of hiding behind seeded data. That bypass is\n * exactly why CI was green while every real inbound Discord message was rejected\n * (#4975).\n *\n * The sender is identified only by `senderIdentifier` (a Discord snowflake, a\n * Slack member id, \u2026). There is deliberately no way to pass an address.\n */\nexport async function ingestInboundChatMessage(\n request: APIRequestContext,\n token: string,\n input: {\n channelId: string;\n senderIdentifier: string;\n senderDisplayName?: string;\n body?: string;\n externalMessageId: string;\n externalConversationId: string;\n },\n): Promise<{\n status: string;\n messageId: string | null;\n conversationId: string | null;\n channelLinkId: string | null;\n channelType: string | null;\n}> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'ingest-inbound', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (ingest-inbound) should return 201 \u2014 ' +\n 'a non-201 here means the hub rejected a sender with no email address',\n ).toBe(201);\n const body = await readJsonSafe<{\n status?: string;\n messageId?: string | null;\n conversationId?: string | null;\n channelLinkId?: string | null;\n channelType?: string | null;\n }>(response);\n return {\n status: body?.status ?? 'unknown',\n messageId: body?.messageId ?? null,\n conversationId: body?.conversationId ?? null,\n channelLinkId: body?.channelLinkId ?? null,\n channelType: body?.channelType ?? null,\n };\n}\n\nexport async function deleteChannelIfExists(\n request: APIRequestContext,\n token: string | null,\n channelId: string | null,\n): Promise<void> {\n if (!token || !channelId) return;\n await apiRequest(\n request,\n 'DELETE',\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}`,\n { token },\n ).catch(() => undefined);\n}\n"],
|
|
5
|
-
"mappings": "AAAA,SAAS,cAAsC;AAC/C,SAAS,kBAAkB;AAC3B,SAAS,UAAU,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from './api';\nimport { expectId, readJsonSafe } from './generalFixtures';\n\n/**\n * Communication-channels integration fixtures.\n *\n * These drive the TEST-ONLY seed endpoint `POST /api/communication_channels/test-seed`,\n * which is gated by `OM_ENABLE_TEST_CHANNEL_SEEDING` (inert/404 in production). Use\n * {@link isChannelSeedingAvailable} to skip tests when the gate is off rather than\n * failing them.\n */\n\nexport type SeedAddressField =\n | string\n | { address: string; name?: string }\n | Array<string | { address: string; name?: string }>;\n\nexport type CapturedTestSeedEmail = {\n capturedAt: string;\n externalMessageId: string;\n conversationId?: string;\n content: {\n text?: string;\n html?: string;\n bodyFormat?: 'text' | 'markdown' | 'html';\n attachments?: Array<{ url: string; mimeType: string; fileName: string; fileSize?: number; inline?: boolean }>;\n raw?: Record<string, unknown>;\n };\n credentials: Record<string, unknown>;\n scope: {\n tenantId: string;\n organizationId: string;\n };\n metadata?: Record<string, unknown>;\n};\n\nconst TEST_SEED_PATH = '/api/communication_channels/test-seed';\n\nfunction resolveSystemCaptureCredentials(): {\n accessToken: string;\n captureCorrelationToken: string;\n} {\n const accessToken = process.env.OM_TEST_EMAIL_CAPTURE_ACCESS_TOKEN?.trim();\n const captureCorrelationToken = process.env.OM_TEST_EMAIL_CAPTURE_CORRELATION_TOKEN?.trim();\n if (!accessToken || !captureCorrelationToken) {\n throw new Error('[internal] System email capture credentials are not configured by the integration harness');\n }\n return { accessToken, captureCorrelationToken };\n}\n\n/**\n * Probe whether the env-gated test-seed endpoint is enabled in the target app.\n * Returns false when the route answers 404 (flag off) so callers can `test.skip`.\n * The caller's token must hold `communication_channels.connect_user_channel`.\n */\nexport async function isChannelSeedingAvailable(\n request: APIRequestContext,\n token: string,\n): Promise<boolean> {\n // A malformed body returns 422 when the gate is ON and 404 when it is OFF.\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: '__probe__' },\n });\n return response.status() !== 404;\n}\n\n/**\n * Seed a connected, network-free `__test_seed__` channel owned by the caller.\n * Returns the new channel id. Tear down with {@link deleteChannelIfExists}.\n */\nexport async function seedConnectedChannel(\n request: APIRequestContext,\n token: string,\n input: {\n displayName?: string;\n externalIdentifier?: string;\n /**\n * `chat` connects the non-email stub instead: a channel whose senders carry\n * an opaque handle and no address, and whose `externalIdentifier` is NULL \u2014\n * the shape a real Discord/Slack channel has. Use it for anything asserting\n * the hub's sender-identity contract (#4975); the default `email` flavor\n * cannot prove it, because it can only ever supply email-shaped data.\n */\n providerFlavor?: 'email' | 'chat';\n /**\n * Relabel the connected stub channel's `provider_key`. Provider packages own\n * routes that filter on their own key, and a real channel for those providers\n * cannot be connected in CI (the Discord adapter validates its bot token\n * against the live API), so without this a provider-scoped listing can only\n * be asserted against an empty result \u2014 the assertion that stayed green\n * through #5602.\n *\n * The row is relabelled, nothing is registered: do not drive an outbound send\n * through a relabelled channel, because delivery would resolve the real\n * provider's adapter.\n */\n labelAsProviderKey?: string;\n } = {},\n): Promise<string> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'connect-channel', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (connect-channel) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{ channelId?: string }>(response);\n return expectId(body?.channelId, 'connect-channel response should include channelId');\n}\n\nexport async function seedSystemEmailChannel(\n request: APIRequestContext,\n token: string,\n input: { displayName?: string; externalIdentifier?: string } = {},\n): Promise<string> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'seed-system-channel', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (seed-system-channel) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{ channelId?: string }>(response);\n return expectId(body?.channelId, 'seed-system-channel response should include channelId');\n}\n\nexport async function clearCapturedSystemEmails(\n request: APIRequestContext,\n token: string,\n options: { systemRecipient?: string } = {},\n): Promise<void> {\n const captureCredentials = options.systemRecipient ? resolveSystemCaptureCredentials() : null;\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: {\n action: 'clear-capture',\n ...options,\n ...(captureCredentials\n ? { captureCorrelationToken: captureCredentials.captureCorrelationToken }\n : {}),\n },\n headers: captureCredentials\n ? { 'x-om-test-email-capture-access-token': captureCredentials.accessToken }\n : undefined,\n });\n expect(response.ok(), 'POST /api/communication_channels/test-seed (clear-capture) should succeed').toBeTruthy();\n}\n\nexport async function listCapturedSystemEmails(\n request: APIRequestContext,\n token: string,\n options: { systemRecipient?: string } = {},\n): Promise<CapturedTestSeedEmail[]> {\n const captureCredentials = options.systemRecipient ? resolveSystemCaptureCredentials() : null;\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: {\n action: 'list-capture',\n ...options,\n ...(captureCredentials\n ? { captureCorrelationToken: captureCredentials.captureCorrelationToken }\n : {}),\n },\n headers: captureCredentials\n ? { 'x-om-test-email-capture-access-token': captureCredentials.accessToken }\n : undefined,\n });\n expect(response.ok(), 'POST /api/communication_channels/test-seed (list-capture) should succeed').toBeTruthy();\n const body = await readJsonSafe<{ items?: CapturedTestSeedEmail[] }>(response);\n return Array.isArray(body?.items) ? body.items : [];\n}\n\nexport async function waitForCapturedSystemEmail(\n request: APIRequestContext,\n token: string,\n predicate: (email: CapturedTestSeedEmail) => boolean,\n options: {\n timeoutMs?: number;\n intervalMs?: number;\n description?: string;\n systemRecipient?: string;\n } = {},\n): Promise<CapturedTestSeedEmail> {\n const timeoutMs = options.timeoutMs ?? 20_000;\n const intervalMs = options.intervalMs ?? 1_000;\n const deadline = Date.now() + timeoutMs;\n let lastItems: CapturedTestSeedEmail[] = [];\n while (Date.now() < deadline) {\n lastItems = await listCapturedSystemEmails(request, token, {\n systemRecipient: options.systemRecipient,\n });\n const found = lastItems.find(predicate);\n if (found) return found;\n await new Promise((resolve) => setTimeout(resolve, intervalMs));\n }\n const summary = lastItems.map((item) => ({\n to: item.metadata?.to,\n from: item.metadata?.from,\n subject: item.metadata?.subject,\n scope: item.scope,\n bodyFormat: item.content?.bodyFormat,\n }));\n throw new Error(\n `Timed out waiting for captured system email${options.description ? `: ${options.description}` : ''}. Captured ${lastItems.length} message(s): ${JSON.stringify(summary)}`,\n );\n}\n\n/**\n * Seed an inbound `MessageChannelLink` for `channelId` and emit\n * `communication_channels.message.received` through the real event bus. The\n * persistent customers link-channel-message-received subscriber is enqueued to\n * the `events` queue \u2014 drain it with `drainIntegrationQueue('events')`.\n *\n * Returns the created link + message ids (the message id is the platform\n * `messages.message` id, usable as `messageThreadId` to thread a follow-up).\n *\n * Pass `createThreadMapping: true` to also seed a `ChannelThreadMapping` for the\n * thread \u2014 required before exercising the reaction (`/messages/[id]/reactions`)\n * and thread-assign (`/threads/[id]/assign`) routes, which resolve the owning\n * channel through that mapping.\n */\nexport async function seedInboundMessage(\n request: APIRequestContext,\n token: string,\n input: {\n channelId: string;\n from?: SeedAddressField;\n to?: SeedAddressField;\n cc?: SeedAddressField;\n subject?: string;\n bodyText?: string;\n messageId?: string;\n inReplyTo?: string;\n references?: string[];\n messageThreadId?: string;\n providerKey?: string;\n createThreadMapping?: boolean;\n },\n): Promise<{ channelLinkId: string; messageId: string; conversationId: string }> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'emit-inbound', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (emit-inbound) should return 201',\n ).toBe(201);\n const body = await readJsonSafe<{\n channelLinkId?: string;\n messageId?: string;\n conversationId?: string;\n }>(response);\n return {\n channelLinkId: expectId(body?.channelLinkId, 'emit-inbound response should include channelLinkId'),\n messageId: expectId(body?.messageId, 'emit-inbound response should include messageId'),\n conversationId: expectId(\n body?.conversationId,\n 'emit-inbound response should include conversationId',\n ),\n };\n}\n\n/**\n * Best-effort delete of a seeded channel via the owner-scoped DELETE route.\n * Safe to call with a null id in `finally`.\n */\n/**\n * Ingest an inbound chat message through the REAL `ingest_inbound_message`\n * command: the stub adapter normalizes the frame and the hub composes the\n * platform message via `messages.messages.compose`.\n *\n * Unlike {@link seedInboundMessage}, nothing here is short-circuited \u2014 no row is\n * inserted behind the compose path \u2014 so a hub contract the provider cannot\n * satisfy fails the test instead of hiding behind seeded data. That bypass is\n * exactly why CI was green while every real inbound Discord message was rejected\n * (#4975).\n *\n * The sender is identified only by `senderIdentifier` (a Discord snowflake, a\n * Slack member id, \u2026). There is deliberately no way to pass an address.\n */\nexport async function ingestInboundChatMessage(\n request: APIRequestContext,\n token: string,\n input: {\n channelId: string;\n senderIdentifier: string;\n senderDisplayName?: string;\n body?: string;\n externalMessageId: string;\n externalConversationId: string;\n },\n): Promise<{\n status: string;\n messageId: string | null;\n conversationId: string | null;\n channelLinkId: string | null;\n channelType: string | null;\n}> {\n const response = await apiRequest(request, 'POST', TEST_SEED_PATH, {\n token,\n data: { action: 'ingest-inbound', ...input },\n });\n expect(\n response.status(),\n 'POST /api/communication_channels/test-seed (ingest-inbound) should return 201 \u2014 ' +\n 'a non-201 here means the hub rejected a sender with no email address',\n ).toBe(201);\n const body = await readJsonSafe<{\n status?: string;\n messageId?: string | null;\n conversationId?: string | null;\n channelLinkId?: string | null;\n channelType?: string | null;\n }>(response);\n return {\n status: body?.status ?? 'unknown',\n messageId: body?.messageId ?? null,\n conversationId: body?.conversationId ?? null,\n channelLinkId: body?.channelLinkId ?? null,\n channelType: body?.channelType ?? null,\n };\n}\n\nexport async function deleteChannelIfExists(\n request: APIRequestContext,\n token: string | null,\n channelId: string | null,\n): Promise<void> {\n if (!token || !channelId) return;\n await apiRequest(\n request,\n 'DELETE',\n `/api/communication_channels/channels/${encodeURIComponent(channelId)}`,\n { token },\n ).catch(() => undefined);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -40,6 +40,9 @@ async function createPersonFixture(request, token, input) {
|
|
|
40
40
|
if (input.companyEntityId) {
|
|
41
41
|
data.companyEntityId = input.companyEntityId;
|
|
42
42
|
}
|
|
43
|
+
if (input.primaryEmail) {
|
|
44
|
+
data.primaryEmail = input.primaryEmail;
|
|
45
|
+
}
|
|
43
46
|
return createEntity(request, token, "/api/customers/people", data, ["id", "entityId", "personId"]);
|
|
44
47
|
}
|
|
45
48
|
async function createDealFixture(request, token, input) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/helpers/integration/crmFixtures.ts"],
|
|
4
|
-
"sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from './api';\nimport { readJsonSafe } from './generalFixtures';\n\ntype JsonRecord = Record<string, unknown>;\n\nexport { readJsonSafe } from './generalFixtures';\n\nfunction isRecord(value: unknown): value is JsonRecord {\n return typeof value === 'object' && value !== null;\n}\n\nfunction findStringByKeys(value: unknown, keys: readonly string[]): string | null {\n if (!isRecord(value)) return null;\n\n for (const key of keys) {\n const candidate = value[key];\n if (typeof candidate === 'string' && candidate.trim().length > 0) {\n return candidate.trim();\n }\n }\n\n for (const nested of Object.values(value)) {\n if (Array.isArray(nested)) continue;\n const found = findStringByKeys(nested, keys);\n if (found) return found;\n }\n\n return null;\n}\n\nasync function createEntity(\n request: APIRequestContext,\n token: string,\n path: string,\n data: Record<string, unknown>,\n idKeys: readonly string[],\n): Promise<string> {\n const response = await apiRequest(request, 'POST', path, { token, data });\n const payload = await readJsonSafe(response);\n expect(response.ok(), `Failed POST ${path}: ${response.status()}`).toBeTruthy();\n const id = findStringByKeys(payload, idKeys);\n expect(id, `No id in ${path} response`).toBeTruthy();\n return id as string;\n}\n\nexport async function createCompanyFixture(\n request: APIRequestContext,\n token: string,\n displayName: string,\n): Promise<string> {\n return createEntity(request, token, '/api/customers/companies', { displayName }, ['id', 'entityId', 'companyId']);\n}\n\nexport async function createPersonFixture(\n request: APIRequestContext,\n token: string,\n input: { firstName: string; lastName: string; displayName: string; companyEntityId?: string },\n): Promise<string> {\n const data: Record<string, unknown> = {\n firstName: input.firstName,\n lastName: input.lastName,\n displayName: input.displayName,\n };\n if (input.companyEntityId) {\n data.companyEntityId = input.companyEntityId;\n }\n return createEntity(request, token, '/api/customers/people', data, ['id', 'entityId', 'personId']);\n}\n\nexport async function createDealFixture(\n request: APIRequestContext,\n token: string,\n input: {\n title: string;\n companyIds?: string[];\n personIds?: string[];\n pipelineId?: string;\n pipelineStageId?: string;\n valueAmount?: number;\n valueCurrency?: string;\n // Optional deal lifecycle fields \u2014 all valid on `dealCreateSchema`. Forwarded so KPI/summary\n // tests can seed won/lost/overdue/owned deals across quarters (see TC-CRM-082). `status` is a\n // free-form dictionary value (e.g. 'open', 'in_progress', 'win', 'lost'); `expectedCloseAt`\n // accepts an ISO string (the schema coerces it to a Date); `closureOutcome` is 'won' | 'lost'.\n status?: string;\n expectedCloseAt?: string;\n ownerUserId?: string;\n closureOutcome?: 'won' | 'lost';\n },\n): Promise<string> {\n const data: Record<string, unknown> = { title: input.title };\n if (input.companyIds?.length) data.companyIds = input.companyIds;\n if (input.personIds?.length) data.personIds = input.personIds;\n if (input.pipelineId) data.pipelineId = input.pipelineId;\n if (input.pipelineStageId) data.pipelineStageId = input.pipelineStageId;\n if (input.valueAmount !== undefined) data.valueAmount = input.valueAmount;\n if (input.valueCurrency) data.valueCurrency = input.valueCurrency;\n if (input.status) data.status = input.status;\n if (input.expectedCloseAt) data.expectedCloseAt = input.expectedCloseAt;\n if (input.ownerUserId) data.ownerUserId = input.ownerUserId;\n if (input.closureOutcome) data.closureOutcome = input.closureOutcome;\n return createEntity(request, token, '/api/customers/deals', data, ['dealId', 'id', 'entityId']);\n}\n\nexport async function createPipelineFixture(\n request: APIRequestContext,\n token: string,\n input: { name: string; isDefault?: boolean },\n): Promise<string> {\n const data: Record<string, unknown> = { name: input.name };\n if (input.isDefault !== undefined) data.isDefault = input.isDefault;\n return createEntity(request, token, '/api/customers/pipelines', data, ['id', 'pipelineId']);\n}\n\nexport async function createPipelineStageFixture(\n request: APIRequestContext,\n token: string,\n input: { pipelineId: string; label: string; order?: number },\n): Promise<string> {\n const data: Record<string, unknown> = { pipelineId: input.pipelineId, label: input.label };\n if (input.order !== undefined) data.order = input.order;\n return createEntity(request, token, '/api/customers/pipeline-stages', data, ['id', 'stageId']);\n}\n\nexport async function deleteEntityByBody(\n request: APIRequestContext,\n token: string | null,\n path: string,\n id: string | null,\n): Promise<void> {\n if (!token || !id) return;\n try {\n await apiRequest(request, 'DELETE', path, { token, data: { id } });\n } catch {\n return;\n }\n}\n\nexport async function deleteEntityIfExists(\n request: APIRequestContext,\n token: string | null,\n path: string,\n id: string | null,\n): Promise<void> {\n if (!token || !id) return;\n try {\n await apiRequest(request, 'DELETE', `${path}?id=${encodeURIComponent(id)}`, { token });\n } catch {\n return;\n }\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { expect, type APIRequestContext } from '@playwright/test';\nimport { apiRequest } from './api';\nimport { readJsonSafe } from './generalFixtures';\n\ntype JsonRecord = Record<string, unknown>;\n\nexport { readJsonSafe } from './generalFixtures';\n\nfunction isRecord(value: unknown): value is JsonRecord {\n return typeof value === 'object' && value !== null;\n}\n\nfunction findStringByKeys(value: unknown, keys: readonly string[]): string | null {\n if (!isRecord(value)) return null;\n\n for (const key of keys) {\n const candidate = value[key];\n if (typeof candidate === 'string' && candidate.trim().length > 0) {\n return candidate.trim();\n }\n }\n\n for (const nested of Object.values(value)) {\n if (Array.isArray(nested)) continue;\n const found = findStringByKeys(nested, keys);\n if (found) return found;\n }\n\n return null;\n}\n\nasync function createEntity(\n request: APIRequestContext,\n token: string,\n path: string,\n data: Record<string, unknown>,\n idKeys: readonly string[],\n): Promise<string> {\n const response = await apiRequest(request, 'POST', path, { token, data });\n const payload = await readJsonSafe(response);\n expect(response.ok(), `Failed POST ${path}: ${response.status()}`).toBeTruthy();\n const id = findStringByKeys(payload, idKeys);\n expect(id, `No id in ${path} response`).toBeTruthy();\n return id as string;\n}\n\nexport async function createCompanyFixture(\n request: APIRequestContext,\n token: string,\n displayName: string,\n): Promise<string> {\n return createEntity(request, token, '/api/customers/companies', { displayName }, ['id', 'entityId', 'companyId']);\n}\n\nexport async function createPersonFixture(\n request: APIRequestContext,\n token: string,\n input: { firstName: string; lastName: string; displayName: string; companyEntityId?: string; primaryEmail?: string },\n): Promise<string> {\n const data: Record<string, unknown> = {\n firstName: input.firstName,\n lastName: input.lastName,\n displayName: input.displayName,\n };\n if (input.companyEntityId) {\n data.companyEntityId = input.companyEntityId;\n }\n if (input.primaryEmail) {\n data.primaryEmail = input.primaryEmail;\n }\n return createEntity(request, token, '/api/customers/people', data, ['id', 'entityId', 'personId']);\n}\n\nexport async function createDealFixture(\n request: APIRequestContext,\n token: string,\n input: {\n title: string;\n companyIds?: string[];\n personIds?: string[];\n pipelineId?: string;\n pipelineStageId?: string;\n valueAmount?: number;\n valueCurrency?: string;\n // Optional deal lifecycle fields \u2014 all valid on `dealCreateSchema`. Forwarded so KPI/summary\n // tests can seed won/lost/overdue/owned deals across quarters (see TC-CRM-082). `status` is a\n // free-form dictionary value (e.g. 'open', 'in_progress', 'win', 'lost'); `expectedCloseAt`\n // accepts an ISO string (the schema coerces it to a Date); `closureOutcome` is 'won' | 'lost'.\n status?: string;\n expectedCloseAt?: string;\n ownerUserId?: string;\n closureOutcome?: 'won' | 'lost';\n },\n): Promise<string> {\n const data: Record<string, unknown> = { title: input.title };\n if (input.companyIds?.length) data.companyIds = input.companyIds;\n if (input.personIds?.length) data.personIds = input.personIds;\n if (input.pipelineId) data.pipelineId = input.pipelineId;\n if (input.pipelineStageId) data.pipelineStageId = input.pipelineStageId;\n if (input.valueAmount !== undefined) data.valueAmount = input.valueAmount;\n if (input.valueCurrency) data.valueCurrency = input.valueCurrency;\n if (input.status) data.status = input.status;\n if (input.expectedCloseAt) data.expectedCloseAt = input.expectedCloseAt;\n if (input.ownerUserId) data.ownerUserId = input.ownerUserId;\n if (input.closureOutcome) data.closureOutcome = input.closureOutcome;\n return createEntity(request, token, '/api/customers/deals', data, ['dealId', 'id', 'entityId']);\n}\n\nexport async function createPipelineFixture(\n request: APIRequestContext,\n token: string,\n input: { name: string; isDefault?: boolean },\n): Promise<string> {\n const data: Record<string, unknown> = { name: input.name };\n if (input.isDefault !== undefined) data.isDefault = input.isDefault;\n return createEntity(request, token, '/api/customers/pipelines', data, ['id', 'pipelineId']);\n}\n\nexport async function createPipelineStageFixture(\n request: APIRequestContext,\n token: string,\n input: { pipelineId: string; label: string; order?: number },\n): Promise<string> {\n const data: Record<string, unknown> = { pipelineId: input.pipelineId, label: input.label };\n if (input.order !== undefined) data.order = input.order;\n return createEntity(request, token, '/api/customers/pipeline-stages', data, ['id', 'stageId']);\n}\n\nexport async function deleteEntityByBody(\n request: APIRequestContext,\n token: string | null,\n path: string,\n id: string | null,\n): Promise<void> {\n if (!token || !id) return;\n try {\n await apiRequest(request, 'DELETE', path, { token, data: { id } });\n } catch {\n return;\n }\n}\n\nexport async function deleteEntityIfExists(\n request: APIRequestContext,\n token: string | null,\n path: string,\n id: string | null,\n): Promise<void> {\n if (!token || !id) return;\n try {\n await apiRequest(request, 'DELETE', `${path}?id=${encodeURIComponent(id)}`, { token });\n } catch {\n return;\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["readJsonSafe"]
|
|
7
7
|
}
|
|
@@ -75,7 +75,13 @@ async function POST(req) {
|
|
|
75
75
|
hint: translate("auth.email.resetPassword.hint", "If you didn't request this, you can safely ignore this email.")
|
|
76
76
|
};
|
|
77
77
|
try {
|
|
78
|
-
await sendEmail({
|
|
78
|
+
await sendEmail({
|
|
79
|
+
to: user.email,
|
|
80
|
+
subject,
|
|
81
|
+
react: ResetPasswordEmail({ resetUrl, copy }),
|
|
82
|
+
tenantId: user.tenantId ? String(user.tenantId) : void 0,
|
|
83
|
+
organizationId: user.organizationId ? String(user.organizationId) : null
|
|
84
|
+
});
|
|
79
85
|
} catch (err) {
|
|
80
86
|
logger.error("Failed to send reset email", { err });
|
|
81
87
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/auth/api/reset.ts"],
|
|
4
|
-
"sourcesContent": ["import { requestPasswordResetSchema } from '@open-mercato/core/modules/auth/data/validators'\nimport { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport ResetPasswordEmail from '@open-mercato/core/modules/auth/emails/ResetPasswordEmail'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { buildNotificationFromType } from '@open-mercato/core/modules/notifications/lib/notificationBuilder'\nimport { resolveNotificationService } from '@open-mercato/core/modules/notifications/lib/notificationService'\nimport notificationTypes from '@open-mercato/core/modules/auth/notifications'\nimport { z } from 'zod'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { mapSecurityEmailUrlError, toSecurityEmailUrl } from '@open-mercato/shared/lib/url'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\n\nconst logger = createLogger('auth').child({ component: 'reset' })\n\nconst resetRateLimitConfig = readEndpointRateLimitConfig('RESET', {\n points: 3, duration: 60, blockDuration: 60, keyPrefix: 'reset',\n})\nconst resetIpRateLimitConfig = readEndpointRateLimitConfig('RESET_IP', {\n points: 10, duration: 60, blockDuration: 60, keyPrefix: 'reset-ip',\n})\n\n// validation via requestPasswordResetSchema\n\nexport async function POST(req: Request) {\n const form = await req.formData()\n const email = String(form.get('email') ?? '')\n // Rate limit \u2014 two layers, both checked before validation and DB work\n const { error: rateLimitError } = await checkAuthRateLimit({\n req, ipConfig: resetIpRateLimitConfig, compoundConfig: resetRateLimitConfig, compoundIdentifier: email,\n })\n if (rateLimitError) return rateLimitError\n const parsed = requestPasswordResetSchema.safeParse({ email })\n if (!parsed.success) {\n const fieldErrors = parsed.error.flatten().fieldErrors\n return NextResponse.json({ error: 'Validation failed', fieldErrors }, { status: 422 })\n }\n let resetUrlTemplate: string\n try {\n resetUrlTemplate = toSecurityEmailUrl(req, '/reset/__token__')\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'auth.reset',\n configMessage: 'Password reset is not configured',\n })\n if (mapped) return NextResponse.json(mapped.body, { status: mapped.status })\n throw error\n }\n const c = await createRequestContainer()\n const auth = c.resolve<AuthService>('authService')\n const resReq = await auth.requestPasswordReset(parsed.data.email)\n if (!resReq) return NextResponse.json({ ok: true })\n const { user, token } = resReq\n const resetUrl = resetUrlTemplate.replace('__token__', token)\n void emitAuthEvent('auth.password.reset.requested', {\n id: String(user.id),\n tenantId: user.tenantId ? String(user.tenantId) : null,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n at: new Date().toISOString(),\n }, { persistent: true }).catch(() => undefined)\n\n const { translate } = await resolveTranslations()\n const subject = translate('auth.email.resetPassword.subject', 'Reset your password')\n const copy = {\n preview: translate('auth.email.resetPassword.preview', 'Reset your password'),\n title: translate('auth.email.resetPassword.title', 'Reset your password'),\n body: translate('auth.email.resetPassword.body', 'Click the link below to set a new password. This link will expire in 60 minutes.'),\n cta: translate('auth.email.resetPassword.cta', 'Set a new password'),\n hint: translate('auth.email.resetPassword.hint', \"If you didn't request this, you can safely ignore this email.\"),\n }\n\n try {\n await sendEmail({
|
|
5
|
-
"mappings": "AAAA,SAAS,kCAAkC;AAC3C,SAAS,oBAAoB;AAE7B,SAAS,8BAA8B;AAEvC,SAAS,iBAAiB;AAC1B,OAAO,wBAAwB;AAC/B,SAAS,2BAA2B;AACpC,SAAS,iCAAiC;AAC1C,SAAS,kCAAkC;AAC3C,OAAO,uBAAuB;AAC9B,SAAS,SAAS;AAClB,SAAS,4BAA4B;AACrC,SAAS,mCAAmC;AAC5C,SAAS,0BAA0B;AACnC,SAAS,0BAA0B,0BAA0B;AAC7D,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB;AAE9B,MAAM,SAAS,aAAa,MAAM,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;AAEhE,MAAM,uBAAuB,4BAA4B,SAAS;AAAA,EAChE,QAAQ;AAAA,EAAG,UAAU;AAAA,EAAI,eAAe;AAAA,EAAI,WAAW;AACzD,CAAC;AACD,MAAM,yBAAyB,4BAA4B,YAAY;AAAA,EACrE,QAAQ;AAAA,EAAI,UAAU;AAAA,EAAI,eAAe;AAAA,EAAI,WAAW;AAC1D,CAAC;AAID,eAAsB,KAAK,KAAc;AACvC,QAAM,OAAO,MAAM,IAAI,SAAS;AAChC,QAAM,QAAQ,OAAO,KAAK,IAAI,OAAO,KAAK,EAAE;AAE5C,QAAM,EAAE,OAAO,eAAe,IAAI,MAAM,mBAAmB;AAAA,IACzD;AAAA,IAAK,UAAU;AAAA,IAAwB,gBAAgB;AAAA,IAAsB,oBAAoB;AAAA,EACnG,CAAC;AACD,MAAI,eAAgB,QAAO;AAC3B,QAAM,SAAS,2BAA2B,UAAU,EAAE,MAAM,CAAC;AAC7D,MAAI,CAAC,OAAO,SAAS;AACnB,UAAM,cAAc,OAAO,MAAM,QAAQ,EAAE;AAC3C,WAAO,aAAa,KAAK,EAAE,OAAO,qBAAqB,YAAY,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACvF;AACA,MAAI;AACJ,MAAI;AACF,uBAAmB,mBAAmB,KAAK,kBAAkB;AAAA,EAC/D,SAAS,OAAO;AACd,UAAM,SAAS,yBAAyB,OAAO;AAAA,MAC7C,OAAO;AAAA,MACP,eAAe;AAAA,IACjB,CAAC;AACD,QAAI,OAAQ,QAAO,aAAa,KAAK,OAAO,MAAM,EAAE,QAAQ,OAAO,OAAO,CAAC;AAC3E,UAAM;AAAA,EACR;AACA,QAAM,IAAI,MAAM,uBAAuB;AACvC,QAAM,OAAO,EAAE,QAAqB,aAAa;AACjD,QAAM,SAAS,MAAM,KAAK,qBAAqB,OAAO,KAAK,KAAK;AAChE,MAAI,CAAC,OAAQ,QAAO,aAAa,KAAK,EAAE,IAAI,KAAK,CAAC;AAClD,QAAM,EAAE,MAAM,MAAM,IAAI;AACxB,QAAM,WAAW,iBAAiB,QAAQ,aAAa,KAAK;AAC5D,OAAK,cAAc,iCAAiC;AAAA,IAClD,IAAI,OAAO,KAAK,EAAE;AAAA,IAClB,UAAU,KAAK,WAAW,OAAO,KAAK,QAAQ,IAAI;AAAA,IAClD,gBAAgB,KAAK,iBAAiB,OAAO,KAAK,cAAc,IAAI;AAAA,IACpE,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,EAC7B,GAAG,EAAE,YAAY,KAAK,CAAC,EAAE,MAAM,MAAM,MAAS;AAE9C,QAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,QAAM,UAAU,UAAU,oCAAoC,qBAAqB;AACnF,QAAM,OAAO;AAAA,IACX,SAAS,UAAU,oCAAoC,qBAAqB;AAAA,IAC5E,OAAO,UAAU,kCAAkC,qBAAqB;AAAA,IACxE,MAAM,UAAU,iCAAiC,kFAAkF;AAAA,IACnI,KAAK,UAAU,gCAAgC,oBAAoB;AAAA,IACnE,MAAM,UAAU,iCAAiC,+DAA+D;AAAA,EAClH;AAEA,MAAI;AACF,UAAM,UAAU,
|
|
4
|
+
"sourcesContent": ["import { requestPasswordResetSchema } from '@open-mercato/core/modules/auth/data/validators'\nimport { NextResponse } from 'next/server'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport ResetPasswordEmail from '@open-mercato/core/modules/auth/emails/ResetPasswordEmail'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { buildNotificationFromType } from '@open-mercato/core/modules/notifications/lib/notificationBuilder'\nimport { resolveNotificationService } from '@open-mercato/core/modules/notifications/lib/notificationService'\nimport notificationTypes from '@open-mercato/core/modules/auth/notifications'\nimport { z } from 'zod'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { mapSecurityEmailUrlError, toSecurityEmailUrl } from '@open-mercato/shared/lib/url'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\n\nconst logger = createLogger('auth').child({ component: 'reset' })\n\nconst resetRateLimitConfig = readEndpointRateLimitConfig('RESET', {\n points: 3, duration: 60, blockDuration: 60, keyPrefix: 'reset',\n})\nconst resetIpRateLimitConfig = readEndpointRateLimitConfig('RESET_IP', {\n points: 10, duration: 60, blockDuration: 60, keyPrefix: 'reset-ip',\n})\n\n// validation via requestPasswordResetSchema\n\nexport async function POST(req: Request) {\n const form = await req.formData()\n const email = String(form.get('email') ?? '')\n // Rate limit \u2014 two layers, both checked before validation and DB work\n const { error: rateLimitError } = await checkAuthRateLimit({\n req, ipConfig: resetIpRateLimitConfig, compoundConfig: resetRateLimitConfig, compoundIdentifier: email,\n })\n if (rateLimitError) return rateLimitError\n const parsed = requestPasswordResetSchema.safeParse({ email })\n if (!parsed.success) {\n const fieldErrors = parsed.error.flatten().fieldErrors\n return NextResponse.json({ error: 'Validation failed', fieldErrors }, { status: 422 })\n }\n let resetUrlTemplate: string\n try {\n resetUrlTemplate = toSecurityEmailUrl(req, '/reset/__token__')\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'auth.reset',\n configMessage: 'Password reset is not configured',\n })\n if (mapped) return NextResponse.json(mapped.body, { status: mapped.status })\n throw error\n }\n const c = await createRequestContainer()\n const auth = c.resolve<AuthService>('authService')\n const resReq = await auth.requestPasswordReset(parsed.data.email)\n if (!resReq) return NextResponse.json({ ok: true })\n const { user, token } = resReq\n const resetUrl = resetUrlTemplate.replace('__token__', token)\n void emitAuthEvent('auth.password.reset.requested', {\n id: String(user.id),\n tenantId: user.tenantId ? String(user.tenantId) : null,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n at: new Date().toISOString(),\n }, { persistent: true }).catch(() => undefined)\n\n const { translate } = await resolveTranslations()\n const subject = translate('auth.email.resetPassword.subject', 'Reset your password')\n const copy = {\n preview: translate('auth.email.resetPassword.preview', 'Reset your password'),\n title: translate('auth.email.resetPassword.title', 'Reset your password'),\n body: translate('auth.email.resetPassword.body', 'Click the link below to set a new password. This link will expire in 60 minutes.'),\n cta: translate('auth.email.resetPassword.cta', 'Set a new password'),\n hint: translate('auth.email.resetPassword.hint', \"If you didn't request this, you can safely ignore this email.\"),\n }\n\n try {\n await sendEmail({\n to: user.email,\n subject,\n react: ResetPasswordEmail({ resetUrl, copy }),\n tenantId: user.tenantId ? String(user.tenantId) : undefined,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n })\n } catch (err) {\n logger.error('Failed to send reset email', { err })\n }\n try {\n const tenantId = user.tenantId ? String(user.tenantId) : null\n if (tenantId) {\n const notificationService = resolveNotificationService(c)\n const typeDef = notificationTypes.find((type) => type.type === 'auth.password_reset.requested')\n if (typeDef) {\n const notificationInput = buildNotificationFromType(typeDef, {\n recipientUserId: String(user.id),\n sourceEntityType: 'auth:user',\n sourceEntityId: String(user.id),\n })\n await notificationService.create(notificationInput, {\n tenantId,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n })\n }\n }\n } catch (err) {\n logger.error('Failed to create notification', { err })\n }\n return NextResponse.json({ ok: true })\n}\n\nexport const metadata = { requireAuth: false }\n\nconst passwordResetRequestSchema = z.object({\n email: z.string().email(),\n})\n\nconst passwordResetResponseSchema = z.object({\n ok: z.literal(true),\n})\n\nconst passwordResetErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Request password reset',\n methods: {\n POST: {\n summary: 'Send reset email',\n description: 'Requests a password reset email for the given account. The endpoint always returns `ok: true` to avoid leaking account existence.',\n requestBody: {\n contentType: 'application/x-www-form-urlencoded',\n schema: passwordResetRequestSchema,\n },\n responses: [\n { status: 200, description: 'Reset email dispatched (or ignored for unknown accounts)', schema: passwordResetResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid request origin', schema: passwordResetErrorSchema },\n { status: 429, description: 'Too many password reset requests', schema: rateLimitErrorSchema },\n { status: 500, description: 'Password reset email origin is not configured', schema: passwordResetErrorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -150,7 +150,13 @@ async function POST(req) {
|
|
|
150
150
|
};
|
|
151
151
|
let emailSent = true;
|
|
152
152
|
try {
|
|
153
|
-
await sendEmail({
|
|
153
|
+
await sendEmail({
|
|
154
|
+
to: user.email,
|
|
155
|
+
subject,
|
|
156
|
+
react: InviteUserEmail({ inviteUrl, copy }),
|
|
157
|
+
tenantId: user.tenantId ? String(user.tenantId) : auth.tenantId ?? void 0,
|
|
158
|
+
organizationId: user.organizationId ? String(user.organizationId) : null
|
|
159
|
+
});
|
|
154
160
|
} catch (err) {
|
|
155
161
|
logger.error("Failed to send invitation email", { err });
|
|
156
162
|
emailSent = false;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/auth/api/users/resend-invite/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { User, PasswordReset } from '@open-mercato/core/modules/auth/data/entities'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport InviteUserEmail from '@open-mercato/core/modules/auth/emails/InviteUserEmail'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { validateCrudMutationGuard, runCrudMutationGuardAfterSuccess } from '@open-mercato/shared/lib/crud/mutation-guard'\nimport { INVITE_TOKEN_TTL_MS } from '@open-mercato/core/modules/auth/lib/inviteToken'\nimport { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'\nimport { generateAuthToken, hashAuthToken } from '@open-mercato/core/modules/auth/lib/tokenHash'\nimport { assertActorCanAccessUserTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('auth').child({ component: 'users-resend-invite' })\n\nconst resendInviteRateLimitConfig = readEndpointRateLimitConfig('RESEND_INVITE', {\n points: 3, duration: 300, blockDuration: 300, keyPrefix: 'resend-invite',\n})\n\nconst requestSchema = z.object({\n id: z.string().uuid(),\n})\n\nconst responseSchema = z.object({\n ok: z.literal(true),\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\nconst validationErrorSchema = z.object({\n error: z.string(),\n fieldErrors: z.record(z.string(), z.array(z.string())).optional(),\n})\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['auth.users.create'] },\n}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: resendInviteRateLimitConfig,\n })\n if (rateLimitError) return rateLimitError\n\n const body = await readJsonSafe(req, {})\n const parsed = requestSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json(\n { error: 'Validation failed', fieldErrors: parsed.error.flatten().fieldErrors },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n\n let isSuperAdmin = false\n try {\n if (auth.sub) {\n const rbacService = container.resolve('rbacService') as {\n loadAcl: (userId: string, scope: { tenantId: string | null; organizationId: string | null }) => Promise<{ isSuperAdmin?: boolean } | null>\n }\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n })\n isSuperAdmin = !!acl?.isSuperAdmin\n }\n } catch (err) {\n logger.error('Failed to resolve rbac', { err })\n }\n\n if (auth.sub) {\n try {\n await assertActorCanAccessUserTarget({\n em,\n rbacService: container.resolve('rbacService') as RbacService,\n actorUserId: auth.sub,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n targetUserId: parsed.data.id,\n actorIsSuperAdmin: isSuperAdmin,\n organizationScope: await resolveOrganizationScopeForRequest({\n container,\n auth,\n request: req,\n tenantId: auth.tenantId ?? null,\n }),\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n\n const where: Record<string, unknown> = { id: parsed.data.id, deletedAt: null }\n if (!isSuperAdmin) {\n if (!auth.tenantId) return NextResponse.json({ error: 'User not found' }, { status: 404 })\n where.tenantId = auth.tenantId\n }\n\n const user = await em.findOne(User, where as any)\n if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })\n\n if (user.passwordHash) {\n return NextResponse.json({ error: 'User already has a password' }, { status: 409 })\n }\n\n const guardResult = await validateCrudMutationGuard(container, {\n tenantId: user.tenantId ? String(user.tenantId) : auth.tenantId ?? '',\n organizationId: user.organizationId ? String(user.organizationId) : null,\n userId: auth.sub ?? '',\n resourceKind: 'auth.user',\n resourceId: String(user.id),\n operation: 'custom',\n requestMethod: 'POST',\n requestHeaders: req.headers,\n })\n if (guardResult && !guardResult.ok) {\n return NextResponse.json(guardResult.body, { status: guardResult.status })\n }\n\n let base: string\n try {\n base = getSecurityEmailBaseUrl(req)\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'auth.users.resend-invite',\n configMessage: 'Invitation email is not configured',\n })\n if (mapped) return NextResponse.json(mapped.body, { status: mapped.status })\n throw error\n }\n\n await em.nativeUpdate(\n PasswordReset,\n { user: user.id, usedAt: null } as any,\n { usedAt: new Date() },\n )\n\n const rawToken = generateAuthToken()\n const tokenHash = hashAuthToken(rawToken)\n const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MS)\n const row = em.create(PasswordReset, { user, token: tokenHash, expiresAt, createdAt: new Date() })\n await em.persist(row).flush()\n\n const inviteUrl = `${base}/reset/${rawToken}`\n\n const { translate } = await resolveTranslations()\n const subject = translate('auth.email.invite.subject', 'You have been invited')\n const copy = {\n preview: translate('auth.email.invite.preview', 'Set up your account'),\n title: translate('auth.email.invite.title', 'You have been invited'),\n body: translate('auth.email.invite.body', 'An administrator has created an account for you. Click the link below to set your password. This link will expire in 48 hours.'),\n cta: translate('auth.email.invite.cta', 'Set up your password'),\n hint: translate('auth.email.invite.hint', 'If you did not expect this invitation, you can safely ignore this email.'),\n }\n\n let emailSent = true\n try {\n await sendEmail({
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { User, PasswordReset } from '@open-mercato/core/modules/auth/data/entities'\nimport { sendEmail } from '@open-mercato/shared/lib/email/send'\nimport InviteUserEmail from '@open-mercato/core/modules/auth/emails/InviteUserEmail'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { rateLimitErrorSchema } from '@open-mercato/shared/lib/ratelimit/helpers'\nimport { readEndpointRateLimitConfig } from '@open-mercato/shared/lib/ratelimit/config'\nimport { checkAuthRateLimit } from '@open-mercato/core/modules/auth/lib/rateLimitCheck'\nimport { validateCrudMutationGuard, runCrudMutationGuardAfterSuccess } from '@open-mercato/shared/lib/crud/mutation-guard'\nimport { INVITE_TOKEN_TTL_MS } from '@open-mercato/core/modules/auth/lib/inviteToken'\nimport { getSecurityEmailBaseUrl, mapSecurityEmailUrlError } from '@open-mercato/shared/lib/url'\nimport { generateAuthToken, hashAuthToken } from '@open-mercato/core/modules/auth/lib/tokenHash'\nimport { assertActorCanAccessUserTarget } from '@open-mercato/core/modules/auth/lib/grantChecks'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('auth').child({ component: 'users-resend-invite' })\n\nconst resendInviteRateLimitConfig = readEndpointRateLimitConfig('RESEND_INVITE', {\n points: 3, duration: 300, blockDuration: 300, keyPrefix: 'resend-invite',\n})\n\nconst requestSchema = z.object({\n id: z.string().uuid(),\n})\n\nconst responseSchema = z.object({\n ok: z.literal(true),\n})\n\nconst errorSchema = z.object({\n error: z.string(),\n})\n\nconst validationErrorSchema = z.object({\n error: z.string(),\n fieldErrors: z.record(z.string(), z.array(z.string())).optional(),\n})\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['auth.users.create'] },\n}\n\nexport async function POST(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n\n const { error: rateLimitError } = await checkAuthRateLimit({\n req,\n ipConfig: resendInviteRateLimitConfig,\n })\n if (rateLimitError) return rateLimitError\n\n const body = await readJsonSafe(req, {})\n const parsed = requestSchema.safeParse(body)\n if (!parsed.success) {\n return NextResponse.json(\n { error: 'Validation failed', fieldErrors: parsed.error.flatten().fieldErrors },\n { status: 422 },\n )\n }\n\n const container = await createRequestContainer()\n const em = container.resolve('em') as EntityManager\n\n let isSuperAdmin = false\n try {\n if (auth.sub) {\n const rbacService = container.resolve('rbacService') as {\n loadAcl: (userId: string, scope: { tenantId: string | null; organizationId: string | null }) => Promise<{ isSuperAdmin?: boolean } | null>\n }\n const acl = await rbacService.loadAcl(auth.sub, {\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n })\n isSuperAdmin = !!acl?.isSuperAdmin\n }\n } catch (err) {\n logger.error('Failed to resolve rbac', { err })\n }\n\n if (auth.sub) {\n try {\n await assertActorCanAccessUserTarget({\n em,\n rbacService: container.resolve('rbacService') as RbacService,\n actorUserId: auth.sub,\n tenantId: auth.tenantId ?? null,\n organizationId: auth.orgId ?? null,\n targetUserId: parsed.data.id,\n actorIsSuperAdmin: isSuperAdmin,\n organizationScope: await resolveOrganizationScopeForRequest({\n container,\n auth,\n request: req,\n tenantId: auth.tenantId ?? null,\n }),\n })\n } catch (err) {\n if (isCrudHttpError(err)) return NextResponse.json(err.body, { status: err.status })\n throw err\n }\n }\n\n const where: Record<string, unknown> = { id: parsed.data.id, deletedAt: null }\n if (!isSuperAdmin) {\n if (!auth.tenantId) return NextResponse.json({ error: 'User not found' }, { status: 404 })\n where.tenantId = auth.tenantId\n }\n\n const user = await em.findOne(User, where as any)\n if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 })\n\n if (user.passwordHash) {\n return NextResponse.json({ error: 'User already has a password' }, { status: 409 })\n }\n\n const guardResult = await validateCrudMutationGuard(container, {\n tenantId: user.tenantId ? String(user.tenantId) : auth.tenantId ?? '',\n organizationId: user.organizationId ? String(user.organizationId) : null,\n userId: auth.sub ?? '',\n resourceKind: 'auth.user',\n resourceId: String(user.id),\n operation: 'custom',\n requestMethod: 'POST',\n requestHeaders: req.headers,\n })\n if (guardResult && !guardResult.ok) {\n return NextResponse.json(guardResult.body, { status: guardResult.status })\n }\n\n let base: string\n try {\n base = getSecurityEmailBaseUrl(req)\n } catch (error) {\n const mapped = mapSecurityEmailUrlError(error, {\n scope: 'auth.users.resend-invite',\n configMessage: 'Invitation email is not configured',\n })\n if (mapped) return NextResponse.json(mapped.body, { status: mapped.status })\n throw error\n }\n\n await em.nativeUpdate(\n PasswordReset,\n { user: user.id, usedAt: null } as any,\n { usedAt: new Date() },\n )\n\n const rawToken = generateAuthToken()\n const tokenHash = hashAuthToken(rawToken)\n const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MS)\n const row = em.create(PasswordReset, { user, token: tokenHash, expiresAt, createdAt: new Date() })\n await em.persist(row).flush()\n\n const inviteUrl = `${base}/reset/${rawToken}`\n\n const { translate } = await resolveTranslations()\n const subject = translate('auth.email.invite.subject', 'You have been invited')\n const copy = {\n preview: translate('auth.email.invite.preview', 'Set up your account'),\n title: translate('auth.email.invite.title', 'You have been invited'),\n body: translate('auth.email.invite.body', 'An administrator has created an account for you. Click the link below to set your password. This link will expire in 48 hours.'),\n cta: translate('auth.email.invite.cta', 'Set up your password'),\n hint: translate('auth.email.invite.hint', 'If you did not expect this invitation, you can safely ignore this email.'),\n }\n\n let emailSent = true\n try {\n await sendEmail({\n to: user.email,\n subject,\n react: InviteUserEmail({ inviteUrl, copy }),\n tenantId: user.tenantId ? String(user.tenantId) : auth.tenantId ?? undefined,\n organizationId: user.organizationId ? String(user.organizationId) : null,\n })\n } catch (err) {\n logger.error('Failed to send invitation email', { err })\n emailSent = false\n }\n\n if (guardResult?.shouldRunAfterSuccess) {\n await runCrudMutationGuardAfterSuccess(container, {\n tenantId: user.tenantId ? String(user.tenantId) : auth.tenantId ?? '',\n organizationId: user.organizationId ? String(user.organizationId) : null,\n userId: auth.sub ?? '',\n resourceKind: 'auth.user',\n resourceId: String(user.id),\n operation: 'custom',\n requestMethod: 'POST',\n requestHeaders: req.headers,\n metadata: guardResult.metadata,\n })\n }\n\n if (!emailSent) {\n return NextResponse.json({ ok: true, warning: 'invite_email_failed' })\n }\n\n return NextResponse.json({ ok: true })\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Resend user invite',\n methods: {\n POST: {\n summary: 'Resend invitation email',\n description: 'Resends the invitation email to a user who has not yet set up their password. Generates a new 48-hour setup token and invalidates prior tokens.',\n requestBody: {\n contentType: 'application/json',\n schema: requestSchema,\n },\n responses: [\n { status: 200, description: 'Invite email sent', schema: responseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid request origin', schema: errorSchema },\n { status: 404, description: 'User not found', schema: errorSchema },\n { status: 409, description: 'User already has a password', schema: errorSchema },\n { status: 422, description: 'Validation error', schema: validationErrorSchema },\n { status: 429, description: 'Rate limit exceeded', schema: rateLimitErrorSchema },\n { status: 500, description: 'Invitation email origin is not configured', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,MAAM,qBAAqB;AACpC,SAAS,iBAAiB;AAC1B,OAAO,qBAAqB;AAC5B,SAAS,2BAA2B;AACpC,SAAS,oBAAoB;AAC7B,SAAS,4BAA4B;AACrC,SAAS,mCAAmC;AAC5C,SAAS,0BAA0B;AACnC,SAAS,2BAA2B,wCAAwC;AAC5E,SAAS,2BAA2B;AACpC,SAAS,yBAAyB,gCAAgC;AAClE,SAAS,mBAAmB,qBAAqB;AACjD,SAAS,sCAAsC;AAE/C,SAAS,0CAA0C;AACnD,SAAS,uBAAuB;AAEhC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,MAAM,EAAE,MAAM,EAAE,WAAW,sBAAsB,CAAC;AAE9E,MAAM,8BAA8B,4BAA4B,iBAAiB;AAAA,EAC/E,QAAQ;AAAA,EAAG,UAAU;AAAA,EAAK,eAAe;AAAA,EAAK,WAAW;AAC3D,CAAC;AAED,MAAM,gBAAgB,EAAE,OAAO;AAAA,EAC7B,IAAI,EAAE,OAAO,EAAE,KAAK;AACtB,CAAC;AAED,MAAM,iBAAiB,EAAE,OAAO;AAAA,EAC9B,IAAI,EAAE,QAAQ,IAAI;AACpB,CAAC;AAED,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,OAAO,EAAE,OAAO;AAClB,CAAC;AAED,MAAM,wBAAwB,EAAE,OAAO;AAAA,EACrC,OAAO,EAAE,OAAO;AAAA,EAChB,aAAa,EAAE,OAAO,EAAE,OAAO,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,EAAE,SAAS;AAClE,CAAC;AAEM,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,mBAAmB,EAAE;AACpE;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,MAAI,CAAC,KAAM,QAAO,aAAa,KAAK,EAAE,OAAO,eAAe,GAAG,EAAE,QAAQ,IAAI,CAAC;AAE9E,QAAM,EAAE,OAAO,eAAe,IAAI,MAAM,mBAAmB;AAAA,IACzD;AAAA,IACA,UAAU;AAAA,EACZ,CAAC;AACD,MAAI,eAAgB,QAAO;AAE3B,QAAM,OAAO,MAAM,aAAa,KAAK,CAAC,CAAC;AACvC,QAAM,SAAS,cAAc,UAAU,IAAI;AAC3C,MAAI,CAAC,OAAO,SAAS;AACnB,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,qBAAqB,aAAa,OAAO,MAAM,QAAQ,EAAE,YAAY;AAAA,MAC9E,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,YAAY,MAAM,uBAAuB;AAC/C,QAAM,KAAK,UAAU,QAAQ,IAAI;AAEjC,MAAI,eAAe;AACnB,MAAI;AACF,QAAI,KAAK,KAAK;AACZ,YAAM,cAAc,UAAU,QAAQ,aAAa;AAGnD,YAAM,MAAM,MAAM,YAAY,QAAQ,KAAK,KAAK;AAAA,QAC9C,UAAU,KAAK,YAAY;AAAA,QAC3B,gBAAgB,KAAK,SAAS;AAAA,MAChC,CAAC;AACD,qBAAe,CAAC,CAAC,KAAK;AAAA,IACxB;AAAA,EACF,SAAS,KAAK;AACZ,WAAO,MAAM,0BAA0B,EAAE,IAAI,CAAC;AAAA,EAChD;AAEA,MAAI,KAAK,KAAK;AACZ,QAAI;AACF,YAAM,+BAA+B;AAAA,QACnC;AAAA,QACA,aAAa,UAAU,QAAQ,aAAa;AAAA,QAC5C,aAAa,KAAK;AAAA,QAClB,UAAU,KAAK,YAAY;AAAA,QAC3B,gBAAgB,KAAK,SAAS;AAAA,QAC9B,cAAc,OAAO,KAAK;AAAA,QAC1B,mBAAmB;AAAA,QACnB,mBAAmB,MAAM,mCAAmC;AAAA,UAC1D;AAAA,UACA;AAAA,UACA,SAAS;AAAA,UACT,UAAU,KAAK,YAAY;AAAA,QAC7B,CAAC;AAAA,MACH,CAAC;AAAA,IACH,SAAS,KAAK;AACZ,UAAI,gBAAgB,GAAG,EAAG,QAAO,aAAa,KAAK,IAAI,MAAM,EAAE,QAAQ,IAAI,OAAO,CAAC;AACnF,YAAM;AAAA,IACR;AAAA,EACF;AAEA,QAAM,QAAiC,EAAE,IAAI,OAAO,KAAK,IAAI,WAAW,KAAK;AAC7E,MAAI,CAAC,cAAc;AACjB,QAAI,CAAC,KAAK,SAAU,QAAO,aAAa,KAAK,EAAE,OAAO,iBAAiB,GAAG,EAAE,QAAQ,IAAI,CAAC;AACzF,UAAM,WAAW,KAAK;AAAA,EACxB;AAEA,QAAM,OAAO,MAAM,GAAG,QAAQ,MAAM,KAAY;AAChD,MAAI,CAAC,KAAM,QAAO,aAAa,KAAK,EAAE,OAAO,iBAAiB,GAAG,EAAE,QAAQ,IAAI,CAAC;AAEhF,MAAI,KAAK,cAAc;AACrB,WAAO,aAAa,KAAK,EAAE,OAAO,8BAA8B,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACpF;AAEA,QAAM,cAAc,MAAM,0BAA0B,WAAW;AAAA,IAC7D,UAAU,KAAK,WAAW,OAAO,KAAK,QAAQ,IAAI,KAAK,YAAY;AAAA,IACnE,gBAAgB,KAAK,iBAAiB,OAAO,KAAK,cAAc,IAAI;AAAA,IACpE,QAAQ,KAAK,OAAO;AAAA,IACpB,cAAc;AAAA,IACd,YAAY,OAAO,KAAK,EAAE;AAAA,IAC1B,WAAW;AAAA,IACX,eAAe;AAAA,IACf,gBAAgB,IAAI;AAAA,EACtB,CAAC;AACD,MAAI,eAAe,CAAC,YAAY,IAAI;AAClC,WAAO,aAAa,KAAK,YAAY,MAAM,EAAE,QAAQ,YAAY,OAAO,CAAC;AAAA,EAC3E;AAEA,MAAI;AACJ,MAAI;AACF,WAAO,wBAAwB,GAAG;AAAA,EACpC,SAAS,OAAO;AACd,UAAM,SAAS,yBAAyB,OAAO;AAAA,MAC7C,OAAO;AAAA,MACP,eAAe;AAAA,IACjB,CAAC;AACD,QAAI,OAAQ,QAAO,aAAa,KAAK,OAAO,MAAM,EAAE,QAAQ,OAAO,OAAO,CAAC;AAC3E,UAAM;AAAA,EACR;AAEA,QAAM,GAAG;AAAA,IACP;AAAA,IACA,EAAE,MAAM,KAAK,IAAI,QAAQ,KAAK;AAAA,IAC9B,EAAE,QAAQ,oBAAI,KAAK,EAAE;AAAA,EACvB;AAEA,QAAM,WAAW,kBAAkB;AACnC,QAAM,YAAY,cAAc,QAAQ;AACxC,QAAM,YAAY,IAAI,KAAK,KAAK,IAAI,IAAI,mBAAmB;AAC3D,QAAM,MAAM,GAAG,OAAO,eAAe,EAAE,MAAM,OAAO,WAAW,WAAW,WAAW,oBAAI,KAAK,EAAE,CAAC;AACjG,QAAM,GAAG,QAAQ,GAAG,EAAE,MAAM;AAE5B,QAAM,YAAY,GAAG,IAAI,UAAU,QAAQ;AAE3C,QAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,QAAM,UAAU,UAAU,6BAA6B,uBAAuB;AAC9E,QAAM,OAAO;AAAA,IACX,SAAS,UAAU,6BAA6B,qBAAqB;AAAA,IACrE,OAAO,UAAU,2BAA2B,uBAAuB;AAAA,IACnE,MAAM,UAAU,0BAA0B,gIAAgI;AAAA,IAC1K,KAAK,UAAU,yBAAyB,sBAAsB;AAAA,IAC9D,MAAM,UAAU,0BAA0B,0EAA0E;AAAA,EACtH;AAEA,MAAI,YAAY;AAChB,MAAI;AACF,UAAM,UAAU;AAAA,MACd,IAAI,KAAK;AAAA,MACT;AAAA,MACA,OAAO,gBAAgB,EAAE,WAAW,KAAK,CAAC;AAAA,MAC1C,UAAU,KAAK,WAAW,OAAO,KAAK,QAAQ,IAAI,KAAK,YAAY;AAAA,MACnE,gBAAgB,KAAK,iBAAiB,OAAO,KAAK,cAAc,IAAI;AAAA,IACtE,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,MAAM,mCAAmC,EAAE,IAAI,CAAC;AACvD,gBAAY;AAAA,EACd;AAEA,MAAI,aAAa,uBAAuB;AACtC,UAAM,iCAAiC,WAAW;AAAA,MAChD,UAAU,KAAK,WAAW,OAAO,KAAK,QAAQ,IAAI,KAAK,YAAY;AAAA,MACnE,gBAAgB,KAAK,iBAAiB,OAAO,KAAK,cAAc,IAAI;AAAA,MACpE,QAAQ,KAAK,OAAO;AAAA,MACpB,cAAc;AAAA,MACd,YAAY,OAAO,KAAK,EAAE;AAAA,MAC1B,WAAW;AAAA,MACX,eAAe;AAAA,MACf,gBAAgB,IAAI;AAAA,MACpB,UAAU,YAAY;AAAA,IACxB,CAAC;AAAA,EACH;AAEA,MAAI,CAAC,WAAW;AACd,WAAO,aAAa,KAAK,EAAE,IAAI,MAAM,SAAS,sBAAsB,CAAC;AAAA,EACvE;AAEA,SAAO,aAAa,KAAK,EAAE,IAAI,KAAK,CAAC;AACvC;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa;AAAA,MACb,aAAa;AAAA,QACX,aAAa;AAAA,QACb,QAAQ;AAAA,MACV;AAAA,MACA,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,qBAAqB,QAAQ,eAAe;AAAA,MAC1E;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,0BAA0B,QAAQ,YAAY;AAAA,QAC1E,EAAE,QAAQ,KAAK,aAAa,kBAAkB,QAAQ,YAAY;AAAA,QAClE,EAAE,QAAQ,KAAK,aAAa,+BAA+B,QAAQ,YAAY;AAAA,QAC/E,EAAE,QAAQ,KAAK,aAAa,oBAAoB,QAAQ,sBAAsB;AAAA,QAC9E,EAAE,QAAQ,KAAK,aAAa,uBAAuB,QAAQ,qBAAqB;AAAA,QAChF,EAAE,QAAQ,KAAK,aAAa,6CAA6C,QAAQ,YAAY;AAAA,MAC/F;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -399,7 +399,13 @@ async function sendInviteToUser(em, user) {
|
|
|
399
399
|
};
|
|
400
400
|
let emailSent = true;
|
|
401
401
|
try {
|
|
402
|
-
await sendEmail({
|
|
402
|
+
await sendEmail({
|
|
403
|
+
to: user.email,
|
|
404
|
+
subject,
|
|
405
|
+
react: InviteUserEmail({ inviteUrl, copy }),
|
|
406
|
+
tenantId: user.tenantId ? String(user.tenantId) : void 0,
|
|
407
|
+
organizationId: user.organizationId ? String(user.organizationId) : null
|
|
408
|
+
});
|
|
403
409
|
} catch (err) {
|
|
404
410
|
logger.error("Failed to send invitation email", { err });
|
|
405
411
|
emailSent = false;
|