@open-mercato/core 0.6.8-develop.7079.1.dbf29dd6a1 → 0.6.8-develop.7080.1.9b0d558a3f
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/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/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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/devices/api/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveLocaleFromAcceptLanguage } from '@open-mercato/shared/lib/i18n/locale'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { E } from '#generated/entities.ids.generated'\nimport { UserDevice } from '../data/entities'\nimport {\n registerDeviceSchema,\n registerDeviceCommandSchema,\n type RegisterDeviceCommandInput,\n} from '../data/validators'\nimport { resolveDeviceActorUserId } from './auth'\nimport { createDevicesCrudOpenApi, createPagedListResponseSchema } from './openapi'\nimport {
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,qBAAqB;AAC9B,SAAS,4BAA4B,+BAA+B;AACpE,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,0CAA0C;AACnD,SAAS,uBAAuB;AAChC,SAAS,oBAAoB;AAC7B,SAAS,2BAA2B;AACpC,SAAS,uCAAuC;AAEhD,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,kBAAkB;AAC3B;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AACP,SAAS,gCAAgC;AACzC,SAAS,0BAA0B,qCAAqC;AACxE,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { resolveLocaleFromAcceptLanguage } from '@open-mercato/shared/lib/i18n/locale'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { E } from '#generated/entities.ids.generated'\nimport { UserDevice } from '../data/entities'\nimport {\n registerDeviceSchema,\n registerDeviceCommandSchema,\n type RegisterDeviceCommandInput,\n} from '../data/validators'\nimport { resolveDeviceActorUserId } from './auth'\nimport { createDevicesCrudOpenApi, createPagedListResponseSchema } from './openapi'\nimport {\n deviceListSchema,\n deviceListFields,\n deviceListSortFieldMap,\n deviceListItemSchema,\n transformDeviceListItem,\n} from './deviceList'\nimport { executeRegister, type DeviceMutationContext } from './deviceOps'\n\nconst logger = createLogger('devices')\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['devices.view'] },\n POST: { requireAuth: true, requireFeatures: ['devices.manage'] },\n}\n\nexport const metadata = routeMetadata\n\nconst NO_MATCH_USER_ID = '00000000-0000-0000-0000-000000000000'\n\nconst crud = makeCrudRoute({\n metadata: routeMetadata,\n orm: {\n entity: UserDevice,\n idField: 'id',\n // organization_id stays nullable, but scoping follows the standard org-scoped pattern (like every\n // other module): the factory auto-filters by the caller's organization scope. The query engine's\n // org scope is null-aware \u2014 callers whose scope includes null (or who have no org restriction) also\n // see null-org rows; a caller pinned to a specific org does not.\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n indexer: { entityType: E.devices.user_device },\n // Align the CRUD cache resource tag with the command's resourceKind ('devices.user_device') so\n // writes through the device commands invalidate this list cache. Without this, the factory falls\n // back to the ORM entity name and the cache is never busted (stale list under ENABLE_CRUD_API_CACHE).\n // The factory does not own the write methods here (custom POST + command bus), so no events are emitted.\n events: { module: 'devices', entity: 'user_device' },\n list: {\n schema: deviceListSchema,\n entityId: E.devices.user_device,\n // push_token is a secret and is never exposed via the list API.\n fields: deviceListFields,\n sortFieldMap: deviceListSortFieldMap,\n // The query engine projects raw column names; expose them in camelCase like every other module.\n transformItem: transformDeviceListItem,\n // Exports are off for the self-serve list. The factory enables them for every list that does not\n // declare `export` (resolveAvailableExportFormats falls back to csv/json/xml/markdown), and its\n // full-export branch replaces the filters with `{}` instead of calling `buildFilters`\n // (`baseFilters = exportFullRequested ? {} : buildFilters(...)`). On this route `buildFilters` is\n // the only user-level boundary \u2014 automatic scoping covers tenant and organization, not the acting\n // user \u2014 so `?format=json&exportScope=full` would return every device in the caller's org scope.\n // With no available formats, `exportRequested` is never true and that branch is unreachable. Bulk\n // device data belongs to the admin tree (api/admin/devices), which gates cross-user listing.\n export: { enabled: false },\n // Self-serve: always scoped to the acting user. Cross-user listing lives in api/admin/devices.\n buildFilters: async (query, ctx) => {\n // Consume the client-supplied advanced filter here (this deletes every `filter[...]` key from\n // `query`) so the CRUD factory's own merge step becomes a no-op. That step is\n // `{ ...existingFilters, ...advancedWhere }` (advanced-filter-integration.ts), and condition\n // field names are never validated \u2014 `filter[conditions][0][field]=user_id` or `=$and` would\n // otherwise overwrite whatever key this route uses for its scope and widen the listing to other\n // users. `mergeAdvancedFilterTree` instead AND-combines under `$and: [routeFilters, treeWhere]`,\n // where the scope sits in its own array element and no client key can reach it. Same pattern as\n // customers/people, companies and deals.\n const advancedFilterTree = consumeAdvancedFilterState(query)\n const filters: Record<string, unknown> = {}\n const actorUserId = resolveDeviceActorUserId(ctx.auth)\n // Self-serve visibility is strictly the acting user's own devices; cross-user listing lives in\n // api/admin/devices. Fail closed when there is no resolvable acting user (e.g. API keys).\n filters.user_id = { $eq: actorUserId ?? NO_MATCH_USER_ID }\n if (query.platform) filters.platform = { $eq: query.platform }\n return mergeAdvancedFilterTree(filters, advancedFilterTree)\n },\n },\n})\n\nexport const GET = crud.GET\n\nexport async function POST(req: Request) {\n const { translate } = await resolveTranslations()\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const actorUserId = resolveDeviceActorUserId(auth)\n if (!auth || !auth.tenantId || !actorUserId) {\n return NextResponse.json({ error: translate('devices.errors.unauthorized', 'Unauthorized') }, { status: 401 })\n }\n\n const body = registerDeviceSchema.parse(await readJsonSafe(req, {}))\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const organizationId = scope?.selectedId ?? auth.orgId ?? null\n\n // When the app doesn't send an explicit per-device locale, fall back to the request's\n // Accept-Language (the mobile client calls this with its own headers). Only on self-register \u2014\n // PUT stays tri-state, and admin register uses the admin's browser headers, not the device's.\n const headerLocale = resolveLocaleFromAcceptLanguage(req.headers.get('accept-language'))\n\n const commandInput = registerDeviceCommandSchema.parse({\n ...body,\n locale: body.locale ?? headerLocale ?? undefined,\n tenantId: auth.tenantId,\n organizationId,\n userId: actorUserId,\n } satisfies RegisterDeviceCommandInput)\n\n const mctx: DeviceMutationContext = { container, auth, scope, organizationId, actorUserId, request: req }\n return await executeRegister(mctx, commandInput)\n } catch (err) {\n if (err instanceof z.ZodError) {\n return NextResponse.json(\n { error: translate('devices.errors.invalid_payload', 'Invalid device payload'), details: err.flatten() },\n { status: 400 },\n )\n }\n if (isCrudHttpError(err) && (err.body as { code?: string } | undefined)?.code === 'device_already_registered') {\n return NextResponse.json(\n { error: translate('devices.errors.already_registered', 'This device is already registered') },\n { status: 409 },\n )\n }\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n logger.error('devices.POST failed', { err })\n return NextResponse.json(\n { error: translate('devices.errors.register_failed', 'Failed to register device') },\n { status: 500 },\n )\n }\n}\n\nconst registerResponseSchema = z.object({\n id: z.string().uuid(),\n deviceId: z.string(),\n revived: z.boolean(),\n})\n\nexport const openApi: OpenApiRouteDoc = createDevicesCrudOpenApi({\n resourceName: 'Device',\n pluralName: 'Devices',\n querySchema: deviceListSchema,\n listResponseSchema: createPagedListResponseSchema(deviceListItemSchema),\n create: {\n schema: registerDeviceSchema,\n responseSchema: registerResponseSchema,\n description:\n 'Registers (or idempotently upserts) the current user\\'s device for the given deviceId. May include an initial push token.',\n },\n})\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -46,7 +46,7 @@ function DeviceAdminEditPage({ params }) {
|
|
|
46
46
|
};
|
|
47
47
|
}, [id, t]);
|
|
48
48
|
React.useEffect(() => {
|
|
49
|
-
const userId = device?.
|
|
49
|
+
const userId = device?.userId;
|
|
50
50
|
if (!userId) return;
|
|
51
51
|
let cancelled = false;
|
|
52
52
|
void (async () => {
|
|
@@ -63,7 +63,7 @@ function DeviceAdminEditPage({ params }) {
|
|
|
63
63
|
return () => {
|
|
64
64
|
cancelled = true;
|
|
65
65
|
};
|
|
66
|
-
}, [device?.
|
|
66
|
+
}, [device?.userId]);
|
|
67
67
|
const fields = React.useMemo(() => [
|
|
68
68
|
{ id: "clientAppVersion", label: t("devices.form.appVersion"), type: "text" },
|
|
69
69
|
{ id: "osVersion", label: t("devices.form.osVersion"), type: "text" },
|
|
@@ -96,7 +96,7 @@ function DeviceAdminEditPage({ params }) {
|
|
|
96
96
|
contentHeader: /* @__PURE__ */ jsxs("dl", { className: "grid grid-cols-1 gap-3 rounded-md border bg-muted p-4 text-sm sm:grid-cols-3", children: [
|
|
97
97
|
/* @__PURE__ */ jsxs("div", { children: [
|
|
98
98
|
/* @__PURE__ */ jsx("dt", { className: "text-xs font-medium text-muted-foreground", children: t("devices.form.deviceId") }),
|
|
99
|
-
/* @__PURE__ */ jsx("dd", { className: "mt-1", children: /* @__PURE__ */ jsx("code", { className: "text-xs", children: device.
|
|
99
|
+
/* @__PURE__ */ jsx("dd", { className: "mt-1", children: /* @__PURE__ */ jsx("code", { className: "text-xs", children: device.deviceId }) })
|
|
100
100
|
] }),
|
|
101
101
|
/* @__PURE__ */ jsxs("div", { children: [
|
|
102
102
|
/* @__PURE__ */ jsx("dt", { className: "text-xs font-medium text-muted-foreground", children: t("devices.form.platform") }),
|
|
@@ -104,16 +104,16 @@ function DeviceAdminEditPage({ params }) {
|
|
|
104
104
|
] }),
|
|
105
105
|
/* @__PURE__ */ jsxs("div", { children: [
|
|
106
106
|
/* @__PURE__ */ jsx("dt", { className: "text-xs font-medium text-muted-foreground", children: t("devices.form.userId") }),
|
|
107
|
-
/* @__PURE__ */ jsx("dd", { className: "mt-1", children: userLabel ? /* @__PURE__ */ jsx(Link, { href: `/backend/users/${encodeURIComponent(device.
|
|
107
|
+
/* @__PURE__ */ jsx("dd", { className: "mt-1", children: userLabel ? /* @__PURE__ */ jsx(Link, { href: `/backend/users/${encodeURIComponent(device.userId)}/edit`, className: "text-primary hover:underline", children: userLabel }) : /* @__PURE__ */ jsx("code", { className: "text-xs", children: device.userId }) })
|
|
108
108
|
] })
|
|
109
109
|
] }),
|
|
110
110
|
fields,
|
|
111
111
|
groups,
|
|
112
|
-
optimisticLockUpdatedAt: device.
|
|
112
|
+
optimisticLockUpdatedAt: device.updatedAt,
|
|
113
113
|
initialValues: {
|
|
114
|
-
clientAppVersion: device.
|
|
115
|
-
osVersion: device.
|
|
116
|
-
pushProvider: device.
|
|
114
|
+
clientAppVersion: device.clientAppVersion ?? "",
|
|
115
|
+
osVersion: device.osVersion ?? "",
|
|
116
|
+
pushProvider: device.pushProvider ?? ""
|
|
117
117
|
},
|
|
118
118
|
submitLabel: t("common.save"),
|
|
119
119
|
cancelHref: "/backend/devices",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/devices/backend/devices/%5Bid%5D/page.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useRouter } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'\nimport { updateCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { LoadingMessage, ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype DeviceDetail = {\n id: string\n
|
|
5
|
-
"mappings": ";AA+F2B,cA2Bb,YA3Ba;AA9F3B,YAAY,WAAW;AACvB,OAAO,UAAU;AACjB,SAAS,iBAAiB;AAC1B,SAAS,MAAM,gBAAgB;AAC/B,SAAS,gBAAoD;AAC7D,SAAS,kBAAkB;AAC3B,SAAS,eAAe;AACxB,SAAS,aAAa;AACtB,SAAS,gBAAgB,cAAc,2BAA2B;AAClE,SAAS,YAAY;AAmBN,SAAR,oBAAqC,EAAE,OAAO,GAAiC;AACpF,QAAM,SAAS,UAAU;AACzB,QAAM,IAAI,KAAK;AACf,QAAM,KAAK,OAAO,QAAQ,OAAO,WAAW,OAAO,KAAK;AACxD,QAAM,CAAC,QAAQ,SAAS,IAAI,MAAM,SAA8B,IAAI;AACpE,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,IAAI;AACrD,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAwB,IAAI;AAC5D,QAAM,CAAC,UAAU,WAAW,IAAI,MAAM,SAAS,KAAK;AACpD,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAwB,IAAI;AAEpE,QAAM,UAAU,MAAM;AACpB,QAAI,YAAY;AAChB,mBAAe,OAAO;AACpB,mBAAa,IAAI;AACjB,eAAS,IAAI;AACb,kBAAY,KAAK;AACjB,YAAM,OAAO,MAAM;AAAA,QACjB,8BAA8B,mBAAmB,EAAE,CAAC;AAAA,QACpD;AAAA,QACA,EAAE,UAAU,KAAK;AAAA,MACnB;AACA,UAAI,UAAW;AACf,UAAI,KAAK,MAAM,KAAK,QAAQ,MAAM;AAChC,kBAAU,KAAK,OAAO,IAAI;AAAA,MAC5B,WAAW,KAAK,WAAW,OAAQ,KAAK,MAAM,CAAC,KAAK,QAAQ,MAAO;AACjE,oBAAY,IAAI;AAAA,MAClB,OAAO;AACL,iBAAS,EAAE,+BAA+B,CAAC;AAAA,MAC7C;AACA,mBAAa,KAAK;AAAA,IACpB;AACA,QAAI,GAAI,MAAK;AACb,WAAO,MAAM;AAAE,kBAAY;AAAA,IAAK;AAAA,EAClC,GAAG,CAAC,IAAI,CAAC,CAAC;AAIV,QAAM,UAAU,MAAM;AACpB,UAAM,SAAS,QAAQ;AACvB,QAAI,CAAC,OAAQ;AACb,QAAI,YAAY;AAChB,UAAM,YAAY;AAChB,YAAM,OAAO,MAAM;AAAA,QACjB,sBAAsB,mBAAmB,MAAM,CAAC;AAAA,QAChD,EAAE,SAAS,EAAE,2BAA2B,IAAI,EAAE;AAAA,QAC9C,EAAE,UAAU,KAAK;AAAA,MACnB,EAAE,MAAM,MAAM,IAAI;AAClB,UAAI,aAAa,CAAC,QAAQ,CAAC,KAAK,GAAI;AACpC,YAAM,QAAQ,KAAK,QAAQ,OAAO,KAAK,CAAC,MAAM,EAAE,OAAO,MAAM;AAC7D,YAAM,QAAQ,OAAO,MAAM,KAAK,KAAK,OAAO,OAAO,KAAK,KAAK;AAC7D,UAAI,MAAO,cAAa,KAAK;AAAA,IAC/B,GAAG;AACH,WAAO,MAAM;AAAE,kBAAY;AAAA,IAAK;AAAA,EAClC,GAAG,CAAC,QAAQ,
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useRouter } from 'next/navigation'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'\nimport { updateCrud } from '@open-mercato/ui/backend/utils/crud'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { LoadingMessage, ErrorMessage, RecordNotFoundState } from '@open-mercato/ui/backend/detail'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype DeviceDetail = {\n id: string\n userId: string\n deviceId: string\n platform: string\n clientAppVersion: string | null\n osVersion: string | null\n pushProvider: string | null\n updatedAt: string | null\n}\n\ntype FormValues = {\n clientAppVersion: string\n osVersion: string\n pushProvider: string\n}\n\nexport default function DeviceAdminEditPage({ params }: { params?: { id?: string } }) {\n const router = useRouter()\n const t = useT()\n const id = typeof params?.id === 'string' ? params.id : ''\n const [device, setDevice] = React.useState<DeviceDetail | null>(null)\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [notFound, setNotFound] = React.useState(false)\n const [userLabel, setUserLabel] = React.useState<string | null>(null)\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n setError(null)\n setNotFound(false)\n const call = await apiCall<{ item?: DeviceDetail }>(\n `/api/devices/admin/devices/${encodeURIComponent(id)}`,\n undefined,\n { fallback: null },\n )\n if (cancelled) return\n if (call.ok && call.result?.item) {\n setDevice(call.result.item)\n } else if (call.status === 404 || (call.ok && !call.result?.item)) {\n setNotFound(true)\n } else {\n setError(t('devices.form.error.loadFailed'))\n }\n setIsLoading(false)\n }\n if (id) load()\n return () => { cancelled = true }\n }, [id, t])\n\n // Resolve the owner's display name for a link to their profile. Devices admins may not hold\n // auth.users.list, so fall back to the raw id (rendered without a link) instead of redirecting.\n React.useEffect(() => {\n const userId = device?.userId\n if (!userId) return\n let cancelled = false\n void (async () => {\n const call = await apiCall<{ items?: { id: string; name?: string | null; email?: string | null }[] }>(\n `/api/auth/users?id=${encodeURIComponent(userId)}`,\n { headers: { 'x-om-forbidden-redirect': '0' } },\n { fallback: null },\n ).catch(() => null)\n if (cancelled || !call || !call.ok) return\n const found = call.result?.items?.find((u) => u.id === userId)\n const label = found?.name?.trim() || found?.email?.trim() || null\n if (label) setUserLabel(label)\n })()\n return () => { cancelled = true }\n }, [device?.userId])\n\n const fields = React.useMemo<CrudField[]>(() => [\n { id: 'clientAppVersion', label: t('devices.form.appVersion'), type: 'text' },\n { id: 'osVersion', label: t('devices.form.osVersion'), type: 'text' },\n { id: 'pushProvider', label: t('devices.form.pushProvider'), type: 'text' },\n ], [t])\n\n const groups = React.useMemo<CrudFormGroup[]>(() => ([\n { id: 'details', title: t('devices.form.details'), column: 1, fields: ['clientAppVersion', 'osVersion', 'pushProvider'] },\n ]), [t])\n\n if (isLoading) {\n return <Page><PageBody><LoadingMessage label={t('common.loading')} /></PageBody></Page>\n }\n if (notFound) {\n return (\n <Page>\n <PageBody>\n <RecordNotFoundState\n label={t('devices.errors.not_found')}\n backHref=\"/backend/devices\"\n backLabel={t('devices.list.title')}\n />\n </PageBody>\n </Page>\n )\n }\n if (error || !device) {\n return <Page><PageBody><ErrorMessage label={error ?? t('devices.form.error.loadFailed')} /></PageBody></Page>\n }\n\n return (\n <Page>\n <PageBody>\n <CrudForm<FormValues>\n title={t('devices.form.editTitle')}\n backHref=\"/backend/devices\"\n contentHeader={(\n <dl className=\"grid grid-cols-1 gap-3 rounded-md border bg-muted p-4 text-sm sm:grid-cols-3\">\n <div>\n <dt className=\"text-xs font-medium text-muted-foreground\">{t('devices.form.deviceId')}</dt>\n <dd className=\"mt-1\"><code className=\"text-xs\">{device.deviceId}</code></dd>\n </div>\n <div>\n <dt className=\"text-xs font-medium text-muted-foreground\">{t('devices.form.platform')}</dt>\n <dd className=\"mt-1\">{device.platform}</dd>\n </div>\n <div>\n <dt className=\"text-xs font-medium text-muted-foreground\">{t('devices.form.userId')}</dt>\n <dd className=\"mt-1\">{userLabel ? (\n <Link href={`/backend/users/${encodeURIComponent(device.userId)}/edit`} className=\"text-primary hover:underline\">{userLabel}</Link>\n ) : (\n <code className=\"text-xs\">{device.userId}</code>\n )}</dd>\n </div>\n </dl>\n )}\n fields={fields}\n groups={groups}\n optimisticLockUpdatedAt={device.updatedAt}\n initialValues={{\n clientAppVersion: device.clientAppVersion ?? '',\n osVersion: device.osVersion ?? '',\n pushProvider: device.pushProvider ?? '',\n }}\n submitLabel={t('common.save')}\n cancelHref=\"/backend/devices\"\n onSubmit={async (values) => {\n await updateCrud(`devices/admin/devices/${encodeURIComponent(id)}`, {\n clientAppVersion: values.clientAppVersion.trim() || null,\n osVersion: values.osVersion.trim() || null,\n pushProvider: values.pushProvider.trim() || null,\n })\n flash(t('devices.form.success.updated'), 'success')\n router.push('/backend/devices')\n }}\n />\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -153,16 +153,16 @@ function DevicesAdminListPage() {
|
|
|
153
153
|
}, [confirm, t]);
|
|
154
154
|
const columns = React.useMemo(() => [
|
|
155
155
|
{
|
|
156
|
-
accessorKey: "
|
|
156
|
+
accessorKey: "deviceId",
|
|
157
157
|
header: t("devices.list.columns.device"),
|
|
158
|
-
cell: ({ row }) => /* @__PURE__ */ jsx("code", { className: "text-xs", children: row.original.
|
|
158
|
+
cell: ({ row }) => /* @__PURE__ */ jsx("code", { className: "text-xs", children: row.original.deviceId })
|
|
159
159
|
},
|
|
160
160
|
{ accessorKey: "platform", header: t("devices.list.columns.platform") },
|
|
161
161
|
{
|
|
162
|
-
accessorKey: "
|
|
162
|
+
accessorKey: "userId",
|
|
163
163
|
header: t("devices.list.columns.user"),
|
|
164
164
|
cell: ({ row }) => {
|
|
165
|
-
const userId = row.original.
|
|
165
|
+
const userId = row.original.userId;
|
|
166
166
|
const label = userLabelById.get(userId);
|
|
167
167
|
return (
|
|
168
168
|
// Stop the click bubbling to the row, whose default action navigates to the device edit page.
|
|
@@ -179,24 +179,24 @@ function DevicesAdminListPage() {
|
|
|
179
179
|
}
|
|
180
180
|
},
|
|
181
181
|
{
|
|
182
|
-
accessorKey: "
|
|
182
|
+
accessorKey: "clientAppVersion",
|
|
183
183
|
header: t("devices.list.columns.appVersion"),
|
|
184
|
-
cell: ({ row }) => row.original.
|
|
184
|
+
cell: ({ row }) => row.original.clientAppVersion || t("devices.list.noValue")
|
|
185
185
|
},
|
|
186
186
|
{
|
|
187
|
-
accessorKey: "
|
|
187
|
+
accessorKey: "osVersion",
|
|
188
188
|
header: t("devices.list.columns.osVersion"),
|
|
189
|
-
cell: ({ row }) => row.original.
|
|
189
|
+
cell: ({ row }) => row.original.osVersion || t("devices.list.noValue")
|
|
190
190
|
},
|
|
191
191
|
{
|
|
192
|
-
accessorKey: "
|
|
192
|
+
accessorKey: "pushProvider",
|
|
193
193
|
header: t("devices.list.columns.pushProvider"),
|
|
194
|
-
cell: ({ row }) => row.original.
|
|
194
|
+
cell: ({ row }) => row.original.pushProvider || t("devices.list.noValue")
|
|
195
195
|
},
|
|
196
196
|
{
|
|
197
|
-
accessorKey: "
|
|
197
|
+
accessorKey: "lastSeenAt",
|
|
198
198
|
header: t("devices.list.columns.lastSeen"),
|
|
199
|
-
cell: ({ row }) => formatDate(row.original.
|
|
199
|
+
cell: ({ row }) => formatDate(row.original.lastSeenAt, t)
|
|
200
200
|
}
|
|
201
201
|
], [t, userLabelById]);
|
|
202
202
|
return /* @__PURE__ */ jsxs(Page, { children: [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/devices/backend/devices/page.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'\n\ntype Row = {\n id: string\n user_id: string\n device_id: string\n platform: string\n client_app_version: string | null\n os_version: string | null\n push_provider: string | null\n push_token_updated_at: string | null\n last_seen_at: string | null\n created_at: string | null\n}\n\ntype ResponsePayload = {\n items: Row[]\n total: number\n page?: number\n pageSize?: number\n totalPages: number\n totalIsCapped?: boolean\n}\n\nfunction formatDate(value: string | null, t: (key: string) => string) {\n if (!value) return t('devices.list.noValue')\n try {\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return t('devices.list.noValue')\n return date.toLocaleString()\n } catch {\n return t('devices.list.noValue')\n }\n}\n\nexport default function DevicesAdminListPage() {\n const [rows, setRows] = React.useState<Row[]>([])\n const [page, setPage] = React.useState(1)\n const [total, setTotal] = React.useState(0)\n const [totalPages, setTotalPages] = React.useState(1)\n const [totalIsCapped, setTotalIsCapped] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const [reloadToken, setReloadToken] = React.useState(0)\n const scopeVersion = useOrganizationScopeVersion()\n const t = useT()\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n const [filterValues, setFilterValues] = React.useState<FilterValues>({})\n const [userOptions, setUserOptions] = React.useState<{ value: string; label: string; description?: string | null }[]>([])\n\n // Devices admins may not hold auth.users.list; degrade gracefully (no options) instead of redirecting.\n const loadUserOptions = React.useCallback(async (query?: string) => {\n const params = new URLSearchParams()\n params.set('page', '1')\n params.set('pageSize', '20')\n if (query && query.trim().length > 0) params.set('search', query.trim())\n const call = await apiCall<{ items?: { id: string; name?: string | null; email?: string | null }[] }>(\n `/api/auth/users?${params.toString()}`,\n { headers: { 'x-om-forbidden-redirect': '0' } },\n { fallback: null },\n ).catch(() => null)\n if (!call || !call.ok) return []\n const next = (call.result?.items ?? []).flatMap((item) => {\n if (!item || typeof item.id !== 'string' || !item.id.trim()) return []\n const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null\n const email = typeof item.email === 'string' && item.email.trim() ? item.email.trim() : null\n const label = name ?? email ?? item.id\n return [{ value: item.id, label, description: email && email !== label ? email : null }]\n })\n setUserOptions((prev) => {\n const map = new Map(prev.map((opt) => [opt.value, opt]))\n for (const opt of next) map.set(opt.value, opt)\n return Array.from(map.values())\n })\n return next\n }, [])\n\n React.useEffect(() => { void loadUserOptions() }, [loadUserOptions, scopeVersion])\n\n // Reuse the picker cache to label the User column; rows whose owner isn't cached still link by id.\n const userLabelById = React.useMemo(\n () => new Map(userOptions.map((opt) => [opt.value, opt.label])),\n [userOptions],\n )\n\n const filters = React.useMemo<FilterDef[]>(() => [\n {\n id: 'platform',\n label: t('devices.list.columns.platform'),\n type: 'select',\n options: [\n { value: 'ios', label: 'iOS' },\n { value: 'android', label: 'Android' },\n { value: 'web', label: 'Web' },\n ],\n },\n {\n id: 'userId',\n label: t('devices.list.columns.user'),\n type: 'combobox',\n options: userOptions,\n loadOptions: loadUserOptions,\n },\n ], [t, userOptions, loadUserOptions])\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n try {\n const params = new URLSearchParams()\n params.set('page', String(page))\n params.set('pageSize', '50')\n const platform = typeof filterValues.platform === 'string' ? filterValues.platform.trim() : ''\n const userId = typeof filterValues.userId === 'string' ? filterValues.userId.trim() : ''\n if (platform) params.set('platform', platform)\n if (userId) params.set('userId', userId)\n const fallback: ResponsePayload = { items: [], total: 0, page, totalPages: 1 }\n const call = await apiCall<ResponsePayload>(`/api/devices/admin/devices?${params.toString()}`, undefined, { fallback })\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('devices.list.error.loadFailed')\n flash(message, 'error')\n return\n }\n const payload = call.result ?? fallback\n if (!cancelled) {\n setRows(Array.isArray(payload.items) ? payload.items : [])\n setTotal(payload.total || 0)\n setTotalPages(payload.totalPages || 1)\n setTotalIsCapped(payload?.totalIsCapped === true)\n }\n } catch (error) {\n if (!cancelled) {\n const message = error instanceof Error ? error.message : t('devices.list.error.loadFailed')\n flash(message, 'error')\n }\n } finally {\n if (!cancelled) setIsLoading(false)\n }\n }\n load()\n return () => { cancelled = true }\n }, [page, reloadToken, scopeVersion, filterValues, t])\n\n const handleDeactivate = React.useCallback(async (row: Row) => {\n const confirmed = await confirm({\n title: t('devices.list.confirmDeactivate'),\n variant: 'destructive',\n })\n if (!confirmed) return\n try {\n // optimistic-lock-exempt: device deactivate is an idempotent soft-delete of a registry row, not a concurrent field edit\n const call = await apiCall<{ error?: string }>(\n `/api/devices/admin/devices/${encodeURIComponent(row.id)}`,\n { method: 'DELETE' },\n { fallback: null },\n )\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('devices.list.error.deactivateFailed')\n flash(message, 'error')\n return\n }\n flash(t('devices.list.success.deactivated'), 'success')\n setReloadToken((token) => token + 1)\n } catch (error) {\n const message = error instanceof Error ? error.message : t('devices.list.error.deactivateFailed')\n flash(message, 'error')\n }\n }, [confirm, t])\n\n const columns = React.useMemo<ColumnDef<Row>[]>(() => [\n {\n accessorKey: 'device_id',\n header: t('devices.list.columns.device'),\n cell: ({ row }) => <code className=\"text-xs\">{row.original.device_id}</code>,\n },\n { accessorKey: 'platform', header: t('devices.list.columns.platform') },\n {\n accessorKey: 'user_id',\n header: t('devices.list.columns.user'),\n cell: ({ row }) => {\n const userId = row.original.user_id\n const label = userLabelById.get(userId)\n return (\n // Stop the click bubbling to the row, whose default action navigates to the device edit page.\n <Link\n href={`/backend/users/${encodeURIComponent(userId)}/edit`}\n className=\"text-primary hover:underline\"\n onClick={(e) => e.stopPropagation()}\n >\n {label ?? <code className=\"text-xs\">{userId}</code>}\n </Link>\n )\n },\n },\n {\n accessorKey: 'client_app_version',\n header: t('devices.list.columns.appVersion'),\n cell: ({ row }) => row.original.client_app_version || t('devices.list.noValue'),\n },\n {\n accessorKey: 'os_version',\n header: t('devices.list.columns.osVersion'),\n cell: ({ row }) => row.original.os_version || t('devices.list.noValue'),\n },\n {\n accessorKey: 'push_provider',\n header: t('devices.list.columns.pushProvider'),\n cell: ({ row }) => row.original.push_provider || t('devices.list.noValue'),\n },\n {\n accessorKey: 'last_seen_at',\n header: t('devices.list.columns.lastSeen'),\n cell: ({ row }) => formatDate(row.original.last_seen_at, t),\n },\n ], [t, userLabelById])\n\n return (\n <Page>\n <PageBody>\n <DataTable\n title={t('devices.list.title')}\n actions={(\n <Button asChild>\n <Link href=\"/backend/devices/create\">{t('devices.list.actions.register')}</Link>\n </Button>\n )}\n columns={columns}\n data={rows}\n filters={filters}\n filterValues={filterValues}\n onFiltersApply={(values) => { setFilterValues(values); setPage(1) }}\n onFiltersClear={() => { setFilterValues({}); setPage(1) }}\n perspective={{ tableId: 'devices.list' }}\n rowActions={(row) => (\n <RowActions items={[\n { id: 'edit', label: t('devices.list.actions.edit'), href: `/backend/devices/${row.id}` },\n { id: 'deactivate', label: t('devices.list.actions.deactivate'), destructive: true, onSelect: () => { void handleDeactivate(row) } },\n ]} />\n )}\n pagination={{ page, pageSize: 50, total, totalPages, totalIsCapped, onPageChange: setPage }}\n isLoading={isLoading}\n />\n </PageBody>\n {ConfirmDialogElement}\n </Page>\n )\n}\n"],
|
|
5
|
-
"mappings": "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
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { DataTable } from '@open-mercato/ui/backend/DataTable'\nimport type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { RowActions } from '@open-mercato/ui/backend/RowActions'\nimport { apiCall } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'\nimport type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'\n\ntype Row = {\n id: string\n userId: string\n deviceId: string\n platform: string\n clientAppVersion: string | null\n osVersion: string | null\n pushProvider: string | null\n pushTokenUpdatedAt: string | null\n lastSeenAt: string | null\n createdAt: string | null\n}\n\ntype ResponsePayload = {\n items: Row[]\n total: number\n page?: number\n pageSize?: number\n totalPages: number\n totalIsCapped?: boolean\n}\n\nfunction formatDate(value: string | null, t: (key: string) => string) {\n if (!value) return t('devices.list.noValue')\n try {\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return t('devices.list.noValue')\n return date.toLocaleString()\n } catch {\n return t('devices.list.noValue')\n }\n}\n\nexport default function DevicesAdminListPage() {\n const [rows, setRows] = React.useState<Row[]>([])\n const [page, setPage] = React.useState(1)\n const [total, setTotal] = React.useState(0)\n const [totalPages, setTotalPages] = React.useState(1)\n const [totalIsCapped, setTotalIsCapped] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const [reloadToken, setReloadToken] = React.useState(0)\n const scopeVersion = useOrganizationScopeVersion()\n const t = useT()\n const { confirm, ConfirmDialogElement } = useConfirmDialog()\n const [filterValues, setFilterValues] = React.useState<FilterValues>({})\n const [userOptions, setUserOptions] = React.useState<{ value: string; label: string; description?: string | null }[]>([])\n\n // Devices admins may not hold auth.users.list; degrade gracefully (no options) instead of redirecting.\n const loadUserOptions = React.useCallback(async (query?: string) => {\n const params = new URLSearchParams()\n params.set('page', '1')\n params.set('pageSize', '20')\n if (query && query.trim().length > 0) params.set('search', query.trim())\n const call = await apiCall<{ items?: { id: string; name?: string | null; email?: string | null }[] }>(\n `/api/auth/users?${params.toString()}`,\n { headers: { 'x-om-forbidden-redirect': '0' } },\n { fallback: null },\n ).catch(() => null)\n if (!call || !call.ok) return []\n const next = (call.result?.items ?? []).flatMap((item) => {\n if (!item || typeof item.id !== 'string' || !item.id.trim()) return []\n const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null\n const email = typeof item.email === 'string' && item.email.trim() ? item.email.trim() : null\n const label = name ?? email ?? item.id\n return [{ value: item.id, label, description: email && email !== label ? email : null }]\n })\n setUserOptions((prev) => {\n const map = new Map(prev.map((opt) => [opt.value, opt]))\n for (const opt of next) map.set(opt.value, opt)\n return Array.from(map.values())\n })\n return next\n }, [])\n\n React.useEffect(() => { void loadUserOptions() }, [loadUserOptions, scopeVersion])\n\n // Reuse the picker cache to label the User column; rows whose owner isn't cached still link by id.\n const userLabelById = React.useMemo(\n () => new Map(userOptions.map((opt) => [opt.value, opt.label])),\n [userOptions],\n )\n\n const filters = React.useMemo<FilterDef[]>(() => [\n {\n id: 'platform',\n label: t('devices.list.columns.platform'),\n type: 'select',\n options: [\n { value: 'ios', label: 'iOS' },\n { value: 'android', label: 'Android' },\n { value: 'web', label: 'Web' },\n ],\n },\n {\n id: 'userId',\n label: t('devices.list.columns.user'),\n type: 'combobox',\n options: userOptions,\n loadOptions: loadUserOptions,\n },\n ], [t, userOptions, loadUserOptions])\n\n React.useEffect(() => {\n let cancelled = false\n async function load() {\n setIsLoading(true)\n try {\n const params = new URLSearchParams()\n params.set('page', String(page))\n params.set('pageSize', '50')\n const platform = typeof filterValues.platform === 'string' ? filterValues.platform.trim() : ''\n const userId = typeof filterValues.userId === 'string' ? filterValues.userId.trim() : ''\n if (platform) params.set('platform', platform)\n if (userId) params.set('userId', userId)\n const fallback: ResponsePayload = { items: [], total: 0, page, totalPages: 1 }\n const call = await apiCall<ResponsePayload>(`/api/devices/admin/devices?${params.toString()}`, undefined, { fallback })\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('devices.list.error.loadFailed')\n flash(message, 'error')\n return\n }\n const payload = call.result ?? fallback\n if (!cancelled) {\n setRows(Array.isArray(payload.items) ? payload.items : [])\n setTotal(payload.total || 0)\n setTotalPages(payload.totalPages || 1)\n setTotalIsCapped(payload?.totalIsCapped === true)\n }\n } catch (error) {\n if (!cancelled) {\n const message = error instanceof Error ? error.message : t('devices.list.error.loadFailed')\n flash(message, 'error')\n }\n } finally {\n if (!cancelled) setIsLoading(false)\n }\n }\n load()\n return () => { cancelled = true }\n }, [page, reloadToken, scopeVersion, filterValues, t])\n\n const handleDeactivate = React.useCallback(async (row: Row) => {\n const confirmed = await confirm({\n title: t('devices.list.confirmDeactivate'),\n variant: 'destructive',\n })\n if (!confirmed) return\n try {\n // optimistic-lock-exempt: device deactivate is an idempotent soft-delete of a registry row, not a concurrent field edit\n const call = await apiCall<{ error?: string }>(\n `/api/devices/admin/devices/${encodeURIComponent(row.id)}`,\n { method: 'DELETE' },\n { fallback: null },\n )\n if (!call.ok) {\n const errorPayload = call.result as { error?: string } | undefined\n const message = typeof errorPayload?.error === 'string' ? errorPayload.error : t('devices.list.error.deactivateFailed')\n flash(message, 'error')\n return\n }\n flash(t('devices.list.success.deactivated'), 'success')\n setReloadToken((token) => token + 1)\n } catch (error) {\n const message = error instanceof Error ? error.message : t('devices.list.error.deactivateFailed')\n flash(message, 'error')\n }\n }, [confirm, t])\n\n const columns = React.useMemo<ColumnDef<Row>[]>(() => [\n {\n accessorKey: 'deviceId',\n header: t('devices.list.columns.device'),\n cell: ({ row }) => <code className=\"text-xs\">{row.original.deviceId}</code>,\n },\n { accessorKey: 'platform', header: t('devices.list.columns.platform') },\n {\n accessorKey: 'userId',\n header: t('devices.list.columns.user'),\n cell: ({ row }) => {\n const userId = row.original.userId\n const label = userLabelById.get(userId)\n return (\n // Stop the click bubbling to the row, whose default action navigates to the device edit page.\n <Link\n href={`/backend/users/${encodeURIComponent(userId)}/edit`}\n className=\"text-primary hover:underline\"\n onClick={(e) => e.stopPropagation()}\n >\n {label ?? <code className=\"text-xs\">{userId}</code>}\n </Link>\n )\n },\n },\n {\n accessorKey: 'clientAppVersion',\n header: t('devices.list.columns.appVersion'),\n cell: ({ row }) => row.original.clientAppVersion || t('devices.list.noValue'),\n },\n {\n accessorKey: 'osVersion',\n header: t('devices.list.columns.osVersion'),\n cell: ({ row }) => row.original.osVersion || t('devices.list.noValue'),\n },\n {\n accessorKey: 'pushProvider',\n header: t('devices.list.columns.pushProvider'),\n cell: ({ row }) => row.original.pushProvider || t('devices.list.noValue'),\n },\n {\n accessorKey: 'lastSeenAt',\n header: t('devices.list.columns.lastSeen'),\n cell: ({ row }) => formatDate(row.original.lastSeenAt, t),\n },\n ], [t, userLabelById])\n\n return (\n <Page>\n <PageBody>\n <DataTable\n title={t('devices.list.title')}\n actions={(\n <Button asChild>\n <Link href=\"/backend/devices/create\">{t('devices.list.actions.register')}</Link>\n </Button>\n )}\n columns={columns}\n data={rows}\n filters={filters}\n filterValues={filterValues}\n onFiltersApply={(values) => { setFilterValues(values); setPage(1) }}\n onFiltersClear={() => { setFilterValues({}); setPage(1) }}\n perspective={{ tableId: 'devices.list' }}\n rowActions={(row) => (\n <RowActions items={[\n { id: 'edit', label: t('devices.list.actions.edit'), href: `/backend/devices/${row.id}` },\n { id: 'deactivate', label: t('devices.list.actions.deactivate'), destructive: true, onSelect: () => { void handleDeactivate(row) } },\n ]} />\n )}\n pagination={{ page, pageSize: 50, total, totalPages, totalIsCapped, onPageChange: setPage }}\n isLoading={isLoading}\n />\n </PageBody>\n {ConfirmDialogElement}\n </Page>\n )\n}\n"],
|
|
5
|
+
"mappings": ";AA4LyB,cA4CrB,YA5CqB;AA3LzB,YAAY,WAAW;AACvB,OAAO,UAAU;AACjB,SAAS,MAAM,gBAAgB;AAC/B,SAAS,iBAAiB;AAE1B,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAC3B,SAAS,eAAe;AACxB,SAAS,aAAa;AACtB,SAAS,mCAAmC;AAC5C,SAAS,YAAY;AACrB,SAAS,wBAAwB;AAyBjC,SAAS,WAAW,OAAsB,GAA4B;AACpE,MAAI,CAAC,MAAO,QAAO,EAAE,sBAAsB;AAC3C,MAAI;AACF,UAAM,OAAO,IAAI,KAAK,KAAK;AAC3B,QAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,EAAG,QAAO,EAAE,sBAAsB;AACjE,WAAO,KAAK,eAAe;AAAA,EAC7B,QAAQ;AACN,WAAO,EAAE,sBAAsB;AAAA,EACjC;AACF;AAEe,SAAR,uBAAwC;AAC7C,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAgB,CAAC,CAAC;AAChD,QAAM,CAAC,MAAM,OAAO,IAAI,MAAM,SAAS,CAAC;AACxC,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAS,CAAC;AAC1C,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAAS,CAAC;AACpD,QAAM,CAAC,eAAe,gBAAgB,IAAI,MAAM,SAAS,KAAK;AAC9D,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,IAAI;AACrD,QAAM,CAAC,aAAa,cAAc,IAAI,MAAM,SAAS,CAAC;AACtD,QAAM,eAAe,4BAA4B;AACjD,QAAM,IAAI,KAAK;AACf,QAAM,EAAE,SAAS,qBAAqB,IAAI,iBAAiB;AAC3D,QAAM,CAAC,cAAc,eAAe,IAAI,MAAM,SAAuB,CAAC,CAAC;AACvE,QAAM,CAAC,aAAa,cAAc,IAAI,MAAM,SAA0E,CAAC,CAAC;AAGxH,QAAM,kBAAkB,MAAM,YAAY,OAAO,UAAmB;AAClE,UAAM,SAAS,IAAI,gBAAgB;AACnC,WAAO,IAAI,QAAQ,GAAG;AACtB,WAAO,IAAI,YAAY,IAAI;AAC3B,QAAI,SAAS,MAAM,KAAK,EAAE,SAAS,EAAG,QAAO,IAAI,UAAU,MAAM,KAAK,CAAC;AACvE,UAAM,OAAO,MAAM;AAAA,MACjB,mBAAmB,OAAO,SAAS,CAAC;AAAA,MACpC,EAAE,SAAS,EAAE,2BAA2B,IAAI,EAAE;AAAA,MAC9C,EAAE,UAAU,KAAK;AAAA,IACnB,EAAE,MAAM,MAAM,IAAI;AAClB,QAAI,CAAC,QAAQ,CAAC,KAAK,GAAI,QAAO,CAAC;AAC/B,UAAM,QAAQ,KAAK,QAAQ,SAAS,CAAC,GAAG,QAAQ,CAAC,SAAS;AACxD,UAAI,CAAC,QAAQ,OAAO,KAAK,OAAO,YAAY,CAAC,KAAK,GAAG,KAAK,EAAG,QAAO,CAAC;AACrE,YAAM,OAAO,OAAO,KAAK,SAAS,YAAY,KAAK,KAAK,KAAK,IAAI,KAAK,KAAK,KAAK,IAAI;AACpF,YAAM,QAAQ,OAAO,KAAK,UAAU,YAAY,KAAK,MAAM,KAAK,IAAI,KAAK,MAAM,KAAK,IAAI;AACxF,YAAM,QAAQ,QAAQ,SAAS,KAAK;AACpC,aAAO,CAAC,EAAE,OAAO,KAAK,IAAI,OAAO,aAAa,SAAS,UAAU,QAAQ,QAAQ,KAAK,CAAC;AAAA,IACzF,CAAC;AACD,mBAAe,CAAC,SAAS;AACvB,YAAM,MAAM,IAAI,IAAI,KAAK,IAAI,CAAC,QAAQ,CAAC,IAAI,OAAO,GAAG,CAAC,CAAC;AACvD,iBAAW,OAAO,KAAM,KAAI,IAAI,IAAI,OAAO,GAAG;AAC9C,aAAO,MAAM,KAAK,IAAI,OAAO,CAAC;AAAA,IAChC,CAAC;AACD,WAAO;AAAA,EACT,GAAG,CAAC,CAAC;AAEL,QAAM,UAAU,MAAM;AAAE,SAAK,gBAAgB;AAAA,EAAE,GAAG,CAAC,iBAAiB,YAAY,CAAC;AAGjF,QAAM,gBAAgB,MAAM;AAAA,IAC1B,MAAM,IAAI,IAAI,YAAY,IAAI,CAAC,QAAQ,CAAC,IAAI,OAAO,IAAI,KAAK,CAAC,CAAC;AAAA,IAC9D,CAAC,WAAW;AAAA,EACd;AAEA,QAAM,UAAU,MAAM,QAAqB,MAAM;AAAA,IAC/C;AAAA,MACE,IAAI;AAAA,MACJ,OAAO,EAAE,+BAA+B;AAAA,MACxC,MAAM;AAAA,MACN,SAAS;AAAA,QACP,EAAE,OAAO,OAAO,OAAO,MAAM;AAAA,QAC7B,EAAE,OAAO,WAAW,OAAO,UAAU;AAAA,QACrC,EAAE,OAAO,OAAO,OAAO,MAAM;AAAA,MAC/B;AAAA,IACF;AAAA,IACA;AAAA,MACE,IAAI;AAAA,MACJ,OAAO,EAAE,2BAA2B;AAAA,MACpC,MAAM;AAAA,MACN,SAAS;AAAA,MACT,aAAa;AAAA,IACf;AAAA,EACF,GAAG,CAAC,GAAG,aAAa,eAAe,CAAC;AAEpC,QAAM,UAAU,MAAM;AACpB,QAAI,YAAY;AAChB,mBAAe,OAAO;AACpB,mBAAa,IAAI;AACjB,UAAI;AACF,cAAM,SAAS,IAAI,gBAAgB;AACnC,eAAO,IAAI,QAAQ,OAAO,IAAI,CAAC;AAC/B,eAAO,IAAI,YAAY,IAAI;AAC3B,cAAM,WAAW,OAAO,aAAa,aAAa,WAAW,aAAa,SAAS,KAAK,IAAI;AAC5F,cAAM,SAAS,OAAO,aAAa,WAAW,WAAW,aAAa,OAAO,KAAK,IAAI;AACtF,YAAI,SAAU,QAAO,IAAI,YAAY,QAAQ;AAC7C,YAAI,OAAQ,QAAO,IAAI,UAAU,MAAM;AACvC,cAAM,WAA4B,EAAE,OAAO,CAAC,GAAG,OAAO,GAAG,MAAM,YAAY,EAAE;AAC7E,cAAM,OAAO,MAAM,QAAyB,8BAA8B,OAAO,SAAS,CAAC,IAAI,QAAW,EAAE,SAAS,CAAC;AACtH,YAAI,CAAC,KAAK,IAAI;AACZ,gBAAM,eAAe,KAAK;AAC1B,gBAAM,UAAU,OAAO,cAAc,UAAU,WAAW,aAAa,QAAQ,EAAE,+BAA+B;AAChH,gBAAM,SAAS,OAAO;AACtB;AAAA,QACF;AACA,cAAM,UAAU,KAAK,UAAU;AAC/B,YAAI,CAAC,WAAW;AACd,kBAAQ,MAAM,QAAQ,QAAQ,KAAK,IAAI,QAAQ,QAAQ,CAAC,CAAC;AACzD,mBAAS,QAAQ,SAAS,CAAC;AAC3B,wBAAc,QAAQ,cAAc,CAAC;AACrC,2BAAiB,SAAS,kBAAkB,IAAI;AAAA,QAClD;AAAA,MACF,SAAS,OAAO;AACd,YAAI,CAAC,WAAW;AACd,gBAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,EAAE,+BAA+B;AAC1F,gBAAM,SAAS,OAAO;AAAA,QACxB;AAAA,MACF,UAAE;AACA,YAAI,CAAC,UAAW,cAAa,KAAK;AAAA,MACpC;AAAA,IACF;AACA,SAAK;AACL,WAAO,MAAM;AAAE,kBAAY;AAAA,IAAK;AAAA,EAClC,GAAG,CAAC,MAAM,aAAa,cAAc,cAAc,CAAC,CAAC;AAErD,QAAM,mBAAmB,MAAM,YAAY,OAAO,QAAa;AAC7D,UAAM,YAAY,MAAM,QAAQ;AAAA,MAC9B,OAAO,EAAE,gCAAgC;AAAA,MACzC,SAAS;AAAA,IACX,CAAC;AACD,QAAI,CAAC,UAAW;AAChB,QAAI;AAEF,YAAM,OAAO,MAAM;AAAA,QACjB,8BAA8B,mBAAmB,IAAI,EAAE,CAAC;AAAA,QACxD,EAAE,QAAQ,SAAS;AAAA,QACnB,EAAE,UAAU,KAAK;AAAA,MACnB;AACA,UAAI,CAAC,KAAK,IAAI;AACZ,cAAM,eAAe,KAAK;AAC1B,cAAM,UAAU,OAAO,cAAc,UAAU,WAAW,aAAa,QAAQ,EAAE,qCAAqC;AACtH,cAAM,SAAS,OAAO;AACtB;AAAA,MACF;AACA,YAAM,EAAE,kCAAkC,GAAG,SAAS;AACtD,qBAAe,CAAC,UAAU,QAAQ,CAAC;AAAA,IACrC,SAAS,OAAO;AACd,YAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,EAAE,qCAAqC;AAChG,YAAM,SAAS,OAAO;AAAA,IACxB;AAAA,EACF,GAAG,CAAC,SAAS,CAAC,CAAC;AAEf,QAAM,UAAU,MAAM,QAA0B,MAAM;AAAA,IACpD;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,6BAA6B;AAAA,MACvC,MAAM,CAAC,EAAE,IAAI,MAAM,oBAAC,UAAK,WAAU,WAAW,cAAI,SAAS,UAAS;AAAA,IACtE;AAAA,IACA,EAAE,aAAa,YAAY,QAAQ,EAAE,+BAA+B,EAAE;AAAA,IACtE;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,2BAA2B;AAAA,MACrC,MAAM,CAAC,EAAE,IAAI,MAAM;AACjB,cAAM,SAAS,IAAI,SAAS;AAC5B,cAAM,QAAQ,cAAc,IAAI,MAAM;AACtC;AAAA;AAAA,UAEE;AAAA,YAAC;AAAA;AAAA,cACC,MAAM,kBAAkB,mBAAmB,MAAM,CAAC;AAAA,cAClD,WAAU;AAAA,cACV,SAAS,CAAC,MAAM,EAAE,gBAAgB;AAAA,cAEjC,mBAAS,oBAAC,UAAK,WAAU,WAAW,kBAAO;AAAA;AAAA,UAC9C;AAAA;AAAA,MAEJ;AAAA,IACF;AAAA,IACA;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,iCAAiC;AAAA,MAC3C,MAAM,CAAC,EAAE,IAAI,MAAM,IAAI,SAAS,oBAAoB,EAAE,sBAAsB;AAAA,IAC9E;AAAA,IACA;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,gCAAgC;AAAA,MAC1C,MAAM,CAAC,EAAE,IAAI,MAAM,IAAI,SAAS,aAAa,EAAE,sBAAsB;AAAA,IACvE;AAAA,IACA;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,mCAAmC;AAAA,MAC7C,MAAM,CAAC,EAAE,IAAI,MAAM,IAAI,SAAS,gBAAgB,EAAE,sBAAsB;AAAA,IAC1E;AAAA,IACA;AAAA,MACE,aAAa;AAAA,MACb,QAAQ,EAAE,+BAA+B;AAAA,MACzC,MAAM,CAAC,EAAE,IAAI,MAAM,WAAW,IAAI,SAAS,YAAY,CAAC;AAAA,IAC1D;AAAA,EACF,GAAG,CAAC,GAAG,aAAa,CAAC;AAErB,SACE,qBAAC,QACC;AAAA,wBAAC,YACC;AAAA,MAAC;AAAA;AAAA,QACC,OAAO,EAAE,oBAAoB;AAAA,QAC7B,SACE,oBAAC,UAAO,SAAO,MACb,8BAAC,QAAK,MAAK,2BAA2B,YAAE,+BAA+B,GAAE,GAC3E;AAAA,QAEF;AAAA,QACA,MAAM;AAAA,QACN;AAAA,QACA;AAAA,QACA,gBAAgB,CAAC,WAAW;AAAE,0BAAgB,MAAM;AAAG,kBAAQ,CAAC;AAAA,QAAE;AAAA,QAClE,gBAAgB,MAAM;AAAE,0BAAgB,CAAC,CAAC;AAAG,kBAAQ,CAAC;AAAA,QAAE;AAAA,QACxD,aAAa,EAAE,SAAS,eAAe;AAAA,QACvC,YAAY,CAAC,QACX,oBAAC,cAAW,OAAO;AAAA,UACjB,EAAE,IAAI,QAAQ,OAAO,EAAE,2BAA2B,GAAG,MAAM,oBAAoB,IAAI,EAAE,GAAG;AAAA,UACxF,EAAE,IAAI,cAAc,OAAO,EAAE,iCAAiC,GAAG,aAAa,MAAM,UAAU,MAAM;AAAE,iBAAK,iBAAiB,GAAG;AAAA,UAAE,EAAE;AAAA,QACrI,GAAG;AAAA,QAEL,YAAY,EAAE,MAAM,UAAU,IAAI,OAAO,YAAY,eAAe,cAAc,QAAQ;AAAA,QAC1F;AAAA;AAAA,IACF,GACF;AAAA,IACC;AAAA,KACH;AAEJ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -42,7 +42,7 @@ function DeviceField({ value, setValue, setFormValue, values, onState }) {
|
|
|
42
42
|
).catch(() => null).then((call) => {
|
|
43
43
|
if (cancelled) return;
|
|
44
44
|
const items = (call && call.ok ? call.result?.items : []) ?? [];
|
|
45
|
-
const opts = items.filter((d) => !!d && typeof d.id === "string" && !!d.
|
|
45
|
+
const opts = items.filter((d) => !!d && typeof d.id === "string" && !!d.pushProvider).map((d) => ({ id: d.id, label: `${d.deviceId} \xB7 ${d.platform}${d.pushProvider ? ` \xB7 ${d.pushProvider}` : ""}`, platform: d.platform }));
|
|
46
46
|
setDevices(opts);
|
|
47
47
|
if (selected && !opts.some((o) => o.id === selected)) setValue("");
|
|
48
48
|
}).finally(() => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/push_notifications/backend/push_notifications/send/page.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useRouter } from 'next/navigation'\nimport { Plus, X } from 'lucide-react'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { CrudForm, type CrudField, type CrudFieldOption, type CrudCustomFieldRenderProps } from '@open-mercato/ui/backend/CrudForm'\nimport { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@open-mercato/ui/primitives/select'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype KeyValue = { key: string; value: string }\n\ntype FormValues = {\n userId: string\n deviceId: string\n mode: string\n title: string\n body: string\n data: KeyValue[]\n sound: string\n badge: number | string\n image: string\n priority: string\n channelId: string\n // form-only helper (never sent): drives channelId visibility (hidden in silent / for an iOS target)\n showChannelId: boolean\n}\n\nconst DELIVERIES_HREF = '/backend/push_notifications'\nconst FORM_ID = 'push-custom-send-form'\n// Radix Select forbids an empty-string item value, so \"all devices\" uses a sentinel that we map\n// back to '' (the form value that means \"fan out to every push-capable device\").\nconst ALL_DEVICES = '__all__'\n\ntype DeviceOption = { id: string; label: string; platform: string }\n// Reported up to the page so the (custom) submit button can disable when there is nothing to send to.\ntype DeviceState = { userId: string; count: number; loading: boolean }\n\n// Dependent picker: loads the *selected recipient's* push-capable devices (admin devices API,\n// filtered by userId) so an admin can target one device or all of them. Reads the live form values,\n// so it re-loads whenever the recipient changes and clears a stale selection. It also derives\n// `showChannelId` (Android channel id is Android-only + visible-only) so the channelId field can hide\n// itself for a selected iOS device or in silent mode. Degrades to \"All devices\" when the admin lacks\n// devices.admin or the recipient has no push-capable device.\nfunction DeviceField({ value, setValue, setFormValue, values, onState }: CrudCustomFieldRenderProps & { onState?: (s: DeviceState) => void }) {\n const t = useT()\n const userId = typeof values?.userId === 'string' ? values.userId : ''\n const mode = typeof values?.mode === 'string' ? values.mode : 'visible'\n const selected = typeof value === 'string' && value ? value : ''\n const [devices, setDevices] = React.useState<DeviceOption[]>([])\n const [loading, setLoading] = React.useState(false)\n\n // Report the recipient + push-capable device count up so the page can disable \"Send\" when there is\n // nowhere to deliver (no recipient, or the recipient has zero push-capable devices).\n React.useEffect(() => {\n onState?.({ userId, count: devices.length, loading })\n }, [userId, devices.length, loading, onState])\n\n React.useEffect(() => {\n if (!userId) {\n setDevices([])\n if (selected) setValue('')\n return\n }\n let cancelled = false\n setLoading(true)\n const params = new URLSearchParams({ userId, pageSize: '50' })\n apiCall<{ items?: Array<{ id: string; device_id: string; platform: string; push_provider?: string | null }> }>(\n `/api/devices/admin/devices?${params.toString()}`,\n { headers: { 'x-om-forbidden-redirect': '0' } },\n { fallback: null },\n )\n .catch(() => null)\n .then((call) => {\n if (cancelled) return\n const items = (call && call.ok ? call.result?.items : []) ?? []\n const opts = items\n .filter((d): d is { id: string; device_id: string; platform: string; push_provider?: string | null } =>\n !!d && typeof d.id === 'string' && !!d.push_provider)\n .map((d) => ({ id: d.id, label: `${d.device_id} \u00B7 ${d.platform}${d.push_provider ? ` \u00B7 ${d.push_provider}` : ''}`, platform: d.platform }))\n setDevices(opts)\n if (selected && !opts.some((o) => o.id === selected)) setValue('')\n })\n .finally(() => { if (!cancelled) setLoading(false) })\n return () => { cancelled = true }\n // Reload only when the recipient changes; `selected`/`setValue` are handled inside.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [userId])\n\n // channelId is Android-only and irrelevant to silent pushes \u2192 hide it for a selected iOS device or\n // in silent mode. Only write when the derived value actually changes (avoids a render loop).\n const selectedPlatform = React.useMemo(() => devices.find((d) => d.id === selected)?.platform, [devices, selected])\n React.useEffect(() => {\n const show = mode !== 'silent' && selectedPlatform !== 'ios'\n if (values?.showChannelId !== show) setFormValue?.('showChannelId', show)\n }, [mode, selectedPlatform, values?.showChannelId, setFormValue])\n\n return (\n <div className=\"space-y-1\">\n <Select value={selected || ALL_DEVICES} onValueChange={(v) => setValue(v === ALL_DEVICES ? '' : v)} disabled={!userId}>\n <SelectTrigger>\n <SelectValue placeholder={`${t('push_notifications.send.deviceAll')} (${devices.length})`} />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value={ALL_DEVICES}>{`${t('push_notifications.send.deviceAll')} (${devices.length})`}</SelectItem>\n {devices.map((d) => (\n <SelectItem key={d.id} value={d.id}>{d.label}</SelectItem>\n ))}\n </SelectContent>\n </Select>\n <p className={`text-xs ${userId && !loading && devices.length === 0 ? 'text-status-warning-text' : 'text-muted-foreground'}`}>\n {!userId\n ? t('push_notifications.send.deviceSelectUserFirst')\n : loading\n ? t('common.loading', 'Loading\u2026')\n : devices.length === 0\n ? t('push_notifications.send.deviceNone')\n : t('push_notifications.send.deviceHint')}\n </p>\n </div>\n )\n}\n\n// Arbitrary key/value payload delivered inside the push `data` map (the app reads it). Shown for both\n// visible and silent sends \u2014 for silent it is the entire payload.\nfunction DataField({ value, setValue }: CrudCustomFieldRenderProps) {\n const t = useT()\n const pairs: KeyValue[] = Array.isArray(value) ? (value as KeyValue[]) : []\n const update = (next: KeyValue[]) => setValue(next)\n return (\n <div className=\"space-y-2\">\n {pairs.length === 0 ? (\n <p className=\"text-xs text-muted-foreground\">{t('push_notifications.send.dataEmpty')}</p>\n ) : null}\n {pairs.map((pair, index) => (\n <div key={index} className=\"flex items-center gap-2\">\n <Input\n placeholder={t('push_notifications.send.dataKey')}\n value={pair.key}\n onChange={(e) => update(pairs.map((p, i) => (i === index ? { ...p, key: e.target.value } : p)))}\n />\n <Input\n placeholder={t('push_notifications.send.dataValue')}\n value={pair.value}\n onChange={(e) => update(pairs.map((p, i) => (i === index ? { ...p, value: e.target.value } : p)))}\n />\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n aria-label={t('push_notifications.send.dataRemove')}\n onClick={() => update(pairs.filter((_, i) => i !== index))}\n >\n <X className=\"h-4 w-4\" />\n </IconButton>\n </div>\n ))}\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => update([...pairs, { key: '', value: '' }])}>\n <Plus className=\"h-4 w-4 mr-1\" />\n {t('push_notifications.send.dataAdd')}\n </Button>\n </div>\n )\n}\n\nexport default function PushCustomSendPage() {\n const router = useRouter()\n const t = useT()\n const [deviceState, setDeviceState] = React.useState<DeviceState>({ userId: '', count: 0, loading: false })\n const [submitting, setSubmitting] = React.useState(false)\n\n // Stable reporter for the device picker \u2192 bails when unchanged so it can't loop the render.\n const reportDeviceState = React.useCallback((next: DeviceState) => {\n setDeviceState((prev) =>\n prev.userId === next.userId && prev.count === next.count && prev.loading === next.loading ? prev : next)\n }, [])\n\n // Recipient search by name/email via /api/auth/users (mirrors the devices list + admin\n // notification-preferences picker). Return results only \u2014 the combobox retains them internally, so\n // pushing them into state that `fields` depends on would re-render the whole form per keystroke.\n const loadUserOptions = React.useCallback(async (query?: string): Promise<CrudFieldOption[]> => {\n const params = new URLSearchParams()\n params.set('page', '1')\n params.set('pageSize', '20')\n if (query && query.trim().length > 0) params.set('search', query.trim())\n const call = await apiCall<{ items?: { id: string; name?: string | null; email?: string | null }[] }>(\n `/api/auth/users?${params.toString()}`,\n { headers: { 'x-om-forbidden-redirect': '0' } },\n { fallback: null },\n ).catch(() => null)\n if (!call || !call.ok) return []\n return (call.result?.items ?? []).flatMap((item): CrudFieldOption[] => {\n if (!item || typeof item.id !== 'string' || !item.id.trim()) return []\n const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null\n const email = typeof item.email === 'string' && item.email.trim() ? item.email.trim() : null\n const label = name && email ? `${name} \u2014 ${email}` : email ?? name ?? item.id\n return [{ value: item.id, label }]\n })\n }, [])\n\n const visibleOnly = { field: 'mode', equals: 'visible' } as const\n\n const fields = React.useMemo<CrudField[]>(() => [\n { id: 'userId', label: t('push_notifications.send.userId'), type: 'combobox', required: true, description: t('push_notifications.send.userIdHint'), loadOptions: loadUserOptions },\n { id: 'deviceId', label: t('push_notifications.send.device'), type: 'custom', component: (props) => <DeviceField {...props} onState={reportDeviceState} /> },\n {\n id: 'mode',\n label: t('push_notifications.send.mode'),\n type: 'select',\n required: true,\n description: t('push_notifications.send.modeHint'),\n options: [\n { value: 'visible', label: t('push_notifications.send.modeVisible') },\n { value: 'silent', label: t('push_notifications.send.modeSilent') },\n ],\n },\n { id: 'title', label: t('push_notifications.send.title'), type: 'text', required: true, visibleWhen: visibleOnly },\n { id: 'body', label: t('push_notifications.send.body'), type: 'textarea', visibleWhen: visibleOnly },\n { id: 'data', label: t('push_notifications.send.data'), type: 'custom', description: t('push_notifications.send.dataHint'), component: (props) => <DataField {...props} /> },\n { id: 'sound', label: t('push_notifications.send.sound'), type: 'text', visibleWhen: visibleOnly },\n { id: 'badge', label: t('push_notifications.send.badge'), type: 'number', visibleWhen: visibleOnly },\n { id: 'image', label: t('push_notifications.send.image'), type: 'text', visibleWhen: visibleOnly },\n {\n id: 'priority',\n label: t('push_notifications.send.priority'),\n type: 'select',\n options: [\n { value: '', label: t('push_notifications.send.priorityDefault') },\n { value: 'high', label: t('push_notifications.send.priorityHigh') },\n { value: 'normal', label: t('push_notifications.send.priorityNormal') },\n ],\n },\n { id: 'channelId', label: t('push_notifications.send.channelId'), type: 'text', visibleWhen: { field: 'showChannelId', equals: true } },\n ], [t, loadUserOptions, reportDeviceState])\n\n // NOTE: intentionally a flat field list (no `groups`). CrudForm only applies `visibleWhen` in the\n // ungrouped render path \u2014 grouped fields ignore it \u2014 and this form relies on mode/platform-driven\n // field visibility (silent hides title/body/options; iOS hides channelId).\n\n return (\n <Page>\n <PageBody>\n <CrudForm<FormValues>\n title={t('push_notifications.send.pageTitle')}\n backHref={DELIVERIES_HREF}\n formId={FORM_ID}\n hideFooterActions\n fields={fields}\n initialValues={{ userId: '', deviceId: '', mode: 'visible', title: '', body: '', data: [], sound: '', badge: '', image: '', priority: '', channelId: '', showChannelId: true }}\n submitLabel={t('push_notifications.send.submit')}\n onSubmit={async (values) => {\n setSubmitting(true)\n try {\n const silent = values.mode === 'silent'\n const body = (values.body ?? '').trim()\n\n const dataMap: Record<string, string> = {}\n for (const pair of Array.isArray(values.data) ? values.data : []) {\n const key = (pair?.key ?? '').trim()\n if (key) dataMap[key] = `${pair?.value ?? ''}`\n }\n const hasData = Object.keys(dataMap).length > 0\n\n // Options that only affect a visible banner are dropped for silent.\n const pushOptions: Record<string, unknown> = {}\n if (!silent) {\n if (typeof values.sound === 'string' && values.sound.trim()) pushOptions.sound = values.sound.trim()\n const badgeNum = typeof values.badge === 'number' ? values.badge : Number.parseInt(String(values.badge ?? ''), 10)\n if (Number.isFinite(badgeNum) && badgeNum >= 0) pushOptions.badge = badgeNum\n if (typeof values.image === 'string' && values.image.trim()) pushOptions.image = values.image.trim()\n if (typeof values.channelId === 'string' && values.channelId.trim()) pushOptions.channelId = values.channelId.trim()\n }\n if (values.priority === 'high' || values.priority === 'normal') pushOptions.priority = values.priority\n const hasOptions = Object.keys(pushOptions).length > 0\n\n // Silent hides the title field; the API still needs one, so use a stable label for the log.\n const title = silent ? (values.title ?? '').trim() || 'Silent push' : (values.title ?? '').trim()\n\n await apiCallOrThrow('/api/push_notifications/custom-send', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n recipientUserId: (values.userId ?? '').trim(),\n deviceId: (values.deviceId ?? '').trim() || undefined,\n title,\n body: !silent && body.length > 0 ? body : undefined,\n silent,\n data: hasData ? dataMap : undefined,\n pushOptions: hasOptions ? pushOptions : undefined,\n }),\n })\n flash(t('push_notifications.send.success'), 'success')\n router.push(DELIVERIES_HREF)\n } finally {\n setSubmitting(false)\n }\n }}\n />\n <div className=\"mt-4 flex items-center justify-between gap-3\">\n <div className=\"text-xs\">\n {!deviceState.userId ? (\n <span className=\"text-muted-foreground\">{t('push_notifications.send.deviceSelectUserFirst')}</span>\n ) : deviceState.count === 0 && !deviceState.loading ? (\n <span className=\"text-status-warning-text\">{t('push_notifications.send.deviceNone')}</span>\n ) : null}\n </div>\n <div className=\"flex gap-2\">\n <Button asChild variant=\"outline\">\n <Link href={DELIVERIES_HREF}>{t('common.cancel', 'Cancel')}</Link>\n </Button>\n <Button\n type=\"submit\"\n form={FORM_ID}\n disabled={!deviceState.userId || deviceState.count === 0 || deviceState.loading || submitting}\n >\n {t('push_notifications.send.submit')}\n </Button>\n </div>\n </div>\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
-
"mappings": ";AA0GU,cAEF,YAFE;AAzGV,YAAY,WAAW;AACvB,OAAO,UAAU;AACjB,SAAS,iBAAiB;AAC1B,SAAS,MAAM,SAAS;AACxB,SAAS,MAAM,gBAAgB;AAC/B,SAAS,gBAAuF;AAChG,SAAS,SAAS,sBAAsB;AACxC,SAAS,aAAa;AACtB,SAAS,QAAQ,eAAe,aAAa,eAAe,kBAAkB;AAC9E,SAAS,aAAa;AACtB,SAAS,kBAAkB;AAC3B,SAAS,cAAc;AACvB,SAAS,YAAY;AAoBrB,MAAM,kBAAkB;AACxB,MAAM,UAAU;AAGhB,MAAM,cAAc;AAYpB,SAAS,YAAY,EAAE,OAAO,UAAU,cAAc,QAAQ,QAAQ,GAAwE;AAC5I,QAAM,IAAI,KAAK;AACf,QAAM,SAAS,OAAO,QAAQ,WAAW,WAAW,OAAO,SAAS;AACpE,QAAM,OAAO,OAAO,QAAQ,SAAS,WAAW,OAAO,OAAO;AAC9D,QAAM,WAAW,OAAO,UAAU,YAAY,QAAQ,QAAQ;AAC9D,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAyB,CAAC,CAAC;AAC/D,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAS,KAAK;AAIlD,QAAM,UAAU,MAAM;AACpB,cAAU,EAAE,QAAQ,OAAO,QAAQ,QAAQ,QAAQ,CAAC;AAAA,EACtD,GAAG,CAAC,QAAQ,QAAQ,QAAQ,SAAS,OAAO,CAAC;AAE7C,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,QAAQ;AACX,iBAAW,CAAC,CAAC;AACb,UAAI,SAAU,UAAS,EAAE;AACzB;AAAA,IACF;AACA,QAAI,YAAY;AAChB,eAAW,IAAI;AACf,UAAM,SAAS,IAAI,gBAAgB,EAAE,QAAQ,UAAU,KAAK,CAAC;AAC7D;AAAA,MACE,8BAA8B,OAAO,SAAS,CAAC;AAAA,MAC/C,EAAE,SAAS,EAAE,2BAA2B,IAAI,EAAE;AAAA,MAC9C,EAAE,UAAU,KAAK;AAAA,IACnB,EACG,MAAM,MAAM,IAAI,EAChB,KAAK,CAAC,SAAS;AACd,UAAI,UAAW;AACf,YAAM,SAAS,QAAQ,KAAK,KAAK,KAAK,QAAQ,QAAQ,CAAC,MAAM,CAAC;AAC9D,YAAM,OAAO,MACV,OAAO,CAAC,MACP,CAAC,CAAC,KAAK,OAAO,EAAE,OAAO,YAAY,CAAC,CAAC,EAAE,
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { useRouter } from 'next/navigation'\nimport { Plus, X } from 'lucide-react'\nimport { Page, PageBody } from '@open-mercato/ui/backend/Page'\nimport { CrudForm, type CrudField, type CrudFieldOption, type CrudCustomFieldRenderProps } from '@open-mercato/ui/backend/CrudForm'\nimport { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport { flash } from '@open-mercato/ui/backend/FlashMessages'\nimport { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@open-mercato/ui/primitives/select'\nimport { Input } from '@open-mercato/ui/primitives/input'\nimport { IconButton } from '@open-mercato/ui/primitives/icon-button'\nimport { Button } from '@open-mercato/ui/primitives/button'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype KeyValue = { key: string; value: string }\n\ntype FormValues = {\n userId: string\n deviceId: string\n mode: string\n title: string\n body: string\n data: KeyValue[]\n sound: string\n badge: number | string\n image: string\n priority: string\n channelId: string\n // form-only helper (never sent): drives channelId visibility (hidden in silent / for an iOS target)\n showChannelId: boolean\n}\n\nconst DELIVERIES_HREF = '/backend/push_notifications'\nconst FORM_ID = 'push-custom-send-form'\n// Radix Select forbids an empty-string item value, so \"all devices\" uses a sentinel that we map\n// back to '' (the form value that means \"fan out to every push-capable device\").\nconst ALL_DEVICES = '__all__'\n\ntype DeviceOption = { id: string; label: string; platform: string }\n// Reported up to the page so the (custom) submit button can disable when there is nothing to send to.\ntype DeviceState = { userId: string; count: number; loading: boolean }\n\n// Dependent picker: loads the *selected recipient's* push-capable devices (admin devices API,\n// filtered by userId) so an admin can target one device or all of them. Reads the live form values,\n// so it re-loads whenever the recipient changes and clears a stale selection. It also derives\n// `showChannelId` (Android channel id is Android-only + visible-only) so the channelId field can hide\n// itself for a selected iOS device or in silent mode. Degrades to \"All devices\" when the admin lacks\n// devices.admin or the recipient has no push-capable device.\nfunction DeviceField({ value, setValue, setFormValue, values, onState }: CrudCustomFieldRenderProps & { onState?: (s: DeviceState) => void }) {\n const t = useT()\n const userId = typeof values?.userId === 'string' ? values.userId : ''\n const mode = typeof values?.mode === 'string' ? values.mode : 'visible'\n const selected = typeof value === 'string' && value ? value : ''\n const [devices, setDevices] = React.useState<DeviceOption[]>([])\n const [loading, setLoading] = React.useState(false)\n\n // Report the recipient + push-capable device count up so the page can disable \"Send\" when there is\n // nowhere to deliver (no recipient, or the recipient has zero push-capable devices).\n React.useEffect(() => {\n onState?.({ userId, count: devices.length, loading })\n }, [userId, devices.length, loading, onState])\n\n React.useEffect(() => {\n if (!userId) {\n setDevices([])\n if (selected) setValue('')\n return\n }\n let cancelled = false\n setLoading(true)\n const params = new URLSearchParams({ userId, pageSize: '50' })\n apiCall<{ items?: Array<{ id: string; deviceId: string; platform: string; pushProvider?: string | null }> }>(\n `/api/devices/admin/devices?${params.toString()}`,\n { headers: { 'x-om-forbidden-redirect': '0' } },\n { fallback: null },\n )\n .catch(() => null)\n .then((call) => {\n if (cancelled) return\n const items = (call && call.ok ? call.result?.items : []) ?? []\n const opts = items\n .filter((d): d is { id: string; deviceId: string; platform: string; pushProvider?: string | null } =>\n !!d && typeof d.id === 'string' && !!d.pushProvider)\n .map((d) => ({ id: d.id, label: `${d.deviceId} \u00B7 ${d.platform}${d.pushProvider ? ` \u00B7 ${d.pushProvider}` : ''}`, platform: d.platform }))\n setDevices(opts)\n if (selected && !opts.some((o) => o.id === selected)) setValue('')\n })\n .finally(() => { if (!cancelled) setLoading(false) })\n return () => { cancelled = true }\n // Reload only when the recipient changes; `selected`/`setValue` are handled inside.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [userId])\n\n // channelId is Android-only and irrelevant to silent pushes \u2192 hide it for a selected iOS device or\n // in silent mode. Only write when the derived value actually changes (avoids a render loop).\n const selectedPlatform = React.useMemo(() => devices.find((d) => d.id === selected)?.platform, [devices, selected])\n React.useEffect(() => {\n const show = mode !== 'silent' && selectedPlatform !== 'ios'\n if (values?.showChannelId !== show) setFormValue?.('showChannelId', show)\n }, [mode, selectedPlatform, values?.showChannelId, setFormValue])\n\n return (\n <div className=\"space-y-1\">\n <Select value={selected || ALL_DEVICES} onValueChange={(v) => setValue(v === ALL_DEVICES ? '' : v)} disabled={!userId}>\n <SelectTrigger>\n <SelectValue placeholder={`${t('push_notifications.send.deviceAll')} (${devices.length})`} />\n </SelectTrigger>\n <SelectContent>\n <SelectItem value={ALL_DEVICES}>{`${t('push_notifications.send.deviceAll')} (${devices.length})`}</SelectItem>\n {devices.map((d) => (\n <SelectItem key={d.id} value={d.id}>{d.label}</SelectItem>\n ))}\n </SelectContent>\n </Select>\n <p className={`text-xs ${userId && !loading && devices.length === 0 ? 'text-status-warning-text' : 'text-muted-foreground'}`}>\n {!userId\n ? t('push_notifications.send.deviceSelectUserFirst')\n : loading\n ? t('common.loading', 'Loading\u2026')\n : devices.length === 0\n ? t('push_notifications.send.deviceNone')\n : t('push_notifications.send.deviceHint')}\n </p>\n </div>\n )\n}\n\n// Arbitrary key/value payload delivered inside the push `data` map (the app reads it). Shown for both\n// visible and silent sends \u2014 for silent it is the entire payload.\nfunction DataField({ value, setValue }: CrudCustomFieldRenderProps) {\n const t = useT()\n const pairs: KeyValue[] = Array.isArray(value) ? (value as KeyValue[]) : []\n const update = (next: KeyValue[]) => setValue(next)\n return (\n <div className=\"space-y-2\">\n {pairs.length === 0 ? (\n <p className=\"text-xs text-muted-foreground\">{t('push_notifications.send.dataEmpty')}</p>\n ) : null}\n {pairs.map((pair, index) => (\n <div key={index} className=\"flex items-center gap-2\">\n <Input\n placeholder={t('push_notifications.send.dataKey')}\n value={pair.key}\n onChange={(e) => update(pairs.map((p, i) => (i === index ? { ...p, key: e.target.value } : p)))}\n />\n <Input\n placeholder={t('push_notifications.send.dataValue')}\n value={pair.value}\n onChange={(e) => update(pairs.map((p, i) => (i === index ? { ...p, value: e.target.value } : p)))}\n />\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n aria-label={t('push_notifications.send.dataRemove')}\n onClick={() => update(pairs.filter((_, i) => i !== index))}\n >\n <X className=\"h-4 w-4\" />\n </IconButton>\n </div>\n ))}\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => update([...pairs, { key: '', value: '' }])}>\n <Plus className=\"h-4 w-4 mr-1\" />\n {t('push_notifications.send.dataAdd')}\n </Button>\n </div>\n )\n}\n\nexport default function PushCustomSendPage() {\n const router = useRouter()\n const t = useT()\n const [deviceState, setDeviceState] = React.useState<DeviceState>({ userId: '', count: 0, loading: false })\n const [submitting, setSubmitting] = React.useState(false)\n\n // Stable reporter for the device picker \u2192 bails when unchanged so it can't loop the render.\n const reportDeviceState = React.useCallback((next: DeviceState) => {\n setDeviceState((prev) =>\n prev.userId === next.userId && prev.count === next.count && prev.loading === next.loading ? prev : next)\n }, [])\n\n // Recipient search by name/email via /api/auth/users (mirrors the devices list + admin\n // notification-preferences picker). Return results only \u2014 the combobox retains them internally, so\n // pushing them into state that `fields` depends on would re-render the whole form per keystroke.\n const loadUserOptions = React.useCallback(async (query?: string): Promise<CrudFieldOption[]> => {\n const params = new URLSearchParams()\n params.set('page', '1')\n params.set('pageSize', '20')\n if (query && query.trim().length > 0) params.set('search', query.trim())\n const call = await apiCall<{ items?: { id: string; name?: string | null; email?: string | null }[] }>(\n `/api/auth/users?${params.toString()}`,\n { headers: { 'x-om-forbidden-redirect': '0' } },\n { fallback: null },\n ).catch(() => null)\n if (!call || !call.ok) return []\n return (call.result?.items ?? []).flatMap((item): CrudFieldOption[] => {\n if (!item || typeof item.id !== 'string' || !item.id.trim()) return []\n const name = typeof item.name === 'string' && item.name.trim() ? item.name.trim() : null\n const email = typeof item.email === 'string' && item.email.trim() ? item.email.trim() : null\n const label = name && email ? `${name} \u2014 ${email}` : email ?? name ?? item.id\n return [{ value: item.id, label }]\n })\n }, [])\n\n const visibleOnly = { field: 'mode', equals: 'visible' } as const\n\n const fields = React.useMemo<CrudField[]>(() => [\n { id: 'userId', label: t('push_notifications.send.userId'), type: 'combobox', required: true, description: t('push_notifications.send.userIdHint'), loadOptions: loadUserOptions },\n { id: 'deviceId', label: t('push_notifications.send.device'), type: 'custom', component: (props) => <DeviceField {...props} onState={reportDeviceState} /> },\n {\n id: 'mode',\n label: t('push_notifications.send.mode'),\n type: 'select',\n required: true,\n description: t('push_notifications.send.modeHint'),\n options: [\n { value: 'visible', label: t('push_notifications.send.modeVisible') },\n { value: 'silent', label: t('push_notifications.send.modeSilent') },\n ],\n },\n { id: 'title', label: t('push_notifications.send.title'), type: 'text', required: true, visibleWhen: visibleOnly },\n { id: 'body', label: t('push_notifications.send.body'), type: 'textarea', visibleWhen: visibleOnly },\n { id: 'data', label: t('push_notifications.send.data'), type: 'custom', description: t('push_notifications.send.dataHint'), component: (props) => <DataField {...props} /> },\n { id: 'sound', label: t('push_notifications.send.sound'), type: 'text', visibleWhen: visibleOnly },\n { id: 'badge', label: t('push_notifications.send.badge'), type: 'number', visibleWhen: visibleOnly },\n { id: 'image', label: t('push_notifications.send.image'), type: 'text', visibleWhen: visibleOnly },\n {\n id: 'priority',\n label: t('push_notifications.send.priority'),\n type: 'select',\n options: [\n { value: '', label: t('push_notifications.send.priorityDefault') },\n { value: 'high', label: t('push_notifications.send.priorityHigh') },\n { value: 'normal', label: t('push_notifications.send.priorityNormal') },\n ],\n },\n { id: 'channelId', label: t('push_notifications.send.channelId'), type: 'text', visibleWhen: { field: 'showChannelId', equals: true } },\n ], [t, loadUserOptions, reportDeviceState])\n\n // NOTE: intentionally a flat field list (no `groups`). CrudForm only applies `visibleWhen` in the\n // ungrouped render path \u2014 grouped fields ignore it \u2014 and this form relies on mode/platform-driven\n // field visibility (silent hides title/body/options; iOS hides channelId).\n\n return (\n <Page>\n <PageBody>\n <CrudForm<FormValues>\n title={t('push_notifications.send.pageTitle')}\n backHref={DELIVERIES_HREF}\n formId={FORM_ID}\n hideFooterActions\n fields={fields}\n initialValues={{ userId: '', deviceId: '', mode: 'visible', title: '', body: '', data: [], sound: '', badge: '', image: '', priority: '', channelId: '', showChannelId: true }}\n submitLabel={t('push_notifications.send.submit')}\n onSubmit={async (values) => {\n setSubmitting(true)\n try {\n const silent = values.mode === 'silent'\n const body = (values.body ?? '').trim()\n\n const dataMap: Record<string, string> = {}\n for (const pair of Array.isArray(values.data) ? values.data : []) {\n const key = (pair?.key ?? '').trim()\n if (key) dataMap[key] = `${pair?.value ?? ''}`\n }\n const hasData = Object.keys(dataMap).length > 0\n\n // Options that only affect a visible banner are dropped for silent.\n const pushOptions: Record<string, unknown> = {}\n if (!silent) {\n if (typeof values.sound === 'string' && values.sound.trim()) pushOptions.sound = values.sound.trim()\n const badgeNum = typeof values.badge === 'number' ? values.badge : Number.parseInt(String(values.badge ?? ''), 10)\n if (Number.isFinite(badgeNum) && badgeNum >= 0) pushOptions.badge = badgeNum\n if (typeof values.image === 'string' && values.image.trim()) pushOptions.image = values.image.trim()\n if (typeof values.channelId === 'string' && values.channelId.trim()) pushOptions.channelId = values.channelId.trim()\n }\n if (values.priority === 'high' || values.priority === 'normal') pushOptions.priority = values.priority\n const hasOptions = Object.keys(pushOptions).length > 0\n\n // Silent hides the title field; the API still needs one, so use a stable label for the log.\n const title = silent ? (values.title ?? '').trim() || 'Silent push' : (values.title ?? '').trim()\n\n await apiCallOrThrow('/api/push_notifications/custom-send', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({\n recipientUserId: (values.userId ?? '').trim(),\n deviceId: (values.deviceId ?? '').trim() || undefined,\n title,\n body: !silent && body.length > 0 ? body : undefined,\n silent,\n data: hasData ? dataMap : undefined,\n pushOptions: hasOptions ? pushOptions : undefined,\n }),\n })\n flash(t('push_notifications.send.success'), 'success')\n router.push(DELIVERIES_HREF)\n } finally {\n setSubmitting(false)\n }\n }}\n />\n <div className=\"mt-4 flex items-center justify-between gap-3\">\n <div className=\"text-xs\">\n {!deviceState.userId ? (\n <span className=\"text-muted-foreground\">{t('push_notifications.send.deviceSelectUserFirst')}</span>\n ) : deviceState.count === 0 && !deviceState.loading ? (\n <span className=\"text-status-warning-text\">{t('push_notifications.send.deviceNone')}</span>\n ) : null}\n </div>\n <div className=\"flex gap-2\">\n <Button asChild variant=\"outline\">\n <Link href={DELIVERIES_HREF}>{t('common.cancel', 'Cancel')}</Link>\n </Button>\n <Button\n type=\"submit\"\n form={FORM_ID}\n disabled={!deviceState.userId || deviceState.count === 0 || deviceState.loading || submitting}\n >\n {t('push_notifications.send.submit')}\n </Button>\n </div>\n </div>\n </PageBody>\n </Page>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7080.1.9b0d558a3f",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -252,16 +252,16 @@
|
|
|
252
252
|
"zod": "^4.4.3"
|
|
253
253
|
},
|
|
254
254
|
"peerDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
256
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
257
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7080.1.9b0d558a3f",
|
|
256
|
+
"@open-mercato/shared": "0.6.8-develop.7080.1.9b0d558a3f",
|
|
257
|
+
"@open-mercato/ui": "0.6.8-develop.7080.1.9b0d558a3f",
|
|
258
258
|
"react": "^19.0.0",
|
|
259
259
|
"react-dom": "^19.0.0"
|
|
260
260
|
},
|
|
261
261
|
"devDependencies": {
|
|
262
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
263
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
264
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
262
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7080.1.9b0d558a3f",
|
|
263
|
+
"@open-mercato/shared": "0.6.8-develop.7080.1.9b0d558a3f",
|
|
264
|
+
"@open-mercato/ui": "0.6.8-develop.7080.1.9b0d558a3f",
|
|
265
265
|
"@testing-library/dom": "^10.4.1",
|
|
266
266
|
"@testing-library/jest-dom": "^7.0.0",
|
|
267
267
|
"@testing-library/react": "^16.3.1",
|
|
@@ -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 },
|