@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.
Files changed (32) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/api/dashboard/widgets/customer-todos/route.js +0 -2
  3. package/dist/modules/customers/api/dashboard/widgets/customer-todos/route.js.map +2 -2
  4. package/dist/modules/customers/api/interactions/tasks/route.js +0 -2
  5. package/dist/modules/customers/api/interactions/tasks/route.js.map +2 -2
  6. package/dist/modules/dashboards/lib/aggregations.js +8 -0
  7. package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
  8. package/dist/modules/query_index/lib/engine.js +8 -8
  9. package/dist/modules/query_index/lib/engine.js.map +2 -2
  10. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +7 -0
  11. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  12. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js +7 -0
  13. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js.map +2 -2
  14. package/dist/modules/staff/api/timesheets/time-entries/bulk/route.js +16 -0
  15. package/dist/modules/staff/api/timesheets/time-entries/bulk/route.js.map +2 -2
  16. package/dist/modules/staff/api/timesheets/time-entries/route.js +4 -3
  17. package/dist/modules/staff/api/timesheets/time-entries/route.js.map +2 -2
  18. package/dist/modules/staff/lib/crud.js +6 -0
  19. package/dist/modules/staff/lib/crud.js.map +2 -2
  20. package/dist/modules/staff/lib/timesheets/timeEntryCacheInvalidation.js +32 -0
  21. package/dist/modules/staff/lib/timesheets/timeEntryCacheInvalidation.js.map +7 -0
  22. package/package.json +7 -7
  23. package/src/modules/customers/api/dashboard/widgets/customer-todos/route.ts +0 -2
  24. package/src/modules/customers/api/interactions/tasks/route.ts +3 -5
  25. package/src/modules/dashboards/lib/aggregations.ts +23 -0
  26. package/src/modules/query_index/lib/engine.ts +30 -14
  27. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +8 -0
  28. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.ts +8 -0
  29. package/src/modules/staff/api/timesheets/time-entries/bulk/route.ts +17 -0
  30. package/src/modules/staff/api/timesheets/time-entries/route.ts +4 -3
  31. package/src/modules/staff/lib/crud.ts +12 -0
  32. 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
- return builder.where((eb: any) => eb.or([
1390
- sql<boolean>`${textExpr} = ${value}`,
1391
- arrContains(value),
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 builder.where(sql<boolean>`${textExpr} <> ${value}`)
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
- return q.where((eb: any) => eb.or([
1521
- sql<boolean>`${textExpr} = ${value}`,
1522
- arrContains(value),
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 q.where(sql<boolean>`${textExpr} <> ${value}`)
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 q.where(sql<boolean>`${textExpr} = ${value}`)
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 q.where(sql<boolean>`${textExpr} <> ${value}`)
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: 'staff.timesheets.time_entries.create',
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: 'staff.timesheets.time_entries.update',
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: 'staff.timesheets.time_entries.delete',
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
+ }