@isi-ui7/bos7-shared 0.2.0

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 (108) hide show
  1. package/dist/api.d.ts +4 -0
  2. package/dist/auth7/client.d.ts +26 -0
  3. package/dist/auth7/delegated-proxy.d.ts +33 -0
  4. package/dist/auth7/hooks/useAuth.d.ts +10 -0
  5. package/dist/auth7/hooks/useSession.d.ts +12 -0
  6. package/dist/auth7/index.d.ts +12 -0
  7. package/dist/auth7/provider.d.ts +13 -0
  8. package/dist/auth7/scope-guard.d.ts +5 -0
  9. package/dist/auth7/service-account.d.ts +2 -0
  10. package/dist/auth7/token-exchange.d.ts +11 -0
  11. package/dist/auth7/types.d.ts +63 -0
  12. package/dist/backend.d.ts +20 -0
  13. package/dist/crud-components.d.ts +47 -0
  14. package/dist/crud-hooks.d.ts +15 -0
  15. package/dist/crud-types.d.ts +86 -0
  16. package/dist/data-table/cursor-adapter.d.ts +74 -0
  17. package/dist/event-bus/index.d.ts +20 -0
  18. package/dist/event-bus/useEventBus.d.ts +21 -0
  19. package/dist/event-bus.d.ts +1 -0
  20. package/dist/export.d.ts +7 -0
  21. package/dist/form-layout.d.ts +39 -0
  22. package/dist/form-numeric.d.ts +35 -0
  23. package/dist/form-renderer-utils.d.ts +10 -0
  24. package/dist/form-renderer.d.ts +18 -0
  25. package/dist/form-rules.d.ts +12 -0
  26. package/dist/form-types.d.ts +130 -0
  27. package/dist/html2canvas.esm-d2sM-0Wm.js +4870 -0
  28. package/dist/i18n.d.ts +35 -0
  29. package/dist/index.d.ts +18 -0
  30. package/dist/index.es-Bylk5fh-.js +5649 -0
  31. package/dist/index.js +20779 -0
  32. package/dist/jspdf.es.min-Dzm5j8wC.js +9977 -0
  33. package/dist/jspdf.plugin.autotable-DcntYaes.js +1078 -0
  34. package/dist/modal-layout.d.ts +45 -0
  35. package/dist/nats/client.d.ts +54 -0
  36. package/dist/notif7.d.ts +21 -0
  37. package/dist/notification/routing.d.ts +4 -0
  38. package/dist/notifications-bff.d.ts +22 -0
  39. package/dist/observability/otel-init.d.ts +1 -0
  40. package/dist/page-layout.d.ts +24 -0
  41. package/dist/purify.es-CiEWEeUM.js +605 -0
  42. package/dist/shell/app-shell-layout.d.ts +70 -0
  43. package/dist/shell/branch.d.ts +6 -0
  44. package/dist/shell/interaction.d.ts +2 -0
  45. package/dist/shell/provider.d.ts +15 -0
  46. package/dist/style-contract.d.ts +72 -0
  47. package/dist/types/core.d.ts +39 -0
  48. package/dist/types/notification.d.ts +30 -0
  49. package/dist/workflow/api-client.d.ts +2 -0
  50. package/dist/workflow/backend.d.ts +9 -0
  51. package/dist/workflow/crud-client.d.ts +3 -0
  52. package/dist/workflow/notif7.d.ts +6 -0
  53. package/dist/workflow/starter.d.ts +44 -0
  54. package/dist/workflow/use-crud-form.d.ts +27 -0
  55. package/dist/workflow/use-workflow-tracker.d.ts +40 -0
  56. package/package.json +92 -0
  57. package/src/api.ts +40 -0
  58. package/src/auth7/client.ts +248 -0
  59. package/src/auth7/delegated-proxy.ts +80 -0
  60. package/src/auth7/hooks/useAuth.ts +21 -0
  61. package/src/auth7/hooks/useSession.ts +75 -0
  62. package/src/auth7/index.ts +14 -0
  63. package/src/auth7/provider.tsx +256 -0
  64. package/src/auth7/scope-guard.ts +54 -0
  65. package/src/auth7/service-account.ts +71 -0
  66. package/src/auth7/token-exchange.ts +125 -0
  67. package/src/auth7/types.ts +72 -0
  68. package/src/backend.ts +96 -0
  69. package/src/crud-components.tsx +580 -0
  70. package/src/crud-hooks.test.ts +32 -0
  71. package/src/crud-hooks.ts +31 -0
  72. package/src/crud-types.ts +64 -0
  73. package/src/data-table/cursor-adapter.ts +239 -0
  74. package/src/event-bus/index.ts +90 -0
  75. package/src/event-bus/useEventBus.ts +93 -0
  76. package/src/event-bus.ts +1 -0
  77. package/src/export.ts +62 -0
  78. package/src/form-contract.css +52 -0
  79. package/src/form-layout.tsx +90 -0
  80. package/src/form-numeric.ts +173 -0
  81. package/src/form-renderer-utils.ts +139 -0
  82. package/src/form-renderer.tsx +624 -0
  83. package/src/form-rules.ts +54 -0
  84. package/src/form-types.ts +126 -0
  85. package/src/i18n.tsx +92 -0
  86. package/src/index.ts +20 -0
  87. package/src/modal-layout.tsx +60 -0
  88. package/src/nats/client.ts +130 -0
  89. package/src/notif7.ts +76 -0
  90. package/src/notification/routing.ts +64 -0
  91. package/src/notifications-bff.ts +273 -0
  92. package/src/observability/otel-init.ts +41 -0
  93. package/src/page-layout.tsx +69 -0
  94. package/src/shell/app-shell-layout.tsx +494 -0
  95. package/src/shell/branch.ts +48 -0
  96. package/src/shell/interaction.ts +17 -0
  97. package/src/shell/provider.tsx +34 -0
  98. package/src/style-contract.test.ts +59 -0
  99. package/src/style-contract.ts +132 -0
  100. package/src/types/core.ts +46 -0
  101. package/src/types/notification.ts +35 -0
  102. package/src/workflow/api-client.ts +7 -0
  103. package/src/workflow/backend.ts +53 -0
  104. package/src/workflow/crud-client.ts +43 -0
  105. package/src/workflow/notif7.ts +28 -0
  106. package/src/workflow/starter.ts +153 -0
  107. package/src/workflow/use-crud-form.ts +176 -0
  108. package/src/workflow/use-workflow-tracker.ts +109 -0
