From 28f8d50c050baf6e5e5beba9bb3e1e3a1179f76c Mon Sep 17 00:00:00 2001 From: Richard R Date: Mon, 1 Jun 2026 20:38:54 -0600 Subject: [PATCH] refactor(settings): remove enableDestructiveDeleteActions runtime flag and related UI Eliminate the enableDestructiveDeleteActions feature flag from runtime configuration, admin panel, environment docs, and user settings modal. Remove all references, toggles, and documentation for this flag. This simplifies the runtime config and user interface by consolidating destructive actions under account deletion only. BREAKING CHANGE: The enableDestructiveDeleteActions runtime flag is no longer supported. Any configuration or code depending on this flag must be updated. --- .env.example | 2 +- docs-site/docs/configure/admin-panel.md | 1 - docs-site/docs/deploy/vercel-deployment.md | 1 - .../docs/reference/environment-variables.md | 1 - src/app/api/documents/[id]/parsed/route.ts | 34 ++++--- src/components/SettingsModal.tsx | 53 +++-------- src/components/admin/AdminFeaturesPanel.tsx | 8 -- src/contexts/RuntimeConfigContext.tsx | 2 - src/lib/server/admin/settings.ts | 1 - src/lib/server/compute/worker-parse-state.ts | 18 ++++ .../server/documents/parse-state-healing.ts | 2 + tests/helpers.ts | 21 +---- tests/landing-routing.spec.ts | 6 +- tests/unit/runtime-seed-json.vitest.spec.ts | 1 - tests/unit/worker-parse-state.vitest.spec.ts | 91 +++++++++++++++++++ 15 files changed, 151 insertions(+), 91 deletions(-) create mode 100644 tests/unit/worker-parse-state.vitest.spec.ts diff --git a/.env.example b/.env.example index f9612d0..8173d6d 100644 --- a/.env.example +++ b/.env.example @@ -87,4 +87,4 @@ S3_BUCKET= # (Optional) v4 JSON seed for first-boot runtime config + shared providers. # If both are set, RUNTIME_SEED_JSON_PATH is used. # RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json -# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"enableDestructiveDeleteActions":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","apiKey":"api_key_optional","defaultModel":"kokoro","enabled":true}]} +# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","apiKey":"api_key_optional","defaultModel":"kokoro","enabled":true}]} diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 79029c3..181071a 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -76,7 +76,6 @@ Runtime-editable settings, one row per key: | `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). | | `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. | | `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). | -| `enableDestructiveDeleteActions` | Show "Delete all data" buttons in the Documents tab (auth-disabled mode). | Word-by-word highlighting and PDF layout parsing capability are controlled by compute-worker server env configuration, not an admin runtime flag. diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index c830ac5..eeedbcd 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -78,7 +78,6 @@ After the first successful deploy and admin login, open **Settings → Admin** a - **Shared providers**: create/edit your provider key(s) here (encrypted at rest). - **Site features**: - `enableDocxConversion=false` on Vercel (`soffice` unavailable). - - `enableDestructiveDeleteActions=false` for safer public deployments. - `enableTtsProvidersTab=false` if you want shared-provider-only UX. - `enableUserSignups=true` unless you explicitly want an invite-only deployment. - `restrictUserApiKeys=true` to block user BYOK through the hosted server. diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 2389c18..45786fc 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -414,7 +414,6 @@ Example: "enableTtsProvidersTab": true, "enableAudiobookExport": true, "enableDocxConversion": true, - "enableDestructiveDeleteActions": true, "showAllProviderModels": true, "disableTtsRateLimit": true, "ttsDailyLimitAnonymous": 50000, diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 6ed1d96..9738b90 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -5,7 +5,10 @@ import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; import { createOrReusePdfWorkerOperation } from '@/lib/server/compute/worker-op-create'; -import { snapshotFromWorkerState } from '@/lib/server/compute/worker-parse-state'; +import { + documentParseStateFromWorkerState, + snapshotFromWorkerState, +} from '@/lib/server/compute/worker-parse-state'; import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'; import { documentKey, @@ -101,8 +104,14 @@ async function finalizeFromWorkerState(input: { logger: ServerLogger; }): Promise { const snapshot = snapshotFromWorkerState(input.workerState); + const workerParseState = documentParseStateFromWorkerState(input.workerState); if (snapshot.parseStatus === 'pending' || snapshot.parseStatus === 'running') { + await writeParseRowState({ + documentId: input.row.id, + userId: input.row.userId, + parseState: stringifyDocumentParseState(workerParseState), + }); return NextResponse.json({ parseStatus: snapshot.parseStatus, parseProgress: snapshot.parseProgress, @@ -114,12 +123,7 @@ async function finalizeFromWorkerState(input: { await writeParseRowState({ documentId: input.row.id, userId: input.row.userId, - parseState: stringifyDocumentParseState({ - status: 'failed', - progress: null, - updatedAt: Date.now(), - error: input.workerState.error?.message ?? 'Worker parse failed', - }), + parseState: stringifyDocumentParseState(workerParseState), }); return NextResponse.json({ @@ -152,11 +156,7 @@ async function finalizeFromWorkerState(input: { await writeParseRowState({ documentId: input.row.id, userId: input.row.userId, - parseState: stringifyDocumentParseState({ - status: 'ready', - progress: null, - updatedAt: Date.now(), - }), + parseState: stringifyDocumentParseState(workerParseState), parsedJsonKey, }); @@ -278,6 +278,11 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }); await recordJobEvent(authCtxOrRes.userId, 'pdf_layout', created.opId, rateConfig); const snapshot = snapshotFromWorkerState(created); + await writeParseRowState({ + documentId: row.id, + userId: row.userId, + parseState: stringifyDocumentParseState(documentParseStateFromWorkerState(created)), + }); return NextResponse.json({ parseStatus: snapshot.parseStatus, parseProgress: snapshot.parseProgress, @@ -409,6 +414,11 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string await recordJobEvent(authCtxOrRes.userId, 'pdf_layout', created.opId, rateConfig); const snapshot = snapshotFromWorkerState(created); + await writeParseRowState({ + documentId: row.id, + userId: row.userId, + parseState: stringifyDocumentParseState(documentParseStateFromWorkerState(created)), + }); return NextResponse.json({ parseStatus: snapshot.parseStatus, diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 3114e6b..a5ad457 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -22,7 +22,7 @@ import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useRouter } from 'next/navigation'; import { showPrivacyModal } from '@/components/PrivacyModal'; -import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; +import { mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings'; @@ -222,7 +222,6 @@ export function SettingsModal({ onOpenChange: (open: boolean) => void; }) { const runtimeConfig = useRuntimeConfig(); - const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions; const showAllProviderModels = runtimeConfig.showAllProviderModels; const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab; const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys; @@ -258,7 +257,6 @@ export function SettingsModal({ const [showProgress, setShowProgress] = useState(false); const [statusMessage, setStatusMessage] = useState(''); const [abortController, setAbortController] = useState(null); - const [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false); const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { baseUrl: authBaseUrl } = useAuthConfig(); @@ -441,17 +439,6 @@ export function SettingsModal({ } }; - const handleDeleteDocs = async () => { - try { - await deleteDocuments(); - await refreshDocuments().catch(() => { }); - } catch (error) { - console.error('Delete failed:', error); - } finally { - setShowDeleteDocsConfirm(false); - } - }; - const handleSignOut = async () => { const client = getAuthClient(authBaseUrl); await client.signOut(); @@ -1189,21 +1176,19 @@ export function SettingsModal({ Disconnect account - {enableDestructiveDelete && ( -
- - -

