@open-mercato/channel-expo 0.6.8-develop.6985.1.fb93574faa

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +26 -0
  3. package/build.mjs +7 -0
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +7 -0
  6. package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.js +56 -0
  7. package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.js.map +7 -0
  8. package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.js +7 -0
  9. package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.js.map +7 -0
  10. package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.js +65 -0
  11. package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.js.map +7 -0
  12. package/dist/modules/channel_expo/acl.js +10 -0
  13. package/dist/modules/channel_expo/acl.js.map +7 -0
  14. package/dist/modules/channel_expo/di.js +24 -0
  15. package/dist/modules/channel_expo/di.js.map +7 -0
  16. package/dist/modules/channel_expo/index.js +9 -0
  17. package/dist/modules/channel_expo/index.js.map +7 -0
  18. package/dist/modules/channel_expo/integration.js +47 -0
  19. package/dist/modules/channel_expo/integration.js.map +7 -0
  20. package/dist/modules/channel_expo/lib/adapter.js +154 -0
  21. package/dist/modules/channel_expo/lib/adapter.js.map +7 -0
  22. package/dist/modules/channel_expo/lib/credentials.js +8 -0
  23. package/dist/modules/channel_expo/lib/credentials.js.map +7 -0
  24. package/dist/modules/channel_expo/lib/fake-provider.js +40 -0
  25. package/dist/modules/channel_expo/lib/fake-provider.js.map +7 -0
  26. package/dist/modules/channel_expo/lib/health.js +10 -0
  27. package/dist/modules/channel_expo/lib/health.js.map +7 -0
  28. package/dist/modules/channel_expo/setup.js +25 -0
  29. package/dist/modules/channel_expo/setup.js.map +7 -0
  30. package/dist/modules/channel_expo/widgets/injection/connect/widget.client.js +157 -0
  31. package/dist/modules/channel_expo/widgets/injection/connect/widget.client.js.map +7 -0
  32. package/dist/modules/channel_expo/widgets/injection/connect/widget.js +17 -0
  33. package/dist/modules/channel_expo/widgets/injection/connect/widget.js.map +7 -0
  34. package/dist/modules/channel_expo/widgets/injection-table.js +15 -0
  35. package/dist/modules/channel_expo/widgets/injection-table.js.map +7 -0
  36. package/jest.config.cjs +34 -0
  37. package/package.json +96 -0
  38. package/src/index.ts +1 -0
  39. package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.ts +76 -0
  40. package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.ts +3 -0
  41. package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.ts +83 -0
  42. package/src/modules/channel_expo/acl.ts +6 -0
  43. package/src/modules/channel_expo/di.ts +26 -0
  44. package/src/modules/channel_expo/index.ts +6 -0
  45. package/src/modules/channel_expo/integration.ts +48 -0
  46. package/src/modules/channel_expo/lib/__tests__/adapter-instance-cache.test.ts +65 -0
  47. package/src/modules/channel_expo/lib/__tests__/adapter.test.ts +211 -0
  48. package/src/modules/channel_expo/lib/__tests__/credentials.test.ts +18 -0
  49. package/src/modules/channel_expo/lib/__tests__/fake-provider.test.ts +94 -0
  50. package/src/modules/channel_expo/lib/__tests__/message-golden.test.ts +77 -0
  51. package/src/modules/channel_expo/lib/adapter.ts +256 -0
  52. package/src/modules/channel_expo/lib/credentials.ts +14 -0
  53. package/src/modules/channel_expo/lib/fake-provider.ts +64 -0
  54. package/src/modules/channel_expo/lib/health.ts +13 -0
  55. package/src/modules/channel_expo/setup.ts +31 -0
  56. package/src/modules/channel_expo/widgets/injection/connect/widget.client.tsx +193 -0
  57. package/src/modules/channel_expo/widgets/injection/connect/widget.ts +16 -0
  58. package/src/modules/channel_expo/widgets/injection-table.ts +13 -0
  59. package/tsconfig.json +9 -0
  60. package/watch.mjs +7 -0
@@ -0,0 +1,256 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type {
3
+ SendMessageInput,
4
+ SendMessageResult,
5
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter'
6
+ import {
7
+ BasePushChannelAdapter,
8
+ deviceUnregisteredResult,
9
+ MISSING_PUSH_TOKEN_RESULT,
10
+ readPushToken,
11
+ type PushReceiptChecker,
12
+ type PushReceiptOutcome,
13
+ } from '@open-mercato/core/modules/communication_channels/lib/push-adapter'
14
+ import { readPushEnvelope, resolvePushBody, type PushEnvelope } from '@open-mercato/core/modules/communication_channels/lib/push-envelope'
15
+ import { expoCredentialsSchema, type ExpoCredentials } from './credentials'
16
+
17
+ export interface ExpoPushTicket {
18
+ status: 'ok' | 'error'
19
+ id?: string
20
+ message?: string
21
+ details?: { error?: string }
22
+ }
23
+
24
+ /**
25
+ * Async delivery receipt fetched later by ticket id via `getPushNotificationReceiptsAsync`. This is
26
+ * where the common "app uninstalled" case surfaces for Expo (`details.error === 'DeviceNotRegistered'`),
27
+ * NOT the synchronous send ticket.
28
+ */
29
+ export interface ExpoPushReceipt {
30
+ status: 'ok' | 'error'
31
+ message?: string
32
+ details?: { error?: string }
33
+ }
34
+
35
+ /**
36
+ * Expo receipt-error codes that mean the device token is permanently invalid ⇒ soft-delete the device
37
+ * (uniform `device_unregistered` contract). Everything else (notably `MessageRateExceeded`) is transient
38
+ * and must NOT kill the token.
39
+ */
40
+ const PERMANENT_EXPO_RECEIPT_ERRORS = new Set(['DeviceNotRegistered'])
41
+
42
+ export interface ExpoPushMessage {
43
+ to: string
44
+ title?: string
45
+ body?: string
46
+ data?: Record<string, string>
47
+ sound?: string
48
+ badge?: number
49
+ priority?: 'default' | 'normal' | 'high'
50
+ channelId?: string
51
+ richContent?: { image?: string }
52
+ /** Data-only / silent delivery (iOS background wake-up). */
53
+ _contentAvailable?: boolean
54
+ }
55
+
56
+ /** Build the Expo message, branching on silent and applying recognized push options. */
57
+ export function buildExpoMessage(token: string, envelope: PushEnvelope): ExpoPushMessage {
58
+ const { options, silent } = envelope
59
+ if (silent) {
60
+ return { to: token, data: envelope.data, _contentAvailable: true }
61
+ }
62
+ const message: ExpoPushMessage = {
63
+ to: token,
64
+ title: envelope.title,
65
+ body: resolvePushBody(envelope),
66
+ data: envelope.data,
67
+ sound: options.sound ?? 'default',
68
+ }
69
+ if (typeof options.badge === 'number') message.badge = options.badge
70
+ if (options.priority) message.priority = options.priority
71
+ if (options.channelId) message.channelId = options.channelId
72
+ if (options.image) message.richContent = { image: options.image }
73
+ return message
74
+ }
75
+
76
+ /**
77
+ * Seam over `expo-server-sdk` so the SDK (and its network client) stays out of
78
+ * the adapter control flow and unit tests.
79
+ */
80
+ export interface ExpoClientLike {
81
+ isExpoPushToken(token: string): boolean | Promise<boolean>
82
+ send(messages: ExpoPushMessage[]): Promise<ExpoPushTicket[]>
83
+ /** Fetch async delivery receipts keyed by ticket id (chunked internally by the SDK). */
84
+ getReceipts(ticketIds: string[]): Promise<Record<string, ExpoPushReceipt>>
85
+ }
86
+
87
+ type ExpoModule = {
88
+ Expo: {
89
+ isExpoPushToken(token: string): boolean
90
+ new (options: { accessToken?: string }): {
91
+ sendPushNotificationsAsync(messages: ExpoPushMessage[]): Promise<ExpoPushTicket[]>
92
+ chunkPushNotificationReceiptIds(receiptIds: string[]): string[][]
93
+ getPushNotificationReceiptsAsync(receiptIds: string[]): Promise<Record<string, ExpoPushReceipt>>
94
+ }
95
+ }
96
+ }
97
+
98
+ export type ExpoClientFactory = (credentials: ExpoCredentials) => ExpoClientLike
99
+
100
+ let clientFactory: ExpoClientFactory | null = null
101
+
102
+ /** Test-only seam to swap the Expo client factory. */
103
+ export function setExpoClientFactory(factory: ExpoClientFactory | null): void {
104
+ clientFactory = factory
105
+ }
106
+
107
+ type ExpoInstance = InstanceType<ExpoModule['Expo']>
108
+
109
+ // Bound the Expo client cache like the FCM/APNs adapters. An Expo instance is a plain SDK client (no
110
+ // background timer or teardown), so eviction just drops the map entry — but leaving it unbounded would
111
+ // grow one entry per distinct tenant access token.
112
+ const INSTANCE_CACHE_MAX = 32
113
+ const expoInstanceCache = new Map<string, Promise<ExpoInstance>>()
114
+
115
+ let expoModulePromise: Promise<ExpoModule> | null = null
116
+
117
+ function loadExpoModule(): Promise<ExpoModule> {
118
+ if (!expoModulePromise) {
119
+ const loading = import('expo-server-sdk').then((mod) => {
120
+ const candidate = mod as unknown as { default?: ExpoModule } & ExpoModule
121
+ return candidate.default ?? candidate
122
+ })
123
+ // Drop a failed import so a later call can retry instead of returning the cached rejection forever.
124
+ loading.catch(() => {
125
+ if (expoModulePromise === loading) expoModulePromise = null
126
+ })
127
+ expoModulePromise = loading
128
+ }
129
+ return expoModulePromise
130
+ }
131
+
132
+ // Key the cache on a hash of the access token, never the raw secret (mirrors the FCM/APNs adapters):
133
+ // a Map key is trivially inspectable in a heap dump, and hashing keeps tenant credential material out
134
+ // of the cache's key space. The empty-token case (Expo allows an unauthenticated client) keeps a stable
135
+ // sentinel so those callers still share one instance.
136
+ function getExpoInstance(accessToken: string | undefined): Promise<ExpoInstance> {
137
+ const cacheKey = accessToken
138
+ ? createHash('sha256').update(accessToken).digest('hex').slice(0, 16)
139
+ : 'no-access-token'
140
+ const existing = expoInstanceCache.get(cacheKey)
141
+ if (existing) {
142
+ // Refresh recency: delete + re-insert moves the key to the newest position.
143
+ expoInstanceCache.delete(cacheKey)
144
+ expoInstanceCache.set(cacheKey, existing)
145
+ return existing
146
+ }
147
+ const pending = loadExpoModule().then(({ Expo }) => new Expo({ accessToken }))
148
+ // Drop a failed init so a later call can retry instead of returning the cached rejection forever.
149
+ pending.catch(() => {
150
+ if (expoInstanceCache.get(cacheKey) === pending) expoInstanceCache.delete(cacheKey)
151
+ })
152
+ expoInstanceCache.set(cacheKey, pending)
153
+ if (expoInstanceCache.size > INSTANCE_CACHE_MAX) {
154
+ const oldestKey = expoInstanceCache.keys().next().value as string | undefined
155
+ if (oldestKey != null) expoInstanceCache.delete(oldestKey)
156
+ }
157
+ return pending
158
+ }
159
+
160
+ function defaultClientFactory(credentials: ExpoCredentials): ExpoClientLike {
161
+ return {
162
+ async isExpoPushToken(token: string): Promise<boolean> {
163
+ const { Expo } = await loadExpoModule()
164
+ return Expo.isExpoPushToken(token)
165
+ },
166
+ async send(messages: ExpoPushMessage[]): Promise<ExpoPushTicket[]> {
167
+ const expo = await getExpoInstance(credentials.accessToken)
168
+ return expo.sendPushNotificationsAsync(messages)
169
+ },
170
+ async getReceipts(ticketIds: string[]): Promise<Record<string, ExpoPushReceipt>> {
171
+ const expo = await getExpoInstance(credentials.accessToken)
172
+ const receipts: Record<string, ExpoPushReceipt> = {}
173
+ for (const chunk of expo.chunkPushNotificationReceiptIds(ticketIds)) {
174
+ Object.assign(receipts, await expo.getPushNotificationReceiptsAsync(chunk))
175
+ }
176
+ return receipts
177
+ },
178
+ }
179
+ }
180
+
181
+ class ExpoChannelAdapter extends BasePushChannelAdapter implements PushReceiptChecker {
182
+ readonly providerKey = 'expo'
183
+ protected readonly credentialsSchema = expoCredentialsSchema
184
+
185
+ async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
186
+ const token = readPushToken(input)
187
+ if (!token) return MISSING_PUSH_TOKEN_RESULT
188
+
189
+ const parsedCredentials = expoCredentialsSchema.safeParse(input.credentials)
190
+ if (!parsedCredentials.success) {
191
+ return { externalMessageId: '', status: 'failed', error: 'invalid_expo_credentials' }
192
+ }
193
+
194
+ const client = (clientFactory ?? defaultClientFactory)(parsedCredentials.data)
195
+
196
+ // A malformed Expo token can never deliver — treat it as unregistered so the
197
+ // worker soft-deletes the device (uniform sentinel across providers).
198
+ if (!(await client.isExpoPushToken(token))) {
199
+ return deviceUnregisteredResult({ reason: 'invalid_expo_push_token' })
200
+ }
201
+
202
+ const envelope = readPushEnvelope(input.content)
203
+
204
+ try {
205
+ const tickets = await client.send([buildExpoMessage(token, envelope)])
206
+ const ticket = tickets[0]
207
+ if (!ticket) return { externalMessageId: '', status: 'failed', error: 'no_response' }
208
+ if (ticket.status === 'ok') {
209
+ return { externalMessageId: ticket.id ?? '', status: 'sent' }
210
+ }
211
+ // Expo delivery is two-phase. A `status: 'ok'` ticket only means Expo *accepted* the message — it
212
+ // does NOT confirm the token is valid. This ticket-level check catches the narrow cases Expo
213
+ // rejects synchronously; the common "app uninstalled" case (`DeviceNotRegistered` for a
214
+ // well-formed-but-stale token) surfaces later in the RECEIPT phase and is pruned by `checkReceipts`
215
+ // below, polled by the push_notifications receipt reaper.
216
+ if (ticket.details?.error === 'DeviceNotRegistered') {
217
+ return deviceUnregisteredResult({ reason: 'DeviceNotRegistered' })
218
+ }
219
+ return { externalMessageId: '', status: 'failed', error: ticket.message ?? 'expo_send_failed' }
220
+ } catch (err) {
221
+ return { externalMessageId: '', status: 'failed', error: err instanceof Error ? err.message : String(err) }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Poll async delivery receipts for previously-accepted tickets and report which map to a
227
+ * permanently-unregistered device token. Returns an entry ONLY for tickets whose receipt is ready
228
+ * (present in the SDK response); tickets with no receipt yet are omitted so the reaper re-checks them
229
+ * on a later sweep. `MessageRateExceeded` (and any other non-`DeviceNotRegistered` error) is treated
230
+ * as transient — the receipt resolves but the token is NOT killed.
231
+ */
232
+ async checkReceipts(ticketIds: string[], credentials: unknown): Promise<PushReceiptOutcome[]> {
233
+ const ids = ticketIds.filter((id) => typeof id === 'string' && id.length > 0)
234
+ if (ids.length === 0) return []
235
+
236
+ const parsedCredentials = expoCredentialsSchema.safeParse(credentials ?? {})
237
+ const client = (clientFactory ?? defaultClientFactory)(parsedCredentials.success ? parsedCredentials.data : {})
238
+
239
+ const receipts = await client.getReceipts(ids)
240
+ const outcomes: PushReceiptOutcome[] = []
241
+ for (const ticketId of ids) {
242
+ const receipt = receipts[ticketId]
243
+ if (!receipt) continue // receipt not ready yet — leave it for a later sweep
244
+ const unregistered = receipt.status === 'error' && !!receipt.details?.error && PERMANENT_EXPO_RECEIPT_ERRORS.has(receipt.details.error)
245
+ outcomes.push({ ticketId, unregistered })
246
+ }
247
+ return outcomes
248
+ }
249
+ }
250
+
251
+ let cachedAdapter: ExpoChannelAdapter | null = null
252
+
253
+ export function getExpoChannelAdapter(): ExpoChannelAdapter {
254
+ if (!cachedAdapter) cachedAdapter = new ExpoChannelAdapter()
255
+ return cachedAdapter
256
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod'
2
+
3
+ /**
4
+ * Tenant-level Expo credentials persisted on `IntegrationCredentials` for provider
5
+ * `channel_expo`. The access token is optional (only required when Expo "enhanced
6
+ * push security" is enabled for the project); stored encrypted at rest.
7
+ */
8
+ export const expoCredentialsSchema = z
9
+ .object({
10
+ accessToken: z.string().optional(),
11
+ })
12
+ .passthrough()
13
+
14
+ export type ExpoCredentials = z.infer<typeof expoCredentialsSchema>
@@ -0,0 +1,64 @@
1
+ import {
2
+ isPushFakeProvidersEnabled,
3
+ recordFakePush,
4
+ warnPushFakeProvidersActive,
5
+ } from '@open-mercato/core/modules/push_notifications/lib/fake-provider-recorder'
6
+ import {
7
+ setExpoClientFactory,
8
+ type ExpoPushMessage,
9
+ type ExpoPushReceipt,
10
+ type ExpoPushTicket,
11
+ } from './adapter'
12
+
13
+ /**
14
+ * Network-free `expo-server-sdk` client used ONLY by integration tests.
15
+ *
16
+ * Swaps the SDK client behind the adapter's existing seam, so the real adapter still runs its token
17
+ * validation, chunking, and `DeviceNotRegistered` → `device_unregistered` mapping. The adapter itself is
18
+ * never replaced or re-registered.
19
+ *
20
+ * `isExpoPushToken` accepts every token: the sentinels below are not real `ExponentPushToken[...]`
21
+ * values, and rejecting them would short-circuit the adapter before it ever reaches the ticket path.
22
+ *
23
+ * Token sentinels match `push_stub`'s convention (see push-stub-adapter.ts):
24
+ * - token containing `unregistered` → an accepted ticket whose *receipt* later reports
25
+ * `DeviceNotRegistered`, reproducing Expo's real two-phase behavior for an uninstalled app
26
+ * - token containing `fail` → a rejected ticket (retryable)
27
+ * - otherwise → success
28
+ *
29
+ * The sentinel is encoded into the ticket id because `getReceipts` is polled by the reaper in a
30
+ * *different* process than the send: the fake keeps no cross-process state.
31
+ *
32
+ * Production safety: never installed at module import; no-op unless `OM_PUSH_FAKE_PROVIDERS` is set.
33
+ */
34
+ const UNREGISTERED_TICKET_MARKER = 'unreg'
35
+
36
+ export function ensureExpoFakeProviderInstalled(): void {
37
+ if (!isPushFakeProvidersEnabled()) return
38
+ warnPushFakeProvidersActive('expo')
39
+ setExpoClientFactory(() => ({
40
+ isExpoPushToken(): boolean {
41
+ return true
42
+ },
43
+ async send(messages: ExpoPushMessage[]): Promise<ExpoPushTicket[]> {
44
+ return messages.map((message) => {
45
+ const token = message.to
46
+ recordFakePush('expo', token, message as unknown as Record<string, unknown>)
47
+ if (token.includes('fail')) {
48
+ return { status: 'error', message: 'fake expo transient failure' }
49
+ }
50
+ const marker = token.includes('unregistered') ? `${UNREGISTERED_TICKET_MARKER}-` : ''
51
+ return { status: 'ok', id: `expo-fake-${marker}${token.slice(-8) || 'token'}` }
52
+ })
53
+ },
54
+ async getReceipts(ticketIds: string[]): Promise<Record<string, ExpoPushReceipt>> {
55
+ const receipts: Record<string, ExpoPushReceipt> = {}
56
+ for (const ticketId of ticketIds) {
57
+ receipts[ticketId] = ticketId.includes(`-${UNREGISTERED_TICKET_MARKER}-`)
58
+ ? { status: 'error', details: { error: 'DeviceNotRegistered' } }
59
+ : { status: 'ok' }
60
+ }
61
+ return receipts
62
+ },
63
+ }))
64
+ }
@@ -0,0 +1,13 @@
1
+ import { makePushClientConfigHealthCheck } from '@open-mercato/core/modules/push_notifications/lib/push-health'
2
+ import { expoCredentialsSchema } from './credentials'
3
+
4
+ /**
5
+ * Liveness probe for the Expo integration. Expo credentials are minimal (an
6
+ * optional access token), so the probe just confirms the stored config is
7
+ * well-formed — no network call. Per-device token validity surfaces on delivery
8
+ * (`device_unregistered` soft-deletes).
9
+ */
10
+ export const channelExpoHealthCheck = makePushClientConfigHealthCheck({
11
+ schema: expoCredentialsSchema,
12
+ providerLabel: 'Expo',
13
+ })
@@ -0,0 +1,31 @@
1
+ import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'
2
+ import {
3
+ hasChannelAdapter,
4
+ registerChannelAdapter,
5
+ } from '@open-mercato/core/modules/communication_channels/lib/adapter-registry-singleton'
6
+ import { getExpoChannelAdapter } from './lib/adapter'
7
+
8
+ /**
9
+ * Register the Expo `ChannelAdapter` once per process at import time. Guarded with
10
+ * `hasChannelAdapter` to silence the duplicate error on dev-mode HMR + repeated
11
+ * test imports. Provider credentials (optional access token) are persisted per
12
+ * tenant via the standard `IntegrationCredentials` flow for `channel_expo`.
13
+ */
14
+ function ensureExpoAdapterRegistered(): void {
15
+ if (hasChannelAdapter('expo')) return
16
+ registerChannelAdapter(getExpoChannelAdapter())
17
+ }
18
+
19
+ ensureExpoAdapterRegistered()
20
+
21
+ export const setup: ModuleSetupConfig = {
22
+ defaultRoleFeatures: {
23
+ superadmin: ['channel_expo.view', 'channel_expo.configure'],
24
+ admin: ['channel_expo.view', 'channel_expo.configure'],
25
+ },
26
+ async onTenantCreated() {
27
+ ensureExpoAdapterRegistered()
28
+ },
29
+ }
30
+
31
+ export default setup
@@ -0,0 +1,193 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
+ import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
8
+ import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
9
+ import {
10
+ resolvePushConnectErrorMessage,
11
+ resolvePushConnectFieldErrors,
12
+ } from '@open-mercato/core/modules/communication_channels/lib/push-connect-error'
13
+ import { Button } from '@open-mercato/ui/primitives/button'
14
+ import {
15
+ Dialog,
16
+ DialogContent,
17
+ DialogFooter,
18
+ DialogHeader,
19
+ DialogTitle,
20
+ } from '@open-mercato/ui/primitives/dialog'
21
+ import { Input } from '@open-mercato/ui/primitives/input'
22
+ import { Label } from '@open-mercato/ui/primitives/label'
23
+ import { PasswordInput } from '@open-mercato/ui/primitives/password-input'
24
+
25
+ type WidgetContext = Record<string, unknown> & {
26
+ reload?: () => void
27
+ }
28
+
29
+ type ConnectResponse = {
30
+ channelId?: string
31
+ error?: string
32
+ code?: string
33
+ fieldErrors?: Record<string, string>
34
+ fieldErrorCodes?: Record<string, string>
35
+ }
36
+
37
+ type FormState = {
38
+ displayName: string
39
+ accessToken: string
40
+ }
41
+
42
+ const INITIAL_FORM: FormState = {
43
+ displayName: 'Expo Push',
44
+ accessToken: '',
45
+ }
46
+
47
+ export default function ConnectExpoWidget({
48
+ context,
49
+ }: InjectionWidgetComponentProps<Record<string, unknown>, Record<string, unknown>>) {
50
+ const t = useT()
51
+ const widgetContext = context as WidgetContext | undefined
52
+ const [open, setOpen] = React.useState(false)
53
+ const [pending, setPending] = React.useState(false)
54
+ const [form, setForm] = React.useState<FormState>(INITIAL_FORM)
55
+ const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})
56
+ const { runMutation, retryLastMutation } = useGuardedMutation({
57
+ contextId: 'channel-expo-connect',
58
+ blockedMessage: t('communication_channels.push.connect.blocked', 'Connection blocked by validation'),
59
+ })
60
+ const mutationContext = React.useMemo(
61
+ () => ({ providerKey: 'expo', retryLastMutation }),
62
+ [retryLastMutation],
63
+ )
64
+
65
+ const update = React.useCallback(
66
+ <K extends keyof FormState>(key: K, value: FormState[K]) => {
67
+ setForm((current) => ({ ...current, [key]: value }))
68
+ setFieldErrors((current) => {
69
+ if (!current[key]) return current
70
+ const next = { ...current }
71
+ delete next[key]
72
+ return next
73
+ })
74
+ },
75
+ [],
76
+ )
77
+
78
+ const submit = React.useCallback(async () => {
79
+ if (pending) return
80
+ setPending(true)
81
+ setFieldErrors({})
82
+ const displayName = form.displayName.trim() || 'Expo Push'
83
+ try {
84
+ const response = await runMutation({
85
+ context: mutationContext,
86
+ mutationPayload: { providerKey: 'expo', displayName },
87
+ operation: () =>
88
+ apiCall<ConnectResponse>('/api/communication_channels/channels/connect/tenant-credentials', {
89
+ method: 'POST',
90
+ headers: { 'content-type': 'application/json' },
91
+ body: JSON.stringify({
92
+ providerKey: 'expo',
93
+ displayName,
94
+ credentials: {
95
+ ...(form.accessToken.trim() ? { accessToken: form.accessToken.trim() } : {}),
96
+ },
97
+ }),
98
+ }),
99
+ })
100
+ const body = response.result as ConnectResponse | undefined
101
+ if (!response.ok) {
102
+ setFieldErrors(resolvePushConnectFieldErrors(t, body))
103
+ flash(resolvePushConnectErrorMessage(t, body), 'error')
104
+ return
105
+ }
106
+ flash(t('communication_channels.push.connect.connected', 'Push provider connected.'), 'success')
107
+ setOpen(false)
108
+ setForm(INITIAL_FORM)
109
+ widgetContext?.reload?.()
110
+ } finally {
111
+ setPending(false)
112
+ }
113
+ }, [form, mutationContext, pending, runMutation, t, widgetContext])
114
+
115
+ const onDialogKeyDown = React.useCallback(
116
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
117
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
118
+ event.preventDefault()
119
+ void submit()
120
+ }
121
+ },
122
+ [submit],
123
+ )
124
+
125
+ return (
126
+ <>
127
+ <Button type="button" variant="outline" onClick={() => setOpen(true)}>
128
+ {t('communication_channels.push.connect.button.expo', 'Connect Expo')}
129
+ </Button>
130
+ <Dialog open={open} onOpenChange={setOpen}>
131
+ <DialogContent onKeyDown={onDialogKeyDown}>
132
+ <DialogHeader>
133
+ <DialogTitle>
134
+ {t('communication_channels.push.connect.title.expo', 'Connect Expo Push')}
135
+ </DialogTitle>
136
+ </DialogHeader>
137
+
138
+ <div className="grid gap-4 py-2">
139
+ <p className="text-sm text-muted-foreground">
140
+ {t(
141
+ 'communication_channels.push.connect.description',
142
+ "This is a shared, tenant-wide channel — every user's devices in this workspace are served by it.",
143
+ )}
144
+ </p>
145
+ <Field
146
+ label={t('communication_channels.push.connect.displayName', 'Display name')}
147
+ error={fieldErrors.displayName}
148
+ >
149
+ <Input
150
+ value={form.displayName}
151
+ onChange={(event) => update('displayName', event.target.value)}
152
+ aria-invalid={Boolean(fieldErrors.displayName)}
153
+ />
154
+ </Field>
155
+ <Field
156
+ label={t('communication_channels.push.connect.fields.expo.accessToken', 'Access token (optional)')}
157
+ error={fieldErrors.accessToken}
158
+ >
159
+ <PasswordInput
160
+ value={form.accessToken}
161
+ onChange={(event) => update('accessToken', event.target.value)}
162
+ aria-invalid={Boolean(fieldErrors.accessToken)}
163
+ />
164
+ </Field>
165
+ </div>
166
+
167
+ <DialogFooter>
168
+ <Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
169
+ {t('communication_channels.push.connect.cancel', 'Cancel')}
170
+ </Button>
171
+ <Button type="button" onClick={() => void submit()} disabled={pending}>
172
+ {pending
173
+ ? t('communication_channels.push.connect.connecting', 'Connecting…')
174
+ : t('communication_channels.push.connect.save', 'Connect')}
175
+ </Button>
176
+ </DialogFooter>
177
+ </DialogContent>
178
+ </Dialog>
179
+ </>
180
+ )
181
+ }
182
+
183
+ function Field(props: { label: string; error?: string; children: React.ReactNode }) {
184
+ return (
185
+ <label className="grid gap-1.5">
186
+ <Label asChild>
187
+ <span>{props.label}</span>
188
+ </Label>
189
+ {props.children}
190
+ {props.error ? <span className="text-xs text-destructive">{props.error}</span> : null}
191
+ </label>
192
+ )
193
+ }
@@ -0,0 +1,16 @@
1
+ import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
2
+ import ConnectExpoWidget from './widget.client'
3
+
4
+ const widget: InjectionWidgetModule<Record<string, unknown>, Record<string, unknown>> = {
5
+ metadata: {
6
+ id: 'channel_expo.injection.connect',
7
+ title: 'Connect Expo',
8
+ description: 'Connects a tenant-wide Expo push channel.',
9
+ features: ['communication_channels.connect_tenant_channel'],
10
+ priority: 80,
11
+ enabled: true,
12
+ },
13
+ Widget: ConnectExpoWidget,
14
+ }
15
+
16
+ export default widget
@@ -0,0 +1,13 @@
1
+ import type { ModuleInjectionTable } from '@open-mercato/shared/modules/widgets/injection'
2
+
3
+ export const injectionTable: ModuleInjectionTable = {
4
+ // Tenant-wide connect entry on the shared channels admin DataTable toolbar.
5
+ 'data-table:communication_channels.channels:toolbar': [
6
+ {
7
+ widgetId: 'channel_expo.injection.connect',
8
+ priority: 80,
9
+ },
10
+ ],
11
+ }
12
+
13
+ export default injectionTable
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "noEmit": true
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
9
+ }
package/watch.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import { watch } from '../../scripts/watch.mjs'
2
+ import { dirname } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+
7
+ watch(__dirname)