@zeniai/client-epic-state 5.1.56 → 5.1.57-betaDI0
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/lib/coreEpics.js +3 -2
- package/lib/entity/accountRecon/accountReconPayload.d.ts +1 -1
- package/lib/entity/accountRecon/accountReconReducer.js +15 -1
- package/lib/entity/chargeCard/chargeCard.d.ts +1 -1
- package/lib/entity/notificationRegistry/notificationRegistryPayload.d.ts +18 -0
- package/lib/entity/notificationRegistry/notificationRegistryPayload.js +28 -0
- package/lib/entity/notificationRegistry/notificationRegistryReducer.d.ts +6 -0
- package/lib/entity/notificationRegistry/notificationRegistryReducer.js +26 -0
- package/lib/entity/notificationRegistry/notificationRegistrySelector.d.ts +2 -0
- package/lib/entity/notificationRegistry/notificationRegistrySelector.js +10 -0
- package/lib/entity/notificationRegistry/notificationRegistryState.d.ts +31 -0
- package/lib/entity/notificationRegistry/notificationRegistryState.js +10 -0
- package/lib/entity/tenant/clearAllEpic.d.ts +3 -1
- package/lib/entity/tenant/clearAllEpic.js +4 -0
- package/lib/epic.d.ts +2 -1
- package/lib/epic.js +2 -1
- package/lib/esm/coreEpics.js +3 -2
- package/lib/esm/entity/accountRecon/accountReconReducer.js +15 -1
- package/lib/esm/entity/notificationRegistry/notificationRegistryPayload.js +24 -0
- package/lib/esm/entity/notificationRegistry/notificationRegistryReducer.js +22 -0
- package/lib/esm/entity/notificationRegistry/notificationRegistrySelector.js +6 -0
- package/lib/esm/entity/notificationRegistry/notificationRegistryState.js +5 -0
- package/lib/esm/entity/tenant/clearAllEpic.js +4 -0
- package/lib/esm/epic.js +2 -1
- package/lib/esm/index.js +6 -2
- package/lib/esm/reducer.js +6 -0
- package/lib/esm/view/expenseAutomationView/selectors/statementParseViewSelector.js +0 -224
- package/lib/esm/view/notificationPreferencesView/epics/saveNotificationPreferencesEpic.js +103 -0
- package/lib/esm/view/notificationPreferencesView/notificationPreferencesEndpoint.js +5 -0
- package/lib/esm/view/notificationPreferencesView/notificationPreferencesViewPayload.js +36 -0
- package/lib/esm/view/notificationPreferencesView/notificationPreferencesViewReducer.js +222 -0
- package/lib/esm/view/notificationPreferencesView/notificationPreferencesViewSelector.js +25 -0
- package/lib/esm/view/notificationPreferencesView/notificationPreferencesViewState.js +4 -0
- package/lib/esm/view/settingsView/epic/fetchNotificationSettingsEpic.js +21 -2
- package/lib/esm/view/settingsView/epic/saveNotificationSettingsEpic.js +2 -1
- package/lib/esm/view/settingsView/settingsViewHelpers.js +3 -2
- package/lib/index.d.ts +9 -4
- package/lib/index.js +52 -42
- package/lib/reducer.d.ts +6 -0
- package/lib/reducer.js +6 -0
- package/lib/view/common/recurringViewHelper.d.ts +2 -2
- package/lib/view/expenseAutomationView/selectors/statementParseViewSelector.d.ts +1 -15
- package/lib/view/expenseAutomationView/selectors/statementParseViewSelector.js +1 -233
- package/lib/view/expenseAutomationView/types/reconciliationViewState.d.ts +7 -44
- package/lib/view/notificationPreferencesView/epics/saveNotificationPreferencesEpic.d.ts +34 -0
- package/lib/view/notificationPreferencesView/epics/saveNotificationPreferencesEpic.js +107 -0
- package/lib/view/notificationPreferencesView/notificationPreferencesEndpoint.d.ts +2 -0
- package/lib/view/notificationPreferencesView/notificationPreferencesEndpoint.js +9 -0
- package/lib/view/notificationPreferencesView/notificationPreferencesViewPayload.d.ts +7 -0
- package/lib/view/notificationPreferencesView/notificationPreferencesViewPayload.js +41 -0
- package/lib/view/notificationPreferencesView/notificationPreferencesViewReducer.d.ts +30 -0
- package/lib/view/notificationPreferencesView/notificationPreferencesViewReducer.js +226 -0
- package/lib/view/notificationPreferencesView/notificationPreferencesViewSelector.d.ts +7 -0
- package/lib/view/notificationPreferencesView/notificationPreferencesViewSelector.js +32 -0
- package/lib/view/notificationPreferencesView/notificationPreferencesViewState.d.ts +14 -0
- package/lib/view/notificationPreferencesView/notificationPreferencesViewState.js +8 -0
- package/lib/view/settingsView/epic/fetchNotificationSettingsEpic.d.ts +9 -1
- package/lib/view/settingsView/epic/fetchNotificationSettingsEpic.js +21 -2
- package/lib/view/settingsView/epic/saveNotificationSettingsEpic.js +2 -1
- package/lib/view/settingsView/settingsViewHelpers.d.ts +1 -1
- package/lib/view/settingsView/settingsViewHelpers.js +3 -2
- package/lib/view/settingsView/settingsViewPayload.d.ts +4 -0
- package/lib/view/settingsView/settingsViewState.d.ts +1 -1
- package/package.json +1 -1
|
@@ -64,117 +64,6 @@ export const getDateFilteredDeletedTransactionIds = (allParsedTransactions, stat
|
|
|
64
64
|
!isStatementTransactionInPeriod(transaction.transactionDate, statementPeriod.statementStartDate, statementPeriod.statementEndDate))
|
|
65
65
|
.map((transaction) => transaction.statementTransactionId);
|
|
66
66
|
};
|
|
67
|
-
const roundCurrency = (value) => Math.round(value * 100) / 100;
|
|
68
|
-
const toApiDateString = (value) => {
|
|
69
|
-
if (value == null || value === '') {
|
|
70
|
-
return '';
|
|
71
|
-
}
|
|
72
|
-
return date(value).format('YYYY-MM-DD');
|
|
73
|
-
};
|
|
74
|
-
const computeStatementTransactionTotals = (transactions) => {
|
|
75
|
-
const totalDeposits = roundCurrency(transactions
|
|
76
|
-
.filter((txn) => txn.transactionDirection === 'credit')
|
|
77
|
-
.reduce((sum, txn) => sum + Math.abs(txn.amount ?? 0), 0));
|
|
78
|
-
const totalPayments = roundCurrency(transactions
|
|
79
|
-
.filter((txn) => txn.transactionDirection === 'debit')
|
|
80
|
-
.reduce((sum, txn) => sum + Math.abs(txn.amount ?? 0), 0));
|
|
81
|
-
return { totalDeposits, totalPayments };
|
|
82
|
-
};
|
|
83
|
-
const computeStatementClosingBalance = (openingBalance, totalDeposits, totalPayments, accountType) => roundCurrency(accountType === 'credit_card'
|
|
84
|
-
? openingBalance + totalPayments - totalDeposits
|
|
85
|
-
: openingBalance + totalDeposits - totalPayments);
|
|
86
|
-
const filterFormTransactionsInPeriod = (transactions, statementStartDate, statementEndDate) => {
|
|
87
|
-
if (statementStartDate === '' || statementEndDate === '') {
|
|
88
|
-
return transactions;
|
|
89
|
-
}
|
|
90
|
-
const periodStart = date(statementStartDate).startOf('day');
|
|
91
|
-
const periodEnd = date(statementEndDate).startOf('day');
|
|
92
|
-
return transactions.filter((txn) => {
|
|
93
|
-
if (txn.transactionDate === '') {
|
|
94
|
-
return true;
|
|
95
|
-
}
|
|
96
|
-
const txnDate = date(txn.transactionDate).startOf('day');
|
|
97
|
-
return (txnDate.isSameOrAfter(periodStart, 'day') &&
|
|
98
|
-
txnDate.isSameOrBefore(periodEnd, 'day'));
|
|
99
|
-
});
|
|
100
|
-
};
|
|
101
|
-
export const isStatementParseTransactionFieldChange = (changedField) => changedField === 'transactions' ||
|
|
102
|
-
changedField?.startsWith('transactions.') === true;
|
|
103
|
-
const isStatementParseBalanceAffectingTransactionFieldChange = (changedField) => {
|
|
104
|
-
if (changedField === 'transactions') {
|
|
105
|
-
return true;
|
|
106
|
-
}
|
|
107
|
-
if (changedField?.startsWith('transactions.') !== true) {
|
|
108
|
-
return false;
|
|
109
|
-
}
|
|
110
|
-
return !changedField.endsWith('.transactionMemo');
|
|
111
|
-
};
|
|
112
|
-
export const isStatementParseManualBalanceField = (field) => field === 'totalDeposits' ||
|
|
113
|
-
field === 'totalPayments' ||
|
|
114
|
-
field === 'closingBalance';
|
|
115
|
-
export const shouldRecalculateStatementParseBalances = (changedField) => isStatementParseBalanceAffectingTransactionFieldChange(changedField) ||
|
|
116
|
-
changedField === 'statementStartDate' ||
|
|
117
|
-
changedField === 'statementEndDate';
|
|
118
|
-
export const resolveStatementParseBalanceFields = (formView, changedField, accountType) => {
|
|
119
|
-
const inPeriodTransactions = filterFormTransactionsInPeriod(formView.transactions, formView.statementStartDate, formView.statementEndDate);
|
|
120
|
-
const { totalDeposits, totalPayments } = computeStatementTransactionTotals(inPeriodTransactions);
|
|
121
|
-
if (!shouldRecalculateStatementParseBalances(changedField)) {
|
|
122
|
-
return {
|
|
123
|
-
openingBalance: formView.openingBalance,
|
|
124
|
-
closingBalance: formView.closingBalance,
|
|
125
|
-
totalDeposits: formView.totalDeposits,
|
|
126
|
-
totalPayments: formView.totalPayments,
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
return {
|
|
130
|
-
openingBalance: formView.openingBalance,
|
|
131
|
-
totalDeposits,
|
|
132
|
-
totalPayments,
|
|
133
|
-
closingBalance: computeStatementClosingBalance(formView.openingBalance, totalDeposits, totalPayments, accountType),
|
|
134
|
-
};
|
|
135
|
-
};
|
|
136
|
-
export const areStatementParseBalancesConsistent = (formView, accountType) => {
|
|
137
|
-
// Validate the four balance fields against each other (what the user sees),
|
|
138
|
-
// not against recomputed transaction totals. Manual deposits/payments edits
|
|
139
|
-
// are allowed as long as closing matches the account-type formula.
|
|
140
|
-
return (roundCurrency(formView.closingBalance) ===
|
|
141
|
-
computeStatementClosingBalance(formView.openingBalance, formView.totalDeposits, formView.totalPayments, accountType));
|
|
142
|
-
};
|
|
143
|
-
const mapParsedTransactionToFormRow = (transaction, index) => ({
|
|
144
|
-
id: transaction.statementTransactionId || `parsed-${index}`,
|
|
145
|
-
transactionDate: toApiDateString(transaction.transactionDate),
|
|
146
|
-
transactionMemo: transaction.transactionMemo,
|
|
147
|
-
amount: transaction.amount,
|
|
148
|
-
transactionDirection: transaction.transactionDirection,
|
|
149
|
-
statementTransactionId: transaction.statementTransactionId,
|
|
150
|
-
});
|
|
151
|
-
const mapLocalTransactionToFormRow = (transaction, id) => ({
|
|
152
|
-
id,
|
|
153
|
-
transactionDate: toApiDateString(transaction.transactionDate),
|
|
154
|
-
transactionMemo: transaction.transactionMemo,
|
|
155
|
-
amount: transaction.amount,
|
|
156
|
-
transactionDirection: transaction.transactionDirection,
|
|
157
|
-
statementTransactionId: transaction.statementTransactionId,
|
|
158
|
-
});
|
|
159
|
-
const buildStatementParseFormTransactions = (originalTransactions, localData) => {
|
|
160
|
-
const deletedIdSet = new Set(localData.statementTransactions?.deletedIds ?? []);
|
|
161
|
-
const updatedById = new Map((localData.statementTransactions?.updated ?? [])
|
|
162
|
-
.filter((transaction) => transaction.statementTransactionId != null)
|
|
163
|
-
.map((transaction) => [
|
|
164
|
-
transaction.statementTransactionId,
|
|
165
|
-
transaction,
|
|
166
|
-
]));
|
|
167
|
-
const persistedTransactions = originalTransactions
|
|
168
|
-
.filter((transaction) => !deletedIdSet.has(transaction.statementTransactionId))
|
|
169
|
-
.map((transaction, index) => {
|
|
170
|
-
const updated = updatedById.get(transaction.statementTransactionId);
|
|
171
|
-
return updated != null
|
|
172
|
-
? mapLocalTransactionToFormRow(updated, transaction.statementTransactionId)
|
|
173
|
-
: mapParsedTransactionToFormRow(transaction, index);
|
|
174
|
-
});
|
|
175
|
-
const addedTransactions = (localData.statementTransactions?.added ?? []).map((transaction, index) => mapLocalTransactionToFormRow(transaction, `added-${index}`));
|
|
176
|
-
return [...persistedTransactions, ...addedTransactions];
|
|
177
|
-
};
|
|
178
67
|
export const mergeStatementParseDeletedTransactionIds = (originalTransactions, statementPeriod, currentTransactionIds) => {
|
|
179
68
|
const dateFilteredDeletedIds = getDateFilteredDeletedTransactionIds(originalTransactions, statementPeriod);
|
|
180
69
|
const dateFilteredDeletedIdSet = new Set(dateFilteredDeletedIds);
|
|
@@ -185,116 +74,3 @@ export const mergeStatementParseDeletedTransactionIds = (originalTransactions, s
|
|
|
185
74
|
.map((transaction) => transaction.statementTransactionId);
|
|
186
75
|
return [...new Set([...userDeletedIds, ...dateFilteredDeletedIds])];
|
|
187
76
|
};
|
|
188
|
-
const resolveStatementMetaDates = (parsedStatementMeta, localMeta) => ({
|
|
189
|
-
statementStartDate: localMeta?.statementStartDate != null && localMeta.statementStartDate !== ''
|
|
190
|
-
? localMeta.statementStartDate
|
|
191
|
-
: (parsedStatementMeta?.statementStartDate ?? ''),
|
|
192
|
-
statementEndDate: localMeta?.statementEndDate != null && localMeta.statementEndDate !== ''
|
|
193
|
-
? localMeta.statementEndDate
|
|
194
|
-
: (parsedStatementMeta?.statementEndDate ?? ''),
|
|
195
|
-
});
|
|
196
|
-
export const getStatementParseFormView = ({ accountType, parsedStatementMeta, originalTransactions, statementUpdateLocalData, }) => {
|
|
197
|
-
const transactions = statementUpdateLocalData != null
|
|
198
|
-
? buildStatementParseFormTransactions(originalTransactions, statementUpdateLocalData)
|
|
199
|
-
: originalTransactions.map(mapParsedTransactionToFormRow);
|
|
200
|
-
const localMeta = statementUpdateLocalData?.statementMeta;
|
|
201
|
-
const { statementStartDate, statementEndDate } = resolveStatementMetaDates(parsedStatementMeta, localMeta);
|
|
202
|
-
const { totalDeposits, totalPayments } = computeStatementTransactionTotals(filterFormTransactionsInPeriod(transactions, statementStartDate, statementEndDate));
|
|
203
|
-
const openingBalance = localMeta?.openingBalance ?? parsedStatementMeta?.openingBalance ?? 0;
|
|
204
|
-
const computedClosingBalance = computeStatementClosingBalance(openingBalance, totalDeposits, totalPayments, accountType);
|
|
205
|
-
return {
|
|
206
|
-
statementStartDate,
|
|
207
|
-
statementEndDate,
|
|
208
|
-
openingBalance,
|
|
209
|
-
closingBalance: localMeta?.closingBalance ?? computedClosingBalance,
|
|
210
|
-
totalDeposits: localMeta?.totalDeposits ?? totalDeposits,
|
|
211
|
-
totalPayments: localMeta?.totalPayments ?? totalPayments,
|
|
212
|
-
transactions: filterFormTransactionsInPeriod(transactions, statementStartDate, statementEndDate),
|
|
213
|
-
};
|
|
214
|
-
};
|
|
215
|
-
export const toStatementUpdateLocalDataFromFormView = ({ accountType, formView, parsedStatementMeta, originalTransactions, changedField, }) => {
|
|
216
|
-
const balanceFields = resolveStatementParseBalanceFields(formView, changedField, accountType);
|
|
217
|
-
const currentTransactionIds = new Set(formView.transactions
|
|
218
|
-
.map((transaction) => transaction.statementTransactionId)
|
|
219
|
-
.filter((id) => id != null && id !== ''));
|
|
220
|
-
const added = [];
|
|
221
|
-
const updated = [];
|
|
222
|
-
formView.transactions.forEach((transaction) => {
|
|
223
|
-
const transactionDate = toApiDateString(transaction.transactionDate);
|
|
224
|
-
if (transaction.statementTransactionId == null) {
|
|
225
|
-
if (transaction.transactionDirection != null) {
|
|
226
|
-
added.push({
|
|
227
|
-
amount: transaction.amount,
|
|
228
|
-
transactionDate,
|
|
229
|
-
transactionDirection: transaction.transactionDirection,
|
|
230
|
-
transactionMemo: transaction.transactionMemo,
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
const original = originalTransactions.find((candidate) => candidate.statementTransactionId === transaction.statementTransactionId);
|
|
236
|
-
if (original != null &&
|
|
237
|
-
(original.amount !== transaction.amount ||
|
|
238
|
-
toApiDateString(original.transactionDate) !== transactionDate ||
|
|
239
|
-
original.transactionMemo !== transaction.transactionMemo)) {
|
|
240
|
-
updated.push({
|
|
241
|
-
amount: transaction.amount,
|
|
242
|
-
transactionDate,
|
|
243
|
-
transactionDirection: transaction.transactionDirection,
|
|
244
|
-
transactionMemo: transaction.transactionMemo,
|
|
245
|
-
statementTransactionId: transaction.statementTransactionId,
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
});
|
|
249
|
-
const statementPeriod = {
|
|
250
|
-
statementStartDate: toApiDateString(formView.statementStartDate),
|
|
251
|
-
statementEndDate: toApiDateString(formView.statementEndDate),
|
|
252
|
-
};
|
|
253
|
-
const deletedIds = mergeStatementParseDeletedTransactionIds(originalTransactions, statementPeriod, currentTransactionIds);
|
|
254
|
-
const deletedIdSet = new Set(deletedIds);
|
|
255
|
-
const inPeriodAdded = filterFormTransactionsInPeriod(added, statementPeriod.statementStartDate, statementPeriod.statementEndDate);
|
|
256
|
-
const inPeriodUpdated = updated.filter((transaction) => transaction.statementTransactionId != null &&
|
|
257
|
-
!deletedIdSet.has(transaction.statementTransactionId));
|
|
258
|
-
return {
|
|
259
|
-
statementMeta: {
|
|
260
|
-
closingBalance: balanceFields.closingBalance,
|
|
261
|
-
openingBalance: balanceFields.openingBalance,
|
|
262
|
-
statementDataStatus: parsedStatementMeta?.statementDataStatus ?? {
|
|
263
|
-
code: '',
|
|
264
|
-
label: '',
|
|
265
|
-
},
|
|
266
|
-
statementEndDate: statementPeriod.statementEndDate,
|
|
267
|
-
statementStartDate: statementPeriod.statementStartDate,
|
|
268
|
-
statementStatus: parsedStatementMeta?.statementStatus ?? {
|
|
269
|
-
code: '',
|
|
270
|
-
label: '',
|
|
271
|
-
},
|
|
272
|
-
statementUploadId: parsedStatementMeta?.statementUploadId ?? '',
|
|
273
|
-
totalDeposits: balanceFields.totalDeposits,
|
|
274
|
-
totalPayments: balanceFields.totalPayments,
|
|
275
|
-
},
|
|
276
|
-
statementTransactions: {
|
|
277
|
-
added: inPeriodAdded,
|
|
278
|
-
deletedIds,
|
|
279
|
-
updated: inPeriodUpdated,
|
|
280
|
-
},
|
|
281
|
-
};
|
|
282
|
-
};
|
|
283
|
-
export const applyStatementParseFormChange = ({ accountType, formView, changedField, parsedStatementMeta, originalTransactions, }) => {
|
|
284
|
-
const balanceFields = resolveStatementParseBalanceFields(formView, changedField, accountType);
|
|
285
|
-
const shouldRecalculate = shouldRecalculateStatementParseBalances(changedField);
|
|
286
|
-
const mergedFormView = shouldRecalculate
|
|
287
|
-
? { ...formView, ...balanceFields }
|
|
288
|
-
: formView;
|
|
289
|
-
return {
|
|
290
|
-
didRecalculateBalances: shouldRecalculate,
|
|
291
|
-
formView: mergedFormView,
|
|
292
|
-
localData: toStatementUpdateLocalDataFromFormView({
|
|
293
|
-
accountType,
|
|
294
|
-
formView: mergedFormView,
|
|
295
|
-
parsedStatementMeta,
|
|
296
|
-
originalTransactions,
|
|
297
|
-
changedField,
|
|
298
|
-
}),
|
|
299
|
-
};
|
|
300
|
-
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { EMPTY, concat, of } from 'rxjs';
|
|
2
|
+
import { catchError, debounceTime, filter, finalize, mergeMap, switchMap, withLatestFrom, } from 'rxjs/operators';
|
|
3
|
+
import { openSnackbar } from '../../../entity/snackbar/snackbarReducer';
|
|
4
|
+
import { createZeniAPIStatus, isSuccessResponse, } from '../../../responsePayload';
|
|
5
|
+
import { notificationPreferencesUrl } from '../notificationPreferencesEndpoint';
|
|
6
|
+
import { mapPreferencesToPayload, } from '../notificationPreferencesViewPayload';
|
|
7
|
+
import { saveNotificationPreferences, saveNotificationPreferencesFailure, saveNotificationPreferencesSuccess, setGroupFrequency, toggleEventChannel, } from '../notificationPreferencesViewReducer';
|
|
8
|
+
import { getNotificationLocalOverrides } from '../notificationPreferencesViewSelector';
|
|
9
|
+
const DEBOUNCE_MS = 300;
|
|
10
|
+
export const saveNotificationPreferencesEpic = (actions$, state$, zeniAPI) => actions$.pipe(filter((action) => toggleEventChannel.match(action) || setGroupFrequency.match(action)),
|
|
11
|
+
// Coalesce bursts of toggles into a single PUT. Trade-off: rapid clickers
|
|
12
|
+
// defer saves until they pause. Consider a max-wait wrapper if this
|
|
13
|
+
// becomes a UX problem.
|
|
14
|
+
debounceTime(DEBOUNCE_MS), withLatestFrom(state$),
|
|
15
|
+
// `switchMap` unsubscribes from any in-flight PUT observable when a new
|
|
16
|
+
// debounced batch fires. The prior HTTP request has already left the
|
|
17
|
+
// client — the network call is NOT cancelled — but its response is
|
|
18
|
+
// ignored, so stale success/failure actions cannot clobber the newer
|
|
19
|
+
// batch's state. Server-side ordering is arrival-order LWW.
|
|
20
|
+
switchMap(([, state]) => {
|
|
21
|
+
const overrides = getNotificationLocalOverrides(state);
|
|
22
|
+
const preferencesPatch = mapPreferencesToPayload(overrides);
|
|
23
|
+
if (Object.keys(preferencesPatch).length === 0) {
|
|
24
|
+
// No diff → do not dispatch a synthetic success (that would wipe
|
|
25
|
+
// stored preferences via the reducer). Simply no-op.
|
|
26
|
+
return EMPTY;
|
|
27
|
+
}
|
|
28
|
+
// Snapshot the Cancel epoch at PUT-dispatch time. Every Cancel bumps
|
|
29
|
+
// `cancelEpoch`; the reducer compares the captured value on Success/
|
|
30
|
+
// Failure and skips the merge when the value has advanced (user
|
|
31
|
+
// pressed Cancel since this save started). See Cursor Bugbot 4aa021f7
|
|
32
|
+
// — the earlier counter model failed when `switchMap` abandoned an
|
|
33
|
+
// in-flight save's response (Success/Failure never dispatched, so
|
|
34
|
+
// the counter never drained and future saves got their merge dropped).
|
|
35
|
+
const cancelEpochAtDispatch = state.notificationPreferencesViewState.cancelEpoch;
|
|
36
|
+
const body = { task_checklist_preferences: preferencesPatch };
|
|
37
|
+
// Suppress the error snackbar when the response lands AFTER the user
|
|
38
|
+
// pressed Cancel — the Failure reducer already swallows the state
|
|
39
|
+
// update, so the toast would be a stale error for a save the user
|
|
40
|
+
// abandoned. `StateObservable#value` reads the current cancelEpoch at
|
|
41
|
+
// response time (not at dispatch time — that's what
|
|
42
|
+
// `cancelEpochAtDispatch` captures). Cursor Bugbot da2f4fe2 + Nikunj.
|
|
43
|
+
const errorSnackbar = openSnackbar({
|
|
44
|
+
messageSection: 'notification_settings_saved',
|
|
45
|
+
messageText: 'failed',
|
|
46
|
+
type: 'error',
|
|
47
|
+
});
|
|
48
|
+
const isCancelledSince = () => cancelEpochAtDispatch <
|
|
49
|
+
state$.value.notificationPreferencesViewState.cancelEpoch;
|
|
50
|
+
// Abort the in-flight HTTP request when `switchMap` disposes this
|
|
51
|
+
// observable (new debounced batch fires, tenant switch, unmount).
|
|
52
|
+
// Without an AbortSignal, `switchMap` would drop the response
|
|
53
|
+
// client-side but the older PUT would still land on the server —
|
|
54
|
+
// under patch-semantic LWW, a slower older PUT arriving after a
|
|
55
|
+
// newer one silently reverts the user's latest choice on the
|
|
56
|
+
// server. Cursor Bugbot e3861a76. `AbortController.abort()` is
|
|
57
|
+
// idempotent so it's safe if the request already completed.
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const request$ = zeniAPI
|
|
60
|
+
.putAndGetJSON(notificationPreferencesUrl(zeniAPI), body, undefined, controller.signal)
|
|
61
|
+
.pipe(mergeMap((response) => {
|
|
62
|
+
if (isSuccessResponse(response)) {
|
|
63
|
+
return of(saveNotificationPreferencesSuccess({
|
|
64
|
+
cancelEpochAtDispatch,
|
|
65
|
+
savedOverrides: overrides,
|
|
66
|
+
serverEcho: response.data?.task_checklist_preferences,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
const actions = [
|
|
70
|
+
saveNotificationPreferencesFailure({
|
|
71
|
+
cancelEpochAtDispatch,
|
|
72
|
+
error: response.status,
|
|
73
|
+
}),
|
|
74
|
+
];
|
|
75
|
+
if (!isCancelledSince()) {
|
|
76
|
+
// Surface a user-visible error — without it the UI reverts to
|
|
77
|
+
// server truth after debounce and the failed toggle looks like
|
|
78
|
+
// a mystery UI bug.
|
|
79
|
+
actions.push(errorSnackbar);
|
|
80
|
+
}
|
|
81
|
+
return of(...actions);
|
|
82
|
+
}), catchError((error) => {
|
|
83
|
+
const actions = [
|
|
84
|
+
saveNotificationPreferencesFailure({
|
|
85
|
+
cancelEpochAtDispatch,
|
|
86
|
+
error: createZeniAPIStatus('Unexpected Error', 'Save notification preferences failed: ' +
|
|
87
|
+
JSON.stringify(error)),
|
|
88
|
+
}),
|
|
89
|
+
];
|
|
90
|
+
if (!isCancelledSince()) {
|
|
91
|
+
actions.push(errorSnackbar);
|
|
92
|
+
}
|
|
93
|
+
return of(...actions);
|
|
94
|
+
}), finalize(() => {
|
|
95
|
+
// switchMap disposal (new batch fires, cancel, unmount) → abort
|
|
96
|
+
// the still-in-flight HTTP request. No-op if the request has
|
|
97
|
+
// already completed successfully or errored.
|
|
98
|
+
controller.abort();
|
|
99
|
+
}));
|
|
100
|
+
// Flip savePreferencesState to In-Progress BEFORE the network call so
|
|
101
|
+
// any "saving…" UI can render.
|
|
102
|
+
return concat(of(saveNotificationPreferences()), request$);
|
|
103
|
+
}));
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Shared endpoint used by both the GET (envelope refresh in
|
|
2
|
+
// `fetchNotificationSettingsEpic`) and the PUT (patch in
|
|
3
|
+
// `saveNotificationPreferencesEpic`). Kept as a helper so an endpoint rename
|
|
4
|
+
// can't drift between the two callers.
|
|
5
|
+
export const notificationPreferencesUrl = (zeniAPI) => `${zeniAPI.apiEndPoints.notificationMicroServiceBaseUrl}/1.0/notification-preferences`;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { toNotificationChannel, toNotificationFrequency, } from '../../entity/notificationRegistry/notificationRegistryState';
|
|
2
|
+
const mapEventEnabledByChannel = (raw) => {
|
|
3
|
+
if (raw == null) {
|
|
4
|
+
return {};
|
|
5
|
+
}
|
|
6
|
+
const out = {};
|
|
7
|
+
Object.entries(raw).forEach(([eventId, channelMap]) => {
|
|
8
|
+
const mapped = {};
|
|
9
|
+
Object.entries(channelMap).forEach(([channel, enabled]) => {
|
|
10
|
+
mapped[toNotificationChannel(channel)] = enabled;
|
|
11
|
+
});
|
|
12
|
+
out[eventId] = mapped;
|
|
13
|
+
});
|
|
14
|
+
return out;
|
|
15
|
+
};
|
|
16
|
+
export const mapPayloadToPreferences = (payload) => {
|
|
17
|
+
const groupFrequencyRaw = payload?.group_frequency_by_group_id ?? {};
|
|
18
|
+
const groupFrequencyByGroupId = Object.fromEntries(Object.entries(groupFrequencyRaw).map(([groupId, frequency]) => [
|
|
19
|
+
groupId,
|
|
20
|
+
toNotificationFrequency(frequency),
|
|
21
|
+
]));
|
|
22
|
+
return {
|
|
23
|
+
eventEnabledByChannel: mapEventEnabledByChannel(payload?.event_enabled_by_channel),
|
|
24
|
+
groupFrequencyByGroupId,
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
export const mapPreferencesToPayload = (preferences) => {
|
|
28
|
+
const payload = {};
|
|
29
|
+
if (Object.keys(preferences.eventEnabledByChannel).length > 0) {
|
|
30
|
+
payload.event_enabled_by_channel = preferences.eventEnabledByChannel;
|
|
31
|
+
}
|
|
32
|
+
if (Object.keys(preferences.groupFrequencyByGroupId).length > 0) {
|
|
33
|
+
payload.group_frequency_by_group_id = preferences.groupFrequencyByGroupId;
|
|
34
|
+
}
|
|
35
|
+
return payload;
|
|
36
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { createSlice } from '@reduxjs/toolkit';
|
|
2
|
+
import { mapPayloadToPreferences, } from './notificationPreferencesViewPayload';
|
|
3
|
+
import { emptyNotificationPreferences, } from './notificationPreferencesViewState';
|
|
4
|
+
export const initialState = {
|
|
5
|
+
cancelEpoch: 0,
|
|
6
|
+
localOverrides: emptyNotificationPreferences(),
|
|
7
|
+
preferences: emptyNotificationPreferences(),
|
|
8
|
+
saveEpoch: 0,
|
|
9
|
+
savePreferencesState: { fetchState: 'Not-Started' },
|
|
10
|
+
};
|
|
11
|
+
const mergePreferences = (base, patch) => {
|
|
12
|
+
const eventEnabledByChannel = {
|
|
13
|
+
...base.eventEnabledByChannel,
|
|
14
|
+
};
|
|
15
|
+
Object.entries(patch.eventEnabledByChannel).forEach(([eventId, channels]) => {
|
|
16
|
+
eventEnabledByChannel[eventId] = {
|
|
17
|
+
...(base.eventEnabledByChannel[eventId] ?? {}),
|
|
18
|
+
...channels,
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
eventEnabledByChannel,
|
|
23
|
+
groupFrequencyByGroupId: {
|
|
24
|
+
...base.groupFrequencyByGroupId,
|
|
25
|
+
...patch.groupFrequencyByGroupId,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
// Remove only the (eventId, channel) and groupId keys that were actually saved
|
|
30
|
+
// to the server from `localOverrides` — AND whose current value still equals
|
|
31
|
+
// what was saved. Any new edits the user added while the PUT was in flight
|
|
32
|
+
// (either new keys not present in `saved`, OR same keys the user flipped back
|
|
33
|
+
// to a different value) are preserved so the next debounce cycle picks them up.
|
|
34
|
+
//
|
|
35
|
+
// Presence-only subtraction (the prior implementation) silently dropped a
|
|
36
|
+
// same-key re-edit that landed AFTER debounce fired but BEFORE the response
|
|
37
|
+
// returned. Concrete case:
|
|
38
|
+
// 1. toggle evt1.email = false → localOverrides = {evt1:{email:false}}
|
|
39
|
+
// 2. debounce PUT #1 fires with savedOverrides = {evt1:{email:false}}
|
|
40
|
+
// 3. user re-toggles evt1.email = true → localOverrides = {evt1:{email:true}}
|
|
41
|
+
// 4. PUT #1 success → subtract dropped `email` on presence, so the true
|
|
42
|
+
// re-edit disappeared and next-debounce found nothing to send.
|
|
43
|
+
const subtractSavedOverrides = (current, saved) => {
|
|
44
|
+
const eventEnabledByChannel = {};
|
|
45
|
+
Object.entries(current.eventEnabledByChannel).forEach(([eventId, channels]) => {
|
|
46
|
+
const savedChannels = saved.eventEnabledByChannel[eventId];
|
|
47
|
+
if (savedChannels == null) {
|
|
48
|
+
eventEnabledByChannel[eventId] = channels;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const remainingChannels = {};
|
|
52
|
+
Object.keys(channels).forEach((channel) => {
|
|
53
|
+
// Preserve the key when it's a new channel OR when the user's
|
|
54
|
+
// current value differs from what was saved (same-key re-edit).
|
|
55
|
+
if (!(channel in savedChannels) ||
|
|
56
|
+
channels[channel] !== savedChannels[channel]) {
|
|
57
|
+
remainingChannels[channel] = channels[channel];
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
if (Object.keys(remainingChannels).length > 0) {
|
|
61
|
+
eventEnabledByChannel[eventId] = remainingChannels;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const groupFrequencyByGroupId = {};
|
|
65
|
+
Object.entries(current.groupFrequencyByGroupId).forEach(([groupId, frequency]) => {
|
|
66
|
+
if (saved.groupFrequencyByGroupId[groupId] !== frequency) {
|
|
67
|
+
groupFrequencyByGroupId[groupId] = frequency;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return { eventEnabledByChannel, groupFrequencyByGroupId };
|
|
71
|
+
};
|
|
72
|
+
const notificationPreferencesView = createSlice({
|
|
73
|
+
name: 'notificationPreferencesView',
|
|
74
|
+
initialState,
|
|
75
|
+
reducers: {
|
|
76
|
+
clearAllNotificationPreferencesView(draft) {
|
|
77
|
+
// Reset everything EXCEPT `cancelEpoch`, which must stay monotonic
|
|
78
|
+
// across the lifetime of the app instance. `initialState.cancelEpoch`
|
|
79
|
+
// is `0`; returning `initialState` verbatim would rewind the counter
|
|
80
|
+
// to zero, and any in-flight save whose `cancelEpochAtDispatch` was
|
|
81
|
+
// captured pre-clearAll (>0) would then satisfy the "captured >=
|
|
82
|
+
// current" epoch guard and merge stale edits into fresh preferences.
|
|
83
|
+
// Bumping instead of resetting also invalidates the abandoned save
|
|
84
|
+
// for free — Cursor Bugbot bc7876f1 followup.
|
|
85
|
+
const nextCancelEpoch = draft.cancelEpoch + 1;
|
|
86
|
+
// saveEpoch must also stay monotonic across clearAll for the same
|
|
87
|
+
// reason (Cursor Bugbot 3632510237). Rewinding to 0 would let a
|
|
88
|
+
// stale-in-flight GET whose `saveEpochAtDispatch` was captured >0
|
|
89
|
+
// satisfy the "captured >= current" epoch guard and clobber fresh
|
|
90
|
+
// preferences on any subsequent save.
|
|
91
|
+
const nextSaveEpoch = draft.saveEpoch + 1;
|
|
92
|
+
return {
|
|
93
|
+
...initialState,
|
|
94
|
+
cancelEpoch: nextCancelEpoch,
|
|
95
|
+
saveEpoch: nextSaveEpoch,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
clearNotificationPreferencesLocalOverrides(draft) {
|
|
99
|
+
// Discards in-flight per-event/per-group edits held in `localOverrides`
|
|
100
|
+
// without touching the fetched `preferences` snapshot. Used by the
|
|
101
|
+
// settings-page Cancel affordance so the effective preferences revert
|
|
102
|
+
// to the last server-known state without triggering a refetch race.
|
|
103
|
+
//
|
|
104
|
+
// Cancel/in-flight-save race: every Cancel bumps `cancelEpoch`
|
|
105
|
+
// monotonically. Any save that captured a lower epoch value at
|
|
106
|
+
// dispatch time is treated as stale by the Success/Failure reducer
|
|
107
|
+
// — its merge is skipped. Unconditional bump (vs the earlier
|
|
108
|
+
// counter model) survives switchMap abandonment and double-Cancel
|
|
109
|
+
// (Cursor Bugbot 4aa021f7): even if the abandoned save's
|
|
110
|
+
// Success/Failure never dispatches, subsequent legitimate saves
|
|
111
|
+
// still work because their captured epoch matches the current one.
|
|
112
|
+
draft.cancelEpoch += 1;
|
|
113
|
+
draft.localOverrides = emptyNotificationPreferences();
|
|
114
|
+
// Also drop `savePreferencesState` back to Not-Started so any
|
|
115
|
+
// "Saving…" affordance bound to `getNotificationPreferencesSaveState`
|
|
116
|
+
// clears on Cancel. Without this, a Cancel-during-save whose stale
|
|
117
|
+
// Success/Failure never fires (switchMap abandonment) leaves the
|
|
118
|
+
// selector reporting In-Progress forever — Cursor Bugbot bc7876f1.
|
|
119
|
+
draft.savePreferencesState = { fetchState: 'Not-Started' };
|
|
120
|
+
},
|
|
121
|
+
fetchNotificationPreferencesSuccess(draft, action) {
|
|
122
|
+
// Piggybacks on the settings-envelope fetch. The GET response is a full
|
|
123
|
+
// snapshot of the server-known preferences (empty maps included), so
|
|
124
|
+
// REPLACE `preferences` rather than merging — merging would leak keys
|
|
125
|
+
// the server has since removed. DO NOT touch `localOverrides`: a refetch
|
|
126
|
+
// that fires while the user has in-flight edits (pre-debounce) must not
|
|
127
|
+
// silently drop them; the save-success path is the only clearer.
|
|
128
|
+
// A `null`/`undefined` payload isn't a snapshot at all (e.g. the
|
|
129
|
+
// envelope key was omitted); no-op instead of wiping preferences.
|
|
130
|
+
if (action.payload.preferences == null) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Stale-GET guard (Cursor Bugbot 3632510237): if the fetch epic
|
|
134
|
+
// captured a `saveEpoch` lower than the current one, a save has
|
|
135
|
+
// succeeded since this GET dispatched — the response is carrying
|
|
136
|
+
// the pre-PUT snapshot and would clobber the just-saved server
|
|
137
|
+
// truth back to the pre-edit state until the next refetch. Skip
|
|
138
|
+
// the replace; the store already reflects the fresher save.
|
|
139
|
+
if (action.payload.saveEpochAtDispatch < draft.saveEpoch) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
draft.preferences = mapPayloadToPreferences(action.payload.preferences);
|
|
143
|
+
},
|
|
144
|
+
saveNotificationPreferences(draft) {
|
|
145
|
+
draft.savePreferencesState = { fetchState: 'In-Progress' };
|
|
146
|
+
},
|
|
147
|
+
saveNotificationPreferencesFailure(draft, action) {
|
|
148
|
+
// Stale failure from a cancelled-since save — swallow silently so
|
|
149
|
+
// the UI doesn't surface an error banner for a save the user
|
|
150
|
+
// already abandoned.
|
|
151
|
+
if (action.payload.cancelEpochAtDispatch < draft.cancelEpoch) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Preserve `localOverrides` so the user's in-flight edits survive a
|
|
155
|
+
// transient network blip. UI should surface an error affordance and
|
|
156
|
+
// allow retry.
|
|
157
|
+
draft.savePreferencesState = {
|
|
158
|
+
fetchState: 'Error',
|
|
159
|
+
error: action.payload.error,
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
saveNotificationPreferencesSuccess(draft, action) {
|
|
163
|
+
// Preserve-on-partial: PUT /preferences is patch-semantic. The response
|
|
164
|
+
// echoes only the fields that changed, so merge into `preferences`
|
|
165
|
+
// instead of replacing — otherwise a single-channel toggle would wipe
|
|
166
|
+
// every other stored preference.
|
|
167
|
+
//
|
|
168
|
+
// Rollback invariant (F5): `serverEcho` may be `undefined` if the server
|
|
169
|
+
// rolls back to an older shape that omits the echo entirely. In that
|
|
170
|
+
// case fall back to merging `savedOverrides` into `preferences` — the
|
|
171
|
+
// PUT succeeded (2xx), so the values we sent are now server truth. If
|
|
172
|
+
// we merged an empty patch instead, `subtractSavedOverrides` would
|
|
173
|
+
// still strip the just-saved keys from `localOverrides`, leaving the
|
|
174
|
+
// effective preferences (preferences ∪ localOverrides) reverted to the
|
|
175
|
+
// pre-edit server snapshot until the next refetch.
|
|
176
|
+
//
|
|
177
|
+
// In-flight-edit invariant: `savedOverrides` is the exact snapshot the
|
|
178
|
+
// epic PUT to the server. Subtracting those keys from `localOverrides`
|
|
179
|
+
// (instead of clearing it wholesale) preserves any edits the user
|
|
180
|
+
// added AFTER the debounce fired but BEFORE the response landed. The
|
|
181
|
+
// next debounce cycle will pick them up. Clearing wholesale drops
|
|
182
|
+
// in-flight edits from both UI (once server echo lands) and server.
|
|
183
|
+
// Cancel/in-flight-save race: `cancelEpoch` monotonically increases
|
|
184
|
+
// on every Cancel. If the save captured a lower epoch at dispatch,
|
|
185
|
+
// the user has pressed Cancel since — skip the `preferences` merge
|
|
186
|
+
// and the `localOverrides` subtract (Cancel already emptied it).
|
|
187
|
+
// Do NOT flip fetchState so a subsequent legit save can still show
|
|
188
|
+
// its own In-Progress → Completed lifecycle. The server itself has
|
|
189
|
+
// accepted the write — reverting server-side is out of FE scope; UI
|
|
190
|
+
// reflects the cancelled intention until the next refetch merges
|
|
191
|
+
// whatever the server persisted.
|
|
192
|
+
if (action.payload.cancelEpochAtDispatch < draft.cancelEpoch) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Bump `saveEpoch` so any settings-envelope GET that was in flight
|
|
196
|
+
// BEFORE this PUT resolved is treated as stale. Its captured epoch
|
|
197
|
+
// will be lower than the new value, so its reducer branch will
|
|
198
|
+
// skip the `preferences` replace. Cursor Bugbot 3632510237.
|
|
199
|
+
draft.saveEpoch += 1;
|
|
200
|
+
draft.savePreferencesState = { fetchState: 'Completed' };
|
|
201
|
+
const patch = action.payload.serverEcho == null
|
|
202
|
+
? action.payload.savedOverrides
|
|
203
|
+
: mapPayloadToPreferences(action.payload.serverEcho);
|
|
204
|
+
draft.preferences = mergePreferences(draft.preferences, patch);
|
|
205
|
+
draft.localOverrides = subtractSavedOverrides(draft.localOverrides, action.payload.savedOverrides);
|
|
206
|
+
},
|
|
207
|
+
setGroupFrequency(draft, action) {
|
|
208
|
+
draft.localOverrides.groupFrequencyByGroupId[action.payload.groupId] =
|
|
209
|
+
action.payload.frequency;
|
|
210
|
+
},
|
|
211
|
+
toggleEventChannel(draft, action) {
|
|
212
|
+
const { channel, enabled, eventId } = action.payload;
|
|
213
|
+
const eventMap = {
|
|
214
|
+
...(draft.localOverrides.eventEnabledByChannel[eventId] ?? {}),
|
|
215
|
+
};
|
|
216
|
+
eventMap[channel] = enabled;
|
|
217
|
+
draft.localOverrides.eventEnabledByChannel[eventId] = eventMap;
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
export const { clearAllNotificationPreferencesView, clearNotificationPreferencesLocalOverrides, fetchNotificationPreferencesSuccess, saveNotificationPreferences, saveNotificationPreferencesFailure, saveNotificationPreferencesSuccess, setGroupFrequency, toggleEventChannel, } = notificationPreferencesView.actions;
|
|
222
|
+
export default notificationPreferencesView.reducer;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const getView = (state) => state.notificationPreferencesViewState;
|
|
2
|
+
export const getNotificationPreferencesSaveState = (state) => getView(state).savePreferencesState;
|
|
3
|
+
export const getEffectiveNotificationPreferences = (state) => {
|
|
4
|
+
const view = getView(state);
|
|
5
|
+
const mergedEventEnabledByChannel = { ...view.preferences.eventEnabledByChannel };
|
|
6
|
+
Object.entries(view.localOverrides.eventEnabledByChannel).forEach(([eventId, channels]) => {
|
|
7
|
+
mergedEventEnabledByChannel[eventId] = {
|
|
8
|
+
...(view.preferences.eventEnabledByChannel[eventId] ?? {}),
|
|
9
|
+
...channels,
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
eventEnabledByChannel: mergedEventEnabledByChannel,
|
|
14
|
+
groupFrequencyByGroupId: {
|
|
15
|
+
...view.preferences.groupFrequencyByGroupId,
|
|
16
|
+
...view.localOverrides.groupFrequencyByGroupId,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
export const getNotificationLocalOverrides = (state) => getView(state).localOverrides;
|
|
21
|
+
export const hasUnsavedNotificationPreferences = (state) => {
|
|
22
|
+
const overrides = getView(state).localOverrides;
|
|
23
|
+
return (Object.keys(overrides.eventEnabledByChannel).length > 0 ||
|
|
24
|
+
Object.keys(overrides.groupFrequencyByGroupId).length > 0);
|
|
25
|
+
};
|