Commit 6bad9034 by krds-arun

feat(backend): form builder migration + master data relationships

- migrations/003: form_builder_update.sql schema additions
- forms: route + service updates for new builder field types
- masterData: new relationships module + domain/repository/routes/service
  updates wiring entity  entity links
- authz: minor plugin tweak
Co-Authored-By: 's avatarClaude Opus 4.7 (1M context) <noreply@anthropic.com>
parent 04e7b90a
BEGIN;
-- Add missing description column to form_template
ALTER TABLE form_template
ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT '';
-- Index on domain (moduleCode) for filtering by module
CREATE INDEX IF NOT EXISTS idx_form_template_domain ON form_template (domain);
-- Comments documenting the schema after migration
COMMENT ON COLUMN form_version.survey_json IS
'FormDefinitionSchema JSON: { meta: { formCode, title, moduleCode, ... }, layout: [...], questions: [...] }';
COMMENT ON COLUMN form_version.normalized_schema_json IS
'Same as survey_json; kept for read-optimized queries';
COMMIT;
......@@ -153,6 +153,19 @@ export async function registerFormRoutes(app: FastifyInstance): Promise<void> {
return reply.code(204).send();
});
// ── DELETE /api/forms ─────────────────────────────────────────────────────
// Bulk delete ALL forms. Requires explicit confirmation header.
app.delete("/api/forms", async (request, reply) => {
const confirmHeader = request.headers["x-confirm-delete-all"];
if (confirmHeader !== "yes-delete-all-forms") {
return reply.code(400).send({
message: "Bulk delete requires header X-Confirm-Delete-All: yes-delete-all-forms",
});
}
const deletedCount = await service.deleteAllForms();
return reply.send({ deleted: deletedCount });
});
// ── POST /api/forms/:code/versions ────────────────────────────────────────
app.post<{ Params: { code: string } }>("/api/forms/:code/versions", async (request, reply) => {
const { code } = request.params;
......
......@@ -94,6 +94,18 @@ export class FormService {
return deleted;
}
/** Bulk delete: deletes ALL forms (soft delete). Returns count of deleted forms. */
async deleteAllForms(): Promise<number> {
const forms = await this.repo.listForms();
let count = 0;
for (const form of forms) {
const ok = await this.repo.softDeleteForm(form.code);
if (ok) count += 1;
}
await this.cache.delByPrefix("forms:");
return count;
}
async publishVersion(
formCode: string,
data: PublishVersionInput
......
......@@ -30,6 +30,8 @@ export interface MasterFilter {
q?: string;
parentId?: string;
isActive?: boolean;
/** Limit results to records linked to a record on the other side of a M2M relation. */
relatedTo?: { relName: string; id: string };
page: number;
limit: number;
}
......@@ -143,4 +145,8 @@ export interface MasterRepository {
create(domain: string, data: CreateMasterInput): Promise<MasterRecord>;
update(domain: string, id: string, data: UpdateMasterInput): Promise<MasterRecord | null>;
softDelete(domain: string, id: string): Promise<boolean>;
/** Returns the IDs (and lightweight records) related to `id` via `relName`. */
listRelated(fromDomain: string, fromId: string, relName: string): Promise<MasterRecord[]>;
/** Replaces the relation set in one transaction (delete-then-insert). */
setRelated(fromDomain: string, fromId: string, relName: string, toIds: string[]): Promise<void>;
}
/**
* Relationship registry — maps `<domain>:<relName>` to its junction table.
*
* Each entry is one *direction* of an M2M edge. The reverse direction has its
* own entry so the API stays symmetric: `entity:countries` and
* `country:entities` both work, pointing at the same `map_entity_country`
* row, just with the columns swapped.
*
* Why a registry (not ad-hoc per-domain code):
* - Compile-time enumeration of every M2M edge in the system.
* - One uniform `setRelated()` / `listRelated()` implementation works for
* every edge — no per-relation handlers to maintain.
* - Junction tables are NEVER named via user input; they're resolved through
* this map → no SQL injection surface.
*/
export interface RelationshipConfig {
/** Junction table, fully-qualified (`schema.table`) */
table: string;
/** Column in junction holding the FROM-side ID */
fromColumn: string;
/** Column in junction holding the TO-side ID */
toColumn: string;
/** TO-side domain slug (matches `DOMAIN_REGISTRY`) */
toDomain: string;
/** Optional extra columns set on insert (for nullable junction columns) */
defaultExtras?: Record<string, unknown>;
}
/** Composite key: `${fromDomain}:${relName}` */
export const RELATIONSHIP_REGISTRY: ReadonlyMap<string, RelationshipConfig> = new Map([
// ── Entity ↔ Countries ──────────────────────────────────────────────────
["entity:countries", { table: "master.map_entity_country", fromColumn: "entity_id", toColumn: "country_id", toDomain: "country" }],
["country:entities", { table: "master.map_entity_country", fromColumn: "country_id", toColumn: "entity_id", toDomain: "entity" }],
// ── BU ↔ Entities ────────────────────────────────────────────────────────
["business-unit:entities", { table: "master.map_bu_entity", fromColumn: "bu_id", toColumn: "entity_id", toDomain: "entity" }],
["entity:business-units", { table: "master.map_bu_entity", fromColumn: "entity_id", toColumn: "bu_id", toDomain: "business-unit" }],
// ── Department ↔ BUs ─────────────────────────────────────────────────────
["department:business-units", { table: "master.map_dept_bu", fromColumn: "dept_id", toColumn: "bu_id", toDomain: "business-unit" }],
["business-unit:departments", { table: "master.map_dept_bu", fromColumn: "bu_id", toColumn: "dept_id", toDomain: "department" }],
// ── Company ↔ Countries ──────────────────────────────────────────────────
["company:countries", { table: "master.map_company_country", fromColumn: "company_id", toColumn: "country_id", toDomain: "country" }],
["country:companies", { table: "master.map_company_country", fromColumn: "country_id", toColumn: "company_id", toDomain: "company" }],
// ── Organisation ↔ Countries ─────────────────────────────────────────────
["organisation:countries", { table: "master.map_organisation", fromColumn: "org_id", toColumn: "country_id", toDomain: "country" }],
["country:organisations", { table: "master.map_organisation", fromColumn: "country_id", toColumn: "org_id", toDomain: "organisation" }],
// ── Speciality ↔ Countries ───────────────────────────────────────────────
["speciality:countries", { table: "master.map_speciality_country", fromColumn: "speciality_id", toColumn: "country_id", toDomain: "country" }],
["country:specialities", { table: "master.map_speciality_country", fromColumn: "country_id", toColumn: "speciality_id", toDomain: "speciality" }],
// ── Company ↔ Specialities ───────────────────────────────────────────────
["company:specialities", { table: "master.map_company_speciality", fromColumn: "company_id", toColumn: "speciality_id", toDomain: "speciality" }],
["speciality:companies", { table: "master.map_company_speciality", fromColumn: "speciality_id", toColumn: "company_id", toDomain: "company" }],
// ── Area ↔ Entities ──────────────────────────────────────────────────────
["area:entities", { table: "master.map_area", fromColumn: "area_id", toColumn: "entity_id", toDomain: "entity" }],
["entity:areas", { table: "master.map_area", fromColumn: "entity_id", toColumn: "area_id", toDomain: "area" }],
]);
export function resolveRelationship(fromDomain: string, relName: string): RelationshipConfig {
const key = `${fromDomain}:${relName}`;
const cfg = RELATIONSHIP_REGISTRY.get(key);
if (!cfg) {
const valid = [...RELATIONSHIP_REGISTRY.keys()].filter((k) => k.startsWith(`${fromDomain}:`)).map((k) => k.split(":")[1]);
throw new Error(`Unknown relationship "${key}". Valid for "${fromDomain}": ${valid.join(", ") || "(none)"}`);
}
return cfg;
}
/** All relations starting from a domain — used by the OPTIONS-style listRelations endpoint. */
export function relationsFromDomain(fromDomain: string): Array<{ name: string; toDomain: string }> {
const out: Array<{ name: string; toDomain: string }> = [];
for (const [key, cfg] of RELATIONSHIP_REGISTRY) {
const [from, name] = key.split(":");
if (from === fromDomain && name) out.push({ name, toDomain: cfg.toDomain });
}
return out;
}
......@@ -10,6 +10,7 @@ import {
type MasterRepository,
type UpdateMasterInput,
} from "./domain.js";
import { resolveRelationship } from "./relationships.js";
// ─── WhereBuilder ─────────────────────────────────────────────────────────────
......@@ -151,6 +152,16 @@ export class PgMasterRepository implements MasterRepository {
wb.addRaw(`parent_id = $${nextIdx}`, filter.parentId);
}
// M2M filter: only include records linked to `relatedTo.id` via the named relation.
if (filter.relatedTo) {
const relCfg = resolveRelationship(domain, filter.relatedTo.relName);
const nextIdx = wb["params"].length + 1;
wb.addRaw(
`id IN (SELECT ${relCfg.fromColumn} FROM ${relCfg.table} WHERE ${relCfg.toColumn} = $${nextIdx})`,
filter.relatedTo.id,
);
}
const { clause, params } = wb.build();
// COUNT
......@@ -291,4 +302,48 @@ export class PgMasterRepository implements MasterRepository {
);
return (result.rowCount ?? 0) > 0;
}
async listRelated(fromDomain: string, fromId: string, relName: string): Promise<MasterRecord[]> {
const rel = resolveRelationship(fromDomain, relName);
const toCfg = resolveDomain(rel.toDomain);
const cols = buildSelectCols(toCfg);
// Inner join through the junction table → only related, undeleted target rows.
const result = await this.pool.query<Record<string, unknown>>(
`SELECT ${cols}
FROM ${toCfg.table} t
INNER JOIN ${rel.table} j ON j.${rel.toColumn} = t.id
WHERE j.${rel.fromColumn} = $1 AND t.deleted_at IS NULL
ORDER BY t.sort_order ASC, t.${toCfg.nameCol ?? "name"} ASC`,
[fromId],
);
return result.rows.map((row) => toRecord(row, toCfg));
}
async setRelated(fromDomain: string, fromId: string, relName: string, toIds: string[]): Promise<void> {
const rel = resolveRelationship(fromDomain, relName);
const unique = [...new Set(toIds)];
const client = await this.pool.connect();
try {
await client.query("BEGIN");
// Replace-all semantics: drop existing edges, insert the new set.
await client.query(`DELETE FROM ${rel.table} WHERE ${rel.fromColumn} = $1`, [fromId]);
if (unique.length > 0) {
// Multi-row insert with one parameter per (fromId, toId) pair.
const placeholders = unique.map((_, i) => `($1, $${i + 2})`).join(", ");
await client.query(
`INSERT INTO ${rel.table} (${rel.fromColumn}, ${rel.toColumn}) VALUES ${placeholders}
ON CONFLICT DO NOTHING`,
[fromId, ...unique],
);
}
await client.query("COMMIT");
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
}
}
......@@ -6,8 +6,11 @@ import {
CreateBodySchema,
IdParamSchema,
ListQuerySchema,
RelationParamSchema,
SetRelationsBodySchema,
UpdateBodySchema,
} from "./types.js";
import { relationsFromDomain } from "./relationships.js";
// Instantiated once per module registration
const service = new MasterDataService(
......@@ -22,9 +25,14 @@ export async function registerMasterDataRoutes(app: FastifyInstance): Promise<vo
if (!parsed.success) {
return reply.code(400).send({ message: parsed.error.message });
}
const { domain, page, limit, q, parentId, isActive } = parsed.data;
const { domain, page, limit, q, parentId, isActive, relatedTo } = parsed.data;
let relatedToParsed: { relName: string; id: string } | undefined;
if (relatedTo) {
const [relName, id] = relatedTo.split(":");
relatedToParsed = { relName: relName!, id: id! };
}
try {
const result = await service.list(domain, { page, limit, q, parentId, isActive });
const result = await service.list(domain, { page, limit, q, parentId, isActive, relatedTo: relatedToParsed });
return reply.send(result);
} catch (err) {
const message = (err as Error).message;
......@@ -133,4 +141,51 @@ export async function registerMasterDataRoutes(app: FastifyInstance): Promise<vo
}
}
);
// GET /api/master-data/:domain/relations → enumerate available relations
app.get<{ Params: { domain: string } }>(
"/api/master-data/:domain/relations",
async (request, reply) => {
return reply.send(relationsFromDomain(request.params.domain));
},
);
// GET /api/master-data/:domain/:id/relations/:relName → list related records
app.get<{ Params: { domain: string; id: string; relName: string } }>(
"/api/master-data/:domain/:id/relations/:relName",
async (request, reply) => {
const parsed = RelationParamSchema.safeParse(request.params);
if (!parsed.success) return reply.code(400).send({ message: parsed.error.message });
const { domain, id, relName } = parsed.data;
try {
const records = await service.listRelated(domain, id, relName);
return reply.send(records);
} catch (err) {
const message = (err as Error).message;
if (message.includes("Unknown")) return reply.code(400).send({ message });
throw err;
}
},
);
// PUT /api/master-data/:domain/:id/relations/:relName → replace all related ids
app.put<{ Params: { domain: string; id: string; relName: string } }>(
"/api/master-data/:domain/:id/relations/:relName",
async (request, reply) => {
const parsed = RelationParamSchema.safeParse(request.params);
if (!parsed.success) return reply.code(400).send({ message: parsed.error.message });
const bodyParsed = SetRelationsBodySchema.safeParse(request.body);
if (!bodyParsed.success) return reply.code(400).send({ message: bodyParsed.error.message });
const { domain, id, relName } = parsed.data;
try {
await service.setRelated(domain, id, relName, bodyParsed.data.toIds);
const refreshed = await service.listRelated(domain, id, relName);
return reply.send(refreshed);
} catch (err) {
const message = (err as Error).message;
if (message.includes("Unknown")) return reply.code(400).send({ message });
throw err;
}
},
);
}
......@@ -18,7 +18,8 @@ export class MasterDataService {
) {}
async list(domain: string, filter: MasterFilter): Promise<MasterPage> {
const key = `master:list:${domain}:${filter.page}:${filter.limit}:${filter.q ?? ""}:${filter.parentId ?? ""}:${filter.isActive ?? ""}`;
const relKey = filter.relatedTo ? `${filter.relatedTo.relName}:${filter.relatedTo.id}` : "";
const key = `master:list:${domain}:${filter.page}:${filter.limit}:${filter.q ?? ""}:${filter.parentId ?? ""}:${filter.isActive ?? ""}:${relKey}`;
const cached = await this.cache.get<MasterPage>(key);
if (cached) return cached;
......@@ -27,6 +28,27 @@ export class MasterDataService {
return page;
}
async listRelated(fromDomain: string, fromId: string, relName: string): Promise<MasterRecord[]> {
const key = `master:rel:${fromDomain}:${fromId}:${relName}`;
const cached = await this.cache.get<MasterRecord[]>(key);
if (cached) return cached;
const records = await this.repo.listRelated(fromDomain, fromId, relName);
await this.cache.set(key, records, config.masterTtlSeconds);
return records;
}
async setRelated(fromDomain: string, fromId: string, relName: string, toIds: string[]): Promise<void> {
await this.repo.setRelated(fromDomain, fromId, relName, toIds);
// Invalidate aggressively: any list using `relatedTo`, the relation listing itself,
// and the reverse-direction relation listings on the target side.
await Promise.all([
this.cache.delByPrefix(`master:list:`),
this.cache.delByPrefix(`master:rel:${fromDomain}:${fromId}:${relName}`),
this.cache.delByPrefix(`master:rel:`),
]);
}
async getById(domain: string, id: string): Promise<MasterRecord | null> {
const key = `master:item:${domain}:${id}`;
const cached = await this.cache.get<MasterRecord>(key);
......
......@@ -15,9 +15,25 @@ export const ListQuerySchema = z.object({
if (v === undefined) return undefined;
return v === "true" ? true : v === "false" ? false : undefined;
}),
/** Filter by M2M relation: format `<relName>:<uuid>`, e.g. `countries:abc-…` */
relatedTo: z
.string()
.regex(/^[a-z0-9-]+:[0-9a-f-]{36}$/i, "relatedTo must be of the form <relName>:<uuid>")
.optional(),
});
export type ListQuery = z.infer<typeof ListQuerySchema>;
export const RelationParamSchema = z.object({
domain: z.string().min(1),
id: z.string().uuid(),
relName: z.string().min(1),
});
export const SetRelationsBodySchema = z.object({
toIds: z.array(z.string().uuid()),
});
export type SetRelationsBody = z.infer<typeof SetRelationsBodySchema>;
export const DomainParamSchema = z.object({
domain: z.string().min(1),
});
......
......@@ -27,6 +27,7 @@ const MATRIX: Array<[string, string, readonly string[]]> = [
// Master data — write
["POST", "/api/master-data", MDM_WRITE],
["PATCH", "/api/master-data", MDM_WRITE],
["PUT", "/api/master-data", MDM_WRITE],
["DELETE", "/api/master-data", ADMINS],
];
......
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