feat: ProductMember — team management for product backlogs

- 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 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-25 13:09:44 +02:00
parent fc12e3cc64
commit 357b1e32e8
18 changed files with 370 additions and 82 deletions

View file

@ -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 }
}