diff --git a/__tests__/actions/settings.test.ts b/__tests__/actions/settings.test.ts new file mode 100644 index 0000000..415b059 --- /dev/null +++ b/__tests__/actions/settings.test.ts @@ -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) + }) +}) diff --git a/actions/settings.ts b/actions/settings.ts new file mode 100644 index 0000000..07c4889 --- /dev/null +++ b/actions/settings.ts @@ -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(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 } +} diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index b299e45..88daf20 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -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() { )} +
+
+

Worker-instellingen

+

+ Drempelwaarden voor de Claude-worker. +

+
+ +
+

API Tokens

diff --git a/components/settings/min-quota-editor.tsx b/components/settings/min-quota-editor.tsx new file mode 100644 index 0000000..feaf2d2 --- /dev/null +++ b/components/settings/min-quota-editor.tsx @@ -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 ( +
+
+ +

+ Worker slaapt tot quota gereset is wanneer onder deze drempel. +

+
+
+ 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" + /> + % + + + +
+
+ ) +} diff --git a/lib/schemas/user.ts b/lib/schemas/user.ts new file mode 100644 index 0000000..fbab76d --- /dev/null +++ b/lib/schemas/user.ts @@ -0,0 +1,3 @@ +import { z } from 'zod' + +export const minQuotaPctSchema = z.number().int().min(1).max(100) diff --git a/prisma/migrations/20260506014222_add_worker_quota_gate/migration.sql b/prisma/migrations/20260506014222_add_worker_quota_gate/migration.sql new file mode 100644 index 0000000..66aa1fb --- /dev/null +++ b/prisma/migrations/20260506014222_add_worker_quota_gate/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f9d54ef..5082a97 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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])