@viengut/vgq-sdk 0.1.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/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @viengut/vgq-sdk
2
+
3
+ SDK headless (TypeScript) cho nền tảng Form lâm sàng **VGQ** của Viện Gút: tải Form đã phát
4
+ hành, quản lý trạng thái Response, đánh giá Enable Condition và validate Response Value —
5
+ không kèm component UI, bạn tự dựng giao diện.
6
+
7
+ ```bash
8
+ npm install @viengut/vgq-sdk
9
+ ```
10
+
11
+ ## Dùng nhanh
12
+
13
+ ```js
14
+ import { ResponseSession, VgqClient } from '@viengut/vgq-sdk';
15
+
16
+ const client = new VgqClient({ baseUrl: 'https://vgq.example.com', actor: 'app:demo' });
17
+
18
+ // Tạo Response mới từ Publication key; draft tự đồng bộ sau mỗi 800ms không gõ.
19
+ const session = await ResponseSession.start(client, {
20
+ publicationKey: 'health-declaration',
21
+ subjectRef: 'patient:VG2607000187',
22
+ careContextRef: 'checkin:1000034907',
23
+ idempotencyKey: crypto.randomUUID(),
24
+ }, { debounceMs: 800, onSync: () => console.log('đã lưu nháp') });
25
+
26
+ session.setValue('ho_ten', 'Nguyễn Văn A'); // ghi theo slug của Question
27
+ session.getValue('ho_ten');
28
+ session.activeState().activeIds; // Question đang bật theo Enable Condition
29
+ const check = session.validate(); // validate phía client (server vẫn là chốt cuối)
30
+ if (check.ok) await session.complete();
31
+
32
+ // Mở lại Draft đang dở:
33
+ const resumed = await ResponseSession.resume(client, responseId);
34
+ ```
35
+
36
+ Engine (Enable Condition, validation) mirror đúng logic server nên UI phản hồi tức thì mà
37
+ không cần round-trip; mọi thay đổi vẫn được server validate lại khi hoàn tất Response.
38
+
39
+ ## Phát triển
40
+
41
+ ```bash
42
+ npm install
43
+ npm test # vitest
44
+ npm run build # tsc -> dist/
45
+ npx tsc --noEmit
46
+ ```
47
+
48
+ Mã nguồn nằm trong repo [vgq-questionnaire](https://github.com/viengut-hospital/vgq-questionnaire)
49
+ (thư mục `sdk/`). Bản quyền thuộc Viện Gút — publish công khai để tiện cài đặt, không kèm
50
+ giấy phép sử dụng lại.
@@ -0,0 +1,48 @@
1
+ import type { FormVersion, InvitationDto, JsonValue, MediaAssetDto, PublicationForm, ResponseDto, ValidationIssue } from './types.js';
2
+ export interface VgqClientOptions {
3
+ /** Gốc của VGQ server, VD: https://vgq.viengut.vn */
4
+ baseUrl: string;
5
+ /** Danh tính người thao tác, gửi qua header X-Actor cho audit trail. */
6
+ actor?: string;
7
+ /** Cho phép thay fetch (test, môi trường không có fetch toàn cục). */
8
+ fetch?: typeof fetch;
9
+ }
10
+ export declare class VgqApiError extends Error {
11
+ readonly status: number;
12
+ readonly issues: ValidationIssue[];
13
+ readonly data: unknown;
14
+ constructor(status: number, message: string, data: unknown);
15
+ }
16
+ export interface StartResponseOptions {
17
+ subjectRef?: string;
18
+ respondentRef?: string;
19
+ careContextRef?: string;
20
+ /** Chống tạo Response trùng khi retry trên mạng không ổn định. */
21
+ idempotencyKey?: string;
22
+ }
23
+ export declare class VgqClient {
24
+ private readonly baseUrl;
25
+ private readonly actor?;
26
+ private readonly fetchImpl;
27
+ constructor(options: VgqClientOptions);
28
+ /** Tải Form đang phát hành qua Publication key ổn định. */
29
+ getPublicationForm(key: string): Promise<PublicationForm>;
30
+ /** Tải một Form Version bất biến (Response luôn gắn đúng version đã bắt đầu). */
31
+ getFormVersion(id: string): Promise<FormVersion>;
32
+ createResponse(publicationKey: string, options?: StartResponseOptions): Promise<ResponseDto>;
33
+ getInvitation(id: string): Promise<InvitationDto>;
34
+ /** Bắt đầu (hoặc mở lại) Response duy nhất của một Invitation. */
35
+ startInvitationResponse(invitationId: string): Promise<ResponseDto>;
36
+ getResponse(id: string): Promise<ResponseDto>;
37
+ /** Cập nhật Draft theo lô; value null xoá giá trị. Idempotent khi retry. */
38
+ updateValues(responseId: string, values: Record<string, JsonValue | null>): Promise<ResponseDto>;
39
+ /** Hoàn thành/xác nhận; server validate độc lập với SDK. */
40
+ completeResponse(id: string): Promise<ResponseDto>;
41
+ getMediaAsset(id: string): Promise<MediaAssetDto>;
42
+ /**
43
+ * URL tuyệt đối tới nội dung một Media Asset (ảnh nền Body Map, media nhúng
44
+ * trong Form). Ổn định theo id nên dùng thẳng trong thẻ img/video/audio.
45
+ */
46
+ mediaContentUrl(assetId: string): string;
47
+ private request;
48
+ }
package/dist/client.js ADDED
@@ -0,0 +1,99 @@
1
+ export class VgqApiError extends Error {
2
+ status;
3
+ issues;
4
+ data;
5
+ constructor(status, message, data) {
6
+ super(message);
7
+ this.name = 'VgqApiError';
8
+ this.status = status;
9
+ this.data = data;
10
+ const body = data;
11
+ this.issues = body?.issues ?? [];
12
+ }
13
+ }
14
+ export class VgqClient {
15
+ baseUrl;
16
+ actor;
17
+ fetchImpl;
18
+ constructor(options) {
19
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
20
+ this.actor = options.actor;
21
+ // Bọc thay vì gán trực tiếp: fetch tách khỏi globalThis sẽ ném
22
+ // "Illegal invocation" trên browser.
23
+ this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
24
+ }
25
+ /** Tải Form đang phát hành qua Publication key ổn định. */
26
+ getPublicationForm(key) {
27
+ return this.request('GET', `/api/publications/${encodeURIComponent(key)}/form`);
28
+ }
29
+ /** Tải một Form Version bất biến (Response luôn gắn đúng version đã bắt đầu). */
30
+ getFormVersion(id) {
31
+ return this.request('GET', `/api/form-versions/${encodeURIComponent(id)}`);
32
+ }
33
+ createResponse(publicationKey, options = {}) {
34
+ const headers = {};
35
+ if (options.idempotencyKey)
36
+ headers['idempotency-key'] = options.idempotencyKey;
37
+ return this.request('POST', `/api/publications/${encodeURIComponent(publicationKey)}/responses`, {
38
+ subject_ref: options.subjectRef ?? null,
39
+ respondent_ref: options.respondentRef ?? null,
40
+ care_context_ref: options.careContextRef ?? null,
41
+ }, headers);
42
+ }
43
+ getInvitation(id) {
44
+ return this.request('GET', `/api/invitations/${encodeURIComponent(id)}`);
45
+ }
46
+ /** Bắt đầu (hoặc mở lại) Response duy nhất của một Invitation. */
47
+ startInvitationResponse(invitationId) {
48
+ return this.request('POST', `/api/invitations/${encodeURIComponent(invitationId)}/responses`);
49
+ }
50
+ getResponse(id) {
51
+ return this.request('GET', `/api/responses/${encodeURIComponent(id)}`);
52
+ }
53
+ /** Cập nhật Draft theo lô; value null xoá giá trị. Idempotent khi retry. */
54
+ updateValues(responseId, values) {
55
+ return this.request('PUT', `/api/responses/${encodeURIComponent(responseId)}/values`, { values });
56
+ }
57
+ /** Hoàn thành/xác nhận; server validate độc lập với SDK. */
58
+ completeResponse(id) {
59
+ return this.request('POST', `/api/responses/${encodeURIComponent(id)}/complete`);
60
+ }
61
+ getMediaAsset(id) {
62
+ return this.request('GET', `/api/media/${encodeURIComponent(id)}`);
63
+ }
64
+ /**
65
+ * URL tuyệt đối tới nội dung một Media Asset (ảnh nền Body Map, media nhúng
66
+ * trong Form). Ổn định theo id nên dùng thẳng trong thẻ img/video/audio.
67
+ */
68
+ mediaContentUrl(assetId) {
69
+ return `${this.baseUrl}/api/media/${encodeURIComponent(assetId)}/content`;
70
+ }
71
+ async request(method, path, body, extraHeaders = {}) {
72
+ const headers = {
73
+ 'content-type': 'application/json',
74
+ ...extraHeaders,
75
+ };
76
+ if (this.actor)
77
+ headers['x-actor'] = this.actor;
78
+ const response = await this.fetchImpl(this.baseUrl + path, {
79
+ method,
80
+ headers,
81
+ body: body === undefined ? undefined : JSON.stringify(body),
82
+ });
83
+ const text = await response.text();
84
+ let data = null;
85
+ if (text) {
86
+ try {
87
+ data = JSON.parse(text);
88
+ }
89
+ catch {
90
+ data = null;
91
+ }
92
+ }
93
+ if (!response.ok) {
94
+ const message = data?.error ?? response.statusText;
95
+ throw new VgqApiError(response.status, message, data);
96
+ }
97
+ return data;
98
+ }
99
+ }
@@ -0,0 +1,36 @@
1
+ import type { FormDefinition, JsonValue, QuestionDef, ResponseValues, ValidationIssue } from './types.js';
2
+ export declare function allQuestions(def: FormDefinition): QuestionDef[];
3
+ /**
4
+ * Sắp xếp topo theo phụ thuộc Enable Condition (Kahn).
5
+ * `stuck` chứa id các Question nằm trong hoặc phụ thuộc vào chu trình.
6
+ */
7
+ export declare function topoOrder(def: FormDefinition): {
8
+ order: string[];
9
+ stuck: string[];
10
+ };
11
+ export interface ActiveState {
12
+ /** Id các Question đang hoạt động. */
13
+ activeIds: Set<string>;
14
+ /** Giá trị hiệu lực theo slug: chỉ gồm giá trị của Question đang hoạt động. */
15
+ effectiveValues: ResponseValues;
16
+ }
17
+ /**
18
+ * Đánh giá Enable Condition toàn Form theo thứ tự topo. Giá trị của Question
19
+ * không hoạt động bị loại khỏi giá trị hiệu lực nên chuỗi phụ thuộc tắt theo.
20
+ */
21
+ export declare function computeActive(def: FormDefinition, values: ResponseValues): ActiveState;
22
+ export declare function isAnswered(value: JsonValue | undefined): boolean;
23
+ export declare function deepEqual(a: JsonValue, b: JsonValue): boolean;
24
+ export type CompletionResult = {
25
+ ok: true;
26
+ official: ResponseValues;
27
+ } | {
28
+ ok: false;
29
+ issues: ValidationIssue[];
30
+ };
31
+ /**
32
+ * Validate hoàn thành giống server: Question bắt buộc đang hoạt động phải có
33
+ * giá trị hợp lệ; giá trị của Question không hoạt động bị loại khỏi kết quả
34
+ * chính thức nhưng vẫn nằm trong Draft.
35
+ */
36
+ export declare function validateCompletion(def: FormDefinition, values: ResponseValues): CompletionResult;
package/dist/engine.js ADDED
@@ -0,0 +1,266 @@
1
+ // Bản sao phía client của Form Engine: đánh giá Enable Condition và validate
2
+ // Response Value đúng như server, để ứng dụng phản hồi ngay cho người dùng.
3
+ // Server vẫn validate độc lập khi hoàn thành — SDK không phải chốt chặn cuối.
4
+ export function allQuestions(def) {
5
+ const out = [];
6
+ const walk = (categories) => {
7
+ for (const category of categories) {
8
+ out.push(...category.questions);
9
+ walk(category.children ?? []);
10
+ }
11
+ };
12
+ walk(def.categories);
13
+ return out;
14
+ }
15
+ function conditionRefs(question) {
16
+ return (question.enable_condition?.any ?? []).flatMap((group) => group.all.map((condition) => condition.question_id));
17
+ }
18
+ /**
19
+ * Sắp xếp topo theo phụ thuộc Enable Condition (Kahn).
20
+ * `stuck` chứa id các Question nằm trong hoặc phụ thuộc vào chu trình.
21
+ */
22
+ export function topoOrder(def) {
23
+ const questions = allQuestions(def);
24
+ const known = new Set(questions.map((q) => q.id));
25
+ const inDegree = new Map();
26
+ const dependents = new Map();
27
+ for (const q of questions) {
28
+ const valid = conditionRefs(q).filter((ref) => known.has(ref));
29
+ inDegree.set(q.id, valid.length);
30
+ for (const ref of valid) {
31
+ const list = dependents.get(ref) ?? [];
32
+ list.push(q.id);
33
+ dependents.set(ref, list);
34
+ }
35
+ }
36
+ const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([id]) => id);
37
+ const order = [];
38
+ while (queue.length > 0) {
39
+ const id = queue.pop();
40
+ order.push(id);
41
+ for (const dependent of dependents.get(id) ?? []) {
42
+ const d = inDegree.get(dependent) - 1;
43
+ inDegree.set(dependent, d);
44
+ if (d === 0)
45
+ queue.push(dependent);
46
+ }
47
+ }
48
+ const done = new Set(order);
49
+ const stuck = [...known].filter((id) => !done.has(id)).sort();
50
+ return { order, stuck };
51
+ }
52
+ /**
53
+ * Đánh giá Enable Condition toàn Form theo thứ tự topo. Giá trị của Question
54
+ * không hoạt động bị loại khỏi giá trị hiệu lực nên chuỗi phụ thuộc tắt theo.
55
+ */
56
+ export function computeActive(def, values) {
57
+ const questions = allQuestions(def);
58
+ const byId = new Map(questions.map((q) => [q.id, q]));
59
+ const { order, stuck } = topoOrder(def);
60
+ const evaluationOrder = stuck.length === 0 ? order : questions.map((q) => q.id);
61
+ const effectiveValues = {};
62
+ for (const q of questions) {
63
+ const v = values[q.slug];
64
+ if (v !== undefined && v !== null)
65
+ effectiveValues[q.slug] = v;
66
+ }
67
+ const activeIds = new Set();
68
+ for (const id of evaluationOrder) {
69
+ const q = byId.get(id);
70
+ const active = !q.enable_condition || evalCondition(q.enable_condition, effectiveValues, byId);
71
+ if (active)
72
+ activeIds.add(id);
73
+ else
74
+ delete effectiveValues[q.slug];
75
+ }
76
+ return { activeIds, effectiveValues };
77
+ }
78
+ function evalCondition(condition, effective, byId) {
79
+ return condition.any.some((group) => group.all.every((c) => evalSingle(c, effective, byId)));
80
+ }
81
+ function evalSingle(c, effective, byId) {
82
+ const question = byId.get(c.question_id);
83
+ const value = question ? effective[question.slug] : undefined;
84
+ const target = c.value === undefined ? null : c.value;
85
+ switch (c.operator) {
86
+ case 'answered':
87
+ return isAnswered(value);
88
+ case 'not_answered':
89
+ return !isAnswered(value);
90
+ case 'eq':
91
+ return value !== undefined && deepEqual(value, target);
92
+ case 'neq':
93
+ return isAnswered(value) && !deepEqual(value, target);
94
+ case 'gt':
95
+ return compare(value, target) === 1;
96
+ case 'gte': {
97
+ const ordering = compare(value, target);
98
+ return ordering === 0 || ordering === 1;
99
+ }
100
+ case 'lt':
101
+ return compare(value, target) === -1;
102
+ case 'lte': {
103
+ const ordering = compare(value, target);
104
+ return ordering === 0 || ordering === -1;
105
+ }
106
+ case 'contains':
107
+ return contains(value, target);
108
+ }
109
+ }
110
+ export function isAnswered(value) {
111
+ if (value === undefined || value === null)
112
+ return false;
113
+ if (typeof value === 'string')
114
+ return value.trim().length > 0;
115
+ if (Array.isArray(value))
116
+ return value.length > 0;
117
+ return true;
118
+ }
119
+ export function deepEqual(a, b) {
120
+ if (a === b)
121
+ return true;
122
+ if (Array.isArray(a) && Array.isArray(b)) {
123
+ return a.length === b.length && a.every((item, i) => deepEqual(item, b[i]));
124
+ }
125
+ if (a !== null && b !== null &&
126
+ typeof a === 'object' && typeof b === 'object' &&
127
+ !Array.isArray(a) && !Array.isArray(b)) {
128
+ const keysA = Object.keys(a);
129
+ const keysB = Object.keys(b);
130
+ return (keysA.length === keysB.length &&
131
+ keysA.every((k) => k in b && deepEqual(a[k], b[k])));
132
+ }
133
+ return false;
134
+ }
135
+ /** So sánh số với số, chuỗi với chuỗi (chuỗi ISO so sánh đúng theo từ điển). */
136
+ function compare(value, target) {
137
+ if (typeof value === 'number' && typeof target === 'number') {
138
+ return value < target ? -1 : value > target ? 1 : 0;
139
+ }
140
+ if (typeof value === 'string' && typeof target === 'string') {
141
+ return value < target ? -1 : value > target ? 1 : 0;
142
+ }
143
+ return null;
144
+ }
145
+ function contains(value, target) {
146
+ if (Array.isArray(value))
147
+ return value.some((item) => deepEqual(item, target));
148
+ if (typeof value === 'string' && typeof target === 'string') {
149
+ return value.includes(target);
150
+ }
151
+ return false;
152
+ }
153
+ /**
154
+ * Validate hoàn thành giống server: Question bắt buộc đang hoạt động phải có
155
+ * giá trị hợp lệ; giá trị của Question không hoạt động bị loại khỏi kết quả
156
+ * chính thức nhưng vẫn nằm trong Draft.
157
+ */
158
+ export function validateCompletion(def, values) {
159
+ const issues = [];
160
+ const questions = allQuestions(def);
161
+ const knownSlugs = new Set(questions.map((q) => q.slug));
162
+ for (const slug of Object.keys(values)) {
163
+ if (!knownSlugs.has(slug)) {
164
+ issues.push({
165
+ path: slug,
166
+ code: 'unknown_question',
167
+ message: `Không có Question nào mang slug '${slug}' trong Form Version này`,
168
+ });
169
+ }
170
+ }
171
+ const state = computeActive(def, values);
172
+ for (const q of questions) {
173
+ if (!state.activeIds.has(q.id))
174
+ continue;
175
+ const value = state.effectiveValues[q.slug];
176
+ if (!isAnswered(value)) {
177
+ if (q.required) {
178
+ issues.push({
179
+ path: q.slug,
180
+ code: 'required',
181
+ message: `Question bắt buộc '${q.label}' chưa có Response Value`,
182
+ });
183
+ }
184
+ continue;
185
+ }
186
+ const problem = checkValue(q, value);
187
+ if (problem)
188
+ issues.push({ path: q.slug, code: 'invalid_value', message: problem });
189
+ }
190
+ if (issues.length > 0)
191
+ return { ok: false, issues };
192
+ const official = {};
193
+ for (const q of questions) {
194
+ const value = state.effectiveValues[q.slug];
195
+ if (value !== undefined)
196
+ official[q.slug] = value;
197
+ }
198
+ return { ok: true, official };
199
+ }
200
+ function checkValue(q, v) {
201
+ const dt = q.data_type;
202
+ switch (dt.type) {
203
+ case 'boolean':
204
+ return typeof v === 'boolean' ? null : 'Giá trị phải là boolean';
205
+ case 'text':
206
+ case 'long_text':
207
+ return typeof v === 'string' ? null : 'Giá trị phải là chuỗi';
208
+ case 'integer':
209
+ return typeof v === 'number' && Number.isInteger(v)
210
+ ? null
211
+ : 'Giá trị phải là số nguyên';
212
+ case 'decimal':
213
+ case 'measurement':
214
+ return typeof v === 'number' && Number.isFinite(v)
215
+ ? null
216
+ : 'Giá trị phải là số';
217
+ case 'date': {
218
+ if (typeof v !== 'string')
219
+ return 'Giá trị ngày phải là chuỗi YYYY-MM-DD';
220
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(v) || Number.isNaN(Date.parse(v))) {
221
+ return `'${v}' không phải ngày hợp lệ (YYYY-MM-DD)`;
222
+ }
223
+ return null;
224
+ }
225
+ case 'date_time': {
226
+ if (typeof v !== 'string')
227
+ return 'Giá trị thời điểm phải là chuỗi RFC 3339';
228
+ const rfc3339 = /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
229
+ if (!rfc3339.test(v) || Number.isNaN(Date.parse(v))) {
230
+ return `'${v}' không phải thời điểm RFC 3339 hợp lệ`;
231
+ }
232
+ return null;
233
+ }
234
+ case 'attachment':
235
+ return typeof v === 'string' && v.trim().length > 0
236
+ ? null
237
+ : 'Giá trị attachment phải là tham chiếu chuỗi';
238
+ // Body Map trả lời bằng slug của các vị trí đã chọn, cùng luật với choice.
239
+ case 'choice':
240
+ case 'body_map': {
241
+ const optionSlugs = new Set(q.options.map((o) => o.slug));
242
+ const checkOne = (item) => {
243
+ if (typeof item !== 'string')
244
+ return 'Giá trị choice phải là slug của Option';
245
+ return optionSlugs.has(item)
246
+ ? null
247
+ : `'${item}' không phải Option của Question này`;
248
+ };
249
+ if (dt.multiple) {
250
+ if (!Array.isArray(v))
251
+ return 'Choice nhiều lựa chọn phải là mảng slug';
252
+ const seen = new Set();
253
+ for (const item of v) {
254
+ const problem = checkOne(item);
255
+ if (problem)
256
+ return problem;
257
+ if (seen.has(item))
258
+ return 'Các lựa chọn không được lặp lại';
259
+ seen.add(item);
260
+ }
261
+ return null;
262
+ }
263
+ return checkOne(v);
264
+ }
265
+ }
266
+ }
@@ -0,0 +1,7 @@
1
+ export * from './types.js';
2
+ export { allQuestions, computeActive, isAnswered, topoOrder, validateCompletion, } from './engine.js';
3
+ export type { ActiveState, CompletionResult } from './engine.js';
4
+ export { VgqApiError, VgqClient } from './client.js';
5
+ export type { StartResponseOptions, VgqClientOptions } from './client.js';
6
+ export { ResponseSession } from './session.js';
7
+ export type { SessionClient, SessionOptions, StartSessionOptions, } from './session.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './types.js';
2
+ export { allQuestions, computeActive, isAnswered, topoOrder, validateCompletion, } from './engine.js';
3
+ export { VgqApiError, VgqClient } from './client.js';
4
+ export { ResponseSession } from './session.js';
@@ -0,0 +1,72 @@
1
+ import type { VgqClient } from './client.js';
2
+ import type { ActiveState, CompletionResult } from './engine.js';
3
+ import type { FormDefinition, JsonValue, ResponseDto, ResponseValues } from './types.js';
4
+ /** Phần VgqClient mà ResponseSession cần — thu hẹp để test dễ stub. */
5
+ export type SessionClient = Pick<VgqClient, 'getFormVersion' | 'createResponse' | 'startInvitationResponse' | 'getResponse' | 'updateValues' | 'completeResponse'>;
6
+ export interface SessionOptions {
7
+ /** Thời gian gom thay đổi trước khi đồng bộ lên server (ms). Mặc định 800. */
8
+ debounceMs?: number;
9
+ /** Gọi khi một lần đồng bộ Draft thất bại; thay đổi được giữ lại để thử lại. */
10
+ onSyncError?: (error: unknown) => void;
11
+ /** Gọi sau mỗi lần đồng bộ Draft thành công. */
12
+ onSync?: (response: ResponseDto) => void;
13
+ }
14
+ export interface StartSessionOptions {
15
+ /** Bắt đầu qua Publication key... */
16
+ publicationKey?: string;
17
+ /** ...hoặc qua Invitation đã được tạo sẵn. */
18
+ invitationId?: string;
19
+ subjectRef?: string;
20
+ respondentRef?: string;
21
+ careContextRef?: string;
22
+ idempotencyKey?: string;
23
+ }
24
+ /**
25
+ * Quản lý trạng thái một Response phía client: giữ giá trị cục bộ, đánh giá
26
+ * Question nào đang hoạt động, validate như server và đồng bộ Draft theo lô
27
+ * (debounce) để mạng chập chờn không tạo Response trùng hay mất dữ liệu.
28
+ */
29
+ export declare class ResponseSession {
30
+ readonly definition: FormDefinition;
31
+ readonly responseId: string;
32
+ readonly formVersionId: string;
33
+ private readonly client;
34
+ private readonly options;
35
+ private readonly questionBySlug;
36
+ private values;
37
+ private lastServerState;
38
+ private dirty;
39
+ private timer;
40
+ private inflight;
41
+ private constructor();
42
+ /** Bắt đầu Response mới qua Publication hoặc Invitation. */
43
+ static start(client: SessionClient, start: StartSessionOptions, options?: SessionOptions): Promise<ResponseSession>;
44
+ /** Mở lại Draft đã có để tiếp tục nhập. */
45
+ static resume(client: SessionClient, responseId: string, options?: SessionOptions): Promise<ResponseSession>;
46
+ get response(): ResponseDto;
47
+ /** Ảnh chụp giá trị Draft cục bộ hiện tại. */
48
+ get draftValues(): ResponseValues;
49
+ getValue(slug: string): JsonValue | undefined;
50
+ /**
51
+ * Đặt giá trị cho một Question theo slug (null/undefined để xoá) và lên lịch
52
+ * đồng bộ. Giá trị của Question đang tắt vẫn được giữ — chỉ khi hoàn thành
53
+ * chúng mới bị loại khỏi kết quả chính thức.
54
+ */
55
+ setValue(slug: string, value: JsonValue | null | undefined): void;
56
+ /** Trạng thái hoạt động của các Question theo Enable Condition. */
57
+ activeState(): ActiveState;
58
+ isActive(slug: string): boolean;
59
+ /** Validate cục bộ giống server, dùng để phản hồi trước khi gửi. */
60
+ validate(): CompletionResult;
61
+ /** Đẩy ngay mọi thay đổi đang chờ lên server. */
62
+ flush(): Promise<void>;
63
+ /**
64
+ * Hoàn thành/xác nhận Response: đồng bộ nốt thay đổi rồi gọi server.
65
+ * Server validate độc lập; lỗi validation ném VgqApiError với `issues`.
66
+ */
67
+ complete(): Promise<ResponseDto>;
68
+ /** Hủy timer đồng bộ (khi rời màn hình). Thay đổi chưa đẩy vẫn giữ trong bộ nhớ. */
69
+ dispose(): void;
70
+ private schedule;
71
+ private push;
72
+ }
@@ -0,0 +1,156 @@
1
+ import { computeActive, validateCompletion } from './engine.js';
2
+ import { allQuestions } from './engine.js';
3
+ /**
4
+ * Quản lý trạng thái một Response phía client: giữ giá trị cục bộ, đánh giá
5
+ * Question nào đang hoạt động, validate như server và đồng bộ Draft theo lô
6
+ * (debounce) để mạng chập chờn không tạo Response trùng hay mất dữ liệu.
7
+ */
8
+ export class ResponseSession {
9
+ definition;
10
+ responseId;
11
+ formVersionId;
12
+ client;
13
+ options;
14
+ questionBySlug;
15
+ values;
16
+ lastServerState;
17
+ dirty = new Set();
18
+ timer = null;
19
+ inflight = Promise.resolve();
20
+ constructor(client, definition, response, options) {
21
+ this.client = client;
22
+ this.definition = definition;
23
+ this.responseId = response.id;
24
+ this.formVersionId = response.form_version_id;
25
+ this.options = options;
26
+ this.values = { ...response.draft_values };
27
+ this.lastServerState = response;
28
+ this.questionBySlug = new Map(allQuestions(definition).map((q) => [q.slug, q]));
29
+ }
30
+ /** Bắt đầu Response mới qua Publication hoặc Invitation. */
31
+ static async start(client, start, options = {}) {
32
+ let response;
33
+ if (start.invitationId) {
34
+ response = await client.startInvitationResponse(start.invitationId);
35
+ }
36
+ else if (start.publicationKey) {
37
+ response = await client.createResponse(start.publicationKey, {
38
+ subjectRef: start.subjectRef,
39
+ respondentRef: start.respondentRef,
40
+ careContextRef: start.careContextRef,
41
+ idempotencyKey: start.idempotencyKey,
42
+ });
43
+ }
44
+ else {
45
+ throw new Error('Cần publicationKey hoặc invitationId để bắt đầu Response');
46
+ }
47
+ // Định nghĩa lấy theo đúng Form Version mà Response đã ghim, không phải
48
+ // version mới nhất của Publication (Draft không đổi Form giữa chừng).
49
+ const version = await client.getFormVersion(response.form_version_id);
50
+ return new ResponseSession(client, version.definition, response, options);
51
+ }
52
+ /** Mở lại Draft đã có để tiếp tục nhập. */
53
+ static async resume(client, responseId, options = {}) {
54
+ const response = await client.getResponse(responseId);
55
+ const version = await client.getFormVersion(response.form_version_id);
56
+ return new ResponseSession(client, version.definition, response, options);
57
+ }
58
+ get response() {
59
+ return this.lastServerState;
60
+ }
61
+ /** Ảnh chụp giá trị Draft cục bộ hiện tại. */
62
+ get draftValues() {
63
+ return { ...this.values };
64
+ }
65
+ getValue(slug) {
66
+ return this.values[slug];
67
+ }
68
+ /**
69
+ * Đặt giá trị cho một Question theo slug (null/undefined để xoá) và lên lịch
70
+ * đồng bộ. Giá trị của Question đang tắt vẫn được giữ — chỉ khi hoàn thành
71
+ * chúng mới bị loại khỏi kết quả chính thức.
72
+ */
73
+ setValue(slug, value) {
74
+ if (!this.questionBySlug.has(slug)) {
75
+ throw new Error(`Không có Question nào mang slug '${slug}' trong Form Version này`);
76
+ }
77
+ if (value === null || value === undefined)
78
+ delete this.values[slug];
79
+ else
80
+ this.values[slug] = value;
81
+ this.dirty.add(slug);
82
+ this.schedule();
83
+ }
84
+ /** Trạng thái hoạt động của các Question theo Enable Condition. */
85
+ activeState() {
86
+ return computeActive(this.definition, this.values);
87
+ }
88
+ isActive(slug) {
89
+ const question = this.questionBySlug.get(slug);
90
+ return question ? this.activeState().activeIds.has(question.id) : false;
91
+ }
92
+ /** Validate cục bộ giống server, dùng để phản hồi trước khi gửi. */
93
+ validate() {
94
+ return validateCompletion(this.definition, this.values);
95
+ }
96
+ /** Đẩy ngay mọi thay đổi đang chờ lên server. */
97
+ flush() {
98
+ if (this.timer) {
99
+ clearTimeout(this.timer);
100
+ this.timer = null;
101
+ }
102
+ const run = this.inflight.then(() => this.push());
103
+ // Chuỗi inflight không được giữ trạng thái rejected, nếu không các lần
104
+ // flush sau sẽ không bao giờ chạy; lỗi vẫn trả về cho caller qua `run`.
105
+ this.inflight = run.catch(() => { });
106
+ return run;
107
+ }
108
+ /**
109
+ * Hoàn thành/xác nhận Response: đồng bộ nốt thay đổi rồi gọi server.
110
+ * Server validate độc lập; lỗi validation ném VgqApiError với `issues`.
111
+ */
112
+ async complete() {
113
+ await this.flush();
114
+ const response = await this.client.completeResponse(this.responseId);
115
+ this.lastServerState = response;
116
+ return response;
117
+ }
118
+ /** Hủy timer đồng bộ (khi rời màn hình). Thay đổi chưa đẩy vẫn giữ trong bộ nhớ. */
119
+ dispose() {
120
+ if (this.timer) {
121
+ clearTimeout(this.timer);
122
+ this.timer = null;
123
+ }
124
+ }
125
+ schedule() {
126
+ if (this.timer)
127
+ clearTimeout(this.timer);
128
+ this.timer = setTimeout(() => {
129
+ this.timer = null;
130
+ // Lỗi đã được báo qua onSyncError; thay đổi được giữ lại để thử lại.
131
+ this.flush().catch(() => { });
132
+ }, this.options.debounceMs ?? 800);
133
+ }
134
+ async push() {
135
+ if (this.dirty.size === 0)
136
+ return;
137
+ const batch = {};
138
+ for (const slug of this.dirty) {
139
+ batch[slug] = this.values[slug] ?? null;
140
+ }
141
+ this.dirty.clear();
142
+ try {
143
+ const response = await this.client.updateValues(this.responseId, batch);
144
+ this.lastServerState = response;
145
+ this.options.onSync?.(response);
146
+ }
147
+ catch (error) {
148
+ // Giữ lại thay đổi để lần flush sau thử lại (PUT merge idempotent nên
149
+ // gửi trùng vô hại).
150
+ for (const slug of Object.keys(batch))
151
+ this.dirty.add(slug);
152
+ this.options.onSyncError?.(error);
153
+ throw error;
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,141 @@
1
+ export type JsonValue = null | boolean | number | string | JsonValue[] | {
2
+ [key: string]: JsonValue;
3
+ };
4
+ export type FormType = 'bang_khai_bao' | 'phieu_ket_qua_chuyen_mon';
5
+ export type DataType = {
6
+ type: 'boolean';
7
+ } | {
8
+ type: 'text';
9
+ } | {
10
+ type: 'long_text';
11
+ } | {
12
+ type: 'integer';
13
+ } | {
14
+ type: 'decimal';
15
+ } | {
16
+ type: 'date';
17
+ } | {
18
+ type: 'date_time';
19
+ } | {
20
+ type: 'choice';
21
+ multiple?: boolean;
22
+ } | {
23
+ type: 'measurement';
24
+ unit: string;
25
+ } | {
26
+ type: 'attachment';
27
+ } | {
28
+ type: 'body_map';
29
+ background_asset_id: string;
30
+ multiple?: boolean;
31
+ };
32
+ export type Operator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'answered' | 'not_answered';
33
+ export interface Condition {
34
+ question_id: string;
35
+ operator: Operator;
36
+ value?: JsonValue;
37
+ }
38
+ export interface ConditionGroup {
39
+ all: Condition[];
40
+ }
41
+ /** Các nhóm điều kiện ALL, kết hợp với nhau bằng ANY. */
42
+ export interface EnableCondition {
43
+ any: ConditionGroup[];
44
+ }
45
+ /**
46
+ * Vị trí do người thiết kế đặt sẵn trên Body Map. Toạ độ chuẩn hoá 0..1 theo
47
+ * chiều rộng/cao của ảnh nền; `region` rỗng/vắng mặt nghĩa là Option dạng điểm.
48
+ */
49
+ export interface BodyMapSpot {
50
+ x: number;
51
+ y: number;
52
+ region?: [number, number][];
53
+ color?: string;
54
+ /** Metadata lâm sàng tuỳ ý; VGQ không diễn giải. */
55
+ metadata?: JsonValue;
56
+ }
57
+ export interface OptionDef {
58
+ id: string;
59
+ slug: string;
60
+ label: string;
61
+ /** Chỉ có mặt khi Question có data type body_map. */
62
+ body_map?: BodyMapSpot | null;
63
+ }
64
+ export interface MediaAssetDto {
65
+ id: string;
66
+ file_name: string;
67
+ content_type: string;
68
+ kind: 'image' | 'video' | 'audio' | 'document';
69
+ size_bytes: number;
70
+ uploaded_by: string;
71
+ created_at: string;
72
+ /** Đường dẫn tương đối ổn định tới nội dung asset. */
73
+ url: string;
74
+ }
75
+ export interface QuestionDef {
76
+ id: string;
77
+ slug: string;
78
+ label: string;
79
+ data_type: DataType;
80
+ required?: boolean;
81
+ options: OptionDef[];
82
+ enable_condition?: EnableCondition | null;
83
+ display?: JsonValue;
84
+ }
85
+ /** Cấp tổ chức duy nhất trong Form: Category lồng nhau và chứa Question. */
86
+ export interface CategoryDef {
87
+ id: string;
88
+ label: string;
89
+ children?: CategoryDef[];
90
+ questions: QuestionDef[];
91
+ }
92
+ export interface FormDefinition {
93
+ title: string;
94
+ form_type: FormType;
95
+ categories: CategoryDef[];
96
+ }
97
+ export interface ValidationIssue {
98
+ path: string;
99
+ code: string;
100
+ message: string;
101
+ }
102
+ export type ResponseValues = Record<string, JsonValue>;
103
+ export type ResponseStatus = 'draft' | 'completed' | 'confirmed';
104
+ export interface ResponseDto {
105
+ id: string;
106
+ form_version_id: string;
107
+ status: ResponseStatus;
108
+ subject_ref: string | null;
109
+ respondent_ref: string | null;
110
+ care_context_ref: string | null;
111
+ draft_values: ResponseValues;
112
+ official_values: ResponseValues | null;
113
+ created_at: string;
114
+ updated_at: string;
115
+ completed_at: string | null;
116
+ completed_by: string | null;
117
+ }
118
+ export interface PublicationForm {
119
+ publication_key: string;
120
+ form_version_id: string;
121
+ version_number: number;
122
+ definition: FormDefinition;
123
+ }
124
+ export interface FormVersion {
125
+ id: string;
126
+ form_id: string;
127
+ version_number: number;
128
+ definition: FormDefinition;
129
+ published_by: string;
130
+ published_at: string;
131
+ }
132
+ export interface InvitationDto {
133
+ id: string;
134
+ publication_key: string;
135
+ subject_ref: string | null;
136
+ respondent_ref: string | null;
137
+ care_context_ref: string | null;
138
+ response_id: string | null;
139
+ created_by: string;
140
+ created_at: string;
141
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@viengut/vgq-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Headless TypeScript SDK for the VGQ clinical form platform: load published Forms, manage Response state, evaluate Enable Conditions and validate Response Values without any UI components.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/viengut-hospital/vgq-questionnaire.git",
8
+ "directory": "sdk"
9
+ },
10
+ "license": "UNLICENSED",
11
+ "publishConfig": {
12
+ "registry": "https://registry.npmjs.org",
13
+ "access": "public"
14
+ },
15
+ "type": "module",
16
+ "main": "./dist/index.js",
17
+ "types": "./dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "scripts": {
28
+ "build": "tsc",
29
+ "prepack": "npm run build",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "vitest run"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.5.0",
35
+ "vitest": "^3.0.0"
36
+ }
37
+ }