@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/reset/validate.js +55 -0
- package/dist/modules/auth/api/reset/validate.js.map +7 -0
- package/dist/modules/auth/data/validators.js +5 -1
- package/dist/modules/auth/data/validators.js.map +2 -2
- package/dist/modules/auth/frontend/reset/[token]/page.js +46 -0
- package/dist/modules/auth/frontend/reset/[token]/page.js.map +2 -2
- package/dist/modules/auth/services/authService.js +16 -2
- package/dist/modules/auth/services/authService.js.map +2 -2
- package/dist/modules/customers/backend/hooks/useEmailDuplicateCheck.js +9 -3
- package/dist/modules/customers/backend/hooks/useEmailDuplicateCheck.js.map +2 -2
- package/dist/modules/devices/api/admin/devices/[id]/route.js +5 -32
- package/dist/modules/devices/api/admin/devices/[id]/route.js.map +2 -2
- package/dist/modules/devices/api/admin/devices/route.js +14 -1
- package/dist/modules/devices/api/admin/devices/route.js.map +2 -2
- package/dist/modules/devices/api/deviceList.js +99 -12
- package/dist/modules/devices/api/deviceList.js.map +2 -2
- package/dist/modules/devices/api/deviceSerialization.js +46 -0
- package/dist/modules/devices/api/deviceSerialization.js.map +7 -0
- package/dist/modules/devices/api/openapi.js +3 -1
- package/dist/modules/devices/api/openapi.js.map +2 -2
- package/dist/modules/devices/api/route.js +9 -1
- package/dist/modules/devices/api/route.js.map +2 -2
- package/dist/modules/devices/backend/devices/[id]/page.js +8 -8
- package/dist/modules/devices/backend/devices/[id]/page.js.map +2 -2
- package/dist/modules/devices/backend/devices/page.js +12 -12
- package/dist/modules/devices/backend/devices/page.js.map +2 -2
- package/dist/modules/push_notifications/backend/push_notifications/send/page.js +1 -1
- package/dist/modules/push_notifications/backend/push_notifications/send/page.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/api/reset/validate.ts +57 -0
- package/src/modules/auth/data/validators.ts +5 -0
- package/src/modules/auth/frontend/reset/[token]/page.tsx +74 -0
- package/src/modules/auth/i18n/de.json +4 -0
- package/src/modules/auth/i18n/en.json +4 -0
- package/src/modules/auth/i18n/es.json +4 -0
- package/src/modules/auth/i18n/ko.json +4 -0
- package/src/modules/auth/i18n/pl.json +4 -0
- package/src/modules/auth/services/authService.ts +18 -2
- package/src/modules/customers/backend/hooks/useEmailDuplicateCheck.ts +12 -4
- package/src/modules/devices/AGENTS.md +8 -0
- package/src/modules/devices/api/admin/devices/[id]/route.ts +5 -34
- package/src/modules/devices/api/admin/devices/route.ts +14 -1
- package/src/modules/devices/api/deviceList.ts +135 -11
- package/src/modules/devices/api/deviceSerialization.ts +50 -0
- package/src/modules/devices/api/openapi.ts +9 -1
- package/src/modules/devices/api/route.ts +9 -1
- package/src/modules/devices/backend/devices/[id]/page.tsx +15 -15
- package/src/modules/devices/backend/devices/page.tsx +20 -20
- 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
|
|
49
|
+
const params = [
|
|
49
50
|
matchMode === 'prefix'
|
|
50
51
|
? `emailStartsWith=${encodeURIComponent(normalized)}`
|
|
51
|
-
: `email=${encodeURIComponent(normalized)}
|
|
52
|
-
|
|
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 ===
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
159
|
+
clientAppVersion: z.string().nullable().optional(),
|
|
160
|
+
osVersion: z.string().nullable().optional(),
|
|
48
161
|
locale: z.string().nullable().optional(),
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 {
|
|
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
|
-
|
|
16
|
-
|
|
15
|
+
userId: string
|
|
16
|
+
deviceId: string
|
|
17
17
|
platform: string
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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?.
|
|
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?.
|
|
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.
|
|
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.
|
|
134
|
+
<Link href={`/backend/users/${encodeURIComponent(device.userId)}/edit`} className="text-primary hover:underline">{userLabel}</Link>
|
|
135
135
|
) : (
|
|
136
|
-
<code className="text-xs">{device.
|
|
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.
|
|
143
|
+
optimisticLockUpdatedAt={device.updatedAt}
|
|
144
144
|
initialValues={{
|
|
145
|
-
clientAppVersion: device.
|
|
146
|
-
osVersion: device.
|
|
147
|
-
pushProvider: device.
|
|
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
|
-
|
|
19
|
-
|
|
18
|
+
userId: string
|
|
19
|
+
deviceId: string
|
|
20
20
|
platform: string
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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: '
|
|
187
|
+
accessorKey: 'deviceId',
|
|
188
188
|
header: t('devices.list.columns.device'),
|
|
189
|
-
cell: ({ row }) => <code className="text-xs">{row.original.
|
|
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: '
|
|
193
|
+
accessorKey: 'userId',
|
|
194
194
|
header: t('devices.list.columns.user'),
|
|
195
195
|
cell: ({ row }) => {
|
|
196
|
-
const userId = row.original.
|
|
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: '
|
|
211
|
+
accessorKey: 'clientAppVersion',
|
|
212
212
|
header: t('devices.list.columns.appVersion'),
|
|
213
|
-
cell: ({ row }) => row.original.
|
|
213
|
+
cell: ({ row }) => row.original.clientAppVersion || t('devices.list.noValue'),
|
|
214
214
|
},
|
|
215
215
|
{
|
|
216
|
-
accessorKey: '
|
|
216
|
+
accessorKey: 'osVersion',
|
|
217
217
|
header: t('devices.list.columns.osVersion'),
|
|
218
|
-
cell: ({ row }) => row.original.
|
|
218
|
+
cell: ({ row }) => row.original.osVersion || t('devices.list.noValue'),
|
|
219
219
|
},
|
|
220
220
|
{
|
|
221
|
-
accessorKey: '
|
|
221
|
+
accessorKey: 'pushProvider',
|
|
222
222
|
header: t('devices.list.columns.pushProvider'),
|
|
223
|
-
cell: ({ row }) => row.original.
|
|
223
|
+
cell: ({ row }) => row.original.pushProvider || t('devices.list.noValue'),
|
|
224
224
|
},
|
|
225
225
|
{
|
|
226
|
-
accessorKey: '
|
|
226
|
+
accessorKey: 'lastSeenAt',
|
|
227
227
|
header: t('devices.list.columns.lastSeen'),
|
|
228
|
-
cell: ({ row }) => formatDate(row.original.
|
|
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;
|
|
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;
|
|
84
|
-
!!d && typeof d.id === 'string' && !!d.
|
|
85
|
-
.map((d) => ({ id: d.id, label: `${d.
|
|
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
|
})
|