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
29 changes: 28 additions & 1 deletion packages/api/src/routes/studies.route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Router } from "express";
import { Router, Request, Response, NextFunction } from "express";
import type { HydratedDocument } from "mongoose";
import type { IUser } from "@seitz/shared";

import { isAuthenticated } from "../middleware/auth";
import * as studyService from "../services/study.service";
Expand Down Expand Up @@ -121,4 +123,29 @@ router.post(
authRoute((req, user) => studyService.duplicateStudy(user, req.params.id))
);

router.get(
"/:id/export",
isAuthenticated,
async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user as unknown as HydratedDocument<IUser>;
const [status, exportObj] = await studyService.exportStudy(
user,
req.params.id
);
if (!exportObj) return res.sendStatus(status);

const filename = `study-${req.params.name}-export.json`;
res.setHeader(
"Content-Disposition",
`attachment; filename="${filename}"`
);
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.status(status).send(JSON.stringify(exportObj, null, 2));
} catch (err) {
next(err);
}
}
);

export default router;
222 changes: 222 additions & 0 deletions packages/api/src/services/export.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { Study } from "../models";
import type { IStudyVariant } from "@seitz/shared";

// Local types used to maintain type safety and accomadate flexibility
type LooseRecord = Record<string, unknown>;
type StageLike = {
type?: string;
stageLabel?: string;
stagePrecursor?: unknown;
} & LooseRecord;
type BaseBatteryLike = {
stages?: StageLike[];
name?: string;
description?: string;
version?: number;
} & LooseRecord;
type TaskRefLike = {
_id?: unknown;
name?: string;
battery?: { name?: string } & LooseRecord;
} & LooseRecord;
type TaskInstanceLike = { task?: TaskRefLike } & LooseRecord;
type SessionLike = { tasks?: TaskInstanceLike[] } & LooseRecord;
type VariantLike = {
serverCode?: string;
_id?: unknown;
name?: string;
sessions?: SessionLike[];
} & LooseRecord;

export async function buildStudyConditionExport(studyId: string) {
const study = await Study.findById(studyId)
.populate({
path: "variants.sessions.tasks.task",
populate: { path: "battery" },
})
.lean()
.exec();

if (!study) throw new Error("Study not found");

const conditions = (study.variants || []).map((v: IStudyVariant) => ({
ProtocolKey: v.serverCode || "",
StudyName: study.name || "",
}));

return {
StudyId: study._id,
StudyName: study.name,
Conditions: conditions,
};
}

export async function buildFullClientExport(studyId: string) {
const study = await Study.findById(studyId)
.populate({ path: "batteries", populate: { path: "battery" } })
.populate({
path: "variants.sessions.tasks.task",
populate: { path: "battery" },
})
.lean()
.exec();

if (!study) throw new Error("Study not found");

const studyName = study.name || "";

const batteryMap = new Map<string, unknown>();

if (Array.isArray(study.batteries)) {
for (const b of study.batteries) {
const id =
(b as unknown as LooseRecord)._id?.toString?.() ?? String(b as unknown);
if (!batteryMap.has(id)) batteryMap.set(id, b as unknown);
}
}

if (Array.isArray(study.variants)) {
for (const variant of (study.variants || []) as unknown as VariantLike[]) {
for (const session of variant.sessions || []) {
for (const t of session.tasks || []) {
const task = (t as TaskInstanceLike).task as TaskRefLike | undefined;
const idKey = task?._id ? String(task._id) : String(task ?? "");
if (task && !batteryMap.has(idKey)) {
batteryMap.set(idKey, task as unknown);
}
}
}
}
}

const Batteries = Array.from(batteryMap.values()).map((b) => {
const baseBattery = (b as unknown as LooseRecord).battery ?? b;
const base = baseBattery as BaseBatteryLike;

const stages = (base.stages || []).map((s) => {
const stage = s as StageLike;
const mapped: Record<string, unknown> = { ...(stage as LooseRecord) };
if (stage.type) {
mapped.Type = stage.type;
delete (mapped as LooseRecord).type;
}
if (stage.stageLabel) {
mapped.StageLabel = stage.stageLabel;
delete (mapped as LooseRecord).stageLabel;
}
// Placeholder for stage precursor
if (!mapped["Stage Precursor"]) {
if ((stage as LooseRecord).stagePrecursor) {
mapped["Stage Precursor"] = (stage as LooseRecord).stagePrecursor;
} else {
mapped["Stage Precursor"] = { Type: "None" };
}
}
return mapped;
});

// Placeholder for type field
return {
Type: "Custom",
Name: (b as LooseRecord).name ?? base.name ?? "",
Description: base.description ?? "",
Version: (base.version as number) || 0,
Stages: stages,
};
});

const Conditions = ((study.variants || []) as unknown as VariantLike[]).map(
(v) => ({
ProtocolKey: v.serverCode || "",
StudyName: studyName,
})
);

const Sessions: unknown[] = [];
const SessionElements: unknown[] = [];
const Protocols: Record<string, unknown> = {};

let nextSessionId = 1;
let nextElementId = 1;

for (const variant of (study.variants || []) as unknown as VariantLike[]) {
const protocolSessions: number[] = [];

for (const session of variant.sessions || []) {
const sessionId = nextSessionId++;
const elementIds: number[] = [];

for (const elem of session.tasks || []) {
const elId = nextElementId++;
elementIds.push(elId);

const task = (elem as TaskInstanceLike).task as TaskRefLike | undefined;

const batteryName =
task?.name ?? task?.battery?.name ?? String(task?._id ?? task);

SessionElements.push({
Id: elId,
Type: "Battery",
Battery: batteryName,
});
}

Sessions.push({ Id: sessionId, Elements: elementIds });
protocolSessions.push(sessionId);
}

// keeping protocolkey as variant server id
const protocolKey = variant.serverCode ?? String(variant._id ?? "");
Protocols[protocolKey] = {
Name: variant.name || "",
Sessions: protocolSessions,
};
}

const ProtocolExport = {
Version: 1, // placeholder since we do not have versioning
Protocols,
Sessions,
SessionElements,
};

return {
studyName,
Batteries,
Conditions,
Protocols: ProtocolExport,
};
}