@@ -0,0 +1,273 @@
1
+ import { getUserIDFromRequest, NOTIF7_URL, notif7Fetch } from './notif7';
2
+
3
+ type Notif7Raw = {
4
+ ID: string;
5
+ UserID?: string;
6
+ EventType?: string;
7
+ Title?: string;
8
+ Body?: string;
9
+ IsRead: boolean;
10
+ RefURL?: string;
11
+ CreatedAt: string;
12
+ Payload?: Record<string, unknown>;
13
+ };
14
+
15
+ type ReqLike = Request & {
16
+ cookies?: { get?: (name: string) => { value?: string } | undefined };
17
+ };
18
+
19
+ const DEFAULT_TASK_EVENT_TYPES = new Set([
20
+ 'task.assign',
21
+ 'task.assigned',
22
+ 'task.delegated_to_you',
23
+ 'task.pool_completed',
24
+ ]);
25
+
26
+ function getAccessToken(req: ReqLike | Request): string | undefined {
27
+ return (req as ReqLike).cookies?.get?.('access_token')?.value
28
+ ?? (req.headers.get('cookie') ?? '')
29
+ .split(';')
30
+ .find((c) => c.trim().startsWith('access_token='))
31
+ ?.split('=').slice(1).join('=')
32
+ .trim();
33
+ }
34
+
35
+ function extractTaskId(n: Notif7Raw): string | undefined {
36
+ const payload = n.Payload;
37
+ if (!payload) return undefined;
38
+ const candidates = [payload.task_id, payload.taskId, payload.taskID, payload.id, payload.ref_id];
39
+ for (const value of candidates) {
40
+ if (typeof value === 'string' && value.trim() !== '') return value;
41
+ }
42
+ return undefined;
43
+ }
44
+
45
+ // Informational event types that should render in the bell panel without a
46
+ // link target. `instance.completed`/`instance.cancelled` for auto-approval
47
+ // flows are status updates the user just acknowledges — there's no actionable
48
+ // follow-up screen, so a clickable link only adds noise (and dead-ends at
49
+ // /inbox/history when the user isn't a workflow operator).
50
+ const INFO_ONLY_EVENT_TYPES = new Set([
51
+ 'instance.completed',
52
+ 'instance.cancelled',
53
+ ]);
54
+
55
+ function resolveActionHref(
56
+ n: Notif7Raw,
57
+ workflowBaseUrl: string,
58
+ taskEventTypes: Set<string>
59
+ ): string | undefined {
60
+ const eventType = (n.EventType || '').toLowerCase();
61
+ if (INFO_ONLY_EVENT_TYPES.has(eventType)) return undefined;
62
+ if (!taskEventTypes.has(eventType)) return n.RefURL || undefined;
63
+ const taskId = extractTaskId(n);
64
+ return taskId ? `${workflowBaseUrl}/inbox/${taskId}` : `${workflowBaseUrl}/inbox`;
65
+ }
66
+
67
+ export interface NotificationBffOptions {
68
+ workflowBaseUrl?: string;
69
+ taskEventTypes?: Set<string>;
70
+ includeUserID?: boolean;
71
+ graceful?: boolean;
72
+ tableActionField?: 'action_url' | 'ref_url';
73
+ bellTitleMap?: Record<string, string>;
74
+ bellTimestampLocale?: boolean;
75
+ }
76
+
77
+ export async function handleNotificationsGet(req: Request, options: NotificationBffOptions = {}): Promise<Response> {
78
+ const workflowBaseUrl = options.workflowBaseUrl || process.env.NEXT_PUBLIC_BOS7_WORKFLOW_URL || 'https://workflow.bos7.local';
79
+ const taskEventTypes = options.taskEventTypes || DEFAULT_TASK_EVENT_TYPES;
80
+ const includeUserID = options.includeUserID ?? true;
81
+ const graceful = options.graceful ?? true;
82
+ const tableActionField = options.tableActionField || 'action_url';
83
+ const bellTitleMap = options.bellTitleMap || {};
84
+ const bellTimestampLocale = options.bellTimestampLocale ?? false;
85
+
86
+ const url = new URL(req.url);
87
+ const searchParams = url.searchParams;
88
+ const isOffsetMode = searchParams.has('page') || searchParams.has('per_page');
89
+ const accessToken = getAccessToken(req);
90
+ const userID = includeUserID ? getUserIDFromRequest(req as ReqLike) : undefined;
91
+
92
+ try {
93
+ if (isOffsetMode) {
94
+ const page = Math.max(1, Number(searchParams.get('page') ?? '1'));
95
+ const perPage = Math.max(1, Number(searchParams.get('per_page') ?? '20'));
96
+ const isReadFilter = searchParams.get('is_read');
97
+
98
+ const query = new URLSearchParams({
99
+ limit: String(perPage),
100
+ offset: String((page - 1) * perPage),
101
+ });
102
+ if (isReadFilter) query.set('is_read', isReadFilter);
103
+
104
+ const listRes = await notif7Fetch('GET', '/api/v1/notifications', { userID, accessToken, query });
105
+ if (!listRes.ok) {
106
+ return Response.json({ data: [], total: 0, page, per_page: perPage, total_pages: 0 });
107
+ }
108
+
109
+ const { data: raw, total = 0 } = await listRes.json() as { data: Notif7Raw[]; total?: number };
110
+ const data = (raw ?? []).map((n) => {
111
+ const actionHref = resolveActionHref(n, workflowBaseUrl, taskEventTypes);
112
+ if (tableActionField === 'ref_url') {
113
+ return {
114
+ id: n.ID,
115
+ title: n.Title,
116
+ message: n.Body || '',
117
+ event_type: n.EventType || '',
118
+ is_read: n.IsRead,
119
+ created_at: n.CreatedAt,
120
+ ref_url: actionHref,
121
+ };
122
+ }
123
+ return {
124
+ id: n.ID,
125
+ user_id: n.UserID,
126
+ event_type: n.EventType,
127
+ title: n.Title,
128
+ message: n.Body,
129
+ is_read: n.IsRead,
130
+ action_url: actionHref,
131
+ created_at: n.CreatedAt,
132
+ };
133
+ });
134
+ return Response.json({ data, total, page, per_page: perPage, total_pages: Math.ceil(total / perPage) || 1 });
135
+ }
136
+
137
+ const [listRes, countRes] = await Promise.all([
138
+ notif7Fetch('GET', '/api/v1/notifications', {
139
+ userID,
140
+ accessToken,
141
+ query: new URLSearchParams({ limit: '20' }),
142
+ }),
143
+ notif7Fetch('GET', '/api/v1/notifications/unread-count', { userID, accessToken }),
144
+ ]);
145
+
146
+ if (!listRes.ok || !countRes.ok) {
147
+ return Response.json({ notifications: [], badgeCount: 0 });
148
+ }
149
+
150
+ const { data: rawNotifs } = await listRes.json() as { data: Notif7Raw[] };
151
+ const { unread_count } = await countRes.json() as { unread_count?: number };
152
+
153
+ const notifications = (rawNotifs ?? []).map((n) => {
154
+ const eventType = (n.EventType ?? '').toLowerCase();
155
+ const ts = bellTimestampLocale
156
+ ? new Date(n.CreatedAt).toLocaleString('id-ID', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
157
+ : n.CreatedAt;
158
+ return {
159
+ id: n.ID,
160
+ title: n.Title ?? bellTitleMap[eventType] ?? n.EventType,
161
+ description: n.Body,
162
+ timestamp: ts,
163
+ read: n.IsRead,
164
+ actionHref: resolveActionHref(n, workflowBaseUrl, taskEventTypes),
165
+ };
166
+ });
167
+
168
+ return Response.json({ notifications, badgeCount: unread_count ?? 0 });
169
+ } catch {
170
+ if (!graceful) return Response.json({ error: 'notification backend error' }, { status: 502 });
171
+ if (isOffsetMode) {
172
+ const page = Math.max(1, Number(searchParams.get('page') ?? '1'));
173
+ const perPage = Math.max(1, Number(searchParams.get('per_page') ?? '20'));
174
+ return Response.json({ data: [], total: 0, page, per_page: perPage, total_pages: 0 });
175
+ }
176
+ return Response.json({ notifications: [], badgeCount: 0 });
177
+ }
178
+ }
179
+
180
+ export async function handleNotificationsMarkAllRead(req: Request, options: { includeUserID?: boolean; path?: string; graceful?: boolean } = {}): Promise<Response> {
181
+ const includeUserID = options.includeUserID ?? true;
182
+ const path = options.path || '/api/v1/notifications/read-all';
183
+ const graceful = options.graceful ?? false;
184
+ const accessToken = getAccessToken(req);
185
+ const userID = includeUserID ? getUserIDFromRequest(req as ReqLike) : undefined;
186
+ try {
187
+ const res = await notif7Fetch('POST', path, { userID, accessToken, body: '{}' });
188
+ const json = await res.json().catch(() => ({}));
189
+ return Response.json(json, { status: res.status });
190
+ } catch {
191
+ if (graceful) return Response.json({ ok: true });
192
+ return Response.json({ ok: false }, { status: 502 });
193
+ }
194
+ }
195
+
196
+ export async function handleNotificationsReadOne(req: Request, id: string, options: { includeUserID?: boolean; graceful?: boolean } = {}): Promise<Response> {
197
+ const includeUserID = options.includeUserID ?? true;
198
+ const graceful = options.graceful ?? false;
199
+ const accessToken = getAccessToken(req);
200
+ const userID = includeUserID ? getUserIDFromRequest(req as ReqLike) : undefined;
201
+ try {
202
+ const res = await notif7Fetch('PATCH', `/api/v1/notifications/${id}/read`, { userID, accessToken });
203
+ const json = await res.json().catch(() => ({}));
204
+ return Response.json(json, { status: res.ok ? 200 : res.status });
205
+ } catch {
206
+ if (graceful) return Response.json({ ok: true });
207
+ return Response.json({ ok: false }, { status: 502 });
208
+ }
209
+ }
210
+
211
+ export async function handleNotificationsStream(req: Request, options: { preferAccessToken?: boolean } = {}): Promise<Response> {
212
+ void options; // preferAccessToken retained for backward-compat; no longer used
213
+ const accessToken = getAccessToken(req);
214
+
215
+ if (!accessToken) {
216
+ return new Response(
217
+ JSON.stringify({ error: { code: 'MISSING_TOKEN', message: 'access_token cookie required for SSE stream' } }),
218
+ { status: 401, headers: { 'Content-Type': 'application/json' } },
219
+ );
220
+ }
221
+ const token = accessToken;
222
+
223
+ const upstreamRes = await fetch(`${NOTIF7_URL}/api/v1/stream`, {
224
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
225
+ });
226
+
227
+ if (!upstreamRes.ok || !upstreamRes.body) {
228
+ return new Response('data: {"type":"error"}\n\n', {
229
+ headers: { 'Content-Type': 'text/event-stream' },
230
+ });
231
+ }
232
+
233
+ // Pipe upstream notif7 SSE bytes to the client through an explicit
234
+ // TransformStream. Returning upstreamRes.body directly relies on Node's
235
+ // fetch ReadableStream propagating chunks immediately, which doesn't happen
236
+ // reliably under Next.js dev; the TransformStream forces per-chunk flushing.
237
+ //
238
+ // Also injects a synthetic `{type:"new_notification"}` envelope after each
239
+ // notif7 event chunk so `@isi-ui7/realtime` useNotificationBell — which
240
+ // expects {type:"badge_sync"|"new_notification"} — fires its onMessage and
241
+ // refreshes the badge. The original notif7 chunk is forwarded untouched so
242
+ // page-level listeners that read EventType/RefID still work.
243
+ const decoder = new TextDecoder();
244
+ const encoder = new TextEncoder();
245
+ const SYNTHETIC = encoder.encode('data: {"type":"new_notification"}\n\n');
246
+ const transform = new TransformStream<Uint8Array, Uint8Array>({
247
+ transform(chunk, controller) {
248
+ controller.enqueue(chunk);
249
+ // Heuristic: any chunk that begins with `data: {` and contains a notif7
250
+ // `EventType` field is a real notification. Heartbeats start with `:`
251
+ // and the initial `: connected\n\n` is also a comment — skip both.
252
+ const text = decoder.decode(chunk, { stream: true });
253
+ if (text.startsWith('data: {') && text.includes('"EventType"')) {
254
+ controller.enqueue(SYNTHETIC);
255
+ }
256
+ },
257
+ });
258
+ void upstreamRes.body.pipeTo(transform.writable).catch(() => {
259
+ /* upstream closed — TransformStream will close client side */
260
+ });
261
+
262
+ return new Response(transform.readable, {
263
+ headers: {
264
+ 'Content-Type': 'text/event-stream',
265
+ 'Cache-Control': 'no-cache, no-transform',
266
+ Connection: 'keep-alive',
267
+ // Without X-Accel-Buffering, some proxies/runtimes buffer SSE chunks until
268
+ // the connection closes — live events never reach the browser, only the
269
+ // catch-up replay on reconnect. Setting `no` forces flush-per-chunk.
270
+ 'X-Accel-Buffering': 'no',
271
+ },
272
+ });
273
+ }
@@ -0,0 +1,41 @@
1
+ // OTel init untuk Next.js BFF — dipanggil sekali dari instrumentation.ts setiap app.
2
+ // Tidak ada side-effect jika OTEL_EXPORTER_OTLP_ENDPOINT tidak di-set.
3
+ //
4
+ // Usage di instrumentation.ts (root dari Next.js project):
5
+ // export async function register() {
6
+ // if (process.env.NEXT_RUNTIME === 'nodejs') {
7
+ // const { initOtel } = await import('@isi-ui7/bos7-shared')
8
+ // await initOtel(process.env.OTEL_SERVICE_NAME)
9
+ // }
10
+ // }
11
+ let initialized = false
12
+
13
+ export async function initOtel(serviceName?: string): Promise<void> {
14
+ if (initialized) return
15
+ initialized = true
16
+
17
+ const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
18
+ if (!endpoint) return // no-op jika endpoint tidak dikonfigurasi
19
+
20
+ const [
21
+ { NodeSDK },
22
+ { OTLPTraceExporter },
23
+ { getNodeAutoInstrumentations },
24
+ ] = await Promise.all([
25
+ import('@opentelemetry/sdk-node'),
26
+ import('@opentelemetry/exporter-trace-otlp-http'),
27
+ import('@opentelemetry/auto-instrumentations-node'),
28
+ ])
29
+
30
+ const sdk = new NodeSDK({
31
+ serviceName: serviceName ?? process.env.OTEL_SERVICE_NAME ?? 'bos7-bff',
32
+ traceExporter: new OTLPTraceExporter({ url: `${endpoint}/v1/traces` }),
33
+ instrumentations: [
34
+ getNodeAutoInstrumentations({
35
+ '@opentelemetry/instrumentation-fs': { enabled: false }, // terlalu verbose
36
+ }),
37
+ ],
38
+ })
39
+
40
+ sdk.start()
41
+ }
@@ -0,0 +1,69 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ type BreadcrumbItem = {
4
+ label: string;
5
+ href?: string;
6
+ };
7
+
8
+ export function PageLayout({ children }: { children: ReactNode }) {
9
+ return <div className="page">{children}</div>;
10
+ }
11
+
12
+ export function PageHeader({
13
+ title,
14
+ subtitle,
15
+ titleExtra,
16
+ breadcrumbs,
17
+ children,
18
+ }: {
19
+ title: string;
20
+ subtitle?: string;
21
+ titleExtra?: ReactNode;
22
+ breadcrumbs?: BreadcrumbItem[];
23
+ children?: ReactNode;
24
+ }) {
25
+ return (
26
+ <div className="page__header">
27
+ <div>
28
+ {breadcrumbs && breadcrumbs.length > 0 ? (
29
+ <div className="page__breadcrumb" aria-label="Breadcrumb">
30
+ {breadcrumbs.map((item, index) => (
31
+ <span key={`${item.label}-${index}`} className="page__breadcrumb-segment">
32
+ {index > 0 ? <span>/</span> : null}
33
+ <span className="page__breadcrumb-current">{item.label}</span>
34
+ </span>
35
+ ))}
36
+ </div>
37
+ ) : null}
38
+ {titleExtra ? (
39
+ <div className="page__title-row">
40
+ <h2 className="page__title">{title}</h2>
41
+ {titleExtra}
42
+ </div>
43
+ ) : (
44
+ <h2 className="page__title">{title}</h2>
45
+ )}
46
+ {subtitle && <p className="page__subtitle">{subtitle}</p>}
47
+ </div>
48
+ {children && <div className="page__actions">{children}</div>}
49
+ </div>
50
+ );
51
+ }
52
+
53
+ export function PageBody({ children }: { children: ReactNode }) {
54
+ return <div className="page__body">{children}</div>;
55
+ }
56
+
57
+ export function PageFooter({
58
+ children,
59
+ spread = false,
60
+ }: {
61
+ children: ReactNode;
62
+ spread?: boolean;
63
+ }) {
64
+ return (
65
+ <div className={`page__footer${spread ? ' page__footer--spread' : ''}`}>
66
+ {children}
67
+ </div>
68
+ );
69
+ }