diff --git a/package-lock.json b/package-lock.json index 1baaf57..47c55e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,87 +47,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@auth/core": { - "version": "0.34.3", - "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.34.3.tgz", - "integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==", - "license": "ISC", - "optional": true, - "peer": true, - "dependencies": { - "@panva/hkdf": "^1.1.1", - "@types/cookie": "0.6.0", - "cookie": "0.6.0", - "jose": "^5.1.3", - "oauth4webapi": "^2.10.4", - "preact": "10.11.3", - "preact-render-to-string": "5.2.3" - }, - "peerDependencies": { - "@simplewebauthn/browser": "^9.0.1", - "@simplewebauthn/server": "^9.0.2", - "nodemailer": "^7" - }, - "peerDependenciesMeta": { - "@simplewebauthn/browser": { - "optional": true - }, - "@simplewebauthn/server": { - "optional": true - }, - "nodemailer": { - "optional": true - } - } - }, - "node_modules/@auth/core/node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/@auth/core/node_modules/jose": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", - "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/@auth/core/node_modules/preact": { - "version": "10.11.3", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz", - "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/@auth/core/node_modules/preact-render-to-string": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz", - "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "pretty-format": "^3.8.0" - }, - "peerDependencies": { - "preact": ">=10" - } - }, "node_modules/@auth/prisma-adapter": { "version": "2.11.1", "resolved": "https://registry.npmjs.org/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz", @@ -1427,14 +1346,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@types/node": { "version": "20.19.27", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", @@ -2278,17 +2189,6 @@ "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", "license": "MIT" }, - "node_modules/oauth4webapi": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-2.17.0.tgz", - "integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==", - "license": "MIT", - "optional": true, - "peer": true, - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e306d23..6dacc48 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,9 +23,11 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - accounts Account[] - sessions Session[] - tasks Task[] + accounts Account[] + sessions Session[] + tasks Task[] + ownedProjects Project[] @relation("OwnedProjects") + projectMembers ProjectMember[] @@map("users") } @@ -74,6 +76,7 @@ model VerificationToken { model Task { id String @id @default(cuid()) userId String + projectId String? // Optional - cho backward compatibility title String description String? status String @default("TODO") // TODO, IN_PROGRESS, DONE @@ -81,10 +84,62 @@ model Task { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) @@index([userId]) + @@index([projectId]) @@index([status]) @@index([deadline]) @@map("tasks") } + +// ==================== PROJECT MODELS ==================== + +model Project { + id String @id @default(cuid()) + name String + description String? + ownerId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + owner User @relation("OwnedProjects", fields: [ownerId], references: [id], onDelete: Cascade) + members ProjectMember[] + invites ProjectInvite[] + tasks Task[] + + @@index([ownerId]) + @@map("projects") +} + +model ProjectMember { + id String @id @default(cuid()) + projectId String + userId String + role String @default("MEMBER") // OWNER, ADMIN, MEMBER + joinedAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([projectId, userId]) + @@index([userId]) + @@map("project_members") +} + +model ProjectInvite { + id String @id @default(cuid()) + projectId String + token String @unique @default(cuid()) + email String? + expiresAt DateTime + used Boolean @default(false) + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([token]) + @@index([projectId]) + @@map("project_invites") +} diff --git a/src/app/api/invite/[token]/route.ts b/src/app/api/invite/[token]/route.ts new file mode 100644 index 0000000..4fb0582 --- /dev/null +++ b/src/app/api/invite/[token]/route.ts @@ -0,0 +1,173 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +interface RouteParams { + params: Promise<{ token: string }> +} + +// GET /api/invite/[token] - Lấy thông tin invite +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { token } = await params + + const invite = await prisma.projectInvite.findUnique({ + where: { token }, + include: { + project: { + select: { + id: true, + name: true, + description: true, + owner: { + select: { + name: true, + email: true, + image: true + } + }, + _count: { + select: { + members: true + } + } + } + } + } + }) + + if (!invite) { + return NextResponse.json( + { success: false, error: 'Link mời không tồn tại' }, + { status: 404 } + ) + } + + if (invite.used) { + return NextResponse.json( + { success: false, error: 'Link mời đã được sử dụng' }, + { status: 400 } + ) + } + + if (new Date() > invite.expiresAt) { + return NextResponse.json( + { success: false, error: 'Link mời đã hết hạn' }, + { status: 400 } + ) + } + + return NextResponse.json({ + success: true, + data: { + project: invite.project, + expiresAt: invite.expiresAt + } + }) + } catch (error) { + console.error('Lỗi lấy thông tin invite:', error) + return NextResponse.json( + { success: false, error: 'Không thể lấy thông tin lời mời' }, + { status: 500 } + ) + } +} + +// POST /api/invite/[token] - Chấp nhận lời mời +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const { token } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Vui lòng đăng nhập để tham gia dự án' }, + { status: 401 } + ) + } + + const invite = await prisma.projectInvite.findUnique({ + where: { token }, + include: { + project: true + } + }) + + if (!invite) { + return NextResponse.json( + { success: false, error: 'Link mời không tồn tại' }, + { status: 404 } + ) + } + + if (invite.used) { + return NextResponse.json( + { success: false, error: 'Link mời đã được sử dụng' }, + { status: 400 } + ) + } + + if (new Date() > invite.expiresAt) { + return NextResponse.json( + { success: false, error: 'Link mời đã hết hạn' }, + { status: 400 } + ) + } + + // Nếu invite có email, kiểm tra email match + if (invite.email && invite.email !== session.user.email) { + return NextResponse.json( + { success: false, error: 'Link mời này dành cho email khác' }, + { status: 403 } + ) + } + + // Kiểm tra đã là member chưa + const existingMember = await prisma.projectMember.findUnique({ + where: { + projectId_userId: { + projectId: invite.projectId, + userId: session.user.id + } + } + }) + + if (existingMember) { + return NextResponse.json( + { success: false, error: 'Bạn đã là thành viên của dự án này' }, + { status: 400 } + ) + } + + // Thêm user vào project và đánh dấu invite đã dùng + await prisma.$transaction([ + prisma.projectMember.create({ + data: { + projectId: invite.projectId, + userId: session.user.id, + role: 'MEMBER' + } + }), + prisma.projectInvite.update({ + where: { id: invite.id }, + data: { used: true } + }) + ]) + + return NextResponse.json({ + success: true, + data: { + projectId: invite.projectId, + projectName: invite.project.name + }, + message: `Đã tham gia dự án "${invite.project.name}"!` + }) + } catch (error) { + console.error('Lỗi accept invite:', error) + return NextResponse.json( + { success: false, error: 'Không thể tham gia dự án' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/projects/[id]/invite/route.ts b/src/app/api/projects/[id]/invite/route.ts new file mode 100644 index 0000000..e8e8461 --- /dev/null +++ b/src/app/api/projects/[id]/invite/route.ts @@ -0,0 +1,219 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +interface RouteParams { + params: Promise<{ id: string }> +} + +// GET /api/projects/[id]/invite - Lấy danh sách invite links +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Chưa đăng nhập' }, + { status: 401 } + ) + } + + // Kiểm tra quyền (OWNER hoặc ADMIN) + const membership = await prisma.projectMember.findUnique({ + where: { + projectId_userId: { + projectId: id, + userId: session.user.id + } + } + }) + + if (!membership || !['OWNER', 'ADMIN'].includes(membership.role)) { + return NextResponse.json( + { success: false, error: 'Bạn không có quyền xem invite links' }, + { status: 403 } + ) + } + + const invites = await prisma.projectInvite.findMany({ + where: { + projectId: id, + used: false, + expiresAt: { + gt: new Date() + } + }, + orderBy: { + createdAt: 'desc' + } + }) + + return NextResponse.json({ + success: true, + data: invites + }) + } catch (error) { + console.error('Lỗi lấy danh sách invites:', error) + return NextResponse.json( + { success: false, error: 'Không thể lấy danh sách lời mời' }, + { status: 500 } + ) + } +} + +// POST /api/projects/[id]/invite - Tạo invite link mới +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Chưa đăng nhập' }, + { status: 401 } + ) + } + + // Kiểm tra quyền (OWNER hoặc ADMIN) + const membership = await prisma.projectMember.findUnique({ + where: { + projectId_userId: { + projectId: id, + userId: session.user.id + } + } + }) + + if (!membership || !['OWNER', 'ADMIN'].includes(membership.role)) { + return NextResponse.json( + { success: false, error: 'Bạn không có quyền tạo lời mời' }, + { status: 403 } + ) + } + + // Kiểm tra project tồn tại + const project = await prisma.project.findUnique({ + where: { id } + }) + + if (!project) { + return NextResponse.json( + { success: false, error: 'Không tìm thấy dự án' }, + { status: 404 } + ) + } + + const body = await request.json().catch(() => ({})) + const { email, expiresInDays = 7 } = body + + // Tính ngày hết hạn (mặc định 7 ngày) + const days = Math.min(Math.max(expiresInDays, 1), 30) // Min 1, max 30 ngày + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + days) + + // Tạo invite + const invite = await prisma.projectInvite.create({ + data: { + projectId: id, + email: email?.trim() || null, + expiresAt + } + }) + + // Tạo invite link + const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000' + const inviteLink = `${baseUrl}/invite/${invite.token}` + + return NextResponse.json( + { + success: true, + data: { + ...invite, + link: inviteLink + }, + message: 'Tạo link mời thành công!' + }, + { status: 201 } + ) + } catch (error) { + console.error('Lỗi tạo invite:', error) + return NextResponse.json( + { success: false, error: 'Không thể tạo lời mời' }, + { status: 500 } + ) + } +} + +// DELETE /api/projects/[id]/invite - Xóa/hủy invite link +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Chưa đăng nhập' }, + { status: 401 } + ) + } + + const { searchParams } = new URL(request.url) + const inviteId = searchParams.get('inviteId') + + if (!inviteId) { + return NextResponse.json( + { success: false, error: 'Thiếu thông tin invite' }, + { status: 400 } + ) + } + + // Kiểm tra quyền (OWNER hoặc ADMIN) + const membership = await prisma.projectMember.findUnique({ + where: { + projectId_userId: { + projectId: id, + userId: session.user.id + } + } + }) + + if (!membership || !['OWNER', 'ADMIN'].includes(membership.role)) { + return NextResponse.json( + { success: false, error: 'Bạn không có quyền hủy lời mời' }, + { status: 403 } + ) + } + + // Kiểm tra invite thuộc project này + const invite = await prisma.projectInvite.findFirst({ + where: { + id: inviteId, + projectId: id + } + }) + + if (!invite) { + return NextResponse.json( + { success: false, error: 'Không tìm thấy lời mời' }, + { status: 404 } + ) + } + + await prisma.projectInvite.delete({ + where: { id: inviteId } + }) + + return NextResponse.json({ + success: true, + message: 'Đã hủy lời mời!' + }) + } catch (error) { + console.error('Lỗi xóa invite:', error) + return NextResponse.json( + { success: false, error: 'Không thể hủy lời mời' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/projects/[id]/members/route.ts b/src/app/api/projects/[id]/members/route.ts new file mode 100644 index 0000000..dcf15ea --- /dev/null +++ b/src/app/api/projects/[id]/members/route.ts @@ -0,0 +1,267 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +interface RouteParams { + params: Promise<{ id: string }> +} + +// GET /api/projects/[id]/members - Lấy danh sách thành viên +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Chưa đăng nhập' }, + { status: 401 } + ) + } + + // Kiểm tra user có trong project không + const membership = await prisma.projectMember.findUnique({ + where: { + projectId_userId: { + projectId: id, + userId: session.user.id + } + } + }) + + if (!membership) { + return NextResponse.json( + { success: false, error: 'Bạn không có quyền xem thành viên dự án này' }, + { status: 403 } + ) + } + + const members = await prisma.projectMember.findMany({ + where: { projectId: id }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true + } + } + }, + orderBy: [ + { role: 'asc' }, // OWNER trước + { joinedAt: 'asc' } + ] + }) + + return NextResponse.json({ + success: true, + data: members + }) + } catch (error) { + console.error('Lỗi lấy danh sách members:', error) + return NextResponse.json( + { success: false, error: 'Không thể lấy danh sách thành viên' }, + { status: 500 } + ) + } +} + +// PATCH /api/projects/[id]/members - Cập nhật role của thành viên +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Chưa đăng nhập' }, + { status: 401 } + ) + } + + // Kiểm tra quyền (chỉ OWNER mới được đổi role) + const myMembership = await prisma.projectMember.findUnique({ + where: { + projectId_userId: { + projectId: id, + userId: session.user.id + } + } + }) + + if (!myMembership || myMembership.role !== 'OWNER') { + return NextResponse.json( + { success: false, error: 'Chỉ chủ dự án mới được thay đổi quyền thành viên' }, + { status: 403 } + ) + } + + const body = await request.json() + const { userId, role } = body + + // Validate + if (!userId) { + return NextResponse.json( + { success: false, error: 'Thiếu thông tin thành viên' }, + { status: 400 } + ) + } + + if (!['ADMIN', 'MEMBER'].includes(role)) { + return NextResponse.json( + { success: false, error: 'Role không hợp lệ' }, + { status: 400 } + ) + } + + // Không thể đổi role của chính mình (OWNER) + if (userId === session.user.id) { + return NextResponse.json( + { success: false, error: 'Không thể thay đổi quyền của chính mình' }, + { status: 400 } + ) + } + + const member = await prisma.projectMember.update({ + where: { + projectId_userId: { + projectId: id, + userId + } + }, + data: { role }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true + } + } + } + }) + + return NextResponse.json({ + success: true, + data: member, + message: 'Cập nhật quyền thành công!' + }) + } catch (error) { + console.error('Lỗi cập nhật member role:', error) + return NextResponse.json( + { success: false, error: 'Không thể cập nhật quyền thành viên' }, + { status: 500 } + ) + } +} + +// DELETE /api/projects/[id]/members - Xóa thành viên khỏi project +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Chưa đăng nhập' }, + { status: 401 } + ) + } + + const { searchParams } = new URL(request.url) + const userIdToRemove = searchParams.get('userId') + + if (!userIdToRemove) { + return NextResponse.json( + { success: false, error: 'Thiếu thông tin thành viên cần xóa' }, + { status: 400 } + ) + } + + // Kiểm tra quyền + const myMembership = await prisma.projectMember.findUnique({ + where: { + projectId_userId: { + projectId: id, + userId: session.user.id + } + } + }) + + // User có thể tự rời khỏi project (trừ OWNER) + const isSelf = userIdToRemove === session.user.id + + if (isSelf) { + if (myMembership?.role === 'OWNER') { + return NextResponse.json( + { success: false, error: 'Chủ dự án không thể rời dự án. Hãy chuyển quyền hoặc xóa dự án.' }, + { status: 400 } + ) + } + } else { + // Xóa người khác - cần quyền OWNER hoặc ADMIN + if (!myMembership || !['OWNER', 'ADMIN'].includes(myMembership.role)) { + return NextResponse.json( + { success: false, error: 'Bạn không có quyền xóa thành viên' }, + { status: 403 } + ) + } + + // Kiểm tra role của người bị xóa + const targetMembership = await prisma.projectMember.findUnique({ + where: { + projectId_userId: { + projectId: id, + userId: userIdToRemove + } + } + }) + + if (!targetMembership) { + return NextResponse.json( + { success: false, error: 'Thành viên không tồn tại' }, + { status: 404 } + ) + } + + // ADMIN không thể xóa OWNER hoặc ADMIN khác + if (myMembership.role === 'ADMIN' && ['OWNER', 'ADMIN'].includes(targetMembership.role)) { + return NextResponse.json( + { success: false, error: 'Bạn không có quyền xóa thành viên này' }, + { status: 403 } + ) + } + + // Không ai có thể xóa OWNER + if (targetMembership.role === 'OWNER') { + return NextResponse.json( + { success: false, error: 'Không thể xóa chủ dự án' }, + { status: 400 } + ) + } + } + + await prisma.projectMember.delete({ + where: { + projectId_userId: { + projectId: id, + userId: userIdToRemove + } + } + }) + + return NextResponse.json({ + success: true, + message: isSelf ? 'Đã rời khỏi dự án!' : 'Đã xóa thành viên khỏi dự án!' + }) + } catch (error) { + console.error('Lỗi xóa member:', error) + return NextResponse.json( + { success: false, error: 'Không thể xóa thành viên' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts new file mode 100644 index 0000000..1e6862c --- /dev/null +++ b/src/app/api/projects/[id]/route.ts @@ -0,0 +1,214 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +interface RouteParams { + params: Promise<{ id: string }> +} + +// GET /api/projects/[id] - Lấy chi tiết một project +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Chưa đăng nhập' }, + { status: 401 } + ) + } + + // Kiểm tra user có quyền xem project này không + const membership = await prisma.projectMember.findUnique({ + where: { + projectId_userId: { + projectId: id, + userId: session.user.id + } + } + }) + + if (!membership) { + return NextResponse.json( + { success: false, error: 'Bạn không có quyền xem dự án này' }, + { status: 403 } + ) + } + + const project = await prisma.project.findUnique({ + where: { id }, + include: { + owner: { + select: { + id: true, + name: true, + email: true, + image: true + } + }, + members: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true + } + } + }, + orderBy: { + joinedAt: 'asc' + } + }, + _count: { + select: { + tasks: true, + invites: true + } + } + } + }) + + if (!project) { + return NextResponse.json( + { success: false, error: 'Không tìm thấy dự án' }, + { status: 404 } + ) + } + + return NextResponse.json({ + success: true, + data: { + ...project, + myRole: membership.role + } + }) + } catch (error) { + console.error('Lỗi lấy chi tiết project:', error) + return NextResponse.json( + { success: false, error: 'Không thể lấy thông tin dự án' }, + { status: 500 } + ) + } +} + +// PATCH /api/projects/[id] - Cập nhật project +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Chưa đăng nhập' }, + { status: 401 } + ) + } + + // Kiểm tra quyền (OWNER hoặc ADMIN) + const membership = await prisma.projectMember.findUnique({ + where: { + projectId_userId: { + projectId: id, + userId: session.user.id + } + } + }) + + if (!membership || !['OWNER', 'ADMIN'].includes(membership.role)) { + return NextResponse.json( + { success: false, error: 'Bạn không có quyền chỉnh sửa dự án này' }, + { status: 403 } + ) + } + + const body = await request.json() + const { name, description } = body + + // Validate + if (name !== undefined) { + if (!name || name.trim().length === 0) { + return NextResponse.json( + { success: false, error: 'Tên dự án không được để trống' }, + { status: 400 } + ) + } + if (name.length > 100) { + return NextResponse.json( + { success: false, error: 'Tên dự án không quá 100 ký tự' }, + { status: 400 } + ) + } + } + + const project = await prisma.project.update({ + where: { id }, + data: { + ...(name !== undefined && { name: name.trim() }), + ...(description !== undefined && { description: description?.trim() || null }) + } + }) + + return NextResponse.json({ + success: true, + data: project, + message: 'Cập nhật dự án thành công!' + }) + } catch (error) { + console.error('Lỗi cập nhật project:', error) + return NextResponse.json( + { success: false, error: 'Không thể cập nhật dự án' }, + { status: 500 } + ) + } +} + +// DELETE /api/projects/[id] - Xóa project +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const session = await getServerSession(authOptions) + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Chưa đăng nhập' }, + { status: 401 } + ) + } + + // Chỉ OWNER mới được xóa project + const membership = await prisma.projectMember.findUnique({ + where: { + projectId_userId: { + projectId: id, + userId: session.user.id + } + } + }) + + if (!membership || membership.role !== 'OWNER') { + return NextResponse.json( + { success: false, error: 'Chỉ chủ dự án mới được xóa dự án' }, + { status: 403 } + ) + } + + await prisma.project.delete({ + where: { id } + }) + + return NextResponse.json({ + success: true, + message: 'Xóa dự án thành công!' + }) + } catch (error) { + console.error('Lỗi xóa project:', error) + return NextResponse.json( + { success: false, error: 'Không thể xóa dự án' }, + { status: 500 } + ) + } +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..da5ca78 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { prisma } from '@/lib/prisma' + +// GET /api/projects - Lấy danh sách projects của user +export async function GET() { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Chưa đăng nhập' }, + { status: 401 } + ) + } + + // Lấy tất cả projects mà user là member + const projects = await prisma.project.findMany({ + where: { + members: { + some: { + userId: session.user.id + } + } + }, + include: { + owner: { + select: { + id: true, + name: true, + email: true, + image: true + } + }, + members: { + include: { + user: { + select: { + id: true, + name: true, + email: true, + image: true + } + } + } + }, + _count: { + select: { + tasks: true + } + } + }, + orderBy: { + createdAt: 'desc' + } + }) + + // Thêm thông tin role của current user vào mỗi project + const projectsWithRole = projects.map(project => { + const memberInfo = project.members.find(m => m.userId === session.user.id) + return { + ...project, + myRole: memberInfo?.role || 'MEMBER' + } + }) + + return NextResponse.json({ + success: true, + data: projectsWithRole + }) + } catch (error) { + console.error('Lỗi lấy danh sách projects:', error) + return NextResponse.json( + { success: false, error: 'Không thể lấy danh sách dự án' }, + { status: 500 } + ) + } +} + +// POST /api/projects - Tạo project mới +export async function POST(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user?.id) { + return NextResponse.json( + { success: false, error: 'Chưa đăng nhập' }, + { status: 401 } + ) + } + + const body = await request.json() + const { name, description } = body + + // Validate + if (!name || name.trim().length === 0) { + return NextResponse.json( + { success: false, error: 'Tên dự án không được để trống' }, + { status: 400 } + ) + } + + if (name.length > 100) { + return NextResponse.json( + { success: false, error: 'Tên dự án không quá 100 ký tự' }, + { status: 400 } + ) + } + + // Tạo project và thêm owner làm member trong một transaction + const project = await prisma.$transaction(async (tx) => { + // Tạo project + const newProject = await tx.project.create({ + data: { + name: name.trim(), + description: description?.trim() || null, + ownerId: session.user.id + } + }) + + // Thêm owner làm member với role OWNER + await tx.projectMember.create({ + data: { + projectId: newProject.id, + userId: session.user.id, + role: 'OWNER' + } + }) + + return newProject + }) + + return NextResponse.json( + { + success: true, + data: project, + message: 'Tạo dự án thành công!' + }, + { status: 201 } + ) + } catch (error) { + console.error('Lỗi tạo project:', error) + return NextResponse.json( + { success: false, error: 'Không thể tạo dự án' }, + { status: 500 } + ) + } +} diff --git a/src/app/invite/[token]/page.tsx b/src/app/invite/[token]/page.tsx new file mode 100644 index 0000000..91adeda --- /dev/null +++ b/src/app/invite/[token]/page.tsx @@ -0,0 +1,242 @@ +'use client' + +import { useState, useEffect, use } from 'react' +import { useSession } from 'next-auth/react' +import { useRouter } from 'next/navigation' +import { FiFolder, FiUsers, FiCheckCircle, FiAlertCircle, FiLogIn } from 'react-icons/fi' +import toast from 'react-hot-toast' + +interface InviteInfo { + project: { + id: string + name: string + description: string | null + owner: { + name: string | null + email: string + image: string | null + } + _count: { + members: number + } + } + expiresAt: string +} + +export default function InvitePage({ params }: { params: Promise<{ token: string }> }) { + const { token } = use(params) + const { data: session, status } = useSession() + const router = useRouter() + + const [inviteInfo, setInviteInfo] = useState(null) + const [loading, setLoading] = useState(true) + const [accepting, setAccepting] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchInvite = async () => { + try { + const response = await fetch(`/api/invite/${token}`) + const data = await response.json() + + if (data.success) { + setInviteInfo(data.data) + } else { + setError(data.error || 'Link mời không hợp lệ') + } + } catch (err) { + setError('Không thể tải thông tin lời mời') + } finally { + setLoading(false) + } + } + + fetchInvite() + }, [token]) + + const handleAccept = async () => { + if (status !== 'authenticated') { + // Lưu token vào localStorage để sau khi login redirect lại + localStorage.setItem('pendingInvite', token) + router.push('/login') + return + } + + setAccepting(true) + try { + const response = await fetch(`/api/invite/${token}`, { + method: 'POST' + }) + + const data = await response.json() + if (data.success) { + toast.success(data.message) + router.push('/') + } else { + toast.error(data.error || 'Không thể tham gia dự án') + } + } catch (err) { + toast.error('Đã có lỗi xảy ra') + } finally { + setAccepting(false) + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + if (error) { + return ( +
+
+
+ +
+

Lời mời không hợp lệ

+

{error}

+ +
+
+ ) + } + + if (!inviteInfo) return null + + const expiresDate = new Date(inviteInfo.expiresAt) + const isExpired = expiresDate < new Date() + + if (isExpired) { + return ( +
+
+
+ +
+

Link đã hết hạn

+

+ Link mời này đã hết hạn vào {expiresDate.toLocaleDateString('vi-VN')}. + Vui lòng liên hệ quản trị viên dự án để nhận link mới. +

+ +
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+
+ +
+

Bạn được mời tham gia dự án

+
+ + {/* Content */} +
+ {/* Project info */} +
+

{inviteInfo.project.name}

+ {inviteInfo.project.description && ( +

{inviteInfo.project.description}

+ )} + +
+
+ + {inviteInfo.project._count.members} thành viên +
+
+ + {/* Owner info */} +
+
+ {inviteInfo.project.owner.image ? ( + + ) : ( + (inviteInfo.project.owner.name || inviteInfo.project.owner.email).charAt(0).toUpperCase() + )} +
+
+

+ {inviteInfo.project.owner.name || 'Chưa đặt tên'} +

+

Chủ dự án

+
+
+
+ + {/* User status */} + {status === 'authenticated' && session?.user && ( +
+ +
+

Đăng nhập với

+

{session.user.email}

+
+
+ )} + + {/* Actions */} +
+ + + +
+ + {/* Expiry notice */} +

+ Link có hiệu lực đến {expiresDate.toLocaleDateString('vi-VN')} +

+
+
+
+ ) +} diff --git a/src/app/projects/[id]/settings/page.tsx b/src/app/projects/[id]/settings/page.tsx new file mode 100644 index 0000000..ac204df --- /dev/null +++ b/src/app/projects/[id]/settings/page.tsx @@ -0,0 +1,37 @@ +'use client' + +import { use } from 'react' +import { useSession } from 'next-auth/react' +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' +import { ProjectSettings } from '@/components/projects' + +export default function ProjectSettingsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + const { data: session, status } = useSession() + const router = useRouter() + + useEffect(() => { + if (status === 'unauthenticated') { + router.push('/login') + } + }, [status, router]) + + if (status === 'loading') { + return ( +
+
+
+ ) + } + + if (!session?.user?.id) { + return null + } + + return ( +
+ +
+ ) +} diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx new file mode 100644 index 0000000..bf376dd --- /dev/null +++ b/src/app/projects/page.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useSession } from 'next-auth/react' +import { useRouter } from 'next/navigation' +import { ProjectSelector } from '@/components/projects' + +export default function ProjectsPage() { + const { data: session, status } = useSession() + const router = useRouter() + const [selectedProjectId, setSelectedProjectId] = useState(null) + + useEffect(() => { + if (status === 'unauthenticated') { + router.push('/login') + } + }, [status, router]) + + const handleProjectChange = (projectId: string | null) => { + setSelectedProjectId(projectId) + if (projectId) { + // Lưu project đã chọn vào localStorage + localStorage.setItem('currentProject', projectId) + // Có thể redirect đến trang tasks của project + router.push(`/?project=${projectId}`) + } + } + + if (status === 'loading') { + return ( +
+
+
+ ) + } + + if (!session) { + return null + } + + return ( +
+ +
+ ) +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 345b3d8..c662256 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -13,13 +13,31 @@ export function Navbar() {
{/* Logo */} -
+
TaskFlow + + {/* Navigation Links */} + {session?.user && ( +
+ + Công việc + + + Dự án + +
+ )}
{/* User Menu */} diff --git a/src/components/projects/ProjectSelector.tsx b/src/components/projects/ProjectSelector.tsx new file mode 100644 index 0000000..4b20d12 --- /dev/null +++ b/src/components/projects/ProjectSelector.tsx @@ -0,0 +1,321 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { FiPlus, FiFolder, FiUsers, FiCheckSquare, FiChevronRight, FiSettings } from 'react-icons/fi' +import toast from 'react-hot-toast' + +interface Project { + id: string + name: string + description: string | null + ownerId: string + createdAt: string + myRole: string + owner: { + id: string + name: string | null + email: string + image: string | null + } + members: Array<{ + id: string + role: string + user: { + id: string + name: string | null + email: string + image: string | null + } + }> + _count: { + tasks: number + } +} + +interface ProjectSelectorProps { + currentProjectId?: string | null + onProjectChange: (projectId: string | null) => void + compact?: boolean +} + +export function ProjectSelector({ + currentProjectId, + onProjectChange, + compact = false +}: ProjectSelectorProps) { + const router = useRouter() + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(true) + const [showCreateModal, setShowCreateModal] = useState(false) + const [newProjectName, setNewProjectName] = useState('') + const [newProjectDesc, setNewProjectDesc] = useState('') + const [creating, setCreating] = useState(false) + + useEffect(() => { + fetchProjects() + }, []) + + const fetchProjects = async () => { + try { + const response = await fetch('/api/projects') + const data = await response.json() + if (data.success) { + setProjects(data.data) + // Tự động chọn project đầu tiên nếu chưa có project nào được chọn + if (!currentProjectId && data.data.length > 0) { + onProjectChange(data.data[0].id) + } + } + } catch (error) { + toast.error('Không thể tải danh sách dự án') + } finally { + setLoading(false) + } + } + + const handleCreateProject = async (e: React.FormEvent) => { + e.preventDefault() + if (!newProjectName.trim()) { + toast.error('Vui lòng nhập tên dự án') + return + } + + setCreating(true) + try { + const response = await fetch('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: newProjectName.trim(), + description: newProjectDesc.trim() || null + }) + }) + + const data = await response.json() + if (data.success) { + toast.success('Tạo dự án thành công!') + setShowCreateModal(false) + setNewProjectName('') + setNewProjectDesc('') + await fetchProjects() + onProjectChange(data.data.id) + } else { + toast.error(data.error || 'Không thể tạo dự án') + } + } catch (error) { + toast.error('Đã có lỗi xảy ra') + } finally { + setCreating(false) + } + } + + const getRoleBadge = (role: string) => { + switch (role) { + case 'OWNER': + return Chủ sở hữu + case 'ADMIN': + return Quản trị + default: + return Thành viên + } + } + + if (loading) { + return ( +
+
+
+ ) + } + + // Compact mode - dropdown style + if (compact) { + return ( +
+ +
+ ) + } + + // Full view mode - Grid of projects + return ( +
+ {/* Header */} +
+
+

Dự án của bạn

+

Chọn dự án để xem và quản lý công việc

+
+ +
+ + {/* Project Grid */} + {projects.length === 0 ? ( +
+ +

Chưa có dự án nào

+

Tạo dự án đầu tiên để bắt đầu quản lý công việc

+ +
+ ) : ( +
+ {projects.map((project) => ( +
onProjectChange(project.id)} + className={`relative p-5 bg-white rounded-xl border-2 cursor-pointer transition-all hover:shadow-lg ${currentProjectId === project.id + ? 'border-primary-500 ring-2 ring-primary-100' + : 'border-gray-200 hover:border-primary-300' + }`} + > + {/* Project Header */} +
+
+

{project.name}

+ {project.description && ( +

{project.description}

+ )} +
+ +
+ + {/* Stats */} +
+
+ + {project._count.tasks} công việc +
+
+ + {project.members.length} thành viên +
+
+ + {/* Role & Members */} +
+ {getRoleBadge(project.myRole)} +
+ {project.members.slice(0, 4).map((member) => ( +
+ {member.user.image ? ( + + ) : ( + (member.user.name || member.user.email).charAt(0).toUpperCase() + )} +
+ ))} + {project.members.length > 4 && ( +
+ +{project.members.length - 4} +
+ )} +
+
+ + {/* Selected indicator */} + {currentProjectId === project.id && ( +
+ )} +
+ ))} +
+ )} + + {/* Create Project Modal */} + {showCreateModal && ( +
+
+

Tạo dự án mới

+
+
+
+ + setNewProjectName(e.target.value)} + placeholder="VD: Website công ty" + className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + maxLength={100} + autoFocus + /> +
+
+ +