@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3660.1.53b73d5aa4
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/generated/entities/customer_settings/index.js +2 -0
- package/dist/generated/entities/customer_settings/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
- package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
- package/dist/modules/customers/api/deals/route.js +255 -122
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
- package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
- package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +2 -0
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
- package/dist/modules/customers/commands/deals.js +6 -0
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/commands/pipeline-stages.js +48 -13
- package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
- package/dist/modules/customers/commands/settings.js +45 -5
- package/dist/modules/customers/commands/settings.js.map +2 -2
- package/dist/modules/customers/data/enrichers.js +130 -1
- package/dist/modules/customers/data/enrichers.js.map +2 -2
- package/dist/modules/customers/data/entities.js +4 -0
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +29 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/bulkDeals.js +150 -0
- package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
- package/dist/modules/customers/lib/kysely.js +12 -0
- package/dist/modules/customers/lib/kysely.js.map +7 -0
- package/dist/modules/customers/lib/stuckDeals.js +32 -0
- package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
- package/generated/entities/customer_settings/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
- package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
- package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
- package/src/modules/customers/api/deals/route.ts +330 -135
- package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
- package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
- package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
- package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
- package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
- package/src/modules/customers/commands/deals.ts +19 -0
- package/src/modules/customers/commands/pipeline-stages.ts +68 -13
- package/src/modules/customers/commands/settings.ts +64 -5
- package/src/modules/customers/data/enrichers.ts +209 -2
- package/src/modules/customers/data/entities.ts +4 -1
- package/src/modules/customers/data/validators.ts +36 -2
- package/src/modules/customers/i18n/de.json +244 -0
- package/src/modules/customers/i18n/en.json +244 -0
- package/src/modules/customers/i18n/es.json +244 -0
- package/src/modules/customers/i18n/pl.json +244 -0
- package/src/modules/customers/lib/bulkDeals.ts +231 -0
- package/src/modules/customers/lib/kysely.ts +60 -0
- package/src/modules/customers/lib/stuckDeals.ts +88 -0
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
- package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
- package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
- package/src/modules/customers/workers/deals-bulk-update-stage.ts +46 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
|
+
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
|
+
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
6
|
+
import { parseBooleanFromUnknown } from "@open-mercato/shared/lib/boolean";
|
|
7
|
+
import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
|
|
8
|
+
import { isTenantDataEncryptionEnabled } from "@open-mercato/shared/lib/encryption/toggles";
|
|
9
|
+
import { fetchStuckDealIds } from "../../../lib/stuckDeals.js";
|
|
10
|
+
import { findMatchingEntityIdsBySearchTokensAcrossSources } from "../../utils.js";
|
|
11
|
+
import { E } from "../../../../../generated/entities.ids.generated.js";
|
|
12
|
+
const metadata = {
|
|
13
|
+
GET: { requireAuth: true, requireFeatures: ["customers.deals.view"] }
|
|
14
|
+
};
|
|
15
|
+
const querySchema = z.object({
|
|
16
|
+
pipelineId: z.string().uuid().optional(),
|
|
17
|
+
search: z.string().optional(),
|
|
18
|
+
status: z.array(z.enum(["open", "closed", "win", "loose"])).optional(),
|
|
19
|
+
ownerUserId: z.array(z.string().uuid()).optional(),
|
|
20
|
+
personId: z.array(z.string().uuid()).optional(),
|
|
21
|
+
companyId: z.array(z.string().uuid()).optional(),
|
|
22
|
+
isStuck: z.preprocess((value) => {
|
|
23
|
+
const parsed = parseBooleanFromUnknown(value);
|
|
24
|
+
return parsed === null ? value : parsed;
|
|
25
|
+
}, z.boolean()).optional(),
|
|
26
|
+
isOverdue: z.preprocess((value) => {
|
|
27
|
+
const parsed = parseBooleanFromUnknown(value);
|
|
28
|
+
return parsed === null ? value : parsed;
|
|
29
|
+
}, z.boolean()).optional(),
|
|
30
|
+
expectedCloseAtFrom: z.string().optional(),
|
|
31
|
+
expectedCloseAtTo: z.string().optional()
|
|
32
|
+
});
|
|
33
|
+
const stageBreakdownByCurrencySchema = z.object({
|
|
34
|
+
currency: z.string(),
|
|
35
|
+
total: z.number(),
|
|
36
|
+
count: z.number()
|
|
37
|
+
});
|
|
38
|
+
const stageAggregateSchema = z.object({
|
|
39
|
+
stageId: z.string(),
|
|
40
|
+
count: z.number(),
|
|
41
|
+
openCount: z.number(),
|
|
42
|
+
totalInBaseCurrency: z.number(),
|
|
43
|
+
byCurrency: z.array(stageBreakdownByCurrencySchema),
|
|
44
|
+
convertedAll: z.boolean(),
|
|
45
|
+
missingRateCurrencies: z.array(z.string())
|
|
46
|
+
});
|
|
47
|
+
const aggregateResponseSchema = z.object({
|
|
48
|
+
baseCurrencyCode: z.string().nullable(),
|
|
49
|
+
perStage: z.array(stageAggregateSchema)
|
|
50
|
+
});
|
|
51
|
+
const aggregateErrorSchema = z.object({
|
|
52
|
+
error: z.string()
|
|
53
|
+
});
|
|
54
|
+
const openApi = {
|
|
55
|
+
tag: "Customers",
|
|
56
|
+
summary: "Deals aggregate per pipeline stage",
|
|
57
|
+
methods: {
|
|
58
|
+
GET: {
|
|
59
|
+
summary: "Per-stage counts and currency totals for kanban lane headers",
|
|
60
|
+
description: "Returns per-stage counts and totals for deals, with values converted to the tenant base currency where rates are available. Used to power kanban lane headers without loading every deal.",
|
|
61
|
+
query: querySchema,
|
|
62
|
+
responses: [
|
|
63
|
+
{ status: 200, description: "Per-stage aggregate payload", schema: aggregateResponseSchema }
|
|
64
|
+
],
|
|
65
|
+
errors: [
|
|
66
|
+
{ status: 400, description: "Invalid query parameters", schema: aggregateErrorSchema },
|
|
67
|
+
{ status: 401, description: "Unauthorized", schema: aggregateErrorSchema }
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function readArrayParam(searchParams, key) {
|
|
73
|
+
const all = searchParams.getAll(key);
|
|
74
|
+
if (!all.length) return null;
|
|
75
|
+
const flat = all.flatMap((v) => v.split(","));
|
|
76
|
+
const trimmed = flat.map((s) => s.trim()).filter(Boolean);
|
|
77
|
+
return trimmed.length ? trimmed : null;
|
|
78
|
+
}
|
|
79
|
+
function restrictToIds(where, values, ids) {
|
|
80
|
+
if (ids.length === 0) {
|
|
81
|
+
where.push("id = ?");
|
|
82
|
+
values.push("00000000-0000-0000-0000-000000000000");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
86
|
+
where.push(`id IN (${placeholders})`);
|
|
87
|
+
values.push(...ids);
|
|
88
|
+
}
|
|
89
|
+
async function GET(req) {
|
|
90
|
+
const auth = await getAuthFromRequest(req);
|
|
91
|
+
if (!auth?.tenantId || !auth.orgId) {
|
|
92
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
93
|
+
}
|
|
94
|
+
const url = new URL(req.url);
|
|
95
|
+
const params = url.searchParams;
|
|
96
|
+
const parsed = querySchema.safeParse({
|
|
97
|
+
pipelineId: params.get("pipelineId") ?? void 0,
|
|
98
|
+
search: params.get("search") ?? void 0,
|
|
99
|
+
status: readArrayParam(params, "status") ?? void 0,
|
|
100
|
+
ownerUserId: readArrayParam(params, "ownerUserId") ?? void 0,
|
|
101
|
+
personId: readArrayParam(params, "personId") ?? void 0,
|
|
102
|
+
companyId: readArrayParam(params, "companyId") ?? void 0,
|
|
103
|
+
isStuck: params.get("isStuck") ?? void 0,
|
|
104
|
+
isOverdue: params.get("isOverdue") ?? void 0,
|
|
105
|
+
expectedCloseAtFrom: params.get("expectedCloseAtFrom") ?? void 0,
|
|
106
|
+
expectedCloseAtTo: params.get("expectedCloseAtTo") ?? void 0
|
|
107
|
+
});
|
|
108
|
+
if (!parsed.success) {
|
|
109
|
+
return NextResponse.json({ error: "Invalid query parameters" }, { status: 400 });
|
|
110
|
+
}
|
|
111
|
+
const container = await createRequestContainer();
|
|
112
|
+
const em = container.resolve("em");
|
|
113
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req });
|
|
114
|
+
const effectiveTenantId = scope.tenantId ?? auth.tenantId;
|
|
115
|
+
const orgFilterIds = Array.isArray(scope.filterIds) && scope.filterIds.length > 0 ? scope.filterIds.filter((id) => typeof id === "string" && id.length > 0) : auth.orgId ? [auth.orgId] : [];
|
|
116
|
+
if (!effectiveTenantId || orgFilterIds.length === 0) {
|
|
117
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
118
|
+
}
|
|
119
|
+
const baseCurrency = await em.getConnection().execute(
|
|
120
|
+
`SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,
|
|
121
|
+
[effectiveTenantId, orgFilterIds[0]]
|
|
122
|
+
);
|
|
123
|
+
const baseCurrencyCode = baseCurrency[0]?.code ?? null;
|
|
124
|
+
const orgPlaceholders = orgFilterIds.map(() => "?").join(",");
|
|
125
|
+
const where = [
|
|
126
|
+
"tenant_id = ?",
|
|
127
|
+
`organization_id IN (${orgPlaceholders})`,
|
|
128
|
+
"deleted_at IS NULL"
|
|
129
|
+
];
|
|
130
|
+
const values = [effectiveTenantId, ...orgFilterIds];
|
|
131
|
+
if (parsed.data.pipelineId) {
|
|
132
|
+
where.push("pipeline_id = ?");
|
|
133
|
+
values.push(parsed.data.pipelineId);
|
|
134
|
+
}
|
|
135
|
+
const search = parsed.data.search?.trim();
|
|
136
|
+
if (search) {
|
|
137
|
+
const searchCtx = {
|
|
138
|
+
container,
|
|
139
|
+
auth,
|
|
140
|
+
organizationScope: scope,
|
|
141
|
+
selectedOrganizationId: orgFilterIds[0],
|
|
142
|
+
organizationIds: orgFilterIds,
|
|
143
|
+
request: req
|
|
144
|
+
};
|
|
145
|
+
const matchingIds = await findMatchingEntityIdsBySearchTokensAcrossSources({
|
|
146
|
+
ctx: searchCtx,
|
|
147
|
+
query: search,
|
|
148
|
+
sources: [
|
|
149
|
+
{
|
|
150
|
+
entityType: E.customers.customer_deal,
|
|
151
|
+
fields: [
|
|
152
|
+
"title",
|
|
153
|
+
"description",
|
|
154
|
+
"status",
|
|
155
|
+
"pipeline_stage",
|
|
156
|
+
"source",
|
|
157
|
+
"value_amount",
|
|
158
|
+
"value_currency",
|
|
159
|
+
"cf:competitive_risk",
|
|
160
|
+
"cf:implementation_complexity"
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
});
|
|
165
|
+
if (matchingIds !== null && matchingIds.length > 0) {
|
|
166
|
+
restrictToIds(where, values, matchingIds);
|
|
167
|
+
} else if (isTenantDataEncryptionEnabled()) {
|
|
168
|
+
restrictToIds(where, values, []);
|
|
169
|
+
} else {
|
|
170
|
+
const searchPattern = `%${escapeLikePattern(search)}%`;
|
|
171
|
+
where.push("(title ILIKE ? ESCAPE '\\' OR description ILIKE ? ESCAPE '\\')");
|
|
172
|
+
values.push(searchPattern, searchPattern);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (parsed.data.status && parsed.data.status.length) {
|
|
176
|
+
const placeholders = parsed.data.status.map(() => "?").join(",");
|
|
177
|
+
where.push(`status IN (${placeholders})`);
|
|
178
|
+
values.push(...parsed.data.status);
|
|
179
|
+
}
|
|
180
|
+
if (parsed.data.ownerUserId && parsed.data.ownerUserId.length) {
|
|
181
|
+
const placeholders = parsed.data.ownerUserId.map(() => "?").join(",");
|
|
182
|
+
where.push(`owner_user_id IN (${placeholders})`);
|
|
183
|
+
values.push(...parsed.data.ownerUserId);
|
|
184
|
+
}
|
|
185
|
+
if (parsed.data.expectedCloseAtFrom) {
|
|
186
|
+
where.push("expected_close_at >= ?");
|
|
187
|
+
values.push(parsed.data.expectedCloseAtFrom);
|
|
188
|
+
}
|
|
189
|
+
if (parsed.data.expectedCloseAtTo) {
|
|
190
|
+
where.push("expected_close_at <= ?");
|
|
191
|
+
values.push(parsed.data.expectedCloseAtTo);
|
|
192
|
+
}
|
|
193
|
+
if (parsed.data.isOverdue) {
|
|
194
|
+
where.push("expected_close_at < CURRENT_DATE AND status = 'open'");
|
|
195
|
+
}
|
|
196
|
+
if (parsed.data.isStuck) {
|
|
197
|
+
const stuckIds = await fetchStuckDealIds(em, orgFilterIds[0], effectiveTenantId);
|
|
198
|
+
restrictToIds(where, values, stuckIds);
|
|
199
|
+
}
|
|
200
|
+
if (parsed.data.personId && parsed.data.personId.length) {
|
|
201
|
+
const placeholders = parsed.data.personId.map(() => "?").join(",");
|
|
202
|
+
where.push(`EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`);
|
|
203
|
+
values.push(...parsed.data.personId);
|
|
204
|
+
}
|
|
205
|
+
if (parsed.data.companyId && parsed.data.companyId.length) {
|
|
206
|
+
const placeholders = parsed.data.companyId.map(() => "?").join(",");
|
|
207
|
+
where.push(`EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`);
|
|
208
|
+
values.push(...parsed.data.companyId);
|
|
209
|
+
}
|
|
210
|
+
const whereSql = where.join(" AND ");
|
|
211
|
+
const rows = await em.getConnection().execute(
|
|
212
|
+
`SELECT
|
|
213
|
+
pipeline_stage_id AS stage_id,
|
|
214
|
+
UPPER(COALESCE(value_currency, '')) AS currency,
|
|
215
|
+
COALESCE(SUM(value_amount), 0) AS total,
|
|
216
|
+
COUNT(*) AS count,
|
|
217
|
+
COUNT(*) FILTER (WHERE status = 'open') AS open_count
|
|
218
|
+
FROM customer_deals
|
|
219
|
+
WHERE ${whereSql}
|
|
220
|
+
GROUP BY pipeline_stage_id, UPPER(COALESCE(value_currency, ''))`,
|
|
221
|
+
values
|
|
222
|
+
);
|
|
223
|
+
const stageMap = /* @__PURE__ */ new Map();
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
const stageId = row.stage_id ?? "__unassigned";
|
|
226
|
+
const total = Number(row.total ?? 0);
|
|
227
|
+
const count = Number(row.count ?? 0);
|
|
228
|
+
const openCount = Number(row.open_count ?? 0);
|
|
229
|
+
const currency = (row.currency ?? "").toString().trim();
|
|
230
|
+
if (!stageMap.has(stageId)) {
|
|
231
|
+
stageMap.set(stageId, {
|
|
232
|
+
stageId,
|
|
233
|
+
count: 0,
|
|
234
|
+
openCount: 0,
|
|
235
|
+
totalInBaseCurrency: 0,
|
|
236
|
+
byCurrency: [],
|
|
237
|
+
convertedAll: true,
|
|
238
|
+
missingRateCurrencies: []
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const agg = stageMap.get(stageId);
|
|
242
|
+
agg.count += count;
|
|
243
|
+
agg.openCount += openCount;
|
|
244
|
+
if (currency.length > 0) {
|
|
245
|
+
agg.byCurrency.push({ currency, total, count });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (baseCurrencyCode) {
|
|
249
|
+
const exchange = container.resolve("exchangeRateService");
|
|
250
|
+
const today = /* @__PURE__ */ new Date();
|
|
251
|
+
const distinctCurrencies = /* @__PURE__ */ new Set();
|
|
252
|
+
for (const agg of stageMap.values()) {
|
|
253
|
+
for (const row of agg.byCurrency) {
|
|
254
|
+
if (row.currency && row.currency !== baseCurrencyCode) {
|
|
255
|
+
distinctCurrencies.add(row.currency);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const rateCache = /* @__PURE__ */ new Map();
|
|
260
|
+
if (exchange && distinctCurrencies.size > 0) {
|
|
261
|
+
const pairs = Array.from(distinctCurrencies).map((c) => ({
|
|
262
|
+
fromCurrencyCode: c,
|
|
263
|
+
toCurrencyCode: baseCurrencyCode
|
|
264
|
+
}));
|
|
265
|
+
try {
|
|
266
|
+
const results = await exchange.getRates({
|
|
267
|
+
pairs,
|
|
268
|
+
date: today,
|
|
269
|
+
scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },
|
|
270
|
+
options: { maxDaysBack: 60, autoFetch: false }
|
|
271
|
+
});
|
|
272
|
+
for (const [key, rateResult] of results) {
|
|
273
|
+
if (rateResult.rates.length > 0) {
|
|
274
|
+
const rate = Number(rateResult.rates[0].rate);
|
|
275
|
+
if (Number.isFinite(rate) && rate > 0) {
|
|
276
|
+
rateCache.set(key, rate);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} catch (err) {
|
|
281
|
+
console.warn("[customers.deals.aggregate] exchange-rate lookup failed; falling back to per-currency totals", err);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const agg of stageMap.values()) {
|
|
285
|
+
let totalBase = 0;
|
|
286
|
+
let convertedAll = true;
|
|
287
|
+
const missingRateCurrencies = [];
|
|
288
|
+
for (const row of agg.byCurrency) {
|
|
289
|
+
if (!row.currency) continue;
|
|
290
|
+
if (row.currency === baseCurrencyCode) {
|
|
291
|
+
totalBase += row.total;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const key = `${row.currency}/${baseCurrencyCode}`;
|
|
295
|
+
const rate = rateCache.get(key);
|
|
296
|
+
if (rate !== void 0) {
|
|
297
|
+
totalBase += row.total * rate;
|
|
298
|
+
} else {
|
|
299
|
+
convertedAll = false;
|
|
300
|
+
if (!missingRateCurrencies.includes(row.currency)) {
|
|
301
|
+
missingRateCurrencies.push(row.currency);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
agg.totalInBaseCurrency = Math.round(totalBase);
|
|
306
|
+
agg.convertedAll = convertedAll;
|
|
307
|
+
agg.missingRateCurrencies = missingRateCurrencies;
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
for (const agg of stageMap.values()) {
|
|
311
|
+
const present = agg.byCurrency.map((row) => row.currency).filter((c) => !!c);
|
|
312
|
+
if (present.length > 0) {
|
|
313
|
+
agg.convertedAll = false;
|
|
314
|
+
agg.missingRateCurrencies = Array.from(new Set(present));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
for (const agg of stageMap.values()) {
|
|
319
|
+
agg.byCurrency.sort((a, b) => b.total - a.total);
|
|
320
|
+
}
|
|
321
|
+
const response = {
|
|
322
|
+
baseCurrencyCode,
|
|
323
|
+
perStage: Array.from(stageMap.values())
|
|
324
|
+
};
|
|
325
|
+
return NextResponse.json(response);
|
|
326
|
+
}
|
|
327
|
+
export {
|
|
328
|
+
GET,
|
|
329
|
+
metadata,
|
|
330
|
+
openApi
|
|
331
|
+
};
|
|
332
|
+
//# sourceMappingURL=route.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../../src/modules/customers/api/deals/aggregate/route.ts"],
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager as CoreEntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport type { ExchangeRateService } from '@open-mercato/core/modules/currencies/services/exchangeRateService'\nimport { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { fetchStuckDealIds } from '../../../lib/stuckDeals'\nimport { findMatchingEntityIdsBySearchTokensAcrossSources } from '../../utils'\nimport { E } from '#generated/entities.ids.generated'\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },\n}\n\nconst querySchema = z.object({\n pipelineId: z.string().uuid().optional(),\n search: z.string().optional(),\n status: z.array(z.enum(['open', 'closed', 'win', 'loose'])).optional(),\n ownerUserId: z.array(z.string().uuid()).optional(),\n personId: z.array(z.string().uuid()).optional(),\n companyId: z.array(z.string().uuid()).optional(),\n isStuck: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n isOverdue: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n expectedCloseAtFrom: z.string().optional(),\n expectedCloseAtTo: z.string().optional(),\n})\n\ntype StageBreakdownByCurrency = {\n currency: string\n total: number\n count: number\n}\n\ntype StageAggregate = {\n stageId: string\n count: number\n openCount: number\n totalInBaseCurrency: number\n byCurrency: StageBreakdownByCurrency[]\n /**\n * `true` when every currency present in `byCurrency` was either the base currency or\n * had a usable exchange rate at request time. `false` when at least one currency in\n * `byCurrency` could not be resolved to base \u2014 the lane header should surface a\n * \"partial\" indicator in that case so the converted total isn't read as authoritative.\n */\n convertedAll: boolean\n /**\n * Currencies present in `byCurrency` that had NO exchange rate to the base currency.\n * Empty when conversion was complete or there is no base currency configured. The\n * client uses this to disclose which slice of the value is excluded from `totalInBaseCurrency`.\n */\n missingRateCurrencies: string[]\n}\n\ntype AggregateResponse = {\n baseCurrencyCode: string | null\n perStage: StageAggregate[]\n}\n\nconst stageBreakdownByCurrencySchema = z.object({\n currency: z.string(),\n total: z.number(),\n count: z.number(),\n})\n\nconst stageAggregateSchema = z.object({\n stageId: z.string(),\n count: z.number(),\n openCount: z.number(),\n totalInBaseCurrency: z.number(),\n byCurrency: z.array(stageBreakdownByCurrencySchema),\n convertedAll: z.boolean(),\n missingRateCurrencies: z.array(z.string()),\n})\n\nconst aggregateResponseSchema = z.object({\n baseCurrencyCode: z.string().nullable(),\n perStage: z.array(stageAggregateSchema),\n})\n\nconst aggregateErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Deals aggregate per pipeline stage',\n methods: {\n GET: {\n summary: 'Per-stage counts and currency totals for kanban lane headers',\n description:\n 'Returns per-stage counts and totals for deals, with values converted to the tenant base currency where rates are available. Used to power kanban lane headers without loading every deal.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Per-stage aggregate payload', schema: aggregateResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: aggregateErrorSchema },\n { status: 401, description: 'Unauthorized', schema: aggregateErrorSchema },\n ],\n },\n },\n}\n\nfunction readArrayParam(searchParams: URLSearchParams, key: string): string[] | null {\n const all = searchParams.getAll(key)\n if (!all.length) return null\n const flat = all.flatMap((v) => v.split(','))\n const trimmed = flat.map((s) => s.trim()).filter(Boolean)\n return trimmed.length ? trimmed : null\n}\n\nfunction restrictToIds(where: string[], values: Array<string | number | null>, ids: string[]) {\n if (ids.length === 0) {\n where.push('id = ?')\n values.push('00000000-0000-0000-0000-000000000000')\n return\n }\n const placeholders = ids.map(() => '?').join(',')\n where.push(`id IN (${placeholders})`)\n values.push(...ids)\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const url = new URL(req.url)\n const params = url.searchParams\n const parsed = querySchema.safeParse({\n pipelineId: params.get('pipelineId') ?? undefined,\n search: params.get('search') ?? undefined,\n status: readArrayParam(params, 'status') ?? undefined,\n ownerUserId: readArrayParam(params, 'ownerUserId') ?? undefined,\n personId: readArrayParam(params, 'personId') ?? undefined,\n companyId: readArrayParam(params, 'companyId') ?? undefined,\n isStuck: params.get('isStuck') ?? undefined,\n isOverdue: params.get('isOverdue') ?? undefined,\n expectedCloseAtFrom: params.get('expectedCloseAtFrom') ?? undefined,\n expectedCloseAtTo: params.get('expectedCloseAtTo') ?? undefined,\n })\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve<CoreEntityManager>('em')\n\n // Resolve organization scope from the request so multi-org operators can aggregate\n // deals for any org their RBAC scope permits (matching the deal detail route at\n // [id]/route.ts:360). Falls back to `auth.orgId` when no scope cookie is set or\n // when rbacService cannot be resolved (e.g. in unit tests with a minimal container).\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const effectiveTenantId = scope.tenantId ?? auth.tenantId\n const orgFilterIds = Array.isArray(scope.filterIds) && scope.filterIds.length > 0\n ? scope.filterIds.filter((id) => typeof id === 'string' && id.length > 0)\n : auth.orgId\n ? [auth.orgId]\n : []\n if (!effectiveTenantId || orgFilterIds.length === 0) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n // Raw SQL is used here intentionally \u2014 the route only projects non-encrypted columns\n // (`pipeline_stage_id`, `value_amount`, `value_currency`, `status`, plus filters). It\n // avoids the per-row decryption cost that would be paid by `findWithDecryption` for an\n // aggregate that never reads `title`/`description`. The search path still relies on\n // the token index above to find matching deals when encrypted columns are involved.\n const baseCurrency = await em.getConnection().execute<Array<{ code: string }>>(\n `SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,\n [effectiveTenantId, orgFilterIds[0]],\n )\n const baseCurrencyCode = baseCurrency[0]?.code ?? null\n\n // Build WHERE clause shared between count + sum queries\n const orgPlaceholders = orgFilterIds.map(() => '?').join(',')\n const where: string[] = [\n 'tenant_id = ?',\n `organization_id IN (${orgPlaceholders})`,\n 'deleted_at IS NULL',\n ]\n const values: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]\n\n if (parsed.data.pipelineId) {\n where.push('pipeline_id = ?')\n values.push(parsed.data.pipelineId)\n }\n const search = parsed.data.search?.trim()\n if (search) {\n const searchCtx: CrudCtx = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: orgFilterIds[0],\n organizationIds: orgFilterIds,\n request: req,\n }\n const matchingIds = await findMatchingEntityIdsBySearchTokensAcrossSources({\n ctx: searchCtx,\n query: search,\n sources: [\n {\n entityType: E.customers.customer_deal,\n fields: [\n 'title',\n 'description',\n 'status',\n 'pipeline_stage',\n 'source',\n 'value_amount',\n 'value_currency',\n 'cf:competitive_risk',\n 'cf:implementation_complexity',\n ],\n },\n ],\n })\n if (matchingIds !== null && matchingIds.length > 0) {\n restrictToIds(where, values, matchingIds)\n } else if (isTenantDataEncryptionEnabled()) {\n // `customers:customer_deal.title` and `.description` are declared in\n // `encryption.ts` as encrypted columns. A raw `ILIKE` over the ciphertext would\n // silently match nothing on encrypted tenants and produce misleading lane totals,\n // so we collapse the result to zero rows instead \u2014 consistent with the list\n // endpoint's behavior when the token index has nothing to offer.\n restrictToIds(where, values, [])\n } else {\n const searchPattern = `%${escapeLikePattern(search)}%`\n where.push(\"(title ILIKE ? ESCAPE '\\\\' OR description ILIKE ? ESCAPE '\\\\')\")\n values.push(searchPattern, searchPattern)\n }\n }\n if (parsed.data.status && parsed.data.status.length) {\n const placeholders = parsed.data.status.map(() => '?').join(',')\n where.push(`status IN (${placeholders})`)\n values.push(...parsed.data.status)\n }\n if (parsed.data.ownerUserId && parsed.data.ownerUserId.length) {\n const placeholders = parsed.data.ownerUserId.map(() => '?').join(',')\n where.push(`owner_user_id IN (${placeholders})`)\n values.push(...parsed.data.ownerUserId)\n }\n if (parsed.data.expectedCloseAtFrom) {\n where.push('expected_close_at >= ?')\n values.push(parsed.data.expectedCloseAtFrom)\n }\n if (parsed.data.expectedCloseAtTo) {\n where.push('expected_close_at <= ?')\n values.push(parsed.data.expectedCloseAtTo)\n }\n if (parsed.data.isOverdue) {\n where.push(\"expected_close_at < CURRENT_DATE AND status = 'open'\")\n }\n if (parsed.data.isStuck) {\n // Reuse the list endpoint's stuck-deal lookup so kanban headers, lane counts, and the\n // cards rendered inside each lane agree on the same definition of \"stuck\". Empty result\n // \u2192 narrow to a sentinel UUID so the WHERE clause collapses to zero rows.\n const stuckIds = await fetchStuckDealIds(em as unknown as PgEntityManager, orgFilterIds[0], effectiveTenantId)\n restrictToIds(where, values, stuckIds)\n }\n if (parsed.data.personId && parsed.data.personId.length) {\n const placeholders = parsed.data.personId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.personId)\n }\n if (parsed.data.companyId && parsed.data.companyId.length) {\n const placeholders = parsed.data.companyId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.companyId)\n }\n\n const whereSql = where.join(' AND ')\n\n // Aggregate by (stage, currency). Treat null stage as the literal `__unassigned`.\n const rows = await em.getConnection().execute<\n Array<{\n stage_id: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n open_count: string | number\n }>\n >(\n `SELECT\n pipeline_stage_id AS stage_id,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n COUNT(*) FILTER (WHERE status = 'open') AS open_count\n FROM customer_deals\n WHERE ${whereSql}\n GROUP BY pipeline_stage_id, UPPER(COALESCE(value_currency, ''))`,\n values,\n )\n\n // Reduce to per-stage aggregates\n const stageMap = new Map<string, StageAggregate>()\n for (const row of rows) {\n const stageId = row.stage_id ?? '__unassigned'\n const total = Number(row.total ?? 0)\n const count = Number(row.count ?? 0)\n const openCount = Number(row.open_count ?? 0)\n const currency = (row.currency ?? '').toString().trim()\n\n if (!stageMap.has(stageId)) {\n stageMap.set(stageId, {\n stageId,\n count: 0,\n openCount: 0,\n totalInBaseCurrency: 0,\n byCurrency: [],\n convertedAll: true,\n missingRateCurrencies: [],\n })\n }\n const agg = stageMap.get(stageId)!\n agg.count += count\n agg.openCount += openCount\n // Include any row that has a real currency code, even when its summed amount is\n // zero \u2014 `byCurrency` should agree with `count` so the per-currency breakdown\n // doesn't silently drop deals whose amount happens to be zero. Rows with no\n // currency code at all still get folded into the stage totals via `count`.\n if (currency.length > 0) {\n agg.byCurrency.push({ currency, total, count })\n }\n }\n\n // Convert to base currency where possible\n if (baseCurrencyCode) {\n const exchange = container.resolve('exchangeRateService') as ExchangeRateService | undefined\n const today = new Date()\n const distinctCurrencies = new Set<string>()\n for (const agg of stageMap.values()) {\n for (const row of agg.byCurrency) {\n if (row.currency && row.currency !== baseCurrencyCode) {\n distinctCurrencies.add(row.currency)\n }\n }\n }\n\n const rateCache = new Map<string, number>()\n if (exchange && distinctCurrencies.size > 0) {\n const pairs = Array.from(distinctCurrencies).map((c) => ({\n fromCurrencyCode: c,\n toCurrencyCode: baseCurrencyCode,\n }))\n try {\n const results = await exchange.getRates({\n pairs,\n date: today,\n scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },\n options: { maxDaysBack: 60, autoFetch: false },\n })\n for (const [key, rateResult] of results) {\n if (rateResult.rates.length > 0) {\n // Pick the first matching rate (sources are equivalent for display purposes)\n const rate = Number(rateResult.rates[0].rate)\n if (Number.isFinite(rate) && rate > 0) {\n rateCache.set(key, rate)\n }\n }\n }\n } catch (err) {\n // Swallow \u2014 partial totals are still useful and we'll fall back to currency-native\n // sums. Logging at warn level so operators can correlate missing-rate disclosures in\n // the UI (`convertedAll: false` / `missingRateCurrencies`) with the underlying error.\n console.warn('[customers.deals.aggregate] exchange-rate lookup failed; falling back to per-currency totals', err)\n }\n }\n\n for (const agg of stageMap.values()) {\n let totalBase = 0\n let convertedAll = true\n const missingRateCurrencies: string[] = []\n for (const row of agg.byCurrency) {\n if (!row.currency) continue\n if (row.currency === baseCurrencyCode) {\n totalBase += row.total\n continue\n }\n const key = `${row.currency}/${baseCurrencyCode}`\n const rate = rateCache.get(key)\n if (rate !== undefined) {\n totalBase += row.total * rate\n } else {\n convertedAll = false\n if (!missingRateCurrencies.includes(row.currency)) {\n missingRateCurrencies.push(row.currency)\n }\n }\n }\n // Even when some rates are missing, totalInBaseCurrency reflects the converted slice.\n // `convertedAll`/`missingRateCurrencies` let the client distinguish a complete\n // conversion from a partial one and disclose which currencies were excluded.\n agg.totalInBaseCurrency = Math.round(totalBase)\n agg.convertedAll = convertedAll\n agg.missingRateCurrencies = missingRateCurrencies\n }\n } else {\n // No base currency configured for the tenant \u2014 surface this by marking every stage as\n // \"not converted\" with all currencies flagged as missing rates. This way the client UI\n // can fall back to a per-currency breakdown without trying to render a board total.\n for (const agg of stageMap.values()) {\n const present = agg.byCurrency.map((row) => row.currency).filter((c): c is string => !!c)\n if (present.length > 0) {\n agg.convertedAll = false\n agg.missingRateCurrencies = Array.from(new Set(present))\n }\n }\n }\n\n // Sort byCurrency rows by total descending for stable display\n for (const agg of stageMap.values()) {\n agg.byCurrency.sort((a, b) => b.total - a.total)\n }\n\n const response: AggregateResponse = {\n baseCurrencyCode,\n perStage: Array.from(stageMap.values()),\n }\n\n return NextResponse.json(response)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
3
|
+
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
4
|
+
import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
|
|
5
|
+
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
6
|
+
import {
|
|
7
|
+
runCrudMutationGuardAfterSuccess,
|
|
8
|
+
validateCrudMutationGuard
|
|
9
|
+
} from "@open-mercato/shared/lib/crud/mutation-guard";
|
|
10
|
+
import {
|
|
11
|
+
CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE,
|
|
12
|
+
getCustomersQueue
|
|
13
|
+
} from "../../../lib/bulkDeals.js";
|
|
14
|
+
import {
|
|
15
|
+
dealsBulkUpdateOwnerSchema as requestSchema,
|
|
16
|
+
dealsBulkUpdateResponseSchema as responseSchema
|
|
17
|
+
} from "../../../data/validators.js";
|
|
18
|
+
const metadata = {
|
|
19
|
+
POST: { requireAuth: true, requireFeatures: ["customers.deals.manage"] }
|
|
20
|
+
};
|
|
21
|
+
const openApi = {
|
|
22
|
+
tags: ["Customers"],
|
|
23
|
+
summary: "Bulk reassign deal owner",
|
|
24
|
+
description: "Queues a background job that reassigns the listed deals to a new owner (or clears the owner when null)."
|
|
25
|
+
};
|
|
26
|
+
async function postImpl(req, translate) {
|
|
27
|
+
const auth = await getAuthFromRequest(req);
|
|
28
|
+
if (!auth?.tenantId || !auth.orgId) {
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
responseSchema.parse({
|
|
31
|
+
ok: false,
|
|
32
|
+
progressJobId: null,
|
|
33
|
+
message: translate("customers.errors.unauthorized", "Unauthorized")
|
|
34
|
+
}),
|
|
35
|
+
{ status: 401 }
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const parsed = requestSchema.safeParse(await readJsonSafe(req));
|
|
39
|
+
if (!parsed.success) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
responseSchema.parse({
|
|
42
|
+
ok: false,
|
|
43
|
+
progressJobId: null,
|
|
44
|
+
message: translate("customers.errors.invalid_payload", "Invalid payload")
|
|
45
|
+
}),
|
|
46
|
+
{ status: 400 }
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const ids = Array.from(new Set(parsed.data.ids));
|
|
50
|
+
const container = await createRequestContainer();
|
|
51
|
+
let guardResult = null;
|
|
52
|
+
try {
|
|
53
|
+
guardResult = await validateCrudMutationGuard(container, {
|
|
54
|
+
tenantId: auth.tenantId,
|
|
55
|
+
organizationId: auth.orgId,
|
|
56
|
+
userId: auth.sub,
|
|
57
|
+
resourceKind: "customers.deal",
|
|
58
|
+
resourceId: ids.join(","),
|
|
59
|
+
operation: "custom",
|
|
60
|
+
requestMethod: req.method,
|
|
61
|
+
requestHeaders: req.headers,
|
|
62
|
+
mutationPayload: { ids, ownerUserId: parsed.data.ownerUserId }
|
|
63
|
+
});
|
|
64
|
+
} catch (guardError) {
|
|
65
|
+
console.warn("[customers.deals.bulk-update-owner] mutation-guard skipped", guardError);
|
|
66
|
+
guardResult = null;
|
|
67
|
+
}
|
|
68
|
+
if (guardResult && !guardResult.ok) {
|
|
69
|
+
return NextResponse.json(guardResult.body, { status: guardResult.status });
|
|
70
|
+
}
|
|
71
|
+
let progressService;
|
|
72
|
+
try {
|
|
73
|
+
progressService = container.resolve("progressService");
|
|
74
|
+
} catch (resolveError) {
|
|
75
|
+
console.error("[customers.deals.bulk-update-owner] progressService resolve failed", resolveError);
|
|
76
|
+
throw new Error(
|
|
77
|
+
`progressService resolve failed: ${resolveError instanceof Error ? resolveError.message : String(resolveError)}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
let progressJob;
|
|
81
|
+
try {
|
|
82
|
+
progressJob = await progressService.createJob(
|
|
83
|
+
{
|
|
84
|
+
jobType: "customers.deals.bulk_update_owner",
|
|
85
|
+
name: translate(
|
|
86
|
+
"customers.deals.kanban.bulk.changeOwner.progress.name",
|
|
87
|
+
"Reassign selected deals to a new owner"
|
|
88
|
+
),
|
|
89
|
+
description: translate(
|
|
90
|
+
"customers.deals.kanban.bulk.changeOwner.progress.description",
|
|
91
|
+
"{count} deals queued for owner reassignment",
|
|
92
|
+
{ count: ids.length }
|
|
93
|
+
),
|
|
94
|
+
totalCount: ids.length,
|
|
95
|
+
cancellable: false,
|
|
96
|
+
meta: {
|
|
97
|
+
source: "customers.deals.bulk-update-owner",
|
|
98
|
+
ownerUserId: parsed.data.ownerUserId
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
tenantId: auth.tenantId,
|
|
103
|
+
organizationId: auth.orgId,
|
|
104
|
+
userId: auth.sub
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
} catch (createError) {
|
|
108
|
+
console.error("[customers.deals.bulk-update-owner] progressService.createJob failed", createError);
|
|
109
|
+
throw new Error(
|
|
110
|
+
`progressService.createJob failed: ${createError instanceof Error ? createError.message : String(createError)}`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const queue = getCustomersQueue(CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE);
|
|
114
|
+
try {
|
|
115
|
+
await queue.enqueue({
|
|
116
|
+
progressJobId: progressJob.id,
|
|
117
|
+
ids,
|
|
118
|
+
ownerUserId: parsed.data.ownerUserId,
|
|
119
|
+
scope: {
|
|
120
|
+
organizationId: auth.orgId,
|
|
121
|
+
tenantId: auth.tenantId,
|
|
122
|
+
userId: auth.sub
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
} catch (error) {
|
|
126
|
+
await progressService.failJob(
|
|
127
|
+
progressJob.id,
|
|
128
|
+
{
|
|
129
|
+
errorMessage: error instanceof Error ? error.message : translate("customers.errors.bulk_enqueue_failed", "Failed to enqueue bulk job")
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
tenantId: auth.tenantId,
|
|
133
|
+
organizationId: auth.orgId,
|
|
134
|
+
userId: auth.sub
|
|
135
|
+
}
|
|
136
|
+
).catch((failErr) => {
|
|
137
|
+
console.warn("[customers.deals.bulk-update-owner] failed to mark progress job as failed", failErr);
|
|
138
|
+
});
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
|
|
142
|
+
await runCrudMutationGuardAfterSuccess(container, {
|
|
143
|
+
tenantId: auth.tenantId,
|
|
144
|
+
organizationId: auth.orgId,
|
|
145
|
+
userId: auth.sub,
|
|
146
|
+
resourceKind: "customers.deal",
|
|
147
|
+
resourceId: ids.join(","),
|
|
148
|
+
operation: "custom",
|
|
149
|
+
requestMethod: req.method,
|
|
150
|
+
requestHeaders: req.headers,
|
|
151
|
+
metadata: guardResult.metadata ?? null
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return NextResponse.json(
|
|
155
|
+
responseSchema.parse({
|
|
156
|
+
ok: true,
|
|
157
|
+
progressJobId: progressJob.id,
|
|
158
|
+
message: translate(
|
|
159
|
+
"customers.deals.kanban.bulk.changeOwner.queued",
|
|
160
|
+
"Bulk owner update started ({count} deals).",
|
|
161
|
+
{ count: ids.length }
|
|
162
|
+
)
|
|
163
|
+
}),
|
|
164
|
+
{ status: 202 }
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
async function POST(req) {
|
|
168
|
+
const { translate } = await resolveTranslations();
|
|
169
|
+
try {
|
|
170
|
+
return await postImpl(req, translate);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error("[customers.deals.bulk-update-owner] route failed", error);
|
|
173
|
+
return NextResponse.json(
|
|
174
|
+
responseSchema.parse({
|
|
175
|
+
ok: false,
|
|
176
|
+
progressJobId: null,
|
|
177
|
+
message: error instanceof Error ? error.message : translate("customers.errors.bulk_enqueue_failed", "Failed to enqueue bulk job")
|
|
178
|
+
}),
|
|
179
|
+
{ status: 500 }
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
export {
|
|
184
|
+
POST,
|
|
185
|
+
metadata,
|
|
186
|
+
openApi
|
|
187
|
+
};
|
|
188
|
+
//# sourceMappingURL=route.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../../src/modules/customers/api/deals/bulk-update-owner/route.ts"],
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport type { TranslateWithFallbackFn } from '@open-mercato/shared/lib/i18n/translate'\nimport {\n runCrudMutationGuardAfterSuccess,\n validateCrudMutationGuard,\n} from '@open-mercato/shared/lib/crud/mutation-guard'\nimport type { ProgressService } from '../../../../progress/lib/progressService'\nimport {\n CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE,\n getCustomersQueue,\n} from '../../../lib/bulkDeals'\nimport {\n dealsBulkUpdateOwnerSchema as requestSchema,\n dealsBulkUpdateResponseSchema as responseSchema,\n} from '../../../data/validators'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['customers.deals.manage'] },\n}\n\nexport const openApi = {\n tags: ['Customers'],\n summary: 'Bulk reassign deal owner',\n description:\n 'Queues a background job that reassigns the listed deals to a new owner (or clears the owner when null).',\n}\n\nasync function postImpl(req: Request, translate: TranslateWithFallbackFn): Promise<NextResponse> {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json(\n responseSchema.parse({\n ok: false,\n progressJobId: null,\n message: translate('customers.errors.unauthorized', 'Unauthorized'),\n }),\n { status: 401 },\n )\n }\n\n const parsed = requestSchema.safeParse(await readJsonSafe(req))\n if (!parsed.success) {\n return NextResponse.json(\n responseSchema.parse({\n ok: false,\n progressJobId: null,\n message: translate('customers.errors.invalid_payload', 'Invalid payload'),\n }),\n { status: 400 },\n )\n }\n\n const ids = Array.from(new Set(parsed.data.ids))\n const container = await createRequestContainer()\n\n // Mutation-guard contract for custom write routes \u2014 see comment on bulk-update-stage\n // route for full rationale. Per-record locks are enforced inside the worker (per id).\n // The guard at the bulk-entry point is best-effort: failures (e.g. a guard that\n // expects a single-record id, or transient lock-table errors) must NOT take the whole\n // bulk enqueue down \u2014 swallow so the bulk job still gets queued.\n let guardResult: Awaited<ReturnType<typeof validateCrudMutationGuard>> = null\n try {\n guardResult = await validateCrudMutationGuard(container, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n userId: auth.sub,\n resourceKind: 'customers.deal',\n resourceId: ids.join(','),\n operation: 'custom',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: { ids, ownerUserId: parsed.data.ownerUserId },\n })\n } catch (guardError) {\n console.warn('[customers.deals.bulk-update-owner] mutation-guard skipped', guardError)\n guardResult = null\n }\n if (guardResult && !guardResult.ok) {\n return NextResponse.json(guardResult.body, { status: guardResult.status })\n }\n\n let progressService: ProgressService\n try {\n progressService = container.resolve('progressService') as ProgressService\n } catch (resolveError) {\n console.error('[customers.deals.bulk-update-owner] progressService resolve failed', resolveError)\n throw new Error(\n `progressService resolve failed: ${resolveError instanceof Error ? resolveError.message : String(resolveError)}`,\n )\n }\n\n let progressJob: Awaited<ReturnType<ProgressService['createJob']>>\n try {\n progressJob = await progressService.createJob(\n {\n jobType: 'customers.deals.bulk_update_owner',\n name: translate(\n 'customers.deals.kanban.bulk.changeOwner.progress.name',\n 'Reassign selected deals to a new owner',\n ),\n description: translate(\n 'customers.deals.kanban.bulk.changeOwner.progress.description',\n '{count} deals queued for owner reassignment',\n { count: ids.length },\n ),\n totalCount: ids.length,\n cancellable: false,\n meta: {\n source: 'customers.deals.bulk-update-owner',\n ownerUserId: parsed.data.ownerUserId,\n },\n },\n {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n userId: auth.sub,\n },\n )\n } catch (createError) {\n console.error('[customers.deals.bulk-update-owner] progressService.createJob failed', createError)\n throw new Error(\n `progressService.createJob failed: ${createError instanceof Error ? createError.message : String(createError)}`,\n )\n }\n\n const queue = getCustomersQueue(CUSTOMERS_DEALS_BULK_UPDATE_OWNER_QUEUE)\n try {\n await queue.enqueue({\n progressJobId: progressJob.id,\n ids,\n ownerUserId: parsed.data.ownerUserId,\n scope: {\n organizationId: auth.orgId,\n tenantId: auth.tenantId,\n userId: auth.sub,\n },\n })\n } catch (error) {\n // See bulk-update-stage route for rationale: without this guard the progress\n // job hangs in `pending` because the worker will never pick it up after a\n // failed enqueue, and the top-bar polls indefinitely.\n await progressService\n .failJob(\n progressJob.id,\n {\n errorMessage:\n error instanceof Error\n ? error.message\n : translate('customers.errors.bulk_enqueue_failed', 'Failed to enqueue bulk job'),\n },\n {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n userId: auth.sub,\n },\n )\n .catch((failErr) => {\n console.warn('[customers.deals.bulk-update-owner] failed to mark progress job as failed', failErr)\n })\n throw error\n }\n\n // After-success half of the mutation-guard contract \u2014 see bulk-update-stage route.\n if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {\n await runCrudMutationGuardAfterSuccess(container, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n userId: auth.sub,\n resourceKind: 'customers.deal',\n resourceId: ids.join(','),\n operation: 'custom',\n requestMethod: req.method,\n requestHeaders: req.headers,\n metadata: guardResult.metadata ?? null,\n })\n }\n\n return NextResponse.json(\n responseSchema.parse({\n ok: true,\n progressJobId: progressJob.id,\n message: translate(\n 'customers.deals.kanban.bulk.changeOwner.queued',\n 'Bulk owner update started ({count} deals).',\n { count: ids.length },\n ),\n }),\n { status: 202 },\n )\n}\n\n// Outer wrapper: every uncaught error becomes a controlled JSON 500 instead of\n// Next.js's default HTML error page. See bulk-update-stage for the same rationale.\nexport async function POST(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n return await postImpl(req, translate)\n } catch (error) {\n console.error('[customers.deals.bulk-update-owner] route failed', error)\n return NextResponse.json(\n responseSchema.parse({\n ok: false,\n progressJobId: null,\n message:\n error instanceof Error\n ? error.message\n : translate('customers.errors.bulk_enqueue_failed', 'Failed to enqueue bulk job'),\n }),\n { status: 500 },\n )\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|