Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Changelog

## v0.5.0 — Protocol-first builds and site form layouts (2026-07)

Two halves of one idea (BYOFW: the sponsor governs the data, sites adapt
the forms that capture it), grounded in CDISC USDM v4 and timed to ICH
M11's adoption as a final guideline (November 2025).

### Protocol-first build path (ADR-0012)
- Import a USDM v4 protocol package (JSON; Excel authoring via the
external usdm4-excel converter is documented) as an immutable,
versioned protocol artifact
- The compiler derives the build from the protocol: encounters → events
(with planned timing and visit windows carried as `edc:` extensions),
scheduled activities → forms, biomedical concepts → items/codelists
via a bundled mapping pack curated from the open COSMoS dataset
(MIT, pinned sha; no CDISC text or runtime CDISC Library dependency)
- A review screen shows the protocol's schedule of activities with
per-concept resolution status; surrogate/unmatched concepts become
draft items that must be completed before publish, so published
builds are always capture-ready
- Publishing runs through the same single build write path as ODM
import and the visual builder; per-field protocol traceability is
recorded relationally and in the exported ODM (`edc:UsdmRef`)
- New guide pages: "Why protocol-first?" (for readers new to USDM/M11)
and "Protocol import (USDM)" (including authoring the Excel workbook
properly)

### Site form layouts (ADR-0013)
- Sites adapt form layout/workflow per site — regroup, reorder, relabel
— as append-only-versioned variants that only reference build fields
- Data-equivalence is validated structurally on every edit (exact
coverage of the governed items, no additions, mandatory flags may
only strengthen); submission is blocked until clean, so the sponsor's
approval queue reviews workflow, not data integrity
- Capture through an approved layout pins the variant version while
every value write keys on canonical build identifiers: captured data
is byte-identical in shape across sites regardless of layout
- Amendments revalidate approved layouts automatically: equivalent
layouts carry forward, the rest are marked stale with notifications
and capture falls back to the standard forms
- New permission `site.forms.manage` (site-scoped, seeded to the
coordinator role); sponsor decisions ride `study.manage`

## v0.4.0 — Python workbench and blinding governance (2026-07)

The analytics workbench speaks Python as well as R and SQL, the engines
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/db/schema/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const notifications = pgTable(
"form.awaiting_signature",
"form.overdue",
"security.anomaly",
"site_forms.stale",
],
}).notNull(),
title: text("title").notNull(),
Expand Down
181 changes: 180 additions & 1 deletion apps/api/src/routes/site-forms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ describe.skipIf(!dbAvailable)("site form variants (integration)", () => {
});

afterAll(async () => {
// The DB client is shared with the amendment suite below; it closes there.
await server.close();
await client.end();
});

const auth = (token: string) => ({ authorization: `Bearer ${token}` });
Expand Down Expand Up @@ -344,3 +344,182 @@ describe.skipIf(!dbAvailable)("site form variants (integration)", () => {
expect(res.statusCode).toBe(400);
});
});

