mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
Add ROS2 bridge
This commit is contained in:
346
src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx
Normal file
346
src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Play, Zap, ArrowLeft, User, FlaskConical } from "lucide-react";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
function TrialDetailContent() {
|
||||
const params = useParams();
|
||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||
const trialId: string =
|
||||
typeof params.trialId === "string" ? params.trialId : "";
|
||||
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
|
||||
// Get trial data
|
||||
const {
|
||||
data: trial,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: "Trials", href: `/studies/${studyId}/trials` },
|
||||
{ label: trial?.participant.participantCode ?? "Trial" },
|
||||
]);
|
||||
|
||||
// Sync selected study (unified study-context)
|
||||
useEffect(() => {
|
||||
if (studyId && selectedStudyId !== studyId) {
|
||||
setSelectedStudyId(studyId);
|
||||
}
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
const getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "default";
|
||||
case "in_progress":
|
||||
return "secondary";
|
||||
case "scheduled":
|
||||
return "outline";
|
||||
case "failed":
|
||||
case "aborted":
|
||||
return "destructive";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading trial...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trial Details"
|
||||
description="View trial information and execution data"
|
||||
icon={Play}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<a href={`/studies/${studyId}/trials`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trials
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h3 className="text-destructive mb-2 text-lg font-semibold">
|
||||
Error Loading Trial
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{error.message || "Failed to load trial data"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!trial) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trial Details"
|
||||
description="View trial information and execution data"
|
||||
icon={Play}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<a href={`/studies/${studyId}/trials`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trials
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h3 className="mb-2 text-lg font-semibold">Trial Not Found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The requested trial could not be found.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={`Trial: ${trial.participant.participantCode}`}
|
||||
description={`${trial.experiment.name} - Session ${trial.sessionNumber}`}
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Button>
|
||||
)}
|
||||
{(trial.status === "in_progress" ||
|
||||
trial.status === "scheduled") && (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Wizard Interface
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/trials`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trials
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Trial Overview */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Play className="h-5 w-5" />
|
||||
Trial Overview
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Basic information about this trial execution
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-muted-foreground text-sm font-medium">
|
||||
Status
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<Badge variant={getStatusBadgeVariant(trial.status)}>
|
||||
{trial.status.replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-muted-foreground text-sm font-medium">
|
||||
Session Number
|
||||
</label>
|
||||
<div className="mt-1 text-sm">{trial.sessionNumber}</div>
|
||||
</div>
|
||||
{trial.scheduledAt && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-sm font-medium">
|
||||
Scheduled
|
||||
</label>
|
||||
<div className="mt-1 text-sm">
|
||||
{formatDistanceToNow(new Date(trial.scheduledAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{trial.startedAt && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-sm font-medium">
|
||||
Started
|
||||
</label>
|
||||
<div className="mt-1 text-sm">
|
||||
{formatDistanceToNow(new Date(trial.startedAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{trial.completedAt && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-sm font-medium">
|
||||
Completed
|
||||
</label>
|
||||
<div className="mt-1 text-sm">
|
||||
{formatDistanceToNow(new Date(trial.completedAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{trial.duration && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-sm font-medium">
|
||||
Duration
|
||||
</label>
|
||||
<div className="mt-1 text-sm">
|
||||
{Math.round(trial.duration / 1000)}s
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{trial.notes && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-sm font-medium">
|
||||
Notes
|
||||
</label>
|
||||
<div className="text-muted-foreground mt-1 text-sm">
|
||||
{trial.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="space-y-6">
|
||||
{/* Experiment Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FlaskConical className="h-5 w-5" />
|
||||
Experiment
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div>
|
||||
<label className="text-muted-foreground text-sm font-medium">
|
||||
Name
|
||||
</label>
|
||||
<div className="mt-1 text-sm">{trial.experiment.name}</div>
|
||||
</div>
|
||||
{trial.experiment.description && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-sm font-medium">
|
||||
Description
|
||||
</label>
|
||||
<div className="text-muted-foreground mt-1 text-sm">
|
||||
{trial.experiment.description}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Participant Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Participant
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div>
|
||||
<label className="text-muted-foreground text-sm font-medium">
|
||||
Code
|
||||
</label>
|
||||
<div className="mt-1 text-sm">
|
||||
{trial.participant.participantCode}
|
||||
</div>
|
||||
</div>
|
||||
{(() => {
|
||||
const demographics = trial.participant.demographics as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
return (
|
||||
demographics &&
|
||||
typeof demographics === "object" && (
|
||||
<div>
|
||||
<label className="text-muted-foreground text-sm font-medium">
|
||||
Demographics
|
||||
</label>
|
||||
<div className="text-muted-foreground mt-1 text-sm">
|
||||
{Object.keys(demographics).length} fields recorded
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrialDetailPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TrialDetailContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Zap, ArrowLeft, Eye, User } from "lucide-react";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
||||
import { WizardView } from "~/components/trials/views/WizardView";
|
||||
import { ObserverView } from "~/components/trials/views/ObserverView";
|
||||
import { ParticipantView } from "~/components/trials/views/ParticipantView";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
function WizardPageContent() {
|
||||
const params = useParams();
|
||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||
const trialId: string =
|
||||
typeof params.trialId === "string" ? params.trialId : "";
|
||||
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
const { data: session } = useSession();
|
||||
|
||||
// Get trial data
|
||||
const {
|
||||
data: trial,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: "Trials", href: `/studies/${studyId}/trials` },
|
||||
{
|
||||
label: trial?.experiment.name ?? "Trial",
|
||||
href: `/studies/${studyId}/trials`,
|
||||
},
|
||||
{ label: "Wizard Interface" },
|
||||
]);
|
||||
|
||||
// Sync selected study (unified study-context)
|
||||
useEffect(() => {
|
||||
if (studyId && selectedStudyId !== studyId) {
|
||||
setSelectedStudyId(studyId);
|
||||
}
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
// Determine user role and view type
|
||||
const getUserRole = () => {
|
||||
if (!session?.user) return "observer";
|
||||
|
||||
// Check URL parameters for role override (for testing)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const roleParam = urlParams.get("view");
|
||||
if (
|
||||
roleParam &&
|
||||
["wizard", "observer", "participant"].includes(roleParam)
|
||||
) {
|
||||
return roleParam;
|
||||
}
|
||||
|
||||
// Default role logic based on user
|
||||
const userRole = session.user.roles?.[0]?.role ?? "observer";
|
||||
if (userRole === "administrator" || userRole === "researcher") {
|
||||
return "wizard";
|
||||
}
|
||||
|
||||
return "observer";
|
||||
};
|
||||
|
||||
const currentRole = getUserRole();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading trial...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Wizard Interface"
|
||||
description="Trial execution interface for wizards"
|
||||
icon={Zap}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<a href={`/studies/${studyId}/trials`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trials
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h3 className="text-destructive mb-2 text-lg font-semibold">
|
||||
Error Loading Trial
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{error.message || "Failed to load trial data"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!trial) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Wizard Interface"
|
||||
description="Trial execution interface for wizards"
|
||||
icon={Zap}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<a href={`/studies/${studyId}/trials`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trials
|
||||
</a>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h3 className="mb-2 text-lg font-semibold">Trial Not Found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
The requested trial could not be found.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getViewTitle = (role: string) => {
|
||||
switch (role) {
|
||||
case "wizard":
|
||||
return `${trial.experiment.name} - Wizard Control`;
|
||||
case "observer":
|
||||
return `${trial.experiment.name} - Observer View`;
|
||||
case "participant":
|
||||
return `Research Session - ${trial.participant.participantCode}`;
|
||||
default:
|
||||
return `${trial.experiment.name} - Trial View`;
|
||||
}
|
||||
};
|
||||
|
||||
const getViewIcon = (role: string) => {
|
||||
switch (role) {
|
||||
case "wizard":
|
||||
return Zap;
|
||||
case "observer":
|
||||
return Eye;
|
||||
case "participant":
|
||||
return User;
|
||||
default:
|
||||
return Zap;
|
||||
}
|
||||
};
|
||||
|
||||
const renderView = () => {
|
||||
const trialData = {
|
||||
...trial,
|
||||
metadata: trial.metadata as Record<string, unknown> | null,
|
||||
participant: {
|
||||
...trial.participant,
|
||||
demographics: trial.participant.demographics as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
},
|
||||
};
|
||||
|
||||
switch (currentRole) {
|
||||
case "wizard":
|
||||
return <WizardView trial={trialData} />;
|
||||
case "observer":
|
||||
return <ObserverView trial={trialData} />;
|
||||
case "participant":
|
||||
return <ParticipantView trial={trialData} />;
|
||||
default:
|
||||
return <ObserverView trial={trialData} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={getViewTitle(currentRole)}
|
||||
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||
icon={getViewIcon(currentRole)}
|
||||
actions={
|
||||
currentRole !== "participant" ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trial
|
||||
</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1">{renderView()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrialWizardPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<WizardPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
79
src/app/api/test-trial/route.ts
Normal file
79
src/app/api/test-trial/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { db } from "~/server/db";
|
||||
import { trials, experiments, participants } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const trialId = searchParams.get("id");
|
||||
|
||||
if (!trialId) {
|
||||
// Get all trials for debugging
|
||||
const allTrials = await db
|
||||
.select({
|
||||
id: trials.id,
|
||||
status: trials.status,
|
||||
experimentId: trials.experimentId,
|
||||
participantId: trials.participantId,
|
||||
sessionNumber: trials.sessionNumber,
|
||||
scheduledAt: trials.scheduledAt,
|
||||
startedAt: trials.startedAt,
|
||||
})
|
||||
.from(trials)
|
||||
.limit(10);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Database connection working",
|
||||
trials: allTrials,
|
||||
count: allTrials.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Get specific trial
|
||||
const trial = await db
|
||||
.select({
|
||||
id: trials.id,
|
||||
status: trials.status,
|
||||
experimentId: trials.experimentId,
|
||||
participantId: trials.participantId,
|
||||
sessionNumber: trials.sessionNumber,
|
||||
scheduledAt: trials.scheduledAt,
|
||||
startedAt: trials.startedAt,
|
||||
experiment: {
|
||||
id: experiments.id,
|
||||
name: experiments.name,
|
||||
},
|
||||
participant: {
|
||||
id: participants.id,
|
||||
participantCode: participants.participantCode,
|
||||
},
|
||||
})
|
||||
.from(trials)
|
||||
.leftJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.leftJoin(participants, eq(trials.participantId, participants.id))
|
||||
.where(eq(trials.id, trialId))
|
||||
.limit(1);
|
||||
|
||||
if (!trial[0]) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: "Trial not found",
|
||||
trialId,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
trial: trial[0],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Test trial API error:", error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
export const runtime = "edge";
|
||||
|
||||
declare global {
|
||||
var WebSocketPair: new () => { 0: WebSocket; 1: WebSocket };
|
||||
|
||||
interface WebSocket {
|
||||
accept(): void;
|
||||
}
|
||||
|
||||
interface ResponseInit {
|
||||
webSocket?: WebSocket;
|
||||
}
|
||||
}
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
interface ClientInfo {
|
||||
userId: string | null;
|
||||
role: "wizard" | "researcher" | "administrator" | "observer" | "unknown";
|
||||
connectedAt: number;
|
||||
}
|
||||
|
||||
interface TrialState {
|
||||
trial: {
|
||||
id: string;
|
||||
status: "scheduled" | "in_progress" | "completed" | "aborted" | "failed";
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
};
|
||||
currentStepIndex: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// Per-trial subscriber sets
|
||||
// Using globalThis for ephemeral in-memory broadcast in the current Edge isolate
|
||||
// (not shared globally across regions/instances)
|
||||
var __trialRooms: Map<string, Set<WebSocket>> | undefined;
|
||||
var __trialState: Map<string, TrialState> | undefined;
|
||||
}
|
||||
|
||||
const rooms = (globalThis.__trialRooms ??= new Map<string, Set<WebSocket>>());
|
||||
const states = (globalThis.__trialState ??= new Map<string, TrialState>());
|
||||
|
||||
function safeJSON<T>(v: T): string {
|
||||
try {
|
||||
return JSON.stringify(v);
|
||||
} catch {
|
||||
return '{"type":"error","data":{"message":"serialization_error"}}';
|
||||
}
|
||||
}
|
||||
|
||||
function send(ws: WebSocket, message: { type: string; data?: Json }) {
|
||||
try {
|
||||
ws.send(safeJSON(message));
|
||||
} catch {
|
||||
// swallow send errors
|
||||
}
|
||||
}
|
||||
|
||||
function broadcast(trialId: string, message: { type: string; data?: Json }) {
|
||||
const room = rooms.get(trialId);
|
||||
if (!room) return;
|
||||
const payload = safeJSON(message);
|
||||
for (const client of room) {
|
||||
try {
|
||||
client.send(payload);
|
||||
} catch {
|
||||
// ignore individual client send failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTrialState(trialId: string): TrialState {
|
||||
let state = states.get(trialId);
|
||||
if (!state) {
|
||||
state = {
|
||||
trial: {
|
||||
id: trialId,
|
||||
status: "scheduled",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
},
|
||||
currentStepIndex: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
states.set(trialId, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function updateTrialStatus(
|
||||
trialId: string,
|
||||
patch: Partial<TrialState["trial"]> &
|
||||
Partial<Pick<TrialState, "currentStepIndex">>,
|
||||
) {
|
||||
const state = ensureTrialState(trialId);
|
||||
if (typeof patch.currentStepIndex === "number") {
|
||||
state.currentStepIndex = patch.currentStepIndex;
|
||||
}
|
||||
state.trial = {
|
||||
...state.trial,
|
||||
...(patch.status !== undefined ? { status: patch.status } : {}),
|
||||
...(patch.startedAt !== undefined
|
||||
? { startedAt: patch.startedAt ?? null }
|
||||
: {}),
|
||||
...(patch.completedAt !== undefined
|
||||
? { completedAt: patch.completedAt ?? null }
|
||||
: {}),
|
||||
};
|
||||
state.updatedAt = Date.now();
|
||||
states.set(trialId, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
// Very lightweight token parse (base64-encoded JSON per client hook)
|
||||
// In production, replace with properly signed JWT verification.
|
||||
function parseToken(token: string | null): ClientInfo {
|
||||
if (!token) {
|
||||
return { userId: null, role: "unknown", connectedAt: Date.now() };
|
||||
}
|
||||
try {
|
||||
const decodedUnknown = JSON.parse(atob(token)) as unknown;
|
||||
const userId =
|
||||
typeof decodedUnknown === "object" &&
|
||||
decodedUnknown !== null &&
|
||||
"userId" in decodedUnknown &&
|
||||
typeof (decodedUnknown as Record<string, unknown>).userId === "string"
|
||||
? ((decodedUnknown as Record<string, unknown>).userId as string)
|
||||
: null;
|
||||
|
||||
const connectedAt = Date.now();
|
||||
const role: ClientInfo["role"] = "wizard"; // default role for live trial control context
|
||||
|
||||
return { userId, role, connectedAt };
|
||||
} catch {
|
||||
return { userId: null, role: "unknown", connectedAt: Date.now() };
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request): Promise<Response> {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const trialId = searchParams.get("trialId");
|
||||
const token = searchParams.get("token");
|
||||
|
||||
if (!trialId) {
|
||||
return new Response("Missing trialId parameter", { status: 400 });
|
||||
}
|
||||
|
||||
// If this isn't a WebSocket upgrade, return a small JSON descriptor
|
||||
const upgrade = req.headers.get("upgrade") ?? "";
|
||||
if (upgrade.toLowerCase() !== "websocket") {
|
||||
return new Response(
|
||||
safeJSON({
|
||||
message: "WebSocket endpoint",
|
||||
trialId,
|
||||
info: "Open a WebSocket connection to this URL to receive live trial updates.",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Create WebSocket pair (typed) and destructure endpoints
|
||||
const pair = new WebSocketPair();
|
||||
const client = pair[0];
|
||||
const server = pair[1];
|
||||
|
||||
// Register server-side handlers
|
||||
server.accept();
|
||||
|
||||
const clientInfo = parseToken(token);
|
||||
|
||||
// Join room
|
||||
const room = rooms.get(trialId) ?? new Set<WebSocket>();
|
||||
room.add(server);
|
||||
rooms.set(trialId, room);
|
||||
|
||||
// Immediately acknowledge connection and provide current trial status snapshot
|
||||
const state = ensureTrialState(trialId);
|
||||
|
||||
send(server, {
|
||||
type: "connection_established",
|
||||
data: {
|
||||
trialId,
|
||||
userId: clientInfo.userId,
|
||||
role: clientInfo.role,
|
||||
connectedAt: clientInfo.connectedAt,
|
||||
},
|
||||
});
|
||||
|
||||
send(server, {
|
||||
type: "trial_status",
|
||||
data: {
|
||||
trial: state.trial,
|
||||
current_step_index: state.currentStepIndex,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
server.addEventListener("message", (ev: MessageEvent<string>) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(typeof ev.data === "string" ? ev.data : "{}");
|
||||
} catch {
|
||||
send(server, {
|
||||
type: "error",
|
||||
data: { message: "invalid_json" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const maybeObj =
|
||||
typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const type = typeof maybeObj.type === "string" ? maybeObj.type : "";
|
||||
const data: Json =
|
||||
maybeObj.data &&
|
||||
typeof maybeObj.data === "object" &&
|
||||
maybeObj.data !== null
|
||||
? (maybeObj.data as Record<string, unknown>)
|
||||
: {};
|
||||
const now = Date.now();
|
||||
|
||||
const getString = (key: string, fallback = ""): string => {
|
||||
const v = (data as Record<string, unknown>)[key];
|
||||
return typeof v === "string" ? v : fallback;
|
||||
};
|
||||
const getNumber = (key: string): number | undefined => {
|
||||
const v = (data as Record<string, unknown>)[key];
|
||||
return typeof v === "number" ? v : undefined;
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case "heartbeat": {
|
||||
send(server, { type: "heartbeat_response", data: { timestamp: now } });
|
||||
break;
|
||||
}
|
||||
|
||||
case "request_trial_status": {
|
||||
const s = ensureTrialState(trialId);
|
||||
send(server, {
|
||||
type: "trial_status",
|
||||
data: {
|
||||
trial: s.trial,
|
||||
current_step_index: s.currentStepIndex,
|
||||
timestamp: now,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "trial_action": {
|
||||
// Supports: start_trial, complete_trial, abort_trial, and generic actions
|
||||
const actionType = getString("actionType", "unknown");
|
||||
let updated: TrialState | null = null;
|
||||
|
||||
if (actionType === "start_trial") {
|
||||
const stepIdx = getNumber("step_index") ?? 0;
|
||||
updated = updateTrialStatus(trialId, {
|
||||
status: "in_progress",
|
||||
startedAt: new Date().toISOString(),
|
||||
currentStepIndex: stepIdx,
|
||||
});
|
||||
} else if (actionType === "complete_trial") {
|
||||
updated = updateTrialStatus(trialId, {
|
||||
status: "completed",
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
} else if (actionType === "abort_trial") {
|
||||
updated = updateTrialStatus(trialId, {
|
||||
status: "aborted",
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Broadcast the action execution event
|
||||
broadcast(trialId, {
|
||||
type: "trial_action_executed",
|
||||
data: {
|
||||
action_type: actionType,
|
||||
timestamp: now,
|
||||
userId: clientInfo.userId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
|
||||
// If trial state changed, broadcast status
|
||||
if (updated) {
|
||||
broadcast(trialId, {
|
||||
type: "trial_status",
|
||||
data: {
|
||||
trial: updated.trial,
|
||||
current_step_index: updated.currentStepIndex,
|
||||
timestamp: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "wizard_intervention": {
|
||||
// Log/broadcast a wizard intervention (note, correction, manual control)
|
||||
broadcast(trialId, {
|
||||
type: "intervention_logged",
|
||||
data: {
|
||||
timestamp: now,
|
||||
userId: clientInfo.userId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "step_transition": {
|
||||
// Update step index and broadcast
|
||||
const from = getNumber("from_step");
|
||||
const to = getNumber("to_step");
|
||||
|
||||
if (typeof to !== "number" || !Number.isFinite(to)) {
|
||||
send(server, {
|
||||
type: "error",
|
||||
data: { message: "invalid_step_transition" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = updateTrialStatus(trialId, {
|
||||
currentStepIndex: to,
|
||||
});
|
||||
|
||||
broadcast(trialId, {
|
||||
type: "step_changed",
|
||||
data: {
|
||||
timestamp: now,
|
||||
userId: clientInfo.userId,
|
||||
from_step:
|
||||
typeof from === "number" ? from : updated.currentStepIndex,
|
||||
to_step: updated.currentStepIndex,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
// Relay unknown/custom messages to participants in the same trial room
|
||||
broadcast(trialId, {
|
||||
type: type !== "" ? type : "message",
|
||||
data: {
|
||||
timestamp: now,
|
||||
userId: clientInfo.userId,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.addEventListener("close", () => {
|
||||
const room = rooms.get(trialId);
|
||||
if (room) {
|
||||
room.delete(server);
|
||||
if (room.size === 0) {
|
||||
rooms.delete(trialId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
server.addEventListener("error", () => {
|
||||
try {
|
||||
server.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const room = rooms.get(trialId);
|
||||
if (room) {
|
||||
room.delete(server);
|
||||
if (room.size === 0) {
|
||||
rooms.delete(trialId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Hand over the client end of the socket to the response
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
webSocket: client,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user