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', + }); + }); +});