From 357b1e32e8118fc8fcb4026b82f5bafe80692525 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Sat, 25 Apr 2026 13:09:44 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20ProductMember=20=E2=80=94=20team=20mana?= =?UTF-8?q?gement=20for=20product=20backlogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ProductMember model (many-to-many User ↔ Product) - Add productAccessFilter helper (owner OR member OR clause) - Replace all ownership checks across actions and API routes - Add addProductMemberAction / removeProductMemberAction / leaveProductAction - Add TeamManager component in product settings (owner adds/removes Developers) - Add LeaveProductButton in user settings (member leaves a product team) - Regenerate Prisma Client after schema migration Co-Authored-By: Claude Sonnet 4.6 --- actions/pbis.ts | 15 ++-- actions/products.ts | 62 +++++++++++++++ actions/sprints.ts | 30 +++----- actions/stories.ts | 23 +++--- actions/tasks.ts | 23 +++--- app/(app)/products/[id]/settings/page.tsx | 21 +++++- app/(app)/settings/page.tsx | 30 +++++++- app/api/products/[id]/next-story/route.ts | 3 +- app/api/sprints/[id]/tasks/route.ts | 3 +- app/api/stories/[id]/log/route.ts | 3 +- app/api/stories/[id]/tasks/reorder/route.ts | 3 +- app/api/tasks/[id]/route.ts | 31 +++++--- components/products/team-manager.tsx | 75 +++++++++++++++++++ components/settings/leave-product-button.tsx | 44 +++++++++++ lib/product-access.ts | 18 +++++ .../migration.sql | 2 + .../migration.sql | 21 ++++++ prisma/schema.prisma | 45 +++++++---- 18 files changed, 370 insertions(+), 82 deletions(-) create mode 100644 components/products/team-manager.tsx create mode 100644 components/settings/leave-product-button.tsx create mode 100644 lib/product-access.ts create mode 100644 prisma/migrations/20260425102419_add_task_implementation_plan/migration.sql create mode 100644 prisma/migrations/20260425110346_add_product_members/migration.sql diff --git a/actions/pbis.ts b/actions/pbis.ts index dee93cb..c98c121 100644 --- a/actions/pbis.ts +++ b/actions/pbis.ts @@ -6,15 +6,12 @@ import { getIronSession } from 'iron-session' import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' +import { getAccessibleProduct } from '@/lib/product-access' async function getSession() { return getIronSession(await cookies(), sessionOptions) } -async function verifyProductOwnership(productId: string, userId: string) { - return prisma.product.findFirst({ where: { id: productId, user_id: userId } }) -} - const createPbiSchema = z.object({ productId: z.string(), title: z.string().min(1, 'Titel is verplicht').max(200), @@ -40,7 +37,7 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } - const product = await verifyProductOwnership(parsed.data.productId, session.userId) + const product = await getAccessibleProduct(parsed.data.productId, session.userId) if (!product) return { error: 'Product niet gevonden' } const last = await prisma.pbi.findFirst({ @@ -79,7 +76,9 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) { where: { id: parsed.data.id }, include: { product: true }, }) - if (!pbi || pbi.product.user_id !== session.userId) return { error: 'PBI niet gevonden' } + if (!pbi) return { error: 'PBI niet gevonden' } + const accessible = await getAccessibleProduct(pbi.product_id, session.userId) + if (!accessible) return { error: 'PBI niet gevonden' } await prisma.pbi.update({ where: { id: parsed.data.id }, @@ -103,7 +102,9 @@ export async function deletePbiAction(id: string) { where: { id }, include: { product: true }, }) - if (!pbi || pbi.product.user_id !== session.userId) return { error: 'PBI niet gevonden' } + if (!pbi) return { error: 'PBI niet gevonden' } + const accessible = await getAccessibleProduct(pbi.product_id, session.userId) + if (!accessible) return { error: 'PBI niet gevonden' } await prisma.pbi.delete({ where: { id } }) diff --git a/actions/products.ts b/actions/products.ts index 1424b74..960ed98 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -7,6 +7,7 @@ import { getIronSession } from 'iron-session' import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' +import { Role } from '@prisma/client' const productSchema = z.object({ name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'), @@ -137,3 +138,64 @@ export async function restoreProductAction(id: string) { revalidatePath('/dashboard') return { success: true } } + +export async function addProductMemberAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const productId = formData.get('productId') as string + const username = (formData.get('username') as string)?.trim().toLowerCase() + + if (!username) return { error: 'Gebruikersnaam is verplicht' } + + const product = await prisma.product.findFirst({ where: { id: productId, user_id: session.userId } }) + if (!product) return { error: 'Product niet gevonden of geen eigenaar' } + + const targetUser = await prisma.user.findUnique({ + where: { username }, + include: { roles: true }, + }) + if (!targetUser) return { error: 'Gebruiker niet gevonden' } + if (targetUser.is_demo) return { error: 'Demo-gebruiker kan niet worden toegevoegd' } + if (targetUser.id === session.userId) return { error: 'Je bent al eigenaar van dit product' } + + const hasDeveloperRole = targetUser.roles.some(r => r.role === Role.DEVELOPER) + if (!hasDeveloperRole) return { error: `Gebruiker "${username}" heeft geen Developer-rol` } + + const existing = await prisma.productMember.findUnique({ + where: { product_id_user_id: { product_id: productId, user_id: targetUser.id } }, + }) + if (existing) return { error: 'Gebruiker is al lid van dit product' } + + await prisma.productMember.create({ + data: { product_id: productId, user_id: targetUser.id }, + }) + + revalidatePath(`/products/${productId}/settings`) + return { success: true } +} + +export async function removeProductMemberAction(productId: string, memberId: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const product = await prisma.product.findFirst({ where: { id: productId, user_id: session.userId } }) + if (!product) return { error: 'Product niet gevonden of geen eigenaar' } + + await prisma.productMember.deleteMany({ where: { product_id: productId, user_id: memberId } }) + + revalidatePath(`/products/${productId}/settings`) + return { success: true } +} + +export async function leaveProductAction(productId: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + + await prisma.productMember.deleteMany({ where: { product_id: productId, user_id: session.userId } }) + + revalidatePath('/settings') + return { success: true } +} diff --git a/actions/sprints.ts b/actions/sprints.ts index 583fffb..31715d4 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -6,17 +6,12 @@ import { getIronSession } from 'iron-session' import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' +import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' async function getSession() { return getIronSession(await cookies(), sessionOptions) } -async function verifyProductOwnership(productId: string, userId: string) { - return prisma.product.findFirst({ where: { id: productId, user_id: userId } }) -} - -// --- Sprint --- - export async function createSprintAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } @@ -31,7 +26,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } - const product = await verifyProductOwnership(parsed.data.productId, session.userId) + const product = await getAccessibleProduct(parsed.data.productId, session.userId) if (!product) return { error: 'Product niet gevonden' } const existing = await prisma.sprint.findFirst({ @@ -61,7 +56,7 @@ export async function updateSprintGoalAction(_prevState: unknown, formData: Form if (!sprint_goal?.trim()) return { error: 'Sprint Goal is verplicht' } const sprint = await prisma.sprint.findFirst({ - where: { id, product: { user_id: session.userId } }, + where: { id, product: productAccessFilter(session.userId) }, }) if (!sprint) return { error: 'Sprint niet gevonden' } @@ -76,16 +71,15 @@ export async function addStoryToSprintAction(sprintId: string, storyId: string) if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const sprint = await prisma.sprint.findFirst({ - where: { id: sprintId, product: { user_id: session.userId } }, + where: { id: sprintId, product: productAccessFilter(session.userId) }, }) if (!sprint) return { error: 'Sprint niet gevonden' } const story = await prisma.story.findFirst({ - where: { id: storyId, product: { user_id: session.userId } }, + where: { id: storyId, product: productAccessFilter(session.userId) }, }) if (!story) return { error: 'Story niet gevonden' } - // Sort order = last in sprint + 1 const last = await prisma.story.findFirst({ where: { sprint_id: sprintId }, orderBy: { sort_order: 'desc' }, @@ -107,20 +101,18 @@ export async function removeStoryFromSprintAction(storyId: string) { if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const story = await prisma.story.findFirst({ - where: { id: storyId, product: { user_id: session.userId } }, + where: { id: storyId, product: productAccessFilter(session.userId) }, include: { sprint: true }, }) if (!story) return { error: 'Story niet gevonden' } - const productId = story.product_id - await prisma.story.update({ where: { id: storyId }, data: { sprint_id: null, status: 'OPEN' }, }) - revalidatePath(`/products/${productId}/sprint`) - revalidatePath(`/products/${productId}`) + revalidatePath(`/products/${story.product_id}/sprint`) + revalidatePath(`/products/${story.product_id}`) return { success: true } } @@ -130,7 +122,7 @@ export async function reorderSprintStoriesAction(sprintId: string, orderedIds: s if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const sprint = await prisma.sprint.findFirst({ - where: { id: sprintId, product: { user_id: session.userId } }, + where: { id: sprintId, product: productAccessFilter(session.userId) }, }) if (!sprint) return { error: 'Sprint niet gevonden' } @@ -153,12 +145,11 @@ export async function completeSprintAction( if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const sprint = await prisma.sprint.findFirst({ - where: { id: sprintId, product: { user_id: session.userId } }, + where: { id: sprintId, product: productAccessFilter(session.userId) }, }) if (!sprint) return { error: 'Sprint niet gevonden' } await prisma.$transaction([ - // Update all story statuses based on decisions ...Object.entries(decisions).map(([storyId, status]) => prisma.story.update({ where: { id: storyId }, @@ -168,7 +159,6 @@ export async function completeSprintAction( }, }) ), - // Close the sprint prisma.sprint.update({ where: { id: sprintId }, data: { status: 'COMPLETED', completed_at: new Date() }, diff --git a/actions/stories.ts b/actions/stories.ts index 61793fe..e7247d2 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -6,14 +6,15 @@ import { getIronSession } from 'iron-session' import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' +import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' async function getSession() { return getIronSession(await cookies(), sessionOptions) } -async function verifyStoryOwnership(storyId: string, userId: string) { +async function verifyStoryAccess(storyId: string, userId: string) { return prisma.story.findFirst({ - where: { id: storyId, product: { user_id: userId } }, + where: { id: storyId, product: productAccessFilter(userId) }, include: { product: true }, }) } @@ -46,9 +47,8 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } - // Verify ownership via product const pbi = await prisma.pbi.findFirst({ - where: { id: parsed.data.pbiId, product: { user_id: session.userId } }, + where: { id: parsed.data.pbiId, product: productAccessFilter(session.userId) }, }) if (!pbi) return { error: 'PBI niet gevonden' } @@ -87,7 +87,7 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData) }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } - const story = await verifyStoryOwnership(parsed.data.id, session.userId) + const story = await verifyStoryAccess(parsed.data.id, session.userId) if (!story) return { error: 'Story niet gevonden' } await prisma.story.update({ @@ -109,7 +109,7 @@ export async function deleteStoryAction(id: string) { if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - const story = await verifyStoryOwnership(id, session.userId) + const story = await verifyStoryAccess(id, session.userId) if (!story) return { error: 'Story niet gevonden' } await prisma.story.delete({ where: { id } }) @@ -123,9 +123,7 @@ export async function reorderPbisAction(productId: string, orderedIds: string[]) if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - const product = await prisma.product.findFirst({ - where: { id: productId, user_id: session.userId }, - }) + const product = await getAccessibleProduct(productId, session.userId) if (!product) return { error: 'Product niet gevonden' } await prisma.$transaction( @@ -144,11 +142,10 @@ export async function updatePbiPriorityAction(pbiId: string, priority: number, p if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const pbi = await prisma.pbi.findFirst({ - where: { id: pbiId, product: { user_id: session.userId } }, + where: { id: pbiId, product: productAccessFilter(session.userId) }, }) if (!pbi) return { error: 'PBI niet gevonden' } - // Place at the end of the target priority group const last = await prisma.pbi.findFirst({ where: { product_id: productId, priority }, orderBy: { sort_order: 'desc' }, @@ -168,7 +165,7 @@ export async function getStoryLogsAction(storyId: string) { if (!session.userId) return { error: 'Niet ingelogd' } const story = await prisma.story.findFirst({ - where: { id: storyId, product: { user_id: session.userId } }, + where: { id: storyId, product: productAccessFilter(session.userId) }, include: { product: { select: { repo_url: true } } }, }) if (!story) return { error: 'Story niet gevonden' } @@ -204,7 +201,7 @@ export async function reorderStoriesAction( if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const pbi = await prisma.pbi.findFirst({ - where: { id: pbiId, product: { user_id: session.userId } }, + where: { id: pbiId, product: productAccessFilter(session.userId) }, }) if (!pbi) return { error: 'PBI niet gevonden' } diff --git a/actions/tasks.ts b/actions/tasks.ts index 1b8a2cf..1cce66e 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -6,6 +6,7 @@ import { getIronSession } from 'iron-session' import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' +import { productAccessFilter } from '@/lib/product-access' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -33,7 +34,7 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const story = await prisma.story.findFirst({ - where: { id: storyId, product: { user_id: session.userId } }, + where: { id: storyId, product: productAccessFilter(session.userId) }, }) if (!story) return { error: 'Story niet gevonden' } @@ -72,10 +73,10 @@ export async function updateTaskAction(_prevState: unknown, formData: FormData) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const task = await prisma.task.findFirst({ - where: { id }, - include: { story: { include: { product: true } } }, + where: { id, story: { product: productAccessFilter(session.userId) } }, + include: { story: true }, }) - if (!task || task.story.product.user_id !== session.userId) return { error: 'Taak niet gevonden' } + if (!task) return { error: 'Taak niet gevonden' } await prisma.task.update({ where: { id }, @@ -92,10 +93,10 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const task = await prisma.task.findFirst({ - where: { id }, - include: { story: { include: { product: true } } }, + where: { id, story: { product: productAccessFilter(session.userId) } }, + include: { story: true }, }) - if (!task || task.story.product.user_id !== session.userId) return { error: 'Taak niet gevonden' } + if (!task) return { error: 'Taak niet gevonden' } await prisma.task.update({ where: { id }, data: { status } }) @@ -109,10 +110,10 @@ export async function deleteTaskAction(id: string) { if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const task = await prisma.task.findFirst({ - where: { id }, - include: { story: { include: { product: true } } }, + where: { id, story: { product: productAccessFilter(session.userId) } }, + include: { story: true }, }) - if (!task || task.story.product.user_id !== session.userId) return { error: 'Taak niet gevonden' } + if (!task) return { error: 'Taak niet gevonden' } await prisma.task.delete({ where: { id } }) @@ -126,7 +127,7 @@ export async function reorderTasksAction(storyId: string, orderedIds: string[]) if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const story = await prisma.story.findFirst({ - where: { id: storyId, product: { user_id: session.userId } }, + where: { id: storyId, product: productAccessFilter(session.userId) }, }) if (!story) return { error: 'Story niet gevonden' } diff --git a/app/(app)/products/[id]/settings/page.tsx b/app/(app)/products/[id]/settings/page.tsx index 5095bef..8150163 100644 --- a/app/(app)/products/[id]/settings/page.tsx +++ b/app/(app)/products/[id]/settings/page.tsx @@ -5,6 +5,7 @@ import { SessionData, sessionOptions } from '@/lib/session' import { prisma } from '@/lib/prisma' import { ProductForm } from '@/components/products/product-form' import { ArchiveProductButton } from '@/components/products/archive-product-button' +import { TeamManager } from '@/components/products/team-manager' import { updateProductAction } from '@/actions/products' import Link from 'next/link' @@ -20,9 +21,17 @@ export default async function ProductSettingsPage({ params }: Props) { const product = await prisma.product.findFirst({ where: { id, user_id: session.userId }, + include: { + members: { + include: { user: { select: { id: true, username: true } } }, + orderBy: { created_at: 'asc' }, + }, + }, }) if (!product) notFound() + const members = product.members.map(m => ({ id: m.user.id, username: m.user.username })) + return (
@@ -45,7 +54,17 @@ export default async function ProductSettingsPage({ params }: Props) { }} /> -
+
+
+

Team

+

+ Voeg Developers toe die aan dit product mogen werken. Alleen gebruikers met de rol Developer kunnen worden toegevoegd. +

+
+ +
+ +

Gevaarlijke zone

diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index c301fa8..41b0873 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -3,14 +3,20 @@ import { getIronSession } from 'iron-session' import { SessionData, sessionOptions } from '@/lib/session' import { prisma } from '@/lib/prisma' import { RoleManager } from '@/components/settings/role-manager' +import { LeaveProductButton } from '@/components/settings/leave-product-button' import Link from 'next/link' export default async function SettingsPage() { const session = await getIronSession(await cookies(), sessionOptions) - const [user, userRoles] = await Promise.all([ + const [user, userRoles, memberships] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, select: { username: true } }), prisma.userRole.findMany({ where: { user_id: session.userId } }), + prisma.productMember.findMany({ + where: { user_id: session.userId }, + include: { product: { select: { id: true, name: true, user: { select: { username: true } } } } }, + orderBy: { created_at: 'asc' }, + }), ]) const currentRoles = userRoles.map(r => r.role as string) @@ -28,6 +34,28 @@ export default async function SettingsPage() { + {memberships.length > 0 && ( +
+
+

Mijn teams

+

+ Products waarbij je als Developer bent toegevoegd. +

+
+
    + {memberships.map(m => ( +
  • +
    +

    {m.product.name}

    +

    Eigenaar: {m.product.user.username}

    +
    + {!session.isDemo && } +
  • + ))} +
+
+ )} +

API Tokens

diff --git a/app/api/products/[id]/next-story/route.ts b/app/api/products/[id]/next-story/route.ts index 91fd210..2747f4f 100644 --- a/app/api/products/[id]/next-story/route.ts +++ b/app/api/products/[id]/next-story/route.ts @@ -1,5 +1,6 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' export async function GET( request: Request, @@ -13,7 +14,7 @@ export async function GET( const { id } = await params const sprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'ACTIVE', product: { user_id: auth.userId } }, + where: { product_id: id, status: 'ACTIVE', product: productAccessFilter(auth.userId) }, }) if (!sprint) { return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 }) diff --git a/app/api/sprints/[id]/tasks/route.ts b/app/api/sprints/[id]/tasks/route.ts index 28465ba..e2f6d12 100644 --- a/app/api/sprints/[id]/tasks/route.ts +++ b/app/api/sprints/[id]/tasks/route.ts @@ -1,5 +1,6 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' export async function GET( request: Request, @@ -16,7 +17,7 @@ export async function GET( const limit = Math.min(Math.max(1, limitParam), 50) const sprint = await prisma.sprint.findFirst({ - where: { id, product: { user_id: auth.userId } }, + where: { id, product: productAccessFilter(auth.userId) }, }) if (!sprint) { return Response.json({ error: 'Sprint niet gevonden' }, { status: 404 }) diff --git a/app/api/stories/[id]/log/route.ts b/app/api/stories/[id]/log/route.ts index baf4ba6..8e1daa3 100644 --- a/app/api/stories/[id]/log/route.ts +++ b/app/api/stories/[id]/log/route.ts @@ -1,5 +1,6 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' import { z } from 'zod' const logSchema = z.discriminatedUnion('type', [ @@ -35,7 +36,7 @@ export async function POST( const { id: storyId } = await params const story = await prisma.story.findFirst({ - where: { id: storyId, product: { user_id: auth.userId } }, + where: { id: storyId, product: productAccessFilter(auth.userId) }, }) if (!story) { return Response.json({ error: 'Story niet gevonden' }, { status: 404 }) diff --git a/app/api/stories/[id]/tasks/reorder/route.ts b/app/api/stories/[id]/tasks/reorder/route.ts index c288653..4c38995 100644 --- a/app/api/stories/[id]/tasks/reorder/route.ts +++ b/app/api/stories/[id]/tasks/reorder/route.ts @@ -1,5 +1,6 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' import { z } from 'zod' const bodySchema = z.object({ @@ -27,7 +28,7 @@ export async function PATCH( } const story = await prisma.story.findFirst({ - where: { id: storyId, product: { user_id: auth.userId } }, + where: { id: storyId, product: productAccessFilter(auth.userId) }, include: { tasks: { select: { id: true } } }, }) if (!story) { diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index 0a9ca1d..796c0bb 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -1,10 +1,16 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' import { z } from 'zod' -const patchSchema = z.object({ - status: z.enum(['TO_DO', 'IN_PROGRESS', 'DONE']), -}) +const patchSchema = z + .object({ + status: z.enum(['TO_DO', 'IN_PROGRESS', 'DONE']).optional(), + implementation_plan: z.string().optional(), + }) + .refine((data) => data.status !== undefined || data.implementation_plan !== undefined, { + message: 'Geef minimaal status of implementation_plan mee', + }) export async function PATCH( request: Request, @@ -21,15 +27,11 @@ export async function PATCH( const { id } = await params const task = await prisma.task.findFirst({ - where: { id }, - include: { story: { include: { product: true } } }, + where: { id, story: { product: productAccessFilter(auth.userId) } }, }) if (!task) { return Response.json({ error: 'Taak niet gevonden' }, { status: 404 }) } - if (task.story.product.user_id !== auth.userId) { - return Response.json({ error: 'Geen toegang' }, { status: 403 }) - } const body = await request.json().catch(() => null) const parsed = patchSchema.safeParse(body) @@ -39,8 +41,17 @@ export async function PATCH( const updated = await prisma.task.update({ where: { id }, - data: { status: parsed.data.status }, + data: { + ...(parsed.data.status !== undefined && { status: parsed.data.status }), + ...(parsed.data.implementation_plan !== undefined && { + implementation_plan: parsed.data.implementation_plan, + }), + }, }) - return Response.json({ id: updated.id, status: updated.status }) + return Response.json({ + id: updated.id, + status: updated.status, + implementation_plan: updated.implementation_plan, + }) } diff --git a/components/products/team-manager.tsx b/components/products/team-manager.tsx new file mode 100644 index 0000000..d5f4760 --- /dev/null +++ b/components/products/team-manager.tsx @@ -0,0 +1,75 @@ +'use client' + +import { useActionState, useState, useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { addProductMemberAction, removeProductMemberAction } from '@/actions/products' + +interface Member { + id: string + username: string +} + +interface TeamManagerProps { + productId: string + members: Member[] +} + +export function TeamManager({ productId, members }: TeamManagerProps) { + const [state, formAction, isPending] = useActionState(addProductMemberAction, null) + const [removingId, setRemovingId] = useState(null) + const [, startTransition] = useTransition() + + function handleRemove(memberId: string) { + setRemovingId(memberId) + startTransition(async () => { + await removeProductMemberAction(productId, memberId) + setRemovingId(null) + }) + } + + return ( +
+ {members.length === 0 ? ( +

Nog geen teamleden toegevoegd.

+ ) : ( +
    + {members.map(m => ( +
  • + {m.username} + +
  • + ))} +
+ )} + +
+ + + +
+ + {state && 'error' in state && typeof state.error === 'string' && ( +

{state.error}

+ )} + {state && 'success' in state && ( +

Teamlid toegevoegd.

+ )} +
+ ) +} diff --git a/components/settings/leave-product-button.tsx b/components/settings/leave-product-button.tsx new file mode 100644 index 0000000..4dba5ad --- /dev/null +++ b/components/settings/leave-product-button.tsx @@ -0,0 +1,44 @@ +'use client' + +import { useState, useTransition } from 'react' +import { Button } from '@/components/ui/button' +import { leaveProductAction } from '@/actions/products' + +interface LeaveProductButtonProps { + productId: string +} + +export function LeaveProductButton({ productId }: LeaveProductButtonProps) { + const [confirming, setConfirming] = useState(false) + const [isPending, startTransition] = useTransition() + + function handleLeave() { + startTransition(async () => { + await leaveProductAction(productId) + }) + } + + if (confirming) { + return ( +
+ + +
+ ) + } + + return ( + + ) +} diff --git a/lib/product-access.ts b/lib/product-access.ts new file mode 100644 index 0000000..b315fee --- /dev/null +++ b/lib/product-access.ts @@ -0,0 +1,18 @@ +import { prisma } from '@/lib/prisma' + +const accessFilter = (userId: string) => ({ + OR: [ + { user_id: userId }, + { members: { some: { user_id: userId } } }, + ], +}) + +export async function getAccessibleProduct(productId: string, userId: string) { + return prisma.product.findFirst({ + where: { id: productId, ...accessFilter(userId) }, + }) +} + +export function productAccessFilter(userId: string) { + return accessFilter(userId) +} diff --git a/prisma/migrations/20260425102419_add_task_implementation_plan/migration.sql b/prisma/migrations/20260425102419_add_task_implementation_plan/migration.sql new file mode 100644 index 0000000..9ffc23f --- /dev/null +++ b/prisma/migrations/20260425102419_add_task_implementation_plan/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "tasks" ADD COLUMN "implementation_plan" TEXT; diff --git a/prisma/migrations/20260425110346_add_product_members/migration.sql b/prisma/migrations/20260425110346_add_product_members/migration.sql new file mode 100644 index 0000000..5e01c2a --- /dev/null +++ b/prisma/migrations/20260425110346_add_product_members/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "product_members" ( + "id" TEXT NOT NULL, + "product_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "product_members_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "product_members_user_id_idx" ON "product_members"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "product_members_product_id_user_id_key" ON "product_members"("product_id", "user_id"); + +-- AddForeignKey +ALTER TABLE "product_members" ADD CONSTRAINT "product_members_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "product_members" ADD CONSTRAINT "product_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 160a5b0..53368ce 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -41,16 +41,17 @@ enum SprintStatus { } model User { - id String @id @default(cuid()) - username String @unique - password_hash String - is_demo Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - roles UserRole[] - api_tokens ApiToken[] - products Product[] - todos Todo[] + id String @id @default(cuid()) + username String @unique + password_hash String + is_demo Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + roles UserRole[] + api_tokens ApiToken[] + products Product[] + todos Todo[] + product_members ProductMember[] @@map("users") } @@ -79,20 +80,21 @@ model ApiToken { } model Product { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user_id String name String description String? repo_url String? definition_of_done String - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt pbis Pbi[] sprints Sprint[] stories Story[] todos Todo[] + members ProductMember[] @@unique([user_id, name]) @@index([user_id, archived]) @@ -190,6 +192,19 @@ model Task { @@map("tasks") } +model ProductMember { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + created_at DateTime @default(now()) + + @@unique([product_id, user_id]) + @@index([user_id]) + @@map("product_members") +} + model Todo { id String @id @default(cuid()) user User @relation(fields: [user_id], references: [id], onDelete: Cascade)