- Permanently deletes your account and all data. -

-
- )} +
+ + +

+ Permanently deletes your account and all data. +

+
) : (
@@ -1246,16 +1231,6 @@ export function SettingsModal({ )} - setShowDeleteDocsConfirm(false)} - onConfirm={handleDeleteDocs} - title="Delete All Data" - message="Are you sure you want to delete all documents from the server? This action cannot be undone." - confirmText="Delete" - isDangerous={true} - /> - setShowDeleteAccountConfirm(false)} diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index 162e4a5..9ba1085 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -495,14 +495,6 @@ export function AdminFeaturesPanel() { right={renderSource('enableDocxConversion')} variant="flat" /> - updateDraft('enableDestructiveDeleteActions', checked)} - right={renderSource('enableDestructiveDeleteActions')} - variant="flat" - />
, + nowMs = Date.now(), +): DocumentParseState { + const { parseStatus, parseProgress } = snapshotFromWorkerState(state); + return { + status: parseStatus, + progress: parseStatus === 'pending' || parseStatus === 'running' + ? parseProgress + : null, + updatedAt: nowMs, + ...(typeof state.opId === 'string' && state.opId.trim() ? { opId: state.opId } : {}), + ...(typeof state.jobId === 'string' && state.jobId.trim() ? { jobId: state.jobId } : {}), + ...(parseStatus === 'failed' && state.error?.message ? { error: state.error.message } : {}), + }; +} + export function mergeNonReadyParseSnapshot(input: { parseStatus: PdfParseStatus; parseProgress: PdfParseProgress | null; diff --git a/src/lib/server/documents/parse-state-healing.ts b/src/lib/server/documents/parse-state-healing.ts index 5824e37..82ca5dc 100644 --- a/src/lib/server/documents/parse-state-healing.ts +++ b/src/lib/server/documents/parse-state-healing.ts @@ -22,6 +22,8 @@ export async function healStaleDocumentParseState(input: { progress: null, updatedAt: Date.now(), error: `Parse state stale for more than ${staleMs}ms; marked failed for retry`, + ...(input.state.opId ? { opId: input.state.opId } : {}), + ...(input.state.jobId ? { jobId: input.state.jobId } : {}), })); await db diff --git a/tests/helpers.ts b/tests/helpers.ts index a8d2182..cfc9472 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -255,9 +255,8 @@ export async function setupTest(page: Page, testInfo?: TestInfo) { } }); - // If auth is enabled, establish an anonymous session BEFORE navigation. - // This keeps each test self-contained (no shared storageState) while ensuring - // server routes that require auth don't intermittently 401 during app startup. + // If we explicitly choose to bootstrap anonymous sessions for a test run, + // do it before navigation so protected startup routes do not intermittently 401. // await ensureAnonymousSession(page); // Navigate to the protected app home before each test @@ -412,22 +411,6 @@ export async function openSettingsDocumentsTab(page: Page) { await settingsDialog.getByRole('button', { name: /^Documents$/ }).click(); } -// Delete all local documents through Settings and close dialogs -export async function deleteAllLocalDocuments(page: Page) { - await openSettingsDocumentsTab(page); - await page.getByRole('button', { name: /Delete all data/i }).click(); - - const heading = page.getByRole('heading', { name: /Delete All Data/i }); - await expect(heading).toBeVisible({ timeout: 10000 }); - - const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]'); - await confirmBtn.click(); - - // Close any remaining modal layers - await page.keyboard.press('Escape'); - await page.keyboard.press('Escape'); -} - // Open the Voices dropdown from the TTS bar and return the button locator export async function openVoicesMenu(page: Page) { const ttsbar = page.locator('[data-app-ttsbar]'); diff --git a/tests/landing-routing.spec.ts b/tests/landing-routing.spec.ts index 930afc7..e12b5bd 100644 --- a/tests/landing-routing.spec.ts +++ b/tests/landing-routing.spec.ts @@ -2,8 +2,6 @@ import { expect, test } from '@playwright/test'; import { setupTest, uploadAndDisplay } from './helpers'; -const AUTH_ENABLED = Boolean(process.env.AUTH_SECRET && process.env.BASE_URL); - test.describe('Landing and app routing', () => { test('public landing renders without anonymous auth bootstrap call', async ({ page }) => { let anonymousSignInCalls = 0; @@ -26,8 +24,6 @@ test.describe('Landing and app routing', () => { }); test('existing authenticated session visiting / redirects to /app', async ({ page }) => { - test.skip(!AUTH_ENABLED); - await page.goto('/app'); await page.waitForLoadState('networkidle'); await page.waitForSelector('.fixed.inset-0.bg-base.z-50', { state: 'detached', timeout: 15000 }).catch(() => {}); @@ -46,7 +42,7 @@ test.describe('Landing and app routing', () => { }); test('protected app routes redirect to /signin when anonymous auth is disabled', async ({ page }) => { - test.skip(!AUTH_ENABLED || process.env.USE_ANONYMOUS_AUTH_SESSIONS !== 'false'); + test.skip(process.env.USE_ANONYMOUS_AUTH_SESSIONS !== 'false'); await page.goto('/app'); await expect(page).toHaveURL(/\/signin$/); diff --git a/tests/unit/runtime-seed-json.vitest.spec.ts b/tests/unit/runtime-seed-json.vitest.spec.ts index 71b2ad7..e62cefc 100644 --- a/tests/unit/runtime-seed-json.vitest.spec.ts +++ b/tests/unit/runtime-seed-json.vitest.spec.ts @@ -234,7 +234,6 @@ describe('runtime config JSON seeding', () => { enableTtsProvidersTab: false, enableAudiobookExport: false, enableDocxConversion: false, - enableDestructiveDeleteActions: false, showAllProviderModels: false, disableTtsRateLimit: false, ttsDailyLimitAnonymous: 12345, diff --git a/tests/unit/worker-parse-state.vitest.spec.ts b/tests/unit/worker-parse-state.vitest.spec.ts new file mode 100644 index 0000000..062cf87 --- /dev/null +++ b/tests/unit/worker-parse-state.vitest.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from 'vitest'; +import type { PdfLayoutJobResult, WorkerOperationState } from '@openreader/compute-core/api-contracts'; +import { + documentParseStateFromWorkerState, + snapshotFromWorkerState, +} from '../../src/lib/server/compute/worker-parse-state'; + +function makeWorkerState( + overrides: Partial>, +): WorkerOperationState { + return { + opId: 'op-123', + opKey: 'pdf_layout|v1|doc-1', + kind: 'pdf_layout', + jobId: 'job-123', + status: 'queued', + queuedAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + ...overrides, + }; +} + +describe('worker parse state mapping', () => { + test('maps queued worker state to pending parse state with op identifiers', () => { + const workerState = makeWorkerState({ + status: 'queued', + progress: { + totalPages: 500, + pagesParsed: 0, + currentPage: 1, + phase: 'infer', + }, + }); + + expect(snapshotFromWorkerState(workerState)).toEqual({ + parseStatus: 'pending', + parseProgress: null, + }); + expect(documentParseStateFromWorkerState(workerState, 1234)).toEqual({ + status: 'pending', + progress: null, + updatedAt: 1234, + opId: 'op-123', + jobId: 'job-123', + }); + }); + + test('maps running worker state to running parse state with progress', () => { + const workerState = makeWorkerState({ + status: 'running', + progress: { + totalPages: 500, + pagesParsed: 120, + currentPage: 121, + phase: 'infer', + }, + }); + + expect(documentParseStateFromWorkerState(workerState, 5678)).toEqual({ + status: 'running', + progress: { + totalPages: 500, + pagesParsed: 120, + currentPage: 121, + phase: 'infer', + }, + updatedAt: 5678, + opId: 'op-123', + jobId: 'job-123', + }); + }); + + test('maps failed worker state to failed parse state and preserves the worker error', () => { + const workerState = makeWorkerState({ + status: 'failed', + error: { + code: 'PDF_PARSE_FAILED', + message: 'layout model crashed', + }, + }); + + expect(documentParseStateFromWorkerState(workerState, 9999)).toEqual({ + status: 'failed', + progress: null, + updatedAt: 9999, + opId: 'op-123', + jobId: 'job-123', + error: 'layout model crashed', + }); + }); +});