@open-mercato/core 0.6.8-develop.7079.1.dbf29dd6a1 → 0.6.8-develop.7082.1.f2a845b020

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 (50) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/reset/validate.js +55 -0
  3. package/dist/modules/auth/api/reset/validate.js.map +7 -0
  4. package/dist/modules/auth/data/validators.js +5 -1
  5. package/dist/modules/auth/data/validators.js.map +2 -2
  6. package/dist/modules/auth/frontend/reset/[token]/page.js +46 -0
  7. package/dist/modules/auth/frontend/reset/[token]/page.js.map +2 -2
  8. package/dist/modules/auth/services/authService.js +16 -2
  9. package/dist/modules/auth/services/authService.js.map +2 -2
  10. package/dist/modules/customers/backend/hooks/useEmailDuplicateCheck.js +9 -3
  11. package/dist/modules/customers/backend/hooks/useEmailDuplicateCheck.js.map +2 -2
  12. package/dist/modules/devices/api/admin/devices/[id]/route.js +5 -32
  13. package/dist/modules/devices/api/admin/devices/[id]/route.js.map +2 -2
  14. package/dist/modules/devices/api/admin/devices/route.js +14 -1
  15. package/dist/modules/devices/api/admin/devices/route.js.map +2 -2
  16. package/dist/modules/devices/api/deviceList.js +99 -12
  17. package/dist/modules/devices/api/deviceList.js.map +2 -2
  18. package/dist/modules/devices/api/deviceSerialization.js +46 -0
  19. package/dist/modules/devices/api/deviceSerialization.js.map +7 -0
  20. package/dist/modules/devices/api/openapi.js +3 -1
  21. package/dist/modules/devices/api/openapi.js.map +2 -2
  22. package/dist/modules/devices/api/route.js +9 -1
  23. package/dist/modules/devices/api/route.js.map +2 -2
  24. package/dist/modules/devices/backend/devices/[id]/page.js +8 -8
  25. package/dist/modules/devices/backend/devices/[id]/page.js.map +2 -2
  26. package/dist/modules/devices/backend/devices/page.js +12 -12
  27. package/dist/modules/devices/backend/devices/page.js.map +2 -2
  28. package/dist/modules/push_notifications/backend/push_notifications/send/page.js +1 -1
  29. package/dist/modules/push_notifications/backend/push_notifications/send/page.js.map +2 -2
  30. package/package.json +7 -7
  31. package/src/modules/auth/api/reset/validate.ts +57 -0
  32. package/src/modules/auth/data/validators.ts +5 -0
  33. package/src/modules/auth/frontend/reset/[token]/page.tsx +74 -0
  34. package/src/modules/auth/i18n/de.json +4 -0
  35. package/src/modules/auth/i18n/en.json +4 -0
  36. package/src/modules/auth/i18n/es.json +4 -0
  37. package/src/modules/auth/i18n/ko.json +4 -0
  38. package/src/modules/auth/i18n/pl.json +4 -0
  39. package/src/modules/auth/services/authService.ts +18 -2
  40. package/src/modules/customers/backend/hooks/useEmailDuplicateCheck.ts +12 -4
  41. package/src/modules/devices/AGENTS.md +8 -0
  42. package/src/modules/devices/api/admin/devices/[id]/route.ts +5 -34
  43. package/src/modules/devices/api/admin/devices/route.ts +14 -1
  44. package/src/modules/devices/api/deviceList.ts +135 -11
  45. package/src/modules/devices/api/deviceSerialization.ts +50 -0
  46. package/src/modules/devices/api/openapi.ts +9 -1
  47. package/src/modules/devices/api/route.ts +9 -1
  48. package/src/modules/devices/backend/devices/[id]/page.tsx +15 -15
  49. package/src/modules/devices/backend/devices/page.tsx +20 -20
  50. package/src/modules/push_notifications/backend/push_notifications/send/page.tsx +4 -4
@@ -40,16 +40,24 @@ export function useEmailDuplicateCheck(
40
40
  }
41
41
 
42
42
  const normalized = trimmed.toLowerCase()
43
+ const excludedId = typeof recordId === 'string' && recordId.trim().length ? recordId.trim() : null
43
44
  let cancelled = false
44
45
  const controller = new AbortController()
