diff --git a/package-lock.json b/package-lock.json
index 1861541..18e9441 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,15 +14,18 @@
"@prisma/client": "^5.14.0",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3",
+ "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
+ "@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
+ "@radix-ui/react-tooltip": "^1.1.8",
"@t3-oss/env-nextjs": "^0.10.1",
"@tanstack/react-query": "^5.50.0",
"@trpc/client": "^11.0.0-rc.446",
@@ -19213,6 +19216,35 @@
}
}
},
+ "node_modules/@radix-ui/react-checkbox": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.1.4.tgz",
+ "integrity": "sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-presence": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-controllable-state": "1.1.0",
+ "@radix-ui/react-use-previous": "1.1.0",
+ "@radix-ui/react-use-size": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-collection": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",
@@ -19668,6 +19700,36 @@
}
}
},
+ "node_modules/@radix-ui/react-scroll-area": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.3.tgz",
+ "integrity": "sha512-l7+NNBfBYYJa9tNqVcP2AGvxdE3lmE6kFTBXdvHgUaZuy+4wGCL1Cl2AfaR7RKyimj7lZURGLwFO59k4eBnDJQ==",
+ "dependencies": {
+ "@radix-ui/number": "1.1.0",
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-direction": "1.1.0",
+ "@radix-ui/react-presence": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0",
+ "@radix-ui/react-use-layout-effect": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-select": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz",
@@ -19811,6 +19873,39 @@
}
}
},
+ "node_modules/@radix-ui/react-tooltip": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz",
+ "integrity": "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.1",
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-context": "1.1.1",
+ "@radix-ui/react-dismissable-layer": "1.1.5",
+ "@radix-ui/react-id": "1.1.0",
+ "@radix-ui/react-popper": "1.2.2",
+ "@radix-ui/react-portal": "1.1.4",
+ "@radix-ui/react-presence": "1.1.2",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-slot": "1.1.2",
+ "@radix-ui/react-use-controllable-state": "1.1.0",
+ "@radix-ui/react-visually-hidden": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
diff --git a/package.json b/package.json
index d1170af..eff8fdf 100644
--- a/package.json
+++ b/package.json
@@ -28,15 +28,18 @@
"@prisma/client": "^5.14.0",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3",
+ "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
+ "@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
+ "@radix-ui/react-tooltip": "^1.1.8",
"@t3-oss/env-nextjs": "^0.10.1",
"@tanstack/react-query": "^5.50.0",
"@trpc/client": "^11.0.0-rc.446",
diff --git a/prisma/migrations/20250323024640_store_user_activity_and_notifications/migration.sql b/prisma/migrations/20250323024640_store_user_activity_and_notifications/migration.sql
new file mode 100644
index 0000000..31763be
--- /dev/null
+++ b/prisma/migrations/20250323024640_store_user_activity_and_notifications/migration.sql
@@ -0,0 +1,44 @@
+-- CreateEnum
+CREATE TYPE "ActivityType" AS ENUM ('TASK_CREATED', 'TASK_UPDATED', 'TASK_DELETED', 'TASK_STATUS_CHANGED', 'TASK_ASSIGNED', 'TASK_TAGGED', 'PROJECT_CREATED', 'PROJECT_UPDATED', 'PROJECT_DELETED', 'MEMBER_ADDED', 'MEMBER_REMOVED', 'MEMBER_ROLE_CHANGED');
+
+-- CreateEnum
+CREATE TYPE "NotificationType" AS ENUM ('TASK_ASSIGNED', 'TASK_TAGGED', 'INVITATION_RECEIVED', 'INVITATION_ACCEPTED', 'TASK_DUE_SOON');
+
+-- CreateTable
+CREATE TABLE "Activity" (
+ "id" TEXT NOT NULL,
+ "type" "ActivityType" NOT NULL,
+ "description" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "userId" TEXT NOT NULL,
+ "projectId" TEXT NOT NULL,
+ "taskId" TEXT,
+ "metadata" JSONB,
+
+ CONSTRAINT "Activity_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Notification" (
+ "id" TEXT NOT NULL,
+ "type" "NotificationType" NOT NULL,
+ "message" TEXT NOT NULL,
+ "isRead" BOOLEAN NOT NULL DEFAULT false,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "userId" TEXT NOT NULL,
+ "metadata" JSONB,
+
+ CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "Activity" ADD CONSTRAINT "Activity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Activity" ADD CONSTRAINT "Activity_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Activity" ADD CONSTRAINT "Activity_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 4582e7e..5a000a5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -1,8 +1,7 @@
-
generator client {
- provider = "prisma-client-js"
+ provider = "prisma-client-js"
previewFeatures = ["jsonProtocol"]
- binaryTargets = ["native", "linux-arm64-openssl-1.0.x"]
+ binaryTargets = ["native", "linux-arm64-openssl-1.0.x"]
}
datasource db {
@@ -47,7 +46,7 @@ model User {
image String?
preferences Json?
createdAt DateTime @default(now())
- updatedAt DateTime? @updatedAt
+ updatedAt DateTime? @updatedAt
// Relationships
createdProjects Project[] @relation("ProjectOwner")
@@ -56,6 +55,8 @@ model User {
assignedTasks Task[] @relation("AssignedTasks")
taggedTasks TaskTag[]
invitations Invitation[] @relation("InvitedBy")
+ activities Activity[]
+ notifications Notification[]
// NextAuth relations
accounts Account[]
@@ -75,6 +76,7 @@ model Project {
members ProjectMember[]
tasks Task[]
invitations Invitation[]
+ activities Activity[]
}
enum UserRoles {
@@ -142,12 +144,13 @@ model Task {
// Relationships
projectId String
- project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
createdById String
- createdBy User @relation(fields: [createdById], references: [id])
+ createdBy User @relation(fields: [createdById], references: [id])
assignedToId String?
- assignedTo User? @relation("AssignedTasks", fields: [assignedToId], references: [id])
+ assignedTo User? @relation("AssignedTasks", fields: [assignedToId], references: [id])
tags TaskTag[]
+ activities Activity[]
}
model TaskTag {
@@ -160,3 +163,53 @@ model TaskTag {
@@unique([taskId, userId])
}
+
+enum ActivityType {
+ TASK_CREATED
+ TASK_UPDATED
+ TASK_DELETED
+ TASK_STATUS_CHANGED
+ TASK_ASSIGNED
+ TASK_TAGGED
+ PROJECT_CREATED
+ PROJECT_UPDATED
+ PROJECT_DELETED
+ MEMBER_ADDED
+ MEMBER_REMOVED
+ MEMBER_ROLE_CHANGED
+}
+
+model Activity {
+ id String @id @default(cuid())
+ type ActivityType
+ description String
+ createdAt DateTime @default(now())
+ userId String
+ projectId String
+ taskId String?
+ metadata Json?
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
+ task Task? @relation(fields: [taskId], references: [id], onDelete: Cascade)
+}
+
+enum NotificationType {
+ TASK_ASSIGNED
+ TASK_TAGGED
+ INVITATION_RECEIVED
+ INVITATION_ACCEPTED
+ TASK_DUE_SOON
+}
+
+model Notification {
+ id String @id @default(cuid())
+ type NotificationType
+ message String
+ isRead Boolean @default(false)
+ createdAt DateTime @default(now())
+ userId String
+ metadata Json?
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+}
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index 48ec779..e5a7840 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -36,7 +36,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
-
+
@@ -107,17 +113,55 @@ export function TaskCard({ task, projectId, members, isAdmin, onDelete, onUpdate
)}
{task.description ?? "No description provided."}
-
-
-
- {formatDistanceToNow(new Date(task.deadline), { addSuffix: true })}
-
- {task?.assignedTo && (
+
+
+
+
-
- {task.assignedTo.name}
+
+ {formatDistanceToNow(new Date(task.deadline), { addSuffix: true })}
- )}
+ {task?.assignedTo && (
+
+
+ {task.assignedTo.name}
+
+ )}
+
+
+
+ {task.tags && task.tags.length > 0 && task.tags.slice(0, 3).map((tag) => (
+
+
+
+
+
+ {tag.user.name?.charAt(0) ?? "U"}
+
+
+
+ Tagged: {tag.user.name}
+
+
+
+ ))}
+
+ {task.tags && task.tags.length > 3 && (
+
+
+
+
+ +{task.tags.length - 3}
+
+
+
+ {task.tags.length - 3} more tagged users
+
+
+
+ )}
+
+
@@ -147,6 +191,21 @@ export function TaskCard({ task, projectId, members, isAdmin, onDelete, onUpdate
+
+
+
+
+ Task Activity
+ View the activity history for this task.
+
+
+
+
+
+ Close
+
+
+
>
)
}
\ No newline at end of file
diff --git a/src/components/project/dialog/EditTaskDialog.tsx b/src/components/project/dialog/EditTaskDialog.tsx
index c24f50e..3d5f5f4 100644
--- a/src/components/project/dialog/EditTaskDialog.tsx
+++ b/src/components/project/dialog/EditTaskDialog.tsx
@@ -25,6 +25,9 @@ import { cn } from "@/lib/utils";
import { api } from "@/trpc/react";
import { toast } from "sonner";
import { type EditTaskDialogProps } from "@/lib/types";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
const taskFormSchema = z.object({
@@ -49,6 +52,7 @@ const taskFormSchema = z.object({
required_error: "Please select a deadline date.",
}),
assignedToId: z.string().optional(),
+ taggedUserIds: z.array(z.string()).optional(),
});
type TaskFormValues = z.infer;
@@ -62,8 +66,9 @@ export function EditTaskDialog({ open, onOpenChange, task, members, onUpdate }:
toast.success("Task updated", {
description: "Your task has been updated successfully.",
});
- onUpdate(data);
+ if (data) onUpdate(data);
onOpenChange(false);
+ setIsLoading(false);
},
onError: (error) => {
toast.error("Error", {
@@ -73,6 +78,9 @@ export function EditTaskDialog({ open, onOpenChange, task, members, onUpdate }:
},
});
+ // get current tagged users
+ const currentTaggedUserIds = task?.tags ? task.tags.map((tag) => tag?.userId) : [];
+
// edit task form state
const form = useForm({
resolver: zodResolver(taskFormSchema),
@@ -83,6 +91,7 @@ export function EditTaskDialog({ open, onOpenChange, task, members, onUpdate }:
status: task.status,
deadline: new Date(task.deadline),
assignedToId: task.assignedToId ?? "",
+ taggedUserIds: currentTaggedUserIds,
},
});
@@ -96,6 +105,7 @@ export function EditTaskDialog({ open, onOpenChange, task, members, onUpdate }:
status: task.status,
deadline: new Date(task.deadline),
assignedToId: task.assignedToId ?? "",
+ taggedUserIds: currentTaggedUserIds,
})
}
}, [task, form, open]);
@@ -111,13 +121,14 @@ export function EditTaskDialog({ open, onOpenChange, task, members, onUpdate }:
status: data.status,
deadline: data.deadline,
assignedToId: data.assignedToId === "unassigned" ? undefined : data.assignedToId,
+ taggedUserIds: data.taggedUserIds ?? [],
});
}
-
+
return (
+ (
+
+ Tag Team Members
+
+
+ {members.filter(member => member.userId !== task.createdById && member.userId !== task.assignedToId).map((member) => (
+
{
+ return (
+
+
+ {
+ const currentValue = field.value ?? []
+ return checked
+ ? field.onChange([...currentValue, member.userId])
+ : field.onChange(currentValue.filter((value) => value !== member.userId))
+ }}
+ />
+
+
+
+
+ {member.user.name?.charAt(0) ?? "U"}
+
+
{member.user.name}
+
+
+ )
+ }}
+ />
+ ))}
+
+
+ Tagged members will be notified and can view the task.
+
+
+ )}
+ />