Loading...
;
}
- if (error) {
- return (
-
-
-
{study.title}
-
{study.description}
+
+
{study.title}
+
+ Manage study settings and participants
+
-
+
- Invites
Settings
+ Participants
-
-
-
-
- Manage Invitations
-
- Invite researchers and participants to collaborate on “{study.title}”
-
-
-
-
-
-
- {invitations.length > 0 ? (
-
- {invitations.map((invitation) => (
-
-
-
{invitation.email}
-
- Role: {invitation.roleName}
- {invitation.accepted ? " • Accepted" : " • Pending"}
-
-
- {!invitation.accepted && (
-
handleDeleteInvitation(invitation.id)}
- >
- Cancel
-
- )}
-
- ))}
-
- ) : (
- No invitations sent yet.
- )}
-
-
+
+
-
-
-
-
- Study Settings
-
- Configure study settings and permissions
-
-
-
- Settings coming soon...
-
-
+
+
+
diff --git a/src/app/dashboard/studies/page.tsx b/src/app/dashboard/studies/page.tsx
index 29954b5..865953e 100644
--- a/src/app/dashboard/studies/page.tsx
+++ b/src/app/dashboard/studies/page.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useState } from "react";
+import { useState } from "react";
import { useRouter } from "next/navigation";
import { PlusIcon, Trash2Icon, Settings2 } from "lucide-react";
import { Button } from "~/components/ui/button";
@@ -9,73 +9,86 @@ import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { useToast } from "~/hooks/use-toast";
+import { PERMISSIONS, hasPermission } from "~/lib/permissions-client";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+ AlertDialogFooter
+} from "~/components/ui/alert-dialog";
+import { ROLES } from "~/lib/roles";
interface Study {
id: number;
title: string;
- description: string;
+ description: string | null;
createdAt: string;
+ updatedAt: string | null;
+ userId: string;
+ permissions: string[];
+ roles: string[];
+}
+
+// Helper function to format role name
+function formatRoleName(role: string): string {
+ return role
+ .split('_')
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join(' ');
}
export default function Studies() {
const [studies, setStudies] = useState
([]);
- const [newStudyTitle, setNewStudyTitle] = useState("");
- const [newStudyDescription, setNewStudyDescription] = useState("");
- const [loading, setLoading] = useState(true);
+ const [title, setTitle] = useState("");
+ const [description, setDescription] = useState("");
const router = useRouter();
const { toast } = useToast();
- useEffect(() => {
- fetchStudies();
- }, []);
-
const fetchStudies = async () => {
try {
- const response = await fetch('/api/studies');
+ const response = await fetch("/api/studies");
+ if (!response.ok) throw new Error("Failed to fetch studies");
const data = await response.json();
- setStudies(data);
+ setStudies(data.data || []);
} catch (error) {
- console.error('Error fetching studies:', error);
+ console.error("Error fetching studies:", error);
toast({
title: "Error",
description: "Failed to load studies",
variant: "destructive",
});
- } finally {
- setLoading(false);
}
};
const createStudy = async (e: React.FormEvent) => {
e.preventDefault();
-
try {
- const response = await fetch('/api/studies', {
- method: 'POST',
+ const response = await fetch("/api/studies", {
+ method: "POST",
headers: {
- 'Content-Type': 'application/json',
+ "Content-Type": "application/json",
},
- body: JSON.stringify({
- title: newStudyTitle,
- description: newStudyDescription,
- }),
+ body: JSON.stringify({ title, description }),
});
if (!response.ok) {
- throw new Error('Failed to create study');
+ throw new Error("Failed to create study");
}
- const newStudy = await response.json();
- setStudies([...studies, newStudy]);
- setNewStudyTitle("");
- setNewStudyDescription("");
-
+ const data = await response.json();
+ setStudies([...studies, data.data]);
+ setTitle("");
+ setDescription("");
toast({
title: "Success",
description: "Study created successfully",
});
} catch (error) {
- console.error('Error creating study:', error);
+ console.error("Error creating study:", error);
toast({
title: "Error",
description: "Failed to create study",
@@ -87,11 +100,11 @@ export default function Studies() {
const deleteStudy = async (id: number) => {
try {
const response = await fetch(`/api/studies/${id}`, {
- method: 'DELETE',
+ method: "DELETE",
});
if (!response.ok) {
- throw new Error('Failed to delete study');
+ throw new Error("Failed to delete study");
}
setStudies(studies.filter(study => study.id !== id));
@@ -100,7 +113,7 @@ export default function Studies() {
description: "Study deleted successfully",
});
} catch (error) {
- console.error('Error deleting study:', error);
+ console.error("Error deleting study:", error);
toast({
title: "Error",
description: "Failed to delete study",
@@ -109,85 +122,128 @@ export default function Studies() {
}
};
- if (loading) {
- return (
-
- );
- }
+ // Fetch studies on mount
+ useState(() => {
+ fetchStudies();
+ });
return (
-
-
Studies
-
Manage your research studies
+
+
Studies
+
+ Manage your research studies and experiments
+
-
-
- Create New Study
-
- Add a new research study to your collection
-
-
-
-
-
-
+ {hasPermission(studies[0]?.permissions || [], PERMISSIONS.CREATE_STUDY) && (
+
+
+ Create New Study
+ Add a new research study to your collection
+
+
+
+
+ Study Title
+ setTitle(e.target.value)}
+ placeholder="Enter study title"
+ required
+ />
+
+
+ Description
+ setDescription(e.target.value)}
+ placeholder="Enter study description"
+ />
+
+
+
+ Create Study
+
+
+
+
+ )}
{studies.length > 0 ? (
studies.map((study) => (
-
-
- {study.title}
- {study.description}
-
-
-
-
router.push(`/dashboard/studies/${study.id}/settings`)}
- >
-
- Settings
-
-
deleteStudy(study.id)}
- >
-
- Delete
-
+
+
+
+
+
+
+ {study.title}
+
+
+ {study.description || "No description provided."}
+
+
+ Your Roles:
+
+ {study.roles?.map(formatRoleName).join(", ")}
+
+
+
+
+ {(hasPermission(study.permissions, PERMISSIONS.EDIT_STUDY) ||
+ hasPermission(study.permissions, PERMISSIONS.MANAGE_ROLES)) && (
+
router.push(`/dashboard/studies/${study.id}/settings`)}
+ >
+
+ Settings
+
+ )}
+ {hasPermission(study.permissions, PERMISSIONS.MANAGE_SYSTEM_SETTINGS) && (
+
+
+
+
+ Delete
+
+
+
+
+ Are you absolutely sure?
+
+
+ This action cannot be undone. This will permanently delete the study
+ "{study.title}" and all associated data including:
+
+
+ All participant data
+ All user roles and permissions
+ All pending invitations
+
+
+
+
+ Cancel
+ deleteStudy(study.id)}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Delete Study
+
+
+
+
+ )}
+
+
-
+
))
) : (
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 3214f67..d246d3b 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,7 @@
import {
ClerkProvider
} from '@clerk/nextjs';
+import { Analytics } from "@vercel/analytics/react"
import { Inter } from 'next/font/google';
import './globals.css';
import { Metadata } from 'next';
@@ -24,6 +25,7 @@ export default function RootLayout({
}) {
return (
+
{children}
diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx
index 3bca8ca..4c4b03c 100644
--- a/src/components/sidebar.tsx
+++ b/src/components/sidebar.tsx
@@ -23,7 +23,6 @@ import { Logo } from "~/components/logo"
const navItems = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Studies", href: "/dashboard/studies", icon: FolderIcon },
- { name: "Participants", href: "/dashboard/participants", icon: UsersRoundIcon },
{ name: "Trials", href: "/dashboard/trials", icon: LandPlotIcon },
{ name: "Forms", href: "/dashboard/forms", icon: FileTextIcon },
{ name: "Data Analysis", href: "/dashboard/analysis", icon: BarChartIcon },
diff --git a/src/components/studies/participants-tab.tsx b/src/components/studies/participants-tab.tsx
new file mode 100644
index 0000000..ce19edc
--- /dev/null
+++ b/src/components/studies/participants-tab.tsx
@@ -0,0 +1,233 @@
+'use client';
+
+import { useState, useEffect } from "react";
+import { PlusIcon, Trash2Icon } from "lucide-react";
+import { Button } from "~/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
+import { Input } from "~/components/ui/input";
+import { Label } from "~/components/ui/label";
+import { useToast } from "~/hooks/use-toast";
+import { PERMISSIONS } from "~/lib/permissions-client";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+ AlertDialogFooter
+} from "~/components/ui/alert-dialog";
+
+interface Participant {
+ id: number;
+ name: string;
+ studyId: number;
+ createdAt: string;
+}
+
+interface ParticipantsTabProps {
+ studyId: number;
+ permissions: string[];
+}
+
+export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps) {
+ const [participants, setParticipants] = useState([]);
+ const [name, setName] = useState("");
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const { toast } = useToast();
+
+ useEffect(() => {
+ fetchParticipants();
+ }, [studyId]);
+
+ const fetchParticipants = async () => {
+ try {
+ const response = await fetch(`/api/studies/${studyId}/participants`);
+ if (!response.ok) throw new Error("Failed to fetch participants");
+ const data = await response.json();
+ setParticipants(data.data || []);
+ } catch (error) {
+ console.error("Error fetching participants:", error);
+ toast({
+ title: "Error",
+ description: "Failed to load participants",
+ variant: "destructive",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const createParticipant = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ const response = await fetch(`/api/studies/${studyId}/participants`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ name }),
+ });
+
+ if (!response.ok) {
+ throw new Error("Failed to create participant");
+ }
+
+ const data = await response.json();
+ setParticipants([...participants, data.data]);
+ setName("");
+ toast({
+ title: "Success",
+ description: "Participant created successfully",
+ });
+ } catch (error) {
+ console.error("Error creating participant:", error);
+ toast({
+ title: "Error",
+ description: "Failed to create participant",
+ variant: "destructive",
+ });
+ }
+ };
+
+ const deleteParticipant = async (participantId: number) => {
+ try {
+ const response = await fetch(`/api/studies/${studyId}/participants`, {
+ method: "DELETE",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ participantId }),
+ });
+
+ if (!response.ok) {
+ throw new Error("Failed to delete participant");
+ }
+
+ setParticipants(participants.filter(p => p.id !== participantId));
+ toast({
+ title: "Success",
+ description: "Participant deleted successfully",
+ });
+ } catch (error) {
+ console.error("Error deleting participant:", error);
+ toast({
+ title: "Error",
+ description: "Failed to delete participant",
+ variant: "destructive",
+ });
+ }
+ };
+
+ const hasPermission = (permission: string) => permissions.includes(permission);
+
+ if (isLoading) {
+ return (
+
+
+ Loading participants...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ {error}
+
+
+ );
+ }
+
+ return (
+
+ {hasPermission(PERMISSIONS.CREATE_PARTICIPANT) && (
+
+
+ Add New Participant
+ Add a new participant to this study
+
+
+
+
+ Participant Name
+ setName(e.target.value)}
+ placeholder="Enter participant name"
+ required
+ />
+
+
+
+ Add Participant
+
+
+
+
+ )}
+
+
+ {participants.length > 0 ? (
+ participants.map((participant) => (
+
+
+
+
+
+ {participant.name}
+
+
+ Added {new Date(participant.createdAt).toLocaleDateString()}
+
+
+ {hasPermission(PERMISSIONS.DELETE_PARTICIPANT) && (
+
+
+
+
+ Delete
+
+
+
+
+ Are you absolutely sure?
+
+ This action cannot be undone. This will permanently delete the participant
+ "{participant.name}" and all associated data.
+
+
+
+ Cancel
+ deleteParticipant(participant.id)}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ Delete Participant
+
+
+
+
+ )}
+
+
+
+ ))
+ ) : (
+
+
+
+ No participants added yet. Add your first participant above.
+
+
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/studies/settings-tab.tsx b/src/components/studies/settings-tab.tsx
new file mode 100644
index 0000000..d71d01f
--- /dev/null
+++ b/src/components/studies/settings-tab.tsx
@@ -0,0 +1,94 @@
+'use client';
+
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
+import { Input } from "~/components/ui/input";
+import { Label } from "~/components/ui/label";
+import { Textarea } from "~/components/ui/textarea";
+import { Button } from "~/components/ui/button";
+import { useToast } from "~/hooks/use-toast";
+import { useState } from "react";
+import { PERMISSIONS } from "~/lib/permissions-client";
+
+interface SettingsTabProps {
+ study: {
+ id: number;
+ title: string;
+ description: string | null;
+ permissions: string[];
+ };
+}
+
+export function SettingsTab({ study }: SettingsTabProps) {
+ const [title, setTitle] = useState(study.title);
+ const [description, setDescription] = useState(study.description || "");
+ const { toast } = useToast();
+
+ const hasPermission = (permission: string) => study.permissions.includes(permission);
+ const canEditStudy = hasPermission(PERMISSIONS.EDIT_STUDY);
+
+ const updateStudy = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ const response = await fetch(`/api/studies/${study.id}`, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ title, description }),
+ });
+
+ if (!response.ok) throw new Error("Failed to update study");
+
+ toast({
+ title: "Success",
+ description: "Study updated successfully",
+ });
+ } catch (error) {
+ console.error("Error updating study:", error);
+ toast({
+ title: "Error",
+ description: "Failed to update study",
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+
+
+ Study Settings
+ Update your study details and configuration
+
+
+
+
+ Study Title
+ setTitle(e.target.value)}
+ placeholder="Enter study title"
+ required
+ disabled={!canEditStudy}
+ />
+
+
+ Description
+ setDescription(e.target.value)}
+ placeholder="Enter study description"
+ disabled={!canEditStudy}
+ />
+
+ {canEditStudy && (
+
+ Save Changes
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/db/schema.ts b/src/db/schema.ts
index bb722b9..bc3d994 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -1,11 +1,19 @@
import { sql, relations } from 'drizzle-orm';
import { integer, pgTable, serial, text, timestamp, varchar, primaryKey, boolean, uniqueIndex } from "drizzle-orm/pg-core";
+export const ENVIRONMENT = {
+ DEVELOPMENT: 'development',
+ PRODUCTION: 'production',
+} as const;
+
+export type Environment = typeof ENVIRONMENT[keyof typeof ENVIRONMENT];
+
export const usersTable = pgTable("users", {
id: varchar("id", { length: 256 }).primaryKey(),
name: varchar("name", { length: 256 }),
email: varchar("email", { length: 256 }).notNull(),
imageUrl: varchar("image_url", { length: 512 }),
+ environment: varchar("environment", { length: 20 }).notNull().default(ENVIRONMENT.DEVELOPMENT),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
});
@@ -17,6 +25,7 @@ export const studyTable = pgTable("study", {
userId: varchar("user_id", { length: 256 })
.references(() => usersTable.id)
.notNull(),
+ environment: varchar("environment", { length: 20 }).notNull().default(ENVIRONMENT.DEVELOPMENT),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
});
diff --git a/src/lib/api-utils.ts b/src/lib/api-utils.ts
new file mode 100644
index 0000000..0484bff
--- /dev/null
+++ b/src/lib/api-utils.ts
@@ -0,0 +1,39 @@
+import { NextResponse } from "next/server";
+import { ENVIRONMENT } from "~/db/schema";
+
+export type ApiResponse = {
+ data?: T;
+ error?: string;
+};
+
+export function getEnvironment(): typeof ENVIRONMENT[keyof typeof ENVIRONMENT] {
+ return process.env.NODE_ENV === 'production'
+ ? ENVIRONMENT.PRODUCTION
+ : ENVIRONMENT.DEVELOPMENT;
+}
+
+export function createApiResponse(
+ data?: T,
+ error?: string,
+ status: number = error ? 400 : 200
+): NextResponse> {
+ return NextResponse.json(
+ { data, error },
+ { status }
+ );
+}
+
+export const ApiError = {
+ Unauthorized: () => createApiResponse(undefined, "Unauthorized", 401),
+ Forbidden: () => createApiResponse(undefined, "Forbidden", 403),
+ NotFound: (resource: string) => createApiResponse(undefined, `${resource} not found`, 404),
+ BadRequest: (message: string) => createApiResponse(undefined, message, 400),
+ ServerError: (error: unknown) => {
+ console.error("Server error:", error);
+ return createApiResponse(
+ undefined,
+ "Internal server error",
+ 500
+ );
+ }
+};
\ No newline at end of file
diff --git a/src/lib/permissions-client.ts b/src/lib/permissions-client.ts
new file mode 100644
index 0000000..bd1581a
--- /dev/null
+++ b/src/lib/permissions-client.ts
@@ -0,0 +1,24 @@
+export const PERMISSIONS = {
+ CREATE_STUDY: 'create_study',
+ EDIT_STUDY: 'edit_study',
+ DELETE_STUDY: 'delete_study',
+ VIEW_STUDY: 'view_study',
+ VIEW_PARTICIPANT_NAMES: 'view_participant_names',
+ CREATE_PARTICIPANT: 'create_participant',
+ EDIT_PARTICIPANT: 'edit_participant',
+ DELETE_PARTICIPANT: 'delete_participant',
+ CONTROL_ROBOT: 'control_robot',
+ VIEW_ROBOT_STATUS: 'view_robot_status',
+ RECORD_EXPERIMENT: 'record_experiment',
+ VIEW_EXPERIMENT: 'view_experiment',
+ VIEW_EXPERIMENT_DATA: 'view_experiment_data',
+ EXPORT_EXPERIMENT_DATA: 'export_experiment_data',
+ ANNOTATE_EXPERIMENT: 'annotate_experiment',
+ MANAGE_ROLES: 'manage_roles',
+ MANAGE_USERS: 'manage_users',
+ MANAGE_SYSTEM_SETTINGS: 'manage_system_settings',
+} as const;
+
+export function hasPermission(permissions: string[], permission: string): boolean {
+ return permissions.includes(permission);
+}
\ No newline at end of file
diff --git a/src/lib/permissions-server.ts b/src/lib/permissions-server.ts
new file mode 100644
index 0000000..32de51f
--- /dev/null
+++ b/src/lib/permissions-server.ts
@@ -0,0 +1,75 @@
+import { eq, and, or } from "drizzle-orm";
+import { db } from "~/db";
+import { userRolesTable, rolePermissionsTable, permissionsTable } from "~/db/schema";
+import { ApiError } from "./api-utils";
+import { auth } from "@clerk/nextjs/server";
+import { PERMISSIONS } from "./permissions-client";
+
+export { PERMISSIONS };
+
+export async function hasStudyAccess(userId: string, studyId: number): Promise {
+ const userRoles = await db
+ .select()
+ .from(userRolesTable)
+ .where(
+ and(
+ eq(userRolesTable.userId, userId),
+ eq(userRolesTable.studyId, studyId)
+ )
+ );
+
+ return userRoles.length > 0;
+}
+
+export async function hasPermission(
+ userId: string,
+ permissionCode: string,
+ studyId: number
+): Promise {
+ const permissions = await db
+ .selectDistinct({
+ permissionCode: permissionsTable.code,
+ })
+ .from(userRolesTable)
+ .innerJoin(rolePermissionsTable, eq(rolePermissionsTable.roleId, userRolesTable.roleId))
+ .innerJoin(permissionsTable, eq(permissionsTable.id, rolePermissionsTable.permissionId))
+ .where(
+ and(
+ eq(userRolesTable.userId, userId),
+ eq(userRolesTable.studyId, studyId)
+ )
+ );
+
+ return permissions.some(p => p.permissionCode === permissionCode);
+}
+
+export type PermissionCheck = {
+ studyId: number;
+ permission?: string;
+ requireStudyAccess?: boolean;
+};
+
+export async function checkPermissions(check: PermissionCheck) {
+ const { userId } = await auth();
+ if (!userId) {
+ return { error: ApiError.Unauthorized() };
+ }
+
+ const { studyId, permission, requireStudyAccess = true } = check;
+
+ if (requireStudyAccess) {
+ const hasAccess = await hasStudyAccess(userId, studyId);
+ if (!hasAccess) {
+ return { error: ApiError.NotFound("Study") };
+ }
+ }
+
+ if (permission) {
+ const hasRequiredPermission = await hasPermission(userId, permission, studyId);
+ if (!hasRequiredPermission) {
+ return { error: ApiError.Forbidden() };
+ }
+ }
+
+ return { userId };
+}
\ No newline at end of file
diff --git a/src/lib/roles.ts b/src/lib/roles.ts
index 42195d4..f6bc822 100644
--- a/src/lib/roles.ts
+++ b/src/lib/roles.ts
@@ -1,4 +1,4 @@
-import { PERMISSIONS } from './permissions';
+import { PERMISSIONS } from './permissions-client';
export const ROLES = {
ADMIN: 'admin',