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