describe.skipIf(!dbAvailable)("amendment integration for variants (integration)", () => {
// Reuses the DB from the suite above via fresh fixtures.
let server: FastifyInstance;
const suffix = randomUUID().slice(0, 8);
const fx = { managerToken: "", coordToken: "", studyId: "", siteId: "", versionId: "" };

beforeAll(async () => {
await runMigrations();
server = await buildServer({ db });
await server.ready();

const passwordHash = await hashPassword(PASSWORD);
const mkUser = async (username: string, isSystemAdmin = false) => {
const [user] = await db
.insert(users)
.values({
username,
email: `${username}@example.com`,
fullName: username,
passwordHash,
isSystemAdmin,
})
.returning();
if (!user) throw new Error("fixture failed");
return user;
};
const manager = await mkUser(`amm-${suffix}`);
const coord = await mkUser(`amc-${suffix}`);
const admin = await mkUser(`amadm-${suffix}`, true);
const [study] = await db
.insert(studies)
.values({ oid: `ST.AM.${suffix}`, name: "Amendment Variants Study" })
.returning();
const [site] = await db
.insert(sites)
.values({ studyId: study?.id ?? "", oid: "SITE.AM", name: "Amendment Site" })
.returning();
if (!study || !site) throw new Error("fixture failed");
fx.studyId = study.id;
fx.siteId = site.id;

const roleId = async (name: string) => {
const [role] = await db.select().from(roles).where(eq(roles.name, name));
if (!role) throw new Error(`seeded role ${name} missing`);
return role.id;
};
await grantRole(db, {
userId: manager.id,
studyId: study.id,
roleId: await roleId("data_manager"),
grantedBy: admin.id,
});
await grantRole(db, {
userId: coord.id,
studyId: study.id,
roleId: await roleId("data_entry"),
siteId: site.id,
grantedBy: admin.id,
});

const login = async (username: string) => {
const res = await server.inject({
method: "POST",
url: "/auth/login",
payload: { username, password: PASSWORD },
});
return res.json().token as string;
};
fx.managerToken = await login(`amm-${suffix}`);
fx.coordToken = await login(`amc-${suffix}`);

const imported = await server.inject({
method: "POST",
url: `/studies/${fx.studyId}/metadata-versions`,
headers: { authorization: `Bearer ${fx.managerToken}` },
payload: { content: demoOdm, note: "baseline" },
});
if (imported.statusCode !== 201) throw new Error(`build import failed: ${imported.body}`);

// Approved screening variant.
const created = await server.inject({
method: "POST",
url: `/studies/${fx.studyId}/sites/${fx.siteId}/form-variants`,
headers: { authorization: `Bearer ${fx.coordToken}` },
payload: { name: "Clinic flow", seedEventOids: ["SE.SCREENING"] },
});
if (created.statusCode !== 201) throw new Error(`variant create failed: ${created.body}`);
fx.versionId = created.json().versionId;
await server.inject({
method: "POST",
url: `/studies/${fx.studyId}/sites/${fx.siteId}/form-variants/versions/${fx.versionId}/submit`,
headers: { authorization: `Bearer ${fx.coordToken}` },
});
const approved = await server.inject({
method: "POST",
url: `/studies/${fx.studyId}/form-variants/versions/${fx.versionId}/approve`,
headers: { authorization: `Bearer ${fx.managerToken}` },
payload: {},
});
if (approved.statusCode !== 200) throw new Error(`approve failed: ${approved.body}`);
});

afterAll(async () => {
await server.close();
await client.end();
});

const auth = (token: string) => ({ authorization: `Bearer ${token}` });

it("carries an equivalent variant forward across an untouched amendment", async () => {
const res = await server.inject({
method: "POST",
url: `/studies/${fx.studyId}/metadata-versions`,
headers: auth(fx.managerToken),
payload: { content: demoOdm, note: "amendment without screening changes" },
});
expect(res.statusCode).toBe(201);

const effective = await server.inject({
method: "GET",
url: `/studies/${fx.studyId}/sites/${fx.siteId}/effective-forms?eventOid=SE.SCREENING`,
headers: auth(fx.coordToken),
});
expect(effective.json().source).toBe("variant");

const variants = await server.inject({
method: "GET",
url: `/studies/${fx.studyId}/sites/${fx.siteId}/form-variants`,
headers: auth(fx.coordToken),
});
const latest = variants.json()[0].latest;
expect(latest.status).toBe("approved");
expect(latest.decisionNote).toContain("carried forward");
});

it("stales the variant when the amendment changes its events, falling back to standard forms", async () => {
const amended = demoOdm
.replace(
'<ItemRef ItemOID="IT.DM.BRTHDTC" Mandatory="Yes"/>',
'<ItemRef ItemOID="IT.DM.BRTHDTC" Mandatory="Yes"/>\n <ItemRef ItemOID="IT.DM.SITEREF" Mandatory="No"/>',
)
.replace(
'<ItemDef DataType="date" Name="Birth Date" OID="IT.DM.BRTHDTC">',
'<ItemDef DataType="text" Name="Referring Site" OID="IT.DM.SITEREF">\n <Question><TranslatedText xml:lang="en" Type="text/plain">Referring site</TranslatedText></Question>\n </ItemDef>\n <ItemDef DataType="date" Name="Birth Date" OID="IT.DM.BRTHDTC">',
);
const res = await server.inject({
method: "POST",
url: `/studies/${fx.studyId}/metadata-versions`,
headers: auth(fx.managerToken),
payload: { content: amended, note: "amendment adding a screening item" },
});
expect(res.statusCode).toBe(201);

const effective = await server.inject({
method: "GET",
url: `/studies/${fx.studyId}/sites/${fx.siteId}/effective-forms?eventOid=SE.SCREENING`,
headers: auth(fx.coordToken),
});
expect(effective.json().source).toBe("standard");

const variants = await server.inject({
method: "GET",
url: `/studies/${fx.studyId}/sites/${fx.siteId}/form-variants`,
headers: auth(fx.coordToken),
});
const versions = variants.json()[0].versions as { status: string }[];
expect(versions.some((v) => v.status === "stale")).toBe(true);

const notifications = await server.inject({
method: "GET",
url: "/notifications",
headers: auth(fx.coordToken),
});
expect(notifications.statusCode).toBe(200);
const items = notifications.json().items ?? notifications.json();
expect(JSON.stringify(items).includes("needs an update")).toBe(true);
});
});
1 change: 1 addition & 0 deletions apps/api/src/services/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Db } from "../db/client.js";
import { notifications } from "../db/schema/index.js";

