@open-mercato/core 0.6.8-develop.6882.1.cd042ac354 → 0.6.8-develop.6889.1.af4501ca97
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/modules/customers/api/dashboard/widgets/customer-todos/route.js +0 -2
- package/dist/modules/customers/api/dashboard/widgets/customer-todos/route.js.map +2 -2
- package/dist/modules/customers/api/interactions/tasks/route.js +0 -2
- package/dist/modules/customers/api/interactions/tasks/route.js.map +2 -2
- package/dist/modules/dashboards/lib/aggregations.js +8 -0
- package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +8 -8
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +7 -0
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js +7 -0
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/bulk/route.js +16 -0
- package/dist/modules/staff/api/timesheets/time-entries/bulk/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/route.js +4 -3
- package/dist/modules/staff/api/timesheets/time-entries/route.js.map +2 -2
- package/dist/modules/staff/lib/crud.js +6 -0
- package/dist/modules/staff/lib/crud.js.map +2 -2
- package/dist/modules/staff/lib/timesheets/timeEntryCacheInvalidation.js +32 -0
- package/dist/modules/staff/lib/timesheets/timeEntryCacheInvalidation.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/customers/api/dashboard/widgets/customer-todos/route.ts +0 -2
- package/src/modules/customers/api/interactions/tasks/route.ts +3 -5
- package/src/modules/dashboards/lib/aggregations.ts +23 -0
- package/src/modules/query_index/lib/engine.ts +30 -14
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +8 -0
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.ts +8 -0
- package/src/modules/staff/api/timesheets/time-entries/bulk/route.ts +17 -0
- package/src/modules/staff/api/timesheets/time-entries/route.ts +4 -3
- package/src/modules/staff/lib/crud.ts +12 -0
- package/src/modules/staff/lib/timesheets/timeEntryCacheInvalidation.ts +64 -0
|
@@ -173,6 +173,21 @@ function normalizeSetFilterValues(value: unknown): unknown[] {
|
|
|
173
173
|
return values
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Builds the shared WHERE clause every aggregation query reads from, together with its parameters.
|
|
178
|
+
*
|
|
179
|
+
* The `eq` / `neq` cases branch on a null value instead of binding it (#5016). Under SQL
|
|
180
|
+
* three-valued logic neither `column = NULL` nor `column != NULL` is ever `TRUE`, so binding a raw
|
|
181
|
+
* null selected zero rows and rendered as a legitimate-looking empty aggregation — a silent zero on
|
|
182
|
+
* a reporting surface. Nullness has to be asked with the `IS NULL` / `IS NOT NULL` keywords, which
|
|
183
|
+
* take no parameter; that is also what the dedicated `is_null` / `is_not_null` operators emit, and
|
|
184
|
+
* it mirrors how the query index builds column filters (`query_index/lib/engine.ts`).
|
|
185
|
+
*
|
|
186
|
+
* The ordering operators (`gt`/`gte`/`lt`/`lte`) deliberately keep binding the value: comparing an
|
|
187
|
+
* ordering against NULL is genuinely undefined rather than a mistake, so their never-`TRUE` result
|
|
188
|
+
* is the correct answer. Set operators refuse null members outright — see
|
|
189
|
+
* `normalizeSetFilterValues`.
|
|
190
|
+
*/
|
|
176
191
|
function buildWhereClause(
|
|
177
192
|
options: Pick<BuildAggregationQueryOptions, 'entityType' | 'dateRange' | 'filters' | 'scope' | 'registry'>,
|
|
178
193
|
): { clause: string; params: unknown[] } {
|
|
@@ -207,10 +222,18 @@ function buildWhereClause(
|
|
|
207
222
|
|
|
208
223
|
switch (filter.operator) {
|
|
209
224
|
case 'eq':
|
|
225
|
+
if (filter.value === null) {
|
|
226
|
+
whereClauses.push(`${filterMapping.dbColumn} IS NULL`)
|
|
227
|
+
break
|
|
228
|
+
}
|
|
210
229
|
whereClauses.push(`${filterMapping.dbColumn} = ?`)
|
|
211
230
|
params.push(filter.value)
|
|
212
231
|
break
|
|
213
232
|
case 'neq':
|
|
233
|
+
if (filter.value === null) {
|
|
234
|
+
whereClauses.push(`${filterMapping.dbColumn} IS NOT NULL`)
|
|
235
|
+
break
|
|
236
|
+
}
|
|
214
237
|
whereClauses.push(`${filterMapping.dbColumn} != ?`)
|
|
215
238
|
params.push(filter.value)
|
|
216
239
|
break
|
|
@@ -1386,12 +1386,18 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1386
1386
|
|
|
1387
1387
|
switch (op) {
|
|
1388
1388
|
case 'eq':
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1389
|
+
// An unset custom field has no array element to contain, so the
|
|
1390
|
+
// arrContains branch cannot match it — compare the text value only.
|
|
1391
|
+
return value === null
|
|
1392
|
+
? builder.where(sql<boolean>`${textExpr} is null`)
|
|
1393
|
+
: builder.where((eb: any) => eb.or([
|
|
1394
|
+
sql<boolean>`${textExpr} = ${value}`,
|
|
1395
|
+
arrContains(value),
|
|
1396
|
+
]))
|
|
1393
1397
|
case 'ne':
|
|
1394
|
-
return
|
|
1398
|
+
return value === null
|
|
1399
|
+
? builder.where(sql<boolean>`${textExpr} is not null`)
|
|
1400
|
+
: builder.where(sql<boolean>`${textExpr} <> ${value}`)
|
|
1395
1401
|
case 'in': {
|
|
1396
1402
|
const values = this.toArray(value)
|
|
1397
1403
|
return builder.where((eb: any) => eb.or(
|
|
@@ -1517,12 +1523,18 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1517
1523
|
}
|
|
1518
1524
|
switch (op) {
|
|
1519
1525
|
case 'eq':
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1526
|
+
// An unset custom field has no array element to contain, so the
|
|
1527
|
+
// arrContains branch cannot match it — compare the text value only.
|
|
1528
|
+
return value === null
|
|
1529
|
+
? q.where(sql<boolean>`${textExpr} is null`)
|
|
1530
|
+
: q.where((eb: any) => eb.or([
|
|
1531
|
+
sql<boolean>`${textExpr} = ${value}`,
|
|
1532
|
+
arrContains(value),
|
|
1533
|
+
]))
|
|
1524
1534
|
case 'ne':
|
|
1525
|
-
return
|
|
1535
|
+
return value === null
|
|
1536
|
+
? q.where(sql<boolean>`${textExpr} is not null`)
|
|
1537
|
+
: q.where(sql<boolean>`${textExpr} <> ${value}`)
|
|
1526
1538
|
case 'in': {
|
|
1527
1539
|
const vals = this.toArray(value)
|
|
1528
1540
|
return q.where((eb: any) => eb.or(
|
|
@@ -1589,9 +1601,13 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1589
1601
|
}
|
|
1590
1602
|
switch (op) {
|
|
1591
1603
|
case 'eq':
|
|
1592
|
-
return
|
|
1604
|
+
return value === null
|
|
1605
|
+
? q.where(sql<boolean>`${textExpr} is null`)
|
|
1606
|
+
: q.where(sql<boolean>`${textExpr} = ${value}`)
|
|
1593
1607
|
case 'ne':
|
|
1594
|
-
return
|
|
1608
|
+
return value === null
|
|
1609
|
+
? q.where(sql<boolean>`${textExpr} is not null`)
|
|
1610
|
+
: q.where(sql<boolean>`${textExpr} <> ${value}`)
|
|
1595
1611
|
case 'in': {
|
|
1596
1612
|
const vals = this.toArray(value)
|
|
1597
1613
|
return q.where(sql<boolean>`${textExpr} in (${sql.join(vals.map((v) => sql`${v}`), sql`, `)})`)
|
|
@@ -1709,8 +1725,8 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
1709
1725
|
): any {
|
|
1710
1726
|
const textExpr = sql<string | null>`(${sql.ref(alias + '.doc')} ->> ${key})`
|
|
1711
1727
|
switch (op) {
|
|
1712
|
-
case 'eq': return sql<boolean>`${textExpr} = ${value}`
|
|
1713
|
-
case 'ne': return sql<boolean>`${textExpr} <> ${value}`
|
|
1728
|
+
case 'eq': return value === null ? sql<boolean>`${textExpr} is null` : sql<boolean>`${textExpr} = ${value}`
|
|
1729
|
+
case 'ne': return value === null ? sql<boolean>`${textExpr} is not null` : sql<boolean>`${textExpr} <> ${value}`
|
|
1714
1730
|
case 'gt':
|
|
1715
1731
|
case 'gte':
|
|
1716
1732
|
case 'lt':
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
runStaffMutationGuards,
|
|
18
18
|
} from '../../../../guards'
|
|
19
19
|
import { emitStaffEvent } from '../../../../../events'
|
|
20
|
+
import { invalidateStaffTimeEntryCache } from '../../../../../lib/timesheets/timeEntryCacheInvalidation'
|
|
20
21
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
21
22
|
|
|
22
23
|
const logger = createLogger('staff')
|
|
@@ -170,6 +171,13 @@ export async function POST(req: Request) {
|
|
|
170
171
|
return startedAt
|
|
171
172
|
})
|
|
172
173
|
|
|
174
|
+
await invalidateStaffTimeEntryCache(
|
|
175
|
+
container,
|
|
176
|
+
{ id: entry.id, organizationId: entry.organizationId, tenantId: entry.tenantId },
|
|
177
|
+
tenantId,
|
|
178
|
+
'timer_started',
|
|
179
|
+
)
|
|
180
|
+
|
|
173
181
|
void emitStaffEvent('staff.timesheets.time_entry.timer_started', {
|
|
174
182
|
id: entry.id,
|
|
175
183
|
staffMemberId: entry.staffMemberId,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
runStaffMutationGuards,
|
|
18
18
|
} from '../../../../guards'
|
|
19
19
|
import { emitStaffEvent } from '../../../../../events'
|
|
20
|
+
import { invalidateStaffTimeEntryCache } from '../../../../../lib/timesheets/timeEntryCacheInvalidation'
|
|
20
21
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
21
22
|
|
|
22
23
|
const logger = createLogger('staff')
|
|
@@ -152,6 +153,13 @@ export async function POST(req: Request) {
|
|
|
152
153
|
return { now: stoppedAt, durationMinutes: computedMinutes }
|
|
153
154
|
})
|
|
154
155
|
|
|
156
|
+
await invalidateStaffTimeEntryCache(
|
|
157
|
+
container,
|
|
158
|
+
{ id: entry.id, organizationId: entry.organizationId, tenantId: entry.tenantId },
|
|
159
|
+
tenantId,
|
|
160
|
+
'timer_stopped',
|
|
161
|
+
)
|
|
162
|
+
|
|
155
163
|
void emitStaffEvent('staff.timesheets.time_entry.timer_stopped', {
|
|
156
164
|
id: entry.id,
|
|
157
165
|
staffMemberId: entry.staffMemberId,
|
|
@@ -14,6 +14,7 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
|
14
14
|
import { StaffTimeEntry, StaffTeamMember, StaffTimeProject } from '../../../../data/entities'
|
|
15
15
|
import { staffTimeEntryBulkSaveSchema } from '../../../../data/validators'
|
|
16
16
|
import { staffTimeEntryCrudEvents } from '../../../../lib/crud'
|
|
17
|
+
import { invalidateStaffTimeEntryCache } from '../../../../lib/timesheets/timeEntryCacheInvalidation'
|
|
17
18
|
import {
|
|
18
19
|
resolveUserFeatures,
|
|
19
20
|
runStaffMutationGuardAfterSuccess,
|
|
@@ -230,6 +231,22 @@ export async function POST(req: Request) {
|
|
|
230
231
|
}
|
|
231
232
|
await flushCrudSideEffects(dataEngine)
|
|
232
233
|
|
|
234
|
+
const invalidatedRecordIds = new Set<string>()
|
|
235
|
+
for (const change of pendingChanges) {
|
|
236
|
+
if (invalidatedRecordIds.has(change.entity.id)) continue
|
|
237
|
+
invalidatedRecordIds.add(change.entity.id)
|
|
238
|
+
await invalidateStaffTimeEntryCache(
|
|
239
|
+
container,
|
|
240
|
+
{
|
|
241
|
+
id: change.entity.id,
|
|
242
|
+
organizationId: change.entity.organizationId,
|
|
243
|
+
tenantId: change.entity.tenantId,
|
|
244
|
+
},
|
|
245
|
+
tenantId,
|
|
246
|
+
`bulk:${change.action}`,
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
|
|
233
250
|
if (guardResult.afterSuccessCallbacks.length) {
|
|
234
251
|
await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
|
|
235
252
|
tenantId,
|
|
@@ -5,6 +5,7 @@ import { resolveCrudRecordId, parseScopedCommandInput } from '@open-mercato/shar
|
|
|
5
5
|
import { StaffTimeEntry } from '../../../data/entities'
|
|
6
6
|
import { staffTimeEntryCreateSchema, staffTimeEntryUpdateSchema } from '../../../data/validators'
|
|
7
7
|
import { buildTimeEntryListFilters, isParseableDateFilter } from '../../../lib/timesheets/timeEntryListFilters'
|
|
8
|
+
import { staffTimeEntryCommandIds } from '../../../lib/crud'
|
|
8
9
|
import { createStaffCrudOpenApi, createPagedListResponseSchema, defaultOkResponseSchema } from '../../openapi'
|
|
9
10
|
|
|
10
11
|
const F = {
|
|
@@ -99,7 +100,7 @@ const crud = makeCrudRoute({
|
|
|
99
100
|
},
|
|
100
101
|
actions: {
|
|
101
102
|
create: {
|
|
102
|
-
commandId:
|
|
103
|
+
commandId: staffTimeEntryCommandIds.create,
|
|
103
104
|
schema: rawBodySchema,
|
|
104
105
|
mapInput: async ({ raw, ctx }) => {
|
|
105
106
|
const { translate } = await resolveTranslations()
|
|
@@ -109,7 +110,7 @@ const crud = makeCrudRoute({
|
|
|
109
110
|
status: 201,
|
|
110
111
|
},
|
|
111
112
|
update: {
|
|
112
|
-
commandId:
|
|
113
|
+
commandId: staffTimeEntryCommandIds.update,
|
|
113
114
|
schema: rawBodySchema,
|
|
114
115
|
mapInput: async ({ raw, ctx }) => {
|
|
115
116
|
const { translate } = await resolveTranslations()
|
|
@@ -118,7 +119,7 @@ const crud = makeCrudRoute({
|
|
|
118
119
|
response: () => ({ ok: true }),
|
|
119
120
|
},
|
|
120
121
|
delete: {
|
|
121
|
-
commandId:
|
|
122
|
+
commandId: staffTimeEntryCommandIds.delete,
|
|
122
123
|
schema: rawBodySchema,
|
|
123
124
|
mapInput: async ({ parsed, ctx }) => {
|
|
124
125
|
const { translate } = await resolveTranslations()
|
|
@@ -36,6 +36,18 @@ export const staffTeamMemberActivityCrudEvents = buildCrudEvents<StaffTeamMember
|
|
|
36
36
|
export const staffTeamMemberJobHistoryCrudEvents = buildCrudEvents<StaffTeamMemberJobHistory>('job_history')
|
|
37
37
|
|
|
38
38
|
// Timesheets
|
|
39
|
+
/**
|
|
40
|
+
* Command ids the time-entries CRUD route registers. Exported so the route and the
|
|
41
|
+
* cache-invalidation helper share one string: `makeCrudRoute` derives the CRUD cache
|
|
42
|
+
* resource tag from the create id, so a second copy of it drifting apart would leave
|
|
43
|
+
* custom write routes flushing a tag nothing is stored under (#4970).
|
|
44
|
+
*/
|
|
45
|
+
export const staffTimeEntryCommandIds = {
|
|
46
|
+
create: 'staff.timesheets.time_entries.create',
|
|
47
|
+
update: 'staff.timesheets.time_entries.update',
|
|
48
|
+
delete: 'staff.timesheets.time_entries.delete',
|
|
49
|
+
} as const
|
|
50
|
+
|
|
39
51
|
export const staffTimeEntryCrudEvents = buildCrudEvents<StaffTimeEntry>('timesheets.time_entry')
|
|
40
52
|
export const staffTimeProjectCrudEvents = buildCrudEvents<StaffTimeProject>('timesheets.time_project')
|
|
41
53
|
export const staffTimeProjectMemberCrudEvents = buildCrudEvents<StaffTimeProjectMember>('timesheets.time_project_member')
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import {
|
|
3
|
+
canonicalizeResourceTag,
|
|
4
|
+
deriveResourceFromCommandId,
|
|
5
|
+
invalidateCrudCache,
|
|
6
|
+
type CrudCacheIdentifiers,
|
|
7
|
+
} from '@open-mercato/shared/lib/crud/cache'
|
|
8
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
9
|
+
import { staffTimeEntryCommandIds } from '../crud'
|
|
10
|
+
|
|
11
|
+
const logger = createLogger('staff').child({ component: 'timesheets-cache' })
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The resource tag the CRUD list cache stores time-entry payloads under.
|
|
15
|
+
*
|
|
16
|
+
* `makeCrudRoute` derives it from the create action's command id (the route declares
|
|
17
|
+
* no `events` config), which singularizes the command's SECOND segment — so the tag
|
|
18
|
+
* is `staff.timesheet`, NOT the `staff.time_entry` the entity name suggests. Deriving
|
|
19
|
+
* it here through the same shared helpers keeps the flush tag equal to the store tag;
|
|
20
|
+
* a hand-typed literal is how a custom write route silently flushes nothing (#3143,
|
|
21
|
+
* #3711). The literal fallback only guards against a null derivation at runtime — the
|
|
22
|
+
* unit test pins the derived value so drift fails in CI instead of in production.
|
|
23
|
+
*/
|
|
24
|
+
export const staffTimeEntryCacheResource =
|
|
25
|
+
canonicalizeResourceTag(deriveResourceFromCommandId(staffTimeEntryCommandIds.create)) ?? 'staff.timesheet'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Flush the cached time-entry collections and record entries after a committed write.
|
|
29
|
+
*
|
|
30
|
+
* Custom write routes that mutate `StaffTimeEntry` through the EntityManager bypass
|
|
31
|
+
* `makeCrudRoute`'s own POST/PUT/DELETE handlers and the command bus, so neither of
|
|
32
|
+
* the platform's two `invalidateCrudCache` call sites runs for them. Without this the
|
|
33
|
+
* opt-in CRUD list cache (`ENABLE_CRUD_API_CACHE`) keeps serving the pre-write payload
|
|
34
|
+
* and the weekly timesheet grid reloads without the rows it just saved (#4970).
|
|
35
|
+
*
|
|
36
|
+
* MUST be called after the transaction commits, never inside it. Because the write is
|
|
37
|
+
* already committed by then, a failing cache backend is logged rather than thrown — the
|
|
38
|
+
* command bus guards its own invalidation the same way. Surfacing it would turn a
|
|
39
|
+
* successful write into an error response and invite a duplicating client retry, while
|
|
40
|
+
* swallowing it costs at most one TTL of staleness.
|
|
41
|
+
*/
|
|
42
|
+
export async function invalidateStaffTimeEntryCache(
|
|
43
|
+
container: AwilixContainer,
|
|
44
|
+
identifiers: CrudCacheIdentifiers,
|
|
45
|
+
fallbackTenant: string | null,
|
|
46
|
+
reason: string,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
try {
|
|
49
|
+
await invalidateCrudCache(
|
|
50
|
+
container,
|
|
51
|
+
staffTimeEntryCacheResource,
|
|
52
|
+
identifiers,
|
|
53
|
+
fallbackTenant,
|
|
54
|
+
reason,
|
|
55
|
+
)
|
|
56
|
+
} catch (err) {
|
|
57
|
+
logger.warn('Time-entry cache invalidation failed', {
|
|
58
|
+
resource: staffTimeEntryCacheResource,
|
|
59
|
+
recordId: identifiers.id ?? null,
|
|
60
|
+
reason,
|
|
61
|
+
err,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
}
|