45
46
  const timer = window.setTimeout(async () => {
46
47
  setChecking(true)
47
48
  try {
48
- const queryParam =
49
+ const params = [
49
50
  matchMode === 'prefix'
50
51
  ? `emailStartsWith=${encodeURIComponent(normalized)}`
51
- : `email=${encodeURIComponent(normalized)}`
52
- const call = await apiCall<{ items?: unknown[] }>(`/api/customers/people?${queryParam}&pageSize=5&page=1`, {
52
+ : `email=${encodeURIComponent(normalized)}`,
53
+ 'pageSize=5',
54
+ 'page=1',
55
+ ]
56
+ // The record being edited is never its own duplicate (#5534). Excluding it in the
57
+ // query — not only in the client-side scan below — also stops it from consuming one
58
+ // of the five returned slots and hiding a genuine duplicate.
59
+ if (excludedId) params.push(`excludeIds=${encodeURIComponent(excludedId)}`)
60
+ const call = await apiCall<{ items?: unknown[] }>(`/api/customers/people?${params.join('&')}`, {
53
61
  signal: controller.signal,
54
62
  })
55
63
  if (!call.ok) {
@@ -72,7 +80,7 @@ export function useEmailDuplicateCheck(
72
80
  })
73
81
  .filter((entry: EmailDuplicateMatch | null): entry is EmailDuplicateMatch => !!entry)
74
82
  .find((entry: EmailDuplicateMatch) => {
75
- if (entry.id === recordId) return false
83
+ if (excludedId && entry.id === excludedId) return false
76
84
  return matchMode === 'prefix'
77
85
  ? entry.email.startsWith(normalized)
78
86
  : entry.email === normalized
@@ -23,6 +23,14 @@ delivery logic** (sender, providers, delivery rows, workers live in the `push_no
23
23
  — they carry undo snapshots, query-index side effects, and domain events.
24
24
  - Treat `push_token` as a secret: **never** include it in list/response field sets. Only
25
25
  `push_provider` and `push_token_updated_at` are exposed.
26
+ - Serialize every GET response in **camelCase**, like every other module. The list routes run
27
+ `transformDeviceListItem` (`api/deviceList.ts`) over the query engine's raw projection and the admin
28
+ detail route uses `serializeDeviceDetail` (`api/deviceSerialization.ts`). Both also emit the legacy
29
+ snake_case keys as **deprecated aliases** via `toDeprecatedSnakeCaseAliases`, kept for one minor
30
+ version per `BACKWARD_COMPATIBILITY.md` § 7 and pinned by `TC-DEV-007`; when the bridge is dropped,
31
+ remove the alias spread from those two functions and the deprecated keys from `deviceListItemSchema`
32
+ / `deviceDetailItemSchema`. Adding a column means adding it to `deviceListFields`, the transform, and
33
+ the schemas. See `.ai/specs/2026-08-24-devices-api-camelcase-responses.md` (#5513).
26
34
  - Honor the `pushToken` tri-state on `PUT`: absent key = leave unchanged; explicit `null` = clear
27
35
  (revoked OS permission) and bump `push_token_updated_at`. The command uses own-property presence.
28
36
  - Keep the list route's CRUD cache tag aligned with the command's `resourceKind`. The list reads
@@ -15,6 +15,8 @@ import { updateDeviceSchema } from '../../../../data/validators'
15
15
  import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
16
16
  import { resolveDeviceActorUserId } from '../../../auth'
17
17
  import { executeUpdate, executeDeactivate, type DeviceMutationContext } from '../../../deviceOps'
18
+ import { serializeDeviceDetail, deviceDetailItemSchema } from '../../../deviceSerialization'
19
+ import { DEPRECATED_SNAKE_CASE_NOTICE } from '../../../openapi'
18
20
 
19
21
  const logger = createLogger('devices')
20
22
 
@@ -38,23 +40,6 @@ async function loadDevice(
38
40
  return findOneWithDecryption(em, UserDevice, { id, tenantId, deletedAt: null }, undefined, { tenantId })
39
41
  }
40
42
 
41
- // push_token is a secret and is never returned.
42
- function serializeDevice(device: UserDevice) {
43
- return {
44
- id: device.id,
45
- user_id: device.userId,
46
- device_id: device.deviceId,
47
- platform: device.platform,
48
- client_app_version: device.clientAppVersion ?? null,
49
- os_version: device.osVersion ?? null,
50
- push_provider: device.pushProvider ?? null,
51
- push_token_updated_at: device.pushTokenUpdatedAt ? device.pushTokenUpdatedAt.toISOString() : null,
52
- last_seen_at: device.lastSeenAt ? device.lastSeenAt.toISOString() : null,
53
- created_at: device.createdAt ? device.createdAt.toISOString() : null,
54
- updated_at: device.updatedAt ? device.updatedAt.toISOString() : null,
55
- }
56
- }
57
-
58
43
  export async function GET(req: Request, { params }: { params: { id: string } }) {
59
44
  const { translate } = await resolveTranslations()
60
45
  try {
@@ -76,7 +61,7 @@ export async function GET(req: Request, { params }: { params: { id: string } })
76
61
  if (!isOrganizationReadAccessAllowed({ scope, auth, organizationId: device.organizationId ?? null })) {
77
62
  return NextResponse.json({ error: translate('devices.errors.forbidden', 'Access denied') }, { status: 403 })
78
63
  }
79
- return NextResponse.json({ item: serializeDevice(device) })
64
+ return NextResponse.json({ item: serializeDeviceDetail(device) })
80
65
  } catch (err) {
81
66
  if (isCrudHttpError(err)) {
82
67
  return NextResponse.json(err.body, { status: err.status })
@@ -177,21 +162,7 @@ export async function DELETE(req: Request, { params }: { params: { id: string }
177
162
 
178
163
  const okResponseSchema = z.object({ ok: z.literal(true), id: z.string().uuid().optional() })
179
164
  const errorResponseSchema = z.object({ error: z.string() })
180
- const detailResponseSchema = z.object({
181
- item: z.object({
182
- id: z.string().uuid(),
183
- user_id: z.string().uuid(),
184
- device_id: z.string(),
185
- platform: z.enum(['ios', 'android', 'web']),
186
- client_app_version: z.string().nullable(),
187
- os_version: z.string().nullable(),
188
- push_provider: z.string().nullable(),
189
- push_token_updated_at: z.string().nullable(),
190
- last_seen_at: z.string().nullable(),
191
- created_at: z.string().nullable(),
192
- updated_at: z.string().nullable(),
193
- }),
194
- })
165
+ const detailResponseSchema = z.object({ item: deviceDetailItemSchema })
195
166
 
196
167
  export const openApi: OpenApiRouteDoc = {
197
168
  tag: 'Devices (admin)',
@@ -199,7 +170,7 @@ export const openApi: OpenApiRouteDoc = {
199
170
  methods: {
200
171
  GET: {
201
172
  summary: 'Get any device',
202
- description: 'Admin: fetch a single device by id (push_token is never returned).',
173
+ description: `Admin: fetch a single device by id (push_token is never returned). ${DEPRECATED_SNAKE_CASE_NOTICE}`,
203
174
  responses: [{ status: 200, description: 'Device', schema: detailResponseSchema }],
204
175
  errors: [
205
176
  { status: 400, description: 'Invalid id', schema: errorResponseSchema },
@@ -2,6 +2,7 @@ import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import { NextResponse } from 'next/server'
3
3
  import { z } from 'zod'
4
4
  import { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'
5
+ import { toHeaderLabel } from '@open-mercato/shared/lib/crud/exporters'
5
6
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
7
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
7
8
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
@@ -21,7 +22,14 @@ import {
21
22
  } from '../../../data/validators'
22
23
  import { resolveDeviceActorUserId } from '../../auth'
23
24
  import { createDevicesCrudOpenApi, createPagedListResponseSchema } from '../../openapi'
24
- import { deviceListSchema, deviceListFields, deviceListSortFieldMap, deviceListItemSchema } from '../../deviceList'
25
+ import {
26
+ deviceListSchema,
27
+ deviceListFields,
28
+ deviceListSortFieldMap,
29
+ deviceListItemSchema,
30
+ deviceExportColumnFields,
31
+ transformDeviceListItem,
32
+ } from '../../deviceList'
25
33
  import { executeRegister, type DeviceMutationContext } from '../../deviceOps'
26
34
 
27
35
  const logger = createLogger('devices')
@@ -55,6 +63,11 @@ const crud = makeCrudRoute({
55
63
  entityId: E.devices.user_device,
56
64
  fields: deviceListFields,
57
65
  sortFieldMap: deviceListSortFieldMap,
66
+ // The query engine projects raw column names; expose them in camelCase like every other module.
67
+ transformItem: transformDeviceListItem,
68
+ // Without an explicit column set the factory's default export spreads each item, so the deprecated
69
+ // snake_case aliases would double every column in the CSV/JSON/XML export.
70
+ export: { columns: deviceExportColumnFields.map((field) => ({ field, header: toHeaderLabel(field) })) },
58
71
  // Tenant + org scope is enforced by the factory (orm.tenantField + orm.orgField); only the optional
59
72
  // userId/platform narrowing is left to do here.
60
73
  buildFilters: async (query) => {
@@ -30,25 +30,149 @@ export const deviceListFields: string[] = [
30
30
  'updated_at',
31
31
  ]
32
32
 
33
+ // The admin list enables exports, and the factory's default export derives its columns by spreading
34
+ // each item — which would emit both spellings of every aliased key. Pin the canonical camelCase set
35
+ // so the export stays single-spelled and does not change shape when the aliases are dropped.
36
+ export const deviceExportColumnFields: string[] = [
37
+ 'id',
38
+ 'tenantId',
39
+ 'organizationId',
40
+ 'userId',
41
+ 'deviceId',
42
+ 'platform',
43
+ 'clientAppVersion',
44
+ 'osVersion',
45
+ 'locale',
46
+ 'pushProvider',
47
+ 'pushTokenUpdatedAt',
48
+ 'lastSeenAt',
49
+ 'createdAt',
50
+ 'updatedAt',
51
+ ]
52
+
33
53
  export const deviceListSortFieldMap: Record<string, string> = {
34
54
  lastSeenAt: 'last_seen_at',
35
55
  createdAt: 'created_at',
36
56
  updatedAt: 'updated_at',
37
57
  }
38
58
 
59
+ // The query engine projects the raw column names listed in `deviceListFields`. Every other module
60
+ // serializes its list items in camelCase (`sales/orders` → `orderNumber`, `warranty_claims` →
61
+ // `claimNumber`), so the list routes run this transform to expose the platform convention (#5513).
62
+ // The snake_case keys stay alongside as deprecated aliases for one minor version, per the
63
+ // deprecation protocol in BACKWARD_COMPATIBILITY.md § 7 (API Route URLs — response fields).
64
+ export function transformDeviceListItem(item: unknown): unknown {
65
+ const record = toRecord(item)
66
+ if (!Object.keys(record).length) return item
67
+ const camel = {
68
+ id: readString(record, 'id', 'id'),
69
+ tenantId: readString(record, 'tenant_id', 'tenantId'),
70
+ organizationId: readString(record, 'organization_id', 'organizationId'),
71
+ userId: readString(record, 'user_id', 'userId'),
72
+ deviceId: readString(record, 'device_id', 'deviceId'),
73
+ platform: readString(record, 'platform', 'platform'),
74
+ clientAppVersion: readString(record, 'client_app_version', 'clientAppVersion'),
75
+ osVersion: readString(record, 'os_version', 'osVersion'),
76
+ locale: readString(record, 'locale', 'locale'),
77
+ pushProvider: readString(record, 'push_provider', 'pushProvider'),
78
+ pushTokenUpdatedAt: toIso(record.push_token_updated_at ?? record.pushTokenUpdatedAt),
79
+ lastSeenAt: toIso(record.last_seen_at ?? record.lastSeenAt),
80
+ createdAt: toIso(record.created_at ?? record.createdAt),
81
+ updatedAt: toIso(record.updated_at ?? record.updatedAt),
82
+ }
83
+ // Spread the raw record first so anything the projection carries beyond the declared field set
84
+ // (custom-field keys, future columns) survives; the aliases then restate the snake_case keys with
85
+ // the same normalized values as their camelCase counterparts.
86
+ return { ...record, ...camel, ...toDeprecatedSnakeCaseAliases(camel) }
87
+ }
88
+
89
+ /**
90
+ * @deprecated Snake_case device response keys are superseded by the camelCase keys and are removed in
91
+ * the next minor release. Read `deviceId`, `userId`, `lastSeenAt`, … instead. See UPGRADE_NOTES.md.
92
+ */
93
+ export function toDeprecatedSnakeCaseAliases(item: DeviceResponseItem): Record<string, unknown> {
94
+ const aliases: Record<string, unknown> = {
95
+ tenant_id: item.tenantId,
96
+ organization_id: item.organizationId,
97
+ user_id: item.userId,
98
+ device_id: item.deviceId,
99
+ client_app_version: item.clientAppVersion,
100
+ os_version: item.osVersion,
101
+ push_provider: item.pushProvider,
102
+ push_token_updated_at: item.pushTokenUpdatedAt,
103
+ last_seen_at: item.lastSeenAt,
104
+ created_at: item.createdAt,
105
+ updated_at: item.updatedAt,
106
+ }
107
+ // The detail response never carried tenant/org, so an absent camelCase source must not grow a new
108
+ // key here — only keys the caller actually supplied get an alias.
109
+ for (const key of Object.keys(aliases)) {
110
+ if (aliases[key] === undefined) delete aliases[key]
111
+ }
112
+ return aliases
113
+ }
114
+
115
+ export type DeviceResponseItem = {
116
+ tenantId?: string | null
117
+ organizationId?: string | null
118
+ userId: string | null
119
+ deviceId: string | null
120
+ clientAppVersion: string | null
121
+ osVersion: string | null
122
+ pushProvider: string | null
123
+ pushTokenUpdatedAt?: string | null
124
+ lastSeenAt?: string | null
125
+ createdAt?: string | null
126
+ updatedAt: string | null
127
+ }
128
+
129
+ function toRecord(value: unknown): Record<string, unknown> {
130
+ return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
131
+ }
132
+
133
+ function readString(record: Record<string, unknown>, snakeKey: string, camelKey: string): string | null {
134
+ const value = record[snakeKey] ?? record[camelKey]
135
+ return typeof value === 'string' ? value : null
136
+ }
137
+
138
+ export function toIso(value: unknown): string | null {
139
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString()
140
+ if (typeof value === 'string') {
141
+ const date = new Date(value)
142
+ return Number.isNaN(date.getTime()) ? value : date.toISOString()
143
+ }
144
+ return null
145
+ }
146
+
147
+ // In-code contract markers. The shared zodToJsonSchema converter does not currently emit per-property
148
+ // descriptions for object schemas, so the rendered deprecation notice lives on the endpoint description
149
+ // (`DEPRECATED_SNAKE_CASE_NOTICE` in ./openapi); these become visible for free if that changes.
150
+ const deprecatedAlias = (of: string) => `Deprecated alias for \`${of}\`; removed in the next minor release.`
151
+
39
152
  export const deviceListItemSchema = z.object({
40
153
  id: z.string().uuid(),
41
- tenant_id: z.string().uuid(),
42
- organization_id: z.string().uuid().nullable().optional(),
43
- user_id: z.string().uuid(),
44
- device_id: z.string(),
154
+ tenantId: z.string().uuid(),
155
+ organizationId: z.string().uuid().nullable().optional(),
156
+ userId: z.string().uuid(),
157
+ deviceId: z.string(),
45
158
  platform: z.enum(['ios', 'android', 'web']),
46
- client_app_version: z.string().nullable().optional(),
47
- os_version: z.string().nullable().optional(),
159
+ clientAppVersion: z.string().nullable().optional(),
160
+ osVersion: z.string().nullable().optional(),
48
161
  locale: z.string().nullable().optional(),
49
- push_provider: z.string().nullable().optional(),
50
- push_token_updated_at: z.string().nullable().optional(),
51
- last_seen_at: z.string().nullable().optional(),
52
- created_at: z.string().nullable().optional(),
53
- updated_at: z.string().nullable().optional(),
162
+ pushProvider: z.string().nullable().optional(),
163
+ pushTokenUpdatedAt: z.string().nullable().optional(),
164
+ lastSeenAt: z.string().nullable().optional(),
165
+ createdAt: z.string().nullable().optional(),
166
+ updatedAt: z.string().nullable().optional(),
167
+ tenant_id: z.string().uuid().describe(deprecatedAlias('tenantId')),
168
+ organization_id: z.string().uuid().nullable().optional().describe(deprecatedAlias('organizationId')),
169
+ user_id: z.string().uuid().describe(deprecatedAlias('userId')),
170
+ device_id: z.string().describe(deprecatedAlias('deviceId')),
171
+ client_app_version: z.string().nullable().optional().describe(deprecatedAlias('clientAppVersion')),
172
+ os_version: z.string().nullable().optional().describe(deprecatedAlias('osVersion')),
173
+ push_provider: z.string().nullable().optional().describe(deprecatedAlias('pushProvider')),
174
+ push_token_updated_at: z.string().nullable().optional().describe(deprecatedAlias('pushTokenUpdatedAt')),
175
+ last_seen_at: z.string().nullable().optional().describe(deprecatedAlias('lastSeenAt')),
176
+ created_at: z.string().nullable().optional().describe(deprecatedAlias('createdAt')),
177
+ updated_at: z.string().nullable().optional().describe(deprecatedAlias('updatedAt')),
54
178
  })
@@ -0,0 +1,50 @@
1
+ import { z } from 'zod'
2
+ import type { UserDevice } from '../data/entities'
3
+ import { toDeprecatedSnakeCaseAliases, toIso } from './deviceList'
4
+
5
+ // Detail contract for `GET /api/devices/admin/devices/:id`. Keys are camelCase like every other
6
+ // module's responses (#5513); the snake_case keys stay alongside as deprecated aliases for one minor
7
+ // version, per the deprecation protocol in BACKWARD_COMPATIBILITY.md § 7.
8
+ // push_token is a secret and is never returned.
9
+ export function serializeDeviceDetail(device: UserDevice): Record<string, unknown> {
10
+ const camel = {
11
+ id: device.id,
12
+ userId: device.userId,
13
+ deviceId: device.deviceId,
14
+ platform: device.platform,
15
+ clientAppVersion: device.clientAppVersion ?? null,
16
+ osVersion: device.osVersion ?? null,
17
+ pushProvider: device.pushProvider ?? null,
18
+ pushTokenUpdatedAt: toIso(device.pushTokenUpdatedAt),
19
+ lastSeenAt: toIso(device.lastSeenAt),
20
+ createdAt: toIso(device.createdAt),
21
+ updatedAt: toIso(device.updatedAt),
22
+ }
23
+ return { ...camel, ...toDeprecatedSnakeCaseAliases(camel) }
24
+ }
25
+
26
+ // See the note in ./deviceList — the rendered deprecation notice lives on the endpoint description.
27
+ const deprecatedAlias = (of: string) => `Deprecated alias for \`${of}\`; removed in the next minor release.`
28
+
29
+ export const deviceDetailItemSchema = z.object({
30
+ id: z.string().uuid(),
31
+ userId: z.string().uuid(),
32
+ deviceId: z.string(),
33
+ platform: z.enum(['ios', 'android', 'web']),
34
+ clientAppVersion: z.string().nullable(),
35
+ osVersion: z.string().nullable(),
36
+ pushProvider: z.string().nullable(),
37
+ pushTokenUpdatedAt: z.string().nullable(),
38
+ lastSeenAt: z.string().nullable(),
39
+ createdAt: z.string().nullable(),
40
+ updatedAt: z.string().nullable(),
41
+ user_id: z.string().uuid().describe(deprecatedAlias('userId')),
42
+ device_id: z.string().describe(deprecatedAlias('deviceId')),
43
+ client_app_version: z.string().nullable().describe(deprecatedAlias('clientAppVersion')),
44
+ os_version: z.string().nullable().describe(deprecatedAlias('osVersion')),
45
+ push_provider: z.string().nullable().describe(deprecatedAlias('pushProvider')),
46
+ push_token_updated_at: z.string().nullable().describe(deprecatedAlias('pushTokenUpdatedAt')),
47
+ last_seen_at: z.string().nullable().describe(deprecatedAlias('lastSeenAt')),
48
+ created_at: z.string().nullable().describe(deprecatedAlias('createdAt')),
49
+ updated_at: z.string().nullable().describe(deprecatedAlias('updatedAt')),
50
+ })
@@ -12,10 +12,18 @@ export function createPagedListResponseSchema(itemSchema: ZodTypeAny) {
12
12
 
13
13
  // createCrudOpenApiFactory already falls back to the shared default create/ok response schemas
14
14
  // when omitted, so there is nothing module-specific to re-pass or re-export here.
15
+ // The shared zodToJsonSchema converter does not emit per-property descriptions for object schemas, so
16
+ // the `.describe()` markers on the deprecated keys in deviceList.ts never reach the document. State the
17
+ // deprecation in the endpoint description, which does render (#5513).
18
+ export const DEPRECATED_SNAKE_CASE_NOTICE =
19
+ 'Response keys are camelCase. The snake_case keys (`user_id`, `device_id`, `last_seen_at`, …) are ' +
20
+ 'deprecated aliases of their camelCase counterparts, retained for one minor version and removed in ' +
21
+ 'the next; read `userId`, `deviceId`, `lastSeenAt`, … instead.'
22
+
15
23
  const buildDevicesCrudOpenApi = createCrudOpenApiFactory({
16
24
  defaultTag: 'Devices',
17
25
  makeListDescription: ({ pluralLower }) =>
18
- `Returns the authenticated user's registered ${pluralLower} (admins may list across users).`,
26
+ `Returns the authenticated user's registered ${pluralLower} (admins may list across users). ${DEPRECATED_SNAKE_CASE_NOTICE}`,
19
27
  })
20
28
 
21
29
  export function createDevicesCrudOpenApi(options: CrudOpenApiOptions): OpenApiRouteDoc {
@@ -20,7 +20,13 @@ import {
20
20
  } from '../data/validators'
21
21
  import { resolveDeviceActorUserId } from './auth'
22
22
  import { createDevicesCrudOpenApi, createPagedListResponseSchema } from './openapi'
23
- import { deviceListSchema, deviceListFields, deviceListSortFieldMap, deviceListItemSchema } from './deviceList'
23
+ import {
24
+ deviceListSchema,
25
+ deviceListFields,
26
+ deviceListSortFieldMap,
27
+ deviceListItemSchema,
28
+ transformDeviceListItem,
29
+ } from './deviceList'
24
30
  import { executeRegister, type DeviceMutationContext } from './deviceOps'
25
31
 
26
32
  const logger = createLogger('devices')
@@ -59,6 +65,8 @@ const crud = makeCrudRoute({
59
65
  // push_token is a secret and is never exposed via the list API.
60
66
  fields: deviceListFields,
61
67
  sortFieldMap: deviceListSortFieldMap,
68
+ // The query engine projects raw column names; expose them in camelCase like every other module.
69
+ transformItem: transformDeviceListItem,
62
70
  // Exports are off for the self-serve list. The factory enables them for every list that does not
63
71
  // declare `export` (resolveAvailableExportFormats falls back to csv/json/xml/markdown), and its
64
72
  // full-export branch replaces the filters with `{}` instead of calling `buildFilters`
@@ -12,13 +12,13 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
12
12
 
13
13
  type DeviceDetail = {
14
14
  id: string
15
- user_id: string
16
- device_id: string
15
+ userId: string
16
+ deviceId: string
17
17
  platform: string
18
- client_app_version: string | null
19
- os_version: string | null
20
- push_provider: string | null
21
- updated_at: string | null
18
+ clientAppVersion: string | null
19
+ osVersion: string | null
20
+ pushProvider: string | null
21
+ updatedAt: string | null
22
22
  }
23
23
 
24
24
  type FormValues = {
@@ -65,7 +65,7 @@ export default function DeviceAdminEditPage({ params }: { params?: { id?: string
65
65
  // Resolve the owner's display name for a link to their profile. Devices admins may not hold
66
66
  // auth.users.list, so fall back to the raw id (rendered without a link) instead of redirecting.
67
67
  React.useEffect(() => {
68
- const userId = device?.user_id
68
+ const userId = device?.userId
69
69
  if (!userId) return
70
70
  let cancelled = false
71
71
  void (async () => {
@@ -80,7 +80,7 @@ export default function DeviceAdminEditPage({ params }: { params?: { id?: string
80
80
  if (label) setUserLabel(label)
81
81
  })()
82
82
  return () => { cancelled = true }
83
- }, [device?.user_id])
83
+ }, [device?.userId])
84
84
 
85
85
  const fields = React.useMemo<CrudField[]>(() => [
86
86
  { id: 'clientAppVersion', label: t('devices.form.appVersion'), type: 'text' },
@@ -122,7 +122,7 @@ export default function DeviceAdminEditPage({ params }: { params?: { id?: string
122
122
  <dl className="grid grid-cols-1 gap-3 rounded-md border bg-muted p-4 text-sm sm:grid-cols-3">
123
123
  <div>
124
124
  <dt className="text-xs font-medium text-muted-foreground">{t('devices.form.deviceId')}</dt>
125
- <dd className="mt-1"><code className="text-xs">{device.device_id}</code></dd>
125
+ <dd className="mt-1"><code className="text-xs">{device.deviceId}</code></dd>
126
126
  </div>
127
127
  <div>
128
128
  <dt className="text-xs font-medium text-muted-foreground">{t('devices.form.platform')}</dt>
@@ -131,20 +131,20 @@ export default function DeviceAdminEditPage({ params }: { params?: { id?: string
131
131
  <div>
132
132
  <dt className="text-xs font-medium text-muted-foreground">{t('devices.form.userId')}</dt>
133
133
  <dd className="mt-1">{userLabel ? (
134
- <Link href={`/backend/users/${encodeURIComponent(device.user_id)}/edit`} className="text-primary hover:underline">{userLabel}</Link>
134
+ <Link href={`/backend/users/${encodeURIComponent(device.userId)}/edit`} className="text-primary hover:underline">{userLabel}</Link>
135
135
  ) : (
136
- <code className="text-xs">{device.user_id}</code>
136
+ <code className="text-xs">{device.userId}</code>
137
137
  )}</dd>
138
138
  </div>
139
139
  </dl>
140
140
  )}
141
141
  fields={fields}
142
142
  groups={groups}
143
- optimisticLockUpdatedAt={device.updated_at}
143
+ optimisticLockUpdatedAt={device.updatedAt}
144
144
  initialValues={{
145
- clientAppVersion: device.client_app_version ?? '',
146
- osVersion: device.os_version ?? '',
147
- pushProvider: device.push_provider ?? '',
145
+ clientAppVersion: device.clientAppVersion ?? '',
146
+ osVersion: device.osVersion ?? '',
147
+ pushProvider: device.pushProvider ?? '',
148
148
  }}
149
149
  submitLabel={t('common.save')}
150
150
  cancelHref="/backend/devices"
@@ -15,15 +15,15 @@ import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar
15
15
 
16
16
  type Row = {
17
17
  id: string
18
- user_id: string
19
- device_id: string
18
+ userId: string
19
+ deviceId: string
20
20
  platform: string
21
- client_app_version: string | null
22
- os_version: string | null
23
- push_provider: string | null
24
- push_token_updated_at: string | null
25
- last_seen_at: string | null
26
- created_at: string | null
21
+ clientAppVersion: string | null
22
+ osVersion: string | null
23
+ pushProvider: string | null
24
+ pushTokenUpdatedAt: string | null
25
+ lastSeenAt: string | null
26
+ createdAt: string | null
27
27
  }
28
28
 
29
29
  type ResponsePayload = {
@@ -184,16 +184,16 @@ export default function DevicesAdminListPage() {
184
184
 
185
185
  const columns = React.useMemo<ColumnDef<Row>[]>(() => [
186
186
  {
187
- accessorKey: 'device_id',
187
+ accessorKey: 'deviceId',
188
188
  header: t('devices.list.columns.device'),
189
- cell: ({ row }) => <code className="text-xs">{row.original.device_id}</code>,
189
+ cell: ({ row }) => <code className="text-xs">{row.original.deviceId}</code>,
190
190
  },
191
191
  { accessorKey: 'platform', header: t('devices.list.columns.platform') },
192
192
  {
193
- accessorKey: 'user_id',
193
+ accessorKey: 'userId',
194
194
  header: t('devices.list.columns.user'),
195
195
  cell: ({ row }) => {
196
- const userId = row.original.user_id
196
+ const userId = row.original.userId
197
197
  const label = userLabelById.get(userId)
198
198
  return (
199
199
  // Stop the click bubbling to the row, whose default action navigates to the device edit page.
@@ -208,24 +208,24 @@ export default function DevicesAdminListPage() {
208
208
  },
209
209
  },
210
210
  {
211
- accessorKey: 'client_app_version',
211
+ accessorKey: 'clientAppVersion',
212
212
  header: t('devices.list.columns.appVersion'),
213
- cell: ({ row }) => row.original.client_app_version || t('devices.list.noValue'),
213
+ cell: ({ row }) => row.original.clientAppVersion || t('devices.list.noValue'),
214
214
  },
215
215
  {
216
- accessorKey: 'os_version',
216
+ accessorKey: 'osVersion',
217
217
  header: t('devices.list.columns.osVersion'),
218
- cell: ({ row }) => row.original.os_version || t('devices.list.noValue'),
218
+ cell: ({ row }) => row.original.osVersion || t('devices.list.noValue'),
219
219
  },
220
220
  {
221
- accessorKey: 'push_provider',
221
+ accessorKey: 'pushProvider',
222
222
  header: t('devices.list.columns.pushProvider'),
223
- cell: ({ row }) => row.original.push_provider || t('devices.list.noValue'),
223
+ cell: ({ row }) => row.original.pushProvider || t('devices.list.noValue'),
224
224
  },
225
225
  {
226
- accessorKey: 'last_seen_at',
226
+ accessorKey: 'lastSeenAt',
227
227
  header: t('devices.list.columns.lastSeen'),
228
- cell: ({ row }) => formatDate(row.original.last_seen_at, t),
228
+ cell: ({ row }) => formatDate(row.original.lastSeenAt, t),
229
229
  },
230
230
  ], [t, userLabelById])
231
231
 
@@ -70,7 +70,7 @@ function DeviceField({ value, setValue, setFormValue, values, onState }: CrudCus
70
70
  let cancelled = false
71
71
  setLoading(true)
72
72
  const params = new URLSearchParams({ userId, pageSize: '50' })
73
- apiCall<{ items?: Array<{ id: string; device_id: string; platform: string; push_provider?: string | null }> }>(
73
+ apiCall<{ items?: Array<{ id: string; deviceId: string; platform: string; pushProvider?: string | null }> }>(
74
74
  `/api/devices/admin/devices?${params.toString()}`,
75
75
  { headers: { 'x-om-forbidden-redirect': '0' } },
76
76
  { fallback: null },
@@ -80,9 +80,9 @@ function DeviceField({ value, setValue, setFormValue, values, onState }: CrudCus
80
80
  if (cancelled) return
81
81
  const items = (call && call.ok ? call.result?.items : []) ?? []
82
82
  const opts = items
83
- .filter((d): d is { id: string; device_id: string; platform: string; push_provider?: string | null } =>
84
- !!d && typeof d.id === 'string' && !!d.push_provider)
85
- .map((d) => ({ id: d.id, label: `${d.device_id} · ${d.platform}${d.push_provider ? ` · ${d.push_provider}` : ''}`, platform: d.platform }))
83
+ .filter((d): d is { id: string; deviceId: string; platform: string; pushProvider?: string | null } =>
84
+ !!d && typeof d.id === 'string' && !!d.pushProvider)
85
+ .map((d) => ({ id: d.id, label: `${d.deviceId} · ${d.platform}${d.pushProvider ? ` · ${d.pushProvider}` : ''}`, platform: d.platform }))
86
86
  setDevices(opts)
87
87
  if (selected && !opts.some((o) => o.id === selected)) setValue('')
88
88
  })