import fs from "fs/promises";
import path from "path";

export async function exportStudyToDisk(studyId: string, outDir: string) {
const study = await Study.findById(studyId).lean().exec();
if (!study) throw new Error("Study not found");

await fs.mkdir(outDir, { recursive: true });

const studyDirName = `${(study.name || "study").replace(
/[^a-z0-9-_.]/gi,
"_"
)}_${studyId}`.slice(0, 140);
const studyDir = path.join(outDir, studyDirName);
await fs.mkdir(studyDir, { recursive: true });

await fs.writeFile(
path.join(studyDir, "study.json"),
JSON.stringify(study, null, 2),
"utf-8"
);

const clientExport = await buildFullClientExport(studyId);
await fs.writeFile(
path.join(studyDir, "clientExport.json"),
JSON.stringify(clientExport, null, 2),
"utf-8"
);

return studyDir;
}
19 changes: 19 additions & 0 deletions packages/api/src/services/study.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import HttpError from "../types/errors";
import { CustomizedBattery, Study } from "../models";
import RedisService from "./redis.service";
import { buildFullClientExport } from "./export.service";

import type { HydratedDocument, Types } from "mongoose";
import type {
Expand Down Expand Up @@ -450,3 +451,21 @@ export const duplicateStudy = async (

return [201, newStudy._id];
};

export const exportStudy = async (
user: HydratedDocument<IUser>,
studyId: string
): APIResponse<unknown> => {
const study = await Study.findOne({ _id: studyId, owner: user._id });
if (!study) throw new HttpError(404, "Study not found.");
try {
const exportObj = await buildFullClientExport(studyId);
return [200, exportObj];
} catch (error) {
console.error("Error exporting study:", error);
throw new HttpError(
500,
"An internal error occured while exporting the study"
);
}
};
57 changes: 57 additions & 0 deletions packages/shared/types/client-export/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
export interface ClientProtocolExport {
Version: number;
Protocols: Record<string, ClientProtocol>;
Sessions: ClientSession[];
SessionElements: ClientSessionElement[];
}

export interface ClientBatteryExport {
Type: string;
Name: string;
Description: string;
Version: number;
Stages: ClientStage[];
}

export interface ClientProtocol {
Name: string;
Sessions: number[];
}

export interface ClientSession {
Id: number;
Elements: number[];
}

export type ClientSessionElement = ClientBatteryElement | ClientEndElement;

export interface ClientBatteryElement {
Id: number;
Type: "Battery";
Battery: string;
}

export interface ClientEndElement {
Id: number;
Type: "End";
Instructions: string;
Lockout: number;
ShowQuitButton: boolean;
Password: string;
}

export type ClientStagePrecursor =
| { Type: "None" }
| { Type: "Script Conditional"; Script: string };

export interface ClientStage {
Type: string;
StageLabel?: string;
"Stage Precursor"?: ClientStagePrecursor;
[key: string]: unknown;
}

export interface ClientConditionExport {
ProtocolKey: string;
StudyName: string;
}
15 changes: 15 additions & 0 deletions packages/ui/src/api/studies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ async function duplicateStudy(id: string) {
return response.data;
}

async function downloadStudyExport(id: string) {
const resp = await axiosInstance.get(`/studies/${id}/export`, {
responseType: "blob",
});
const url = window.URL.createObjectURL(resp.data);
const a = document.createElement("a");
a.href = url;
a.download = `study-${id}-export.json`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
}

export interface VariantFromQuery {
_id: string;
name: string;
Expand Down Expand Up @@ -111,4 +125,5 @@ export default {
updateVariant,
fetchRecentStudies,
duplicateStudy,
downloadStudyExport,
};
Loading