26 KiB
Executable File
HRIStudio Technical Implementation Guide
Overview
This guide provides step-by-step technical instructions for implementing HRIStudio using the T3 stack with Next.js, tRPC, Drizzle ORM, NextAuth.js v5, and supporting infrastructure.
Table of Contents
- Project Setup
- Development Environment
- Database Implementation
- Authentication System
- tRPC API Implementation
- Frontend Architecture
- Real-time Features
- File Storage System
- Plugin System
- Testing Strategy
- Deployment
- Performance Optimization
- Security Implementation
Project Setup
1. Initialize Project
# Create new Next.js project with T3 stack
bunx create-t3-app@latest hristudio \
--nextjs \
--tailwind \
--trpc \
--drizzle \
--app-router \
--package-manager bun \
--typescript
# Update to Next.js 15
cd hristudio
bun add next@15 react@rc react-dom@rc
cd hristudio
2. Install Additional Dependencies
# Core dependencies
bun add @auth/drizzle-adapter next-auth@beta
bun add @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-label @radix-ui/react-select @radix-ui/react-slot @radix-ui/react-tabs @radix-ui/react-toast
bun add class-variance-authority clsx tailwind-merge
bun add nuqs zod react-hook-form @hookform/resolvers
bun add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
bun add ws @types/ws
bun add date-fns
bun add recharts
bun add roslib @types/roslib # For ROS2 integration via rosbridge
# Development dependencies
bun add -d @types/node
bun add -d drizzle-kit
bun add -d @faker-js/faker
bun add -d vitest @vitest/ui
bun add -d @testing-library/react @testing-library/jest-dom
bun add -d playwright @playwright/test
3. Project Structure
src/
├── app/
│ ├── (auth)/
│ │ ├── login/
│ │ ├── register/
│ │ └── verify/
│ ├── (dashboard)/
│ │ ├── studies/
│ │ ├── experiments/
│ │ ├── trials/
│ │ └── admin/
│ ├── api/
│ │ ├── auth/[...nextauth]/
│ │ ├── trpc/[trpc]/
│ │ └── ws/
│ └── layout.tsx
├── components/
│ ├── ui/ # Shadcn components
│ ├── experiment/
│ │ ├── designer/
│ │ ├── step-editor/
│ │ └── action-library/
│ ├── trial/
│ │ ├── wizard-interface/
│ │ ├── execution-panel/
│ │ └── quick-actions/
│ └── layout/
│ ├── navigation/
│ ├── sidebar/
│ └── breadcrumbs/
├── features/
│ ├── auth/
│ ├── studies/
│ ├── experiments/
│ ├── trials/
│ ├── participants/
│ ├── plugins/
│ └── analysis/
├── hooks/
├── lib/
│ ├── auth/
│ ├── db/
│ ├── trpc/
│ ├── storage/
│ └── websocket/
├── server/
│ ├── api/
│ └── db/
└── types/
Development Environment
1. Environment Variables
Create .env.local:
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/hristudio?sslmode=disable"
# NextAuth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="generate-with-openssl-rand-base64-32"
# OAuth Providers (optional)
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
GITHUB_ID=""
GITHUB_SECRET=""
# S3/MinIO
S3_ENDPOINT="http://localhost:9000"
S3_ACCESS_KEY_ID="minioadmin"
S3_SECRET_ACCESS_KEY="minioadmin"
S3_BUCKET_NAME="hristudio"
S3_REGION="us-east-1"
# WebSocket
WS_URL="wss://your-app.vercel.app/api/ws" # For production on Vercel
# ROS2 Bridge
ROSBRIDGE_URL="ws://localhost:9090" # Local development
# ROSBRIDGE_URL="wss://your-ros-bridge.com:9090" # Production
# Email (for production)
SMTP_HOST=""
SMTP_PORT=""
SMTP_USER=""
SMTP_PASS=""
SMTP_FROM=""
2. Docker Services
Ensure Docker services are running:
docker-compose up -d
3. Initialize Database
# Generate database migrations
bun db:generate
# Run migrations
bun db:migrate
# Seed database (development)
bun db:seed
Database Implementation
1. Drizzle Configuration
Create src/lib/db/index.ts:
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
const connectionString = process.env.DATABASE_URL!;
const sql = postgres(connectionString, { max: 1 });
export const db = drizzle(sql, { schema });
export type Database = typeof db;
2. Schema Definition
Create modular schema files:
src/lib/db/schema/users.ts:
import { pgTable, uuid, varchar, timestamp, boolean } from 'drizzle-orm/pg-core';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: varchar('email', { length: 255 }).notNull().unique(),
emailVerified: timestamp('email_verified'),
name: varchar('name', { length: 255 }),
image: varchar('image'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
deletedAt: timestamp('deleted_at'),
});
export const insertUserSchema = createInsertSchema(users);
export const selectUserSchema = createSelectSchema(users);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
3. Relationships
src/lib/db/schema/relations.ts:
import { relations } from 'drizzle-orm';
import { users, studies, studyMembers } from './index';
export const usersRelations = relations(users, ({ many }) => ({
ownedStudies: many(studies),
studyMemberships: many(studyMembers),
}));
export const studiesRelations = relations(studies, ({ one, many }) => ({
creator: one(users, {
fields: [studies.createdBy],
references: [users.id],
}),
members: many(studyMembers),
experiments: many(experiments),
}));
4. Database Utilities
src/lib/db/utils.ts:
import { db } from './index';
import { sql } from 'drizzle-orm';
export async function withTransaction<T>(
callback: (tx: typeof db) => Promise<T>
): Promise<T> {
return await db.transaction(callback);
}
export async function checkUserPermission(
userId: string,
studyId: string,
requiredPermission: string
): Promise<boolean> {
const result = await db.execute(sql`
SELECT check_user_permission(${userId}, ${studyId}, ${requiredPermission})
`);
return result.rows[0]?.check_user_permission ?? false;
}
Authentication System
1. NextAuth Configuration
src/lib/auth/config.ts:
import { NextAuthConfig } from 'next-auth';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import CredentialsProvider from 'next-auth/providers/credentials';
import GoogleProvider from 'next-auth/providers/google';
import { db } from '@/lib/db';
import { users, accounts, sessions } from '@/lib/db/schema';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
export const authConfig: NextAuthConfig = {
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
}),
session: {
strategy: 'database',
maxAge: 30 * 24 * 60 * 60, // 30 days
},
pages: {
signIn: '/login',
signUp: '/register',
error: '/auth/error',
verifyRequest: '/auth/verify',
},
providers: [
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const parsed = z
.object({
email: z.string().email(),
password: z.string().min(8),
})
.safeParse(credentials);
if (!parsed.success) return null;
const user = await db.query.users.findFirst({
where: eq(users.email, parsed.data.email),
});
if (!user || !user.password) return null;
const isValid = await bcrypt.compare(
parsed.data.password,
user.password
);
if (!isValid) return null;
return {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
};
},
}),
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
callbacks: {
async session({ session, user }) {
// Add user roles to session
const userWithRoles = await db.query.users.findFirst({
where: eq(users.id, user.id),
with: {
systemRoles: true,
},
});
session.user.id = user.id;
session.user.roles = userWithRoles?.systemRoles || [];
return session;
},
},
};
2. Auth Utilities
src/lib/auth/utils.ts:
import { auth } from './index';
import { redirect } from 'next/navigation';
export async function requireAuth() {
const session = await auth();
if (!session) {
redirect('/login');
}
return session;
}
export async function requireRole(role: SystemRole) {
const session = await requireAuth();
const hasRole = session.user.roles.some(r => r.role === role);
if (!hasRole) {
throw new Error('Insufficient permissions');
}
return session;
}
tRPC API Implementation
1. tRPC Setup
src/lib/trpc/trpc.ts:
import { initTRPC, TRPCError } from '@trpc/server';
import { type Session } from 'next-auth';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { db } from '@/lib/db';
export const createTRPCContext = async (opts: {
headers: Headers;
session: Session | null;
}) => {
return {
db,
session: opts.session,
...opts,
};
};
// In-memory stores for real-time state (instead of Redis)
export const trialStateStore = new Map<string, TrialState>();
export const activeConnections = new Map<string, Set<WebSocket>>();
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
session: { ...ctx.session, user: ctx.session.user },
},
});
});
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
2. Router Implementation
src/server/api/routers/studies.ts:
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '@/lib/trpc/trpc';
import { studies, studyMembers } from '@/lib/db/schema';
import { TRPCError } from '@trpc/server';
import { eq, and } from 'drizzle-orm';
export const studiesRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({
page: z.number().min(1).default(1),
limit: z.number().min(1).max(100).default(20),
status: z.enum(['draft', 'active', 'completed', 'archived']).optional(),
myStudiesOnly: z.boolean().default(false),
}))
.query(async ({ ctx, input }) => {
const offset = (input.page - 1) * input.limit;
const whereConditions = [];
if (input.status) {
whereConditions.push(eq(studies.status, input.status));
}
if (input.myStudiesOnly) {
const memberStudyIds = await ctx.db
.select({ studyId: studyMembers.studyId })
.from(studyMembers)
.where(eq(studyMembers.userId, ctx.session.user.id));
whereConditions.push(
inArray(studies.id, memberStudyIds.map(m => m.studyId))
);
}
const [items, totalCount] = await Promise.all([
ctx.db.query.studies.findMany({
where: and(...whereConditions),
limit: input.limit,
offset,
with: {
creator: true,
members: {
with: {
user: true,
},
},
},
orderBy: (studies, { desc }) => [desc(studies.createdAt)],
}),
ctx.db.select({ count: count() }).from(studies).where(and(...whereConditions)),
]);
return {
items,
totalCount: totalCount[0].count,
totalPages: Math.ceil(totalCount[0].count / input.limit),
currentPage: input.page,
};
}),
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
institution: z.string().optional(),
irbProtocol: z.string().optional(),
metadata: z.record(z.any()).default({}),
}))
.mutation(async ({ ctx, input }) => {
return await ctx.db.transaction(async (tx) => {
// Create study
const [study] = await tx.insert(studies).values({
...input,
createdBy: ctx.session.user.id,
}).returning();
// Add creator as owner
await tx.insert(studyMembers).values({
studyId: study.id,
userId: ctx.session.user.id,
role: 'owner',
});
// Log activity
await tx.insert(activityLogs).values({
studyId: study.id,
userId: ctx.session.user.id,
action: 'study.created',
description: `Created study "${study.name}"`,
});
return study;
});
}),
// ... more procedures
});
3. Root Router
src/server/api/root.ts:
import { createTRPCRouter } from '@/lib/trpc/trpc';
import { authRouter } from './routers/auth';
import { studiesRouter } from './routers/studies';
import { experimentsRouter } from './routers/experiments';
import { trialsRouter } from './routers/trials';
import { participantsRouter } from './routers/participants';
import { robotsRouter } from './routers/robots';
import { mediaRouter } from './routers/media';
import { analysisRouter } from './routers/analysis';
import { adminRouter } from './routers/admin';
export const appRouter = createTRPCRouter({
auth: authRouter,
studies: studiesRouter,
experiments: experimentsRouter,
trials: trialsRouter,
participants: participantsRouter,
robots: robotsRouter,
media: mediaRouter,
analysis: analysisRouter,
admin: adminRouter,
});
export type AppRouter = typeof appRouter;
Frontend Architecture
1. Component Organization
src/components/experiment/designer/ExperimentDesigner.tsx:
'use client';
import { useState, useCallback } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { Canvas } from './Canvas';
import { Toolbar } from './Toolbar';
import { PropertiesPanel } from './PropertiesPanel';
import { useExperiment } from '@/hooks/useExperiment';
interface ExperimentDesignerProps {
experimentId: string;
}
export function ExperimentDesigner({ experimentId }: ExperimentDesignerProps) {
const { experiment, updateExperiment } = useExperiment(experimentId);
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const handleStepSelect = useCallback((stepId: string) => {
setSelectedStepId(stepId);
}, []);
const handleStepUpdate = useCallback((stepId: string, updates: Partial<Step>) => {
updateExperiment({
steps: experiment.steps.map(step =>
step.id === stepId ? { ...step, ...updates } : step
),
});
}, [experiment, updateExperiment]);
return (
<DndProvider backend={HTML5Backend}>
<div className="flex h-full">
<Toolbar className="w-64 border-r" />
<div className="flex-1 flex flex-col">
<Canvas
steps={experiment.steps}
onStepSelect={handleStepSelect}
onStepUpdate={handleStepUpdate}
selectedStepId={selectedStepId}
/>
</div>
{selectedStepId && (
<PropertiesPanel
step={experiment.steps.find(s => s.id === selectedStepId)!}
onUpdate={(updates) => handleStepUpdate(selectedStepId, updates)}
className="w-80 border-l"
/>
)}
</div>
</DndProvider>
);
}
2. Custom Hooks
src/hooks/useExperiment.ts:
import { api } from '@/lib/trpc/react';
import { useRouter } from 'next/navigation';
import { toast } from '@/components/ui/use-toast';
export function useExperiment(experimentId: string) {
const router = useRouter();
const utils = api.useUtils();
const { data: experiment, isLoading } = api.experiments.get.useQuery({
id: experimentId,
});
const updateMutation = api.experiments.update.useMutation({
onSuccess: () => {
utils.experiments.get.invalidate({ id: experimentId });
toast({
title: 'Experiment updated',
description: 'Your changes have been saved.',
});
},
onError: (error) => {
toast({
title: 'Update failed',
description: error.message,
variant: 'destructive',
});
},
});
const updateExperiment = (updates: Partial<Experiment>) => {
updateMutation.mutate({
id: experimentId,
...updates,
});
};
return {
experiment,
isLoading,
updateExperiment,
isUpdating: updateMutation.isLoading,
};
}
3. Server Components
src/app/(dashboard)/studies/[studyId]/page.tsx:
import { Suspense } from 'react';
import { notFound } from 'next/navigation';
import { requireAuth } from '@/lib/auth/utils';
import { api } from '@/lib/trpc/server';
import { StudyDashboard } from '@/components/study/StudyDashboard';
import { StudyDashboardSkeleton } from '@/components/study/StudyDashboardSkeleton';
interface StudyPageProps {
params: {
studyId: string;
};
}
export default async function StudyPage({ params }: StudyPageProps) {
await requireAuth();
const study = await api.studies.get({
id: params.studyId,
});
if (!study) {
notFound();
}
return (
<Suspense fallback={<StudyDashboardSkeleton />}>
<StudyDashboard study={study} />
</Suspense>
);
}
Real-time Features
1. WebSocket Server
src/server/websocket/server.ts:
// For Vercel deployment, we use Edge Runtime WebSocket API
// src/app/api/ws/route.ts
import { NextRequest } from 'next/server';
import { verifySession } from '@/lib/auth/session';
import { TrialExecutionHandler } from '@/lib/websocket/handlers/trial-execution';
import { ObserverHandler } from '@/lib/websocket/handlers/observer';
export const runtime = 'edge';
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const token = searchParams.get('token');
if (req.headers.get('upgrade') !== 'websocket') {
return new Response('Expected websocket', { status: 400 });
}
const handlers = {
trial: new TrialExecutionHandler(),
observer: new ObserverHandler(),
};
wss.on('connection', async (ws, req) => {
const { query } = parse(req.url!, true);
const token = query.token as string;
try {
const session = await verifySession(token);
if (!session) {
ws.close(1008, 'Invalid session');
return;
}
const handler = handlers[query.type as keyof typeof handlers];
if (!handler) {
ws.close(1008, 'Invalid connection type');
return;
}
await handler.handleConnection(ws, session, query);
} catch (error) {
console.error('WebSocket connection error:', error);
ws.close(1011, 'Internal error');
}
});
const { socket, response } = Deno.upgradeWebSocket(req);
socket.onopen = () => handleConnection(socket, session);
socket.onmessage = (event) => handleMessage(socket, event.data);
socket.onclose = () => handleDisconnect(socket);
return response;
}
2. Client WebSocket Hook
src/hooks/useWebSocket.ts:
import { useEffect, useRef, useState, useCallback } from 'react';
import { useSession } from 'next-auth/react';
interface UseWebSocketOptions {
url: string;
onMessage?: (data: any) => void;
onConnect?: () => void;
onDisconnect?: () => void;
reconnectAttempts?: number;
}
export function useWebSocket({
url,
onMessage,
onConnect,
onDisconnect,
reconnectAttempts = 5,
}: UseWebSocketOptions) {
const { data: session } = useSession();
const ws = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [lastMessage, setLastMessage] = useState<any>(null);
const reconnectCount = useRef(0);
const connect = useCallback(() => {
if (!session?.user) return;
const wsUrl = new URL(url);
wsUrl.searchParams.set('token', session.user.sessionToken);
ws.current = new WebSocket(wsUrl.toString());
ws.current.onopen = () => {
setIsConnected(true);
reconnectCount.current = 0;
onConnect?.();
};
ws.current.onmessage = (event) => {
const data = JSON.parse(event.data);
setLastMessage(data);
onMessage?.(data);
};
ws.current.onclose = () => {
setIsConnected(false);
onDisconnect?.();
if (reconnectCount.current < reconnectAttempts) {
reconnectCount.current++;
setTimeout(connect, 1000 * Math.pow(2, reconnectCount.current));
}
};
ws.current.onerror = (error) => {
console.error('WebSocket error:', error);
};
}, [session, url, onConnect, onDisconnect, onMessage, reconnectAttempts]);
useEffect(() => {
connect();
return () => {
ws.current?.close();
};
}, [connect]);
const sendMessage = useCallback((data: any) => {
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(data));
}
}, []);
return {
isConnected,
sendMessage,
lastMessage,
};
}
File Storage System
1. S3/MinIO Client
src/lib/storage/s3.ts:
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3Client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
forcePathStyle: true, // Required for MinIO
});
export async function uploadFile(
key: string,
body: Buffer | Uint8Array | string,
contentType?: string
) {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET_NAME,
Key: key,
Body: body,
ContentType: contentType,
});
return await s3Client.send(command);
}
export async function getPresignedUrl(
key: string,
expiresIn: number = 3600
): Promise<string> {
const command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET_NAME,
Key: key,
});
return await getSignedUrl(s3Client, command, { expiresIn });
}
export async function deleteFile(key: string) {
const command = new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET_NAME,
Key: key,
});
return await s3Client.send(command);
}
2. File Upload Handler
src/features/media/upload.ts:
import { z } from 'zod';
import { uploadFile } from '@/lib/storage/s3';
import { db } from '@/lib/db';
import { mediaCaptures } from '@/lib/db/schema';
const uploadSchema = z.object({
trialId: z.string().uuid(),
file: z.instanceof(File),
mediaType: z.enum(['video', 'audio', 'image']),
startTimestamp: z.date(),
endTimestamp: z.date().optional(),
});
export async function handleMediaUpload(input: z.infer<typeof uploadSchema>) {
const { trialId, file, mediaType, startTimestamp, endTimestamp } = input;
// Generate unique key
const key = `trials/${trialId}/${mediaType}/${Date.now()}-${file.name}`;
// Upload to S3
const buffer = await file.arrayBuffer();
await uploadFile(key, Buffer.from(buffer), file.type);
// Save metadata to database
const [mediaRecord] = await db.insert(mediaCaptures).values({
trialId,
mediaType,
storagePath: key,
fileSize: file.size,
format: file.type,
startTimestamp,
endTimestamp,
metadata: {
originalName: file.name,
uploadedAt: new Date(),
},
}).returning();
return mediaRecord;
}
Plugin System
1. Plugin Interface
src/lib/plugins/types.ts:
import { z } from 'zod';
export interface RobotPlugin {
id: string;
name: string;
version: string;
robotId: string;
// Configuration
configSchema: z.ZodSchema;
defaultConfig: Record<string, any>;
// Capabilities
actions: ActionDefinition[];
// Lifecycle
initialize(config: any): Promise<void>;
connect(): Promise<boolean>;
disconnect(): Promise<void>;
// Execution
executeAction(action: Action, params: any): Promise<ActionResult>;
// State
getState(): Promise<RobotState>;
}
export interface ActionDefinition {
id: string;
name: string;
description: string;
category: string;
icon?: string;
parameterSchema: z.ZodSchema;
timeout?: number;
retryable?: boolean;
}
export interface ActionResult {
success: boolean;
data?: any;
error?: string;
duration: number;
}
export interface RobotState {
connected: boolean;
battery?: number;
position?: { x: number; y: number; z: number };
sensors?: Record<string, any>;
}
2. Plugin Manager
src/lib/plugins/manager.ts:
import { RobotPlugin } from './types';
import { db } from '@/lib/db';
import { plugins, studyPlugins } from '@/lib/db/schema';
export class PluginManager {
private plugins = new Map<string, RobotPlugin>();
private instances = new Map<string, RobotPlugin>();
async loadPlugin(pluginId: string): Promise<RobotPlugin> {
if (this.plugins.has(pluginId)) {
return this.plugins.get(plug