@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +26 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.js +56 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.js.map +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.js +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.js.map +7 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.js +65 -0
- package/dist/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.js.map +7 -0
- package/dist/modules/channel_expo/acl.js +10 -0
- package/dist/modules/channel_expo/acl.js.map +7 -0
- package/dist/modules/channel_expo/di.js +24 -0
- package/dist/modules/channel_expo/di.js.map +7 -0
- package/dist/modules/channel_expo/index.js +9 -0
- package/dist/modules/channel_expo/index.js.map +7 -0
- package/dist/modules/channel_expo/integration.js +47 -0
- package/dist/modules/channel_expo/integration.js.map +7 -0
- package/dist/modules/channel_expo/lib/adapter.js +154 -0
- package/dist/modules/channel_expo/lib/adapter.js.map +7 -0
- package/dist/modules/channel_expo/lib/credentials.js +8 -0
- package/dist/modules/channel_expo/lib/credentials.js.map +7 -0
- package/dist/modules/channel_expo/lib/fake-provider.js +40 -0
- package/dist/modules/channel_expo/lib/fake-provider.js.map +7 -0
- package/dist/modules/channel_expo/lib/health.js +10 -0
- package/dist/modules/channel_expo/lib/health.js.map +7 -0
- package/dist/modules/channel_expo/setup.js +25 -0
- package/dist/modules/channel_expo/setup.js.map +7 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.client.js +157 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.client.js.map +7 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.js +17 -0
- package/dist/modules/channel_expo/widgets/injection/connect/widget.js.map +7 -0
- package/dist/modules/channel_expo/widgets/injection-table.js +15 -0
- package/dist/modules/channel_expo/widgets/injection-table.js.map +7 -0
- package/jest.config.cjs +34 -0
- package/package.json +96 -0
- package/src/index.ts +1 -0
- package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-004.spec.ts +76 -0
- package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.meta.ts +3 -0
- package/src/modules/channel_expo/__integration__/TC-CHANNEL-PUSH-007.spec.ts +83 -0
- package/src/modules/channel_expo/acl.ts +6 -0
- package/src/modules/channel_expo/di.ts +26 -0
- package/src/modules/channel_expo/index.ts +6 -0
- package/src/modules/channel_expo/integration.ts +48 -0
- package/src/modules/channel_expo/lib/__tests__/adapter-instance-cache.test.ts +65 -0
- package/src/modules/channel_expo/lib/__tests__/adapter.test.ts +211 -0
- package/src/modules/channel_expo/lib/__tests__/credentials.test.ts +18 -0
- package/src/modules/channel_expo/lib/__tests__/fake-provider.test.ts +94 -0
- package/src/modules/channel_expo/lib/__tests__/message-golden.test.ts +77 -0
- package/src/modules/channel_expo/lib/adapter.ts +256 -0
- package/src/modules/channel_expo/lib/credentials.ts +14 -0
- package/src/modules/channel_expo/lib/fake-provider.ts +64 -0
- package/src/modules/channel_expo/lib/health.ts +13 -0
- package/src/modules/channel_expo/setup.ts +31 -0
- package/src/modules/channel_expo/widgets/injection/connect/widget.client.tsx +193 -0
- package/src/modules/channel_expo/widgets/injection/connect/widget.ts +16 -0
- package/src/modules/channel_expo/widgets/injection-table.ts +13 -0
- package/tsconfig.json +9 -0
- 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