feat(ST-qfpqpxzy): DB schema + settings-UI voor min_quota_pct worker-drempel (#118)
- 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:
parent
78543ee796
commit
555ed8fe89
7 changed files with 190 additions and 4 deletions
72
__tests__/actions/settings.test.ts
Normal file
72
__tests__/actions/settings.test.ts
Normal 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
29
actions/settings.ts
Normal 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 }
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
||||||
import { RoleManager } from '@/components/settings/role-manager'
|
import { RoleManager } from '@/components/settings/role-manager'
|
||||||
import { LeaveProductButton } from '@/components/settings/leave-product-button'
|
import { LeaveProductButton } from '@/components/settings/leave-product-button'
|
||||||
import { ProfileEditor } from '@/components/settings/profile-editor'
|
import { ProfileEditor } from '@/components/settings/profile-editor'
|
||||||
|
import { MinQuotaEditor } from '@/components/settings/min-quota-editor'
|
||||||
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
import { ActivateProductButton } from '@/components/shared/activate-product-button'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
|
@ -14,7 +15,7 @@ export default async function SettingsPage() {
|
||||||
const [user, userRoles, ownedProducts, memberships] = await Promise.all([
|
const [user, userRoles, ownedProducts, memberships] = await Promise.all([
|
||||||
prisma.user.findUnique({
|
prisma.user.findUnique({
|
||||||
where: { id: session.userId },
|
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.userRole.findMany({ where: { user_id: session.userId } }),
|
||||||
prisma.product.findMany({
|
prisma.product.findMany({
|
||||||
|
|
@ -157,6 +158,19 @@ export default async function SettingsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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="bg-surface-container-low border border-border rounded-xl p-5 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-sm font-medium text-foreground">API Tokens</h2>
|
<h2 className="text-sm font-medium text-foreground">API Tokens</h2>
|
||||||
|
|
|
||||||
59
components/settings/min-quota-editor.tsx
Normal file
59
components/settings/min-quota-editor.tsx
Normal 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
3
lib/schemas/user.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const minQuotaPctSchema = z.number().int().min(1).max(100)
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -121,6 +121,7 @@ model User {
|
||||||
active_product_id String?
|
active_product_id String?
|
||||||
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
||||||
idea_code_counter Int @default(0)
|
idea_code_counter Int @default(0)
|
||||||
|
min_quota_pct Int @default(20)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
updated_at DateTime @updatedAt
|
updated_at DateTime @updatedAt
|
||||||
roles UserRole[]
|
roles UserRole[]
|
||||||
|
|
@ -381,6 +382,8 @@ model ClaudeWorker {
|
||||||
product_id String?
|
product_id String?
|
||||||
started_at DateTime @default(now())
|
started_at DateTime @default(now())
|
||||||
last_seen_at DateTime @default(now())
|
last_seen_at DateTime @default(now())
|
||||||
|
last_quota_pct Int?
|
||||||
|
last_quota_check_at DateTime?
|
||||||
|
|
||||||
@@unique([token_id])
|
@@unique([token_id])
|
||||||
@@index([user_id, last_seen_at])
|
@@index([user_id, last_seen_at])
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue