@open-mercato/core 0.7.1-develop.7113.1.9d83dca10c → 0.7.1-develop.7120.1.a0154ac412
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/crmFixtures.js.map +1 -1
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js +9 -2
- package/dist/modules/communication_channels/api/post/channels/[id]/test-send/route.js.map +2 -2
- package/dist/modules/communication_channels/lib/outbound-recipient.js +5 -1
- package/dist/modules/communication_channels/lib/outbound-recipient.js.map +2 -2
- package/dist/modules/communication_channels/lib/test-seed.js +5 -0
- package/dist/modules/communication_channels/lib/test-seed.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +1 -1
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +1 -1
- package/dist/modules/customers/backend/customers/deals/page.js +4 -3
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +1 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/cli.js +5 -5
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/components/detail/pipelineStageUtils.js +1 -0
- package/dist/modules/customers/components/detail/pipelineStageUtils.js.map +2 -2
- package/dist/modules/customers/components/linking/adapters/dealAdapter.js +2 -2
- package/dist/modules/customers/components/linking/adapters/dealAdapter.js.map +2 -2
- package/dist/modules/customers/lib/closureStage.js.map +2 -2
- package/dist/modules/customers/lib/dealStatus.js +6 -4
- package/dist/modules/customers/lib/dealStatus.js.map +2 -2
- package/dist/modules/customers/lib/dealsSummaryQueries.js +3 -3
- package/dist/modules/customers/lib/dealsSummaryQueries.js.map +1 -1
- package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js +30 -0
- package/dist/modules/customers/migrations/Migration20260824180000_deal_status_lost.js.map +7 -0
- package/dist/modules/dashboards/widgets/dashboard/pipeline-summary/config.js.map +1 -1
- package/dist/modules/eudr/notifications.js +5 -0
- package/dist/modules/eudr/notifications.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +59 -28
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +120 -37
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/warranty_claims/notifications.js +5 -0
- package/dist/modules/warranty_claims/notifications.js.map +2 -2
- package/dist/modules/wms/notifications.js +2 -0
- package/dist/modules/wms/notifications.js.map +2 -2
- package/package.json +7 -7
- package/src/helpers/integration/crmFixtures.ts +1 -1
- package/src/modules/communication_channels/api/post/channels/[id]/test-send/route.ts +21 -9
- package/src/modules/communication_channels/lib/adapter.ts +11 -0
- package/src/modules/communication_channels/lib/outbound-recipient.ts +14 -1
- package/src/modules/communication_channels/lib/test-seed.ts +19 -0
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +1 -1
- package/src/modules/customers/backend/customers/deals/page.tsx +4 -3
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +5 -5
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +3 -3
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2 -2
- package/src/modules/customers/cli.ts +5 -5
- package/src/modules/customers/commands/deals.ts +4 -3
- package/src/modules/customers/components/detail/pipelineStageUtils.ts +1 -0
- package/src/modules/customers/components/linking/adapters/dealAdapter.tsx +2 -2
- package/src/modules/customers/i18n/de.json +1 -1
- package/src/modules/customers/i18n/en.json +1 -1
- package/src/modules/customers/i18n/es.json +1 -1
- package/src/modules/customers/i18n/ko.json +1 -1
- package/src/modules/customers/i18n/pl.json +1 -1
- package/src/modules/customers/lib/closureStage.ts +2 -1
- package/src/modules/customers/lib/dealStatus.ts +23 -12
- package/src/modules/customers/lib/dealsSummaryQueries.ts +3 -3
- package/src/modules/customers/migrations/Migration20260824180000_deal_status_lost.ts +55 -0
- package/src/modules/dashboards/widgets/dashboard/pipeline-summary/config.ts +1 -1
- package/src/modules/eudr/notifications.ts +5 -0
- package/src/modules/query_index/lib/search-tokens.ts +100 -43
- package/src/modules/sales/commands/documents.ts +153 -39
- package/src/modules/warranty_claims/notifications.ts +5 -0
- package/src/modules/wms/notifications.ts +2 -0
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
// Single source of truth for deal open/closed (terminal) semantics.
|
|
2
2
|
//
|
|
3
3
|
// `customer_deals.status` is a lenient text column and the supported writers do not agree on
|
|
4
|
-
// one spelling: the closure hooks and the kanban board persist `win` / `
|
|
5
|
-
// `customers.update_deal_stage` persists `won` / `lost`,
|
|
6
|
-
// `closed
|
|
7
|
-
//
|
|
8
|
-
//
|
|
4
|
+
// one spelling: the closure hooks and the kanban board persist `win` / `lost`, the AI tool
|
|
5
|
+
// `customers.update_deal_stage` persists `won` / `lost`, the seeded vocabulary also carries
|
|
6
|
+
// `closed`, and rows written before 0.7.1 carry the misspelled `loose`. The terminal set
|
|
7
|
+
// below therefore covers every spelling a status writer persists, so status-based readers
|
|
8
|
+
// can share one classification. `closureOutcome` is a separate signal, and callers that
|
|
9
|
+
// combine both columns must handle it explicitly.
|
|
9
10
|
//
|
|
10
11
|
// A status not known to code counts as OPEN. Deal stages are configurable per tenant, so the
|
|
11
12
|
// lenient column can hold values this module has never seen — treating them as open keeps them
|
|
12
13
|
// visible in active-deal counts instead of silently reclassifying them as closed.
|
|
13
14
|
|
|
14
15
|
export const DEAL_STATUS_WIN = 'win' as const
|
|
15
|
-
export const
|
|
16
|
+
export const DEAL_STATUS_LOST = 'lost' as const
|
|
16
17
|
export const DEAL_STATUS_CLOSED = 'closed' as const
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* @deprecated Misspelling of the lost-deal status, canonical until 0.7.0. Writers now
|
|
21
|
+
* persist {@link DEAL_STATUS_LOST}. This export stays so downstream modules keep
|
|
22
|
+
* compiling and readers keep matching rows written before the 0.7.1 migration; it is
|
|
23
|
+
* scheduled for removal no earlier than 0.9.0.
|
|
24
|
+
*/
|
|
25
|
+
export const DEAL_STATUS_LOSE = 'loose' as const
|
|
26
|
+
|
|
27
|
+
// `won` is the spelling the AI stage tool writes; `win` is what the UI closure flows
|
|
28
|
+
// persist. Both are accepted so neither writer produces an unreadable status.
|
|
20
29
|
export const WON_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_WIN, 'won']
|
|
21
30
|
|
|
22
|
-
|
|
31
|
+
// `lost` is canonical. `loose` is the pre-0.7.1 spelling the UI closure flows and the
|
|
32
|
+
// kanban board persisted; unmigrated rows and tenant dictionaries still carry it.
|
|
33
|
+
export const LOST_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_LOST, DEAL_STATUS_LOSE]
|
|
23
34
|
|
|
24
35
|
export const CLOSED_DEAL_STATUS_LIST: readonly string[] = [
|
|
25
36
|
...WON_DEAL_STATUS_LIST,
|
|
@@ -50,10 +61,10 @@ export function isOpenDealStatus(value: string | null | undefined): boolean {
|
|
|
50
61
|
export function canonicalDealStatus(value: string): string {
|
|
51
62
|
const lower = value.toLowerCase()
|
|
52
63
|
if (lower === 'won') return DEAL_STATUS_WIN
|
|
53
|
-
if (lower === '
|
|
64
|
+
if (lower === 'loose') return DEAL_STATUS_LOST
|
|
54
65
|
// Canonical spellings normalize to lower-case too, so an upper-case selection still
|
|
55
66
|
// matches the lower-case persisted values and dictionary options.
|
|
56
|
-
if (lower === 'win' || lower === '
|
|
67
|
+
if (lower === 'win' || lower === 'lost' || lower === 'open' || lower === 'closed') return lower
|
|
57
68
|
return value
|
|
58
69
|
}
|
|
59
70
|
|
|
@@ -61,7 +72,7 @@ export function canonicalDealStatus(value: string): string {
|
|
|
61
72
|
* Expand operator-selected status filter values into the canonical vocabulary so a
|
|
62
73
|
* single selection matches every spelling the supported writers persist (#5107).
|
|
63
74
|
*
|
|
64
|
-
* Alias groups (`win`/`won`, `loose
|
|
75
|
+
* Alias groups (`win`/`won`, `lost`/`loose`, `closed`) are matched case-insensitively,
|
|
65
76
|
* expanded to their canonical spellings, and also keep the caller's original token so
|
|
66
77
|
* the result is always a strict superset of a verbatim match. Unknown values emit both
|
|
67
78
|
* the trimmed original and its lower-case form — mixed-case tenant dictionary statuses
|
|
@@ -146,9 +146,9 @@ export async function loadDealsSummaryQueryRows(input: DealsSummaryQueryInput):
|
|
|
146
146
|
const winLossRows = await connection.execute<WinLossRow[]>(
|
|
147
147
|
`SELECT
|
|
148
148
|
COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,
|
|
149
|
-
COUNT(*) FILTER (WHERE (status
|
|
149
|
+
COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,
|
|
150
150
|
COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,
|
|
151
|
-
COUNT(*) FILTER (WHERE (status
|
|
151
|
+
COUNT(*) FILTER (WHERE (status IN ('lost', 'loose') OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost
|
|
152
152
|
FROM customer_deals
|
|
153
153
|
WHERE ${scopeWhere}`,
|
|
154
154
|
[
|
|
@@ -168,7 +168,7 @@ export async function loadDealsSummaryQueryRows(input: DealsSummaryQueryInput):
|
|
|
168
168
|
`SELECT
|
|
169
169
|
to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,
|
|
170
170
|
COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,
|
|
171
|
-
COUNT(*) FILTER (WHERE status
|
|
171
|
+
COUNT(*) FILTER (WHERE status IN ('lost', 'loose') OR closure_outcome = 'lost') AS lost
|
|
172
172
|
FROM customer_deals
|
|
173
173
|
WHERE ${scopeWhere} AND updated_at >= ?
|
|
174
174
|
GROUP BY 1`,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Rename the misspelled lost-deal vocabulary `loose` to `lost`.
|
|
5
|
+
*
|
|
6
|
+
* `loose` was the canonical spelling the UI closure flows, the kanban board and the seeded
|
|
7
|
+
* dictionaries persisted up to 0.7.0. `lost` is what every other surface already used
|
|
8
|
+
* (`closure_outcome`, the AI stage tool, every operator-facing label), so the two spellings
|
|
9
|
+
* had to be reconciled anywhere a reader joined them.
|
|
10
|
+
*
|
|
11
|
+
* Forward-only and conservative. Nothing is deleted, and a row is left alone whenever
|
|
12
|
+
* renaming it would collide with an entry the tenant already has:
|
|
13
|
+
*
|
|
14
|
+
* - `customer_deals.status` / `pipeline_stage`: `loose` -> `lost`.
|
|
15
|
+
* - `customer_dictionary_entries` (`deal_status`, `pipeline_stage`): the `loose` entry is
|
|
16
|
+
* renamed only when the same scope has no `lost` entry, since
|
|
17
|
+
* `customer_dictionary_entries_unique` covers (org, tenant, kind, normalized_value).
|
|
18
|
+
* Its label is corrected only when it is still the seeded `Loose`, so a tenant that
|
|
19
|
+
* renamed the option keeps its own wording.
|
|
20
|
+
* - `customer_pipeline_stages.name`: the seeded `Loose` label becomes `Lost`.
|
|
21
|
+
*
|
|
22
|
+
* Readers keep accepting `loose` (see `lib/dealStatus.ts`), so an instance that skips this
|
|
23
|
+
* migration, or a row this migration deliberately leaves behind, still classifies correctly.
|
|
24
|
+
*/
|
|
25
|
+
export class Migration20260824180000_deal_status_lost extends Migration {
|
|
26
|
+
override async up(): Promise<void> {
|
|
27
|
+
this.addSql(`update "customer_deals" set "status" = 'lost' where lower("status") = 'loose';`);
|
|
28
|
+
this.addSql(`update "customer_deals" set "pipeline_stage" = 'lost' where lower("pipeline_stage") = 'loose';`);
|
|
29
|
+
|
|
30
|
+
this.addSql(`
|
|
31
|
+
update "customer_dictionary_entries" as e
|
|
32
|
+
set "value" = 'lost',
|
|
33
|
+
"normalized_value" = 'lost',
|
|
34
|
+
"label" = case when e."label" = 'Loose' then 'Lost' else e."label" end,
|
|
35
|
+
"updated_at" = now()
|
|
36
|
+
where e."kind" in ('deal_status', 'pipeline_stage')
|
|
37
|
+
and e."normalized_value" = 'loose'
|
|
38
|
+
and not exists (
|
|
39
|
+
select 1 from "customer_dictionary_entries" as other
|
|
40
|
+
where other."organization_id" = e."organization_id"
|
|
41
|
+
and other."tenant_id" = e."tenant_id"
|
|
42
|
+
and other."kind" = e."kind"
|
|
43
|
+
and other."normalized_value" = 'lost'
|
|
44
|
+
);
|
|
45
|
+
`);
|
|
46
|
+
|
|
47
|
+
this.addSql(`update "customer_pipeline_stages" set "name" = 'Lost', "updated_at" = now() where "name" = 'Loose';`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
override async down(): Promise<void> {
|
|
51
|
+
// Reverting is lossy: an instance may have carried `lost` rows before this migration ran,
|
|
52
|
+
// and those are indistinguishable afterwards from the ones it renamed. Readers accept
|
|
53
|
+
// both spellings, so leaving the data on the canonical `lost` is the safe no-op.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -9,7 +9,7 @@ import type { WidgetDataRequest } from '../../../services/widgetDataService'
|
|
|
9
9
|
// `customers/lib/interactionStatus.ts`, which treats an unknown interaction status as open.
|
|
10
10
|
//
|
|
11
11
|
// Every vocabulary that reaches the column has to be listed here:
|
|
12
|
-
// - `win` / `
|
|
12
|
+
// - `win` / `lost` are written by the deal closure UI and the kanban board.
|
|
13
13
|
// - `won` / `lost` are written verbatim by the `customers.update_deal_stage` AI tool, whose
|
|
14
14
|
// free-form `toStage` is passed straight through to `status`.
|
|
15
15
|
// - `closed` is a seeded `deal_status` dictionary value, persisted by the dashboards
|
|
@@ -4,6 +4,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
4
4
|
{
|
|
5
5
|
type: 'eudr.statement.submitted',
|
|
6
6
|
module: 'eudr',
|
|
7
|
+
channels: ['in_app', 'email'],
|
|
7
8
|
titleKey: 'eudr.notifications.statement.submitted.title',
|
|
8
9
|
bodyKey: 'eudr.notifications.statement.submitted.body',
|
|
9
10
|
icon: 'file-check',
|
|
@@ -14,6 +15,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
14
15
|
{
|
|
15
16
|
type: 'eudr.statement.reference_issued',
|
|
16
17
|
module: 'eudr',
|
|
18
|
+
channels: ['in_app', 'email'],
|
|
17
19
|
titleKey: 'eudr.notifications.statement.referenceIssued.title',
|
|
18
20
|
bodyKey: 'eudr.notifications.statement.referenceIssued.body',
|
|
19
21
|
icon: 'badge-check',
|
|
@@ -24,6 +26,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
24
26
|
{
|
|
25
27
|
type: 'eudr.statement.withdrawn',
|
|
26
28
|
module: 'eudr',
|
|
29
|
+
channels: ['in_app', 'email'],
|
|
27
30
|
titleKey: 'eudr.notifications.statement.withdrawn.title',
|
|
28
31
|
bodyKey: 'eudr.notifications.statement.withdrawn.body',
|
|
29
32
|
icon: 'undo',
|
|
@@ -34,6 +37,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
34
37
|
{
|
|
35
38
|
type: 'eudr.risk.non_negligible',
|
|
36
39
|
module: 'eudr',
|
|
40
|
+
channels: ['in_app', 'email'],
|
|
37
41
|
titleKey: 'eudr.notifications.risk.nonNegligible.title',
|
|
38
42
|
bodyKey: 'eudr.notifications.risk.nonNegligible.body',
|
|
39
43
|
icon: 'alert-triangle',
|
|
@@ -44,6 +48,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
44
48
|
{
|
|
45
49
|
type: 'eudr.mitigation.completed',
|
|
46
50
|
module: 'eudr',
|
|
51
|
+
channels: ['in_app', 'email'],
|
|
47
52
|
titleKey: 'eudr.notifications.mitigation.completed.title',
|
|
48
53
|
bodyKey: 'eudr.notifications.mitigation.completed.body',
|
|
49
54
|
icon: 'check-circle',
|
|
@@ -162,6 +162,60 @@ function buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null)
|
|
|
162
162
|
return pairs
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
type TokenRowLike = { field?: unknown; token_hash?: unknown; token?: unknown }
|
|
166
|
+
|
|
167
|
+
// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`
|
|
168
|
+
// would otherwise sign identically to field `a` + hash `b c`.
|
|
169
|
+
const SIGNATURE_SEPARATOR = String.fromCharCode(0)
|
|
170
|
+
|
|
171
|
+
// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a
|
|
172
|
+
// stored NULL has to sign the same as the `null` a freshly built row carries — otherwise every
|
|
173
|
+
// record compares as changed and the skip never fires.
|
|
174
|
+
function tokenSignature(row: TokenRowLike): string {
|
|
175
|
+
return [
|
|
176
|
+
String(row.field ?? ''),
|
|
177
|
+
String(row.token_hash ?? ''),
|
|
178
|
+
row.token == null ? '' : String(row.token),
|
|
179
|
+
].join(SIGNATURE_SEPARATOR)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement
|
|
183
|
+
// defect, and a set comparison reads such a record as already correct and preserves the duplicates
|
|
184
|
+
// forever. Counting sends it through a full rewrite, which collapses them.
|
|
185
|
+
function tallyOf(rows: Iterable<TokenRowLike>): Map<string, number> {
|
|
186
|
+
const tally = new Map<string, number>()
|
|
187
|
+
for (const row of rows) {
|
|
188
|
+
const signature = tokenSignature(row)
|
|
189
|
+
tally.set(signature, (tally.get(signature) ?? 0) + 1)
|
|
190
|
+
}
|
|
191
|
+
return tally
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {
|
|
195
|
+
const left = a ?? new Map<string, number>()
|
|
196
|
+
const right = b ?? new Map<string, number>()
|
|
197
|
+
if (left.size !== right.size) return false
|
|
198
|
+
for (const [key, count] of left.entries()) {
|
|
199
|
+
if (right.get(key) !== count) return false
|
|
200
|
+
}
|
|
201
|
+
return true
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function tallyTokenRows<TRow extends TokenRowLike>(
|
|
205
|
+
rows: Iterable<TRow>,
|
|
206
|
+
keyOf: (row: TRow) => string
|
|
207
|
+
): Map<string, Map<string, number>> {
|
|
208
|
+
const tallies = new Map<string, Map<string, number>>()
|
|
209
|
+
for (const row of rows) {
|
|
210
|
+
const key = keyOf(row)
|
|
211
|
+
const tally = tallies.get(key) ?? new Map<string, number>()
|
|
212
|
+
const signature = tokenSignature(row)
|
|
213
|
+
tally.set(signature, (tally.get(signature) ?? 0) + 1)
|
|
214
|
+
tallies.set(key, tally)
|
|
215
|
+
}
|
|
216
|
+
return tallies
|
|
217
|
+
}
|
|
218
|
+
|
|
165
219
|
export async function replaceSearchTokensForRecord(
|
|
166
220
|
db: Kysely<any>,
|
|
167
221
|
params: BuildTokenOptions,
|
|
@@ -174,6 +228,52 @@ export async function replaceSearchTokensForRecord(
|
|
|
174
228
|
const tenantId = params.tenantId ?? null
|
|
175
229
|
const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)
|
|
176
230
|
|
|
231
|
+
// Same comparison #5402 gave the batch path, over the scope this path actually writes: the
|
|
232
|
+
// delete below is narrowed to the document's own `(entity_id, field)` pairs, so the comparison
|
|
233
|
+
// has to be narrowed the same way. Reading wider would let a token row under a field this
|
|
234
|
+
// document does not carry — the `cf_` twin the search module used to write, say — read as a
|
|
235
|
+
// difference forever and defeat the skip on every write.
|
|
236
|
+
const scopeTokenQuery = (query: any): any => {
|
|
237
|
+
let scoped = query
|
|
238
|
+
.where('entity_type' as any, '=', params.entityType)
|
|
239
|
+
.where(sql<boolean>`organization_id is not distinct from ${organizationId}`)
|
|
240
|
+
.where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)
|
|
241
|
+
.where('entity_id' as any, '=', String(params.recordId))
|
|
242
|
+
if (fieldPairs.length) {
|
|
243
|
+
scoped = scoped.where('field' as any, 'in', fieldPairs.map(([, field]) => field))
|
|
244
|
+
}
|
|
245
|
+
return scoped
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Read through the caller's transaction when there is one. A separate connection cannot see that
|
|
249
|
+
// transaction's own uncommitted writes, so it could report rows a pending delete has already
|
|
250
|
+
// removed and talk this call out of re-inserting them.
|
|
251
|
+
const reader = options?.trx ?? db
|
|
252
|
+
|
|
253
|
+
// Count probe first, as in the batch path: it returns one row whatever the table holds, so a
|
|
254
|
+
// record whose stored rows have run away (#4681) is settled without materializing them.
|
|
255
|
+
const storedCountRows = await scopeTokenQuery(
|
|
256
|
+
reader.selectFrom('search_tokens' as any).select(sql<number>`count(*)`.as('token_count') as any),
|
|
257
|
+
).execute()
|
|
258
|
+
const storedCount = Number((storedCountRows as any[])[0]?.token_count ?? 0)
|
|
259
|
+
|
|
260
|
+
let unchanged = storedCount === rows.length
|
|
261
|
+
if (unchanged && rows.length) {
|
|
262
|
+
const stored = await scopeTokenQuery(
|
|
263
|
+
reader.selectFrom('search_tokens' as any).select(['field' as any, 'token_hash' as any, 'token' as any]),
|
|
264
|
+
)
|
|
265
|
+
// Counts already match, so this cannot truncate. It bounds the read if a concurrent writer
|
|
266
|
+
// inserts between the probe and here; a truncated read compares as changed, which costs a
|
|
267
|
+
// rewrite rather than a wrong skip.
|
|
268
|
+
.limit(rows.length)
|
|
269
|
+
.execute()
|
|
270
|
+
unchanged = tallyEquals(tallyOf(rows), tallyOf(stored as any[]))
|
|
271
|
+
}
|
|
272
|
+
if (unchanged) {
|
|
273
|
+
debug('record.skip', { entityType: params.entityType, recordId: params.recordId, tokenCount: rows.length })
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
177
277
|
const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {
|
|
178
278
|
let deleteQuery = executor
|
|
179
279
|
.deleteFrom('search_tokens' as any)
|
|
@@ -223,49 +323,6 @@ export async function deleteSearchTokensForRecord(
|
|
|
223
323
|
.execute()
|
|
224
324
|
}
|
|
225
325
|
|
|
226
|
-
// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`
|
|
227
|
-
// would otherwise sign identically to field `a` + hash `b c`.
|
|
228
|
-
const SIGNATURE_SEPARATOR = String.fromCharCode(0)
|
|
229
|
-
|
|
230
|
-
// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a
|
|
231
|
-
// stored NULL has to sign the same as the `null` a freshly built row carries — otherwise every
|
|
232
|
-
// record compares as changed and the skip never fires.
|
|
233
|
-
function tokenSignature(row: { field?: unknown; token_hash?: unknown; token?: unknown }): string {
|
|
234
|
-
return [
|
|
235
|
-
String(row.field ?? ''),
|
|
236
|
-
String(row.token_hash ?? ''),
|
|
237
|
-
row.token == null ? '' : String(row.token),
|
|
238
|
-
].join(SIGNATURE_SEPARATOR)
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement
|
|
242
|
-
// defect, and a set comparison reads such a record as already correct and preserves the duplicates
|
|
243
|
-
// forever. Counting sends it through a full rewrite, which collapses them.
|
|
244
|
-
function tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {
|
|
245
|
-
const left = a ?? new Map<string, number>()
|
|
246
|
-
const right = b ?? new Map<string, number>()
|
|
247
|
-
if (left.size !== right.size) return false
|
|
248
|
-
for (const [key, count] of left.entries()) {
|
|
249
|
-
if (right.get(key) !== count) return false
|
|
250
|
-
}
|
|
251
|
-
return true
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function tallyTokenRows<TRow extends { field?: unknown; token_hash?: unknown; token?: unknown }>(
|
|
255
|
-
rows: Iterable<TRow>,
|
|
256
|
-
keyOf: (row: TRow) => string
|
|
257
|
-
): Map<string, Map<string, number>> {
|
|
258
|
-
const tallies = new Map<string, Map<string, number>>()
|
|
259
|
-
for (const row of rows) {
|
|
260
|
-
const key = keyOf(row)
|
|
261
|
-
const tally = tallies.get(key) ?? new Map<string, number>()
|
|
262
|
-
const signature = tokenSignature(row)
|
|
263
|
-
tally.set(signature, (tally.get(signature) ?? 0) + 1)
|
|
264
|
-
tallies.set(key, tally)
|
|
265
|
-
}
|
|
266
|
-
return tallies
|
|
267
|
-
}
|
|
268
|
-
|
|
269
326
|
export async function replaceSearchTokensForBatch(
|
|
270
327
|
db: Kysely<any>,
|
|
271
328
|
payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-nocheck
|
|
2
2
|
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { registerCommand } from "@open-mercato/shared/lib/commands";
|
|
6
7
|
import type { CommandHandler } from "@open-mercato/shared/lib/commands";
|
|
@@ -9197,6 +9198,88 @@ const createInvoiceCommand: CommandHandler<
|
|
|
9197
9198
|
},
|
|
9198
9199
|
};
|
|
9199
9200
|
|
|
9201
|
+
const invoiceUpdateChangeKeys = [
|
|
9202
|
+
"invoiceNumber",
|
|
9203
|
+
"statusEntryId",
|
|
9204
|
+
"status",
|
|
9205
|
+
"issueDate",
|
|
9206
|
+
"dueDate",
|
|
9207
|
+
"currencyCode",
|
|
9208
|
+
"subtotalNetAmount",
|
|
9209
|
+
"subtotalGrossAmount",
|
|
9210
|
+
"discountTotalAmount",
|
|
9211
|
+
"taxTotalAmount",
|
|
9212
|
+
"grandTotalNetAmount",
|
|
9213
|
+
"grandTotalGrossAmount",
|
|
9214
|
+
"paidTotalAmount",
|
|
9215
|
+
"outstandingAmount",
|
|
9216
|
+
"metadata",
|
|
9217
|
+
] as const;
|
|
9218
|
+
|
|
9219
|
+
function toAuditISOString(value: unknown): string | null {
|
|
9220
|
+
if (value instanceof Date) {
|
|
9221
|
+
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
|
9222
|
+
}
|
|
9223
|
+
if (typeof value === "string") {
|
|
9224
|
+
const parsed = new Date(value);
|
|
9225
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
9226
|
+
}
|
|
9227
|
+
return null;
|
|
9228
|
+
}
|
|
9229
|
+
|
|
9230
|
+
function auditValuesEqual(left: unknown, right: unknown): boolean {
|
|
9231
|
+
if (Object.is(left, right)) return true;
|
|
9232
|
+
if (left instanceof Date || right instanceof Date) {
|
|
9233
|
+
const leftIso = toAuditISOString(left);
|
|
9234
|
+
const rightIso = toAuditISOString(right);
|
|
9235
|
+
return leftIso !== null && rightIso !== null && leftIso === rightIso;
|
|
9236
|
+
}
|
|
9237
|
+
return isDeepStrictEqual(left, right);
|
|
9238
|
+
}
|
|
9239
|
+
|
|
9240
|
+
function buildAuditChanges(
|
|
9241
|
+
before: Record<string, unknown>,
|
|
9242
|
+
after: Record<string, unknown>,
|
|
9243
|
+
keys: readonly string[],
|
|
9244
|
+
): Record<string, { from: unknown; to: unknown }> {
|
|
9245
|
+
const changedKeys = keys.filter((key) => !auditValuesEqual(before[key], after[key]));
|
|
9246
|
+
return buildChanges(before, after, changedKeys);
|
|
9247
|
+
}
|
|
9248
|
+
|
|
9249
|
+
function selectInvoiceUpdateChangeKeys(
|
|
9250
|
+
input: z.infer<typeof invoiceUpdateSchema>,
|
|
9251
|
+
): Array<(typeof invoiceUpdateChangeKeys)[number]> {
|
|
9252
|
+
return invoiceUpdateChangeKeys.filter((key) =>
|
|
9253
|
+
key === "status"
|
|
9254
|
+
? input.statusEntryId !== undefined
|
|
9255
|
+
: input[key as keyof z.infer<typeof invoiceUpdateSchema>] !== undefined,
|
|
9256
|
+
);
|
|
9257
|
+
}
|
|
9258
|
+
|
|
9259
|
+
function applyInvoiceHeaderUpdate(
|
|
9260
|
+
invoice: SalesInvoice,
|
|
9261
|
+
input: z.infer<typeof invoiceUpdateSchema>,
|
|
9262
|
+
resolvedStatus: string | null | undefined,
|
|
9263
|
+
): void {
|
|
9264
|
+
if (input.invoiceNumber !== undefined) invoice.invoiceNumber = input.invoiceNumber;
|
|
9265
|
+
if (input.statusEntryId !== undefined) {
|
|
9266
|
+
invoice.statusEntryId = input.statusEntryId;
|
|
9267
|
+
invoice.status = resolvedStatus ?? null;
|
|
9268
|
+
}
|
|
9269
|
+
if (input.issueDate !== undefined) invoice.issueDate = input.issueDate;
|
|
9270
|
+
if (input.dueDate !== undefined) invoice.dueDate = input.dueDate;
|
|
9271
|
+
if (input.currencyCode !== undefined) invoice.currencyCode = input.currencyCode;
|
|
9272
|
+
if (input.subtotalNetAmount !== undefined) invoice.subtotalNetAmount = toNumericString(input.subtotalNetAmount);
|
|
9273
|
+
if (input.subtotalGrossAmount !== undefined) invoice.subtotalGrossAmount = toNumericString(input.subtotalGrossAmount);
|
|
9274
|
+
if (input.discountTotalAmount !== undefined) invoice.discountTotalAmount = toNumericString(input.discountTotalAmount);
|
|
9275
|
+
if (input.taxTotalAmount !== undefined) invoice.taxTotalAmount = toNumericString(input.taxTotalAmount);
|
|
9276
|
+
if (input.grandTotalNetAmount !== undefined) invoice.grandTotalNetAmount = toNumericString(input.grandTotalNetAmount);
|
|
9277
|
+
if (input.grandTotalGrossAmount !== undefined) invoice.grandTotalGrossAmount = toNumericString(input.grandTotalGrossAmount);
|
|
9278
|
+
if (input.paidTotalAmount !== undefined) invoice.paidTotalAmount = toNumericString(input.paidTotalAmount);
|
|
9279
|
+
if (input.outstandingAmount !== undefined) invoice.outstandingAmount = toNumericString(input.outstandingAmount);
|
|
9280
|
+
if (input.metadata !== undefined) invoice.metadata = input.metadata;
|
|
9281
|
+
}
|
|
9282
|
+
|
|
9200
9283
|
const updateInvoiceCommand: CommandHandler<
|
|
9201
9284
|
z.infer<typeof invoiceUpdateSchema>,
|
|
9202
9285
|
{ invoiceId: string }
|
|
@@ -9225,29 +9308,12 @@ const updateInvoiceCommand: CommandHandler<
|
|
|
9225
9308
|
deletedAt: null,
|
|
9226
9309
|
});
|
|
9227
9310
|
|
|
9228
|
-
|
|
9229
|
-
"invoiceNumber",
|
|
9230
|
-
"statusEntryId",
|
|
9231
|
-
"status",
|
|
9232
|
-
"issueDate",
|
|
9233
|
-
"dueDate",
|
|
9234
|
-
"currencyCode",
|
|
9235
|
-
"subtotalNetAmount",
|
|
9236
|
-
"subtotalGrossAmount",
|
|
9237
|
-
"discountTotalAmount",
|
|
9238
|
-
"taxTotalAmount",
|
|
9239
|
-
"grandTotalNetAmount",
|
|
9240
|
-
"grandTotalGrossAmount",
|
|
9241
|
-
"paidTotalAmount",
|
|
9242
|
-
"outstandingAmount",
|
|
9243
|
-
"metadata",
|
|
9244
|
-
]);
|
|
9245
|
-
|
|
9311
|
+
let resolvedStatus: string | null | undefined;
|
|
9246
9312
|
if (parsed.statusEntryId !== undefined) {
|
|
9247
|
-
|
|
9313
|
+
resolvedStatus = await resolveDictionaryEntryValue(em, parsed.statusEntryId ?? null, { tenantId: invoice.tenantId });
|
|
9248
9314
|
}
|
|
9249
9315
|
|
|
9250
|
-
|
|
9316
|
+
applyInvoiceHeaderUpdate(invoice, parsed, resolvedStatus);
|
|
9251
9317
|
invoice.updatedAt = new Date();
|
|
9252
9318
|
await em.flush();
|
|
9253
9319
|
|
|
@@ -9283,11 +9349,18 @@ const updateInvoiceCommand: CommandHandler<
|
|
|
9283
9349
|
const em = (ctx.container.resolve("em") as EntityManager).fork();
|
|
9284
9350
|
return loadInvoiceSnapshot(em, result.invoiceId);
|
|
9285
9351
|
},
|
|
9286
|
-
buildLog: async ({ result, snapshots }) => {
|
|
9352
|
+
buildLog: async ({ input, result, snapshots }) => {
|
|
9287
9353
|
const before = snapshots.before as InvoiceGraphSnapshot | undefined;
|
|
9288
9354
|
const after = snapshots.after as InvoiceGraphSnapshot | undefined;
|
|
9289
9355
|
if (!after) return null;
|
|
9290
9356
|
const { translate } = await resolveTranslations();
|
|
9357
|
+
const changes = before
|
|
9358
|
+
? buildAuditChanges(
|
|
9359
|
+
before.invoice as unknown as Record<string, unknown>,
|
|
9360
|
+
after.invoice as unknown as Record<string, unknown>,
|
|
9361
|
+
selectInvoiceUpdateChangeKeys(input),
|
|
9362
|
+
)
|
|
9363
|
+
: {};
|
|
9291
9364
|
return {
|
|
9292
9365
|
actionLabel: translate("sales.audit.invoices.update", "Update invoice"),
|
|
9293
9366
|
resourceKind: "sales.invoice",
|
|
@@ -9296,6 +9369,7 @@ const updateInvoiceCommand: CommandHandler<
|
|
|
9296
9369
|
organizationId: after.invoice.organizationId,
|
|
9297
9370
|
snapshotBefore: before,
|
|
9298
9371
|
snapshotAfter: after,
|
|
9372
|
+
changes: Object.keys(changes).length ? changes : null,
|
|
9299
9373
|
payload: {
|
|
9300
9374
|
undo: { before, after } satisfies InvoiceUndoPayload,
|
|
9301
9375
|
},
|
|
@@ -9704,6 +9778,52 @@ const createCreditMemoCommand: CommandHandler<
|
|
|
9704
9778
|
},
|
|
9705
9779
|
};
|
|
9706
9780
|
|
|
9781
|
+
const creditMemoUpdateChangeKeys = [
|
|
9782
|
+
"creditMemoNumber",
|
|
9783
|
+
"statusEntryId",
|
|
9784
|
+
"status",
|
|
9785
|
+
"reason",
|
|
9786
|
+
"issueDate",
|
|
9787
|
+
"currencyCode",
|
|
9788
|
+
"subtotalNetAmount",
|
|
9789
|
+
"subtotalGrossAmount",
|
|
9790
|
+
"taxTotalAmount",
|
|
9791
|
+
"grandTotalNetAmount",
|
|
9792
|
+
"grandTotalGrossAmount",
|
|
9793
|
+
"metadata",
|
|
9794
|
+
] as const;
|
|
9795
|
+
|
|
9796
|
+
function selectCreditMemoUpdateChangeKeys(
|
|
9797
|
+
input: z.infer<typeof creditMemoUpdateSchema>,
|
|
9798
|
+
): Array<(typeof creditMemoUpdateChangeKeys)[number]> {
|
|
9799
|
+
return creditMemoUpdateChangeKeys.filter((key) =>
|
|
9800
|
+
key === "status"
|
|
9801
|
+
? input.statusEntryId !== undefined
|
|
9802
|
+
: input[key as keyof z.infer<typeof creditMemoUpdateSchema>] !== undefined,
|
|
9803
|
+
);
|
|
9804
|
+
}
|
|
9805
|
+
|
|
9806
|
+
function applyCreditMemoHeaderUpdate(
|
|
9807
|
+
creditMemo: SalesCreditMemo,
|
|
9808
|
+
input: z.infer<typeof creditMemoUpdateSchema>,
|
|
9809
|
+
resolvedStatus: string | null | undefined,
|
|
9810
|
+
): void {
|
|
9811
|
+
if (input.creditMemoNumber !== undefined) creditMemo.creditMemoNumber = input.creditMemoNumber;
|
|
9812
|
+
if (input.statusEntryId !== undefined) {
|
|
9813
|
+
creditMemo.statusEntryId = input.statusEntryId;
|
|
9814
|
+
creditMemo.status = resolvedStatus ?? null;
|
|
9815
|
+
}
|
|
9816
|
+
if (input.reason !== undefined) creditMemo.reason = input.reason;
|
|
9817
|
+
if (input.issueDate !== undefined) creditMemo.issueDate = input.issueDate;
|
|
9818
|
+
if (input.currencyCode !== undefined) creditMemo.currencyCode = input.currencyCode;
|
|
9819
|
+
if (input.subtotalNetAmount !== undefined) creditMemo.subtotalNetAmount = toNumericString(input.subtotalNetAmount);
|
|
9820
|
+
if (input.subtotalGrossAmount !== undefined) creditMemo.subtotalGrossAmount = toNumericString(input.subtotalGrossAmount);
|
|
9821
|
+
if (input.taxTotalAmount !== undefined) creditMemo.taxTotalAmount = toNumericString(input.taxTotalAmount);
|
|
9822
|
+
if (input.grandTotalNetAmount !== undefined) creditMemo.grandTotalNetAmount = toNumericString(input.grandTotalNetAmount);
|
|
9823
|
+
if (input.grandTotalGrossAmount !== undefined) creditMemo.grandTotalGrossAmount = toNumericString(input.grandTotalGrossAmount);
|
|
9824
|
+
if (input.metadata !== undefined) creditMemo.metadata = input.metadata;
|
|
9825
|
+
}
|
|
9826
|
+
|
|
9707
9827
|
const updateCreditMemoCommand: CommandHandler<
|
|
9708
9828
|
z.infer<typeof creditMemoUpdateSchema>,
|
|
9709
9829
|
{ creditMemoId: string }
|
|
@@ -9732,26 +9852,12 @@ const updateCreditMemoCommand: CommandHandler<
|
|
|
9732
9852
|
deletedAt: null,
|
|
9733
9853
|
});
|
|
9734
9854
|
|
|
9735
|
-
|
|
9736
|
-
"creditMemoNumber",
|
|
9737
|
-
"statusEntryId",
|
|
9738
|
-
"status",
|
|
9739
|
-
"reason",
|
|
9740
|
-
"issueDate",
|
|
9741
|
-
"currencyCode",
|
|
9742
|
-
"subtotalNetAmount",
|
|
9743
|
-
"subtotalGrossAmount",
|
|
9744
|
-
"taxTotalAmount",
|
|
9745
|
-
"grandTotalNetAmount",
|
|
9746
|
-
"grandTotalGrossAmount",
|
|
9747
|
-
"metadata",
|
|
9748
|
-
]);
|
|
9749
|
-
|
|
9855
|
+
let resolvedStatus: string | null | undefined;
|
|
9750
9856
|
if (parsed.statusEntryId !== undefined) {
|
|
9751
|
-
|
|
9857
|
+
resolvedStatus = await resolveDictionaryEntryValue(em, parsed.statusEntryId ?? null, { tenantId: creditMemo.tenantId });
|
|
9752
9858
|
}
|
|
9753
9859
|
|
|
9754
|
-
|
|
9860
|
+
applyCreditMemoHeaderUpdate(creditMemo, parsed, resolvedStatus);
|
|
9755
9861
|
creditMemo.updatedAt = new Date();
|
|
9756
9862
|
await em.flush();
|
|
9757
9863
|
|
|
@@ -9787,11 +9893,18 @@ const updateCreditMemoCommand: CommandHandler<
|
|
|
9787
9893
|
const em = (ctx.container.resolve("em") as EntityManager).fork();
|
|
9788
9894
|
return loadCreditMemoSnapshot(em, result.creditMemoId);
|
|
9789
9895
|
},
|
|
9790
|
-
buildLog: async ({ result, snapshots }) => {
|
|
9896
|
+
buildLog: async ({ input, result, snapshots }) => {
|
|
9791
9897
|
const before = snapshots.before as CreditMemoGraphSnapshot | undefined;
|
|
9792
9898
|
const after = snapshots.after as CreditMemoGraphSnapshot | undefined;
|
|
9793
9899
|
if (!after) return null;
|
|
9794
9900
|
const { translate } = await resolveTranslations();
|
|
9901
|
+
const changes = before
|
|
9902
|
+
? buildAuditChanges(
|
|
9903
|
+
before.creditMemo as unknown as Record<string, unknown>,
|
|
9904
|
+
after.creditMemo as unknown as Record<string, unknown>,
|
|
9905
|
+
selectCreditMemoUpdateChangeKeys(input),
|
|
9906
|
+
)
|
|
9907
|
+
: {};
|
|
9795
9908
|
return {
|
|
9796
9909
|
actionLabel: translate("sales.audit.credit_memos.update", "Update credit memo"),
|
|
9797
9910
|
resourceKind: "sales.credit_memo",
|
|
@@ -9800,6 +9913,7 @@ const updateCreditMemoCommand: CommandHandler<
|
|
|
9800
9913
|
organizationId: after.creditMemo.organizationId,
|
|
9801
9914
|
snapshotBefore: before,
|
|
9802
9915
|
snapshotAfter: after,
|
|
9916
|
+
changes: Object.keys(changes).length ? changes : null,
|
|
9803
9917
|
payload: {
|
|
9804
9918
|
undo: { before, after } satisfies CreditMemoUndoPayload,
|
|
9805
9919
|
},
|
|
@@ -4,6 +4,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
4
4
|
{
|
|
5
5
|
type: 'warranty_claims.claim.submitted',
|
|
6
6
|
module: 'warranty_claims',
|
|
7
|
+
channels: ['in_app', 'email'],
|
|
7
8
|
titleKey: 'warranty_claims.notifications.submitted.title',
|
|
8
9
|
bodyKey: 'warranty_claims.notifications.submitted.body',
|
|
9
10
|
icon: 'clipboard-check',
|
|
@@ -23,6 +24,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
23
24
|
{
|
|
24
25
|
type: 'warranty_claims.claim.assigned',
|
|
25
26
|
module: 'warranty_claims',
|
|
27
|
+
channels: ['in_app', 'email'],
|
|
26
28
|
titleKey: 'warranty_claims.notifications.assigned.title',
|
|
27
29
|
bodyKey: 'warranty_claims.notifications.assigned.body',
|
|
28
30
|
icon: 'user-check',
|
|
@@ -42,6 +44,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
42
44
|
{
|
|
43
45
|
type: 'warranty_claims.claim.status_changed',
|
|
44
46
|
module: 'warranty_claims',
|
|
47
|
+
channels: ['in_app', 'email'],
|
|
45
48
|
titleKey: 'warranty_claims.notifications.statusChanged.title',
|
|
46
49
|
bodyKey: 'warranty_claims.notifications.statusChanged.body',
|
|
47
50
|
icon: 'refresh-cw',
|
|
@@ -61,6 +64,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
61
64
|
{
|
|
62
65
|
type: 'warranty_claims.claim.escalated',
|
|
63
66
|
module: 'warranty_claims',
|
|
67
|
+
channels: ['in_app', 'email'],
|
|
64
68
|
titleKey: 'warranty_claims.notifications.escalated.title',
|
|
65
69
|
bodyKey: 'warranty_claims.notifications.escalated.body',
|
|
66
70
|
icon: 'alarm-clock',
|
|
@@ -80,6 +84,7 @@ export const notificationTypes: NotificationTypeDefinition[] = [
|
|
|
80
84
|
{
|
|
81
85
|
type: 'warranty_claims.claim.customer_replied',
|
|
82
86
|
module: 'warranty_claims',
|
|
87
|
+
channels: ['in_app', 'email'],
|
|
83
88
|
titleKey: 'warranty_claims.notifications.customerReplied.title',
|
|
84
89
|
bodyKey: 'warranty_claims.notifications.customerReplied.body',
|
|
85
90
|
icon: 'message-square-reply',
|