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
REDIS_URL=redis://localhost:6379
CACHE_TTL_SCHEMA_SECONDS=300
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;
......@@ -10,6 +10,7 @@
},
"dependencies": {
"@azure/storage-blob": "^12.31.0",
"@fastify/compress": "^8.3.1",
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^10.0.0",
"fastify": "^5.6.1",
......@@ -18,8 +19,10 @@
"zod": "^4.1.12"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.10.0",
"@types/pg": "^8.20.0",
"js-yaml": "^4.1.1",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
......
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 = {
port: Number(process.env.PORT ?? 8080),
postgresUrl: process.env.POSTGRES_URL ?? "postgres://postgres:password@localhost:5432/maf-gateway",
redisUrl: process.env.REDIS_URL ?? "",
schemaTtlSeconds: Number(process.env.CACHE_TTL_SCHEMA_SECONDS ?? 300),
masterTtlSeconds: Number(process.env.CACHE_TTL_MASTER_SECONDS ?? 180),
azureStorageConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING ?? "",
azureBlobContainer: process.env.AZURE_BLOB_CONTAINER ?? "form-uploads"
port: Number(process.env["PORT"] ?? 8080),
postgresUrl: required("POSTGRES_URL", "postgres://postgres:password@localhost:5432/maf-gateway"),
redisUrl: process.env["REDIS_URL"] ?? "",
schemaTtlSeconds: Number(process.env["CACHE_TTL_SCHEMA_SECONDS"] ?? 300),
masterTtlSeconds: Number(process.env["CACHE_TTL_MASTER_SECONDS"] ?? 180),
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 ──────────────────────────────────────────────────────────────────
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;
code: string;
displayName: string;
......@@ -43,6 +72,8 @@ export interface SubmissionRecord {
// ─── Inputs ───────────────────────────────────────────────────────────────────
export type FormSettingsInput = Partial<FormSettings>;
export interface CreateFormInput {
code: string;
displayName: string;
......@@ -52,6 +83,7 @@ export interface CreateFormInput {
surveyJson: Record<string, unknown>;
semver?: string;
schemaChecksum: string;
settings?: FormSettingsInput;
}
export interface UpdateFormInput {
......@@ -63,6 +95,7 @@ export interface UpdateFormInput {
schemaChecksum?: string;
/** Semver for the draft — required when surveyJson is provided and no draft exists */
semver?: string;
settings?: FormSettingsInput;
}
export interface PublishVersionInput {
......@@ -95,10 +128,49 @@ export interface SubmissionPage {
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 ─────────────────────────────────────────────────────
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 {
listForms(): Promise<FormRecord[]>;
listForms(filter?: ListFormsFilter): Promise<FormRecord[]>;
getFormByCode(code: string): Promise<FormWithVersions | null>;
getFormVersion(formId: string, semver: string): Promise<FormVersionRecord | null>;
createForm(data: CreateFormInput): Promise<FormRecord>;
......@@ -107,6 +179,15 @@ export interface FormRepository {
publishVersion(formId: string, data: PublishVersionInput): Promise<FormVersionRecord>;
saveSubmission(data: SubmissionInput): Promise<SubmissionRecord>;
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) ───────
......
......@@ -4,7 +4,9 @@ import { PgFormRepository } from "./repository.js";
import { FormService } from "./service.js";
import {
CreateFormBodySchema,
ListFormsQuerySchema,
ListSubmissionsQuerySchema,
ManagersQuerySchema,
PublishVersionBodySchema,
SchemaQuerySchema,
SubmissionBodySchema,
......@@ -24,6 +26,41 @@ const FormRecordSchema = {
currentVersionId: { type: ["string", "null"] },
createdAt: { 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;
......@@ -74,10 +111,37 @@ const service = new FormService(new PgFormRepository(), new MemoryCacheAdapter()
export async function registerFormRoutes(app: FastifyInstance): Promise<void> {
// ── GET /api/forms ────────────────────────────────────────────────────────
app.get("/api/forms", {
schema: { response: { 200: { type: "array", items: FormRecordSchema } } },
app.get("/api/forms", async (request, reply) => {
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) => {
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=... ────────────────────────
......@@ -114,6 +178,7 @@ export async function registerFormRoutes(app: FastifyInstance): Promise<void> {
schemaChecksum: parsed.data.schemaChecksum,
semver: parsed.data.semver,
moduleCode: parsed.data.moduleCode,
settings: parsed.data.settings,
});
return reply.code(201).send(form);
} catch (err) {
......@@ -140,6 +205,7 @@ export async function registerFormRoutes(app: FastifyInstance): Promise<void> {
surveyJson: parsed.data.surveyJson,
schemaChecksum: parsed.data.schemaChecksum,
semver: parsed.data.semver,
settings: parsed.data.settings,
});
if (!form) return reply.code(404).send({ message: `Form "${code}" not found` });
return reply.send(form);
......
......@@ -2,6 +2,7 @@ import type { CachePort } from "../cache/cache-port.js";
import { config } from "../../config.js";
import type {
CreateFormInput,
FormOptionCatalogs,
FormRecord,
FormRepository,
FormVersionRecord,
......@@ -20,26 +21,25 @@ export class FormService {
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 cached = await this.cache.get<FormRecord[]>(key);
if (cached) return cached;
const forms = await this.repo.listForms();
await this.cache.set(key, forms, config.schemaTtlSeconds);
return forms;
}
async getFormByCode(code: string): Promise<FormWithVersions | null> {
const key = `forms:detail:${code}`;
const cached = await this.cache.get<FormWithVersions>(key);
if (cached) return cached;
const form = await this.repo.getFormByCode(code);
if (form) {
await this.cache.set(key, form, config.schemaTtlSeconds);
}
return form;
// No cache — admin edits (publish, settings) need to reflect immediately
// and the underlying SELECT is one indexed lookup + one ORDER BY DESC scan.
return this.repo.getFormByCode(code);
}
/**
......@@ -161,4 +161,19 @@ export class FormService {
async listSubmissions(formId: string, filter: SubmissionFilter): Promise<SubmissionPage> {
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 {
// ─── 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 */
export const SchemaQuerySchema = z.object({
formCode: z.string().min(1),
......@@ -48,6 +72,31 @@ export const SchemaQuerySchema = z.object({
});
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 */
export const SubmissionBodySchema = z.object({
formCode: z.string().min(1),
......@@ -64,10 +113,11 @@ export const CreateFormBodySchema = z.object({
displayName: z.string().min(1),
description: z.string().optional(),
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"),
/** platform.module.code — e.g. "audit", "inspection", "checklist" */
moduleCode: z.string().optional().default("audit"),
settings: FormSettingsSchema.optional(),
});
export type CreateFormBody = z.infer<typeof CreateFormBodySchema>;
......@@ -79,6 +129,7 @@ export const UpdateFormBodySchema = z.object({
surveyJson: z.record(z.string(), z.unknown()).optional(),
schemaChecksum: z.string().optional(),
semver: z.string().optional(),
settings: FormSettingsSchema.optional(),
});
export type UpdateFormBody = z.infer<typeof UpdateFormBodySchema>;
......@@ -87,7 +138,7 @@ export const PublishVersionBodySchema = z.object({
semver: z.string().min(1),
surveyJson: z.record(z.string(), z.unknown()),
questionIndex: z.record(z.string(), z.unknown()).optional().default({}),
schemaChecksum: z.string().min(1),
schemaChecksum: z.string().optional().default(""),
publishedBy: z.string().uuid().optional(),
});
export type PublishVersionBody = z.infer<typeof PublishVersionBodySchema>;
......
import { BlobServiceClient } from "@azure/storage-blob";
import { config } from "../../config.js";
import { randomUUID } from "node:crypto";
import { uploadBufferToBlob, buildReadUrl } from "../../services/blob-storage.js";
export async function uploadToAzure(buffer: Buffer, originalName: string, mimeType: string): Promise<string> {
const serviceClient = BlobServiceClient.fromConnectionString(config.azureStorageConnectionString);
const containerClient = serviceClient.getContainerClient(config.azureBlobContainer);
const blobName = `${Date.now()}-${originalName.replace(/[^a-zA-Z0-9._-]/g, "_")}`;
const blockBlobClient = containerClient.getBlockBlobClient(blobName);
await blockBlobClient.upload(buffer, buffer.length, {
blobHTTPHeaders: { blobContentType: mimeType }
});
return blockBlobClient.url;
export async function uploadToAzure(
buffer: Buffer,
originalName: string,
mimeType: string,
): Promise<string> {
const safe = originalName.replace(/[^a-zA-Z0-9._-]/g, "_");
const blobPath = `poc/${Date.now()}-${randomUUID().slice(0, 8)}-${safe}`;
await uploadBufferToBlob({ blobPath, buffer, contentType: mimeType });
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 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[]]> = [
["POST", "/api/forms", WRITERS],
["PATCH", "/api/forms", WRITERS],
["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
["GET", "/api/master-data", ALL_ROLES],
// Master data — write
......
import Fastify from "fastify";
import compress from "@fastify/compress";
import cors from "@fastify/cors";
import multipart from "@fastify/multipart";
import { config } from "./config.js";
......@@ -6,14 +7,41 @@ import { authorize } from "./plugins/authz.js";
import { registerFormRoutes } from "./modules/forms/routes.js";
import { registerMasterDataRoutes } from "./modules/masterData/routes.js";
import { registerPocRoutes } from "./modules/poc/routes.js";
import { registerSubmissionRoutes } from "./modules/submissions/routes.js";
const app = Fastify({
logger: process.env["NODE_ENV"] !== "production",
disableRequestLogging: process.env["NODE_ENV"] === "production",
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 } });
app.addHook("preHandler", authorize);
......@@ -21,6 +49,7 @@ app.addHook("preHandler", authorize);
await registerFormRoutes(app);
await registerMasterDataRoutes(app);
await registerPocRoutes(app);
await registerSubmissionRoutes(app);
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