feat(ST-qfpqpxzy): DB schema + settings-UI voor min_quota_pct worker-drempel

- User.min_quota_pct Int @default(20) + ClaudeWorker.last_quota_pct/last_quota_check_at
- Migratie add_worker_quota_gate
- lib/schemas/user.ts: minQuotaPctSchema (int, 1-100)
- actions/settings.ts: updateMinQuotaPctAction met auth/demo/zod-guard
- MinQuotaEditor component met numeric input en DemoTooltip
- Settings-pagina: Worker-instellingen sectie

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-06 03:44:46 +02:00
parent 78543ee796
commit 3a73b4f1c9
7 changed files with 190 additions and 4 deletions

View file

@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockUserUpdate, mockGetIronSession } = vi.hoisted(() => ({
mockUserUpdate: vi.fn(),
mockGetIronSession: vi.fn(),
}))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
vi.mock('iron-session', () => ({ getIronSession: mockGetIronSession }))
vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 'test', password: 'test' } }))
vi.mock('@/lib/prisma', () => ({
prisma: { user: { update: mockUserUpdate } },
}))
import { updateMinQuotaPctAction } from '@/actions/settings'
const SESSION_USER = { userId: 'user-1', isDemo: false }
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
const SESSION_UNAUTH = { userId: undefined, isDemo: false }
describe('updateMinQuotaPctAction', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUserUpdate.mockResolvedValue({})
})
it('returns error when not authenticated', async () => {
mockGetIronSession.mockResolvedValue(SESSION_UNAUTH)
const result = await updateMinQuotaPctAction(20)
expect(result).toMatchObject({ error: expect.any(String) })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('returns 403 error for demo session', async () => {
mockGetIronSession.mockResolvedValue(SESSION_DEMO)
const result = await updateMinQuotaPctAction(20)
expect(result).toMatchObject({ status: 403 })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('returns 422 error when value is 0 (below min)', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
const result = await updateMinQuotaPctAction(0)
expect(result).toMatchObject({ status: 422 })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('returns 422 error when value is 101 (above max)', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
const result = await updateMinQuotaPctAction(101)
expect(result).toMatchObject({ status: 422 })
expect(mockUserUpdate).not.toHaveBeenCalled()
})
it('saves valid value and returns success', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
const result = await updateMinQuotaPctAction(35)
expect(result).toEqual({ success: true })
expect(mockUserUpdate).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { min_quota_pct: 35 },
})
})
it('accepts boundary values 1 and 100', async () => {
mockGetIronSession.mockResolvedValue(SESSION_USER)
await updateMinQuotaPctAction(1)
await updateMinQuotaPctAction(100)
expect(mockUserUpdate).toHaveBeenCalledTimes(2)
})
})

29
actions/settings.ts Normal file
View file

@ -0,0 +1,29 @@
'use server'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { minQuotaPctSchema } from '@/lib/schemas/user'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
export async function updateMinQuotaPctAction(value: number) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', status: 403 }
const parsed = minQuotaPctSchema.safeParse(value)
if (!parsed.success) return { error: 'Waarde moet tussen 1 en 100 liggen', status: 422 }
await prisma.user.update({
where: { id: session.userId },
data: { min_quota_pct: parsed.data },
})
revalidatePath('/settings')
return { success: true }
}

View file

@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
import { RoleManager } from '@/components/settings/role-manager'
import { LeaveProductButton } from '@/components/settings/leave-product-button'
import { ProfileEditor } from '@/components/settings/profile-editor'
import { MinQuotaEditor } from '@/components/settings/min-quota-editor'
import { ActivateProductButton } from '@/components/shared/activate-product-button'
import Link from 'next/link'
@ -14,7 +15,7 @@ export default async function SettingsPage() {
const [user, userRoles, ownedProducts, memberships] = await Promise.all([
prisma.user.findUnique({
where: { id: session.userId },
select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true },
select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true, min_quota_pct: true },
}),
prisma.userRole.findMany({ where: { user_id: session.userId } }),
prisma.product.findMany({
@ -157,6 +158,19 @@ export default async function SettingsPage() {
)}
</div>
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-4">
<div>
<h2 className="text-sm font-medium text-foreground">Worker-instellingen</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Drempelwaarden voor de Claude-worker.
</p>
</div>
<MinQuotaEditor
currentValue={user?.min_quota_pct ?? 20}
isDemo={session.isDemo ?? false}
/>
</div>
<div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium text-foreground">API Tokens</h2>

View file

@ -0,0 +1,59 @@
'use client'
import { useState, useTransition } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { updateMinQuotaPctAction } from '@/actions/settings'
interface MinQuotaEditorProps {
currentValue: number
isDemo: boolean
}
export function MinQuotaEditor({ currentValue, isDemo }: MinQuotaEditorProps) {
const [value, setValue] = useState(currentValue)
const [isPending, startTransition] = useTransition()
function handleSave() {
startTransition(async () => {
const result = await updateMinQuotaPctAction(value)
if ('error' in result) {
toast.error(result.error as string)
} else {
toast.success('Instelling opgeslagen')
}
})
}
return (
<div className="space-y-3">
<div>
<label htmlFor="min-quota-pct" className="text-sm font-medium text-foreground">
Minimaal beschikbaar Claude-quota voordat de worker een job oppakt (%)
</label>
<p className="text-xs text-muted-foreground mt-0.5">
Worker slaapt tot quota gereset is wanneer onder deze drempel.
</p>
</div>
<div className="flex items-center gap-3">
<input
id="min-quota-pct"
type="number"
min={1}
max={100}
value={value}
onChange={e => setValue(Number(e.target.value))}
disabled={isDemo || isPending}
className="w-24 rounded border border-border bg-surface-container px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
/>
<span className="text-sm text-muted-foreground">%</span>
<DemoTooltip show={isDemo}>
<Button onClick={handleSave} disabled={isDemo || isPending} size="sm">
Opslaan
</Button>
</DemoTooltip>
</div>
</div>
)
}

3
lib/schemas/user.ts Normal file
View file

@ -0,0 +1,3 @@
import { z } from 'zod'
export const minQuotaPctSchema = z.number().int().min(1).max(100)

View file

@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "claude_workers" ADD COLUMN "last_quota_check_at" TIMESTAMP(3),
ADD COLUMN "last_quota_pct" INTEGER;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "min_quota_pct" INTEGER NOT NULL DEFAULT 20;

View file

@ -121,6 +121,7 @@ model User {
active_product_id String?
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
idea_code_counter Int @default(0)
min_quota_pct Int @default(20)
created_at DateTime @default(now())
updated_at DateTime @updatedAt
roles UserRole[]
@ -378,9 +379,11 @@ model ClaudeWorker {
user_id String
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
token_id String
product_id String?
started_at DateTime @default(now())
last_seen_at DateTime @default(now())
product_id String?
started_at DateTime @default(now())
last_seen_at DateTime @default(now())
last_quota_pct Int?
last_quota_check_at DateTime?
@@unique([token_id])
@@index([user_id, last_seen_at])