# Settings Page Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a DB-backed app-wide Settings feature with a secure admin UI, a SettingsService (cache + write-through), API endpoints, Prisma schema + migration, and tests so settings changes immediately affect application behavior. **Architecture:** Server-centric SettingsService persists to the database (Prisma) and exposes admin REST endpoints. Frontend admin UI calls these endpoints and subscribes to in-process updates. No multi-instance pub/sub in v1. **Tech Stack:** Node 20, TypeScript, Prisma, React Router (server+client), Vitest (unit), Playwright (E2E), TailwindCSS. --- ### Task 1: Add Prisma model & migration **Files:** - Modify: `prisma\schema.prisma` (add model) - Create: migration via CLI (described below) - [ ] **Step 1: Add model to schema** Edit `prisma\schema.prisma` and add: ```prisma model AppSetting { id Int @id @default(autoincrement()) key String @unique value Json description String? updatedAt DateTime @updatedAt updatedBy String? } ``` - [ ] **Step 2: Create and run migration (local dev)** Run: ``` npx prisma migrate dev --name add_app_setting --preview-feature ``` Expected: new migration created and prisma client regenerated. Verify `prisma/migrations/` contains a folder. - [ ] **Step 3: Commit migration files** ``` git add prisma/schema.prisma prisma/migrations && git commit -m "chore(db): add AppSetting model" ``` ### Task 2: Implement SettingsService (server-side cache + emitter) **Files:** - Create: `app\lib\settings.server.ts` - Modify: `app\server\index.ts` or equivalent bootstrap (if needed) to import the service for in-process subscribers - Test: `app\lib\__tests__\settings.server.test.ts` - [ ] **Step 1: Create SettingsService file** Create `app\lib\settings.server.ts` with the following content: ```ts import { PrismaClient } from '@prisma/client'; import EventEmitter from 'events'; const prisma = new PrismaClient(); type JSONValue = any; class SettingsService extends EventEmitter { private cache: Map = new Map(); private initialized = false; async init() { if (this.initialized) return; const rows = await prisma.appSetting.findMany(); rows.forEach(r => this.cache.set(r.key, r.value)); this.initialized = true; } async get(key: string) { if (!this.initialized) await this.init(); return this.cache.has(key) ? this.cache.get(key) : null; } async set(key: string, value: JSONValue, updatedBy?: string) { if (!this.initialized) await this.init(); // write-through await prisma.appSetting.upsert({ where: { key }, update: { value, updatedBy }, create: { key, value, updatedBy }, }); this.cache.set(key, value); this.emit('update', { key, value }); return { key, value }; } subscribe(fn: (payload: { key: string; value: any }) => void) { this.on('update', fn); return () => this.off('update', fn); } } export const settingsService = new SettingsService(); ``` - [ ] **Step 2: Write unit test for SettingsService** Create `app\lib\__tests__\settings.server.test.ts`: ```ts import { settingsService } from '../settings.server'; describe('SettingsService', () => { test('set and get', async () => { const key = `test_key_${Date.now()}`; const val = { enabled: true }; await settingsService.set(key, val, 'test'); const got = await settingsService.get(key); expect(got).toEqual(val); }); }); ``` Run: `npm run test -w 1 -- app/lib/__tests__/settings.server.test.ts` Expected: PASS (may require test DB setup; in dev environment prisma will use sqlite or configured DB) - [ ] **Step 3: Commit** ``` git add app/lib/settings.server.ts app/lib/__tests__/settings.server.test.ts git commit -m "feat(settings): add SettingsService with cache and emitter" ``` ### Task 3: Add secure API endpoints **Files:** - Create: `app\routes\api\admin\settings\index.ts` (GET, POST list) - Create: `app\routes\api\admin\settings\[key].ts` (PUT update) - Modify: shared auth util if needed to check admin role: `app\lib\auth.server.ts` (just reuse existing session check or fallback to ADMIN_TOKEN) - Test: `app\routes\api\admin\__tests__\settings.api.test.ts` - [ ] **Step 1: Implement GET/POST index handler** Create `app\routes\api\admin\settings\index.ts`: ```ts import type { RequestHandler } from '@remix-run/node'; import { settingsService } from '~/lib/settings.server'; import { requireAdmin } from '~/lib/auth.server'; export const loader: RequestHandler = async ({ request }) => { await requireAdmin(request); // return all settings const keys = Array.from((await settingsService['init'](), settingsService as any).cache.keys()); const entries = [] as any[]; for (const key of keys) { const value = await settingsService.get(key); entries.push({ key, value }); } return new Response(JSON.stringify(entries), { status: 200 }); }; export const action: RequestHandler = async ({ request }) => { await requireAdmin(request); const body = await request.json(); if (!body.key) return new Response('Missing key', { status: 400 }); const created = await settingsService.set(body.key, body.value, 'admin'); return new Response(JSON.stringify(created), { status: 201 }); }; ``` - [ ] **Step 2: Implement PUT handler for key** Create `app\routes\api\admin\settings\[key].ts`: ```ts import type { RequestHandler } from '@remix-run/node'; import { settingsService } from '~/lib/settings.server'; import { requireAdmin } from '~/lib/auth.server'; export const action: RequestHandler = async ({ request, params }) => { await requireAdmin(request); const key = params.key as string; const body = await request.json(); if (!key) return new Response('Missing key', { status: 400 }); await settingsService.set(key, body.value, 'admin'); return new Response(null, { status: 204 }); }; ``` - [ ] **Step 3: Tests for API** Create `app\routes\api\admin\__tests__\settings.api.test.ts` with a simple fetch-based integration test (Vitest + node fetch environment): ```ts import fetch from 'node-fetch'; // This is a placeholder instructions block; run API tests with the dev server running or mock requireAdmin test('API index returns json', async () => { // For simplicity, call settingsService directly in unit tests rather than full server const { settingsService } = await import('../../../lib/settings.server'); const key = 'api_test_' + Date.now(); await settingsService.set(key, { foo: 'bar' }, 'test'); const v = await settingsService.get(key); expect(v).toEqual({ foo: 'bar' }); }); ``` Run: `npm run test -w 1 -- app/routes/api/admin/__tests__/settings.api.test.ts` Commit after passing tests. ### Task 4: Frontend admin UI route **Files:** - Create: `app\routes\settings.tsx` (UI page for admins) - Modify: `app\components\Navbar.tsx` (add link to /settings when isAdmin) - Test: `tests\e2e\settings.spec.ts` (Playwright) - [ ] **Step 1: Create settings route component** Create `app\routes\settings.tsx`: ```tsx import React, { useEffect, useState } from 'react'; export default function SettingsPage() { const [items, setItems] = useState>([]); const [loading, setLoading] = useState(true); useEffect(() => { fetch('/api/admin/settings') .then(r => r.json()) .then(j => setItems(j)) .finally(() => setLoading(false)); }, []); async function save(key: string, value: any) { await fetch(`/api/admin/settings/${encodeURIComponent(key)}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }), }); // update local cache setItems(s => s.map(i => (i.key === key ? { ...i, value } : i))); } if (loading) return
Loading...
; return (

Settings

    {items.map(it => (
  • {it.key}