Commit 8a959f9b by krds-arun

feat(backend): submissions module + scope filter + compression

- Submissions module (routes/repository/service/types/domain) with
  draft → submitted state machine, transition endpoint, Azure SAS
  file attachment via blob-storage service, conduct score, and
  org-scope filters (orgId/companyId/countryId/entityId/buId/deptId/areaId).
- Forms: GET /api/forms/by-scope alias + scope filter on listForms
  (empty-array OR id-in-array semantics); GET /api/forms/managers
  joining auth.users + user_org_assignment with active-user fallback.
- Form settings: persist frequencyCycle, visibility, country/sub/
  company/entity/BU scope arrays, role arrays via buildSettings
  assignments. Checksum no-op skips full survey_json TOAST rewrite
  on draft PATCH when checksum matches.
- Performance: register @fastify/compress (br/gzip/deflate, 1 KiB
  threshold) — large surveyJson GETs drop 85–98% on the wire. Body
  limit bumped to 16 MB so PATCH/POST of full form schemas no longer
  trips 413 (which the browser was surfacing as CORS errors).
- CORS: explicit allowedHeaders (incl. if-match/etag, x-tenant-id,
  x-confirm-delete-all), expose etag, credentials, 10 min preflight
  cache.
- Migrations 004_form_settings + 005_seed_test_forms and a
  scripts/migrate-webforms helper for legacy form import.
