feat: add initial seed data migration and form builder components

- Created migration 0001_seed_data.sql to insert minimal seed data for users, accounts, and roles.
- Added meta journal for migration tracking.
- Implemented FormBuilder component for dynamic form field creation and management.
- Developed FormFieldRenderer component to render various types of form fields based on user input.
- Introduced constants for trust levels and status configurations.
- Defined types for form fields and trial data structures to enhance type safety and clarity.
This commit is contained in:
2026-03-26 14:56:00 -04:00
parent 1c7f0297a6
commit 7c360dc860
29 changed files with 1551 additions and 4779 deletions
+1 -75
View File
@@ -4,18 +4,12 @@ import { signOut } from "~/lib/auth-client";
import { toast } from "sonner";
import { TRPCClientError } from "@trpc/client";
/**
* Auth error codes that should trigger automatic logout
*/
const AUTH_ERROR_CODES = [
"UNAUTHORIZED",
"FORBIDDEN",
"UNAUTHENTICATED",
] as const;
/**
* Auth error messages that should trigger automatic logout
*/
const AUTH_ERROR_MESSAGES = [
"unauthorized",
"unauthenticated",
@@ -27,15 +21,10 @@ const AUTH_ERROR_MESSAGES = [
"access denied",
] as const;
/**
* Checks if an error is an authentication/authorization error that should trigger logout
*/
export function isAuthError(error: unknown): boolean {
if (!error) return false;
// Check TRPC errors
if (error instanceof TRPCClientError) {
// Check error code
const trpcErrorData = error.data as
| { code?: string; httpStatus?: number }
| undefined;
@@ -47,24 +36,20 @@ export function isAuthError(error: unknown): boolean {
return true;
}
// Check HTTP status codes
const httpStatus = trpcErrorData?.httpStatus;
if (httpStatus === 401 || httpStatus === 403) {
return true;
}
// Check error message
const message = error.message?.toLowerCase() ?? "";
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check generic errors
if (error instanceof Error) {
const message = error.message?.toLowerCase() || "";
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check error objects with message property
if (typeof error === "object" && error !== null) {
if ("message" in error) {
const errorObj = error as { message: unknown };
@@ -72,7 +57,6 @@ export function isAuthError(error: unknown): boolean {
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check for status codes in error objects
if ("status" in error) {
const statusObj = error as { status: unknown };
const status = statusObj.status as number;
@@ -83,9 +67,6 @@ export function isAuthError(error: unknown): boolean {
return false;
}
/**
* Handles authentication errors by logging out the user
*/
export async function handleAuthError(
error: unknown,
customMessage?: string,
@@ -96,11 +77,9 @@ export async function handleAuthError(
console.warn("Authentication error detected, logging out user:", error);
// Show user-friendly message
const message = customMessage ?? "Session expired. Please log in again.";
toast.error(message);
// Small delay to let the toast show
setTimeout(() => {
void (async () => {
try {
@@ -108,72 +87,19 @@ export async function handleAuthError(
window.location.href = "/";
} catch (signOutError) {
console.error("Error during sign out:", signOutError);
// Force redirect if signOut fails
window.location.href = "/";
}
})();
}, 1000);
}
/**
* React Query error handler that automatically handles auth errors
*/
export function createAuthErrorHandler(customMessage?: string) {
return (error: unknown) => {
void handleAuthError(error, customMessage);
};
}
/**
* tRPC error handler that automatically handles auth errors
*/
export function handleTRPCError(error: unknown, customMessage?: string): void {
void handleAuthError(error, customMessage);
}
/**
* Generic error handler for any error type
*/
export function handleGenericError(
error: unknown,
customMessage?: string,
): void {
void handleAuthError(error, customMessage);
}
/**
* Hook-style error handler for use in React components
*/
export function useAuthErrorHandler() {
return {
handleAuthError: (error: unknown, customMessage?: string) => {
void handleAuthError(error, customMessage);
},
handleAuthError,
isAuthError,
createErrorHandler: createAuthErrorHandler,
};
}
/**
* Higher-order function to wrap API calls with automatic auth error handling
*/
export function withAuthErrorHandling<
T extends (...args: unknown[]) => Promise<unknown>,
>(fn: T, customMessage?: string): T {
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
try {
return (await fn(...args)) as ReturnType<T>;
} catch (error) {
await handleAuthError(error, customMessage);
throw error; // Re-throw so calling code can handle it too
}
}) as T;
}
/**
* Utility to check if current error should show a generic error message
* (i.e., it's not an auth error that will auto-logout)
*/
export function shouldShowGenericError(error: unknown): boolean {
return !isAuthError(error);
}
+43
View File
@@ -0,0 +1,43 @@
import type { LucideIcon } from "lucide-react";
export const trustLevelConfig = {
official: {
label: "Official",
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
description: "Official HRIStudio plugin",
},
verified: {
label: "Verified",
className: "bg-green-100 text-green-800 hover:bg-green-200",
description: "Verified by the community",
},
community: {
label: "Community",
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
description: "Community contributed",
},
};
export const statusConfig = {
active: {
label: "Active",
className: "bg-green-100 text-green-800 hover:bg-green-200",
description: "Plugin is active and working",
},
deprecated: {
label: "Deprecated",
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
description: "Plugin is deprecated",
},
inactive: {
label: "Inactive",
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
description: "Plugin is not active",
},
};
export const formStatusColors = {
pending: "bg-yellow-100 text-yellow-700",
completed: "bg-green-100 text-green-700",
rejected: "bg-red-100 text-red-700",
};
+21
View File
@@ -464,6 +464,7 @@ export class WizardRosService extends EventEmitter {
* Subscribe to robot sensor topics
*/
private subscribeToRobotTopics(): void {
console.log("[WizardROS] Setting up robot topics...");
const topics = [
{ topic: "/joint_states", type: "sensor_msgs/JointState" },
{ topic: "/bumper", type: "naoqi_bridge_msgs/Bumper" },
@@ -476,6 +477,11 @@ export class WizardRosService extends EventEmitter {
topics.forEach(({ topic, type }) => {
this.subscribe(topic, type);
});
this.advertise("/speech", "std_msgs/String");
this.advertise("/cmd_vel", "geometry_msgs/Twist");
this.advertise("/robot_pose", "geometry_msgs/Pose");
this.advertise("/animation", "std_msgs/String");
}
/**
@@ -492,6 +498,21 @@ export class WizardRosService extends EventEmitter {
this.send(message);
}
/**
* Advertise a ROS topic (declare the type before publishing)
*/
private advertise(topic: string, messageType: string): void {
console.log(`[WizardROS] Advertising topic ${topic} as ${messageType}`);
const message: RosMessage = {
op: "advertise",
topic,
type: messageType,
id: `adv_${this.messageId++}`,
};
this.send(message);
}
/**
* Publish message to ROS topic
*/
+51
View File
@@ -0,0 +1,51 @@
export interface FormFieldSettings {
scale?: number;
}
export interface FormField {
id: string;
type: FormFieldType;
label: string;
required: boolean;
options?: string[];
settings?: FormFieldSettings;
}
export type FormFieldType =
| "text"
| "textarea"
| "multiple_choice"
| "checkbox"
| "rating"
| "yes_no"
| "date"
| "signature";
export type FormType = "consent" | "survey" | "questionnaire";
export interface FormFieldTypeConfig {
value: FormFieldType;
label: string;
icon: string;
}
export const FORM_FIELD_TYPES: FormFieldTypeConfig[] = [
{ value: "text", label: "Text (short)", icon: "📝" },
{ value: "textarea", label: "Text (long)", icon: "📄" },
{ value: "multiple_choice", label: "Multiple Choice", icon: "☑️" },
{ value: "checkbox", label: "Checkbox", icon: "✅" },
{ value: "rating", label: "Rating Scale", icon: "⭐" },
{ value: "yes_no", label: "Yes/No", icon: "✔️" },
{ value: "date", label: "Date", icon: "📅" },
{ value: "signature", label: "Signature", icon: "✍️" },
];
export function createField(type: FormFieldType): FormField {
return {
id: crypto.randomUUID(),
type,
label: `New ${FORM_FIELD_TYPES.find((f) => f.value === type)?.label || "Field"}`,
required: false,
options: type === "multiple_choice" ? ["Option 1", "Option 2"] : undefined,
};
}
+73
View File
@@ -0,0 +1,73 @@
export interface StepData {
id: string;
name: string;
description: string | null;
type: "wizard_action" | "robot_action" | "parallel_steps" | "conditional";
parameters: Record<string, unknown>;
conditions?: {
options?: {
label: string;
value: string;
nextStepId?: string;
nextStepIndex?: number;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
}[];
};
order: number;
actions?: ActionData[];
}
export interface ActionData {
id: string;
name: string;
description: string | null;
type: string;
parameters: Record<string, unknown>;
order: number;
pluginId: string | null;
}
export interface TrialData {
id: string;
status: TrialStatus;
scheduledAt: Date | null;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
sessionNumber: number | null;
notes: string | null;
experimentId: string;
participantId: string | null;
wizardId: string | null;
experiment: {
id: string;
name: string;
description: string | null;
studyId: string;
};
participant: {
id: string;
participantCode: string;
demographics: Record<string, unknown> | null;
};
}
export type TrialStatus =
| "scheduled"
| "in_progress"
| "completed"
| "aborted"
| "failed";
export interface TrialEvent {
type: string;
timestamp: Date;
data?: unknown;
message?: string;
}