diff --git a/.gitignore b/.gitignore index b0b4022..1f8748a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,6 @@ node_modules/ # Build output dist/ -dist-dashboard/ # Environment variables .env diff --git a/apps/dashboard/src/api/contracts.ts b/apps/dashboard/src/api/contracts.ts index 5518cbc..7c02874 100644 --- a/apps/dashboard/src/api/contracts.ts +++ b/apps/dashboard/src/api/contracts.ts @@ -1,46 +1,11 @@ -import { z } from 'zod'; +export { + ControlResponseSchema as controlResponseSchema, + StatsResponseSchema as botStatsSchema, + TimersResponseSchema as timersSchema, +} from '../../../../packages/api-contracts/src/index.js'; -const activityItemSchema = z.object({ - timestamp: z.number(), - type: z.enum(['sent', 'skipped', 'response', 'error', 'info', 'debug']), - message: z.string(), - recipient: z.string().optional(), - imageUrl: z.string().optional(), -}); - -export const botStatsSchema = z.object({ - isRunning: z.boolean(), - dryRunMode: z.boolean(), - totalProcessed: z.number(), - totalMessagesSent: z.number(), - totalResponses: z.number(), - responseRate: z.number(), - messagesThisHour: z.number(), - maxMessagesPerHour: z.number(), - subreddits: z.array(z.object({ name: z.string(), type: z.enum(['image', 'chat']) })), - targetGenders: z.array(z.string()), - recentActivity: z.array(activityItemSchema), - invitationCooldown: z - .object({ - until: z.number(), - reason: z.string(), - redditCode: z.string().nullable(), - }) - .nullable(), -}); - -export const timersSchema = z.object({ - nextMessageAvailableAt: z.number().nullable(), - hourlyResetAt: z.number(), - isHourlyLimitReached: z.boolean(), - messagesRemaining: z.number(), -}); - -export const controlResponseSchema = z.object({ - success: z.literal(true), - running: z.boolean(), -}); - -export type BotStats = z.infer; -export type Timers = z.infer; -export type ControlResponse = z.infer; +export type { + ControlResponse, + StatsResponse as BotStats, + TimersResponse as Timers, +} from '../../../../packages/api-contracts/src/index.js'; diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts index fca6b35..1c72a5e 100644 --- a/apps/dashboard/vite.config.ts +++ b/apps/dashboard/vite.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ root: fileURLToPath(new URL('.', import.meta.url)), plugins: [react()], build: { - outDir: '../../dist-dashboard', + outDir: 'dist', emptyOutDir: true, }, server: { diff --git a/docs/frontend-migration.md b/docs/frontend-migration.md new file mode 100644 index 0000000..63dcebe --- /dev/null +++ b/docs/frontend-migration.md @@ -0,0 +1,102 @@ +# React Dashboard Migration + +## Outcome + +Replace the inline dashboard in `public/index.html` with a typed, accessible React +application without disrupting the bot worker or its Express API. Express remains +the authenticated API, SSE, private-media, and static-asset host. + +The migration is complete only when the legacy dashboard can be removed without +losing a documented capability and the full test, type-check, production-build, +browser-acceptance, authentication, private-media, and reconnect gates pass. + +## Architecture + +- `apps/dashboard`: React, TypeScript, Vite, React Router, and TanStack Query. +- `packages/api-contracts`: shared Zod schemas for HTTP and realtime transport. +- `src/web`: existing Express host, gradually adapted to the shared contracts. +- Backend service and database types stay private to the backend. Transport + contracts must not expose database rows as an accidental public API. +- Server state lives in TanStack Query. Route, filter, and selection state lives + in URLs. Local component state is reserved for ephemeral presentation state. +- Do not add a global client-state library until a demonstrated cross-feature + state requirement cannot be met by those boundaries. + +## Navigation + +The application shell provides a responsive sidebar or drawer with these routes: + +| Route | Responsibility | +| --- | --- | +| `/` | Health, metrics, timers, limits, and important recent activity | +| `/conversations` | Searchable conversation inbox | +| `/conversations/:id` | Conversation history, context, suggestions, and sending | +| `/media` | Private blurred-by-default gallery and sync controls | +| `/performance` | Trends, strategies, subreddits, and successful examples | +| `/operations` | Domain activity, background jobs, and diagnostics | +| `/operations/logs` | Filterable bounded realtime technical console | +| `/settings/*` | Monitoring, limits, behaviour, AI, and access settings | + +The shell always surfaces bot state, dry-run state, Reddit session state, +realtime connection state, cooldowns, active sync progress, notifications, and +the guarded start/stop control. + +## Realtime model + +HTTP snapshots are authoritative. A typed SSE event stream updates or invalidates +the relevant query cache. Every event has an ID, type, occurrence timestamp, and +validated payload. The stream supports bounded replay and `Last-Event-ID` resume. +After reconnection the client refetches authoritative snapshots to repair gaps. + +Required event families are bot status, message sent or failed, conversation +updated, reply received, sync lifecycle and progress, rate-limit state, Reddit +session state, and technical logs. + +Important domain events appear in a readable activity timeline. Raw logs remain +in Operations. Toasts are reserved for user-triggered outcomes requiring timely +feedback. Realtime disconnection never blanks cached content; it marks it stale +and falls back to visibility-aware polling where necessary. + +## Feature slices + +1. Capture and validate existing HTTP contracts and critical legacy journeys. +2. Add the frontend build, application shell, authentication handling, routing, + error boundaries, design tokens, query client, and test harness. +3. Deliver Overview as the first production vertical slice. +4. Add the typed realtime event core, SSE transport, reconnection, and query + invalidation. +5. Migrate Conversations, including explicit suggestion and send mutations. +6. Migrate Media, preserving authentication and blur-by-default privacy. +7. Migrate Performance with sample sizes and low-confidence warnings. +8. Migrate Operations, access/login, and route-backed validated Settings. +9. Run feature parity and security audits, switch Express to the Vite output, + remove the legacy HTML, and update operator and deployment documentation. + +Each slice must be usable independently and must not require a flag-day rewrite. +The legacy dashboard remains available until replacement behavior is proven. + +## Engineering gates + +- New behavior follows red-green-refactor with Vitest. +- Component tests use Testing Library and test observable behavior. +- HTTP tests use a controlled boundary such as MSW rather than mocking `fetch` + throughout feature tests. +- Contract tests prove representative current responses parse and malformed + payloads fail. +- Realtime tests cover ordering, replay, duplicate handling, reconnection, and + cache repair. +- Browser tests cover authentication, start/stop, conversation inspection, + sending confirmation, sync, settings persistence, and private media. +- Interactive controls are keyboard accessible and restore focus after dialogs. +- No production frontend or backend code uses `any` or `console.log`. +- Mutations display the confirmed server outcome and do not rely on optimistic + success for operational controls. +- Production builds are deterministic and the Docker image serves only generated + frontend assets plus authenticated private content. + +## Cutover evidence + +Before deleting `public/index.html`, record evidence for every legacy capability, +all API and SSE authentication paths, private media access, container restart and +SSE recovery, responsive navigation, frontend type-check/build, backend build, +the complete Vitest suite, and the critical Playwright journeys. diff --git a/package.json b/package.json index 7815f73..943455f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "main": "dist/index.js", "scripts": { - "build": "tsc", + "build": "npm run build:server && npm run dashboard:build", + "build:server": "tsc", "dashboard:build": "vite build --config apps/dashboard/vite.config.ts", "dashboard:dev": "vite --config apps/dashboard/vite.config.ts", "dashboard:typecheck": "tsc -p apps/dashboard/tsconfig.json --noEmit", diff --git a/packages/api-contracts/src/common.ts b/packages/api-contracts/src/common.ts index 2fcbbec..db7d81e 100644 --- a/packages/api-contracts/src/common.ts +++ b/packages/api-contracts/src/common.ts @@ -6,6 +6,10 @@ export const ErrorResponseSchema = z.strictObject({ }); export const SuccessResponseSchema = z.strictObject({ success: z.literal(true) }); +export const ControlResponseSchema = z.strictObject({ + success: z.literal(true), + running: z.boolean(), +}); export const RejectedActionResponseSchema = z.strictObject({ success: z.literal(false), reason: z.string(), @@ -13,3 +17,4 @@ export const RejectedActionResponseSchema = z.strictObject({ export type ErrorResponse = z.infer; export type SuccessResponse = z.infer; +export type ControlResponse = z.infer; diff --git a/packages/api-contracts/tests/contracts.test.ts b/packages/api-contracts/tests/contracts.test.ts index 742e35d..14883c0 100644 --- a/packages/api-contracts/tests/contracts.test.ts +++ b/packages/api-contracts/tests/contracts.test.ts @@ -6,6 +6,7 @@ import { ChatSyncTriggerResponseSchema, ConfigResponseSchema, ConfigUpdateRequestSchema, + ControlResponseSchema, ConversationDetailResponseSchema, ConversationsResponseSchema, DashboardEventSchema, @@ -62,6 +63,13 @@ describe('dashboard endpoint contracts', () => { expect(StatsResponseSchema.safeParse({ ...payload, isRunning: 'yes' }).success).toBe(false); }); + it('validates authoritative start and stop responses', () => { + expect(ControlResponseSchema.safeParse({ success: true, running: true }).success).toBe(true); + expect(ControlResponseSchema.safeParse({ success: true, running: false }).success).toBe(true); + expect(ControlResponseSchema.safeParse({ success: true }).success).toBe(false); + expect(ControlResponseSchema.safeParse({ success: false, running: true }).success).toBe(false); + }); + it('validates timer, activity, message, and successful-example payloads', () => { expect(TimersResponseSchema.safeParse({ nextMessageAvailableAt: null, diff --git a/src/web/dashboard-assets.ts b/src/web/dashboard-assets.ts new file mode 100644 index 0000000..f5aee30 --- /dev/null +++ b/src/web/dashboard-assets.ts @@ -0,0 +1,34 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export interface DashboardAssets { + directory: string; + indexFile: string; + isLegacy: boolean; +} + +export type FileExists = (filePath: string) => boolean; + +export function resolveDashboardAssets( + workspaceDirectory: string, + fileExists: FileExists = fs.existsSync, +): DashboardAssets { + const reactDirectory = path.join(workspaceDirectory, 'apps', 'dashboard', 'dist'); + const reactIndexFile = path.join(reactDirectory, 'index.html'); + + if (fileExists(reactIndexFile)) { + return { + directory: reactDirectory, + indexFile: reactIndexFile, + isLegacy: false, + }; + } + + const legacyDirectory = path.join(workspaceDirectory, 'public'); + + return { + directory: legacyDirectory, + indexFile: path.join(legacyDirectory, 'index.html'), + isLegacy: true, + }; +} diff --git a/tests/dashboard-assets.test.ts b/tests/dashboard-assets.test.ts new file mode 100644 index 0000000..1dbd370 --- /dev/null +++ b/tests/dashboard-assets.test.ts @@ -0,0 +1,27 @@ +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { resolveDashboardAssets } from '../src/web/dashboard-assets.js'; + +describe('resolveDashboardAssets', () => { + it('selects the built React dashboard when its index exists', () => { + const workspace = path.resolve('workspace'); + const reactIndex = path.join(workspace, 'apps', 'dashboard', 'dist', 'index.html'); + const fileExists = vi.fn((candidate: string) => candidate === reactIndex); + + expect(resolveDashboardAssets(workspace, fileExists)).toEqual({ + directory: path.dirname(reactIndex), + indexFile: reactIndex, + isLegacy: false, + }); + }); + + it('falls back to the legacy dashboard during incremental migration', () => { + const workspace = path.resolve('workspace'); + + expect(resolveDashboardAssets(workspace, () => false)).toEqual({ + directory: path.join(workspace, 'public'), + indexFile: path.join(workspace, 'public', 'index.html'), + isLegacy: true, + }); + }); +});