@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.
- package/dist/api.d.ts +4 -0
- package/dist/auth7/client.d.ts +26 -0
- package/dist/auth7/delegated-proxy.d.ts +33 -0
- package/dist/auth7/hooks/useAuth.d.ts +10 -0
- package/dist/auth7/hooks/useSession.d.ts +12 -0
- package/dist/auth7/index.d.ts +12 -0
- package/dist/auth7/provider.d.ts +13 -0
- package/dist/auth7/scope-guard.d.ts +5 -0
- package/dist/auth7/service-account.d.ts +2 -0
- package/dist/auth7/token-exchange.d.ts +11 -0
- package/dist/auth7/types.d.ts +63 -0
- package/dist/backend.d.ts +20 -0
- package/dist/crud-components.d.ts +47 -0
- package/dist/crud-hooks.d.ts +15 -0
- package/dist/crud-types.d.ts +86 -0
- package/dist/data-table/cursor-adapter.d.ts +74 -0
- package/dist/event-bus/index.d.ts +20 -0
- package/dist/event-bus/useEventBus.d.ts +21 -0
- package/dist/event-bus.d.ts +1 -0
- package/dist/export.d.ts +7 -0
- package/dist/form-layout.d.ts +39 -0
- package/dist/form-numeric.d.ts +35 -0
- package/dist/form-renderer-utils.d.ts +10 -0
- package/dist/form-renderer.d.ts +18 -0
- package/dist/form-rules.d.ts +12 -0
- package/dist/form-types.d.ts +130 -0
- package/dist/html2canvas.esm-d2sM-0Wm.js +4870 -0
- package/dist/i18n.d.ts +35 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.es-Bylk5fh-.js +5649 -0
- package/dist/index.js +20779 -0
- package/dist/jspdf.es.min-Dzm5j8wC.js +9977 -0
- package/dist/jspdf.plugin.autotable-DcntYaes.js +1078 -0
- package/dist/modal-layout.d.ts +45 -0
- package/dist/nats/client.d.ts +54 -0
- package/dist/notif7.d.ts +21 -0
- package/dist/notification/routing.d.ts +4 -0
- package/dist/notifications-bff.d.ts +22 -0
- package/dist/observability/otel-init.d.ts +1 -0
- package/dist/page-layout.d.ts +24 -0
- package/dist/purify.es-CiEWEeUM.js +605 -0
- package/dist/shell/app-shell-layout.d.ts +70 -0
- package/dist/shell/branch.d.ts +6 -0
- package/dist/shell/interaction.d.ts +2 -0
- package/dist/shell/provider.d.ts +15 -0
- package/dist/style-contract.d.ts +72 -0
- package/dist/types/core.d.ts +39 -0
- package/dist/types/notification.d.ts +30 -0
- package/dist/workflow/api-client.d.ts +2 -0
- package/dist/workflow/backend.d.ts +9 -0
- package/dist/workflow/crud-client.d.ts +3 -0
- package/dist/workflow/notif7.d.ts +6 -0
- package/dist/workflow/starter.d.ts +44 -0
- package/dist/workflow/use-crud-form.d.ts +27 -0
- package/dist/workflow/use-workflow-tracker.d.ts +40 -0
- package/package.json +92 -0
- package/src/api.ts +40 -0
- package/src/auth7/client.ts +248 -0
- package/src/auth7/delegated-proxy.ts +80 -0
- package/src/auth7/hooks/useAuth.ts +21 -0
- package/src/auth7/hooks/useSession.ts +75 -0
- package/src/auth7/index.ts +14 -0
- package/src/auth7/provider.tsx +256 -0
- package/src/auth7/scope-guard.ts +54 -0
- package/src/auth7/service-account.ts +71 -0
- package/src/auth7/token-exchange.ts +125 -0
- package/src/auth7/types.ts +72 -0
- package/src/backend.ts +96 -0
- package/src/crud-components.tsx +580 -0
- package/src/crud-hooks.test.ts +32 -0
- package/src/crud-hooks.ts +31 -0
- package/src/crud-types.ts +64 -0
- package/src/data-table/cursor-adapter.ts +239 -0
- package/src/event-bus/index.ts +90 -0
- package/src/event-bus/useEventBus.ts +93 -0
- package/src/event-bus.ts +1 -0
- package/src/export.ts +62 -0
- package/src/form-contract.css +52 -0
- package/src/form-layout.tsx +90 -0
- package/src/form-numeric.ts +173 -0
- package/src/form-renderer-utils.ts +139 -0
- package/src/form-renderer.tsx +624 -0
- package/src/form-rules.ts +54 -0
- package/src/form-types.ts +126 -0
- package/src/i18n.tsx +92 -0
- package/src/index.ts +20 -0
- package/src/modal-layout.tsx +60 -0
- package/src/nats/client.ts +130 -0
- package/src/notif7.ts +76 -0
- package/src/notification/routing.ts +64 -0
- package/src/notifications-bff.ts +273 -0
- package/src/observability/otel-init.ts +41 -0
- package/src/page-layout.tsx +69 -0
- package/src/shell/app-shell-layout.tsx +494 -0
- package/src/shell/branch.ts +48 -0
- package/src/shell/interaction.ts +17 -0
- package/src/shell/provider.tsx +34 -0
- package/src/style-contract.test.ts +59 -0
- package/src/style-contract.ts +132 -0
- package/src/types/core.ts +46 -0
- package/src/types/notification.ts +35 -0
- package/src/workflow/api-client.ts +7 -0
- package/src/workflow/backend.ts +53 -0
- package/src/workflow/crud-client.ts +43 -0
- package/src/workflow/notif7.ts +28 -0
- package/src/workflow/starter.ts +153 -0
- package/src/workflow/use-crud-form.ts +176 -0
- package/src/workflow/use-workflow-tracker.ts +109 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { Ui7FormDensity } from "./style-contract";
|
|
3
|
+
import type { T_LookupTblStruct } from "@isi-ui7/lookup-input";
|
|
4
|
+
|
|
5
|
+
export type FormMode = "create" | "edit" | "view";
|
|
6
|
+
|
|
7
|
+
export type ApiPath = string | ((params: Record<string, string>) => string);
|
|
8
|
+
|
|
9
|
+
// ── Validation ────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/** Per-field validator function — same signature used by Rules.* and FieldValidation.validators[]. */
|
|
12
|
+
export type FieldValidatorFn<TData = Record<string, unknown>> = (
|
|
13
|
+
value: unknown,
|
|
14
|
+
data: TData,
|
|
15
|
+
) => string | undefined;
|
|
16
|
+
|
|
17
|
+
export type FieldValidation<TData extends Record<string, unknown>> = {
|
|
18
|
+
required?: true | string;
|
|
19
|
+
minLength?: number | [number, string];
|
|
20
|
+
maxLength?: number | [number, string];
|
|
21
|
+
min?: number | [number, string];
|
|
22
|
+
max?: number | [number, string];
|
|
23
|
+
pattern?: RegExp | [RegExp, string];
|
|
24
|
+
/** Single custom validator (kept for backward compatibility). */
|
|
25
|
+
custom?: FieldValidatorFn<TData>;
|
|
26
|
+
/** Array of validators — compose Rules.* or custom functions. Runs after built-in rules. */
|
|
27
|
+
validators?: FieldValidatorFn<TData>[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ── Field ─────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export type FormFieldType =
|
|
33
|
+
| "text" | "textarea" | "number" | "date"
|
|
34
|
+
| "select" | "lookup" | "checkbox" | "toggle";
|
|
35
|
+
|
|
36
|
+
export type FormFieldOption = { value: string; label: string };
|
|
37
|
+
|
|
38
|
+
export type FormFieldLookupConfig<TData extends Record<string, unknown>> = {
|
|
39
|
+
lookupAPI: string;
|
|
40
|
+
lookupDataStructure: Record<string, T_LookupTblStruct>;
|
|
41
|
+
lookupFieldTitles: Record<string, string>;
|
|
42
|
+
displayFieldNames: string[];
|
|
43
|
+
popupWidthPx?: number;
|
|
44
|
+
placeholder?: string;
|
|
45
|
+
dataSource?: "api" | "direct";
|
|
46
|
+
/** Return a patch to merge into the form when a lookup row is selected. */
|
|
47
|
+
onDataPatch?: (row: Record<string, unknown>, data: TData) => Partial<TData>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type FormFieldNumericConfig<TData extends Record<string, unknown>> =
|
|
51
|
+
| { kind: "currency"; currencyField: keyof TData; precisionByCurrency: Record<string, number>; defaultPrecision?: number }
|
|
52
|
+
| { kind: "percent"; maxFractionDigits: number }
|
|
53
|
+
| { kind: "integer" }
|
|
54
|
+
| { kind: "phone"; minDigits: number; maxDigits: number; allowedPrefixes?: string[] };
|
|
55
|
+
|
|
56
|
+
export type FormField<TData extends Record<string, unknown>> = {
|
|
57
|
+
key: keyof TData;
|
|
58
|
+
label: ReactNode;
|
|
59
|
+
type: FormFieldType;
|
|
60
|
+
span?: number;
|
|
61
|
+
/** Static or dynamic readonly. Function receives current mode + form data. */
|
|
62
|
+
readonly?: boolean | ((mode: FormMode, data: TData) => boolean);
|
|
63
|
+
/** Static or dynamic visibility. Hidden fields are skipped in validation. */
|
|
64
|
+
hidden?: boolean | ((mode: FormMode, data: TData) => boolean);
|
|
65
|
+
/** Restrict to specific modes (undefined = all modes). */
|
|
66
|
+
mode?: FormMode | FormMode[];
|
|
67
|
+
helperText?: ReactNode;
|
|
68
|
+
placeholder?: string;
|
|
69
|
+
maxLength?: number;
|
|
70
|
+
rows?: number;
|
|
71
|
+
inputMode?: "text" | "numeric" | "decimal" | "email" | "tel" | "url";
|
|
72
|
+
options?: FormFieldOption[];
|
|
73
|
+
lookup?: FormFieldLookupConfig<TData>;
|
|
74
|
+
numeric?: FormFieldNumericConfig<TData>;
|
|
75
|
+
format?: (value: TData[keyof TData], data: TData) => ReactNode;
|
|
76
|
+
validation?: FieldValidation<TData>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// ── Section ───────────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
export type FormSection<TData extends Record<string, unknown>> = {
|
|
82
|
+
title?: ReactNode;
|
|
83
|
+
description?: ReactNode;
|
|
84
|
+
/** Number of equal columns in the section grid. Default 1. */
|
|
85
|
+
columns?: 1 | 2 | 3;
|
|
86
|
+
fields: FormField<TData>[];
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// ── Layout ────────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export type FormPageDef<TData extends Record<string, unknown>> = {
|
|
92
|
+
label: string;
|
|
93
|
+
sections: FormSection<TData>[];
|
|
94
|
+
/** Optional: save draft on Next. Returns {ok,error?} (no 202 required). */
|
|
95
|
+
stepSaveApiPath?: ApiPath;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type FormLayout<TData extends Record<string, unknown>> =
|
|
99
|
+
| { type: "single-page"; sections: FormSection<TData>[] }
|
|
100
|
+
| { type: "tabs"; pages: FormPageDef<TData>[] }
|
|
101
|
+
| { type: "wizard"; pages: FormPageDef<TData>[]; validateOnNext?: boolean };
|
|
102
|
+
|
|
103
|
+
// ── CrudForm (schema-driven) ──────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
export type CrudForm<TData extends Record<string, unknown>> = {
|
|
106
|
+
title: { create: string; edit: string; view: string };
|
|
107
|
+
subtitle?: string | { create: string; edit: string; view: string };
|
|
108
|
+
/** Omit entirely to use i18n defaults ("Simpan" / "Update"). Per-key also optional. */
|
|
109
|
+
submitLabel?: { create?: string; edit?: string };
|
|
110
|
+
/** Omit to use i18n default ("Batal"). */
|
|
111
|
+
backLabel?: { view?: string; others?: string};
|
|
112
|
+
/** Form layout density. Defaults to "compact". */
|
|
113
|
+
density?: Ui7FormDensity;
|
|
114
|
+
|
|
115
|
+
emptyData: TData;
|
|
116
|
+
|
|
117
|
+
createApiPath: ApiPath;
|
|
118
|
+
updateApiPath?: ApiPath;
|
|
119
|
+
/** Required for edit + view modes. */
|
|
120
|
+
loadApiPath?: ApiPath;
|
|
121
|
+
|
|
122
|
+
/** Cross-field validation run after per-field rules. */
|
|
123
|
+
validate?: (data: TData) => Partial<Record<keyof TData, string>>;
|
|
124
|
+
|
|
125
|
+
layout: FormLayout<TData>;
|
|
126
|
+
};
|
package/src/i18n.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useT } from "@isi-ui7/i18n";
|
|
4
|
+
|
|
5
|
+
// ── Label contract ─────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type BosSharedLabels = {
|
|
8
|
+
validRequired: (fieldLabel: string) => string;
|
|
9
|
+
validMinLength: (n: number) => string;
|
|
10
|
+
validMaxLength: (n: number) => string;
|
|
11
|
+
validMin: (n: number) => string;
|
|
12
|
+
validMax: (n: number) => string;
|
|
13
|
+
validPattern: string;
|
|
14
|
+
validationError: string;
|
|
15
|
+
|
|
16
|
+
ruleEmail: string;
|
|
17
|
+
ruleUrl: string;
|
|
18
|
+
ruleNik: string;
|
|
19
|
+
ruleKodePos: string;
|
|
20
|
+
rulePhone: string;
|
|
21
|
+
|
|
22
|
+
boolYes: string;
|
|
23
|
+
boolNo: string;
|
|
24
|
+
toggleOn: string;
|
|
25
|
+
toggleOff: string;
|
|
26
|
+
emptyValue: string;
|
|
27
|
+
|
|
28
|
+
loading: string;
|
|
29
|
+
saving: string;
|
|
30
|
+
errorTitle: string;
|
|
31
|
+
close: string;
|
|
32
|
+
cancel: string;
|
|
33
|
+
prev: string;
|
|
34
|
+
next: string;
|
|
35
|
+
|
|
36
|
+
subtitleCreate: string;
|
|
37
|
+
subtitleEdit: string;
|
|
38
|
+
subtitleView: string;
|
|
39
|
+
submitCreate: string;
|
|
40
|
+
submitEdit: string;
|
|
41
|
+
deleteTitle: string;
|
|
42
|
+
deleteDescription: string;
|
|
43
|
+
deleteConfirm: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ── Thin adapter — reads from @isi-ui7/i18n (wired in createShellProvider) ───
|
|
47
|
+
|
|
48
|
+
function ip(template: string, params: Record<string, string | number>): string {
|
|
49
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => String(params[key as string] ?? ""));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useBosSharedI18n(): BosSharedLabels {
|
|
53
|
+
const t = useT();
|
|
54
|
+
return {
|
|
55
|
+
validRequired: (label) => ip(t("bos7.validRequired", "{{label}} wajib diisi"), { label }),
|
|
56
|
+
validMinLength: (n) => ip(t("bos7.validMinLength", "Minimal {{n}} karakter"), { n }),
|
|
57
|
+
validMaxLength: (n) => ip(t("bos7.validMaxLength", "Maksimal {{n}} karakter"), { n }),
|
|
58
|
+
validMin: (n) => ip(t("bos7.validMin", "Nilai minimal {{n}}"), { n }),
|
|
59
|
+
validMax: (n) => ip(t("bos7.validMax", "Nilai maksimal {{n}}"), { n }),
|
|
60
|
+
validPattern: t("bos7.validPattern", "Format tidak valid"),
|
|
61
|
+
validationError: t("bos7.validationError", "Periksa field wajib"),
|
|
62
|
+
|
|
63
|
+
ruleEmail: t("bos7.ruleEmail", "Format email tidak valid"),
|
|
64
|
+
ruleUrl: t("bos7.ruleUrl", "URL harus diawali http:// atau https://"),
|
|
65
|
+
ruleNik: t("bos7.ruleNik", "NIK harus 16 digit angka"),
|
|
66
|
+
ruleKodePos: t("bos7.ruleKodePos", "Kode pos harus 5 digit"),
|
|
67
|
+
rulePhone: t("bos7.rulePhone", "No HP tidak valid"),
|
|
68
|
+
|
|
69
|
+
boolYes: t("bos7.boolYes", "Ya"),
|
|
70
|
+
boolNo: t("bos7.boolNo", "Tidak"),
|
|
71
|
+
toggleOn: t("bos7.toggleOn", "On"),
|
|
72
|
+
toggleOff: t("bos7.toggleOff", "Off"),
|
|
73
|
+
emptyValue: t("bos7.emptyValue", "—"),
|
|
74
|
+
|
|
75
|
+
loading: t("bos7.loading", "Memuat data..."),
|
|
76
|
+
saving: t("bos7.saving", "Memproses..."),
|
|
77
|
+
errorTitle: t("bos7.errorTitle", "Gagal"),
|
|
78
|
+
close: t("bos7.close", "Tutup"),
|
|
79
|
+
cancel: t("bos7.cancel", "Batal"),
|
|
80
|
+
prev: t("bos7.prev", "Kembali"),
|
|
81
|
+
next: t("bos7.next", "Lanjut"),
|
|
82
|
+
|
|
83
|
+
subtitleCreate: t("bos7.subtitleCreate", "Isi form untuk menambah data baru."),
|
|
84
|
+
subtitleEdit: t("bos7.subtitleEdit", "Edit data yang dipilih."),
|
|
85
|
+
subtitleView: t("bos7.subtitleView", "Detail data."),
|
|
86
|
+
submitCreate: t("bos7.submitCreate", "Simpan"),
|
|
87
|
+
submitEdit: t("bos7.submitEdit", "Update"),
|
|
88
|
+
deleteTitle: t("bos7.deleteTitle", "Konfirmasi Hapus"),
|
|
89
|
+
deleteDescription: t("bos7.deleteDescription", "Data berikut akan dihapus permanen."),
|
|
90
|
+
deleteConfirm: t("bos7.deleteConfirm", "Hapus"),
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export * from './auth7';
|
|
2
|
+
export * from './page-layout';
|
|
3
|
+
export * from './backend';
|
|
4
|
+
export * from './export';
|
|
5
|
+
export * from './api';
|
|
6
|
+
export * from './types/core';
|
|
7
|
+
export * from './types/notification';
|
|
8
|
+
export * from './nats/client';
|
|
9
|
+
export * from './event-bus';
|
|
10
|
+
export * from './notification/routing';
|
|
11
|
+
export * from './style-contract';
|
|
12
|
+
|
|
13
|
+
export * from './shell/provider';
|
|
14
|
+
export * from './shell/app-shell-layout';
|
|
15
|
+
export * from './notifications-bff';
|
|
16
|
+
|
|
17
|
+
export * from './crud-types';
|
|
18
|
+
export * from './crud-hooks';
|
|
19
|
+
export * from './form-types';
|
|
20
|
+
export * from './i18n';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Modal-flavoured counterpart to ./page-layout.
|
|
7
|
+
*
|
|
8
|
+
* PageLayout assumes its parent provides an explicit height (full-page shell),
|
|
9
|
+
* so `.page__body { flex: 1; overflow-y: auto }` resolves correctly. Inside a
|
|
10
|
+
* modal dialog the height is content-driven (capped at 90vh by the dialog),
|
|
11
|
+
* which means `flex: 1` collapses to 0 and the scroll behaviour breaks.
|
|
12
|
+
*
|
|
13
|
+
* These components mirror the same UX (sticky title + sticky footer + scrolling
|
|
14
|
+
* body) but use explicit `max-height` on the wrapper so they work in an
|
|
15
|
+
* auto-sized modal. Designed to be used alongside `<PageComponent>.modalTitle`
|
|
16
|
+
* (which renders the dialog header bar with × close + edge-to-edge border).
|
|
17
|
+
*
|
|
18
|
+
* All visual styles live in `@isi-ui7/corporate-themes/dist/globals.css` under
|
|
19
|
+
* the `.modal-layout`, `.modal-layout__body`, `.modal-layout__footer` selectors
|
|
20
|
+
* (and the `.modal-layout--{size}` size modifiers) so the dialog header
|
|
21
|
+
* border-bottom and footer border-top align edge-to-edge.
|
|
22
|
+
*
|
|
23
|
+
* Pattern:
|
|
24
|
+
* <ModalLayout>
|
|
25
|
+
* <ModalBody>...form fields / detail rows...</ModalBody>
|
|
26
|
+
* <ModalFooter>
|
|
27
|
+
* <Button kind="primary">Save</Button>
|
|
28
|
+
* <Button kind="ghost">Cancel</Button>
|
|
29
|
+
* </ModalFooter>
|
|
30
|
+
* </ModalLayout>
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export type ModalSize = "sm" | "md" | "lg" | "xl";
|
|
34
|
+
|
|
35
|
+
export type ModalLayoutProps = {
|
|
36
|
+
/** Width preset matching @isi-ui7/modal-manager size names. Default: md. */
|
|
37
|
+
size?: ModalSize;
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function ModalLayout({ size = "md", children }: ModalLayoutProps) {
|
|
42
|
+
return <div className={`modal-layout modal-layout--${size}`}>{children}</div>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ModalBody({ children }: { children: ReactNode }) {
|
|
46
|
+
return <div className="modal-layout__body">{children}</div>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ModalFooter({
|
|
50
|
+
children,
|
|
51
|
+
spread = false,
|
|
52
|
+
}: {
|
|
53
|
+
/** Action buttons. */
|
|
54
|
+
children: ReactNode;
|
|
55
|
+
/** If true, the first child is pushed to the left edge (e.g. Back left, Next right). */
|
|
56
|
+
spread?: boolean;
|
|
57
|
+
}) {
|
|
58
|
+
const cls = spread ? "modal-layout__footer modal-layout__footer--spread" : "modal-layout__footer";
|
|
59
|
+
return <div className={cls}>{children}</div>;
|
|
60
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// lib/nats/client.ts — NATS client for bos7-template
|
|
2
|
+
|
|
3
|
+
export interface NatsConfig {
|
|
4
|
+
url: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
timeout?: number;
|
|
7
|
+
maxReconnect?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Message {
|
|
11
|
+
subject: string;
|
|
12
|
+
data: Uint8Array | string | null;
|
|
13
|
+
reply?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface JetStreamMessage extends Message {
|
|
17
|
+
meta?: JetStreamMeta;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface JetStreamMeta {
|
|
21
|
+
stream: string;
|
|
22
|
+
sequence: number;
|
|
23
|
+
timestamp: Date;
|
|
24
|
+
redelivered: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type MessageHandler = (msg: Message) => void | Promise<void>;
|
|
28
|
+
export type JetStreamHandler = (msg: JetStreamMessage) => void | Promise<void>;
|
|
29
|
+
|
|
30
|
+
export interface Subscription {
|
|
31
|
+
subject: string;
|
|
32
|
+
unsubscribe: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface NatsConnection {
|
|
36
|
+
close(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NatsClient {
|
|
40
|
+
publish(subject: string, data: Uint8Array | string): Promise<void>;
|
|
41
|
+
subscribe(subject: string, handler: MessageHandler): Promise<Subscription>;
|
|
42
|
+
request(subject: string, data: Uint8Array | string, timeout: number): Promise<Message>;
|
|
43
|
+
close(): void;
|
|
44
|
+
isConnected(): boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface JetStreamClient extends NatsClient {
|
|
48
|
+
publishAsync(subject: string, data: Uint8Array | string): Promise<void>;
|
|
49
|
+
subscribeStream(stream: string, subject: string, handler: JetStreamHandler): Promise<Subscription>;
|
|
50
|
+
createStream(name: string, subjects: string[]): Promise<void>;
|
|
51
|
+
streamExists(name: string): Promise<boolean>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class NatsClientImpl implements NatsClient {
|
|
55
|
+
private conn: NatsConnection | null = null;
|
|
56
|
+
private config: NatsConfig;
|
|
57
|
+
private subscriptions: Map<string, Subscription> = new Map();
|
|
58
|
+
|
|
59
|
+
constructor(config: NatsConfig) {
|
|
60
|
+
this.config = {
|
|
61
|
+
name: 'bos7-client',
|
|
62
|
+
timeout: 5000,
|
|
63
|
+
maxReconnect: 10,
|
|
64
|
+
...config,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async connect(): Promise<void> {
|
|
69
|
+
// Note: Actual implementation would use nats.ws or nats.deno
|
|
70
|
+
// This is a placeholder for the interface
|
|
71
|
+
console.log('NATS client connecting to:', this.config.url);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async publish(subject: string, data: Uint8Array | string): Promise<void> {
|
|
75
|
+
if (!this.isConnected()) {
|
|
76
|
+
throw new Error('NATS client not connected');
|
|
77
|
+
}
|
|
78
|
+
// Implementation would use nats.ws publish
|
|
79
|
+
console.log('Publishing to', subject, ':', data);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async subscribe(subject: string): Promise<Subscription> {
|
|
83
|
+
if (!this.isConnected()) {
|
|
84
|
+
throw new Error('NATS client not connected');
|
|
85
|
+
}
|
|
86
|
+
const unsubscribe = () => {
|
|
87
|
+
this.subscriptions.delete(subject);
|
|
88
|
+
};
|
|
89
|
+
const sub = { subject, unsubscribe };
|
|
90
|
+
this.subscriptions.set(subject, sub);
|
|
91
|
+
return sub;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async request(_subject: string, _data: Uint8Array | string, _timeout: number): Promise<Message> {
|
|
95
|
+
void _subject;
|
|
96
|
+
void _data;
|
|
97
|
+
void _timeout;
|
|
98
|
+
if (!this.isConnected()) {
|
|
99
|
+
throw new Error('NATS client not connected');
|
|
100
|
+
}
|
|
101
|
+
throw new Error('NATS request not implemented');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
close(): void {
|
|
105
|
+
this.subscriptions.forEach((sub) => sub.unsubscribe());
|
|
106
|
+
this.subscriptions.clear();
|
|
107
|
+
// Implementation would close nats.ws connection
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
isConnected(): boolean {
|
|
111
|
+
return this.conn !== null && this.conn !== undefined;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let globalClient: NatsClient | null = null;
|
|
116
|
+
|
|
117
|
+
export async function createClient(config: NatsConfig): Promise<NatsClient> {
|
|
118
|
+
const client = new NatsClientImpl(config);
|
|
119
|
+
await client.connect();
|
|
120
|
+
globalClient = client;
|
|
121
|
+
return client;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getClient(): NatsClient | null {
|
|
125
|
+
return globalClient;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isConnected(): boolean {
|
|
129
|
+
return globalClient?.isConnected() ?? false;
|
|
130
|
+
}
|
package/src/notif7.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { SignJWT } from 'jose';
|
|
2
|
+
|
|
3
|
+
export const NOTIF7_URL = process.env.NOTIF7_URL ?? '';
|
|
4
|
+
export const NOTIF7_JWT_SECRET = process.env.NOTIF7_JWT_SECRET ?? '';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_USER_ID = 'b0000000-0000-0000-0000-000000000001';
|
|
7
|
+
export const DEFAULT_BRANCH_ID = 'c0000000-0000-0000-0000-000000000001';
|
|
8
|
+
|
|
9
|
+
type RequestLike = Request & {
|
|
10
|
+
cookies?: {
|
|
11
|
+
get?: (name: string) => { value?: string } | undefined;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function getUserIDFromRequest(req: RequestLike | Request): string {
|
|
16
|
+
const cookieHeader = (req as RequestLike).cookies?.get?.('access_token')?.value
|
|
17
|
+
?? (req.headers.get('cookie') ?? '')
|
|
18
|
+
.split(';')
|
|
19
|
+
.find((c: string) => c.trim().startsWith('access_token='))
|
|
20
|
+
?.split('=').slice(1).join('=')
|
|
21
|
+
.trim();
|
|
22
|
+
|
|
23
|
+
if (!cookieHeader) return DEFAULT_USER_ID;
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const payload = cookieHeader.split('.')[1];
|
|
27
|
+
if (!payload) return DEFAULT_USER_ID;
|
|
28
|
+
const decoded = JSON.parse(
|
|
29
|
+
Buffer.from(payload.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf-8')
|
|
30
|
+
);
|
|
31
|
+
return (decoded.sub as string | undefined) ?? DEFAULT_USER_ID;
|
|
32
|
+
} catch {
|
|
33
|
+
return DEFAULT_USER_ID;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function mintJWT(userID: string): Promise<string> {
|
|
38
|
+
const secret = new TextEncoder().encode(NOTIF7_JWT_SECRET);
|
|
39
|
+
return new SignJWT({ branch_id: DEFAULT_BRANCH_ID, roles: ['app_user'] })
|
|
40
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
41
|
+
.setSubject(userID)
|
|
42
|
+
.setJti(crypto.randomUUID())
|
|
43
|
+
.setExpirationTime('60s')
|
|
44
|
+
.setIssuedAt()
|
|
45
|
+
.sign(secret);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface Notif7Options {
|
|
49
|
+
userID?: string;
|
|
50
|
+
accessToken?: string;
|
|
51
|
+
query?: URLSearchParams;
|
|
52
|
+
body?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function notif7Fetch(
|
|
56
|
+
method: string,
|
|
57
|
+
path: string,
|
|
58
|
+
opts: Notif7Options = {}
|
|
59
|
+
): Promise<Response> {
|
|
60
|
+
const userID = opts.userID ?? DEFAULT_USER_ID;
|
|
61
|
+
const authHeader = opts.accessToken
|
|
62
|
+
? `Bearer ${opts.accessToken}`
|
|
63
|
+
: NOTIF7_JWT_SECRET ? `Bearer ${await mintJWT(userID)}` : '';
|
|
64
|
+
const url = opts.query
|
|
65
|
+
? `${NOTIF7_URL}${path}?${opts.query}`
|
|
66
|
+
: `${NOTIF7_URL}${path}`;
|
|
67
|
+
|
|
68
|
+
return fetch(url, {
|
|
69
|
+
method,
|
|
70
|
+
headers: {
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
73
|
+
},
|
|
74
|
+
...(opts.body !== undefined ? { body: opts.body } : {}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export const DEFAULT_WORKFLOW_BASE_URL = "https://workflow.bos7.local";
|
|
2
|
+
|
|
3
|
+
function openNamedWindow(url: string, name: string): void {
|
|
4
|
+
const win = window.open(url, name);
|
|
5
|
+
win?.focus();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function openPortalNotificationsWindow(url: string): void {
|
|
9
|
+
openNamedWindow(url, "bos7PortalNotificationsWindow");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function openWorkflowNotificationTarget(
|
|
13
|
+
actionHref: string | undefined,
|
|
14
|
+
workflowBaseUrl = DEFAULT_WORKFLOW_BASE_URL
|
|
15
|
+
): boolean {
|
|
16
|
+
if (!actionHref) return false;
|
|
17
|
+
try {
|
|
18
|
+
const actionUrl = new URL(actionHref, window.location.origin);
|
|
19
|
+
const workflowOrigin = new URL(workflowBaseUrl).origin;
|
|
20
|
+
if (actionUrl.origin !== workflowOrigin) return false;
|
|
21
|
+
openNamedWindow(actionUrl.toString(), "bos7WorkflowInboxWindow");
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveNotificationHref(
|
|
29
|
+
href: string | undefined,
|
|
30
|
+
push: (path: string) => void,
|
|
31
|
+
workflowBaseUrl = DEFAULT_WORKFLOW_BASE_URL
|
|
32
|
+
): void {
|
|
33
|
+
if (!href) return;
|
|
34
|
+
const isWorkflowInboxPath = href.startsWith("/inbox");
|
|
35
|
+
|
|
36
|
+
if (isWorkflowInboxPath) {
|
|
37
|
+
try {
|
|
38
|
+
const workflowOrigin = new URL(workflowBaseUrl).origin;
|
|
39
|
+
if (window.location.origin !== workflowOrigin) {
|
|
40
|
+
openNamedWindow(`${workflowOrigin}${href}`, "bos7WorkflowInboxWindow");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// fall through to local push
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (href.startsWith("/")) {
|
|
49
|
+
push(href);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (openWorkflowNotificationTarget(href, workflowBaseUrl)) return;
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(href);
|
|
55
|
+
if (url.origin === window.location.origin) {
|
|
56
|
+
push(url.pathname + url.search + url.hash);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
push(href);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
window.open(href, "_blank", "noopener,noreferrer");
|
|
64
|
+
}
|