export type NotificationType =
| "site_forms.stale"
| "query.opened"
| "query.answered"
| "form.awaiting_signature"
Expand Down
125 changes: 125 additions & 0 deletions apps/api/src/services/site-forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
siteFormVariantVersions,
studyMetadataVersions,
} from "../db/schema/index.js";
import { notifyPermissionHolders } from "./notifications.js";
import type { StudyBuildDefinition } from "./study-builds.js";

/**
Expand Down Expand Up @@ -306,3 +307,127 @@ export async function effectiveFormsForEvent(
forms: forms.map((f) => ({ oid: f.oid, name: f.name })),
};
}

/**
* Amendment integration: when a new build publishes, every approved variant
* is revalidated against it. Data-equivalent variants are carried forward
* automatically (a cloned approved version targeting the new build, audited
* as a system action); the rest are marked stale — capture falls back to the
* standard forms until the site submits an updated layout — and both the
* site and the sponsor are notified. Runs inside the build-publish
* transaction so the outcome commits atomically with the amendment; variant
* problems never block the amendment itself.
*/
export async function revalidateVariantsForBuild(
db: Db,
input: { studyId: string; newMetadataVersionId: string; actorId: string },
): Promise<{ carried: number; staled: number }> {
const newMdv = await buildDefinition(db, input.newMetadataVersionId);

const approvedRows = await db
.select({
versionId: siteFormVariantVersions.id,
variantId: siteFormVariantVersions.variantId,
version: siteFormVariantVersions.version,
metadataVersionId: siteFormVariantVersions.metadataVersionId,
definition: siteFormVariantVersions.definition,
siteId: siteFormVariants.siteId,
name: siteFormVariants.name,
})
.from(siteFormVariantVersions)
.innerJoin(siteFormVariants, eq(siteFormVariantVersions.variantId, siteFormVariants.id))
.where(
and(
eq(siteFormVariants.studyId, input.studyId),
eq(siteFormVariantVersions.status, "approved"),
),
);

let carried = 0;
let staled = 0;
for (const row of approvedRows) {
if (row.metadataVersionId === input.newMetadataVersionId) continue;
const definition = siteFormVariantDefinitionSchema.parse(row.definition);
const issues = validateVariantCoverage(newMdv, definition).filter(
(i) => i.severity === "error",
);

if (issues.length === 0) {
const [latest] = await db
.select({ version: siteFormVariantVersions.version })
.from(siteFormVariantVersions)
.where(eq(siteFormVariantVersions.variantId, row.variantId))
.orderBy(desc(siteFormVariantVersions.version))
.limit(1);
const [carriedRow] = await db
.insert(siteFormVariantVersions)
.values({
variantId: row.variantId,
version: (latest?.version ?? row.version) + 1,
metadataVersionId: input.newMetadataVersionId,
definition: row.definition as object,
status: "approved",
decidedBy: input.actorId,
decidedAt: new Date(),
decisionNote: "carried forward on amendment (still data-equivalent)",
createdBy: input.actorId,
})
.returning();
// The superseded approval is retired so only one approval is live.
await db
.update(siteFormVariantVersions)
.set({ status: "retired" })
.where(eq(siteFormVariantVersions.id, row.versionId));
await db.insert(auditEvents).values({
actorId: input.actorId,
studyId: input.studyId,
action: "site_forms.carried_forward",
entityType: "site_form_variant_version",
entityId: carriedRow?.id ?? row.versionId,
newValue: { variantId: row.variantId, metadataVersionId: input.newMetadataVersionId },
});
carried++;
continue;
}

await db
.update(siteFormVariantVersions)
.set({ status: "stale" })
.where(eq(siteFormVariantVersions.id, row.versionId));
await db.insert(auditEvents).values({
actorId: input.actorId,
studyId: input.studyId,
action: "site_forms.staled",
entityType: "site_form_variant_version",
entityId: row.versionId,
newValue: {
variantId: row.variantId,
metadataVersionId: input.newMetadataVersionId,
issues: issues.map((i) => i.message).slice(0, 5),
},
});
const notification = {
studyId: input.studyId,
type: "site_forms.stale" as const,
title: `Site layout "${row.name}" needs an update`,
body: "A new study build changed the data this layout covers. Capture uses the standard forms until an updated layout is approved.",
payload: { variantId: row.variantId, siteId: row.siteId },
dedupeKey: `site_forms.stale:${row.versionId}`,
};
await notifyPermissionHolders(db, {
permission: "site.forms.manage",
scope: { studyId: input.studyId, siteId: row.siteId },
excludeUserId: input.actorId,
notification,
});
await notifyPermissionHolders(db, {
permission: "study.manage",
scope: { studyId: input.studyId },
excludeUserId: input.actorId,
notification,
});
staled++;
}

return { carried, staled };
}
Loading
Loading