@open-mercato/channel-apns 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 +32 -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_apns/__integration__/TC-CHANNEL-PUSH-003.spec.js +47 -0
  7. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-003.spec.js.map +7 -0
  8. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.js +7 -0
  9. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.js.map +7 -0
  10. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.js +104 -0
  11. package/dist/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.js.map +7 -0
  12. package/dist/modules/channel_apns/acl.js +10 -0
  13. package/dist/modules/channel_apns/acl.js.map +7 -0
  14. package/dist/modules/channel_apns/di.js +24 -0
  15. package/dist/modules/channel_apns/di.js.map +7 -0
  16. package/dist/modules/channel_apns/index.js +9 -0
  17. package/dist/modules/channel_apns/index.js.map +7 -0
  18. package/dist/modules/channel_apns/integration.js +76 -0
  19. package/dist/modules/channel_apns/integration.js.map +7 -0
  20. package/dist/modules/channel_apns/lib/adapter.js +117 -0
  21. package/dist/modules/channel_apns/lib/adapter.js.map +7 -0
  22. package/dist/modules/channel_apns/lib/credentials.js +42 -0
  23. package/dist/modules/channel_apns/lib/credentials.js.map +7 -0
  24. package/dist/modules/channel_apns/lib/fake-provider.js +30 -0
  25. package/dist/modules/channel_apns/lib/fake-provider.js.map +7 -0
  26. package/dist/modules/channel_apns/lib/health.js +10 -0
  27. package/dist/modules/channel_apns/lib/health.js.map +7 -0
  28. package/dist/modules/channel_apns/setup.js +25 -0
  29. package/dist/modules/channel_apns/setup.js.map +7 -0
  30. package/dist/modules/channel_apns/widgets/injection/connect/widget.client.js +225 -0
  31. package/dist/modules/channel_apns/widgets/injection/connect/widget.client.js.map +7 -0
  32. package/dist/modules/channel_apns/widgets/injection/connect/widget.js +17 -0
  33. package/dist/modules/channel_apns/widgets/injection/connect/widget.js.map +7 -0
  34. package/dist/modules/channel_apns/widgets/injection-table.js +15 -0
  35. package/dist/modules/channel_apns/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_apns/__integration__/TC-CHANNEL-PUSH-003.spec.ts +67 -0
  40. package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.meta.ts +3 -0
  41. package/src/modules/channel_apns/__integration__/TC-CHANNEL-PUSH-006.spec.ts +145 -0
  42. package/src/modules/channel_apns/acl.ts +6 -0
  43. package/src/modules/channel_apns/di.ts +26 -0
  44. package/src/modules/channel_apns/index.ts +6 -0
  45. package/src/modules/channel_apns/integration.ts +77 -0
  46. package/src/modules/channel_apns/lib/__tests__/adapter.test.ts +158 -0
  47. package/src/modules/channel_apns/lib/__tests__/apnsTestKey.ts +21 -0
  48. package/src/modules/channel_apns/lib/__tests__/credentials.test.ts +98 -0
  49. package/src/modules/channel_apns/lib/__tests__/fake-provider.test.ts +93 -0
  50. package/src/modules/channel_apns/lib/__tests__/message-golden.test.ts +101 -0
  51. package/src/modules/channel_apns/lib/adapter.ts +199 -0
  52. package/src/modules/channel_apns/lib/credentials.ts +88 -0
  53. package/src/modules/channel_apns/lib/fake-provider.ts +54 -0
  54. package/src/modules/channel_apns/lib/health.ts +13 -0
  55. package/src/modules/channel_apns/setup.ts +31 -0
  56. package/src/modules/channel_apns/widgets/injection/connect/widget.client.tsx +251 -0
  57. package/src/modules/channel_apns/widgets/injection/connect/widget.ts +16 -0
  58. package/src/modules/channel_apns/widgets/injection-table.ts +13 -0
  59. package/tsconfig.json +9 -0
  60. package/watch.mjs +7 -0