parent 6bad9034
...@@ -3,3 +3,14 @@ POSTGRES_URL=postgres://postgres:password@localhost:5432/maf-gateway ...@@ -3,3 +3,14 @@ POSTGRES_URL=postgres://postgres:password@localhost:5432/maf-gateway
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
CACHE_TTL_SCHEMA_SECONDS=300 CACHE_TTL_SCHEMA_SECONDS=300
CACHE_TTL_MASTER_SECONDS=180 CACHE_TTL_MASTER_SECONDS=180
# Azure Blob Storage — Shared Key auth
AZURE_ACCOUNT_NAME=<storage-account-name>
AZURE_ACCOUNT_KEY=<base64-storage-account-key>
AZURE_BASE_URL=https://<account>.blob.core.windows.net
AZURE_CONTAINER_NAME=<container-name>
AZURE_CDN_BASE_URL=
# SAS lifetimes (minutes)
AZURE_UPLOAD_SAS_TTL_MIN=15
AZURE_DOWNLOAD_SAS_TTL_MIN=60
-- 004_form_settings.sql
-- Adds form-level settings: scheduling, visibility, multi-scope, and roles.
-- All new columns are nullable / have safe defaults so the migration is backfill-free.
BEGIN;
-- 1. Cadence / lifecycle / visibility ────────────────────────────────────────
ALTER TABLE forms.form
ADD COLUMN IF NOT EXISTS frequency_cycle TEXT,
ADD COLUMN IF NOT EXISTS is_internal BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS internal_status TEXT,
ADD COLUMN IF NOT EXISTS visibility TEXT NOT NULL DEFAULT 'visible';
ALTER TABLE forms.form
ADD CONSTRAINT form_frequency_chk
CHECK (frequency_cycle IS NULL OR frequency_cycle IN
('adhoc','daily','weekly','biweekly','monthly','quarterly','semiannual','yearly'));
ALTER TABLE forms.form
ADD CONSTRAINT form_visibility_chk
CHECK (visibility IN ('visible','hidden'));
-- 2. Multi-scope (master refs) ───────────────────────────────────────────────
-- Stored as uuid[] for simple list semantics. FK integrity is enforced at the
-- application layer (catalogs are stable; arrays keep the model flat). If
-- referential integrity becomes load-bearing we can promote to join tables.
ALTER TABLE forms.form
ADD COLUMN IF NOT EXISTS country_ids UUID[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS subsidiary_ids UUID[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS company_ids UUID[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS entity_ids UUID[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS bu_ids UUID[] NOT NULL DEFAULT '{}';
-- 3. Role assignments (auth.roles refs) ──────────────────────────────────────
ALTER TABLE forms.form
ADD COLUMN IF NOT EXISTS technical_role_ids UUID[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS operational_role_ids UUID[] NOT NULL DEFAULT '{}',
ADD COLUMN IF NOT EXISTS notification_role_ids UUID[] NOT NULL DEFAULT '{}';
-- 4. GIN indexes for fast "forms-visible-to-this-user" lookups ───────────────
CREATE INDEX IF NOT EXISTS idx_form_country_ids ON forms.form USING GIN (country_ids);
CREATE INDEX IF NOT EXISTS idx_form_bu_ids ON forms.form USING GIN (bu_ids);
CREATE INDEX IF NOT EXISTS idx_form_entity_ids ON forms.form USING GIN (entity_ids);
CREATE INDEX IF NOT EXISTS idx_form_tech_roles ON forms.form USING GIN (technical_role_ids);
CREATE INDEX IF NOT EXISTS idx_form_ops_roles ON forms.form USING GIN (operational_role_ids);
CREATE INDEX IF NOT EXISTS idx_form_notif_roles ON forms.form USING GIN (notification_role_ids);
CREATE INDEX IF NOT EXISTS idx_form_visibility ON forms.form (visibility) WHERE deleted_at IS NULL;
-- 5. Documentation ───────────────────────────────────────────────────────────
COMMENT ON COLUMN forms.form.frequency_cycle IS 'Audit/inspection cadence: adhoc|daily|weekly|biweekly|monthly|quarterly|semiannual|yearly';
COMMENT ON COLUMN forms.form.is_internal IS 'Internal-only form (not visible to external auditors)';
COMMENT ON COLUMN forms.form.internal_status IS 'Workflow status for internal forms (draft|active|retired|...) — free-form text';
COMMENT ON COLUMN forms.form.visibility IS 'visible|hidden — toggles the form in user-facing listings';
COMMENT ON COLUMN forms.form.country_ids IS 'master.mst_country.id[] — countries the form applies to';
COMMENT ON COLUMN forms.form.subsidiary_ids IS 'master.mst_operating_subsidiaries.id[]';
COMMENT ON COLUMN forms.form.company_ids IS 'master.mst_company.id[]';
COMMENT ON COLUMN forms.form.entity_ids IS 'master.mst_entity.id[]';
COMMENT ON COLUMN forms.form.bu_ids IS 'master.mst_business_unit.id[]';
COMMENT ON COLUMN forms.form.technical_role_ids IS 'auth.roles.id[] — technical checklist roles';
COMMENT ON COLUMN forms.form.operational_role_ids IS 'auth.roles.id[] — operational checklist roles';
COMMENT ON COLUMN forms.form.notification_role_ids IS 'auth.roles.id[] — roles to notify on submission events';
COMMIT;
-- 005_seed_test_forms.sql
-- Seed three published test forms (audit / inspection / checklist) so the
-- conduct flow has real content to load end-to-end.
-- Idempotent: re-running updates surveyJson in place.
BEGIN;
-- ── Helper: upsert a published form ─────────────────────────────────────────
--
-- Inserts a `forms.form` row + a published `forms.form_version` and points
-- `current_version_id` at it. Re-running replaces the existing surveyJson on
-- the published version.
DO $seed$
DECLARE
v_audit_module_id uuid := (SELECT id FROM platform.module WHERE code = 'audit' LIMIT 1);
v_inspect_module_id uuid := (SELECT id FROM platform.module WHERE code = 'inspection' LIMIT 1);
v_checklist_module_id uuid := (SELECT id FROM platform.module WHERE code = 'checklist' LIMIT 1);
v_form_id uuid;
v_ver_id uuid;
BEGIN
IF v_audit_module_id IS NULL OR v_inspect_module_id IS NULL OR v_checklist_module_id IS NULL THEN
RAISE EXCEPTION 'platform.module rows for audit/inspection/checklist must exist before seeding';
END IF;
-- ─── 1. AUDIT TEST FORM ───────────────────────────────────────────────────
INSERT INTO forms.form (module_id, code, display_name, description, is_active, visibility)
VALUES (
v_audit_module_id,
'test_audit_form',
'Test Audit Form',
'Generic store audit covering housekeeping, safety, and customer service.',
TRUE,
'visible'
)
ON CONFLICT (code) DO UPDATE
SET display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
is_active = TRUE,
visibility = 'visible',
deleted_at = NULL,
updated_at = NOW()
RETURNING id INTO v_form_id;
-- Upsert published version
INSERT INTO forms.form_version (form_id, semver, schema_checksum, survey_json, is_published, published_at)
VALUES (
v_form_id,
'1.0.0',
'seed-audit-v1',
$json$
{
"title": "Test Audit Form",
"moduleCode": "audit",
"enableConductScore": true,
"showProgressBar": "top",
"pages": [
{
"name": "general",
"title": "General observations",
"elements": [
{
"type": "text",
"name": "siteName",
"title": "Site / store name",
"isRequired": true
},
{
"type": "text",
"name": "auditedBy",
"title": "Audited by",
"isRequired": true
},
{
"type": "radiogroup",
"name": "housekeeping",
"title": "Housekeeping standard",
"isRequired": true,
"choices": [
{ "value": "excellent", "text": "Excellent", "conductScore": 10 },
{ "value": "ok", "text": "Acceptable", "conductScore": 5 },
{ "value": "poor", "text": "Poor", "conductScore": 0 }
]
},
{
"type": "radiogroup",
"name": "safety",
"title": "Safety controls in place",
"isRequired": true,
"choices": [
{ "value": "yes", "text": "Yes", "conductScore": 10 },
{ "value": "partial", "text": "Partial", "conductScore": 5 },
{ "value": "no", "text": "No", "conductScore": 0 }
]
}
]
},
{
"name": "evidence",
"title": "Evidence",
"elements": [
{
"type": "comment",
"name": "observations",
"title": "Observations / findings"
},
{
"type": "file",
"name": "evidenceFiles",
"title": "Upload photos / evidence (optional)",
"allowMultiple": true,
"maxSize": 10485760,
"storeDataAsText": false
}
]
}
]
}
$json$::jsonb,
TRUE,
NOW()
)
ON CONFLICT (form_id, semver) DO UPDATE
SET survey_json = EXCLUDED.survey_json,
schema_checksum = EXCLUDED.schema_checksum,
is_published = TRUE,
published_at = COALESCE(forms.form_version.published_at, NOW())
RETURNING id INTO v_ver_id;
UPDATE forms.form SET current_version_id = v_ver_id, updated_at = NOW() WHERE id = v_form_id;
-- ─── 2. INSPECTION TEST FORM ─────────────────────────────────────────────
INSERT INTO forms.form (module_id, code, display_name, description, is_active, visibility)
VALUES (
v_inspect_module_id,
'test_inspection_form',
'Test Inspection Form',
'Equipment and safety inspection — checks critical assets and signage.',
TRUE,
'visible'
)
ON CONFLICT (code) DO UPDATE
SET display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
is_active = TRUE,
visibility = 'visible',
deleted_at = NULL,
updated_at = NOW()
RETURNING id INTO v_form_id;
INSERT INTO forms.form_version (form_id, semver, schema_checksum, survey_json, is_published, published_at)
VALUES (
v_form_id,
'1.0.0',
'seed-inspection-v1',
$json$
{
"title": "Test Inspection Form",
"moduleCode": "inspection",
"enableConductScore": true,
"showProgressBar": "top",
"pages": [
{
"name": "equipment",
"title": "Equipment checks",
"elements": [
{
"type": "text",
"name": "assetTag",
"title": "Asset tag / identifier",
"isRequired": true
},
{
"type": "radiogroup",
"name": "extinguisher",
"title": "Fire extinguisher pressure within range",
"isRequired": true,
"choices": [
{ "value": "yes", "text": "Yes", "conductScore": 10 },
{ "value": "no", "text": "No / NA", "conductScore": 0 }
]
},
{
"type": "radiogroup",
"name": "exitSign",
"title": "Emergency exit signage visible",
"isRequired": true,
"choices": [
{ "value": "yes", "text": "Yes", "conductScore": 10 },
{ "value": "no", "text": "No", "conductScore": 0 }
]
},
{
"type": "rating",
"name": "overallCondition",
"title": "Overall condition",
"rateMin": 1,
"rateMax": 5
}
]
},
{
"name": "photos",
"title": "Attach photos",
"elements": [
{
"type": "comment",
"name": "notes",
"title": "Inspector notes"
},
{
"type": "file",
"name": "photos",
"title": "Photos of issues (optional)",
"allowMultiple": true,
"maxSize": 10485760,
"storeDataAsText": false
}
]
}
]
}
$json$::jsonb,
TRUE,
NOW()
)
ON CONFLICT (form_id, semver) DO UPDATE
SET survey_json = EXCLUDED.survey_json,
schema_checksum = EXCLUDED.schema_checksum,
is_published = TRUE,
published_at = COALESCE(forms.form_version.published_at, NOW())
RETURNING id INTO v_ver_id;
UPDATE forms.form SET current_version_id = v_ver_id, updated_at = NOW() WHERE id = v_form_id;
-- ─── 3. CHECKLIST TEST FORM ──────────────────────────────────────────────
INSERT INTO forms.form (module_id, code, display_name, description, is_active, visibility)
VALUES (
v_checklist_module_id,
'test_checklist_form',
'Test Checklist Form',
'Daily opening checklist — staff sign-off on operational readiness.',
TRUE,
'visible'
)
ON CONFLICT (code) DO UPDATE
SET display_name = EXCLUDED.display_name,
description = EXCLUDED.description,
is_active = TRUE,
visibility = 'visible',
deleted_at = NULL,
updated_at = NOW()
RETURNING id INTO v_form_id;
INSERT INTO forms.form_version (form_id, semver, schema_checksum, survey_json, is_published, published_at)
VALUES (
v_form_id,
'1.0.0',
'seed-checklist-v1',
$json$
{
"title": "Test Checklist Form",
"moduleCode": "checklist",
"enableConductScore": false,
"showProgressBar": "top",
"pages": [
{
"name": "opening",
"title": "Opening checklist",
"elements": [
{
"type": "checkbox",
"name": "openingTasks",
"title": "Confirm completed tasks",
"isRequired": true,
"choices": [
"Doors unlocked & alarms reset",
"Lights and HVAC turned on",
"POS terminals booted",
"Floor cleaned and dry",
"Visual merchandising aligned",
"Daily safety briefing held"
]
},
{
"type": "text",
"name": "onDutyManager",
"title": "On-duty manager",
"isRequired": true
},
{
"type": "comment",
"name": "issues",
"title": "Issues to escalate"
},
{
"type": "file",
"name": "openingPhotos",
"title": "Photos of any issues (optional)",
"allowMultiple": true,
"maxSize": 10485760,
"storeDataAsText": false
}
]
}
]
}
$json$::jsonb,
TRUE,
NOW()
)
ON CONFLICT (form_id, semver) DO UPDATE
SET survey_json = EXCLUDED.survey_json,
schema_checksum = EXCLUDED.schema_checksum,
is_published = TRUE,
published_at = COALESCE(forms.form_version.published_at, NOW())
RETURNING id INTO v_ver_id;
UPDATE forms.form SET current_version_id = v_ver_id, updated_at = NOW() WHERE id = v_form_id;
END;
$seed$;
COMMIT;
...@@ -10,6 +10,7 @@ ...@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@azure/storage-blob": "^12.31.0", "@azure/storage-blob": "^12.31.0",
"@fastify/compress": "^8.3.1",
"@fastify/cors": "^11.2.0", "@fastify/cors": "^11.2.0",
"@fastify/multipart": "^10.0.0", "@fastify/multipart": "^10.0.0",
"fastify": "^5.6.1", "fastify": "^5.6.1",
...@@ -18,8 +19,10 @@ ...@@ -18,8 +19,10 @@
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"js-yaml": "^4.1.1",
"tsx": "^4.20.6", "tsx": "^4.20.6",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
......
...@@ -11,6 +11,9 @@ importers: ...@@ -11,6 +11,9 @@ importers:
'@azure/storage-blob': '@azure/storage-blob':
specifier: ^12.31.0 specifier: ^12.31.0
version: 12.31.0 version: 12.31.0
'@fastify/compress':
specifier: ^8.3.1
version: 8.3.1
'@fastify/cors': '@fastify/cors':
specifier: ^11.2.0 specifier: ^11.2.0
version: 11.2.0 version: 11.2.0
...@@ -30,12 +33,18 @@ importers: ...@@ -30,12 +33,18 @@ importers:
specifier: ^4.1.12 specifier: ^4.1.12
version: 4.4.3 version: 4.4.3
devDependencies: devDependencies:
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/node': '@types/node':
specifier: ^24.10.0 specifier: ^24.10.0
version: 24.12.2 version: 24.12.2
'@types/pg': '@types/pg':
specifier: ^8.20.0 specifier: ^8.20.0
version: 8.20.0 version: 8.20.0
js-yaml:
specifier: ^4.1.1
version: 4.1.1
tsx: tsx:
specifier: ^4.20.6 specifier: ^4.20.6
version: 4.21.0 version: 4.21.0
...@@ -256,12 +265,18 @@ packages: ...@@ -256,12 +265,18 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@fastify/accept-negotiator@2.0.1':
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
'@fastify/ajv-compiler@4.0.5': '@fastify/ajv-compiler@4.0.5':
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
'@fastify/busboy@3.2.0': '@fastify/busboy@3.2.0':
resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==}
'@fastify/compress@8.3.1':
resolution: {integrity: sha512-BUpItLr6MUX9e9ukg5Y6xekyA/7pBFG8QWtFCrUDm9ctoBc3R2/nA16yOaOWtVoccpXGjdDEYA/MxAb5+8cxag==}
'@fastify/cors@11.2.0': '@fastify/cors@11.2.0':
resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==} resolution: {integrity: sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==}
...@@ -295,6 +310,9 @@ packages: ...@@ -295,6 +310,9 @@ packages:
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/node@24.12.2': '@types/node@24.12.2':
resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==} resolution: {integrity: sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==}
...@@ -305,6 +323,10 @@ packages: ...@@ -305,6 +323,10 @@ packages:
resolution: {integrity: sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==} resolution: {integrity: sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
abstract-logging@2.0.1: abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
...@@ -323,6 +345,9 @@ packages: ...@@ -323,6 +345,9 @@ packages:
ajv@8.20.0: ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
atomic-sleep@1.0.0: atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
...@@ -330,6 +355,15 @@ packages: ...@@ -330,6 +355,15 @@ packages:
avvio@9.2.0: avvio@9.2.0:
resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==} resolution: {integrity: sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
cluster-key-slot@1.1.2: cluster-key-slot@1.1.2:
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
...@@ -338,6 +372,9 @@ packages: ...@@ -338,6 +372,9 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
...@@ -355,11 +392,24 @@ packages: ...@@ -355,11 +392,24 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'} engines: {node: '>=6'}
duplexify@3.7.1:
resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==}
duplexify@4.1.3:
resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
esbuild@0.27.7: esbuild@0.27.7:
resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
events@3.3.0: events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'} engines: {node: '>=0.8.x'}
...@@ -415,6 +465,12 @@ packages: ...@@ -415,6 +465,12 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ioredis@5.10.1: ioredis@5.10.1:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'} engines: {node: '>=12.22.0'}
...@@ -423,6 +479,13 @@ packages: ...@@ -423,6 +479,13 @@ packages:
resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==} resolution: {integrity: sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
json-schema-ref-resolver@3.0.0: json-schema-ref-resolver@3.0.0:
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
...@@ -438,6 +501,14 @@ packages: ...@@ -438,6 +501,14 @@ packages:
lodash.isarguments@3.1.0: lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
...@@ -445,10 +516,16 @@ packages: ...@@ -445,10 +516,16 @@ packages:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
path-expression-matcher@1.5.0: path-expression-matcher@1.5.0:
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
peek-stream@1.1.3:
resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==}
pg-cloudflare@1.3.0: pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
...@@ -509,15 +586,39 @@ packages: ...@@ -509,15 +586,39 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
process-warning@4.0.1: process-warning@4.0.1:
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==} resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
process-warning@5.0.0: process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
pumpify@2.0.1:
resolution: {integrity: sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==}
quick-format-unescaped@4.0.4: quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
readable-stream@2.3.8:
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readable-stream@4.7.0:
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
real-require@0.2.0: real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'} engines: {node: '>= 12.13.0'}
...@@ -548,6 +649,12 @@ packages: ...@@ -548,6 +649,12 @@ packages:
rfdc@1.4.1: rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-regex2@5.1.1: safe-regex2@5.1.1:
resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==} resolution: {integrity: sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==}
hasBin: true hasBin: true
...@@ -577,6 +684,15 @@ packages: ...@@ -577,6 +684,15 @@ packages:
standard-as-callback@2.1.0: standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
stream-shift@1.0.3:
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
string_decoder@1.1.1:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strnum@2.2.3: strnum@2.2.3:
resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==} resolution: {integrity: sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==}
...@@ -584,6 +700,9 @@ packages: ...@@ -584,6 +700,9 @@ packages:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'} engines: {node: '>=20'}
through2@2.0.5:
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
toad-cache@3.7.0: toad-cache@3.7.0:
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
engines: {node: '>=12'} engines: {node: '>=12'}
...@@ -604,6 +723,12 @@ packages: ...@@ -604,6 +723,12 @@ packages:
undici-types@7.16.0: undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xtend@4.0.2: xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
...@@ -804,6 +929,8 @@ snapshots: ...@@ -804,6 +929,8 @@ snapshots:
'@esbuild/win32-x64@0.27.7': '@esbuild/win32-x64@0.27.7':
optional: true optional: true
'@fastify/accept-negotiator@2.0.1': {}
'@fastify/ajv-compiler@4.0.5': '@fastify/ajv-compiler@4.0.5':
dependencies: dependencies:
ajv: 8.20.0 ajv: 8.20.0
...@@ -812,6 +939,17 @@ snapshots: ...@@ -812,6 +939,17 @@ snapshots:
'@fastify/busboy@3.2.0': {} '@fastify/busboy@3.2.0': {}
'@fastify/compress@8.3.1':
dependencies:
'@fastify/accept-negotiator': 2.0.1
fastify-plugin: 5.1.0
mime-db: 1.54.0
minipass: 7.1.3
peek-stream: 1.1.3
pump: 3.0.4
pumpify: 2.0.1
readable-stream: 4.7.0
'@fastify/cors@11.2.0': '@fastify/cors@11.2.0':
dependencies: dependencies:
fastify-plugin: 5.1.0 fastify-plugin: 5.1.0
...@@ -850,6 +988,8 @@ snapshots: ...@@ -850,6 +988,8 @@ snapshots:
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@types/js-yaml@4.0.9': {}
'@types/node@24.12.2': '@types/node@24.12.2':
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 7.16.0
...@@ -868,6 +1008,10 @@ snapshots: ...@@ -868,6 +1008,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
abstract-logging@2.0.1: {} abstract-logging@2.0.1: {}
agent-base@7.1.4: {} agent-base@7.1.4: {}
...@@ -883,6 +1027,8 @@ snapshots: ...@@ -883,6 +1027,8 @@ snapshots:
json-schema-traverse: 1.0.0 json-schema-traverse: 1.0.0
require-from-string: 2.0.2 require-from-string: 2.0.2
argparse@2.0.1: {}
atomic-sleep@1.0.0: {} atomic-sleep@1.0.0: {}
avvio@9.2.0: avvio@9.2.0:
...@@ -890,10 +1036,21 @@ snapshots: ...@@ -890,10 +1036,21 @@ snapshots:
'@fastify/error': 4.2.0 '@fastify/error': 4.2.0
fastq: 1.20.1 fastq: 1.20.1
base64-js@1.5.1: {}
buffer-from@1.1.2: {}
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
cluster-key-slot@1.1.2: {} cluster-key-slot@1.1.2: {}
cookie@1.1.1: {} cookie@1.1.1: {}
core-util-is@1.0.3: {}
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
...@@ -902,6 +1059,24 @@ snapshots: ...@@ -902,6 +1059,24 @@ snapshots:
dequal@2.0.3: {} dequal@2.0.3: {}
duplexify@3.7.1:
dependencies:
end-of-stream: 1.4.5
inherits: 2.0.4
readable-stream: 2.3.8
stream-shift: 1.0.3
duplexify@4.1.3:
dependencies:
end-of-stream: 1.4.5
inherits: 2.0.4
readable-stream: 3.6.2
stream-shift: 1.0.3
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
esbuild@0.27.7: esbuild@0.27.7:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.27.7 '@esbuild/aix-ppc64': 0.27.7
...@@ -931,6 +1106,8 @@ snapshots: ...@@ -931,6 +1106,8 @@ snapshots:
'@esbuild/win32-ia32': 0.27.7 '@esbuild/win32-ia32': 0.27.7
'@esbuild/win32-x64': 0.27.7 '@esbuild/win32-x64': 0.27.7
event-target-shim@5.0.1: {}
events@3.3.0: {} events@3.3.0: {}
fast-decode-uri-component@1.0.1: {} fast-decode-uri-component@1.0.1: {}
...@@ -1014,6 +1191,10 @@ snapshots: ...@@ -1014,6 +1191,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
ieee754@1.2.1: {}
inherits@2.0.4: {}
ioredis@5.10.1: ioredis@5.10.1:
dependencies: dependencies:
'@ioredis/commands': 1.5.1 '@ioredis/commands': 1.5.1
...@@ -1030,6 +1211,12 @@ snapshots: ...@@ -1030,6 +1211,12 @@ snapshots:
ipaddr.js@2.4.0: {} ipaddr.js@2.4.0: {}
isarray@1.0.0: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
json-schema-ref-resolver@3.0.0: json-schema-ref-resolver@3.0.0:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
...@@ -1046,12 +1233,26 @@ snapshots: ...@@ -1046,12 +1233,26 @@ snapshots:
lodash.isarguments@3.1.0: {} lodash.isarguments@3.1.0: {}
mime-db@1.54.0: {}
minipass@7.1.3: {}
ms@2.1.3: {} ms@2.1.3: {}
on-exit-leak-free@2.1.2: {} on-exit-leak-free@2.1.2: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
path-expression-matcher@1.5.0: {} path-expression-matcher@1.5.0: {}
peek-stream@1.1.3:
dependencies:
buffer-from: 1.1.2
duplexify: 3.7.1
through2: 2.0.5
pg-cloudflare@1.3.0: pg-cloudflare@1.3.0:
optional: true optional: true
...@@ -1117,12 +1318,51 @@ snapshots: ...@@ -1117,12 +1318,51 @@ snapshots:
dependencies: dependencies:
xtend: 4.0.2 xtend: 4.0.2
process-nextick-args@2.0.1: {}
process-warning@4.0.1: {} process-warning@4.0.1: {}
process-warning@5.0.0: {} process-warning@5.0.0: {}
process@0.11.10: {}
pump@3.0.4:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
pumpify@2.0.1:
dependencies:
duplexify: 4.1.3
inherits: 2.0.4
pump: 3.0.4
quick-format-unescaped@4.0.4: {} quick-format-unescaped@4.0.4: {}
readable-stream@2.3.8:
dependencies:
core-util-is: 1.0.3
inherits: 2.0.4
isarray: 1.0.0
process-nextick-args: 2.0.1
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readable-stream@4.7.0:
dependencies:
abort-controller: 3.0.0
buffer: 6.0.3
events: 3.3.0
process: 0.11.10
string_decoder: 1.3.0
real-require@0.2.0: {} real-require@0.2.0: {}
redis-errors@1.2.0: {} redis-errors@1.2.0: {}
...@@ -1141,6 +1381,10 @@ snapshots: ...@@ -1141,6 +1381,10 @@ snapshots:
rfdc@1.4.1: {} rfdc@1.4.1: {}
safe-buffer@5.1.2: {}
safe-buffer@5.2.1: {}
safe-regex2@5.1.1: safe-regex2@5.1.1:
dependencies: dependencies:
ret: 0.5.0 ret: 0.5.0
...@@ -1161,12 +1405,27 @@ snapshots: ...@@ -1161,12 +1405,27 @@ snapshots:
standard-as-callback@2.1.0: {} standard-as-callback@2.1.0: {}
stream-shift@1.0.3: {}
string_decoder@1.1.1:
dependencies:
safe-buffer: 5.1.2
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strnum@2.2.3: {} strnum@2.2.3: {}
thread-stream@4.0.0: thread-stream@4.0.0:
dependencies: dependencies:
real-require: 0.2.0 real-require: 0.2.0
through2@2.0.5:
dependencies:
readable-stream: 2.3.8
xtend: 4.0.2
toad-cache@3.7.0: {} toad-cache@3.7.0: {}
tslib@2.8.1: {} tslib@2.8.1: {}
...@@ -1182,6 +1441,10 @@ snapshots: ...@@ -1182,6 +1441,10 @@ snapshots:
undici-types@7.16.0: {} undici-types@7.16.0: {}
util-deprecate@1.0.2: {}
wrappy@1.0.2: {}
xtend@4.0.2: {} xtend@4.0.2: {}
zod@4.4.3: {} zod@4.4.3: {}
/**
* Drupal Webform → SurveyJS Migration Script (v2 — full rewrite)
*
* Reads all webform.webform.*.yml files, converts each to SurveyJS JSON,
* writes output JSON files to webform-migrated/, then bulk-upserts to the API.
*
* Usage:
* npx tsx scripts/migrate-webforms.ts # dry-run (write JSON files only)
* npx tsx scripts/migrate-webforms.ts --post # upsert to API (PUT if exists, POST if new)
*
* Key structural rules:
* • Each top-level fieldset → one SurveyJS page (tab in the runner)
* • Each webform_custom_composite → one panel containing [radiogroup + comment + file]
* • Nested fieldsets / webform_section → sub-panel within the page
* • webform_flexbox / container → transparent layout wrapper, children unwrapped
* • enableConductScore is derived from actual question data (not hardcoded by module)
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { createHash } from "node:crypto";
import * as yaml from "js-yaml";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ─── Config ──────────────────────────────────────────────────────────────────
const WEBFORM_DIR = path.resolve(__dirname, "../../../webform");
const OUTPUT_DIR = path.resolve(__dirname, "../../../webform-migrated");
const API_URL = "http://localhost:8080";
const DRY_RUN = !process.argv.includes("--post");
const SKIP_PATTERN =
/\b(adasd|aritest|asdf|dummy|sample|demo|testing|checklistsfasdsad|audti-testing|permit_webform_test|test_incident|test-indicdent)\b/i;
// ─── Category → moduleCode ────────────────────────────────────────────────────
const CATEGORY_MODULE: Record<string, string> = {
technical_checklist: "checklist",
operational_checklist: "checklist",
"242": "audit",
"243": "audit",
"276": "audit",
"238": "audit",
"225": "audit",
"281": "inspection",
"1028": "permit",
"2108": "permit",
contrac: "permit",
};
// ─── Types ────────────────────────────────────────────────────────────────────
interface SurveyChoice { value: string; text: string; }
interface SurveyElement { type: string; name: string; title?: string; [k: string]: unknown; }
interface SurveyPage { name: string; title: string; elements: SurveyElement[]; }
interface SurveyJson {
title: string;
moduleCode: string;
enableConductScore: boolean;
showProgressBar: string;
pages: SurveyPage[];
}
interface MigrationResult {
code: string;
displayName: string;
moduleCode: string;
surveyJson: SurveyJson;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function slug(s: string): string {
return s
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "");
}
function inferModuleCode(categories: string[]): string {
for (const cat of categories) {
const m = CATEGORY_MODULE[String(cat)];
if (m) return m;
}
return "audit";
}
function checksum(obj: unknown): string {
return createHash("sha256").update(JSON.stringify(obj)).digest("hex");
}
// ─── Radio options normalisation ──────────────────────────────────────────────
interface ChoiceResult {
choices: SurveyChoice[];
conductScoreByOption: Record<string, number | "na"> | null;
}
function normaliseOptions(options: unknown, maxScore: number): ChoiceResult {
if (!options || typeof options !== "object") {
return {
choices: [
{ value: "Yes", text: "Yes" },
{ value: "No", text: "No" },
{ value: "NA", text: "N/A" },
],
conductScoreByOption: maxScore > 0 ? { Yes: maxScore, No: 0, NA: "na" } : null,
};
}
const keys = Object.keys(options as Record<string, unknown>);
// yes / no / na (most common)
if (keys.includes("yes") && keys.includes("no") && keys.includes("na")) {
return {
choices: [
{ value: "Yes", text: "Yes" },
{ value: "No", text: "No" },
{ value: "NA", text: "N/A" },
],
conductScoreByOption: maxScore > 0 ? { Yes: maxScore, No: 0, NA: "na" } : null,
};
}
// yes / no (binary)
if (keys.includes("yes") && keys.includes("no")) {
return {
choices: [
{ value: "Yes", text: "Yes" },
{ value: "No", text: "No" },
],
conductScoreByOption: maxScore > 0 ? { Yes: maxScore, No: 0 } : null,
};
}
// 0 / 1 / 2 [/ na] (compliant scale)
if (keys.includes("2") && keys.includes("1") && keys.includes("0")) {
const hasNa = keys.includes("na");
const choices: SurveyChoice[] = [
{ value: "2", text: "Compliant" },
{ value: "1", text: "Partially Compliant" },
{ value: "0", text: "Non-Compliant" },
];
if (hasNa) choices.push({ value: "NA", text: "N/A" });
const score: Record<string, number | "na"> = {
"2": maxScore,
"1": Math.round(maxScore / 2),
"0": 0,
};
if (hasNa) score["NA"] = "na";
return { choices, conductScoreByOption: maxScore > 0 ? score : null };
}
// Generic fallback — map as-is
const opts = options as Record<string, string>;
const choices: SurveyChoice[] = keys.map((k) => ({
value: k,
text: opts[k] ? String(opts[k]) : k,
}));
return { choices, conductScoreByOption: null };
}
// ─── Convert webform_custom_composite → single panel element ─────────────────
function convertComposite(name: string, el: Record<string, unknown>): SurveyElement {
const title = String(el["#title"] ?? name).trim();
const subEls = (el["#element"] ?? {}) as Record<string, Record<string, unknown>>;
const radioKey = Object.keys(subEls).find((k) => k.includes("radio"));
const radioEl = radioKey ? subEls[radioKey] : null;
const rawOpts = radioEl?.["#options"];
const scoreKey = Object.keys(subEls).find(
(k) => k.includes("text") && !k.includes("textarea") && !k.includes("file")
);
const scoreEl = scoreKey ? subEls[scoreKey] : null;
const maxScore = scoreEl?.["#value"]
? parseInt(String(scoreEl["#value"]).trim(), 10) || 0
: 0;
const qName = slug(name);
const { choices, conductScoreByOption } = normaliseOptions(rawOpts, maxScore);
const mainQ: Record<string, unknown> = {
type: "radiogroup",
name: qName,
title,
isRequired: false,
choices,
};
if (maxScore > 0 && conductScoreByOption) {
mainQ["conductScore"] = { max: maxScore, scoreByOption: conductScoreByOption };
}
return {
type: "panel",
name: `${qName}_grp`,
elements: [
mainQ,
{
type: "comment",
name: `${qName}_remarks`,
title: "Remarks",
isRequired: false,
visibleIf: `{${qName}} notempty`,
},
{
type: "file",
name: `${qName}_photo`,
title: "Supporting photo",
isRequired: false,
acceptedTypes: "image/*",
visibleIf: `{${qName}} notempty`,
},
],
} as SurveyElement;
}
// ─── Convert a single simple field ───────────────────────────────────────────
function convertField(name: string, el: Record<string, unknown>): SurveyElement | null {
const type = el["#type"] as string | undefined;
const title = String(el["#title"] ?? name).trim();
const required = el["#required"] === true;
const qName = slug(name);
switch (type) {
case "textfield":
return { type: "text", name: qName, title, isRequired: required };
case "textarea":
return { type: "comment", name: qName, title, isRequired: required };
case "date":
return { type: "text", inputType: "date", name: qName, title, isRequired: required };
case "datetime":
return { type: "text", inputType: "datetime-local", name: qName, title, isRequired: required };
case "webform_time":
return { type: "text", inputType: "time", name: qName, title, isRequired: required };
case "number":
return { type: "text", inputType: "number", name: qName, title, isRequired: required };
case "email":
return { type: "text", inputType: "email", name: qName, title, isRequired: required };
case "checkbox":
return { type: "boolean", name: qName, title, isRequired: required };
case "checkboxes": {
const rawOpts = el["#options"];
let choices: SurveyChoice[] = [];
if (rawOpts && typeof rawOpts === "object") {
choices = Object.entries(rawOpts as Record<string, string>).map(
([k, v]) => ({ value: k, text: v ? String(v) : k })
);
}
return { type: "checkbox", name: qName, title, isRequired: required, choices };
}
case "radios": {
const rawOpts = el["#options"];
let choices: SurveyChoice[];
if (rawOpts === "yes_no") {
choices = [{ value: "yes", text: "Yes" }, { value: "no", text: "No" }];
} else if (rawOpts && typeof rawOpts === "object") {
choices = Object.entries(rawOpts as Record<string, string>).map(
([k, v]) => ({ value: k, text: v ? String(v) : k })
);
} else {
choices = [{ value: "yes", text: "Yes" }, { value: "no", text: "No" }];
}
return { type: "radiogroup", name: qName, title, isRequired: required, choices };
}
case "managed_file":
return {
type: "file",
name: qName,
title: title || "Upload file",
isRequired: required,
acceptedTypes: "image/*,application/pdf",
};
case "webform_entity_select":
case "entity_autocomplete":
return { type: "text", name: qName, title, isRequired: required };
case "webform_term_checkboxes":
return { type: "text", name: qName, title, isRequired: required };
case "webform_markup":
return { type: "html", name: qName, html: String(el["#markup"] ?? "") };
case "item":
return { type: "html", name: qName, html: `<strong>${title}</strong>` };
// Layout/structural types handled by convertElements
case "hidden":
case "container":
case "webform_flexbox":
case "webform_section":
case "fieldset":
case "webform_custom_composite":
case "managed_file_multiple":
return null;
default:
return null;
}
}
// ─── Recursively extract all elements from a container ───────────────────────
function convertElements(
group: Record<string, unknown>,
out: SurveyElement[],
): void {
for (const [key, value] of Object.entries(group)) {
if (key.startsWith("#")) continue;
if (!value || typeof value !== "object") continue;
const el = value as Record<string, unknown>;
const type = el["#type"] as string | undefined;
// Standalone file upload siblings — already captured inside their composite
if (key.startsWith("upload_file_")) continue;
// Skip hidden system fields
if (type === "hidden") continue;
if (type === "webform_custom_composite") {
out.push(convertComposite(key, el));
continue;
}
// Named groups: fieldset and webform_section become sub-panels
if (type === "fieldset" || type === "webform_section") {
const subElements: SurveyElement[] = [];
convertElements(el, subElements);
if (subElements.length > 0) {
out.push({
type: "panel",
name: slug(key),
title: String(el["#title"] ?? key).trim(),
elements: subElements,
} as SurveyElement);
}
continue;
}
// Layout wrappers — transparent: extract children into parent level
if (type === "webform_flexbox" || type === "container") {
convertElements(el, out);
continue;
}
// Standalone file elements (not inside a composite)
if (type === "managed_file") {
// Only add if not an upload_file_ sibling (those are already skipped above)
out.push({
type: "file",
name: slug(key),
title: String(el["#title"] ?? key).trim() || "Upload file",
isRequired: el["#required"] === true,
acceptedTypes: "image/*,application/pdf",
});
continue;
}
const fieldEl = convertField(key, el);
if (fieldEl) out.push(fieldEl);
}
}
// ─── Convert a top-level fieldset → SurveyJS page ────────────────────────────
function convertGroup(groupName: string, group: Record<string, unknown>): SurveyPage {
const pageTitle = String(group["#title"] ?? groupName).trim();
const elements: SurveyElement[] = [];
convertElements(group, elements);
return { name: slug(groupName), title: pageTitle, elements };
}
// ─── Detect whether any element carries conductScore data ────────────────────
function hasConductScore(el: SurveyElement): boolean {
if (Array.isArray(el.elements)) {
return (el.elements as SurveyElement[]).some(hasConductScore);
}
return !!(el as Record<string, unknown>)["conductScore"];
}
// ─── Main converter ───────────────────────────────────────────────────────────
function convertWebform(raw: Record<string, unknown>): MigrationResult | null {
const id = String(raw.id ?? "unknown");
const title = String(raw.title ?? id).trim();
const categories: string[] = Array.isArray(raw.categories)
? raw.categories.map(String)
: [];
const moduleCode = inferModuleCode(categories);
// Parse the nested elements YAML string
let rootElements: Record<string, unknown>;
try {
const parsed = yaml.load(raw.elements as string);
if (!parsed || typeof parsed !== "object") return null;
rootElements = parsed as Record<string, unknown>;
} catch {
return null;
}
// Unwrap outer 'container' if present
let groups: Record<string, unknown> = rootElements;
const outerContainer = rootElements["container"];
if (
outerContainer &&
typeof outerContainer === "object" &&
(outerContainer as Record<string, unknown>)["#type"] === "container"
) {
groups = outerContainer as Record<string, unknown>;
}
const pages: SurveyPage[] = [];
for (const [key, value] of Object.entries(groups)) {
if (key.startsWith("#")) continue;
if (!value || typeof value !== "object") continue;
const el = value as Record<string, unknown>;
const type = el["#type"] as string | undefined;
if (type === "fieldset") {
const page = convertGroup(key, el);
if (page.elements.length > 0) pages.push(page);
continue;
}
// Top-level fieldsets that are NOT inside a container (e.g. permit forms)
// already handled above. But some forms have the top-level structure without
// a 'container' wrapper — handle those too via a fallback.
}
// Fallback: if no container found, treat top-level non-hidden, non-# keys as fieldsets
if (pages.length === 0) {
for (const [key, value] of Object.entries(rootElements)) {
if (key.startsWith("#")) continue;
if (!value || typeof value !== "object") continue;
const el = value as Record<string, unknown>;
const type = el["#type"] as string | undefined;
if (type === "fieldset") {
const page = convertGroup(key, el);
if (page.elements.length > 0) pages.push(page);
}
}
}
if (pages.length === 0) return null;
// Determine if any question carries scoring data
const enableConductScore = pages.some((p) => p.elements.some(hasConductScore));
const surveyJson: SurveyJson = {
title,
moduleCode,
enableConductScore,
showProgressBar: "top",
pages,
};
return { code: id, displayName: title, moduleCode, surveyJson };
}
// ─── API upsert (PUT if exists, POST if new) ──────────────────────────────────
async function upsertForm(result: MigrationResult): Promise<{ ok: boolean; message: string }> {
try {
// Check if form already exists
const getRes = await fetch(`${API_URL}/api/forms/${result.code}`, {
headers: { "x-maf-role": "Form Builder" },
});
if (getRes.ok) {
// Update existing draft
const putRes = await fetch(`${API_URL}/api/forms/${result.code}`, {
method: "PATCH",
headers: { "content-type": "application/json", "x-maf-role": "Form Builder" },
body: JSON.stringify({
displayName: result.displayName,
surveyJson: result.surveyJson,
schemaChecksum: checksum(result.surveyJson),
}),
});
if (!putRes.ok) return { ok: false, message: await putRes.text() };
return { ok: true, message: "updated" };
}
// Create new form
const postRes = await fetch(`${API_URL}/api/forms`, {
method: "POST",
headers: { "content-type": "application/json", "x-maf-role": "Form Builder" },
body: JSON.stringify({
code: result.code,
displayName: result.displayName,
moduleCode: result.moduleCode,
surveyJson: result.surveyJson,
schemaChecksum: checksum(result.surveyJson),
}),
});
if (!postRes.ok) return { ok: false, message: await postRes.text() };
return { ok: true, message: "created" };
} catch (err) {
return { ok: false, message: (err as Error).message };
}
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
// Clear stale JSON files from previous runs
for (const f of fs.readdirSync(OUTPUT_DIR)) {
if (f.endsWith(".json")) fs.unlinkSync(path.join(OUTPUT_DIR, f));
}
const files = fs
.readdirSync(WEBFORM_DIR)
.filter((f) => f.startsWith("webform.webform.") && f.endsWith(".yml"));
const stats = {
total: 0, converted: 0, skipped: 0, posted: 0, updated: 0, failed: 0,
};
const failures: Array<{ file: string; reason: string }> = [];
const summary: Array<{
code: string; moduleCode: string; pages: number; questions: number;
}> = [];
for (const file of files) {
stats.total++;
if (SKIP_PATTERN.test(file)) {
stats.skipped++;
continue;
}
const filePath = path.join(WEBFORM_DIR, file);
let raw: Record<string, unknown>;
try {
raw = yaml.load(fs.readFileSync(filePath, "utf8")) as Record<string, unknown>;
} catch (e) {
stats.skipped++;
failures.push({ file, reason: `YAML parse error: ${(e as Error).message}` });
continue;
}
if (!raw || !raw.elements || raw.elements === "null") {
stats.skipped++;
continue;
}
const result = convertWebform(raw);
if (!result) {
stats.skipped++;
continue;
}
// Count radiogroup questions (at any depth)
function countRadiogroups(els: SurveyElement[]): number {
return els.reduce((n, e) => {
if (e.type === "radiogroup") return n + 1;
if (Array.isArray(e.elements)) return n + countRadiogroups(e.elements as SurveyElement[]);
return n;
}, 0);
}
const questionCount = result.surveyJson.pages.reduce(
(n, p) => n + countRadiogroups(p.elements),
0
);
summary.push({
code: result.code,
moduleCode: result.moduleCode,
pages: result.surveyJson.pages.length,
questions: questionCount,
});
// Write JSON file
const outFile = path.join(OUTPUT_DIR, `${result.code}.json`);
fs.writeFileSync(outFile, JSON.stringify(result, null, 2), "utf8");
stats.converted++;
if (!DRY_RUN) {
const { ok, message } = await upsertForm(result);
if (ok) {
if (message === "updated") stats.updated++;
else stats.posted++;
} else {
stats.failed++;
failures.push({ file, reason: message });
}
process.stdout.write(` ${ok ? "✓" : "✗"} ${result.code}: ${message}\n`);
}
}
// Write summary report
const reportPath = path.join(OUTPUT_DIR, "_migration-report.json");
fs.writeFileSync(
reportPath,
JSON.stringify({ stats, summary, failures }, null, 2),
"utf8"
);
console.log("\n─── Migration Summary ───────────────────────────────────");
console.log(` Total YAML files : ${stats.total}`);
console.log(` Converted : ${stats.converted}`);
console.log(` Skipped/empty : ${stats.skipped}`);
if (!DRY_RUN) {
console.log(` Created : ${stats.posted}`);
console.log(` Updated : ${stats.updated}`);
console.log(` Failed : ${stats.failed}`);
}
console.log("\n Module breakdown:");
const byModule = summary.reduce<Record<string, number>>((acc, r) => {
acc[r.moduleCode] = (acc[r.moduleCode] ?? 0) + 1;
return acc;
}, {});
for (const [mod, cnt] of Object.entries(byModule).sort((a, b) => b[1] - a[1])) {
console.log(` ${mod.padEnd(12)}: ${cnt}`);
}
const avgPages = summary.length > 0
? (summary.reduce((s, r) => s + r.pages, 0) / summary.length).toFixed(1)
: "0";
const avgQs = summary.length > 0
? (summary.reduce((s, r) => s + r.questions, 0) / summary.length).toFixed(1)
: "0";
console.log(`\n Avg pages/form : ${avgPages}`);
console.log(` Avg questions/form: ${avgQs}`);
if (failures.length > 0) {
console.log(`\n Failures (${failures.length}):`);
for (const { file, reason } of failures.slice(0, 20)) {
console.log(` ${file}: ${reason}`);
}
}
console.log(`\n Output dir: ${OUTPUT_DIR}`);
console.log(` Report : ${reportPath}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
import fs from "node:fs";
import path from "node:path";
// ─── Lightweight .env loader (no extra dependency) ────────────────────────────
(function loadDotenv() {
const envPath = path.resolve(process.cwd(), ".env");
if (!fs.existsSync(envPath)) return;
const text = fs.readFileSync(envPath, "utf8");
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq === -1) continue;
const key = line.slice(0, eq).trim();
let value = line.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = value;
}
})();
function required(name: string, fallback?: string): string {
const v = process.env[name];
if (v && v.length > 0) return v;
if (fallback !== undefined) return fallback;
throw new Error(`Missing required environment variable: ${name}`);
}
export const config = { export const config = {
port: Number(process.env.PORT ?? 8080), port: Number(process.env["PORT"] ?? 8080),
postgresUrl: process.env.POSTGRES_URL ?? "postgres://postgres:password@localhost:5432/maf-gateway", postgresUrl: required("POSTGRES_URL", "postgres://postgres:password@localhost:5432/maf-gateway"),
redisUrl: process.env.REDIS_URL ?? "", redisUrl: process.env["REDIS_URL"] ?? "",
schemaTtlSeconds: Number(process.env.CACHE_TTL_SCHEMA_SECONDS ?? 300), schemaTtlSeconds: Number(process.env["CACHE_TTL_SCHEMA_SECONDS"] ?? 300),
masterTtlSeconds: Number(process.env.CACHE_TTL_MASTER_SECONDS ?? 180), masterTtlSeconds: Number(process.env["CACHE_TTL_MASTER_SECONDS"] ?? 180),
azureStorageConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING ?? "",
azureBlobContainer: process.env.AZURE_BLOB_CONTAINER ?? "form-uploads" azure: {
accountName: required("AZURE_ACCOUNT_NAME", ""),
accountKey: required("AZURE_ACCOUNT_KEY", ""),
baseUrl: required("AZURE_BASE_URL", ""),
containerName: required("AZURE_CONTAINER_NAME", ""),
cdnBaseUrl: process.env["AZURE_CDN_BASE_URL"] ?? "",
uploadSasTtlMin: Number(process.env["AZURE_UPLOAD_SAS_TTL_MIN"] ?? 15),
downloadSasTtlMin: Number(process.env["AZURE_DOWNLOAD_SAS_TTL_MIN"] ?? 60),
},
}; };
// ─── Records ────────────────────────────────────────────────────────────────── // ─── Records ──────────────────────────────────────────────────────────────────
export interface FormRecord { export type FrequencyCycle =
| "adhoc"
| "daily"
| "weekly"
| "biweekly"
| "monthly"
| "quarterly"
| "semiannual"
| "yearly";
export type FormVisibility = "visible" | "hidden";
export interface FormSettings {
frequencyCycle: FrequencyCycle | null;
isInternal: boolean;
internalStatus: string | null;
visibility: FormVisibility;
// multi-scope
countryIds: string[];
subsidiaryIds: string[];
companyIds: string[];
entityIds: string[];
buIds: string[];
// role assignments
technicalRoleIds: string[];
operationalRoleIds: string[];
notificationRoleIds: string[];
}
export interface FormRecord extends FormSettings {
id: string; id: string;
code: string; code: string;
displayName: string; displayName: string;
...@@ -43,6 +72,8 @@ export interface SubmissionRecord { ...@@ -43,6 +72,8 @@ export interface SubmissionRecord {
// ─── Inputs ─────────────────────────────────────────────────────────────────── // ─── Inputs ───────────────────────────────────────────────────────────────────
export type FormSettingsInput = Partial<FormSettings>;
export interface CreateFormInput { export interface CreateFormInput {
code: string; code: string;
displayName: string; displayName: string;
...@@ -52,6 +83,7 @@ export interface CreateFormInput { ...@@ -52,6 +83,7 @@ export interface CreateFormInput {
surveyJson: Record<string, unknown>; surveyJson: Record<string, unknown>;
semver?: string; semver?: string;
schemaChecksum: string; schemaChecksum: string;
settings?: FormSettingsInput;
} }
export interface UpdateFormInput { export interface UpdateFormInput {
...@@ -63,6 +95,7 @@ export interface UpdateFormInput { ...@@ -63,6 +95,7 @@ export interface UpdateFormInput {
schemaChecksum?: string; schemaChecksum?: string;
/** Semver for the draft — required when surveyJson is provided and no draft exists */ /** Semver for the draft — required when surveyJson is provided and no draft exists */
semver?: string; semver?: string;
settings?: FormSettingsInput;
} }
export interface PublishVersionInput { export interface PublishVersionInput {
...@@ -95,10 +128,49 @@ export interface SubmissionPage { ...@@ -95,10 +128,49 @@ export interface SubmissionPage {
limit: number; limit: number;
} }
// ─── Catalog options for settings dropdowns ───────────────────────────────────
export interface CatalogOption {
id: string;
code: string | null;
name: string;
}
export interface FormOptionCatalogs {
countries: CatalogOption[];
organisations: CatalogOption[];
subsidiaries: CatalogOption[];
companies: CatalogOption[];
entities: CatalogOption[];
businessUnits: CatalogOption[];
roles: CatalogOption[];
}
// ─── Repository interface ───────────────────────────────────────────────────── // ─── Repository interface ─────────────────────────────────────────────────────
export interface ListFormsFilter {
moduleCode?: string;
visibility?: FormVisibility;
isActive?: boolean;
q?: string;
/** Scope filters — a form matches if its corresponding array contains the id
* OR the form's array is empty (i.e. "all scopes"). */
countryId?: string;
subsidiaryId?: string;
companyId?: string;
entityId?: string;
buId?: string;
}
export interface ManagerOption {
id: string;
email: string;
displayName: string;
role: string | null;
}
export interface FormRepository { export interface FormRepository {
listForms(): Promise<FormRecord[]>; listForms(filter?: ListFormsFilter): Promise<FormRecord[]>;
getFormByCode(code: string): Promise<FormWithVersions | null>; getFormByCode(code: string): Promise<FormWithVersions | null>;
getFormVersion(formId: string, semver: string): Promise<FormVersionRecord | null>; getFormVersion(formId: string, semver: string): Promise<FormVersionRecord | null>;
createForm(data: CreateFormInput): Promise<FormRecord>; createForm(data: CreateFormInput): Promise<FormRecord>;
...@@ -107,6 +179,15 @@ export interface FormRepository { ...@@ -107,6 +179,15 @@ export interface FormRepository {
publishVersion(formId: string, data: PublishVersionInput): Promise<FormVersionRecord>; publishVersion(formId: string, data: PublishVersionInput): Promise<FormVersionRecord>;
saveSubmission(data: SubmissionInput): Promise<SubmissionRecord>; saveSubmission(data: SubmissionInput): Promise<SubmissionRecord>;
listSubmissions(formId: string, filter: SubmissionFilter): Promise<SubmissionPage>; listSubmissions(formId: string, filter: SubmissionFilter): Promise<SubmissionPage>;
getOptionCatalogs(): Promise<FormOptionCatalogs>;
/** Users assigned to a given BU/entity/etc. — for "BU manager" dropdowns. */
listManagers(filter: {
buId?: string;
entityId?: string;
companyId?: string;
countryId?: string;
limit?: number;
}): Promise<ManagerOption[]>;
} }
// ─── Legacy types (kept for backward compat with existing service/types) ─────── // ─── Legacy types (kept for backward compat with existing service/types) ───────
......
...@@ -2,11 +2,16 @@ import { randomUUID } from "node:crypto"; ...@@ -2,11 +2,16 @@ import { randomUUID } from "node:crypto";
import type pg from "pg"; import type pg from "pg";
import { getPool } from "../../db/pg-pool.js"; import { getPool } from "../../db/pg-pool.js";
import type { import type {
CatalogOption,
CreateFormInput, CreateFormInput,
FormOptionCatalogs,
FormRecord, FormRecord,
FormRepository, FormRepository,
FormSettingsInput,
FormVersionRecord, FormVersionRecord,
FormVisibility,
FormWithVersions, FormWithVersions,
FrequencyCycle,
PublishVersionInput, PublishVersionInput,
SubmissionFilter, SubmissionFilter,
SubmissionInput, SubmissionInput,
...@@ -17,6 +22,10 @@ import type { ...@@ -17,6 +22,10 @@ import type {
// ─── Row mappers ────────────────────────────────────────────────────────────── // ─── Row mappers ──────────────────────────────────────────────────────────────
function toUuidArr(v: unknown): string[] {
return Array.isArray(v) ? (v as string[]) : [];
}
function toFormRecord(row: Record<string, unknown>): FormRecord { function toFormRecord(row: Record<string, unknown>): FormRecord {
return { return {
id: row["id"] as string, id: row["id"] as string,
...@@ -27,9 +36,54 @@ function toFormRecord(row: Record<string, unknown>): FormRecord { ...@@ -27,9 +36,54 @@ function toFormRecord(row: Record<string, unknown>): FormRecord {
currentVersionId: (row["current_version_id"] as string | null) ?? null, currentVersionId: (row["current_version_id"] as string | null) ?? null,
createdAt: (row["created_at"] as Date).toISOString(), createdAt: (row["created_at"] as Date).toISOString(),
updatedAt: (row["updated_at"] as Date).toISOString(), updatedAt: (row["updated_at"] as Date).toISOString(),
// settings
frequencyCycle: (row["frequency_cycle"] as FrequencyCycle | null) ?? null,
isInternal: (row["is_internal"] as boolean) ?? false,
internalStatus: (row["internal_status"] as string | null) ?? null,
visibility: (row["visibility"] as FormVisibility) ?? "visible",
countryIds: toUuidArr(row["country_ids"]),
subsidiaryIds: toUuidArr(row["subsidiary_ids"]),
companyIds: toUuidArr(row["company_ids"]),
entityIds: toUuidArr(row["entity_ids"]),
buIds: toUuidArr(row["bu_ids"]),
technicalRoleIds: toUuidArr(row["technical_role_ids"]),
operationalRoleIds: toUuidArr(row["operational_role_ids"]),
notificationRoleIds: toUuidArr(row["notification_role_ids"]),
}; };
} }
const FORM_COLUMNS = `
id, code, display_name, description, is_active, current_version_id,
created_at, updated_at,
frequency_cycle, is_internal, internal_status, visibility,
country_ids, subsidiary_ids, company_ids, entity_ids, bu_ids,
technical_role_ids, operational_role_ids, notification_role_ids
`;
/** Build the column->value pairs for the form settings UPDATE/INSERT. */
function buildSettingsAssignments(
settings: FormSettingsInput,
vals: unknown[],
sets: string[],
): void {
const push = (col: string, v: unknown): void => {
vals.push(v);
sets.push(`${col} = $${vals.length}`);
};
if (settings.frequencyCycle !== undefined) push("frequency_cycle", settings.frequencyCycle);
if (settings.isInternal !== undefined) push("is_internal", settings.isInternal);
if (settings.internalStatus !== undefined) push("internal_status", settings.internalStatus);
if (settings.visibility !== undefined) push("visibility", settings.visibility);
if (settings.countryIds !== undefined) push("country_ids", settings.countryIds);
if (settings.subsidiaryIds !== undefined) push("subsidiary_ids", settings.subsidiaryIds);
if (settings.companyIds !== undefined) push("company_ids", settings.companyIds);
if (settings.entityIds !== undefined) push("entity_ids", settings.entityIds);
if (settings.buIds !== undefined) push("bu_ids", settings.buIds);
if (settings.technicalRoleIds !== undefined) push("technical_role_ids", settings.technicalRoleIds);
if (settings.operationalRoleIds !== undefined) push("operational_role_ids", settings.operationalRoleIds);
if (settings.notificationRoleIds !== undefined) push("notification_role_ids", settings.notificationRoleIds);
}
function toVersionRecord(row: Record<string, unknown>): FormVersionRecord { function toVersionRecord(row: Record<string, unknown>): FormVersionRecord {
return { return {
id: row["id"] as string, id: row["id"] as string,
...@@ -70,21 +124,119 @@ export class PgFormRepository implements FormRepository { ...@@ -70,21 +124,119 @@ export class PgFormRepository implements FormRepository {
this.pool = getPool(); this.pool = getPool();
} }
async listForms(): Promise<FormRecord[]> { async listForms(filter?: import("./domain.js").ListFormsFilter): Promise<FormRecord[]> {
const where: string[] = ["deleted_at IS NULL"];
const params: unknown[] = [];
if (filter?.moduleCode) {
params.push(filter.moduleCode);
where.push(
`module_id = (SELECT id FROM platform.module WHERE code = $${params.length} LIMIT 1)`,
);
}
if (filter?.visibility) {
params.push(filter.visibility);
where.push(`visibility = $${params.length}`);
}
if (filter?.isActive !== undefined) {
params.push(filter.isActive);
where.push(`is_active = $${params.length}`);
}
if (filter?.q) {
params.push(`%${filter.q}%`);
where.push(`(code ILIKE $${params.length} OR display_name ILIKE $${params.length})`);
}
// Scope filters: a form matches if its corresponding array contains the id
// OR the form's array is empty (universal scope).
const scopeFilter = (col: string, val: string | undefined): void => {
if (!val) return;
params.push(val);
where.push(`(${col} = '{}'::uuid[] OR $${params.length}::uuid = ANY(${col}))`);
};
scopeFilter("country_ids", filter?.countryId);
scopeFilter("subsidiary_ids", filter?.subsidiaryId);
scopeFilter("company_ids", filter?.companyId);
scopeFilter("entity_ids", filter?.entityId);
scopeFilter("bu_ids", filter?.buId);
const result = await this.pool.query<Record<string, unknown>>( const result = await this.pool.query<Record<string, unknown>>(
`SELECT id, code, display_name, description, is_active, current_version_id, `SELECT ${FORM_COLUMNS}
created_at, updated_at
FROM forms.form FROM forms.form
WHERE deleted_at IS NULL WHERE ${where.join(" AND ")}
ORDER BY created_at DESC` ORDER BY created_at DESC`,
params,
); );
return result.rows.map(toFormRecord); return result.rows.map(toFormRecord);
} }
async listManagers(filter: {
buId?: string; entityId?: string; companyId?: string; countryId?: string;
limit?: number;
}): Promise<import("./domain.js").ManagerOption[]> {
const params: unknown[] = [];
const where: string[] = ["u.is_active = TRUE", "u.is_deleted = FALSE"];
// If any scope is provided, try to join user_org_assignment.
const hasScope = !!(filter.buId || filter.entityId || filter.companyId || filter.countryId);
if (hasScope) {
const assignWhere: string[] = [];
const push = (col: string, v: string | undefined): void => {
if (!v) return;
params.push(v);
assignWhere.push(`uoa.${col} = $${params.length}`);
};
push("bu_id", filter.buId);
push("entity_id", filter.entityId);
push("company_id", filter.companyId);
push("country_id", filter.countryId);
// First try assignment-based query
const r = await this.pool.query<Record<string, unknown>>(
`SELECT DISTINCT u.id, u.email, u.display_name, u.first_name, u.last_name, r.name AS role
FROM auth.users u
JOIN auth.user_org_assignment uoa ON uoa.user_id = u.id
LEFT JOIN auth.roles r ON r.id = uoa.role_id
WHERE ${where.join(" AND ")}${assignWhere.length ? " AND " + assignWhere.join(" AND ") : ""}
ORDER BY u.display_name
LIMIT ${Math.min(filter.limit ?? 50, 200)}`,
params,
);
if (r.rowCount && r.rowCount > 0) {
return r.rows.map((row) => ({
id: row["id"] as string,
email: row["email"] as string,
displayName: (row["display_name"] as string) ||
`${row["first_name"] ?? ""} ${row["last_name"] ?? ""}`.trim() ||
(row["email"] as string),
role: (row["role"] as string | null) ?? null,
}));
}
// Fall through to the unfiltered fallback (no assignments seeded).
}
// Fallback: return active users so the dropdown is never empty.
const r = await this.pool.query<Record<string, unknown>>(
`SELECT u.id, u.email, u.display_name, u.first_name, u.last_name
FROM auth.users u
WHERE ${where.join(" AND ")}
ORDER BY u.display_name
LIMIT ${Math.min(filter.limit ?? 50, 200)}`,
);
return r.rows.map((row) => ({
id: row["id"] as string,
email: row["email"] as string,
displayName: (row["display_name"] as string) ||
`${row["first_name"] ?? ""} ${row["last_name"] ?? ""}`.trim() ||
(row["email"] as string),
role: null,
}));
}
async getFormByCode(code: string): Promise<FormWithVersions | null> { async getFormByCode(code: string): Promise<FormWithVersions | null> {
const formResult = await this.pool.query<Record<string, unknown>>( const formResult = await this.pool.query<Record<string, unknown>>(
`SELECT id, code, display_name, description, is_active, current_version_id, `SELECT ${FORM_COLUMNS}
created_at, updated_at
FROM forms.form FROM forms.form
WHERE code = $1 AND deleted_at IS NULL`, WHERE code = $1 AND deleted_at IS NULL`,
[code] [code]
...@@ -144,51 +296,44 @@ export class PgFormRepository implements FormRepository { ...@@ -144,51 +296,44 @@ export class PgFormRepository implements FormRepository {
try { try {
await client.query("BEGIN"); await client.query("BEGIN");
// 1. Insert form // 1. Insert form (settings have safe defaults; apply overrides if provided)
const formResult = await client.query<Record<string, unknown>>( const insertResult = await client.query<{ id: string }>(
`INSERT INTO forms.form (module_id, code, display_name, description, is_active) `INSERT INTO forms.form (module_id, code, display_name, description, is_active)
VALUES ($1, $2, $3, $4, TRUE) VALUES ($1, $2, $3, $4, TRUE)
RETURNING id, code, display_name, description, is_active, RETURNING id`,
current_version_id, created_at, updated_at`,
[moduleId, data.code, data.displayName, data.description ?? null] [moduleId, data.code, data.displayName, data.description ?? null]
); );
const formRow = formResult.rows[0]!; const formId = insertResult.rows[0]!.id;
const formId = formRow["id"] as string;
const semver = data.semver ?? "1.0.0"; const semver = data.semver ?? "1.0.0";
// 2. Insert initial version if (data.settings) {
const versionResult = await client.query<Record<string, unknown>>( const vals: unknown[] = [];
`INSERT INTO forms.form_version const sets: string[] = [];
(form_id, semver, survey_json, question_index, schema_checksum, buildSettingsAssignments(data.settings, vals, sets);
is_published, published_at) if (sets.length > 0) {
VALUES ($1, $2, $3, $4, $5, TRUE, NOW()) vals.push(formId);
RETURNING id`, await client.query(
[ `UPDATE forms.form SET ${sets.join(", ")}, updated_at = NOW()
formId, WHERE id = $${vals.length}`,
semver, vals,
JSON.stringify(data.surveyJson), );
"{}", }
data.schemaChecksum, }
]
);
const versionId = versionResult.rows[0]!["id"] as string;
// 3. Link current_version_id // 2. Insert initial unpublished draft (published on explicit publish action)
await client.query( await client.query<Record<string, unknown>>(
`UPDATE forms.form `INSERT INTO forms.form_version
SET current_version_id = $1, updated_at = NOW() (form_id, semver, survey_json, question_index, schema_checksum, is_published)
WHERE id = $2`, VALUES ($1, 'draft', $2, '{}', $3, FALSE)`,
[versionId, formId] [formId, JSON.stringify(data.surveyJson), data.schemaChecksum]
); );
await client.query("COMMIT"); await client.query("COMMIT");
// Return fresh form row with current_version_id set // Return fresh form row with current_version_id set
const refreshed = await this.pool.query<Record<string, unknown>>( const refreshed = await this.pool.query<Record<string, unknown>>(
`SELECT id, code, display_name, description, is_active, current_version_id, `SELECT ${FORM_COLUMNS} FROM forms.form WHERE id = $1`,
created_at, updated_at [formId],
FROM forms.form WHERE id = $1`,
[formId]
); );
return toFormRecord(refreshed.rows[0]!); return toFormRecord(refreshed.rows[0]!);
} catch (err) { } catch (err) {
...@@ -213,12 +358,13 @@ export class PgFormRepository implements FormRepository { ...@@ -213,12 +358,13 @@ export class PgFormRepository implements FormRepository {
if (data.description !== undefined) push("description", data.description); if (data.description !== undefined) push("description", data.description);
if (data.isActive !== undefined) push("is_active", data.isActive); if (data.isActive !== undefined) push("is_active", data.isActive);
if (data.settings) buildSettingsAssignments(data.settings, vals, sets);
vals.push(code); vals.push(code);
const formResult = await client.query<Record<string, unknown>>( const formResult = await client.query<Record<string, unknown>>(
`UPDATE forms.form SET ${sets.join(", ")} `UPDATE forms.form SET ${sets.join(", ")}
WHERE code = $${vals.length} AND deleted_at IS NULL WHERE code = $${vals.length} AND deleted_at IS NULL
RETURNING id, code, display_name, description, is_active, RETURNING ${FORM_COLUMNS}`,
current_version_id, created_at, updated_at`,
vals vals
); );
if ((formResult.rowCount ?? 0) === 0) { await client.query("ROLLBACK"); return null; } if ((formResult.rowCount ?? 0) === 0) { await client.query("ROLLBACK"); return null; }
...@@ -228,23 +374,37 @@ export class PgFormRepository implements FormRepository { ...@@ -228,23 +374,37 @@ export class PgFormRepository implements FormRepository {
// 2. If surveyJson provided — upsert draft version // 2. If surveyJson provided — upsert draft version
if (data.surveyJson !== undefined) { if (data.surveyJson !== undefined) {
const checksum = data.schemaChecksum ?? ""; const checksum = data.schemaChecksum ?? "";
// Find existing unpublished draft // Find existing unpublished draft (pull checksum so we can skip
const draftResult = await client.query<{ id: string; semver: string }>( // the JSON write when nothing has changed — a 1.5 MB draft round-trip
`SELECT id, semver FROM forms.form_version // becomes a no-op metadata UPDATE instead of a full TOAST rewrite).
const draftResult = await client.query<{ id: string; semver: string; schema_checksum: string }>(
`SELECT id, semver, schema_checksum FROM forms.form_version
WHERE form_id = $1 AND is_published = FALSE WHERE form_id = $1 AND is_published = FALSE
ORDER BY created_at DESC LIMIT 1`, ORDER BY created_at DESC LIMIT 1`,
[formId] [formId]
); );
if (draftResult.rowCount! > 0) { if (draftResult.rowCount! > 0) {
// Update existing draft in-place const draft = draftResult.rows[0]!;
await client.query( const checksumMatches = checksum.length > 0 && draft.schema_checksum === checksum;
`UPDATE forms.form_version if (checksumMatches) {
SET survey_json = $1, schema_checksum = $2, // Identical schema — only bump semver if the caller asked for it.
semver = COALESCE($3, semver) if (data.semver && data.semver !== draft.semver) {
WHERE id = $4`, await client.query(
[JSON.stringify(data.surveyJson), checksum, data.semver ?? null, draftResult.rows[0]!.id] `UPDATE forms.form_version SET semver = $1 WHERE id = $2`,
); [data.semver, draft.id]
);
}
} else {
// Update existing draft in-place with new JSON.
await client.query(
`UPDATE forms.form_version
SET survey_json = $1, schema_checksum = $2,
semver = COALESCE($3, semver)
WHERE id = $4`,
[JSON.stringify(data.surveyJson), checksum, data.semver ?? null, draft.id]
);
}
} else { } else {
// No draft — insert new draft version // No draft — insert new draft version
const semver = data.semver ?? "draft"; const semver = data.semver ?? "draft";
...@@ -290,36 +450,47 @@ export class PgFormRepository implements FormRepository { ...@@ -290,36 +450,47 @@ export class PgFormRepository implements FormRepository {
try { try {
await client.query("BEGIN"); await client.query("BEGIN");
// 1. Insert new version // 1. Promote existing unpublished draft if one exists; otherwise insert new published version
let versionResult: pg.QueryResult<Record<string, unknown>>; const draftResult = await client.query<{ id: string }>(
try { `SELECT id FROM forms.form_version WHERE form_id = $1 AND is_published = FALSE ORDER BY created_at DESC LIMIT 1`,
versionResult = await client.query<Record<string, unknown>>( [formId]
`INSERT INTO forms.form_version );
(form_id, semver, survey_json, question_index, schema_checksum,
is_published, published_at, published_by) let version: FormVersionRecord;
VALUES ($1, $2, $3, $4, $5, TRUE, NOW(), $6) if ((draftResult.rowCount ?? 0) > 0) {
const draftId = draftResult.rows[0]!.id;
const updateResult = await client.query<Record<string, unknown>>(
`UPDATE forms.form_version
SET semver = $1, survey_json = $2, question_index = $3,
schema_checksum = $4, is_published = TRUE,
published_at = NOW(), published_by = $5
WHERE id = $6
RETURNING id, form_id, semver, schema_checksum, survey_json, RETURNING id, form_id, semver, schema_checksum, survey_json,
question_index, is_published, published_at, created_at`, question_index, is_published, published_at, created_at`,
[ [data.semver, JSON.stringify(data.surveyJson), JSON.stringify(data.questionIndex), data.schemaChecksum, data.publishedBy ?? null, draftId]
formId,
data.semver,
JSON.stringify(data.surveyJson),
JSON.stringify(data.questionIndex),
data.schemaChecksum,
data.publishedBy ?? null,
]
); );
} catch (err: unknown) { version = toVersionRecord(updateResult.rows[0]!);
// Unique constraint violation on (form_id, semver) } else {
const pgErr = err as { code?: string }; let insertResult: pg.QueryResult<Record<string, unknown>>;
if (pgErr.code === "23505") { try {
throw new Error( insertResult = await client.query<Record<string, unknown>>(
`Version ${data.semver} already exists for this form. Choose a different semver.` `INSERT INTO forms.form_version
(form_id, semver, survey_json, question_index, schema_checksum,
is_published, published_at, published_by)
VALUES ($1, $2, $3, $4, $5, TRUE, NOW(), $6)
RETURNING id, form_id, semver, schema_checksum, survey_json,
question_index, is_published, published_at, created_at`,
[formId, data.semver, JSON.stringify(data.surveyJson), JSON.stringify(data.questionIndex), data.schemaChecksum, data.publishedBy ?? null]
); );
} catch (insertErr: unknown) {
const pgErr = insertErr as { code?: string };
if (pgErr.code === "23505") {
throw new Error(`Version ${data.semver} already exists for this form. Choose a different semver.`);
}
throw insertErr;
} }
throw err; version = toVersionRecord(insertResult.rows[0]!);
} }
const version = toVersionRecord(versionResult.rows[0]!);
// 2. Update form's current_version_id // 2. Update form's current_version_id
await client.query( await client.query(
...@@ -400,4 +571,52 @@ export class PgFormRepository implements FormRepository { ...@@ -400,4 +571,52 @@ export class PgFormRepository implements FormRepository {
limit, limit,
}; };
} }
async getOptionCatalogs(): Promise<FormOptionCatalogs> {
const sortKey = "ORDER BY sort_order, name";
const liveFilter = "WHERE deleted_at IS NULL AND is_active = TRUE";
const [
countries, organisations, subsidiaries, companies, entities, businessUnits, roles,
] = await Promise.all([
this.pool.query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_country ${liveFilter} ${sortKey}`,
),
this.pool.query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_organisation ${liveFilter} ${sortKey}`,
),
this.pool.query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_operating_subsidiaries ${liveFilter} ${sortKey}`,
),
this.pool.query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_company ${liveFilter} ${sortKey}`,
),
this.pool.query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_entity ${liveFilter} ${sortKey}`,
),
this.pool.query<Record<string, unknown>>(
`SELECT id, code, name FROM master.mst_business_unit ${liveFilter} ${sortKey}`,
),
this.pool.query<Record<string, unknown>>(
`SELECT id, code, name FROM auth.roles
WHERE deleted_at IS NULL ORDER BY name`,
),
]);
const mapOpt = (r: Record<string, unknown>): CatalogOption => ({
id: r["id"] as string,
code: (r["code"] as string | null) ?? null,
name: r["name"] as string,
});
return {
countries: countries.rows.map(mapOpt),
organisations: organisations.rows.map(mapOpt),
subsidiaries: subsidiaries.rows.map(mapOpt),
companies: companies.rows.map(mapOpt),
entities: entities.rows.map(mapOpt),
businessUnits: businessUnits.rows.map(mapOpt),
roles: roles.rows.map(mapOpt),
};
}
} }
...@@ -4,7 +4,9 @@ import { PgFormRepository } from "./repository.js"; ...@@ -4,7 +4,9 @@ import { PgFormRepository } from "./repository.js";
import { FormService } from "./service.js"; import { FormService } from "./service.js";
import { import {
CreateFormBodySchema, CreateFormBodySchema,
ListFormsQuerySchema,
ListSubmissionsQuerySchema, ListSubmissionsQuerySchema,
ManagersQuerySchema,
PublishVersionBodySchema, PublishVersionBodySchema,
SchemaQuerySchema, SchemaQuerySchema,
SubmissionBodySchema, SubmissionBodySchema,
...@@ -24,6 +26,41 @@ const FormRecordSchema = { ...@@ -24,6 +26,41 @@ const FormRecordSchema = {
currentVersionId: { type: ["string", "null"] }, currentVersionId: { type: ["string", "null"] },
createdAt: { type: "string" }, createdAt: { type: "string" },
updatedAt: { type: "string" }, updatedAt: { type: "string" },
// settings
frequencyCycle: { type: ["string", "null"] },
isInternal: { type: "boolean" },
internalStatus: { type: ["string", "null"] },
visibility: { type: "string" },
countryIds: { type: "array", items: { type: "string" } },
subsidiaryIds: { type: "array", items: { type: "string" } },
companyIds: { type: "array", items: { type: "string" } },
entityIds: { type: "array", items: { type: "string" } },
buIds: { type: "array", items: { type: "string" } },
technicalRoleIds: { type: "array", items: { type: "string" } },
operationalRoleIds: { type: "array", items: { type: "string" } },
notificationRoleIds: { type: "array", items: { type: "string" } },
},
} as const;
const CatalogOptionSchema = {
type: "object",
properties: {
id: { type: "string" },
code: { type: ["string", "null"] },
name: { type: "string" },
},
} as const;
const FormOptionsSchema = {
type: "object",
properties: {
countries: { type: "array", items: CatalogOptionSchema },
organisations: { type: "array", items: CatalogOptionSchema },
subsidiaries: { type: "array", items: CatalogOptionSchema },
companies: { type: "array", items: CatalogOptionSchema },
entities: { type: "array", items: CatalogOptionSchema },
businessUnits: { type: "array", items: CatalogOptionSchema },
roles: { type: "array", items: CatalogOptionSchema },
}, },
} as const; } as const;
...@@ -74,10 +111,37 @@ const service = new FormService(new PgFormRepository(), new MemoryCacheAdapter() ...@@ -74,10 +111,37 @@ const service = new FormService(new PgFormRepository(), new MemoryCacheAdapter()
export async function registerFormRoutes(app: FastifyInstance): Promise<void> { export async function registerFormRoutes(app: FastifyInstance): Promise<void> {
// ── GET /api/forms ──────────────────────────────────────────────────────── // ── GET /api/forms ────────────────────────────────────────────────────────
app.get("/api/forms", { app.get("/api/forms", async (request, reply) => {
schema: { response: { 200: { type: "array", items: FormRecordSchema } } }, const parsed = ListFormsQuerySchema.safeParse(request.query);
if (!parsed.success) return reply.code(400).send({ message: parsed.error.message });
return reply.send(await service.listForms(parsed.data));
});
// ── GET /api/forms/by-scope — alias for the conduct selection step ───────
// Same query as /api/forms but the path makes intent explicit and lets the
// gateway client cache responses separately.
app.get("/api/forms/by-scope", async (request, reply) => {
const parsed = ListFormsQuerySchema.safeParse(request.query);
if (!parsed.success) return reply.code(400).send({ message: parsed.error.message });
return reply.send({ items: await service.listForms(parsed.data) });
});
// ── GET /api/forms/options ────────────────────────────────────────────────
// Catalogs for the form settings panel (countries, orgs, BUs, roles, …).
app.get("/api/forms/options", {
schema: { response: { 200: FormOptionsSchema } },
}, async (_request, reply) => { }, async (_request, reply) => {
return reply.send(await service.listForms()); return reply.send(await service.getOptionCatalogs());
});
// ── GET /api/forms/managers ───────────────────────────────────────────────
// Users assigned to a given BU / entity / company / country — populates the
// "BU manager" dropdown on the conduct selection step.
app.get("/api/forms/managers", async (request, reply) => {
const parsed = ManagersQuerySchema.safeParse(request.query);
if (!parsed.success) return reply.code(400).send({ message: parsed.error.message });
const managers = await service.listManagers(parsed.data);
return reply.send({ items: managers });
}); });
// ── GET /api/forms/schema?formCode=...&version=... ──────────────────────── // ── GET /api/forms/schema?formCode=...&version=... ────────────────────────
...@@ -114,6 +178,7 @@ export async function registerFormRoutes(app: FastifyInstance): Promise<void> { ...@@ -114,6 +178,7 @@ export async function registerFormRoutes(app: FastifyInstance): Promise<void> {
schemaChecksum: parsed.data.schemaChecksum, schemaChecksum: parsed.data.schemaChecksum,
semver: parsed.data.semver, semver: parsed.data.semver,
moduleCode: parsed.data.moduleCode, moduleCode: parsed.data.moduleCode,
settings: parsed.data.settings,
}); });
return reply.code(201).send(form); return reply.code(201).send(form);
} catch (err) { } catch (err) {
...@@ -140,6 +205,7 @@ export async function registerFormRoutes(app: FastifyInstance): Promise<void> { ...@@ -140,6 +205,7 @@ export async function registerFormRoutes(app: FastifyInstance): Promise<void> {
surveyJson: parsed.data.surveyJson, surveyJson: parsed.data.surveyJson,
schemaChecksum: parsed.data.schemaChecksum, schemaChecksum: parsed.data.schemaChecksum,
semver: parsed.data.semver, semver: parsed.data.semver,
settings: parsed.data.settings,
}); });
if (!form) return reply.code(404).send({ message: `Form "${code}" not found` }); if (!form) return reply.code(404).send({ message: `Form "${code}" not found` });
return reply.send(form); return reply.send(form);
......
...@@ -2,6 +2,7 @@ import type { CachePort } from "../cache/cache-port.js"; ...@@ -2,6 +2,7 @@ import type { CachePort } from "../cache/cache-port.js";
import { config } from "../../config.js"; import { config } from "../../config.js";
import type { import type {
CreateFormInput, CreateFormInput,
FormOptionCatalogs,
FormRecord, FormRecord,
FormRepository, FormRepository,
FormVersionRecord, FormVersionRecord,
...@@ -20,26 +21,25 @@ export class FormService { ...@@ -20,26 +21,25 @@ export class FormService {
private readonly cache: CachePort private readonly cache: CachePort
) {} ) {}
async listForms(): Promise<FormRecord[]> { async listForms(filter?: import("./domain.js").ListFormsFilter): Promise<FormRecord[]> {
// Filtered lists bypass cache (they're cheap and need to reflect admin
// changes immediately). Unfiltered all-forms is cached at schemaTtlSeconds.
const hasFilter = !!(filter && Object.keys(filter).length > 0);
if (hasFilter) {
return this.repo.listForms(filter);
}
const key = "forms:list"; const key = "forms:list";
const cached = await this.cache.get<FormRecord[]>(key); const cached = await this.cache.get<FormRecord[]>(key);
if (cached) return cached; if (cached) return cached;
const forms = await this.repo.listForms(); const forms = await this.repo.listForms();
await this.cache.set(key, forms, config.schemaTtlSeconds); await this.cache.set(key, forms, config.schemaTtlSeconds);
return forms; return forms;
} }
async getFormByCode(code: string): Promise<FormWithVersions | null> { async getFormByCode(code: string): Promise<FormWithVersions | null> {
const key = `forms:detail:${code}`; // No cache — admin edits (publish, settings) need to reflect immediately
const cached = await this.cache.get<FormWithVersions>(key); // and the underlying SELECT is one indexed lookup + one ORDER BY DESC scan.
if (cached) return cached; return this.repo.getFormByCode(code);
const form = await this.repo.getFormByCode(code);
if (form) {
await this.cache.set(key, form, config.schemaTtlSeconds);
}
return form;
} }
/** /**
...@@ -161,4 +161,19 @@ export class FormService { ...@@ -161,4 +161,19 @@ export class FormService {
async listSubmissions(formId: string, filter: SubmissionFilter): Promise<SubmissionPage> { async listSubmissions(formId: string, filter: SubmissionFilter): Promise<SubmissionPage> {
return this.repo.listSubmissions(formId, filter); return this.repo.listSubmissions(formId, filter);
} }
async getOptionCatalogs(): Promise<FormOptionCatalogs> {
const key = "forms:options";
const cached = await this.cache.get<FormOptionCatalogs>(key);
if (cached) return cached;
const cats = await this.repo.getOptionCatalogs();
await this.cache.set(key, cats, config.schemaTtlSeconds);
return cats;
}
async listManagers(filter: {
buId?: string; entityId?: string; companyId?: string; countryId?: string; limit?: number;
}): Promise<import("./domain.js").ManagerOption[]> {
return this.repo.listManagers(filter);
}
} }
...@@ -41,6 +41,30 @@ export interface SubmissionRequest { ...@@ -41,6 +41,30 @@ export interface SubmissionRequest {
// ─── New Zod schemas ────────────────────────────────────────────────────────── // ─── New Zod schemas ──────────────────────────────────────────────────────────
/** Form-level settings shared by create and update payloads */
export const FrequencyCycleEnum = z.enum([
"adhoc","daily","weekly","biweekly","monthly","quarterly","semiannual","yearly",
]);
export const VisibilityEnum = z.enum(["visible","hidden"]);
const UuidArray = z.array(z.string().uuid()).default([]);
export const FormSettingsSchema = z.object({
frequencyCycle: FrequencyCycleEnum.nullable().optional(),
isInternal: z.boolean().optional(),
internalStatus: z.string().max(64).nullable().optional(),
visibility: VisibilityEnum.optional(),
countryIds: UuidArray.optional(),
subsidiaryIds: UuidArray.optional(),
companyIds: UuidArray.optional(),
entityIds: UuidArray.optional(),
buIds: UuidArray.optional(),
technicalRoleIds: UuidArray.optional(),
operationalRoleIds: UuidArray.optional(),
notificationRoleIds: UuidArray.optional(),
}).strict();
export type FormSettingsBody = z.infer<typeof FormSettingsSchema>;
/** Legacy schema endpoint query: formCode + version */ /** Legacy schema endpoint query: formCode + version */
export const SchemaQuerySchema = z.object({ export const SchemaQuerySchema = z.object({
formCode: z.string().min(1), formCode: z.string().min(1),
...@@ -48,6 +72,31 @@ export const SchemaQuerySchema = z.object({ ...@@ -48,6 +72,31 @@ export const SchemaQuerySchema = z.object({
}); });
export type SchemaQuery = z.infer<typeof SchemaQuerySchema>; export type SchemaQuery = z.infer<typeof SchemaQuerySchema>;
/** Filter for GET /api/forms */
export const ListFormsQuerySchema = z.object({
moduleCode: z.string().optional(),
visibility: VisibilityEnum.optional(),
isActive: z.coerce.boolean().optional(),
q: z.string().optional(),
// scope filters
countryId: z.string().uuid().optional(),
subsidiaryId: z.string().uuid().optional(),
companyId: z.string().uuid().optional(),
entityId: z.string().uuid().optional(),
buId: z.string().uuid().optional(),
});
export type ListFormsQuery = z.infer<typeof ListFormsQuerySchema>;
/** Filter for GET /api/forms/managers */
export const ManagersQuerySchema = z.object({
buId: z.string().uuid().optional(),
entityId: z.string().uuid().optional(),
companyId: z.string().uuid().optional(),
countryId: z.string().uuid().optional(),
limit: z.coerce.number().int().positive().max(200).optional(),
});
export type ManagersQuery = z.infer<typeof ManagersQuerySchema>;
/** Submit answers to a form */ /** Submit answers to a form */
export const SubmissionBodySchema = z.object({ export const SubmissionBodySchema = z.object({
formCode: z.string().min(1), formCode: z.string().min(1),
...@@ -64,10 +113,11 @@ export const CreateFormBodySchema = z.object({ ...@@ -64,10 +113,11 @@ export const CreateFormBodySchema = z.object({
displayName: z.string().min(1), displayName: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
surveyJson: z.record(z.string(), z.unknown()), surveyJson: z.record(z.string(), z.unknown()),
schemaChecksum: z.string().min(1), schemaChecksum: z.string().optional().default(""),
semver: z.string().optional().default("1.0.0"), semver: z.string().optional().default("1.0.0"),
/** platform.module.code — e.g. "audit", "inspection", "checklist" */ /** platform.module.code — e.g. "audit", "inspection", "checklist" */
moduleCode: z.string().optional().default("audit"), moduleCode: z.string().optional().default("audit"),
settings: FormSettingsSchema.optional(),
}); });
export type CreateFormBody = z.infer<typeof CreateFormBodySchema>; export type CreateFormBody = z.infer<typeof CreateFormBodySchema>;
...@@ -79,6 +129,7 @@ export const UpdateFormBodySchema = z.object({ ...@@ -79,6 +129,7 @@ export const UpdateFormBodySchema = z.object({
surveyJson: z.record(z.string(), z.unknown()).optional(), surveyJson: z.record(z.string(), z.unknown()).optional(),
schemaChecksum: z.string().optional(), schemaChecksum: z.string().optional(),
semver: z.string().optional(), semver: z.string().optional(),
settings: FormSettingsSchema.optional(),
}); });
export type UpdateFormBody = z.infer<typeof UpdateFormBodySchema>; export type UpdateFormBody = z.infer<typeof UpdateFormBodySchema>;
...@@ -87,7 +138,7 @@ export const PublishVersionBodySchema = z.object({ ...@@ -87,7 +138,7 @@ export const PublishVersionBodySchema = z.object({
semver: z.string().min(1), semver: z.string().min(1),
surveyJson: z.record(z.string(), z.unknown()), surveyJson: z.record(z.string(), z.unknown()),
questionIndex: z.record(z.string(), z.unknown()).optional().default({}), questionIndex: z.record(z.string(), z.unknown()).optional().default({}),
schemaChecksum: z.string().min(1), schemaChecksum: z.string().optional().default(""),
publishedBy: z.string().uuid().optional(), publishedBy: z.string().uuid().optional(),
}); });
export type PublishVersionBody = z.infer<typeof PublishVersionBodySchema>; export type PublishVersionBody = z.infer<typeof PublishVersionBodySchema>;
......
import { BlobServiceClient } from "@azure/storage-blob"; import { randomUUID } from "node:crypto";
import { config } from "../../config.js"; import { uploadBufferToBlob, buildReadUrl } from "../../services/blob-storage.js";
export async function uploadToAzure(buffer: Buffer, originalName: string, mimeType: string): Promise<string> { export async function uploadToAzure(
const serviceClient = BlobServiceClient.fromConnectionString(config.azureStorageConnectionString); buffer: Buffer,
const containerClient = serviceClient.getContainerClient(config.azureBlobContainer); originalName: string,
const blobName = `${Date.now()}-${originalName.replace(/[^a-zA-Z0-9._-]/g, "_")}`; mimeType: string,
const blockBlobClient = containerClient.getBlockBlobClient(blobName); ): Promise<string> {
await blockBlobClient.upload(buffer, buffer.length, { const safe = originalName.replace(/[^a-zA-Z0-9._-]/g, "_");
blobHTTPHeaders: { blobContentType: mimeType } const blobPath = `poc/${Date.now()}-${randomUUID().slice(0, 8)}-${safe}`;
}); await uploadBufferToBlob({ blobPath, buffer, contentType: mimeType });
return blockBlobClient.url; return buildReadUrl(blobPath);
} }
// ─── Status / state enums (mirror DB check constraints) ──────────────────────
export const SUBMISSION_STATUSES = [
"draft",
"submitted",
"approved",
"rejected",
"archived",
] as const;
export type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];
export const MODERATION_STATES = ["pending", "flagged", "clear"] as const;
export type ModerationState = (typeof MODERATION_STATES)[number];
// ─── Records ─────────────────────────────────────────────────────────────────
export interface OrgScope {
orgId?: string | null;
companyId?: string | null;
countryId?: string | null;
entityId?: string | null;
buId?: string | null;
deptId?: string | null;
areaId?: string | null;
}
export interface SubmissionRecord extends OrgScope {
id: string;
submittedAt: string;
formId: string;
formVersionId: string;
schemaChecksum: string;
moduleCode: string;
submittedBy: string | null;
submissionCode: string;
status: SubmissionStatus;
moderationState: ModerationState | null;
answers: Record<string, unknown>;
conductScore: number | null;
sourceRefId: string | null;
workflowState: string | null;
completedAt: string | null;
createdAt: string;
updatedAt: string | null;
}
export interface SubmissionFileRecord {
id: string;
submissionId: string;
submissionSubmittedAt: string;
questionCode: string | null;
fileName: string;
blobPath: string;
blobContainer: string;
fileSizeBytes: number | null;
contentType: string | null;
sha256Hex: string | null;
uploadedBy: string | null;
uploadedAt: string;
}
export interface SubmissionEventRecord {
id: string;
submissionId: string;
submissionSubmittedAt: string;
eventType: string;
actorUserId: string | null;
diff: Record<string, unknown> | null;
occurredAt: string;
}
export interface SubmissionWithFiles extends SubmissionRecord {
files: SubmissionFileRecord[];
}
// ─── Inputs ──────────────────────────────────────────────────────────────────
export interface CreateSubmissionInput extends OrgScope {
formId: string;
formVersionId: string;
schemaChecksum: string;
moduleCode: string;
submittedBy?: string | null;
answers: Record<string, unknown>;
status?: SubmissionStatus;
conductScore?: number | null;
sourceRefId?: string | null;
workflowState?: string | null;
}
export interface UpdateSubmissionInput extends OrgScope {
answers?: Record<string, unknown>;
status?: SubmissionStatus;
moderationState?: ModerationState | null;
conductScore?: number | null;
workflowState?: string | null;
completedAt?: string | null;
}
export interface ListSubmissionsFilter {
formId?: string;
moduleCode?: string;
status?: SubmissionStatus | SubmissionStatus[];
submittedBy?: string;
/** Inclusive ISO date / timestamp string */
fromDate?: string;
/** Inclusive ISO date / timestamp string */
toDate?: string;
/** JSON containment fragment on `answers` (jsonb @> path-ops) */
answersContains?: Record<string, unknown>;
/** Org filters */
orgId?: string;
companyId?: string;
countryId?: string;
entityId?: string;
buId?: string;
deptId?: string;
areaId?: string;
page: number;
limit: number;
}
export interface SubmissionPage {
items: SubmissionRecord[];
total: number;
page: number;
limit: number;
}
export interface AttachFileInput {
questionCode?: string | null;
fileName: string;
blobPath: string;
blobContainer: string;
fileSizeBytes?: number;
contentType?: string;
sha256Hex?: string;
uploadedBy?: string | null;
}
import { randomUUID } from "node:crypto";
import type pg from "pg";
import { getPool } from "../../db/pg-pool.js";
import type {
AttachFileInput,
CreateSubmissionInput,
ListSubmissionsFilter,
SubmissionEventRecord,
SubmissionFileRecord,
SubmissionPage,
SubmissionRecord,
SubmissionStatus,
SubmissionWithFiles,
UpdateSubmissionInput,
} from "./domain.js";
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Postgres `timestamptz` keeps microsecond precision; JS Dates only ms.
* When the caller round-trips an ISO timestamp back to us, we compare against
* a 1-ms window so the row issued from `Date.toISOString()` still matches and
* partition pruning continues to work.
*/
function tsWindow(column: string, paramIdx: number): string {
return `${column} >= $${paramIdx}::timestamptz AND ${column} < $${paramIdx}::timestamptz + interval '1 millisecond'`;
}
// ─── Row mappers ──────────────────────────────────────────────────────────────
function toSubmission(row: Record<string, unknown>): SubmissionRecord {
return {
id: row["id"] as string,
submittedAt: (row["submitted_at"] as Date).toISOString(),
formId: row["form_id"] as string,
formVersionId: row["form_version_id"] as string,
schemaChecksum: row["schema_checksum"] as string,
moduleCode: row["module_code"] as string,
submittedBy: (row["submitted_by"] as string | null) ?? null,
submissionCode: row["submission_code"] as string,
status: row["status"] as SubmissionStatus,
moderationState: (row["moderation_state"] as SubmissionRecord["moderationState"]) ?? null,
answers: (row["answers"] as Record<string, unknown>) ?? {},
conductScore: row["conduct_score"] != null ? Number(row["conduct_score"]) : null,
sourceRefId: (row["source_ref_id"] as string | null) ?? null,
workflowState: (row["workflow_state"] as string | null) ?? null,
completedAt: row["completed_at"] != null
? (row["completed_at"] as Date).toISOString()
: null,
createdAt: (row["created_at"] as Date).toISOString(),
updatedAt: row["updated_at"] != null
? (row["updated_at"] as Date).toISOString()
: null,
orgId: (row["org_id"] as string | null) ?? null,
companyId: (row["company_id"] as string | null) ?? null,
countryId: (row["country_id"] as string | null) ?? null,
entityId: (row["entity_id"] as string | null) ?? null,
buId: (row["bu_id"] as string | null) ?? null,
deptId: (row["dept_id"] as string | null) ?? null,
areaId: (row["area_id"] as string | null) ?? null,
};
}
function toFile(row: Record<string, unknown>): SubmissionFileRecord {
return {
id: row["id"] as string,
submissionId: row["submission_id"] as string,
submissionSubmittedAt: (row["submission_submitted_at"] as Date).toISOString(),
questionCode: (row["question_code"] as string | null) ?? null,
fileName: row["file_name"] as string,
blobPath: row["blob_path"] as string,
blobContainer: row["blob_container"] as string,
fileSizeBytes: row["file_size_bytes"] != null ? Number(row["file_size_bytes"]) : null,
contentType: (row["content_type"] as string | null) ?? null,
sha256Hex: (row["sha256_hex"] as string | null) ?? null,
uploadedBy: (row["uploaded_by"] as string | null) ?? null,
uploadedAt: (row["uploaded_at"] as Date).toISOString(),
};
}
function toEvent(row: Record<string, unknown>): SubmissionEventRecord {
return {
id: String(row["id"]),
submissionId: row["submission_id"] as string,
submissionSubmittedAt: (row["submission_submitted_at"] as Date).toISOString(),
eventType: row["event_type"] as string,
actorUserId: (row["actor_user_id"] as string | null) ?? null,
diff: (row["diff"] as Record<string, unknown> | null) ?? null,
occurredAt: (row["occurred_at"] as Date).toISOString(),
};
}
// ─── Repository ───────────────────────────────────────────────────────────────
export class PgSubmissionRepository {
private readonly pool: pg.Pool;
constructor() {
this.pool = getPool();
}
/** Look up surveyJson for a form version — used by the score calculator. */
async getFormVersionSurveyJson(
formVersionId: string,
): Promise<Record<string, unknown> | null> {
const r = await this.pool.query<{ survey_json: Record<string, unknown> }>(
`SELECT survey_json FROM forms.form_version WHERE id = $1`,
[formVersionId],
);
if (r.rowCount === 0) return null;
return r.rows[0]!.survey_json;
}
// ── Create / update / fetch ────────────────────────────────────────────────
async create(data: CreateSubmissionInput): Promise<SubmissionRecord> {
const submissionCode = `SUB_${randomUUID().replace(/-/g, "").slice(0, 16).toUpperCase()}`;
const result = await this.pool.query<Record<string, unknown>>(
`INSERT INTO forms.submission (
submitted_at, form_id, form_version_id, schema_checksum, module_code,
submitted_by, submission_code, status, answers,
conduct_score, source_ref_id, workflow_state,
org_id, company_id, country_id, entity_id, bu_id, dept_id, area_id
)
VALUES (
NOW(), $1, $2, $3, $4,
$5, $6, $7, $8,
$9, $10, $11,
$12, $13, $14, $15, $16, $17, $18
)
RETURNING *`,
[
data.formId,
data.formVersionId,
data.schemaChecksum,
data.moduleCode,
data.submittedBy ?? null,
submissionCode,
data.status ?? "draft",
JSON.stringify(data.answers),
data.conductScore ?? null,
data.sourceRefId ?? null,
data.workflowState ?? null,
data.orgId ?? null,
data.companyId ?? null,
data.countryId ?? null,
data.entityId ?? null,
data.buId ?? null,
data.deptId ?? null,
data.areaId ?? null,
],
);
return toSubmission(result.rows[0]!);
}
/**
* Lookup by id alone scans all partitions (no global index on id). When the
* caller has `submittedAt`, pass it for fast partition pruning.
*
* Note: Postgres `timestamptz` keeps microsecond precision; JS Dates round to
* milliseconds. We compare on a 1-ms window so a JS-issued ISO string still
* matches the row it came from (and partition pruning still applies).
*/
async getById(id: string, submittedAt?: string): Promise<SubmissionWithFiles | null> {
const conditions: string[] = ["id = $1", "deleted_at IS NULL"];
const params: unknown[] = [id];
if (submittedAt) {
params.push(submittedAt);
conditions.push(tsWindow("submitted_at", params.length));
}
const subResult = await this.pool.query<Record<string, unknown>>(
`SELECT * FROM forms.submission WHERE ${conditions.join(" AND ")} LIMIT 1`,
params,
);
if (subResult.rowCount === 0) return null;
const sub = toSubmission(subResult.rows[0]!);
const filesResult = await this.pool.query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file
WHERE submission_id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL
ORDER BY uploaded_at DESC`,
[sub.id, sub.submittedAt],
);
return { ...sub, files: filesResult.rows.map(toFile) };
}
async getByCode(code: string): Promise<SubmissionWithFiles | null> {
const subResult = await this.pool.query<Record<string, unknown>>(
`SELECT * FROM forms.submission
WHERE submission_code = $1 AND deleted_at IS NULL
ORDER BY submitted_at DESC LIMIT 1`,
[code],
);
if (subResult.rowCount === 0) return null;
const sub = toSubmission(subResult.rows[0]!);
const filesResult = await this.pool.query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file
WHERE submission_id = $1 AND submission_submitted_at = $2 AND deleted_at IS NULL
ORDER BY uploaded_at DESC`,
[sub.id, sub.submittedAt],
);
return { ...sub, files: filesResult.rows.map(toFile) };
}
async update(
id: string,
submittedAt: string,
data: UpdateSubmissionInput,
): Promise<SubmissionRecord | null> {
const sets: string[] = ["updated_at = NOW()"];
const vals: unknown[] = [];
const push = (col: string, v: unknown): void => {
vals.push(v);
sets.push(`${col} = $${vals.length}`);
};
if (data.answers !== undefined) push("answers", JSON.stringify(data.answers));
if (data.status !== undefined) push("status", data.status);
if (data.moderationState !== undefined) push("moderation_state", data.moderationState);
if (data.conductScore !== undefined) push("conduct_score", data.conductScore);
if (data.workflowState !== undefined) push("workflow_state", data.workflowState);
if (data.completedAt !== undefined) push("completed_at", data.completedAt);
if (data.orgId !== undefined) push("org_id", data.orgId);
if (data.companyId !== undefined) push("company_id", data.companyId);
if (data.countryId !== undefined) push("country_id", data.countryId);
if (data.entityId !== undefined) push("entity_id", data.entityId);
if (data.buId !== undefined) push("bu_id", data.buId);
if (data.deptId !== undefined) push("dept_id", data.deptId);
if (data.areaId !== undefined) push("area_id", data.areaId);
if (vals.length === 0) {
// Nothing to update — return current row
const r = await this.getById(id, submittedAt);
return r ?? null;
}
vals.push(id);
vals.push(submittedAt);
const idIdx = vals.length - 1;
const tsIdx = vals.length;
const result = await this.pool.query<Record<string, unknown>>(
`UPDATE forms.submission SET ${sets.join(", ")}
WHERE id = $${idIdx} AND ${tsWindow("submitted_at", tsIdx)}
AND deleted_at IS NULL
RETURNING *`,
vals,
);
if ((result.rowCount ?? 0) === 0) return null;
return toSubmission(result.rows[0]!);
}
async softDelete(id: string, submittedAt: string): Promise<boolean> {
const result = await this.pool.query(
`UPDATE forms.submission
SET deleted_at = NOW(), updated_at = NOW()
WHERE id = $1 AND ${tsWindow("submitted_at", 2)} AND deleted_at IS NULL`,
[id, submittedAt],
);
return (result.rowCount ?? 0) > 0;
}
// ── Listing ────────────────────────────────────────────────────────────────
async list(filter: ListSubmissionsFilter): Promise<SubmissionPage> {
const conditions: string[] = ["deleted_at IS NULL"];
const params: unknown[] = [];
const push = (clause: (idx: number) => string, value: unknown): void => {
params.push(value);
conditions.push(clause(params.length));
};
if (filter.formId) push((i) => `form_id = $${i}`, filter.formId);
if (filter.moduleCode) push((i) => `module_code = $${i}`, filter.moduleCode);
if (filter.submittedBy) push((i) => `submitted_by = $${i}`, filter.submittedBy);
if (filter.status) {
const statuses = Array.isArray(filter.status) ? filter.status : [filter.status];
params.push(statuses);
conditions.push(`status = ANY($${params.length}::text[])`);
}
if (filter.fromDate) push((i) => `submitted_at >= $${i}`, filter.fromDate);
if (filter.toDate) push((i) => `submitted_at <= $${i}`, filter.toDate);
if (filter.orgId) push((i) => `org_id = $${i}`, filter.orgId);
if (filter.companyId) push((i) => `company_id = $${i}`, filter.companyId);
if (filter.countryId) push((i) => `country_id = $${i}`, filter.countryId);
if (filter.entityId) push((i) => `entity_id = $${i}`, filter.entityId);
if (filter.buId) push((i) => `bu_id = $${i}`, filter.buId);
if (filter.deptId) push((i) => `dept_id = $${i}`, filter.deptId);
if (filter.areaId) push((i) => `area_id = $${i}`, filter.areaId);
if (filter.answersContains && Object.keys(filter.answersContains).length > 0) {
params.push(JSON.stringify(filter.answersContains));
conditions.push(`answers @> $${params.length}::jsonb`);
}
const whereClause = "WHERE " + conditions.join(" AND ");
const countResult = await this.pool.query<{ count: string }>(
`SELECT COUNT(*) AS count FROM forms.submission ${whereClause}`,
params,
);
const total = parseInt(countResult.rows[0]?.count ?? "0", 10);
const limit = filter.limit;
const offset = (filter.page - 1) * limit;
const dataResult = await this.pool.query<Record<string, unknown>>(
`SELECT * FROM forms.submission
${whereClause}
ORDER BY submitted_at DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, limit, offset],
);
return {
items: dataResult.rows.map(toSubmission),
total,
page: filter.page,
limit,
};
}
// ── Files ──────────────────────────────────────────────────────────────────
async attachFile(
submissionId: string,
submissionSubmittedAt: string,
file: AttachFileInput,
): Promise<SubmissionFileRecord> {
const result = await this.pool.query<Record<string, unknown>>(
`INSERT INTO forms.submission_file (
submission_id, submission_submitted_at,
question_code, file_name, blob_path, blob_container,
file_size_bytes, content_type, sha256_hex, uploaded_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[
submissionId,
submissionSubmittedAt,
file.questionCode ?? null,
file.fileName,
file.blobPath,
file.blobContainer,
file.fileSizeBytes ?? null,
file.contentType ?? null,
file.sha256Hex ?? null,
file.uploadedBy ?? null,
],
);
return toFile(result.rows[0]!);
}
async listFiles(
submissionId: string,
submissionSubmittedAt: string,
): Promise<SubmissionFileRecord[]> {
const result = await this.pool.query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file
WHERE submission_id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL
ORDER BY uploaded_at DESC`,
[submissionId, submissionSubmittedAt],
);
return result.rows.map(toFile);
}
async getFile(
fileId: string,
submissionSubmittedAt: string,
): Promise<SubmissionFileRecord | null> {
const result = await this.pool.query<Record<string, unknown>>(
`SELECT * FROM forms.submission_file
WHERE id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL
LIMIT 1`,
[fileId, submissionSubmittedAt],
);
if (result.rowCount === 0) return null;
return toFile(result.rows[0]!);
}
async softDeleteFile(
fileId: string,
submissionSubmittedAt: string,
): Promise<boolean> {
const result = await this.pool.query(
`UPDATE forms.submission_file
SET deleted_at = NOW()
WHERE id = $1 AND ${tsWindow("submission_submitted_at", 2)} AND deleted_at IS NULL`,
[fileId, submissionSubmittedAt],
);
return (result.rowCount ?? 0) > 0;
}
// ── Events (audit timeline) ────────────────────────────────────────────────
async recordEvent(
submissionId: string,
submissionSubmittedAt: string,
eventType: string,
actorUserId: string | null,
diff: Record<string, unknown> | null,
): Promise<SubmissionEventRecord> {
const result = await this.pool.query<Record<string, unknown>>(
`INSERT INTO forms.submission_event (
submission_id, submission_submitted_at,
event_type, actor_user_id, diff
)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[
submissionId,
submissionSubmittedAt,
eventType,
actorUserId,
diff != null ? JSON.stringify(diff) : null,
],
);
return toEvent(result.rows[0]!);
}
async listEvents(submissionId: string): Promise<SubmissionEventRecord[]> {
const result = await this.pool.query<Record<string, unknown>>(
`SELECT * FROM forms.submission_event
WHERE submission_id = $1
ORDER BY occurred_at DESC, id DESC
LIMIT 500`,
[submissionId],
);
return result.rows.map(toEvent);
}
}
export const submissionRepository = new PgSubmissionRepository();
import type { FastifyInstance } from "fastify";
import { config } from "../../config.js";
import {
buildReadUrl,
deleteBlob,
generateUploadSas,
} from "../../services/blob-storage.js";
import type {
AttachFileInput,
CreateSubmissionInput,
ListSubmissionsFilter,
SubmissionStatus,
UpdateSubmissionInput,
} from "./domain.js";
import { submissionRepository as repo } from "./repository.js";
import { calculateConductScore, canTransition, nextStates } from "./service.js";
import {
AttachFileBodySchema,
CreateSubmissionBodySchema,
GetSubmissionParamsSchema,
GetSubmissionQuerySchema,
ListSubmissionsQuerySchema,
TransitionSubmissionBodySchema,
UpdateSubmissionBodySchema,
UploadSasBodySchema,
} from "./types.js";
const actorOf = (request: { headers: Record<string, unknown> }): string | null => {
const id = request.headers["x-maf-user-id"];
return typeof id === "string" && id.length > 0 ? id : null;
};
export async function registerSubmissionRoutes(app: FastifyInstance): Promise<void> {
// ── Create ────────────────────────────────────────────────────────────────
app.post("/api/submissions", async (request, reply) => {
const parsed = CreateSubmissionBodySchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid body", errors: parsed.error.issues });
}
const body = parsed.data;
const input: CreateSubmissionInput = {
formId: body.formId,
formVersionId: body.formVersionId,
schemaChecksum: body.schemaChecksum,
moduleCode: body.moduleCode,
submittedBy: body.submittedBy ?? actorOf(request),
answers: body.answers,
status: body.status,
conductScore: body.conductScore ?? null,
sourceRefId: body.sourceRefId ?? null,
workflowState: body.workflowState ?? null,
orgId: body.orgId ?? null,
companyId: body.companyId ?? null,
countryId: body.countryId ?? null,
entityId: body.entityId ?? null,
buId: body.buId ?? null,
deptId: body.deptId ?? null,
areaId: body.areaId ?? null,
};
const created = await repo.create(input);
await repo.recordEvent(
created.id,
created.submittedAt,
"created",
actorOf(request),
{ status: created.status },
);
return reply.code(201).send(created);
});
// ── List ──────────────────────────────────────────────────────────────────
app.get("/api/submissions", async (request, reply) => {
const parsed = ListSubmissionsQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.code(400).send({ message: "Invalid query", errors: parsed.error.issues });
}
const q = parsed.data;
const statuses = q.status
? (q.status.split(",").map((s) => s.trim()).filter(Boolean) as SubmissionStatus[])
: undefined;
const filter: ListSubmissionsFilter = {
formId: q.formId,
moduleCode: q.moduleCode,
status: statuses && statuses.length > 0
? (statuses.length === 1 ? statuses[0] : statuses)
: undefined,
submittedBy: q.submittedBy,
fromDate: q.fromDate,
toDate: q.toDate,
orgId: q.orgId,
companyId: q.companyId,
countryId: q.countryId,
entityId: q.entityId,
buId: q.buId,
deptId: q.deptId,
areaId: q.areaId,
page: q.page,
limit: q.limit,
};
const page = await repo.list(filter);
return reply.send(page);
});
// ── Get one ───────────────────────────────────────────────────────────────
app.get("/api/submissions/:id", async (request, reply) => {
const paramsParsed = GetSubmissionParamsSchema.safeParse(request.params);
const queryParsed = GetSubmissionQuerySchema.safeParse(request.query);
if (!paramsParsed.success || !queryParsed.success) {
return reply.code(400).send({ message: "Invalid request" });
}
const found = await repo.getById(paramsParsed.data.id, queryParsed.data.submittedAt);
if (!found) return reply.code(404).send({ message: "Submission not found" });
const files = found.files.map((f) => ({
...f,
downloadUrl: buildReadUrl(f.blobPath),
}));
return reply.send({ ...found, files });
});
// ── Get by submission_code ────────────────────────────────────────────────
app.get("/api/submissions/by-code/:code", async (request, reply) => {
const code = (request.params as { code?: string }).code;
if (!code) return reply.code(400).send({ message: "code required" });
const found = await repo.getByCode(code);
if (!found) return reply.code(404).send({ message: "Submission not found" });
const files = found.files.map((f) => ({
...f,
downloadUrl: buildReadUrl(f.blobPath),
}));
return reply.send({ ...found, files });
});
// ── Update (partial) ──────────────────────────────────────────────────────
app.patch("/api/submissions/:id", async (request, reply) => {
const paramsParsed = GetSubmissionParamsSchema.safeParse(request.params);
const queryParsed = GetSubmissionQuerySchema.safeParse(request.query);
const bodyParsed = UpdateSubmissionBodySchema.safeParse(request.body);
if (!paramsParsed.success || !queryParsed.success || !bodyParsed.success) {
return reply.code(400).send({
message: "Invalid request",
errors: bodyParsed.success ? undefined : bodyParsed.error.issues,
});
}
const { id } = paramsParsed.data;
const submittedAt = queryParsed.data.submittedAt;
if (!submittedAt) {
return reply.code(400).send({
message: "submittedAt query param is required for updates (partition key)",
});
}
const existing = await repo.getById(id, submittedAt);
if (!existing) return reply.code(404).send({ message: "Submission not found" });
// Block status transitions through PATCH — use /transition for those
if (bodyParsed.data.status && bodyParsed.data.status !== existing.status) {
return reply.code(409).send({
message: "Use POST /api/submissions/:id/transition to change status",
});
}
const data: UpdateSubmissionInput = bodyParsed.data;
const updated = await repo.update(id, submittedAt, data);
if (!updated) return reply.code(404).send({ message: "Submission not found" });
await repo.recordEvent(
updated.id,
updated.submittedAt,
"updated",
actorOf(request),
{ fields: Object.keys(bodyParsed.data) },
);
return reply.send(updated);
});
// ── Status transition ─────────────────────────────────────────────────────
app.post("/api/submissions/:id/transition", async (request, reply) => {
const paramsParsed = GetSubmissionParamsSchema.safeParse(request.params);
const queryParsed = GetSubmissionQuerySchema.safeParse(request.query);
const bodyParsed = TransitionSubmissionBodySchema.safeParse(request.body);
if (!paramsParsed.success || !queryParsed.success || !bodyParsed.success) {
return reply.code(400).send({ message: "Invalid request" });
}
const submittedAt = queryParsed.data.submittedAt;
if (!submittedAt) {
return reply.code(400).send({ message: "submittedAt query param required" });
}
const existing = await repo.getById(paramsParsed.data.id, submittedAt);
if (!existing) return reply.code(404).send({ message: "Submission not found" });
const to = bodyParsed.data.to;
if (!canTransition(existing.status, to)) {
return reply.code(409).send({
message: `Cannot transition ${existing.status}${to}`,
allowed: nextStates(existing.status),
});
}
const patch: UpdateSubmissionInput = { status: to };
if (to === "approved" || to === "submitted") patch.completedAt = new Date().toISOString();
// On draft→submitted: compute conduct score from the form definition + answers
if (existing.status === "draft" && to === "submitted") {
const surveyJson = await repo.getFormVersionSurveyJson(existing.formVersionId);
if (surveyJson) {
const score = calculateConductScore(surveyJson, existing.answers);
if (score !== null) patch.conductScore = score;
}
}
const updated = await repo.update(existing.id, existing.submittedAt, patch);
if (!updated) return reply.code(404).send({ message: "Submission not found" });
await repo.recordEvent(
updated.id,
updated.submittedAt,
`status_changed:${existing.status}_to_${to}`,
actorOf(request),
{ from: existing.status, to, reason: bodyParsed.data.reason ?? null },
);
return reply.send(updated);
});
// ── Soft-delete ──────────────────────────────────────────────────────────
app.delete("/api/submissions/:id", async (request, reply) => {
const paramsParsed = GetSubmissionParamsSchema.safeParse(request.params);
const queryParsed = GetSubmissionQuerySchema.safeParse(request.query);
if (!paramsParsed.success || !queryParsed.success) {
return reply.code(400).send({ message: "Invalid request" });
}
const submittedAt = queryParsed.data.submittedAt;
if (!submittedAt) {
return reply.code(400).send({ message: "submittedAt query param required" });
}
const ok = await repo.softDelete(paramsParsed.data.id, submittedAt);
if (!ok) return reply.code(404).send({ message: "Submission not found" });
await repo.recordEvent(
paramsParsed.data.id,
submittedAt,
"deleted",
actorOf(request),
null,
);
return reply.code(204).send();
});
// ── Events / audit timeline ──────────────────────────────────────────────
app.get("/api/submissions/:id/events", async (request, reply) => {
const paramsParsed = GetSubmissionParamsSchema.safeParse(request.params);
if (!paramsParsed.success) return reply.code(400).send({ message: "Invalid id" });
const events = await repo.listEvents(paramsParsed.data.id);
return reply.send({ items: events });
});
// ── Files: request upload SAS ────────────────────────────────────────────
app.post("/api/submissions/:id/files/upload-url", async (request, reply) => {
const paramsParsed = GetSubmissionParamsSchema.safeParse(request.params);
const queryParsed = GetSubmissionQuerySchema.safeParse(request.query);
const bodyParsed = UploadSasBodySchema.safeParse(request.body);
if (!paramsParsed.success || !queryParsed.success || !bodyParsed.success) {
return reply.code(400).send({ message: "Invalid request" });
}
const submittedAt = queryParsed.data.submittedAt;
if (!submittedAt) {
return reply.code(400).send({ message: "submittedAt query param required" });
}
const existing = await repo.getById(paramsParsed.data.id, submittedAt);
if (!existing) return reply.code(404).send({ message: "Submission not found" });
const sas = generateUploadSas({
submissionId: existing.id,
submittedAt: new Date(existing.submittedAt),
originalName: bodyParsed.data.fileName,
contentType: bodyParsed.data.contentType,
questionCode: bodyParsed.data.questionCode ?? null,
});
return reply.send(sas);
});
// ── Files: attach (after successful direct upload) ───────────────────────
app.post("/api/submissions/:id/files", async (request, reply) => {
const paramsParsed = GetSubmissionParamsSchema.safeParse(request.params);
const queryParsed = GetSubmissionQuerySchema.safeParse(request.query);
const bodyParsed = AttachFileBodySchema.safeParse(request.body);
if (!paramsParsed.success || !queryParsed.success || !bodyParsed.success) {
return reply.code(400).send({
message: "Invalid request",
errors: bodyParsed.success ? undefined : bodyParsed.error.issues,
});
}
const submittedAt = queryParsed.data.submittedAt;
if (!submittedAt) {
return reply.code(400).send({ message: "submittedAt query param required" });
}
const existing = await repo.getById(paramsParsed.data.id, submittedAt);
if (!existing) return reply.code(404).send({ message: "Submission not found" });
// Guard: blob_path must be inside this submission's folder
if (!bodyParsed.data.blobPath.startsWith(`submissions/`) ||
!bodyParsed.data.blobPath.includes(`/${existing.id}/`)) {
return reply.code(400).send({
message: "blobPath must live under this submission's folder",
});
}
const attach: AttachFileInput = {
questionCode: bodyParsed.data.questionCode ?? null,
fileName: bodyParsed.data.fileName,
blobPath: bodyParsed.data.blobPath,
blobContainer: config.azure.containerName,
fileSizeBytes: bodyParsed.data.fileSizeBytes,
contentType: bodyParsed.data.contentType,
sha256Hex: bodyParsed.data.sha256Hex,
uploadedBy: bodyParsed.data.uploadedBy ?? actorOf(request),
};
const file = await repo.attachFile(existing.id, existing.submittedAt, attach);
await repo.recordEvent(
existing.id,
existing.submittedAt,
"file_attached",
actorOf(request),
{ fileId: file.id, fileName: file.fileName, blobPath: file.blobPath },
);
return reply.code(201).send({
...file,
downloadUrl: buildReadUrl(file.blobPath),
});
});
// ── Files: list ──────────────────────────────────────────────────────────
app.get("/api/submissions/:id/files", async (request, reply) => {
const paramsParsed = GetSubmissionParamsSchema.safeParse(request.params);
const queryParsed = GetSubmissionQuerySchema.safeParse(request.query);
if (!paramsParsed.success || !queryParsed.success) {
return reply.code(400).send({ message: "Invalid request" });
}
const submittedAt = queryParsed.data.submittedAt;
if (!submittedAt) {
return reply.code(400).send({ message: "submittedAt query param required" });
}
const files = await repo.listFiles(paramsParsed.data.id, submittedAt);
return reply.send({
items: files.map((f) => ({ ...f, downloadUrl: buildReadUrl(f.blobPath) })),
});
});
// ── Files: download URL (re-issue SAS or CDN) ────────────────────────────
app.get("/api/submissions/:id/files/:fileId/download", async (request, reply) => {
const p = request.params as { id?: string; fileId?: string };
const queryParsed = GetSubmissionQuerySchema.safeParse(request.query);
if (!p.id || !p.fileId || !queryParsed.success) {
return reply.code(400).send({ message: "Invalid request" });
}
const submittedAt = queryParsed.data.submittedAt;
if (!submittedAt) {
return reply.code(400).send({ message: "submittedAt query param required" });
}
const file = await repo.getFile(p.fileId, submittedAt);
if (!file) return reply.code(404).send({ message: "File not found" });
return reply.send({ url: buildReadUrl(file.blobPath, { forceSas: true }) });
});
// ── Files: soft-delete (also tombstones the blob) ────────────────────────
app.delete("/api/submissions/:id/files/:fileId", async (request, reply) => {
const p = request.params as { id?: string; fileId?: string };
const queryParsed = GetSubmissionQuerySchema.safeParse(request.query);
if (!p.id || !p.fileId || !queryParsed.success) {
return reply.code(400).send({ message: "Invalid request" });
}
const submittedAt = queryParsed.data.submittedAt;
if (!submittedAt) {
return reply.code(400).send({ message: "submittedAt query param required" });
}
const file = await repo.getFile(p.fileId, submittedAt);
if (!file) return reply.code(404).send({ message: "File not found" });
const dbOk = await repo.softDeleteFile(p.fileId, submittedAt);
if (!dbOk) return reply.code(404).send({ message: "File not found" });
try {
await deleteBlob(file.blobPath);
} catch (err) {
app.log.warn({ err, blobPath: file.blobPath }, "blob delete failed");
}
await repo.recordEvent(
file.submissionId,
file.submissionSubmittedAt,
"file_deleted",
actorOf(request),
{ fileId: file.id, fileName: file.fileName },
);
return reply.code(204).send();
});
}
import type { SubmissionStatus } from "./domain.js";
// ─── State machine ────────────────────────────────────────────────────────────
//
// draft ──► submitted ──► approved
// │ │
// │ └────► rejected ──► draft (revise)
// │
// ▼
// archived (terminal — any non-approved row can be archived)
//
// approved ──► archived (terminal)
const TRANSITIONS: Record<SubmissionStatus, SubmissionStatus[]> = {
draft: ["submitted", "archived"],
submitted: ["approved", "rejected", "draft", "archived"],
rejected: ["draft", "archived"],
approved: ["archived"],
archived: [],
};
export function canTransition(from: SubmissionStatus, to: SubmissionStatus): boolean {
if (from === to) return true;
return TRANSITIONS[from].includes(to);
}
export function nextStates(from: SubmissionStatus): SubmissionStatus[] {
return TRANSITIONS[from];
}
// ─── Conduct score helper ─────────────────────────────────────────────────────
/**
* Walk a SurveyJS-shaped answers object plus the form's surveyJson definition
* and return a 0-100 conduct score = sum(selected conductScore) / sum(maxScore) * 100.
* The form is presumed to have `conductScore` properties on its radiogroup
* questions (matches the convention used by our form builder).
*/
export function calculateConductScore(
surveyJson: Record<string, unknown>,
answers: Record<string, unknown>,
): number | null {
let earned = 0;
let total = 0;
function walk(elements: unknown): void {
if (!Array.isArray(elements)) return;
for (const el of elements) {
if (!el || typeof el !== "object") continue;
const e = el as Record<string, unknown>;
if (Array.isArray(e["elements"])) walk(e["elements"]);
const choices = e["choices"];
if (!Array.isArray(choices)) continue;
const max = choices.reduce((m: number, c: unknown) => {
if (c && typeof c === "object") {
const s = Number((c as Record<string, unknown>)["conductScore"]);
if (Number.isFinite(s) && s > m) return s;
}
return m;
}, 0);
if (max <= 0) continue;
total += max;
const qName = e["name"] as string | undefined;
if (!qName) continue;
const answerVal = answers[qName];
if (answerVal == null) continue;
const matched = choices.find((c: unknown) => {
if (c && typeof c === "object") {
return (c as Record<string, unknown>)["value"] === answerVal;
}
return c === answerVal;
});
if (matched && typeof matched === "object") {
const s = Number((matched as Record<string, unknown>)["conductScore"]);
if (Number.isFinite(s)) earned += s;
}
}
}
const pages = (surveyJson["pages"] as unknown[]) ?? [];
for (const page of pages) {
if (page && typeof page === "object") {
walk((page as Record<string, unknown>)["elements"]);
}
}
if (total === 0) return null;
return Math.round((earned / total) * 10_000) / 100;
}
import { z } from "zod";
import { MODERATION_STATES, SUBMISSION_STATUSES } from "./domain.js";
const uuid = z.string().uuid();
const orgScope = {
orgId: uuid.optional(),
companyId: uuid.optional(),
countryId: uuid.optional(),
entityId: uuid.optional(),
buId: uuid.optional(),
deptId: uuid.optional(),
areaId: uuid.optional(),
};
// ─── Create ──────────────────────────────────────────────────────────────────
export const CreateSubmissionBodySchema = z.object({
formId: uuid,
formVersionId: uuid,
schemaChecksum: z.string().min(1),
moduleCode: z.string().min(1),
submittedBy: uuid.optional(),
answers: z.record(z.string(), z.unknown()),
status: z.enum(SUBMISSION_STATUSES).optional().default("draft"),
conductScore: z.number().min(0).max(999.99).optional(),
sourceRefId: uuid.optional(),
workflowState: z.string().optional(),
...orgScope,
});
export type CreateSubmissionBody = z.infer<typeof CreateSubmissionBodySchema>;
// ─── Update ──────────────────────────────────────────────────────────────────
export const UpdateSubmissionBodySchema = z.object({
answers: z.record(z.string(), z.unknown()).optional(),
status: z.enum(SUBMISSION_STATUSES).optional(),
moderationState: z.enum(MODERATION_STATES).nullable().optional(),
conductScore: z.number().min(0).max(999.99).nullable().optional(),
workflowState: z.string().nullable().optional(),
completedAt: z.string().datetime().nullable().optional(),
...orgScope,
});
export type UpdateSubmissionBody = z.infer<typeof UpdateSubmissionBodySchema>;
// ─── Transition ──────────────────────────────────────────────────────────────
export const TransitionSubmissionBodySchema = z.object({
to: z.enum(SUBMISSION_STATUSES),
reason: z.string().max(2000).optional(),
});
export type TransitionSubmissionBody = z.infer<typeof TransitionSubmissionBodySchema>;
// ─── List query ──────────────────────────────────────────────────────────────
export const ListSubmissionsQuerySchema = z.object({
formId: uuid.optional(),
moduleCode: z.string().optional(),
status: z.string().optional(),
submittedBy: uuid.optional(),
fromDate: z.string().optional(),
toDate: z.string().optional(),
orgId: uuid.optional(),
companyId: uuid.optional(),
countryId: uuid.optional(),
entityId: uuid.optional(),
buId: uuid.optional(),
deptId: uuid.optional(),
areaId: uuid.optional(),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export type ListSubmissionsQuery = z.infer<typeof ListSubmissionsQuerySchema>;
// ─── Get / submission_submitted_at requirement ───────────────────────────────
/**
* Looking up a partition-key table by id alone is allowed but scans every
* partition. Clients that have `submittedAt` should pass it as a query param
* for fast partition pruning.
*/
export const GetSubmissionParamsSchema = z.object({
id: uuid,
});
export const GetSubmissionQuerySchema = z.object({
submittedAt: z.string().datetime().optional(),
});
// ─── Upload SAS request ──────────────────────────────────────────────────────
export const UploadSasBodySchema = z.object({
fileName: z.string().min(1).max(255),
contentType: z.string().max(127).optional(),
questionCode: z.string().max(100).optional(),
});
export type UploadSasBody = z.infer<typeof UploadSasBodySchema>;
// ─── Attach uploaded file ────────────────────────────────────────────────────
export const AttachFileBodySchema = z.object({
blobPath: z.string().min(1).max(1024),
fileName: z.string().min(1).max(255),
questionCode: z.string().max(100).optional(),
contentType: z.string().max(127).optional(),
fileSizeBytes: z.number().int().nonnegative().optional(),
sha256Hex: z.string().regex(/^[a-f0-9]{64}$/i).optional(),
uploadedBy: uuid.optional(),
});
export type AttachFileBody = z.infer<typeof AttachFileBodySchema>;
...@@ -22,6 +22,11 @@ const MATRIX: Array<[string, string, readonly string[]]> = [ ...@@ -22,6 +22,11 @@ const MATRIX: Array<[string, string, readonly string[]]> = [
["POST", "/api/forms", WRITERS], ["POST", "/api/forms", WRITERS],
["PATCH", "/api/forms", WRITERS], ["PATCH", "/api/forms", WRITERS],
["DELETE", "/api/forms", ADMINS], ["DELETE", "/api/forms", ADMINS],
// Submissions (separate module) — all authenticated roles can read
["GET", "/api/submissions", ALL_ROLES],
["POST", "/api/submissions", ALL_ROLES],
["PATCH", "/api/submissions", ALL_ROLES],
["DELETE", "/api/submissions", WRITERS],
// Master data — read // Master data — read
["GET", "/api/master-data", ALL_ROLES], ["GET", "/api/master-data", ALL_ROLES],
// Master data — write // Master data — write
......
import Fastify from "fastify"; import Fastify from "fastify";
import compress from "@fastify/compress";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import multipart from "@fastify/multipart"; import multipart from "@fastify/multipart";
import { config } from "./config.js"; import { config } from "./config.js";
...@@ -6,14 +7,41 @@ import { authorize } from "./plugins/authz.js"; ...@@ -6,14 +7,41 @@ import { authorize } from "./plugins/authz.js";
import { registerFormRoutes } from "./modules/forms/routes.js"; import { registerFormRoutes } from "./modules/forms/routes.js";
import { registerMasterDataRoutes } from "./modules/masterData/routes.js"; import { registerMasterDataRoutes } from "./modules/masterData/routes.js";
import { registerPocRoutes } from "./modules/poc/routes.js"; import { registerPocRoutes } from "./modules/poc/routes.js";
import { registerSubmissionRoutes } from "./modules/submissions/routes.js";
const app = Fastify({ const app = Fastify({
logger: process.env["NODE_ENV"] !== "production", logger: process.env["NODE_ENV"] !== "production",
disableRequestLogging: process.env["NODE_ENV"] === "production", disableRequestLogging: process.env["NODE_ENV"] === "production",
ajv: { customOptions: { removeAdditional: false } }, ajv: { customOptions: { removeAdditional: false } },
// SurveyJS form schemas (with many pages, choices, and inline help text)
// routinely exceed Fastify's 1 MB default. Bump to 16 MB so PATCH/POST of
// form definitions doesn't trip a 413 (which the browser surfaces as a
// CORS error because the request connection closes mid-flight).
bodyLimit: 16 * 1024 * 1024,
}); });
await app.register(cors, { origin: "http://localhost:3000" }); await app.register(cors, {
origin: ["http://localhost:3000"],
methods: ["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: [
"content-type",
"if-match",
"if-none-match",
"x-maf-role",
"x-tenant-id",
"x-confirm-delete-all",
],
exposedHeaders: ["content-length", "content-type", "etag"],
credentials: true,
maxAge: 600,
});
// Compress responses ≥1 KiB. Large surveyJson payloads typically gzip ~85–92%,
// turning a 2 MB schema into <300 KB on the wire. Brotli used when supported.
await app.register(compress, {
global: true,
encodings: ["br", "gzip", "deflate"],
threshold: 1024,
});
await app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } }); await app.register(multipart, { limits: { fileSize: 25 * 1024 * 1024 } });
app.addHook("preHandler", authorize); app.addHook("preHandler", authorize);
...@@ -21,6 +49,7 @@ app.addHook("preHandler", authorize); ...@@ -21,6 +49,7 @@ app.addHook("preHandler", authorize);
await registerFormRoutes(app); await registerFormRoutes(app);
await registerMasterDataRoutes(app); await registerMasterDataRoutes(app);
await registerPocRoutes(app); await registerPocRoutes(app);
await registerSubmissionRoutes(app);
app.get("/health", async () => ({ ok: true })); app.get("/health", async () => ({ ok: true }));
......
import {
BlobSASPermissions,
BlobServiceClient,
generateBlobSASQueryParameters,
SASProtocol,
StorageSharedKeyCredential,
type ContainerClient,
} from "@azure/storage-blob";
import { randomUUID } from "node:crypto";
import { config } from "../config.js";
// ─── Singleton service client (re-used across requests) ───────────────────────
const credential = new StorageSharedKeyCredential(
config.azure.accountName,
config.azure.accountKey,
);
const serviceClient = new BlobServiceClient(config.azure.baseUrl, credential);
const container: ContainerClient = serviceClient.getContainerClient(
config.azure.containerName,
);
// ─── Path helpers ─────────────────────────────────────────────────────────────
/**
* Build a partition-friendly object path for a submission file.
* Layout: `submissions/{yyyy}/{mm}/{submission_id}/{file_id}-{safe_name}`
*/
export function buildSubmissionBlobPath(opts: {
submissionId: string;
submittedAt: Date;
questionCode?: string | null;
originalName: string;
}): { blobPath: string; fileId: string; safeName: string } {
const fileId = randomUUID();
const yyyy = String(opts.submittedAt.getUTCFullYear());
const mm = String(opts.submittedAt.getUTCMonth() + 1).padStart(2, "0");
const safeName = opts.originalName.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 200);
const segment = opts.questionCode
? `${opts.questionCode}/${fileId}-${safeName}`
: `${fileId}-${safeName}`;
return {
blobPath: `submissions/${yyyy}/${mm}/${opts.submissionId}/${segment}`,
fileId,
safeName,
};
}
// ─── Read URLs ────────────────────────────────────────────────────────────────
/**
* Build a public read URL: prefers CDN base URL if configured, otherwise
* generates a SAS-signed URL valid for AZURE_DOWNLOAD_SAS_TTL_MIN minutes.
*/
export function buildReadUrl(blobPath: string, opts: { forceSas?: boolean } = {}): string {
if (!opts.forceSas && config.azure.cdnBaseUrl) {
return `${stripTrailingSlash(config.azure.cdnBaseUrl)}/${config.azure.containerName}/${blobPath}`;
}
const expiresOn = new Date(Date.now() + config.azure.downloadSasTtlMin * 60_000);
const sas = generateBlobSASQueryParameters(
{
containerName: config.azure.containerName,
blobName: blobPath,
permissions: BlobSASPermissions.parse("r"),
protocol: SASProtocol.Https,
startsOn: new Date(Date.now() - 60_000),
expiresOn,
},
credential,
).toString();
return `${config.azure.baseUrl}/${config.azure.containerName}/${blobPath}?${sas}`;
}
// ─── Upload SAS (browser → Azure direct PUT) ──────────────────────────────────
export interface UploadSasResult {
blobPath: string;
fileId: string;
uploadUrl: string;
expiresAt: string;
blobName: string;
container: string;
method: "PUT";
headers: Record<string, string>;
}
/**
* Generate a write-only SAS URL the browser can PUT a file to.
* Caller must send `x-ms-blob-type: BlockBlob` as a request header.
*/
export function generateUploadSas(opts: {
submissionId: string;
submittedAt: Date;
originalName: string;
contentType?: string;
questionCode?: string | null;
}): UploadSasResult {
const { blobPath, fileId } = buildSubmissionBlobPath(opts);
const expiresOn = new Date(Date.now() + config.azure.uploadSasTtlMin * 60_000);
const sas = generateBlobSASQueryParameters(
{
containerName: config.azure.containerName,
blobName: blobPath,
permissions: BlobSASPermissions.parse("cw"),
protocol: SASProtocol.Https,
startsOn: new Date(Date.now() - 60_000),
expiresOn,
contentType: opts.contentType ?? undefined,
},
credential,
).toString();
return {
blobPath,
fileId,
uploadUrl: `${config.azure.baseUrl}/${config.azure.containerName}/${blobPath}?${sas}`,
expiresAt: expiresOn.toISOString(),
blobName: blobPath,
container: config.azure.containerName,
method: "PUT",
headers: {
"x-ms-blob-type": "BlockBlob",
...(opts.contentType ? { "Content-Type": opts.contentType } : {}),
},
};
}
// ─── Server-side upload (for small / proxy flows) ────────────────────────────
export interface ServerUploadResult {
blobPath: string;
url: string;
sizeBytes: number;
contentType: string;
etag: string | undefined;
}
export async function uploadBufferToBlob(opts: {
blobPath: string;
buffer: Buffer;
contentType: string;
}): Promise<ServerUploadResult> {
const block = container.getBlockBlobClient(opts.blobPath);
const result = await block.uploadData(opts.buffer, {
blobHTTPHeaders: { blobContentType: opts.contentType },
});
return {
blobPath: opts.blobPath,
url: block.url,
sizeBytes: opts.buffer.length,
contentType: opts.contentType,
etag: result.etag,
};
}
// ─── Existence / delete / properties ──────────────────────────────────────────
export async function blobExists(blobPath: string): Promise<boolean> {
return container.getBlockBlobClient(blobPath).exists();
}
export interface BlobProperties {
sizeBytes: number;
contentType: string | undefined;
etag: string | undefined;
lastModified: Date | undefined;
}
export async function getBlobProperties(blobPath: string): Promise<BlobProperties | null> {
const client = container.getBlockBlobClient(blobPath);
try {
const p = await client.getProperties();
return {
sizeBytes: p.contentLength ?? 0,
contentType: p.contentType,
etag: p.etag,
lastModified: p.lastModified,
};
} catch (err: unknown) {
const e = err as { statusCode?: number };
if (e.statusCode === 404) return null;
throw err;
}
}
export async function deleteBlob(blobPath: string): Promise<boolean> {
const res = await container.getBlockBlobClient(blobPath).deleteIfExists();
return res.succeeded;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function stripTrailingSlash(s: string): string {
return s.endsWith("/") ? s.slice(0, -1) : s;
}
export const blobStorage = {
generateUploadSas,
buildReadUrl,
uploadBufferToBlob,
blobExists,
getBlobProperties,
deleteBlob,
buildSubmissionBlobPath,
container,
};
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment