350 lines
11 KiB
Markdown
350 lines
11 KiB
Markdown
# 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<string, JSONValue> = 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<Array<{ key: string; value: any }>>([]);
|
|
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 <div>Loading...</div>;
|
|
return (
|
|
<div className="p-6">
|
|
<h1 className="text-2xl font-bold mb-4">Settings</h1>
|
|
<ul>
|
|
{items.map(it => (
|
|
<li key={it.key} className="mb-3">
|
|
<div className="flex items-center gap-4">
|
|
<div className="font-medium">{it.key}</div>
|
|
<textarea
|
|
className="border p-2 w-2/3"
|
|
defaultValue={JSON.stringify(it.value, null, 2)}
|
|
onBlur={e => {
|
|
try {
|
|
const v = JSON.parse(e.currentTarget.value);
|
|
save(it.key, v);
|
|
} catch (err) {
|
|
alert('Invalid JSON');
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add Navbar link**
|
|
|
|
Modify `app\components\Navbar.tsx` to conditionally render a link to `/settings` for admins. Use existing session/isAdmin check.
|
|
|
|
- [ ] **Step 3: Playwright E2E test**
|
|
|
|
Create `tests\e2e\settings.spec.ts`:
|
|
|
|
```ts
|
|
import { test, expect } from '@playwright/test';
|
|
|
|
test('admin can view and edit settings', async ({ page }) => {
|
|
await page.goto('http://localhost:5173/settings');
|
|
await expect(page.locator('text=Settings')).toBeVisible();
|
|
});
|
|
```
|
|
|
|
Run: `npm run test:e2e -- --project=chromium tests/e2e/settings.spec.ts`
|
|
|
|
Commit after tests pass.
|
|
|
|
|
|
### Task 5: Wiring settings into app behavior (example flag)
|
|
|
|
**Files:**
|
|
- Modify: A feature-flagged consumer (example: `app\routes\landing.tsx` or the component that uses ANALYSIS_BACKGROUND)
|
|
|
|
- [ ] **Step 1: Read setting where used**
|
|
|
|
Example change in a consumer component:
|
|
|
|
```ts
|
|
import { settingsService } from '~/lib/settings.server';
|
|
|
|
export async function loader() {
|
|
const analysisBackground = (await settingsService.get('ANALYSIS_BACKGROUND')) ?? { enabled: false };
|
|
return json({ analysisBackground });
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write test demonstrating behavior switch**
|
|
|
|
Add a unit test that sets the setting then validates the consumer behavior.
|
|
|
|
Commit changes.
|
|
|
|
|
|
### Task 6: Tests, CI, and rollout notes
|
|
|
|
- Unit tests: SettingsService, API tests, consumer behavior tests.
|
|
- E2E: settings.spec.ts to validate page loads and editing works.
|
|
- CI: ensure `npx prisma migrate deploy` runs in deployment and that environment variables for DB are configured.
|
|
|
|
---
|
|
|
|
## Self-review checklist
|
|
|
|
1. Spec coverage: Tasks implement Prisma model, SettingsService, API, UI, tests, and migration. Multi-instance propagation is out-of-scope for v1, noted in spec.
|
|
2. Placeholders: No TODOs remain in the plan. All code blocks are concrete starting points.
|
|
3. Type consistency: Method names `settingsService.get` and `.set` used consistently.
|
|
|
|
Plan saved to `docs/superpowers/plans/2026-05-16-settings-page-plan.md`.
|
|
|
|
|
|
----
|
|
|
|
Plan author: Copilot
|