@@ -0,0 +1,251 @@
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 { Checkbox } from '@open-mercato/ui/primitives/checkbox'
15
+ import {
16
+ Dialog,
17
+ DialogContent,
18
+ DialogFooter,
19
+ DialogHeader,
20
+ DialogTitle,
21
+ } from '@open-mercato/ui/primitives/dialog'
22
+ import { Input } from '@open-mercato/ui/primitives/input'
23
+ import { Label } from '@open-mercato/ui/primitives/label'
24
+ import { Textarea } from '@open-mercato/ui/primitives/textarea'
25
+
26
+ type WidgetContext = Record<string, unknown> & {
27
+ reload?: () => void
28
+ }
29
+
30
+ type ConnectResponse = {
31
+ channelId?: string
32
+ error?: string
33
+ code?: string
34
+ fieldErrors?: Record<string, string>
35
+ fieldErrorCodes?: Record<string, string>
36
+ }
37
+
38
+ type FormState = {
39
+ displayName: string
40
+ p8Key: string
41
+ keyId: string
42
+ teamId: string
43
+ bundleId: string
44
+ production: boolean
45
+ }
46
+
47
+ const INITIAL_FORM: FormState = {
48
+ displayName: 'Apple Push Notification service',
49
+ p8Key: '',
50
+ keyId: '',
51
+ teamId: '',
52
+ bundleId: '',
53
+ production: false,
54
+ }
55
+
56
+ export default function ConnectApnsWidget({
57
+ context,
58
+ }: InjectionWidgetComponentProps<Record<string, unknown>, Record<string, unknown>>) {
59
+ const t = useT()
60
+ const widgetContext = context as WidgetContext | undefined
61
+ const [open, setOpen] = React.useState(false)
62
+ const [pending, setPending] = React.useState(false)
63
+ const [form, setForm] = React.useState<FormState>(INITIAL_FORM)
64
+ const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})
65
+ const { runMutation, retryLastMutation } = useGuardedMutation({
66
+ contextId: 'channel-apns-connect',
67
+ blockedMessage: t('communication_channels.push.connect.blocked', 'Connection blocked by validation'),
68
+ })
69
+ const mutationContext = React.useMemo(
70
+ () => ({ providerKey: 'apns', retryLastMutation }),
71
+ [retryLastMutation],
72
+ )
73
+
74
+ const update = React.useCallback(
75
+ <K extends keyof FormState>(key: K, value: FormState[K]) => {
76
+ setForm((current) => ({ ...current, [key]: value }))
77
+ setFieldErrors((current) => {
78
+ if (!current[key]) return current
79
+ const next = { ...current }
80
+ delete next[key]
81
+ return next
82
+ })
83
+ },
84
+ [],
85
+ )
86
+
87
+ const submit = React.useCallback(async () => {
88
+ if (pending) return
89
+ setPending(true)
90
+ setFieldErrors({})
91
+ const displayName = form.displayName.trim() || 'Apple Push Notification service'
92
+ try {
93
+ const response = await runMutation({
94
+ context: mutationContext,
95
+ mutationPayload: { providerKey: 'apns', displayName },
96
+ operation: () =>
97
+ apiCall<ConnectResponse>('/api/communication_channels/channels/connect/tenant-credentials', {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify({
101
+ providerKey: 'apns',
102
+ displayName,
103
+ credentials: {
104
+ p8Key: form.p8Key.trim(),
105
+ keyId: form.keyId.trim(),
106
+ teamId: form.teamId.trim(),
107
+ bundleId: form.bundleId.trim(),
108
+ production: form.production,
109
+ },
110
+ }),
111
+ }),
112
+ })
113
+ const body = response.result as ConnectResponse | undefined
114
+ if (!response.ok) {
115
+ setFieldErrors(resolvePushConnectFieldErrors(t, body))
116
+ flash(resolvePushConnectErrorMessage(t, body), 'error')
117
+ return
118
+ }
119
+ flash(t('communication_channels.push.connect.connected', 'Push provider connected.'), 'success')
120
+ setOpen(false)
121
+ setForm(INITIAL_FORM)
122
+ widgetContext?.reload?.()
123
+ } finally {
124
+ setPending(false)
125
+ }
126
+ }, [form, mutationContext, pending, runMutation, t, widgetContext])
127
+
128
+ const onDialogKeyDown = React.useCallback(
129
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
130
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
131
+ event.preventDefault()
132
+ void submit()
133
+ }
134
+ },
135
+ [submit],
136
+ )
137
+
138
+ return (
139
+ <>
140
+ <Button type="button" variant="outline" onClick={() => setOpen(true)}>
141
+ {t('communication_channels.push.connect.button.apns', 'Connect APNs')}
142
+ </Button>
143
+ <Dialog open={open} onOpenChange={setOpen}>
144
+ <DialogContent onKeyDown={onDialogKeyDown}>
145
+ <DialogHeader>
146
+ <DialogTitle>
147
+ {t('communication_channels.push.connect.title.apns', 'Connect Apple Push Notification service')}
148
+ </DialogTitle>
149
+ </DialogHeader>
150
+
151
+ <div className="grid gap-4 py-2">
152
+ <p className="text-sm text-muted-foreground">
153
+ {t(
154
+ 'communication_channels.push.connect.description',
155
+ "This is a shared, tenant-wide channel — every user's devices in this workspace are served by it.",
156
+ )}
157
+ </p>
158
+ <Field
159
+ label={t('communication_channels.push.connect.displayName', 'Display name')}
160
+ error={fieldErrors.displayName}
161
+ >
162
+ <Input
163
+ value={form.displayName}
164
+ onChange={(event) => update('displayName', event.target.value)}
165
+ aria-invalid={Boolean(fieldErrors.displayName)}
166
+ />
167
+ </Field>
168
+ <Field
169
+ label={t('communication_channels.push.connect.fields.apns.p8Key', 'APNs .p8 key')}
170
+ error={fieldErrors.p8Key}
171
+ >
172
+ <Textarea
173
+ rows={6}
174
+ value={form.p8Key}
175
+ onChange={(event) => update('p8Key', event.target.value)}
176
+ aria-invalid={Boolean(fieldErrors.p8Key)}
177
+ className="font-mono text-xs"
178
+ />
179
+ </Field>
180
+ <div className="grid gap-3 md:grid-cols-2">
181
+ <Field
182
+ label={t('communication_channels.push.connect.fields.apns.keyId', 'Key ID')}
183
+ error={fieldErrors.keyId}
184
+ >
185
+ <Input
186
+ value={form.keyId}
187
+ onChange={(event) => update('keyId', event.target.value)}
188
+ aria-invalid={Boolean(fieldErrors.keyId)}
189
+ />
190
+ </Field>
191
+ <Field
192
+ label={t('communication_channels.push.connect.fields.apns.teamId', 'Team ID')}
193
+ error={fieldErrors.teamId}
194
+ >
195
+ <Input
196
+ value={form.teamId}
197
+ onChange={(event) => update('teamId', event.target.value)}
198
+ aria-invalid={Boolean(fieldErrors.teamId)}
199
+ />
200
+ </Field>
201
+ </div>
202
+ <Field
203
+ label={t('communication_channels.push.connect.fields.apns.bundleId', 'Bundle ID')}
204
+ error={fieldErrors.bundleId}
205
+ >
206
+ <Input
207
+ value={form.bundleId}
208
+ onChange={(event) => update('bundleId', event.target.value)}
209
+ aria-invalid={Boolean(fieldErrors.bundleId)}
210
+ />
211
+ </Field>
212
+ <label className="flex items-center gap-2">
213
+ <Checkbox
214
+ checked={form.production}
215
+ onCheckedChange={(value) => update('production', value === true)}
216
+ />
217
+ <Label asChild>
218
+ <span>
219
+ {t('communication_channels.push.connect.fields.apns.production', 'Production environment')}
220
+ </span>
221
+ </Label>
222
+ </label>
223
+ </div>
224
+
225
+ <DialogFooter>
226
+ <Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={pending}>
227
+ {t('communication_channels.push.connect.cancel', 'Cancel')}
228
+ </Button>
229
+ <Button type="button" onClick={() => void submit()} disabled={pending}>
230
+ {pending
231
+ ? t('communication_channels.push.connect.connecting', 'Connecting…')
232
+ : t('communication_channels.push.connect.save', 'Connect')}
233
+ </Button>
234
+ </DialogFooter>
235
+ </DialogContent>
236
+ </Dialog>
237
+ </>
238
+ )
239
+ }
240
+
241
+ function Field(props: { label: string; error?: string; children: React.ReactNode }) {
242
+ return (
243
+ <label className="grid gap-1.5">
244
+ <Label asChild>
245
+ <span>{props.label}</span>
246
+ </Label>
247
+ {props.children}
248
+ {props.error ? <span className="text-xs text-destructive">{props.error}</span> : null}
249
+ </label>
250
+ )
251
+ }
@@ -0,0 +1,16 @@
1
+ import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
2
+ import ConnectApnsWidget from './widget.client'
3
+
4
+ const widget: InjectionWidgetModule<Record<string, unknown>, Record<string, unknown>> = {
5
+ metadata: {
6
+ id: 'channel_apns.injection.connect',
7
+ title: 'Connect APNs',
8
+ description: 'Connects a tenant-wide Apple Push Notification service push channel.',
9
+ features: ['communication_channels.connect_tenant_channel'],
10
+ priority: 90,
11
+ enabled: true,
12
+ },
13
+ Widget: ConnectApnsWidget,
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_apns.injection.connect',
8
+ priority: 90,
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)