From 39f1f0e886ccf414ec68ae32860984734ea12df8 Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Sun, 24 May 2026 00:51:44 -0400 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20safe=20Tier=200+1=20=E2=80=94?= =?UTF-8?q?=20backend=20partial-class=20splits=20+=20util=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero behaviour change. C# partial classes compile to identical IL; backend integration + unit tests verify no regression. New unit tests are additive (test infrastructure for mobile is new; web Vitest existed already). Backend cosmetic splits (compile-identical): - AppDbContext.cs 655 → 104 LOC + 7 partials (Catalog, User, Reading, UserBooks, Vocabulary, Ops, Seo, Collections) - VocabularyEndpoints.cs 1559 → 875 LOC + 6 partials (Stats, Settings, Pending, Lookups, Clusters, Admin) - AdminService.cs 977 → 131 LOC + 4 partials (Upload, Editions, Chapters, UserUploads) Each partial < 400 LOC, single domain per file. Largest non-migration backend file dropped from 1559 → 875 LOC. Tests added (Tier 0 — additive): - Web: analytics.test.ts (15), dataEvents.test.ts (12), errorUtils.test.ts (5), formatTime.test.ts (8) — +40 cases - Mobile (NEW Vitest infra): searchUtils.test.ts (18), features.test.ts (9), vocabStatsCache.test.ts (11) — +38 cases, vitest.config.ts + __mocks__/async-storage.ts (in-process AsyncStorage mock) Verification: - dotnet build: 0 warnings, 0 errors - dotnet test UnitTests: 216/216 pass - dotnet test Extraction.Tests: 313/313 pass - dotnet test Search.Tests: 20/20 pass - pnpm tsc (web): exit 0 - pnpm test (web): 474/474 pass - pnpm tsc (mobile): exit 0 - pnpm test (mobile): 38/38 pass Co-Authored-By: Claude Opus 4.7 --- apps/mobile/package-lock.json | 2116 +++++++++++++++-- apps/mobile/package.json | 8 +- .../mobile/src/lib/__mocks__/async-storage.ts | 45 + apps/mobile/src/lib/features.test.ts | 60 + apps/mobile/src/lib/searchUtils.test.ts | 89 + apps/mobile/src/lib/vocabStatsCache.test.ts | 110 + apps/mobile/vitest.config.ts | 46 + apps/web/src/lib/analytics.test.ts | 232 ++ apps/web/src/lib/dataEvents.test.ts | 129 + apps/web/src/lib/errorUtils.test.ts | 32 + apps/web/src/lib/formatTime.test.ts | 43 + .../Endpoints/VocabularyEndpoints.Admin.cs | 58 + .../Endpoints/VocabularyEndpoints.Clusters.cs | 137 ++ .../Endpoints/VocabularyEndpoints.Lookups.cs | 149 ++ .../Endpoints/VocabularyEndpoints.Pending.cs | 97 + .../Endpoints/VocabularyEndpoints.Settings.cs | 113 + .../Endpoints/VocabularyEndpoints.Stats.cs | 224 ++ .../src/Api/Endpoints/VocabularyEndpoints.cs | 720 +----- .../Admin/AdminService.Chapters.cs | 111 + .../Admin/AdminService.Editions.cs | 383 +++ .../Application/Admin/AdminService.Upload.cs | 341 +++ .../Admin/AdminService.UserUploads.cs | 113 + backend/src/Application/Admin/AdminService.cs | 876 +------ .../Persistence/AppDbContext.Catalog.cs | 155 ++ .../Persistence/AppDbContext.Collections.cs | 35 + .../Persistence/AppDbContext.Ops.cs | 55 + .../Persistence/AppDbContext.Reading.cs | 107 + .../Persistence/AppDbContext.Seo.cs | 58 + .../Persistence/AppDbContext.User.cs | 73 + .../Persistence/AppDbContext.UserBooks.cs | 96 + .../Persistence/AppDbContext.Vocabulary.cs | 130 + .../Persistence/AppDbContext.cs | 611 +---- 32 files changed, 5225 insertions(+), 2327 deletions(-) create mode 100644 apps/mobile/src/lib/__mocks__/async-storage.ts create mode 100644 apps/mobile/src/lib/features.test.ts create mode 100644 apps/mobile/src/lib/searchUtils.test.ts create mode 100644 apps/mobile/src/lib/vocabStatsCache.test.ts create mode 100644 apps/mobile/vitest.config.ts create mode 100644 apps/web/src/lib/analytics.test.ts create mode 100644 apps/web/src/lib/dataEvents.test.ts create mode 100644 apps/web/src/lib/errorUtils.test.ts create mode 100644 apps/web/src/lib/formatTime.test.ts create mode 100644 backend/src/Api/Endpoints/VocabularyEndpoints.Admin.cs create mode 100644 backend/src/Api/Endpoints/VocabularyEndpoints.Clusters.cs create mode 100644 backend/src/Api/Endpoints/VocabularyEndpoints.Lookups.cs create mode 100644 backend/src/Api/Endpoints/VocabularyEndpoints.Pending.cs create mode 100644 backend/src/Api/Endpoints/VocabularyEndpoints.Settings.cs create mode 100644 backend/src/Api/Endpoints/VocabularyEndpoints.Stats.cs create mode 100644 backend/src/Application/Admin/AdminService.Chapters.cs create mode 100644 backend/src/Application/Admin/AdminService.Editions.cs create mode 100644 backend/src/Application/Admin/AdminService.Upload.cs create mode 100644 backend/src/Application/Admin/AdminService.UserUploads.cs create mode 100644 backend/src/Infrastructure/Persistence/AppDbContext.Catalog.cs create mode 100644 backend/src/Infrastructure/Persistence/AppDbContext.Collections.cs create mode 100644 backend/src/Infrastructure/Persistence/AppDbContext.Ops.cs create mode 100644 backend/src/Infrastructure/Persistence/AppDbContext.Reading.cs create mode 100644 backend/src/Infrastructure/Persistence/AppDbContext.Seo.cs create mode 100644 backend/src/Infrastructure/Persistence/AppDbContext.User.cs create mode 100644 backend/src/Infrastructure/Persistence/AppDbContext.UserBooks.cs create mode 100644 backend/src/Infrastructure/Persistence/AppDbContext.Vocabulary.cs diff --git a/apps/mobile/package-lock.json b/apps/mobile/package-lock.json index 98181600..340b1f20 100644 --- a/apps/mobile/package-lock.json +++ b/apps/mobile/package-lock.json @@ -15,7 +15,6 @@ "expo": "~55.0.9", "expo-apple-authentication": "~55.0.9", "expo-asset": "^55.0.10", - "expo-audio": "~55.0.14", "expo-clipboard": "~55.0.9", "expo-constants": "~55.0.7", "expo-dev-client": "~55.0.19", @@ -46,9 +45,26 @@ "devDependencies": { "@playwright/test": "^1.52.0", "@types/react": "~19.2.2", + "@vitest/coverage-v8": "2.1.9", + "expo-audio": "~55.0.14", "express": "^5.2.1", "http-proxy-middleware": "^3.0.5", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vitest": "2.1.9" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/@babel/code-frame": { @@ -1472,6 +1488,13 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@egjs/hammerjs": { "version": "2.0.17", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", @@ -1484,178 +1507,569 @@ "node": ">=0.8.0" } }, - "node_modules/@expo-google-fonts/material-symbols": { - "version": "0.4.27", - "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.27.tgz", - "integrity": "sha512-cnb3DZnWUWpezGFkJ8y4MT5f/lw6FcgDzeJzic+T+vpQHLHG1cg3SC3i1w1i8Bk4xKR4HPY3t9iIRNvtr5ml8A==", - "license": "MIT AND Apache-2.0" - }, - "node_modules/@expo/code-signing-certificates": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", - "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "node-forge": "^1.3.3" + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@expo/config": { - "version": "55.0.15", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.15.tgz", - "integrity": "sha512-lHc0ELIQ8126jYOMZpLv3WIuvordW98jFg5aT/J1/12n2ycuXu01XLZkJsdw0avO34cusUYb1It+MvY8JiMduA==", + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@expo/config-plugins": "~55.0.8", - "@expo/config-types": "^55.0.5", - "@expo/json-file": "^10.0.13", - "@expo/require-utils": "^55.0.4", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.6.0", - "slugify": "^1.3.4" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@expo/config-plugins": { - "version": "55.0.8", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.8.tgz", - "integrity": "sha512-8WfWTRntTCcowfOS+tHdB0z98gKetTwktg4G5TWkCkXVa8Jt1NUnvzaaU4UHk2vbR2U4N84RyZJFizSwfF6C9g==", + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@expo/config-types": "^55.0.5", - "@expo/json-file": "~10.0.13", - "@expo/plist": "^0.5.2", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "resolve-from": "^5.0.0", - "semver": "^7.5.4", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@expo/config-types": { - "version": "55.0.5", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz", - "integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==", - "license": "MIT" - }, - "node_modules/@expo/devcert": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", - "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@expo/sudo-prompt": "^9.3.1", - "debug": "^3.1.0" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@expo/devcert/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "ms": "^2.1.1" + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@expo/devtools": { - "version": "55.0.2", - "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-55.0.2.tgz", - "integrity": "sha512-4VsFn9MUriocyuhyA+ycJP3TJhUsOFHDc270l9h3LhNpXMf6wvIdGcA0QzXkZtORXmlDybWXRP2KT1k36HcQkA==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^4.1.2" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-native": { - "optional": true - } + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@expo/dom-webview": { - "version": "55.0.3", - "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-55.0.3.tgz", - "integrity": "sha512-bY4/rfcZ0f43DvOtMn8/kmPlmo01tex5hRoc5hKbwBwQjqWQuQt0ACwu7akR9IHI4j0WNG48eL6cZB6dZUFrzg==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*" + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@expo/env": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.1.1.tgz", - "integrity": "sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "getenv": "^2.0.0" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=20.12.0" + "node": ">=12" } }, - "node_modules/@expo/fingerprint": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.16.6.tgz", - "integrity": "sha512-nRITNbnu3RKSHPvKVehrSU4KG2VY9V8nvULOHBw98ukHCAU4bGrU5APvcblOkX3JAap+xEHsg/mZvqlvkLInmQ==", + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@expo/env": "^2.0.11", - "@expo/spawn-async": "^1.7.2", - "arg": "^5.0.2", - "chalk": "^4.1.2", - "debug": "^4.3.4", - "getenv": "^2.0.0", - "glob": "^13.0.0", - "ignore": "^5.3.1", - "minimatch": "^10.2.2", - "resolve-from": "^5.0.0", - "semver": "^7.6.0" - }, - "bin": { - "fingerprint": "bin/cli.js" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@expo/image-utils": { - "version": "0.8.13", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.13.tgz", - "integrity": "sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@expo/require-utils": "^55.0.4", - "@expo/spawn-async": "^1.7.2", - "chalk": "^4.0.0", - "getenv": "^2.0.0", - "jimp-compact": "0.16.1", - "parse-png": "^2.1.0", - "semver": "^7.6.0" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" } }, - "node_modules/@expo/json-file": { - "version": "10.0.13", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.13.tgz", - "integrity": "sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.20.0", - "json5": "^2.2.3" - } - }, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo-google-fonts/material-symbols": { + "version": "0.4.27", + "resolved": "https://registry.npmjs.org/@expo-google-fonts/material-symbols/-/material-symbols-0.4.27.tgz", + "integrity": "sha512-cnb3DZnWUWpezGFkJ8y4MT5f/lw6FcgDzeJzic+T+vpQHLHG1cg3SC3i1w1i8Bk4xKR4HPY3t9iIRNvtr5ml8A==", + "license": "MIT AND Apache-2.0" + }, + "node_modules/@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "license": "MIT", + "dependencies": { + "node-forge": "^1.3.3" + } + }, + "node_modules/@expo/config": { + "version": "55.0.15", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-55.0.15.tgz", + "integrity": "sha512-lHc0ELIQ8126jYOMZpLv3WIuvordW98jFg5aT/J1/12n2ycuXu01XLZkJsdw0avO34cusUYb1It+MvY8JiMduA==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~55.0.8", + "@expo/config-types": "^55.0.5", + "@expo/json-file": "^10.0.13", + "@expo/require-utils": "^55.0.4", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/@expo/config-plugins": { + "version": "55.0.8", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-55.0.8.tgz", + "integrity": "sha512-8WfWTRntTCcowfOS+tHdB0z98gKetTwktg4G5TWkCkXVa8Jt1NUnvzaaU4UHk2vbR2U4N84RyZJFizSwfF6C9g==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^55.0.5", + "@expo/json-file": "~10.0.13", + "@expo/plist": "^0.5.2", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/config-types": { + "version": "55.0.5", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-55.0.5.tgz", + "integrity": "sha512-sCmSUZG4mZ/ySXvfyyBdhjivz8Q539X1NondwDdYG7s3SBsk+wsgPJzYsqgAG/P9+l0xWjUD2F+kQ1cAJ6NNLg==", + "license": "MIT" + }, + "node_modules/@expo/devcert": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.1.tgz", + "integrity": "sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==", + "license": "MIT", + "dependencies": { + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + } + }, + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@expo/devtools": { + "version": "55.0.2", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-55.0.2.tgz", + "integrity": "sha512-4VsFn9MUriocyuhyA+ycJP3TJhUsOFHDc270l9h3LhNpXMf6wvIdGcA0QzXkZtORXmlDybWXRP2KT1k36HcQkA==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/dom-webview": { + "version": "55.0.3", + "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-55.0.3.tgz", + "integrity": "sha512-bY4/rfcZ0f43DvOtMn8/kmPlmo01tex5hRoc5hKbwBwQjqWQuQt0ACwu7akR9IHI4j0WNG48eL6cZB6dZUFrzg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/env": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.1.1.tgz", + "integrity": "sha512-rVvHC4I6xlPcg+mAO09ydUi2Wjv1ZytpLmHOSzvXzBAz9mMrJggqCe4s4dubjJvi/Ino/xQCLhbaLCnTtLpikg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/@expo/fingerprint": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.16.6.tgz", + "integrity": "sha512-nRITNbnu3RKSHPvKVehrSU4KG2VY9V8nvULOHBw98ukHCAU4bGrU5APvcblOkX3JAap+xEHsg/mZvqlvkLInmQ==", + "license": "MIT", + "dependencies": { + "@expo/env": "^2.0.11", + "@expo/spawn-async": "^1.7.2", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^10.2.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "bin": { + "fingerprint": "bin/cli.js" + } + }, + "node_modules/@expo/image-utils": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.13.tgz", + "integrity": "sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^55.0.4", + "@expo/spawn-async": "^1.7.2", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/json-file": { + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.13.tgz", + "integrity": "sha512-pX/XjQn7tgNw6zuuV2ikmegmwe/S7uiwhrs2wXrANMkq7ozrA+JcZwgW9Q/8WZgciBzfAhNp5hnackHcrmapQA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, "node_modules/@expo/local-build-cache-provider": { "version": "55.0.7", "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-55.0.7.tgz", @@ -1916,26 +2330,129 @@ "chalk": "^4.1.0", "js-yaml": "^4.1.0" }, - "bin": { - "excpretty": "build/cli.js" + "bin": { + "excpretty": "build/cli.js" + } + }, + "node_modules/@expo/xcpretty/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/@expo/xcpretty/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/@expo/xcpretty/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/@expo/xcpretty/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/@isaacs/ttlcache": { @@ -2135,6 +2652,17 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@playwright/test": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", @@ -3049,33 +3577,383 @@ "react-native": "*" } }, - "node_modules/@react-navigation/native-stack": { - "version": "7.14.9", - "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.14.9.tgz", - "integrity": "sha512-s76NyRr/VSPRqXaLtaKUj9Q1qZ5ym0831QZFFXJcRyom6QYpo9eESB9/dfeN+tTEnH7kP77CwoCuR0THKMuk3w==", + "node_modules/@react-navigation/native-stack": { + "version": "7.14.9", + "resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.14.9.tgz", + "integrity": "sha512-s76NyRr/VSPRqXaLtaKUj9Q1qZ5ym0831QZFFXJcRyom6QYpo9eESB9/dfeN+tTEnH7kP77CwoCuR0THKMuk3w==", + "license": "MIT", + "dependencies": { + "@react-navigation/elements": "^2.9.13", + "color": "^4.2.3", + "sf-symbols-typescript": "^2.1.0", + "warn-once": "^0.1.1" + }, + "peerDependencies": { + "@react-navigation/native": "^7.2.1", + "react": ">= 18.2.0", + "react-native": "*", + "react-native-safe-area-context": ">= 4.0.0", + "react-native-screens": ">= 4.0.0" + } + }, + "node_modules/@react-navigation/routers": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz", + "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==", + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@react-navigation/elements": "^2.9.13", - "color": "^4.2.3", - "sf-symbols-typescript": "^2.1.0", - "warn-once": "^0.1.1" - }, - "peerDependencies": { - "@react-navigation/native": "^7.2.1", - "react": ">= 18.2.0", - "react-native": "*", - "react-native-safe-area-context": ">= 4.0.0", - "react-native-screens": ">= 4.0.0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@react-navigation/routers": { - "version": "7.5.3", - "resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-7.5.3.tgz", - "integrity": "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11" - } + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@sinclair/typebox": { "version": "0.27.10", @@ -3142,6 +4020,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3237,6 +4122,246 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "license": "ISC" }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/coverage-v8/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitest/coverage-v8/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.11", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", @@ -3395,6 +4520,16 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/await-lock": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", @@ -3849,6 +4984,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3912,6 +5057,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3928,6 +5090,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chrome-launcher": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", @@ -4287,6 +5459,16 @@ "node": ">=0.10" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -4372,6 +5554,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4428,6 +5617,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4441,6 +5637,45 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4481,6 +5716,16 @@ "node": ">=4" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4506,6 +5751,16 @@ "dev": true, "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expo": { "version": "55.0.9", "resolved": "https://registry.npmjs.org/expo/-/expo-55.0.9.tgz", @@ -4598,6 +5853,7 @@ "version": "55.0.14", "resolved": "https://registry.npmjs.org/expo-audio/-/expo-audio-55.0.14.tgz", "integrity": "sha512-Biy6ffKXrnKHgcWSVWLKVdWLNhV/pj1JWJeotY6nDR6fVe8mjXQDCvi6EbaSFPdffVHym6UB2siKzWUNSnG+kQ==", + "dev": true, "license": "MIT", "peerDependencies": { "expo": "*", @@ -5711,6 +6967,36 @@ "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", "license": "BSD-2-Clause" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5967,6 +7253,13 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6298,6 +7591,66 @@ "semver": "bin/semver.js" } }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jest-environment-node": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", @@ -6946,6 +8299,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6955,6 +8315,44 @@ "yallist": "^3.0.2" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -7734,6 +9132,13 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parse-png": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", @@ -7824,6 +9229,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8658,6 +10080,58 @@ "node": "*" } }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -8973,6 +10447,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -9096,6 +10577,13 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -9123,6 +10611,13 @@ "node": ">= 0.6" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-buffers": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", @@ -9155,6 +10650,22 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -9167,6 +10678,20 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/structured-headers": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", @@ -9325,6 +10850,50 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -9665,6 +11234,155 @@ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", @@ -9738,6 +11456,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -9755,6 +11490,25 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 08d900fc..737ae669 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -17,6 +17,8 @@ "submit:ios": "eas submit --platform ios --profile production", "submit:android": "eas submit --platform android --profile production", "update": "eas update", + "test": "vitest run", + "test:watch": "vitest", "test:e2e": "npx playwright test --config=e2e/playwright.config.ts --project=android", "test:e2e:ios": "npx playwright test --config=e2e/playwright.config.ts --project=ios", "test:e2e:ui": "npx playwright test --config=e2e/playwright.config.ts --project=android --ui" @@ -29,7 +31,6 @@ "expo": "~55.0.9", "expo-apple-authentication": "~55.0.9", "expo-asset": "^55.0.10", - "expo-audio": "~55.0.14", "expo-clipboard": "~55.0.9", "expo-constants": "~55.0.7", "expo-dev-client": "~55.0.19", @@ -60,9 +61,12 @@ "devDependencies": { "@playwright/test": "^1.52.0", "@types/react": "~19.2.2", + "@vitest/coverage-v8": "2.1.9", + "expo-audio": "~55.0.14", "express": "^5.2.1", "http-proxy-middleware": "^3.0.5", - "typescript": "~5.9.2" + "typescript": "~5.9.2", + "vitest": "2.1.9" }, "private": true } diff --git a/apps/mobile/src/lib/__mocks__/async-storage.ts b/apps/mobile/src/lib/__mocks__/async-storage.ts new file mode 100644 index 00000000..c257f5bc --- /dev/null +++ b/apps/mobile/src/lib/__mocks__/async-storage.ts @@ -0,0 +1,45 @@ +/** + * In-process AsyncStorage mock for Vitest unit tests. + * + * Wired via `vitest.config.ts` resolve.alias — anything that imports + * `@react-native-async-storage/async-storage` in test runs gets this + * instead. Mirrors the real API surface that our `lib/` code uses today + * (setItem / getItem / removeItem / getAllKeys / multiGet / multiRemove). + * + * Not exposed at runtime — only loaded when the alias fires. + * + * Tests that need to reset state between cases can call `__reset()` from + * the default export (e.g. in `beforeEach`). + */ + +const store = new Map() + +const AsyncStorage = { + async getItem(key: string): Promise { + return store.has(key) ? store.get(key)! : null + }, + async setItem(key: string, value: string): Promise { + store.set(key, value) + }, + async removeItem(key: string): Promise { + store.delete(key) + }, + async getAllKeys(): Promise { + return Array.from(store.keys()) + }, + async multiGet(keys: readonly string[]): Promise { + return keys.map(k => [k, store.has(k) ? store.get(k)! : null]) + }, + async multiRemove(keys: readonly string[]): Promise { + for (const k of keys) store.delete(k) + }, + async clear(): Promise { + store.clear() + }, + /** Test-only: wipe the in-memory store between cases. */ + __reset(): void { + store.clear() + }, +} + +export default AsyncStorage diff --git a/apps/mobile/src/lib/features.test.ts b/apps/mobile/src/lib/features.test.ts new file mode 100644 index 00000000..d35e5748 --- /dev/null +++ b/apps/mobile/src/lib/features.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import AsyncStorage from '@react-native-async-storage/async-storage' +import { + FEATURES, + READER_OVERLAY_V2_STORAGE_KEY, + resolveReaderOverlayV2Active, + readReaderOverlayV2Active, +} from './features' + +// The mock exposes __reset for clean state between tests. +const MockedStorage = AsyncStorage as unknown as { __reset(): void } + +describe('FEATURES — build-time defaults', () => { + it('readerOverlayV2 defaults to true (matches shipped behavior)', () => { + expect(FEATURES.readerOverlayV2).toBe(true) + }) +}) + +describe('resolveReaderOverlayV2Active (pure)', () => { + it('"0" → false (kill-switch override)', () => { + expect(resolveReaderOverlayV2Active('0')).toBe(false) + }) + + it('"1" → true (force-on override)', () => { + expect(resolveReaderOverlayV2Active('1')).toBe(true) + }) + + it('null → build-time default', () => { + expect(resolveReaderOverlayV2Active(null)).toBe(FEATURES.readerOverlayV2) + }) + + it('unrecognized value → build-time default', () => { + expect(resolveReaderOverlayV2Active('maybe')).toBe(FEATURES.readerOverlayV2) + expect(resolveReaderOverlayV2Active('')).toBe(FEATURES.readerOverlayV2) + expect(resolveReaderOverlayV2Active('true')).toBe(FEATURES.readerOverlayV2) // strict check + }) +}) + +describe('readReaderOverlayV2Active (AsyncStorage-backed)', () => { + beforeEach(() => MockedStorage.__reset()) + + it('returns default when storage empty', async () => { + expect(await readReaderOverlayV2Active()).toBe(FEATURES.readerOverlayV2) + }) + + it('honors "0" override', async () => { + await AsyncStorage.setItem(READER_OVERLAY_V2_STORAGE_KEY, '0') + expect(await readReaderOverlayV2Active()).toBe(false) + }) + + it('honors "1" override', async () => { + await AsyncStorage.setItem(READER_OVERLAY_V2_STORAGE_KEY, '1') + expect(await readReaderOverlayV2Active()).toBe(true) + }) + + it('falls back to default on unrecognized stored value', async () => { + await AsyncStorage.setItem(READER_OVERLAY_V2_STORAGE_KEY, 'corrupt') + expect(await readReaderOverlayV2Active()).toBe(FEATURES.readerOverlayV2) + }) +}) diff --git a/apps/mobile/src/lib/searchUtils.test.ts b/apps/mobile/src/lib/searchUtils.test.ts new file mode 100644 index 00000000..5b2a4309 --- /dev/null +++ b/apps/mobile/src/lib/searchUtils.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest' +import { normalizeForSearch, matchesQuery } from './searchUtils' + +describe('normalizeForSearch', () => { + it('returns empty string for null', () => { + expect(normalizeForSearch(null)).toBe('') + }) + + it('returns empty string for undefined', () => { + expect(normalizeForSearch(undefined)).toBe('') + }) + + it('returns empty string for empty string', () => { + expect(normalizeForSearch('')).toBe('') + }) + + it('lowercases ASCII', () => { + expect(normalizeForSearch('Dracula')).toBe('dracula') + }) + + it('strips Latin diacritics (NFD decomposition)', () => { + expect(normalizeForSearch('Pâtisserie')).toBe('patisserie') + expect(normalizeForSearch('NAÏVE')).toBe('naive') + expect(normalizeForSearch('Łódź')).toBe('łodz') // ł survives (not combining), but ó → o + }) + + it('strips combining marks even from Cyrillic (й = и + combining breve)', () => { + // Real behavior documented: "й" decomposes to "и" + U+0306 (combining + // breve). The diacritic strip turns "й" → "и". This is intentional — + // it means a user typing "достоевскии" matches "Достоевский" entries, + // which is friendlier for typos than strict NFC matching. + expect(normalizeForSearch('Достоевский')).toBe('достоевскии') + // Letters without combining marks pass through unchanged. + expect(normalizeForSearch('Толстой')).toBe('толстои') // "й" stripped similarly + expect(normalizeForSearch('Чехов')).toBe('чехов') + }) + + it('handles mixed case + diacritics + whitespace', () => { + expect(normalizeForSearch(' Lévi-Strauss ')).toBe(' levi-strauss ') + }) +}) + +describe('matchesQuery', () => { + it('empty query matches anything (no filter)', () => { + expect(matchesQuery({ title: 'War and Peace', author: 'Tolstoy' }, '')).toBe(true) + }) + + it('whitespace-only query matches anything', () => { + expect(matchesQuery({ title: 'War and Peace', author: 'Tolstoy' }, ' ')).toBe(true) + }) + + it('matches by title (case-insensitive)', () => { + expect(matchesQuery({ title: 'Dracula', author: 'Stoker' }, 'dracula')).toBe(true) + expect(matchesQuery({ title: 'Dracula', author: 'Stoker' }, 'DRACULA')).toBe(true) + }) + + it('matches by author', () => { + expect(matchesQuery({ title: 'Dracula', author: 'Bram Stoker' }, 'stoker')).toBe(true) + }) + + it('matches across title + author (multi-term AND)', () => { + expect(matchesQuery({ title: 'Dracula', author: 'Bram Stoker' }, 'dracula stoker')).toBe(true) + }) + + it('does not match when any term missing', () => { + expect(matchesQuery({ title: 'Dracula', author: 'Stoker' }, 'dracula tolstoy')).toBe(false) + }) + + it('matches partial substrings', () => { + expect(matchesQuery({ title: 'War and Peace', author: 'Tolstoy' }, 'pea')).toBe(true) + }) + + it('matches with diacritics stripped on both sides', () => { + expect(matchesQuery({ title: 'Pâtisserie', author: null }, 'patiss')).toBe(true) + }) + + it('returns false for null fields without query match', () => { + expect(matchesQuery({ title: null, author: null }, 'anything')).toBe(false) + }) + + it('handles undefined fields gracefully', () => { + expect(matchesQuery({}, 'foo')).toBe(false) + expect(matchesQuery({}, '')).toBe(true) // empty query = match + }) + + it('extra whitespace in query splits cleanly', () => { + expect(matchesQuery({ title: 'A B C', author: null }, ' a b ')).toBe(true) + }) +}) diff --git a/apps/mobile/src/lib/vocabStatsCache.test.ts b/apps/mobile/src/lib/vocabStatsCache.test.ts new file mode 100644 index 00000000..c192e54c --- /dev/null +++ b/apps/mobile/src/lib/vocabStatsCache.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import AsyncStorage from '@react-native-async-storage/async-storage' +import type { VocabularyStatsDto } from '@textstack/shared' +import { saveVocabStatsCache, getVocabStatsCache, clearVocabStatsCache } from './vocabStatsCache' + +const MockedStorage = AsyncStorage as unknown as { __reset(): void } +const STORAGE_KEY = 'vocab.stats.last.v2' + +// Minimal stats fixture — anything with totalWords passes validation. +// Full DTO has nested byStage / dailyCap / weeklyProgress; we cast through +// unknown because the cache validator only checks totalWords + cachedAt. +function fxStats(overrides: Partial = {}): VocabularyStatsDto { + return { + totalWords: 42, + byStage: { new: 7, recognition: 10, recall: 10, context: 10, mastered: 5 }, + dueNow: 0, + retiredCount: 0, + pendingCount: 0, + lookupCount: 0, + clusterCount: 0, + dailyCap: { used: 0, cap: 15, remaining: 15 }, + weeklyProgress: { used: 0, budget: 70, remaining: 70, resetAt: '' }, + ...overrides, + } as unknown as VocabularyStatsDto +} + +describe('saveVocabStatsCache → getVocabStatsCache (round-trip)', () => { + beforeEach(() => MockedStorage.__reset()) + + it('writes and reads back the stats payload', async () => { + await saveVocabStatsCache(fxStats({ totalWords: 100, retiredCount: 12 })) + const cached = await getVocabStatsCache() + expect(cached).not.toBeNull() + expect(cached!.stats.totalWords).toBe(100) + expect(cached!.stats.retiredCount).toBe(12) + }) + + it('cachedAt is set to a recent epoch ms', async () => { + const before = Date.now() + await saveVocabStatsCache(fxStats()) + const after = Date.now() + const cached = await getVocabStatsCache() + expect(cached!.cachedAt).toBeGreaterThanOrEqual(before) + expect(cached!.cachedAt).toBeLessThanOrEqual(after) + }) + + it('subsequent save overwrites prior value', async () => { + await saveVocabStatsCache(fxStats({ totalWords: 1 })) + await saveVocabStatsCache(fxStats({ totalWords: 999 })) + expect((await getVocabStatsCache())!.stats.totalWords).toBe(999) + }) +}) + +describe('getVocabStatsCache — defensive reads', () => { + beforeEach(() => MockedStorage.__reset()) + + it('returns null for empty store', async () => { + expect(await getVocabStatsCache()).toBeNull() + }) + + it('returns null for corrupted JSON', async () => { + await AsyncStorage.setItem(STORAGE_KEY, 'not-valid-json{{{') + expect(await getVocabStatsCache()).toBeNull() + }) + + it('returns null when payload missing cachedAt', async () => { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ + stats: fxStats(), + })) + expect(await getVocabStatsCache()).toBeNull() + }) + + it('returns null when payload missing stats object', async () => { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ + cachedAt: Date.now(), + })) + expect(await getVocabStatsCache()).toBeNull() + }) + + it('returns null when stats.totalWords missing (schema drift defense)', async () => { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ + cachedAt: Date.now(), + stats: { dueNow: 5 }, // no totalWords + })) + expect(await getVocabStatsCache()).toBeNull() + }) + + it('returns null when cachedAt is a string (wrong type)', async () => { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ + cachedAt: '2026-05-23', + stats: fxStats(), + })) + expect(await getVocabStatsCache()).toBeNull() + }) +}) + +describe('clearVocabStatsCache', () => { + beforeEach(() => MockedStorage.__reset()) + + it('removes the cache entry', async () => { + await saveVocabStatsCache(fxStats()) + expect(await getVocabStatsCache()).not.toBeNull() + await clearVocabStatsCache() + expect(await getVocabStatsCache()).toBeNull() + }) + + it('does not throw when cache already empty', async () => { + await expect(clearVocabStatsCache()).resolves.not.toThrow() + }) +}) diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts new file mode 100644 index 00000000..04b725b0 --- /dev/null +++ b/apps/mobile/vitest.config.ts @@ -0,0 +1,46 @@ +import { defineConfig } from 'vitest/config' +import { resolve } from 'node:path' + +/** + * Vitest config for mobile — UNIT TESTS ONLY for pure utilities. + * + * Hooks/components are NOT in scope here. They depend on the React Native + * runtime, which doesn't run under Vitest's Node/jsdom envs without a + * full @testing-library/react-native + native-module mock setup. That's + * a separate infra slice — when a real regression demands it, add it then. + * + * For now: pure logic in `src/lib/` is testable, and that's the highest + * ROI per minute of setup. RN modules (AsyncStorage, Linking, Platform) + * get aliased to in-process mocks at the top of this file so tests for + * `features.ts`/`vocabStatsCache.ts` etc. can run without bundling RN. + */ +export default defineConfig({ + test: { + // Default environment is Node — fast and matches the pure-fn target. + // Tests that need DOM-like APIs can opt-in via `// @vitest-environment jsdom`. + environment: 'node', + // Only the lib/ folder is in scope. Apps/components/hooks intentionally + // excluded — see header for rationale. + include: ['src/lib/**/*.test.ts'], + // __DEV__ is a React Native global. Tests may run in Node where it + // doesn't exist; define it so our utility code's `__DEV__ &&` paths + // don't crash. + globals: false, + }, + resolve: { + alias: [ + // AsyncStorage native module → tiny in-memory mock. Lives next to + // the tests so it can be inspected per-test if needed. + { + find: '@react-native-async-storage/async-storage', + replacement: resolve(__dirname, 'src/lib/__mocks__/async-storage.ts'), + }, + ], + }, + define: { + // RN-only global. Mock as false in tests so production-like paths + // run (skip dev logs); flip in a specific test with vi.stubGlobal if + // a __DEV__ branch needs coverage. + __DEV__: false, + }, +}) diff --git a/apps/web/src/lib/analytics.test.ts b/apps/web/src/lib/analytics.test.ts new file mode 100644 index 00000000..80201204 --- /dev/null +++ b/apps/web/src/lib/analytics.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { + track, + trackSignUp, + trackLogin, + trackBookOpened, + trackReadingSessionEnd, + trackVocabSaved, + trackBookUploaded, + trackTranslationUsed, + trackTtsPlayed, + trackSearchPerformed, + trackLandingCtaClick, +} from './analytics' + +// --- Helpers ----------------------------------------------------------- + +/** Captures gtag invocations so we can assert shapes without coupling to GA4. */ +function installGtagSpy() { + const calls: unknown[][] = [] + ;(window as unknown as { gtag: (...args: unknown[]) => void }).gtag = (...args: unknown[]) => { + calls.push(args) + } + return calls +} + +function uninstallGtag() { + delete (window as unknown as { gtag?: unknown }).gtag +} + +describe('analytics.track — safety guarantees', () => { + afterEach(() => uninstallGtag()) + + it('does not throw when gtag is undefined', () => { + uninstallGtag() + expect(() => track('sign_up', { method: 'email' })).not.toThrow() + }) + + it('does not throw when gtag throws internally', () => { + ;(window as unknown as { gtag: (...args: unknown[]) => void }).gtag = () => { + throw new Error('blocker dropped the call') + } + expect(() => track('login', { method: 'google' })).not.toThrow() + }) + + it('forwards event name + params to gtag', () => { + const calls = installGtagSpy() + track('book_opened', { source: 'library', edition_id: 'e1' }) + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual(['event', 'book_opened', { source: 'library', edition_id: 'e1' }]) + }) + + it('passes empty object when params omitted', () => { + const calls = installGtagSpy() + track('search_performed') + expect(calls[0]).toEqual(['event', 'search_performed', {}]) + }) +}) + +// --- Convenience wrappers — shape contract tests ----------------------- +// Senior intent: pin the wire shape going to GA4. Renames here = silent +// dashboard breakage, so the test catches it before deploy. + +describe('trackSignUp', () => { + beforeEach(() => installGtagSpy()) + afterEach(() => uninstallGtag()) + + it('emits sign_up with method', () => { + const calls = installGtagSpy() + trackSignUp('apple') + expect(calls[0]).toEqual(['event', 'sign_up', { method: 'apple' }]) + }) +}) + +describe('trackLogin', () => { + afterEach(() => uninstallGtag()) + + it('emits login with method', () => { + const calls = installGtagSpy() + trackLogin('google') + expect(calls[0]).toEqual(['event', 'login', { method: 'google' }]) + }) +}) + +describe('trackBookOpened', () => { + afterEach(() => uninstallGtag()) + + it('catalog source with editionId', () => { + const calls = installGtagSpy() + trackBookOpened({ source: 'library', editionId: 'ed-1', language: 'en' }) + expect(calls[0]).toEqual(['event', 'book_opened', { + source: 'library', + edition_id: 'ed-1', + user_book_id: undefined, + language: 'en', + }]) + }) + + it('userbook source with userBookId', () => { + const calls = installGtagSpy() + trackBookOpened({ source: 'userbook', userBookId: 'ub-1' }) + expect(calls[0]).toEqual(['event', 'book_opened', { + source: 'userbook', + edition_id: undefined, + user_book_id: 'ub-1', + language: undefined, + }]) + }) + + it('null id fields are normalized to undefined', () => { + const calls = installGtagSpy() + trackBookOpened({ source: 'demo', editionId: null, userBookId: null }) + const params = calls[0][2] as Record + expect(params.edition_id).toBeUndefined() + expect(params.user_book_id).toBeUndefined() + }) +}) + +describe('trackReadingSessionEnd', () => { + afterEach(() => uninstallGtag()) + + it('rounds duration to seconds + computes minutes', () => { + const calls = installGtagSpy() + trackReadingSessionEnd({ + durationSeconds: 372.6, wordsRead: 1500, + startPercent: 0.1234, endPercent: 0.5678, + editionId: 'ed-1', + }) + const params = calls[0][2] as Record + expect(params.duration_seconds).toBe(373) // rounded + expect(params.minutes).toBe(6) // 372.6 / 60 rounded + expect(params.words_read).toBe(1500) + expect(params.start_percent).toBe(0.12) // 2 decimals + expect(params.end_percent).toBe(0.57) + }) + + it('handles zero values', () => { + const calls = installGtagSpy() + trackReadingSessionEnd({ + durationSeconds: 0, wordsRead: 0, startPercent: 0, endPercent: 0, + }) + const params = calls[0][2] as Record + expect(params.duration_seconds).toBe(0) + expect(params.minutes).toBe(0) + }) +}) + +describe('trackVocabSaved', () => { + afterEach(() => uninstallGtag()) + + it('includes nativeLanguage when provided', () => { + const calls = installGtagSpy() + trackVocabSaved({ language: 'en', nativeLanguage: 'uk', source: 'reader' }) + expect(calls[0][2]).toEqual({ language: 'en', native_language: 'uk', source: 'reader' }) + }) + + it('omits nativeLanguage when undefined', () => { + const calls = installGtagSpy() + trackVocabSaved({ language: 'en', source: 'manual' }) + const params = calls[0][2] as Record + expect(params.native_language).toBeUndefined() + }) +}) + +describe('trackBookUploaded', () => { + afterEach(() => uninstallGtag()) + + it('computes size_mb to 1 decimal', () => { + const calls = installGtagSpy() + trackBookUploaded({ format: 'epub', sizeBytes: 1_572_864 }) // 1.5 MB exact + const params = calls[0][2] as Record + expect(params.format).toBe('epub') + expect(params.size_bytes).toBe(1_572_864) + expect(params.size_mb).toBe(1.5) + }) + + it('rounds size_mb correctly', () => { + const calls = installGtagSpy() + trackBookUploaded({ format: 'pdf', sizeBytes: 5_242_880 }) // 5.0 MB exact + expect((calls[0][2] as Record).size_mb).toBe(5) + }) +}) + +describe('trackTranslationUsed', () => { + afterEach(() => uninstallGtag()) + + it('emits with from/to lang and kind', () => { + const calls = installGtagSpy() + trackTranslationUsed({ fromLang: 'fr', toLang: 'en', kind: 'word' }) + expect(calls[0][2]).toEqual({ from_lang: 'fr', to_lang: 'en', kind: 'word' }) + }) +}) + +describe('trackTtsPlayed', () => { + afterEach(() => uninstallGtag()) + + it('emits with language and kind', () => { + const calls = installGtagSpy() + trackTtsPlayed({ language: 'de', kind: 'sentence' }) + expect(calls[0][2]).toEqual({ language: 'de', kind: 'sentence' }) + }) +}) + +describe('trackSearchPerformed', () => { + afterEach(() => uninstallGtag()) + + it('truncates query to 100 chars (cardinality control)', () => { + const calls = installGtagSpy() + const longQuery = 'a'.repeat(500) + trackSearchPerformed({ query: longQuery, resultsCount: 12 }) + const params = calls[0][2] as Record + expect((params.query as string).length).toBe(100) + expect(params.results_count).toBe(12) + }) + + it('short query passes through', () => { + const calls = installGtagSpy() + trackSearchPerformed({ query: 'dracula' }) + const params = calls[0][2] as Record + expect(params.query).toBe('dracula') + }) +}) + +describe('trackLandingCtaClick', () => { + afterEach(() => uninstallGtag()) + + it('emits page + label', () => { + const calls = installGtagSpy() + trackLandingCtaClick({ page: '/landing', label: 'Try free' }) + expect(calls[0][2]).toEqual({ page: '/landing', label: 'Try free' }) + }) +}) diff --git a/apps/web/src/lib/dataEvents.test.ts b/apps/web/src/lib/dataEvents.test.ts new file mode 100644 index 00000000..e4c24e3a --- /dev/null +++ b/apps/web/src/lib/dataEvents.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { emitDataChange, emitDataChanges, useDataChange } from './dataEvents' + +describe('emitDataChange', () => { + let received: { entity: string; detail: unknown }[] = [] + let handlers: Array<{ name: string; fn: EventListener }> = [] + + beforeEach(() => { + received = [] + handlers = [] + }) + + afterEach(() => { + for (const { name, fn } of handlers) window.removeEventListener(name, fn) + }) + + function listen(entity: string) { + const handler: EventListener = (e) => { + received.push({ entity, detail: (e as CustomEvent).detail }) + } + const name = 'textstack:data:' + entity + handlers.push({ name, fn: handler }) + window.addEventListener(name, handler) + } + + it('dispatches a CustomEvent on the prefixed channel', () => { + listen('vocabulary') + emitDataChange('vocabulary') + expect(received).toHaveLength(1) + expect(received[0].entity).toBe('vocabulary') + }) + + it('passes the detail payload through to listeners', () => { + listen('library') + emitDataChange('library', { reason: 'book-saved', id: 'ed-1' }) + expect(received[0].detail).toEqual({ reason: 'book-saved', id: 'ed-1' }) + }) + + it('listeners on a different entity do not fire', () => { + listen('vocabulary') + emitDataChange('library') + expect(received).toHaveLength(0) + }) +}) + +describe('emitDataChanges (multi-entity)', () => { + let received: string[] = [] + let handlers: Array<{ name: string; fn: EventListener }> = [] + + beforeEach(() => { + received = [] + handlers = [] + }) + + afterEach(() => { + for (const { name, fn } of handlers) window.removeEventListener(name, fn) + }) + + function listen(entity: string) { + const handler: EventListener = () => received.push(entity) + const name = 'textstack:data:' + entity + handlers.push({ name, fn: handler }) + window.addEventListener(name, handler) + } + + it('fires events for each entity in array', () => { + listen('user-books') + listen('shelves') + emitDataChanges(['user-books', 'shelves']) + expect(received.sort()).toEqual(['shelves', 'user-books']) + }) + + it('passes detail to all listeners', () => { + let lastDetail: unknown + window.addEventListener('textstack:data:tags', e => { + lastDetail = (e as CustomEvent).detail + }, { once: true }) + emitDataChanges(['tags'], { id: 'tag-1' }) + expect(lastDetail).toEqual({ id: 'tag-1' }) + }) +}) + +describe('useDataChange hook', () => { + it('fires callback when matching entity changes', () => { + let fired = 0 + const { unmount } = renderHook(() => useDataChange('highlights', () => { fired++ })) + emitDataChange('highlights') + expect(fired).toBe(1) + emitDataChange('highlights') + expect(fired).toBe(2) + unmount() + }) + + it('does NOT fire for unrelated entity', () => { + let fired = 0 + const { unmount } = renderHook(() => useDataChange('bookmarks', () => { fired++ })) + emitDataChange('vocabulary') + expect(fired).toBe(0) + unmount() + }) + + it('accepts array of entities — fires for any of them', () => { + let fired = 0 + const { unmount } = renderHook(() => + useDataChange(['library', 'user-books'], () => { fired++ }) + ) + emitDataChange('library') + emitDataChange('user-books') + emitDataChange('vocabulary') // not in array — should not fire + expect(fired).toBe(2) + unmount() + }) + + it('removes listener on unmount (no leak)', () => { + let fired = 0 + const { unmount } = renderHook(() => useDataChange('collections', () => { fired++ })) + emitDataChange('collections') + expect(fired).toBe(1) + unmount() + emitDataChange('collections') + expect(fired).toBe(1) // not incremented after unmount + }) + + it('emitDataChange is a no-op when window undefined (SSR safety)', () => { + // Can't actually unset window in jsdom — just check the no-throw contract. + expect(() => emitDataChange('reading-progress')).not.toThrow() + }) +}) diff --git a/apps/web/src/lib/errorUtils.test.ts b/apps/web/src/lib/errorUtils.test.ts new file mode 100644 index 00000000..ace582f2 --- /dev/null +++ b/apps/web/src/lib/errorUtils.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest' +import { isNotFoundError } from './errorUtils' +import { HttpError } from './fetchWithRetry' + +describe('isNotFoundError', () => { + it('returns true for HttpError(404)', () => { + expect(isNotFoundError(new HttpError(404, 'Not Found'))).toBe(true) + }) + + it('returns false for HttpError with other status', () => { + expect(isNotFoundError(new HttpError(500, 'Server Error'))).toBe(false) + expect(isNotFoundError(new HttpError(401, 'Unauthorized'))).toBe(false) + expect(isNotFoundError(new HttpError(403, 'Forbidden'))).toBe(false) + expect(isNotFoundError(new HttpError(400, 'Bad Request'))).toBe(false) + }) + + it('returns false for plain Error', () => { + expect(isNotFoundError(new Error('any'))).toBe(false) + }) + + it('returns false for TypeError', () => { + expect(isNotFoundError(new TypeError('network'))).toBe(false) + }) + + it('returns false for non-Error values (defensive boundary)', () => { + expect(isNotFoundError(null)).toBe(false) + expect(isNotFoundError(undefined)).toBe(false) + expect(isNotFoundError('string')).toBe(false) + expect(isNotFoundError(404)).toBe(false) + expect(isNotFoundError({ status: 404 })).toBe(false) // duck-typed obj is NOT HttpError + }) +}) diff --git a/apps/web/src/lib/formatTime.test.ts b/apps/web/src/lib/formatTime.test.ts new file mode 100644 index 00000000..ca173f4b --- /dev/null +++ b/apps/web/src/lib/formatTime.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { formatTime } from './formatTime' + +describe('formatTime', () => { + it('0 seconds → "0m"', () => { + expect(formatTime(0)).toBe('0m') + }) + + it('seconds under a minute → "0m"', () => { + expect(formatTime(45)).toBe('0m') + }) + + it('exact minutes → "Nm"', () => { + expect(formatTime(60)).toBe('1m') + expect(formatTime(180)).toBe('3m') + }) + + it('minutes with leftover seconds → floors to minutes', () => { + expect(formatTime(89)).toBe('1m') // 1m29s + expect(formatTime(119)).toBe('1m') + expect(formatTime(125)).toBe('2m') + }) + + it('exact hour → "1h 0m"', () => { + expect(formatTime(3600)).toBe('1h 0m') + }) + + it('hours and minutes', () => { + expect(formatTime(3660)).toBe('1h 1m') // 1h 1m + expect(formatTime(5400)).toBe('1h 30m') // 1h 30m + expect(formatTime(7200)).toBe('2h 0m') // 2h + expect(formatTime(7320)).toBe('2h 2m') // 2h 2m + }) + + it('multi-hour readings', () => { + expect(formatTime(3600 * 12)).toBe('12h 0m') + expect(formatTime(3600 * 100)).toBe('100h 0m') + }) + + it('drops sub-minute seconds in hour mode', () => { + expect(formatTime(3659)).toBe('1h 0m') // 1h 0m 59s → floors leftover seconds + }) +}) diff --git a/backend/src/Api/Endpoints/VocabularyEndpoints.Admin.cs b/backend/src/Api/Endpoints/VocabularyEndpoints.Admin.cs new file mode 100644 index 00000000..f8f7915d --- /dev/null +++ b/backend/src/Api/Endpoints/VocabularyEndpoints.Admin.cs @@ -0,0 +1,58 @@ +using Application.Common.Interfaces; +using Microsoft.EntityFrameworkCore; +using TextStack.Vocabulary; + +namespace Api.Endpoints; + +/// +/// Admin-side maintenance endpoints. Currently just BackfillDefinitions — +/// crawls VocabularyWords missing a Definition and asks the configured +/// enricher to fill them. Rate-limited by `Task.Delay(200)` per item to +/// be polite to the Free Dictionary API. +/// +public static partial class VocabularyEndpoints +{ + private static async Task BackfillDefinitions( + IAppDbContext db, + IDefinitionEnricher enricher, + ILogger logger, + CancellationToken ct) + { + var words = await db.VocabularyWords + .Where(w => w.Definition == null || w.Definition == "") + .OrderBy(w => w.CreatedAt) + .Take(500) + .ToListAsync(ct); + + var enriched = 0; + var failed = 0; + + foreach (var w in words) + { + try + { + var def = await enricher.FetchDefinitionAsync(w.Word, w.Language, ct); + if (def != null) + { + w.Definition = def; + enriched++; + } + else + { + failed++; + } + // Rate limit: Free Dictionary API + await Task.Delay(200, ct); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Backfill failed for word {Word}", w.Word); + failed++; + } + } + + await db.SaveChangesAsync(ct); + + return Results.Ok(new { total = words.Count, enriched, failed }); + } +} diff --git a/backend/src/Api/Endpoints/VocabularyEndpoints.Clusters.cs b/backend/src/Api/Endpoints/VocabularyEndpoints.Clusters.cs new file mode 100644 index 00000000..3d848a22 --- /dev/null +++ b/backend/src/Api/Endpoints/VocabularyEndpoints.Clusters.cs @@ -0,0 +1,137 @@ +using Application.Auth; +using Application.Common.Interfaces; +using Application.Vocabulary; +using Microsoft.EntityFrameworkCore; +using TextStack.Vocabulary; +using TextStack.Vocabulary.Contracts; + +namespace Api.Endpoints; + +/// +/// Thematic word clusters (Phase 4 F3) bonus rounds: LLM-grouped sets of +/// related vocabulary the user can opt into for an extra MC quiz pass. +/// +public static partial class VocabularyEndpoints +{ + private static async Task GetClusters( + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var clusters = await db.WordClusters + .Where(c => c.UserId == userId + && c.SiteId == siteId + && !c.IsDismissed + && c.CompletedAt == null) + .OrderByDescending(c => c.CreatedAt) + .Take(10) + .Select(c => new WordClusterDto( + c.Id, c.Title, c.Theme, + c.EditionId, c.UserBookId, c.BookTitle, + c.MemberCount, c.CohesionScore, + c.IsConfirmed, c.CreatedAt)) + .ToListAsync(ct); + + return Results.Ok(new { items = clusters }); + } + + private static async Task StartClusterBonus( + Guid id, + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + IReviewCardBuilder cardBuilder, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var cluster = await db.WordClusters + .FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId && c.SiteId == siteId, ct); + if (cluster == null) return Results.NotFound(); + if (cluster.IsDismissed || cluster.CompletedAt != null) return Results.Conflict(); + + // Load cluster members. Retired members are skipped — they graduated. + var members = await db.VocabularyWords + .Where(w => w.ClusterId == id + && w.UserId == userId + && w.SiteId == siteId + && !w.IsRetired) + .ToListAsync(ct); + + if (members.Count == 0) return Results.NotFound(); + + // Distractor pool from user's other words (not in cluster). + var languages = members.Select(w => w.Language).Distinct().ToList(); + var memberIds = members.Select(w => w.Id).ToHashSet(); + var pool = await db.VocabularyWords + .Where(w => w.UserId == userId && w.SiteId == siteId + && !memberIds.Contains(w.Id) + && languages.Contains(w.Language)) + .OrderBy(_ => EF.Functions.Random()) + .Take(MaxDistractorPoolSize) + .Select(w => new DistractorPoolEntry(w.Word, w.Language)) + .ToListAsync(ct); + + var wordsForReview = members.Select(w => new WordForReview( + w.Id, w.Word, w.Language, + w.Translation, w.Definition, + w.Sentence, w.BookTitle, w.Hint, + w.Explanation, w.Distractors, + w.Stage, w.TotalReviews)).ToList(); + + var cards = cardBuilder.BuildCards(wordsForReview, pool); + var cardDtos = cards.Select(c => new ReviewCardDto( + c.WordId, c.Word, c.Translation, c.Definition, + c.ReviewMode, c.BlankSentence, c.OriginalSentence, c.BookTitle, + c.Hint, c.Explanation, c.IsNew, c.Options, c.CorrectOptionIndex)).ToList(); + + cluster.IsConfirmed = true; + await db.SaveChangesAsync(ct); + + return Results.Ok(new { clusterId = cluster.Id, title = cluster.Title, cards = cardDtos }); + } + + private static async Task DismissCluster( + Guid id, + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var cluster = await db.WordClusters + .FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId && c.SiteId == siteId, ct); + if (cluster == null) return Results.NotFound(); + + cluster.IsDismissed = true; + cluster.DismissedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + return Results.NoContent(); + } + + private static async Task CompleteCluster( + Guid id, + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var cluster = await db.WordClusters + .FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId && c.SiteId == siteId, ct); + if (cluster == null) return Results.NotFound(); + + cluster.CompletedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + return Results.NoContent(); + } +} diff --git a/backend/src/Api/Endpoints/VocabularyEndpoints.Lookups.cs b/backend/src/Api/Endpoints/VocabularyEndpoints.Lookups.cs new file mode 100644 index 00000000..89afa751 --- /dev/null +++ b/backend/src/Api/Endpoints/VocabularyEndpoints.Lookups.cs @@ -0,0 +1,149 @@ +using Application.Auth; +using Application.Common.Interfaces; +using Domain.Entities; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Api.Endpoints; + +/// +/// Word lookups (Phase 3 F1): rare/OOV taps land here instead of polluting +/// the SRS queue. "Add anyway" promotes a lookup to VocabularyWord +/// (bypasses daily cap, respects the 5000-word hard ceiling). +/// +public static partial class VocabularyEndpoints +{ + private static async Task GetLookups( + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + [FromQuery] int? limit, + [FromQuery] int? offset, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var take = Math.Clamp(limit ?? 50, 1, 200); + var skip = Math.Max(0, offset ?? 0); + + var baseQuery = db.WordLookups + .Where(l => l.UserId == userId && l.SiteId == siteId); + + var total = await baseQuery.CountAsync(ct); + var items = await baseQuery + .OrderByDescending(l => l.LastTappedAt) + .Skip(skip) + .Take(take) + .Select(l => new WordLookupDto( + l.Id, l.Word, l.Language, l.ZipfRank, l.TapCount, + l.Sentence, l.BookTitle, l.EditionId, l.ChapterId, l.UserBookId, + l.LastTranslation, l.FirstTappedAt, l.LastTappedAt)) + .ToListAsync(ct); + + return Results.Ok(new WordLookupListResponse(items, total)); + } + + // "Add anyway" — user overrides the frequency filter. Creates a VocabularyWord + // directly and drops the Lookup. Bypasses the daily cap on purpose: the user + // explicitly asked for this word. + private static async Task PromoteLookup( + Guid id, + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + IServiceScopeFactory scopeFactory, + ILogger logger, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var lookup = await db.WordLookups + .FirstOrDefaultAsync(l => l.Id == id && l.UserId == userId && l.SiteId == siteId, ct); + if (lookup == null) return Results.NotFound(); + + // Guard against a race where the word was saved via another path between + // the list render and the promote click. + var already = await db.VocabularyWords + .FirstOrDefaultAsync(w => w.UserId == userId && w.SiteId == siteId + && w.Word == lookup.Word && w.Language == lookup.Language, ct); + if (already != null) + { + db.WordLookups.Remove(lookup); + await db.SaveChangesAsync(ct); + return Results.Ok(ToDto(already)); + } + + // Hard ceiling still applies — Add Anyway bypasses the daily cap, not + // the 5000-word vocabulary limit. Counts both active SRS + pending + // so a user can't exceed the cap via the lookup bypass. + var count = await db.VocabularyWords.CountAsync( + w => w.UserId == userId && w.SiteId == siteId, ct); + count += await db.PendingVocabularyWords.CountAsync( + p => p.UserId == userId && p.SiteId == siteId, ct); + if (count >= MaxWordsPerUser) + return Results.Problem("Vocabulary limit reached (5000 words)", statusCode: 429); + + var now = DateTimeOffset.UtcNow; + var entry = new VocabularyWord + { + Id = Guid.NewGuid(), + UserId = userId, + SiteId = siteId, + Word = lookup.Word, + Language = lookup.Language, + Translation = lookup.LastTranslation, + EditionId = lookup.EditionId, + ChapterId = lookup.ChapterId, + UserBookId = lookup.UserBookId, + Sentence = lookup.Sentence, + BookTitle = lookup.BookTitle, + ZipfRank = lookup.ZipfRank, + Source = "manual_add_anyway", + ActivatedAt = now, + Stage = 0, + IntervalDays = 0, + ConsecutiveCorrect = 0, + NextReviewAt = now, + TotalReviews = 0, + CorrectReviews = 0, + CreatedAt = now, + UpdatedAt = now, + }; + db.VocabularyWords.Add(entry); + db.WordLookups.Remove(lookup); + await db.SaveChangesAsync(ct); + + var nativeLang = await db.Users + .Where(u => u.Id == userId) + .Select(u => u.NativeLanguage) + .FirstOrDefaultAsync(ct); + if (!string.IsNullOrWhiteSpace(nativeLang)) + { + QueueEnrichment(scopeFactory, logger, entry.Id, entry.Word, entry.Language, + entry.Definition, entry.Sentence, nativeLang); + } + + return Results.Ok(ToDto(entry)); + } + + private static async Task DismissLookup( + Guid id, + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var lookup = await db.WordLookups + .FirstOrDefaultAsync(l => l.Id == id && l.UserId == userId && l.SiteId == siteId, ct); + if (lookup == null) return Results.NotFound(); + + db.WordLookups.Remove(lookup); + await db.SaveChangesAsync(ct); + return Results.NoContent(); + } +} diff --git a/backend/src/Api/Endpoints/VocabularyEndpoints.Pending.cs b/backend/src/Api/Endpoints/VocabularyEndpoints.Pending.cs new file mode 100644 index 00000000..7c16465b --- /dev/null +++ b/backend/src/Api/Endpoints/VocabularyEndpoints.Pending.cs @@ -0,0 +1,97 @@ +using Application.Auth; +using Application.Common.Interfaces; +using Application.Vocabulary; +using Microsoft.EntityFrameworkCore; + +namespace Api.Endpoints; + +/// +/// Anti-spiral pending buffer (Phase 2): over-cap word saves land in +/// `PendingVocabularyWords`; the daily-cap roller promotes them, or the +/// user can manually `Promote`/`Dismiss`. +/// +public static partial class VocabularyEndpoints +{ + private static async Task GetPending( + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + DailyCapService dailyCap, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var items = await db.PendingVocabularyWords + .Where(p => p.UserId == userId && p.SiteId == siteId) + .OrderByDescending(p => p.Priority) + .ThenBy(p => p.CreatedAt) + .Select(p => new PendingVocabWordDto( + p.Id, p.Word, p.Language, p.Translation, p.Definition, + p.EditionId, p.ChapterId, p.UserBookId, + p.Sentence, p.BookTitle, p.Priority, p.Source, p.CreatedAt)) + .ToListAsync(ct); + + var cap = await dailyCap.GetStatusAsync(userId, siteId, ct); + return Results.Ok(new PendingListResponse(items, cap.Used, cap.Cap, cap.Remaining)); + } + + // Manual promote — bypasses the daily cap. User explicitly asked for this + // word to skip the queue; respect it even if today is "full". + private static async Task PromotePending( + Guid id, + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + DailyCapService dailyCap, + IServiceScopeFactory scopeFactory, + ILogger logger, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var pending = await db.PendingVocabularyWords + .FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId && p.SiteId == siteId, ct); + if (pending == null) return Results.NotFound(); + + // Capture enrichment inputs before PromoteAsync disposes the pending row. + var wordText = pending.Word; + var lang = pending.Language; + var def = pending.Definition; + var sent = pending.Sentence; + + var now = DateTimeOffset.UtcNow; + var promoted = await dailyCap.PromoteAsync(pending, now, ct); + + var nativeLang = await db.Users + .Where(u => u.Id == userId) + .Select(u => u.NativeLanguage) + .FirstOrDefaultAsync(ct); + if (!string.IsNullOrWhiteSpace(nativeLang)) + { + QueueEnrichment(scopeFactory, logger, promoted.Id, wordText, lang, def, sent, nativeLang); + } + + return Results.Ok(ToDto(promoted)); + } + + private static async Task DismissPending( + Guid id, + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var pending = await db.PendingVocabularyWords + .FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId && p.SiteId == siteId, ct); + if (pending == null) return Results.NotFound(); + + db.PendingVocabularyWords.Remove(pending); + await db.SaveChangesAsync(ct); + return Results.NoContent(); + } +} diff --git a/backend/src/Api/Endpoints/VocabularyEndpoints.Settings.cs b/backend/src/Api/Endpoints/VocabularyEndpoints.Settings.cs new file mode 100644 index 00000000..c8ff2189 --- /dev/null +++ b/backend/src/Api/Endpoints/VocabularyEndpoints.Settings.cs @@ -0,0 +1,113 @@ +using Application.Auth; +using Application.Common.Interfaces; +using Domain.Entities; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Api.Endpoints; + +/// +/// Anti-spiral settings (DailyNewCap / WeeklyReviewBudget / frequency +/// filter / clustering / auto-retire) and the Unretire flow that pulls +/// a word back into review without restarting at Stage 0. +/// +public static partial class VocabularyEndpoints +{ + private static async Task GetSettings( + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var settings = await db.UserVocabularySettings + .FirstOrDefaultAsync(s => s.UserId == userId && s.SiteId == siteId, ct); + + // First-time read: return defaults without persisting — settings row is + // created lazily on first PUT to avoid a write on every new user. + return Results.Ok(new VocabSettingsDto( + DailyNewCap: settings?.DailyNewCap ?? 15, + WeeklyReviewBudget: settings?.WeeklyReviewBudget ?? 70, + FrequencyFilterEnabled: settings?.FrequencyFilterEnabled ?? true, + ClusteringEnabled: settings?.ClusteringEnabled ?? true, + AutoRetireEnabled: settings?.AutoRetireEnabled ?? true)); + } + + private static async Task UpdateSettings( + [FromBody] VocabSettingsDto request, + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + if (request.DailyNewCap is < 5 or > 100) + return Results.BadRequest("DailyNewCap must be 5–100"); + if (request.WeeklyReviewBudget is < 10 or > 500) + return Results.BadRequest("WeeklyReviewBudget must be 10–500"); + + var settings = await db.UserVocabularySettings + .FirstOrDefaultAsync(s => s.UserId == userId && s.SiteId == siteId, ct); + var now = DateTimeOffset.UtcNow; + + if (settings is null) + { + settings = new UserVocabularySettings + { + UserId = userId, + SiteId = siteId, + CreatedAt = now, + }; + db.UserVocabularySettings.Add(settings); + } + + settings.DailyNewCap = request.DailyNewCap; + settings.WeeklyReviewBudget = request.WeeklyReviewBudget; + settings.FrequencyFilterEnabled = request.FrequencyFilterEnabled; + settings.ClusteringEnabled = request.ClusteringEnabled; + settings.AutoRetireEnabled = request.AutoRetireEnabled; + settings.UpdatedAt = now; + + await db.SaveChangesAsync(ct); + return Results.Ok(request); + } + + // --- Unretire (Phase 1) --- + + private static async Task UnretireWord( + Guid id, + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var word = await FindUserWordAsync(db, id, userId, siteId, ct); + if (word == null) return Results.NotFound(); + + if (!word.IsRetired) return Results.Ok(ToDto(word)); + + // Drop back to Stage 3 (Context), not Stage 0 — the user once mastered + // this word, so we're only asking for one resurfacing review, not + // starting from scratch. ConsecutiveCorrect resets so the 3/14d + // retirement rule has to be re-earned. + var now = DateTimeOffset.UtcNow; + word.IsRetired = false; + word.RetiredAt = null; + word.RetiredReason = null; + word.Stage = 3; + word.ConsecutiveCorrect = 0; + word.IntervalDays = 1; + word.NextReviewAt = now; + word.UpdatedAt = now; + + await db.SaveChangesAsync(ct); + return Results.Ok(ToDto(word)); + } +} diff --git a/backend/src/Api/Endpoints/VocabularyEndpoints.Stats.cs b/backend/src/Api/Endpoints/VocabularyEndpoints.Stats.cs new file mode 100644 index 00000000..38322512 --- /dev/null +++ b/backend/src/Api/Endpoints/VocabularyEndpoints.Stats.cs @@ -0,0 +1,224 @@ +using Application.Auth; +using Application.Common.Interfaces; +using Application.Vocabulary; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Api.Endpoints; + +/// +/// Stats endpoints — overview (`GET /me/vocabulary/stats`) for the home +/// card + per-day aggregation (`GET /me/vocabulary/stats/daily`) for the +/// calendar heatmap. Pure read-side; no writes. +/// +public static partial class VocabularyEndpoints +{ + private static async Task GetStats( + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + WeeklyBudgetService weeklyBudget, + DailyCapService dailyCap, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var now = DateTimeOffset.UtcNow; + var todayStart = new DateTimeOffset(now.Date, TimeSpan.Zero); + + // Base scope: everything the user owns. Stage breakdown + totalWords + // keep retired rows (user still wants to see "2000 mastered" even if + // they no longer appear in the queue). + var words = db.VocabularyWords + .Where(w => w.UserId == userId && w.SiteId == siteId); + + var totalWords = await words.CountAsync(ct); + + var byStage = await words + .GroupBy(w => w.Stage) + .Select(g => new { Stage = g.Key, Count = g.Count() }) + .ToListAsync(ct); + + var stageDict = byStage.ToDictionary(s => s.Stage, s => s.Count); + // dueNow intentionally excludes retired rows — matches what the review + // queue actually returns, so the banner never over-promises work. + var dueNow = await words.CountAsync(w => !w.IsRetired && w.NextReviewAt <= now, ct); + var retiredCount = await words.CountAsync(w => w.IsRetired, ct); + + // Single query for today's review stats + var todayStats = await db.VocabularyReviews + .Where(r => r.UserId == userId && r.SiteId == siteId && r.CreatedAt >= todayStart) + .GroupBy(_ => 1) + .Select(g => new + { + Total = g.Count(), + Correct = g.Count(r => r.IsCorrect), + Practice = g.Count(r => r.ReviewMode.StartsWith("practice_")), + PracticeCorrect = g.Count(r => r.ReviewMode.StartsWith("practice_") && r.IsCorrect), + }) + .FirstOrDefaultAsync(ct); + + var reviewedToday = todayStats?.Total ?? 0; + var correctToday = todayStats?.Correct ?? 0; + var practiceToday = todayStats?.Practice ?? 0; + var practiceCorrectToday = todayStats?.PracticeCorrect ?? 0; + var srsReviewedToday = reviewedToday - practiceToday; + var srsCorrectToday = correctToday - practiceCorrectToday; + + // Single query for all-time review stats + var allStats = await db.VocabularyReviews + .Where(r => r.UserId == userId && r.SiteId == siteId) + .GroupBy(_ => 1) + .Select(g => new { Total = g.Count(), Correct = g.Count(r => r.IsCorrect) }) + .FirstOrDefaultAsync(ct); + + var totalReviews = allStats?.Total ?? 0; + var totalCorrect = allStats?.Correct ?? 0; + + // Streak: consecutive days with reviews (HashSet for O(1) lookup) + var reviewDays = (await db.VocabularyReviews + .Where(r => r.UserId == userId && r.SiteId == siteId) + .Select(r => r.CreatedAt.Date) + .Distinct() + .OrderByDescending(d => d) + .Take(365) + .ToListAsync(ct)) + .ToHashSet(); + + var streak = 0; + var checkDate = now.Date; + if (!reviewDays.Contains(checkDate)) + checkDate = checkDate.AddDays(-1); + while (reviewDays.Contains(checkDate)) + { + streak++; + checkDate = checkDate.AddDays(-1); + } + + var wordsByBook = await words + .Where(w => w.BookTitle != null) + .GroupBy(w => new { w.EditionId, w.UserBookId, w.BookTitle }) + .Select(g => new { g.Key.EditionId, g.Key.UserBookId, g.Key.BookTitle, Count = g.Count() }) + .OrderByDescending(b => b.Count) + .Take(20) + .ToListAsync(ct); + + var weeklyProgress = ToDto(await weeklyBudget.GetProgressAsync(userId, siteId, ct)); + var capStatus = await dailyCap.GetStatusAsync(userId, siteId, ct); + var pendingCount = await db.PendingVocabularyWords + .CountAsync(p => p.UserId == userId && p.SiteId == siteId, ct); + var lookupCount = await db.WordLookups + .CountAsync(l => l.UserId == userId && l.SiteId == siteId, ct); + var clusterCount = await db.WordClusters + .CountAsync(c => c.UserId == userId && c.SiteId == siteId + && !c.IsDismissed && c.CompletedAt == null, ct); + + return Results.Ok(new + { + totalWords, + byStage = new + { + @new = stageDict.GetValueOrDefault(0), + recognition = stageDict.GetValueOrDefault(1), + recall = stageDict.GetValueOrDefault(2), + context = stageDict.GetValueOrDefault(3), + mastered = stageDict.GetValueOrDefault(4), + }, + dueNow, + retiredCount, + pendingCount, + lookupCount, + clusterCount, + weeklyProgress, + dailyCap = new { used = capStatus.Used, cap = capStatus.Cap, remaining = capStatus.Remaining }, + reviewedToday, + correctRateToday = reviewedToday > 0 ? Math.Round((double)correctToday / reviewedToday * 100, 1) : 0, + srsReviewedToday, + srsCorrectRateToday = srsReviewedToday > 0 ? Math.Round((double)srsCorrectToday / srsReviewedToday * 100, 1) : 0, + practicedToday = practiceToday, + practiceCorrectRateToday = practiceToday > 0 ? Math.Round((double)practiceCorrectToday / practiceToday * 100, 1) : 0, + totalReviews, + overallCorrectRate = totalReviews > 0 ? Math.Round((double)totalCorrect / totalReviews * 100, 1) : 0, + streak, + wordsByBook, + }); + } + + // --- Daily Stats --- + + private static TimeSpan ParseTzOffset(string? tz) + { + if (string.IsNullOrEmpty(tz)) return TimeSpan.Zero; + if (int.TryParse(tz, out var minutes)) + return TimeSpan.FromMinutes(minutes); + return TimeSpan.Zero; + } + + private static async Task GetDailyStats( + HttpContext httpContext, + AuthService authService, + IAppDbContext db, + [FromQuery] DateTimeOffset? from, + [FromQuery] DateTimeOffset? to, + [FromQuery] string? tz, + CancellationToken ct) + { + if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) + return Results.Unauthorized(); + + var tzOffset = ParseTzOffset(tz); + var now = DateTimeOffset.UtcNow; + var start = from ?? now.AddDays(-365); + var end = to ?? now; + + // Reviews per day + var reviews = await db.VocabularyReviews + .Where(r => r.UserId == userId && r.SiteId == siteId + && r.CreatedAt >= start && r.CreatedAt <= end) + .Select(r => new { r.CreatedAt, r.IsCorrect, r.ReviewMode }) + .ToListAsync(ct); + + var reviewsByDay = reviews + .GroupBy(r => r.CreatedAt.ToOffset(tzOffset).Date) + .ToDictionary( + g => g.Key, + g => new + { + ReviewCount = g.Count(), + CorrectCount = g.Count(r => r.IsCorrect), + PracticeCount = g.Count(r => r.ReviewMode.StartsWith("practice_")), + SrsCount = g.Count(r => !r.ReviewMode.StartsWith("practice_")), + }); + + // Words added per day + var words = await db.VocabularyWords + .Where(w => w.UserId == userId && w.SiteId == siteId + && w.CreatedAt >= start && w.CreatedAt <= end) + .Select(w => w.CreatedAt) + .ToListAsync(ct); + + var wordsByDay = words + .GroupBy(d => d.ToOffset(tzOffset).Date) + .ToDictionary(g => g.Key, g => g.Count()); + + // Merge all dates + var allDates = reviewsByDay.Keys.Union(wordsByDay.Keys).OrderBy(d => d); + + var result = allDates.Select(date => + { + reviewsByDay.TryGetValue(date, out var r); + return new + { + date, + wordsAdded = wordsByDay.GetValueOrDefault(date, 0), + reviewCount = r?.ReviewCount ?? 0, + correctCount = r?.CorrectCount ?? 0, + practiceCount = r?.PracticeCount ?? 0, + srsCount = r?.SrsCount ?? 0, + }; + }).ToList(); + + return Results.Ok(result); + } +} diff --git a/backend/src/Api/Endpoints/VocabularyEndpoints.cs b/backend/src/Api/Endpoints/VocabularyEndpoints.cs index ec870708..f8903ec3 100644 --- a/backend/src/Api/Endpoints/VocabularyEndpoints.cs +++ b/backend/src/Api/Endpoints/VocabularyEndpoints.cs @@ -13,7 +13,24 @@ namespace Api.Endpoints; -public static class VocabularyEndpoints +/// +/// Vocabulary HTTP surface. Routes registered via ; +/// handlers are split across partial files by sub-domain to keep each +/// file under ~300 LOC and reviewable in isolation: +/// +/// - VocabularyEndpoints.Stats.cs GetStats, GetDailyStats, ParseTzOffset +/// - VocabularyEndpoints.Settings.cs GetSettings, UpdateSettings, UnretireWord +/// - VocabularyEndpoints.Pending.cs GetPending, PromotePending, DismissPending +/// - VocabularyEndpoints.Lookups.cs GetLookups, PromoteLookup, DismissLookup +/// - VocabularyEndpoints.Clusters.cs GetClusters, StartClusterBonus, DismissCluster, CompleteCluster +/// - VocabularyEndpoints.Admin.cs BackfillDefinitions +/// +/// Everything else (SaveWord + Words CRUD + Review + helpers + DTOs) +/// stays in this file. Splits use C# `partial` — compile-identical to +/// the original monolithic file. Shared helpers (TryGetAuth, ToDto, +/// UpsertLookupAsync, FindUserWordAsync, QueueEnrichment) live here. +/// +public static partial class VocabularyEndpoints { private const int MaxWordsPerUser = 5000; private const int MaxDistractorPoolSize = 200; @@ -639,263 +656,6 @@ private static async Task SubmitReview( newInterval, word.NextReviewAt, word.TotalReviews, word.CorrectReviews)); } - // --- Stats --- - - private static async Task GetStats( - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - WeeklyBudgetService weeklyBudget, - DailyCapService dailyCap, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var now = DateTimeOffset.UtcNow; - var todayStart = new DateTimeOffset(now.Date, TimeSpan.Zero); - - // Base scope: everything the user owns. Stage breakdown + totalWords - // keep retired rows (user still wants to see "2000 mastered" even if - // they no longer appear in the queue). - var words = db.VocabularyWords - .Where(w => w.UserId == userId && w.SiteId == siteId); - - var totalWords = await words.CountAsync(ct); - - var byStage = await words - .GroupBy(w => w.Stage) - .Select(g => new { Stage = g.Key, Count = g.Count() }) - .ToListAsync(ct); - - var stageDict = byStage.ToDictionary(s => s.Stage, s => s.Count); - // dueNow intentionally excludes retired rows — matches what the review - // queue actually returns, so the banner never over-promises work. - var dueNow = await words.CountAsync(w => !w.IsRetired && w.NextReviewAt <= now, ct); - var retiredCount = await words.CountAsync(w => w.IsRetired, ct); - - // Single query for today's review stats - var todayStats = await db.VocabularyReviews - .Where(r => r.UserId == userId && r.SiteId == siteId && r.CreatedAt >= todayStart) - .GroupBy(_ => 1) - .Select(g => new - { - Total = g.Count(), - Correct = g.Count(r => r.IsCorrect), - Practice = g.Count(r => r.ReviewMode.StartsWith("practice_")), - PracticeCorrect = g.Count(r => r.ReviewMode.StartsWith("practice_") && r.IsCorrect), - }) - .FirstOrDefaultAsync(ct); - - var reviewedToday = todayStats?.Total ?? 0; - var correctToday = todayStats?.Correct ?? 0; - var practiceToday = todayStats?.Practice ?? 0; - var practiceCorrectToday = todayStats?.PracticeCorrect ?? 0; - var srsReviewedToday = reviewedToday - practiceToday; - var srsCorrectToday = correctToday - practiceCorrectToday; - - // Single query for all-time review stats - var allStats = await db.VocabularyReviews - .Where(r => r.UserId == userId && r.SiteId == siteId) - .GroupBy(_ => 1) - .Select(g => new { Total = g.Count(), Correct = g.Count(r => r.IsCorrect) }) - .FirstOrDefaultAsync(ct); - - var totalReviews = allStats?.Total ?? 0; - var totalCorrect = allStats?.Correct ?? 0; - - // Streak: consecutive days with reviews (HashSet for O(1) lookup) - var reviewDays = (await db.VocabularyReviews - .Where(r => r.UserId == userId && r.SiteId == siteId) - .Select(r => r.CreatedAt.Date) - .Distinct() - .OrderByDescending(d => d) - .Take(365) - .ToListAsync(ct)) - .ToHashSet(); - - var streak = 0; - var checkDate = now.Date; - if (!reviewDays.Contains(checkDate)) - checkDate = checkDate.AddDays(-1); - while (reviewDays.Contains(checkDate)) - { - streak++; - checkDate = checkDate.AddDays(-1); - } - - var wordsByBook = await words - .Where(w => w.BookTitle != null) - .GroupBy(w => new { w.EditionId, w.UserBookId, w.BookTitle }) - .Select(g => new { g.Key.EditionId, g.Key.UserBookId, g.Key.BookTitle, Count = g.Count() }) - .OrderByDescending(b => b.Count) - .Take(20) - .ToListAsync(ct); - - var weeklyProgress = ToDto(await weeklyBudget.GetProgressAsync(userId, siteId, ct)); - var capStatus = await dailyCap.GetStatusAsync(userId, siteId, ct); - var pendingCount = await db.PendingVocabularyWords - .CountAsync(p => p.UserId == userId && p.SiteId == siteId, ct); - var lookupCount = await db.WordLookups - .CountAsync(l => l.UserId == userId && l.SiteId == siteId, ct); - var clusterCount = await db.WordClusters - .CountAsync(c => c.UserId == userId && c.SiteId == siteId - && !c.IsDismissed && c.CompletedAt == null, ct); - - return Results.Ok(new - { - totalWords, - byStage = new - { - @new = stageDict.GetValueOrDefault(0), - recognition = stageDict.GetValueOrDefault(1), - recall = stageDict.GetValueOrDefault(2), - context = stageDict.GetValueOrDefault(3), - mastered = stageDict.GetValueOrDefault(4), - }, - dueNow, - retiredCount, - pendingCount, - lookupCount, - clusterCount, - weeklyProgress, - dailyCap = new { used = capStatus.Used, cap = capStatus.Cap, remaining = capStatus.Remaining }, - reviewedToday, - correctRateToday = reviewedToday > 0 ? Math.Round((double)correctToday / reviewedToday * 100, 1) : 0, - srsReviewedToday, - srsCorrectRateToday = srsReviewedToday > 0 ? Math.Round((double)srsCorrectToday / srsReviewedToday * 100, 1) : 0, - practicedToday = practiceToday, - practiceCorrectRateToday = practiceToday > 0 ? Math.Round((double)practiceCorrectToday / practiceToday * 100, 1) : 0, - totalReviews, - overallCorrectRate = totalReviews > 0 ? Math.Round((double)totalCorrect / totalReviews * 100, 1) : 0, - streak, - wordsByBook, - }); - } - - // --- Daily Stats --- - - private static TimeSpan ParseTzOffset(string? tz) - { - if (string.IsNullOrEmpty(tz)) return TimeSpan.Zero; - if (int.TryParse(tz, out var minutes)) - return TimeSpan.FromMinutes(minutes); - return TimeSpan.Zero; - } - - private static async Task GetDailyStats( - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - [FromQuery] DateTimeOffset? from, - [FromQuery] DateTimeOffset? to, - [FromQuery] string? tz, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var tzOffset = ParseTzOffset(tz); - var now = DateTimeOffset.UtcNow; - var start = from ?? now.AddDays(-365); - var end = to ?? now; - - // Reviews per day - var reviews = await db.VocabularyReviews - .Where(r => r.UserId == userId && r.SiteId == siteId - && r.CreatedAt >= start && r.CreatedAt <= end) - .Select(r => new { r.CreatedAt, r.IsCorrect, r.ReviewMode }) - .ToListAsync(ct); - - var reviewsByDay = reviews - .GroupBy(r => r.CreatedAt.ToOffset(tzOffset).Date) - .ToDictionary( - g => g.Key, - g => new - { - ReviewCount = g.Count(), - CorrectCount = g.Count(r => r.IsCorrect), - PracticeCount = g.Count(r => r.ReviewMode.StartsWith("practice_")), - SrsCount = g.Count(r => !r.ReviewMode.StartsWith("practice_")), - }); - - // Words added per day - var words = await db.VocabularyWords - .Where(w => w.UserId == userId && w.SiteId == siteId - && w.CreatedAt >= start && w.CreatedAt <= end) - .Select(w => w.CreatedAt) - .ToListAsync(ct); - - var wordsByDay = words - .GroupBy(d => d.ToOffset(tzOffset).Date) - .ToDictionary(g => g.Key, g => g.Count()); - - // Merge all dates - var allDates = reviewsByDay.Keys.Union(wordsByDay.Keys).OrderBy(d => d); - - var result = allDates.Select(date => - { - reviewsByDay.TryGetValue(date, out var r); - return new - { - date, - wordsAdded = wordsByDay.GetValueOrDefault(date, 0), - reviewCount = r?.ReviewCount ?? 0, - correctCount = r?.CorrectCount ?? 0, - practiceCount = r?.PracticeCount ?? 0, - srsCount = r?.SrsCount ?? 0, - }; - }).ToList(); - - return Results.Ok(result); - } - - // --- Admin: Backfill Definitions --- - - private static async Task BackfillDefinitions( - IAppDbContext db, - IDefinitionEnricher enricher, - ILogger logger, - CancellationToken ct) - { - var words = await db.VocabularyWords - .Where(w => w.Definition == null || w.Definition == "") - .OrderBy(w => w.CreatedAt) - .Take(500) - .ToListAsync(ct); - - var enriched = 0; - var failed = 0; - - foreach (var w in words) - { - try - { - var def = await enricher.FetchDefinitionAsync(w.Word, w.Language, ct); - if (def != null) - { - w.Definition = def; - enriched++; - } - else - { - failed++; - } - // Rate limit: Free Dictionary API - await Task.Delay(200, ct); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Backfill failed for word {Word}", w.Word); - failed++; - } - } - - await db.SaveChangesAsync(ct); - - return Results.Ok(new { total = words.Count, enriched, failed }); - } - // --- Reader Vocab (lightweight word+stage list) --- private static async Task GetReaderVocab( @@ -1018,450 +778,6 @@ private static async Task UpsertLookupAsync( private static WeeklyProgressDto ToDto(WeeklyProgress p) => new(p.Used, p.Budget, p.Remaining, p.ResetAt); - // --- Settings (Phase 1 anti-spiral) --- - - private static async Task GetSettings( - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var settings = await db.UserVocabularySettings - .FirstOrDefaultAsync(s => s.UserId == userId && s.SiteId == siteId, ct); - - // First-time read: return defaults without persisting — settings row is - // created lazily on first PUT to avoid a write on every new user. - return Results.Ok(new VocabSettingsDto( - DailyNewCap: settings?.DailyNewCap ?? 15, - WeeklyReviewBudget: settings?.WeeklyReviewBudget ?? 70, - FrequencyFilterEnabled: settings?.FrequencyFilterEnabled ?? true, - ClusteringEnabled: settings?.ClusteringEnabled ?? true, - AutoRetireEnabled: settings?.AutoRetireEnabled ?? true)); - } - - private static async Task UpdateSettings( - [FromBody] VocabSettingsDto request, - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - if (request.DailyNewCap is < 5 or > 100) - return Results.BadRequest("DailyNewCap must be 5–100"); - if (request.WeeklyReviewBudget is < 10 or > 500) - return Results.BadRequest("WeeklyReviewBudget must be 10–500"); - - var settings = await db.UserVocabularySettings - .FirstOrDefaultAsync(s => s.UserId == userId && s.SiteId == siteId, ct); - var now = DateTimeOffset.UtcNow; - - if (settings is null) - { - settings = new UserVocabularySettings - { - UserId = userId, - SiteId = siteId, - CreatedAt = now, - }; - db.UserVocabularySettings.Add(settings); - } - - settings.DailyNewCap = request.DailyNewCap; - settings.WeeklyReviewBudget = request.WeeklyReviewBudget; - settings.FrequencyFilterEnabled = request.FrequencyFilterEnabled; - settings.ClusteringEnabled = request.ClusteringEnabled; - settings.AutoRetireEnabled = request.AutoRetireEnabled; - settings.UpdatedAt = now; - - await db.SaveChangesAsync(ct); - return Results.Ok(request); - } - - // --- Unretire (Phase 1) --- - - private static async Task UnretireWord( - Guid id, - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var word = await FindUserWordAsync(db, id, userId, siteId, ct); - if (word == null) return Results.NotFound(); - - if (!word.IsRetired) return Results.Ok(ToDto(word)); - - // Drop back to Stage 3 (Context), not Stage 0 — the user once mastered - // this word, so we're only asking for one resurfacing review, not - // starting from scratch. ConsecutiveCorrect resets so the 3/14d - // retirement rule has to be re-earned. - var now = DateTimeOffset.UtcNow; - word.IsRetired = false; - word.RetiredAt = null; - word.RetiredReason = null; - word.Stage = 3; - word.ConsecutiveCorrect = 0; - word.IntervalDays = 1; - word.NextReviewAt = now; - word.UpdatedAt = now; - - await db.SaveChangesAsync(ct); - return Results.Ok(ToDto(word)); - } - - // --- Pending Buffer (Phase 2) --- - - private static async Task GetPending( - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - DailyCapService dailyCap, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var items = await db.PendingVocabularyWords - .Where(p => p.UserId == userId && p.SiteId == siteId) - .OrderByDescending(p => p.Priority) - .ThenBy(p => p.CreatedAt) - .Select(p => new PendingVocabWordDto( - p.Id, p.Word, p.Language, p.Translation, p.Definition, - p.EditionId, p.ChapterId, p.UserBookId, - p.Sentence, p.BookTitle, p.Priority, p.Source, p.CreatedAt)) - .ToListAsync(ct); - - var cap = await dailyCap.GetStatusAsync(userId, siteId, ct); - return Results.Ok(new PendingListResponse(items, cap.Used, cap.Cap, cap.Remaining)); - } - - // Manual promote — bypasses the daily cap. User explicitly asked for this - // word to skip the queue; respect it even if today is "full". - private static async Task PromotePending( - Guid id, - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - DailyCapService dailyCap, - IServiceScopeFactory scopeFactory, - ILogger logger, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var pending = await db.PendingVocabularyWords - .FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId && p.SiteId == siteId, ct); - if (pending == null) return Results.NotFound(); - - // Capture enrichment inputs before PromoteAsync disposes the pending row. - var wordText = pending.Word; - var lang = pending.Language; - var def = pending.Definition; - var sent = pending.Sentence; - - var now = DateTimeOffset.UtcNow; - var promoted = await dailyCap.PromoteAsync(pending, now, ct); - - var nativeLang = await db.Users - .Where(u => u.Id == userId) - .Select(u => u.NativeLanguage) - .FirstOrDefaultAsync(ct); - if (!string.IsNullOrWhiteSpace(nativeLang)) - { - QueueEnrichment(scopeFactory, logger, promoted.Id, wordText, lang, def, sent, nativeLang); - } - - return Results.Ok(ToDto(promoted)); - } - - private static async Task DismissPending( - Guid id, - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var pending = await db.PendingVocabularyWords - .FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId && p.SiteId == siteId, ct); - if (pending == null) return Results.NotFound(); - - db.PendingVocabularyWords.Remove(pending); - await db.SaveChangesAsync(ct); - return Results.NoContent(); - } - - // --- Lookups (Phase 3 F1) --- - - private static async Task GetLookups( - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - [FromQuery] int? limit, - [FromQuery] int? offset, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var take = Math.Clamp(limit ?? 50, 1, 200); - var skip = Math.Max(0, offset ?? 0); - - var baseQuery = db.WordLookups - .Where(l => l.UserId == userId && l.SiteId == siteId); - - var total = await baseQuery.CountAsync(ct); - var items = await baseQuery - .OrderByDescending(l => l.LastTappedAt) - .Skip(skip) - .Take(take) - .Select(l => new WordLookupDto( - l.Id, l.Word, l.Language, l.ZipfRank, l.TapCount, - l.Sentence, l.BookTitle, l.EditionId, l.ChapterId, l.UserBookId, - l.LastTranslation, l.FirstTappedAt, l.LastTappedAt)) - .ToListAsync(ct); - - return Results.Ok(new WordLookupListResponse(items, total)); - } - - // "Add anyway" — user overrides the frequency filter. Creates a VocabularyWord - // directly and drops the Lookup. Bypasses the daily cap on purpose: the user - // explicitly asked for this word. - private static async Task PromoteLookup( - Guid id, - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - IServiceScopeFactory scopeFactory, - ILogger logger, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var lookup = await db.WordLookups - .FirstOrDefaultAsync(l => l.Id == id && l.UserId == userId && l.SiteId == siteId, ct); - if (lookup == null) return Results.NotFound(); - - // Guard against a race where the word was saved via another path between - // the list render and the promote click. - var already = await db.VocabularyWords - .FirstOrDefaultAsync(w => w.UserId == userId && w.SiteId == siteId - && w.Word == lookup.Word && w.Language == lookup.Language, ct); - if (already != null) - { - db.WordLookups.Remove(lookup); - await db.SaveChangesAsync(ct); - return Results.Ok(ToDto(already)); - } - - // Hard ceiling still applies — Add Anyway bypasses the daily cap, not - // the 5000-word vocabulary limit. Counts both active SRS + pending - // so a user can't exceed the cap via the lookup bypass. - var count = await db.VocabularyWords.CountAsync( - w => w.UserId == userId && w.SiteId == siteId, ct); - count += await db.PendingVocabularyWords.CountAsync( - p => p.UserId == userId && p.SiteId == siteId, ct); - if (count >= MaxWordsPerUser) - return Results.Problem("Vocabulary limit reached (5000 words)", statusCode: 429); - - var now = DateTimeOffset.UtcNow; - var entry = new VocabularyWord - { - Id = Guid.NewGuid(), - UserId = userId, - SiteId = siteId, - Word = lookup.Word, - Language = lookup.Language, - Translation = lookup.LastTranslation, - EditionId = lookup.EditionId, - ChapterId = lookup.ChapterId, - UserBookId = lookup.UserBookId, - Sentence = lookup.Sentence, - BookTitle = lookup.BookTitle, - ZipfRank = lookup.ZipfRank, - Source = "manual_add_anyway", - ActivatedAt = now, - Stage = 0, - IntervalDays = 0, - ConsecutiveCorrect = 0, - NextReviewAt = now, - TotalReviews = 0, - CorrectReviews = 0, - CreatedAt = now, - UpdatedAt = now, - }; - db.VocabularyWords.Add(entry); - db.WordLookups.Remove(lookup); - await db.SaveChangesAsync(ct); - - var nativeLang = await db.Users - .Where(u => u.Id == userId) - .Select(u => u.NativeLanguage) - .FirstOrDefaultAsync(ct); - if (!string.IsNullOrWhiteSpace(nativeLang)) - { - QueueEnrichment(scopeFactory, logger, entry.Id, entry.Word, entry.Language, - entry.Definition, entry.Sentence, nativeLang); - } - - return Results.Ok(ToDto(entry)); - } - - private static async Task DismissLookup( - Guid id, - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var lookup = await db.WordLookups - .FirstOrDefaultAsync(l => l.Id == id && l.UserId == userId && l.SiteId == siteId, ct); - if (lookup == null) return Results.NotFound(); - - db.WordLookups.Remove(lookup); - await db.SaveChangesAsync(ct); - return Results.NoContent(); - } - - // --- Clusters (F3 bonus round) --- - - private static async Task GetClusters( - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var clusters = await db.WordClusters - .Where(c => c.UserId == userId - && c.SiteId == siteId - && !c.IsDismissed - && c.CompletedAt == null) - .OrderByDescending(c => c.CreatedAt) - .Take(10) - .Select(c => new WordClusterDto( - c.Id, c.Title, c.Theme, - c.EditionId, c.UserBookId, c.BookTitle, - c.MemberCount, c.CohesionScore, - c.IsConfirmed, c.CreatedAt)) - .ToListAsync(ct); - - return Results.Ok(new { items = clusters }); - } - - private static async Task StartClusterBonus( - Guid id, - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - IReviewCardBuilder cardBuilder, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var cluster = await db.WordClusters - .FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId && c.SiteId == siteId, ct); - if (cluster == null) return Results.NotFound(); - if (cluster.IsDismissed || cluster.CompletedAt != null) return Results.Conflict(); - - // Load cluster members. Retired members are skipped — they graduated. - var members = await db.VocabularyWords - .Where(w => w.ClusterId == id - && w.UserId == userId - && w.SiteId == siteId - && !w.IsRetired) - .ToListAsync(ct); - - if (members.Count == 0) return Results.NotFound(); - - // Distractor pool from user's other words (not in cluster). - var languages = members.Select(w => w.Language).Distinct().ToList(); - var memberIds = members.Select(w => w.Id).ToHashSet(); - var pool = await db.VocabularyWords - .Where(w => w.UserId == userId && w.SiteId == siteId - && !memberIds.Contains(w.Id) - && languages.Contains(w.Language)) - .OrderBy(_ => EF.Functions.Random()) - .Take(MaxDistractorPoolSize) - .Select(w => new DistractorPoolEntry(w.Word, w.Language)) - .ToListAsync(ct); - - var wordsForReview = members.Select(w => new WordForReview( - w.Id, w.Word, w.Language, - w.Translation, w.Definition, - w.Sentence, w.BookTitle, w.Hint, - w.Explanation, w.Distractors, - w.Stage, w.TotalReviews)).ToList(); - - var cards = cardBuilder.BuildCards(wordsForReview, pool); - var cardDtos = cards.Select(c => new ReviewCardDto( - c.WordId, c.Word, c.Translation, c.Definition, - c.ReviewMode, c.BlankSentence, c.OriginalSentence, c.BookTitle, - c.Hint, c.Explanation, c.IsNew, c.Options, c.CorrectOptionIndex)).ToList(); - - cluster.IsConfirmed = true; - await db.SaveChangesAsync(ct); - - return Results.Ok(new { clusterId = cluster.Id, title = cluster.Title, cards = cardDtos }); - } - - private static async Task DismissCluster( - Guid id, - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var cluster = await db.WordClusters - .FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId && c.SiteId == siteId, ct); - if (cluster == null) return Results.NotFound(); - - cluster.IsDismissed = true; - cluster.DismissedAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(ct); - return Results.NoContent(); - } - - private static async Task CompleteCluster( - Guid id, - HttpContext httpContext, - AuthService authService, - IAppDbContext db, - CancellationToken ct) - { - if (!TryGetAuth(httpContext, authService, out var userId, out var siteId)) - return Results.Unauthorized(); - - var cluster = await db.WordClusters - .FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId && c.SiteId == siteId, ct); - if (cluster == null) return Results.NotFound(); - - cluster.CompletedAt = DateTimeOffset.UtcNow; - await db.SaveChangesAsync(ct); - return Results.NoContent(); - } } // --- DTOs --- diff --git a/backend/src/Application/Admin/AdminService.Chapters.cs b/backend/src/Application/Admin/AdminService.Chapters.cs new file mode 100644 index 00000000..00d9b27e --- /dev/null +++ b/backend/src/Application/Admin/AdminService.Chapters.cs @@ -0,0 +1,111 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Application.Common.Interfaces; +using Application.SsgRebuild; +using Contracts.Admin; +using Contracts.Common; +using Domain.Entities; +using Domain.Enums; +using Domain.Utilities; +using Microsoft.EntityFrameworkCore; +using Application.UserBooks; +using TextStack.Search.Abstractions; +using TextStack.Search.Contracts; +using TextStack.Search.Enums; + +namespace Application.Admin; + +/// +/// Chapter CRUD — detail load, update (re-strips HTML + recounts words), delete (with renumbering of trailing chapters). HTML stripping + word counting helpers live here too. +/// +public partial class AdminService +{ + // Chapter CRUD + + public async Task GetChapterDetailAsync(Guid id, CancellationToken ct) + { + return await db.Chapters + .Where(c => c.Id == id) + .Select(c => new AdminChapterDetailDto( + c.Id, + c.EditionId, + c.ChapterNumber, + c.Slug, + c.Title, + c.Html, + c.WordCount, + c.CreatedAt, + c.UpdatedAt + )) + .FirstOrDefaultAsync(ct); + } + + public async Task<(bool Success, string? Error)> UpdateChapterAsync( + Guid id, UpdateChapterRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Title)) + return (false, "Title is required"); + + if (string.IsNullOrWhiteSpace(request.Html)) + return (false, "Content is required"); + + var chapter = await db.Chapters.FindAsync([id], ct); + if (chapter is null) + return (false, "Chapter not found"); + + chapter.Title = request.Title; + chapter.Html = request.Html; + chapter.PlainText = StripHtml(request.Html); + chapter.WordCount = CountWords(chapter.PlainText); + chapter.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return (true, null); + } + + public async Task<(bool Success, string? Error)> DeleteChapterAsync(Guid id, CancellationToken ct) + { + var chapter = await db.Chapters.FindAsync([id], ct); + if (chapter is null) + return (false, "Chapter not found"); + + var editionId = chapter.EditionId; + var deletedNumber = chapter.ChapterNumber; + + db.Chapters.Remove(chapter); + + // Renumber remaining chapters + var remaining = await db.Chapters + .Where(c => c.EditionId == editionId && c.ChapterNumber > deletedNumber) + .OrderBy(c => c.ChapterNumber) + .ToListAsync(ct); + + foreach (var ch in remaining) + { + ch.ChapterNumber--; + ch.UpdatedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(ct); + return (true, null); + } + + private static string StripHtml(string html) + { + if (string.IsNullOrEmpty(html)) + return string.Empty; + + // Simple regex-based HTML stripping + var text = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " "); + text = System.Net.WebUtility.HtmlDecode(text); + text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " "); + return text.Trim(); + } + + private static int CountWords(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return 0; + return text.Split([' ', '\n', '\r', '\t'], StringSplitOptions.RemoveEmptyEntries).Length; + } +} diff --git a/backend/src/Application/Admin/AdminService.Editions.cs b/backend/src/Application/Admin/AdminService.Editions.cs new file mode 100644 index 00000000..fe76ba29 --- /dev/null +++ b/backend/src/Application/Admin/AdminService.Editions.cs @@ -0,0 +1,383 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Application.Common.Interfaces; +using Application.SsgRebuild; +using Contracts.Admin; +using Contracts.Common; +using Domain.Entities; +using Domain.Enums; +using Domain.Utilities; +using Microsoft.EntityFrameworkCore; +using Application.UserBooks; +using TextStack.Search.Abstractions; +using TextStack.Search.Contracts; +using TextStack.Search.Enums; + +namespace Application.Admin; + +/// +/// Edition CRUD + publish workflow — stats, paginated listing with filters, detail load, update with SEO + authors + genres, delete (Draft only), publish (with search indexing + SSG rebuild), unpublish. +/// +public partial class AdminService +{ + // Edition CRUD + + public async Task GetStatsAsync(Guid? siteId, CancellationToken ct) + { + var editionQuery = db.Editions.AsQueryable(); + var authorQuery = db.Authors.AsQueryable(); + var chapterQuery = db.Chapters.AsQueryable(); + + if (siteId.HasValue) + { + editionQuery = editionQuery.Where(e => e.SiteId == siteId.Value); + authorQuery = authorQuery.Where(a => a.SiteId == siteId.Value); + chapterQuery = chapterQuery.Where(c => c.Edition.SiteId == siteId.Value); + } + + var totalEditions = await editionQuery.CountAsync(ct); + var publishedEditions = await editionQuery.Where(e => e.Status == EditionStatus.Published).CountAsync(ct); + var draftEditions = await editionQuery.Where(e => e.Status == EditionStatus.Draft).CountAsync(ct); + var totalChapters = await chapterQuery.CountAsync(ct); + var totalAuthors = await authorQuery.CountAsync(ct); + var totalUsers = await db.Users.CountAsync(ct); + + return new AdminStatsDto( + TotalEditions: totalEditions, + PublishedEditions: publishedEditions, + DraftEditions: draftEditions, + TotalChapters: totalChapters, + TotalAuthors: totalAuthors, + TotalUsers: totalUsers + ); + } + + public async Task> GetEditionsAsync( + Guid? siteId, int offset, int limit, EditionStatus? status, string? search, string? language, bool? indexable, bool? seoReady, string? sort, string? sortOrder, CancellationToken ct) + { + var query = db.Editions.AsQueryable(); + + if (siteId.HasValue) + query = query.Where(e => e.SiteId == siteId.Value); + + if (status.HasValue) + query = query.Where(e => e.Status == status.Value); + + if (!string.IsNullOrWhiteSpace(search)) + query = query.Where(e => e.Title.Contains(search) || e.EditionAuthors.Any(ea => ea.Author.Name.Contains(search))); + + if (!string.IsNullOrWhiteSpace(language)) + query = query.Where(e => e.Language == language); + + if (indexable.HasValue) + { + if (indexable.Value) + query = query.Where(e => e.Indexable && e.Status == EditionStatus.Published); + else + query = query.Where(e => !e.Indexable || e.Status != EditionStatus.Published); + } + + if (seoReady.HasValue) + { + if (seoReady.Value) + query = query.Where(e => + e.Description != null && e.Description != "" && + e.SeoRelevanceText != null && e.SeoRelevanceText != "" && + e.SeoThemesJson != null && e.SeoThemesJson != "" && + e.SeoFaqsJson != null && e.SeoFaqsJson != "" && + e.Chapters.Any()); + else + query = query.Where(e => + e.Description == null || e.Description == "" || + e.SeoRelevanceText == null || e.SeoRelevanceText == "" || + e.SeoThemesJson == null || e.SeoThemesJson == "" || + e.SeoFaqsJson == null || e.SeoFaqsJson == "" || + !e.Chapters.Any()); + } + + var total = await query.CountAsync(ct); + + var sortField = (sort ?? "createdat").ToLowerInvariant(); + var isDesc = (sortOrder ?? "desc").ToLowerInvariant() == "desc"; + + query = (sortField, isDesc) switch + { + ("title", false) => query.OrderBy(e => e.Title), + ("title", true) => query.OrderByDescending(e => e.Title), + ("createdat", false) => query.OrderBy(e => e.CreatedAt), + _ => query.OrderByDescending(e => e.CreatedAt) + }; + + var items = await query + .Skip(offset) + .Take(limit) + .Select(e => new AdminEditionListDto( + e.Id, + e.Slug, + e.Title, + e.Language, + e.Status.ToString(), + e.Chapters.Count, + e.CreatedAt, + e.PublishedAt, + string.Join(", ", e.EditionAuthors.OrderBy(ea => ea.Order).Select(ea => ea.Author.Name)), + e.Description != null && e.Description != "" && + e.SeoRelevanceText != null && e.SeoRelevanceText != "" && + e.SeoThemesJson != null && e.SeoThemesJson != "" && + e.SeoFaqsJson != null && e.SeoFaqsJson != "" && + e.Chapters.Any() + )) + .ToListAsync(ct); + + return new PaginatedResult(total, items); + } + + public async Task GetEditionDetailAsync(Guid id, CancellationToken ct) + { + return await db.Editions + .Where(e => e.Id == id) + .Select(e => new AdminEditionDetailDto( + e.Id, + e.WorkId, + e.SiteId, + e.Slug, + e.Title, + e.Language, + e.Description, + e.CoverPath, + e.Status.ToString(), + e.IsPublicDomain, + e.CreatedAt, + e.PublishedAt, + e.Chapters + .OrderBy(c => c.ChapterNumber) + .Select(c => new AdminChapterDto(c.Id, c.ChapterNumber, c.Slug ?? "", c.Title, c.WordCount)) + .ToList(), + e.EditionAuthors + .OrderBy(ea => ea.Order) + .Select(ea => new AdminEditionAuthorDto(ea.AuthorId, ea.Author.Slug, ea.Author.Name, ea.Order, ea.Role.ToString())) + .ToList(), + e.Genres + .OrderBy(g => g.Name) + .Select(g => new AdminEditionGenreDto(g.Id, g.Slug, g.Name)) + .ToList(), + e.Indexable, + e.SeoTitle, + e.SeoDescription, + e.CanonicalOverride, + e.SeoRelevanceText, + e.SeoThemesJson, + e.SeoFaqsJson + )) + .FirstOrDefaultAsync(ct); + } + + public async Task<(bool Success, string? Error)> UpdateEditionAsync( + Guid id, UpdateEditionRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Title)) + return (false, "Title is required"); + + if (request.Title.Length > 500) + return (false, "Title must be 500 characters or less"); + + if (request.Description?.Length > 5000) + return (false, "Description must be 5000 characters or less"); + + var edition = await db.Editions.FindAsync([id], ct); + if (edition is null) + return (false, "Edition not found"); + + edition.Title = request.Title; + edition.Description = request.Description; + edition.UpdatedAt = DateTimeOffset.UtcNow; + + // SEO fields + if (request.Indexable.HasValue) + edition.Indexable = request.Indexable.Value; + edition.SeoTitle = request.SeoTitle; + edition.SeoDescription = request.SeoDescription; + edition.CanonicalOverride = request.CanonicalOverride; + + // SEO content blocks + edition.SeoRelevanceText = request.SeoRelevanceText; + edition.SeoThemesJson = request.SeoThemesJson; + edition.SeoFaqsJson = request.SeoFaqsJson; + + // Handle author assignment + if (request.Authors is not null) + { + // Remove existing author associations + var existingAuthors = await db.EditionAuthors + .Where(ea => ea.EditionId == id) + .ToListAsync(ct); + db.EditionAuthors.RemoveRange(existingAuthors); + + // Add new author associations with order + for (var i = 0; i < request.Authors.Count; i++) + { + var authorDto = request.Authors[i]; + var role = Enum.TryParse(authorDto.Role, true, out var parsedRole) + ? parsedRole + : AuthorRole.Author; + + db.EditionAuthors.Add(new EditionAuthor + { + EditionId = id, + AuthorId = authorDto.AuthorId, + Order = i, + Role = role + }); + } + } + + // Handle genre assignment + if (request.GenreIds is not null) + { + // Load edition with genres for M2M update + var editionWithGenres = await db.Editions + .Include(e => e.Genres) + .FirstAsync(e => e.Id == id, ct); + + // Clear existing genres + editionWithGenres.Genres.Clear(); + + // Add new genres + if (request.GenreIds.Count > 0) + { + var genres = await db.Genres + .Where(g => request.GenreIds.Contains(g.Id) && g.SiteId == edition.SiteId) + .ToListAsync(ct); + + foreach (var genre in genres) + { + editionWithGenres.Genres.Add(genre); + } + } + } + + await db.SaveChangesAsync(ct); + + if (edition.Status == EditionStatus.Published) + _ = EnqueueSsgSafe(edition.SiteId, bookSlugs: [edition.Slug]); + + return (true, null); + } + + public async Task<(bool Success, string? Error)> DeleteEditionAsync(Guid id, CancellationToken ct) + { + var edition = await db.Editions + .Include(e => e.Chapters) + .Include(e => e.BookFiles) + .FirstOrDefaultAsync(e => e.Id == id, ct); + + if (edition is null) + return (false, "Edition not found"); + + if (edition.Status == EditionStatus.Published) + return (false, "Cannot delete published edition. Unpublish first."); + + // Delete related entities + db.Chapters.RemoveRange(edition.Chapters); + db.BookFiles.RemoveRange(edition.BookFiles); + + // Delete ingestion jobs + var jobs = await db.IngestionJobs.Where(j => j.EditionId == id).ToListAsync(ct); + db.IngestionJobs.RemoveRange(jobs); + + db.Editions.Remove(edition); + + await db.SaveChangesAsync(ct); + return (true, null); + } + + public async Task<(bool Success, string? Error)> PublishEditionAsync(Guid id, CancellationToken ct) + { + var edition = await db.Editions + .Include(e => e.Chapters) + .Include(e => e.EditionAuthors) + .ThenInclude(ea => ea.Author) + .FirstOrDefaultAsync(e => e.Id == id, ct); + + if (edition is null) + return (false, "Edition not found"); + + if (edition.Status == EditionStatus.Published) + return (false, "Edition is already published"); + + if (edition.Chapters.Count == 0) + return (false, "Cannot publish edition with no chapters"); + + edition.Status = EditionStatus.Published; + edition.PublishedAt = DateTimeOffset.UtcNow; + edition.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + + // Index chapters for search + await IndexChaptersAsync(edition, ct); + + // Trigger SSG rebuild for this book (fire and forget) + _ = EnqueueSsgSafe(edition.SiteId, bookSlugs: [edition.Slug]); + + return (true, null); + } + + private async Task IndexChaptersAsync(Edition edition, CancellationToken ct) + { + var searchLang = edition.Language switch + { + "en" => SearchLanguage.En, + _ => SearchLanguage.Auto + }; + + var authors = string.Join(", ", edition.EditionAuthors.OrderBy(ea => ea.Order).Select(ea => ea.Author.Name)); + + var documents = edition.Chapters.Select(chapter => new IndexDocument( + Id: chapter.Id.ToString(), + Title: chapter.Title, + Content: chapter.PlainText, + Language: searchLang, + SiteId: edition.SiteId, + Metadata: new Dictionary + { + ["chapterId"] = chapter.Id, + ["chapterSlug"] = chapter.Slug ?? string.Empty, + ["chapterTitle"] = chapter.Title, + ["chapterNumber"] = chapter.ChapterNumber, + ["editionId"] = edition.Id, + ["editionSlug"] = edition.Slug, + ["editionTitle"] = edition.Title, + ["language"] = edition.Language, + ["authors"] = authors, + ["coverPath"] = edition.CoverPath ?? string.Empty + } + )).ToList(); + + if (documents.Count > 0) + { + await searchIndexer.IndexBatchAsync(documents, ct); + } + } + + public async Task<(bool Success, string? Error)> UnpublishEditionAsync(Guid id, CancellationToken ct) + { + var edition = await db.Editions.FindAsync([id], ct); + + if (edition is null) + return (false, "Edition not found"); + + if (edition.Status != EditionStatus.Published) + return (false, "Edition is not published"); + + edition.Status = EditionStatus.Draft; + edition.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + + // Full rebuild — book removed from public listings + _ = EnqueueSsgSafe(edition.SiteId); + + return (true, null); + } +} diff --git a/backend/src/Application/Admin/AdminService.Upload.cs b/backend/src/Application/Admin/AdminService.Upload.cs new file mode 100644 index 00000000..61da2a8a --- /dev/null +++ b/backend/src/Application/Admin/AdminService.Upload.cs @@ -0,0 +1,341 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Application.Common.Interfaces; +using Application.SsgRebuild; +using Contracts.Admin; +using Contracts.Common; +using Domain.Entities; +using Domain.Enums; +using Domain.Utilities; +using Microsoft.EntityFrameworkCore; +using Application.UserBooks; +using TextStack.Search.Abstractions; +using TextStack.Search.Contracts; +using TextStack.Search.Enums; + +namespace Application.Admin; + +/// +/// Upload + ingestion-job management — file validation, work/edition creation, book upload, ingestion job listing/detail/preview/retry, and slug generation. Extracted from the monolithic AdminService.cs. +/// +public partial class AdminService +{ + public async Task<(bool Valid, string? Error)> ValidateUploadAsync( + Guid siteId, string fileName, long fileSize, CancellationToken ct) + { + if (!await db.Sites.AnyAsync(s => s.Id == siteId, ct)) + return (false, "Invalid siteId"); + + if (fileSize == 0) + return (false, "File is empty"); + + if (fileSize > MaxFileSize) + return (false, $"File too large. Max {MaxFileSize / 1024 / 1024}MB"); + + var ext = Path.GetExtension(fileName).ToLowerInvariant(); + if (!AllowedExtensions.Contains(ext)) + return (false, $"Invalid file type. Allowed: {string.Join(", ", AllowedExtensions)}"); + + return (true, null); + } + + public async Task<(bool Valid, string? Error, Work? Work)> GetOrCreateWorkAsync( + Guid siteId, string title, Guid? workId, CancellationToken ct) + { + if (workId.HasValue) + { + var work = await db.Works.FindAsync([workId.Value], ct); + if (work is null) + return (false, "Work not found", null); + if (work.SiteId != siteId) + return (false, "Work belongs to different site", null); + return (true, null, work); + } + + var slug = SlugGenerator.GenerateSlug(title); + var existingWork = await db.Works + .FirstOrDefaultAsync(w => w.SiteId == siteId && w.Slug == slug, ct); + if (existingWork is not null) + return (true, null, existingWork); + + var newWork = new Work + { + Id = Guid.NewGuid(), + SiteId = siteId, + Slug = slug, + CreatedAt = DateTimeOffset.UtcNow + }; + db.Works.Add(newWork); + return (true, null, newWork); + } + + public async Task UploadBookAsync(UploadBookRequest req, Work work, CancellationToken ct) + { + var ext = Path.GetExtension(req.FileName).ToLowerInvariant(); + var format = ext switch + { + ".epub" => BookFormat.Epub, + ".pdf" => BookFormat.Pdf, + ".fb2" => BookFormat.Fb2, + _ => BookFormat.Other + }; + + var editionSlug = await GenerateUniqueEditionSlugAsync(req.SiteId, req.Title, req.Language, ct); + var edition = new Edition + { + Id = Guid.NewGuid(), + WorkId = work.Id, + SiteId = req.SiteId, + Language = req.Language, + Slug = editionSlug, + Title = req.Title, + Description = req.Description, + Status = EditionStatus.Draft, + SourceEditionId = req.SourceEditionId, + IsPublicDomain = false, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + db.Editions.Add(edition); + + // Add authors if provided + if (req.AuthorIds is { Count: > 0 }) + { + var order = 0; + foreach (var authorId in req.AuthorIds) + { + db.EditionAuthors.Add(new EditionAuthor + { + EditionId = edition.Id, + AuthorId = authorId, + Order = order++, + Role = AuthorRole.Author + }); + } + } + + // Add genre if provided (M2M via edition_genres) + if (req.GenreId.HasValue) + { + var genre = await db.Genres.FindAsync([req.GenreId.Value], ct); + if (genre is not null) + { + edition.Genres.Add(genre); + } + } + + var storagePath = await storage.SaveFileAsync(edition.Id, req.FileName, req.FileStream, ct); + + req.FileStream.Position = 0; + using var sha = SHA256.Create(); + var hashBytes = await sha.ComputeHashAsync(req.FileStream, ct); + var hash = Convert.ToHexString(hashBytes).ToLowerInvariant(); + + var bookFile = new BookFile + { + Id = Guid.NewGuid(), + EditionId = edition.Id, + OriginalFileName = req.FileName, + StoragePath = storagePath, + Format = format, + Sha256 = hash, + UploadedAt = DateTimeOffset.UtcNow + }; + db.BookFiles.Add(bookFile); + + var job = new IngestionJob + { + Id = Guid.NewGuid(), + EditionId = edition.Id, + BookFileId = bookFile.Id, + TargetLanguage = req.Language, + WorkId = req.WorkId, + SourceEditionId = req.SourceEditionId, + Status = JobStatus.Queued, + AttemptCount = 0, + CreatedAt = DateTimeOffset.UtcNow + }; + db.IngestionJobs.Add(job); + + await db.SaveChangesAsync(ct); + + return new UploadBookResult(work.Id, edition.Id, bookFile.Id, job.Id); + } + + public async Task> GetIngestionJobsAsync( + IngestionJobsQuery query, CancellationToken ct) + { + var q = db.IngestionJobs + .Include(j => j.Edition) + .Include(j => j.BookFile) + .AsQueryable(); + + if (query.Status.HasValue) + q = q.Where(j => j.Status == query.Status.Value); + + if (!string.IsNullOrWhiteSpace(query.Search)) + q = q.Where(j => j.Edition.Title.Contains(query.Search) || + j.BookFile.OriginalFileName.Contains(query.Search)); + + return await q + .OrderByDescending(j => j.CreatedAt) + .Skip(query.Offset) + .Take(query.Limit) + .Select(j => new IngestionJobDto( + j.Id, + j.EditionId, + j.Edition.Title, + j.BookFile.OriginalFileName, + j.Status.ToString(), + j.SourceFormat, + j.UnitsCount, + j.TextSource, + j.Error, + j.CreatedAt, + j.StartedAt, + j.FinishedAt + )) + .ToListAsync(ct); + } + + public async Task GetIngestionJobAsync(Guid id, CancellationToken ct) + { + var job = await db.IngestionJobs + .Include(j => j.Edition) + .Include(j => j.BookFile) + .FirstOrDefaultAsync(j => j.Id == id, ct); + + if (job is null) + return null; + + List? warnings = null; + if (!string.IsNullOrEmpty(job.WarningsJson)) + { + try + { + warnings = JsonSerializer.Deserialize>(job.WarningsJson); + } + catch + { + // Ignore deserialization errors + } + } + + var diagnostics = job.SourceFormat is not null + ? new IngestionDiagnosticsDto( + job.SourceFormat, + job.UnitsCount, + job.TextSource, + job.Confidence, + warnings) + : null; + + return new IngestionJobDetailDto( + job.Id, + job.EditionId, + job.BookFileId, + job.BookFile.OriginalFileName, + job.TargetLanguage, + job.Status, + job.AttemptCount, + job.Error, + job.CreatedAt, + job.StartedAt, + job.FinishedAt, + new IngestionEditionDto(job.Edition.Title, job.Edition.Language, job.Edition.Slug), + diagnostics + ); + } + + public async Task GetChapterPreviewAsync( + Guid jobId, int chapterIndex, int maxChars, CancellationToken ct) + { + maxChars = Math.Min(maxChars, 10000); // Enforce max limit + + var job = await db.IngestionJobs + .FirstOrDefaultAsync(j => j.Id == jobId, ct); + + if (job is null) + return null; + + var chapter = await db.Chapters + .Where(c => c.EditionId == job.EditionId) + .OrderBy(c => c.ChapterNumber) + .Skip(chapterIndex) + .FirstOrDefaultAsync(ct); + + if (chapter is null) + return null; + + var preview = chapter.PlainText.Length <= maxChars + ? chapter.PlainText + : chapter.PlainText[..maxChars] + "..."; + + return new ChapterPreviewDto( + chapter.ChapterNumber, + chapter.Title, + preview, + chapter.PlainText.Length + ); + } + + public async Task<(bool Success, string? Error, IngestionJobDetailDto? Job)> RetryJobAsync( + Guid id, CancellationToken ct) + { + var job = await db.IngestionJobs + .Include(j => j.Edition) + .Include(j => j.BookFile) + .FirstOrDefaultAsync(j => j.Id == id, ct); + + if (job is null) + return (false, "Job not found", null); + + // Idempotency: if already queued or processing, just return current state + if (job.Status == JobStatus.Queued || job.Status == JobStatus.Processing) + { + var currentDto = await GetIngestionJobAsync(id, ct); + return (true, null, currentDto); + } + + // Only allow retry for failed jobs + if (job.Status != JobStatus.Failed) + return (false, "Can only retry failed jobs", null); + + // Reset job for retry + job.Status = JobStatus.Queued; + job.Error = null; + job.StartedAt = null; + job.FinishedAt = null; + // Keep diagnostics from previous attempt for reference + // AttemptCount will be incremented when processing starts + + await db.SaveChangesAsync(ct); + + var dto = await GetIngestionJobAsync(id, ct); + return (true, null, dto); + } + + private async Task GenerateUniqueEditionSlugAsync( + Guid siteId, string title, string language, CancellationToken ct) + { + var baseSlug = SlugGenerator.GenerateSlug(title); + var slug = baseSlug; + var exists = await db.Editions.AnyAsync(e => e.SiteId == siteId && e.Language == language && e.Slug == slug, ct); + + if (exists) + { + slug = $"{baseSlug}-{language}"; + exists = await db.Editions.AnyAsync(e => e.SiteId == siteId && e.Language == language && e.Slug == slug, ct); + } + + var counter = 2; + while (exists) + { + slug = $"{baseSlug}-{language}-{counter}"; + exists = await db.Editions.AnyAsync(e => e.SiteId == siteId && e.Language == language && e.Slug == slug, ct); + counter++; + } + + return slug; + } +} diff --git a/backend/src/Application/Admin/AdminService.UserUploads.cs b/backend/src/Application/Admin/AdminService.UserUploads.cs new file mode 100644 index 00000000..534f2488 --- /dev/null +++ b/backend/src/Application/Admin/AdminService.UserUploads.cs @@ -0,0 +1,113 @@ +using System.Security.Cryptography; +using System.Text.Json; +using Application.Common.Interfaces; +using Application.SsgRebuild; +using Contracts.Admin; +using Contracts.Common; +using Domain.Entities; +using Domain.Enums; +using Domain.Utilities; +using Microsoft.EntityFrameworkCore; +using Application.UserBooks; +using TextStack.Search.Abstractions; +using TextStack.Search.Contracts; +using TextStack.Search.Enums; + +namespace Application.Admin; + +/// +/// User-uploaded book moderation — list with status/user-type filters + search, aggregate stats, delete (via UserBookService), takedown (with reason). +/// +public partial class AdminService +{ + // User Uploads + + public async Task> GetUserUploadsAsync( + UserUploadsQuery query, CancellationToken ct) + { + var q = db.UserBooks.AsQueryable(); + + if (query.Status.HasValue) + q = q.Where(b => b.Status == query.Status.Value); + + if (string.Equals(query.UserType, "guest", StringComparison.OrdinalIgnoreCase)) + q = q.Where(b => b.User.IsGuest); + else if (string.Equals(query.UserType, "registered", StringComparison.OrdinalIgnoreCase)) + q = q.Where(b => !b.User.IsGuest); + + if (!string.IsNullOrWhiteSpace(query.Search)) + q = q.Where(b => b.Title.Contains(query.Search) || + (b.Author != null && b.Author.Contains(query.Search)) || + b.User.Email.Contains(query.Search)); + + var total = await q.CountAsync(ct); + + var items = await q + .OrderByDescending(b => b.CreatedAt) + .Skip(query.Offset) + .Take(query.Limit) + .Select(b => new UserUploadListDto( + b.Id, + b.Title, + b.Author, + b.Language, + b.Status.ToString(), + b.Chapters.Count, + b.TotalWordCount, + b.BookFiles.Sum(f => f.FileSize), + b.BookFiles.Select(f => f.Format.ToString()).FirstOrDefault(), + b.BookFiles.Select(f => f.OriginalFileName).FirstOrDefault(), + b.User.Email, + b.User.IsGuest, + b.ErrorMessage, + b.CreatedAt, + b.TakedownAt, + b.TakedownReason)) + .ToListAsync(ct); + + return new PaginatedResult(total, items); + } + + public async Task GetUserUploadStatsAsync(CancellationToken ct) + { + var total = await db.UserBooks.CountAsync(ct); + var processing = await db.UserBooks.CountAsync(b => b.Status == UserBookStatus.Processing, ct); + var ready = await db.UserBooks.CountAsync(b => b.Status == UserBookStatus.Ready, ct); + var failed = await db.UserBooks.CountAsync(b => b.Status == UserBookStatus.Failed, ct); + var guest = await db.UserBooks.CountAsync(b => b.User.IsGuest, ct); + var registered = total - guest; + var storageBytes = await db.UserBookFiles.Select(f => (long?)f.FileSize).SumAsync(ct) ?? 0; + + return new UserUploadStatsDto(total, processing, ready, failed, guest, registered, storageBytes); + } + + public async Task<(bool Success, string? Error)> DeleteUserUploadAsync(Guid id, CancellationToken ct) + { + var book = await db.UserBooks.FirstOrDefaultAsync(b => b.Id == id, ct); + if (book is null) + return (false, "User book not found"); + + return await userBookService.DeleteAsync(book.UserId, book.Id, ct); + } + + public async Task<(bool Success, string? Error)> TakedownUserUploadAsync(Guid id, string reason, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(reason)) + return (false, "Reason is required"); + + var book = await db.UserBooks.FirstOrDefaultAsync(b => b.Id == id, ct); + if (book is null) + return (false, "User book not found"); + + var trimmed = reason.Trim(); + if (trimmed.Length > 1000) + trimmed = trimmed[..1000]; + + book.TakedownAt = DateTimeOffset.UtcNow; + book.TakedownReason = trimmed; + book.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return (true, null); + } +} diff --git a/backend/src/Application/Admin/AdminService.cs b/backend/src/Application/Admin/AdminService.cs index 610b4eb5..e4083715 100644 --- a/backend/src/Application/Admin/AdminService.cs +++ b/backend/src/Application/Admin/AdminService.cs @@ -90,871 +90,25 @@ public record IngestionJobsQuery( public record ChapterPreviewDto(int ChapterNumber, string Title, string Preview, int TotalLength); -public class AdminService(IAppDbContext db, IFileStorageService storage, ISearchIndexer searchIndexer, SsgRebuildService ssgRebuildService, UserBookService userBookService) +/// +/// Admin service. Handlers are split across partial files by sub-domain +/// to keep each file under ~350 LOC and reviewable in isolation: +/// +/// - AdminService.Upload.cs ValidateUpload, GetOrCreateWork, UploadBook, ingestion jobs, RetryJob +/// - AdminService.Editions.cs GetStats, GetEditions, GetEditionDetail, Update/Delete/Publish/UnpublishEdition, IndexChapters +/// - AdminService.Chapters.cs GetChapterDetail, UpdateChapter, DeleteChapter (+ StripHtml/CountWords helpers) +/// - AdminService.UserUploads.cs GetUserUploads, GetUserUploadStats, DeleteUserUpload, TakedownUserUpload +/// +/// This file keeps the primary constructor (DI injection), file-level +/// constants, the shared EnqueueSsgSafe helper, and the file-scope DTOs +/// above. Splits use C# `partial` — compile-identical to the original +/// monolithic file. +/// +public partial class AdminService(IAppDbContext db, IFileStorageService storage, ISearchIndexer searchIndexer, SsgRebuildService ssgRebuildService, UserBookService userBookService) { private static readonly string[] AllowedExtensions = [".epub", ".pdf", ".fb2"]; private const long MaxFileSize = 100 * 1024 * 1024; - public async Task<(bool Valid, string? Error)> ValidateUploadAsync( - Guid siteId, string fileName, long fileSize, CancellationToken ct) - { - if (!await db.Sites.AnyAsync(s => s.Id == siteId, ct)) - return (false, "Invalid siteId"); - - if (fileSize == 0) - return (false, "File is empty"); - - if (fileSize > MaxFileSize) - return (false, $"File too large. Max {MaxFileSize / 1024 / 1024}MB"); - - var ext = Path.GetExtension(fileName).ToLowerInvariant(); - if (!AllowedExtensions.Contains(ext)) - return (false, $"Invalid file type. Allowed: {string.Join(", ", AllowedExtensions)}"); - - return (true, null); - } - - public async Task<(bool Valid, string? Error, Work? Work)> GetOrCreateWorkAsync( - Guid siteId, string title, Guid? workId, CancellationToken ct) - { - if (workId.HasValue) - { - var work = await db.Works.FindAsync([workId.Value], ct); - if (work is null) - return (false, "Work not found", null); - if (work.SiteId != siteId) - return (false, "Work belongs to different site", null); - return (true, null, work); - } - - var slug = SlugGenerator.GenerateSlug(title); - var existingWork = await db.Works - .FirstOrDefaultAsync(w => w.SiteId == siteId && w.Slug == slug, ct); - if (existingWork is not null) - return (true, null, existingWork); - - var newWork = new Work - { - Id = Guid.NewGuid(), - SiteId = siteId, - Slug = slug, - CreatedAt = DateTimeOffset.UtcNow - }; - db.Works.Add(newWork); - return (true, null, newWork); - } - - public async Task UploadBookAsync(UploadBookRequest req, Work work, CancellationToken ct) - { - var ext = Path.GetExtension(req.FileName).ToLowerInvariant(); - var format = ext switch - { - ".epub" => BookFormat.Epub, - ".pdf" => BookFormat.Pdf, - ".fb2" => BookFormat.Fb2, - _ => BookFormat.Other - }; - - var editionSlug = await GenerateUniqueEditionSlugAsync(req.SiteId, req.Title, req.Language, ct); - var edition = new Edition - { - Id = Guid.NewGuid(), - WorkId = work.Id, - SiteId = req.SiteId, - Language = req.Language, - Slug = editionSlug, - Title = req.Title, - Description = req.Description, - Status = EditionStatus.Draft, - SourceEditionId = req.SourceEditionId, - IsPublicDomain = false, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow - }; - db.Editions.Add(edition); - - // Add authors if provided - if (req.AuthorIds is { Count: > 0 }) - { - var order = 0; - foreach (var authorId in req.AuthorIds) - { - db.EditionAuthors.Add(new EditionAuthor - { - EditionId = edition.Id, - AuthorId = authorId, - Order = order++, - Role = AuthorRole.Author - }); - } - } - - // Add genre if provided (M2M via edition_genres) - if (req.GenreId.HasValue) - { - var genre = await db.Genres.FindAsync([req.GenreId.Value], ct); - if (genre is not null) - { - edition.Genres.Add(genre); - } - } - - var storagePath = await storage.SaveFileAsync(edition.Id, req.FileName, req.FileStream, ct); - - req.FileStream.Position = 0; - using var sha = SHA256.Create(); - var hashBytes = await sha.ComputeHashAsync(req.FileStream, ct); - var hash = Convert.ToHexString(hashBytes).ToLowerInvariant(); - - var bookFile = new BookFile - { - Id = Guid.NewGuid(), - EditionId = edition.Id, - OriginalFileName = req.FileName, - StoragePath = storagePath, - Format = format, - Sha256 = hash, - UploadedAt = DateTimeOffset.UtcNow - }; - db.BookFiles.Add(bookFile); - - var job = new IngestionJob - { - Id = Guid.NewGuid(), - EditionId = edition.Id, - BookFileId = bookFile.Id, - TargetLanguage = req.Language, - WorkId = req.WorkId, - SourceEditionId = req.SourceEditionId, - Status = JobStatus.Queued, - AttemptCount = 0, - CreatedAt = DateTimeOffset.UtcNow - }; - db.IngestionJobs.Add(job); - - await db.SaveChangesAsync(ct); - - return new UploadBookResult(work.Id, edition.Id, bookFile.Id, job.Id); - } - - public async Task> GetIngestionJobsAsync( - IngestionJobsQuery query, CancellationToken ct) - { - var q = db.IngestionJobs - .Include(j => j.Edition) - .Include(j => j.BookFile) - .AsQueryable(); - - if (query.Status.HasValue) - q = q.Where(j => j.Status == query.Status.Value); - - if (!string.IsNullOrWhiteSpace(query.Search)) - q = q.Where(j => j.Edition.Title.Contains(query.Search) || - j.BookFile.OriginalFileName.Contains(query.Search)); - - return await q - .OrderByDescending(j => j.CreatedAt) - .Skip(query.Offset) - .Take(query.Limit) - .Select(j => new IngestionJobDto( - j.Id, - j.EditionId, - j.Edition.Title, - j.BookFile.OriginalFileName, - j.Status.ToString(), - j.SourceFormat, - j.UnitsCount, - j.TextSource, - j.Error, - j.CreatedAt, - j.StartedAt, - j.FinishedAt - )) - .ToListAsync(ct); - } - - public async Task GetIngestionJobAsync(Guid id, CancellationToken ct) - { - var job = await db.IngestionJobs - .Include(j => j.Edition) - .Include(j => j.BookFile) - .FirstOrDefaultAsync(j => j.Id == id, ct); - - if (job is null) - return null; - - List? warnings = null; - if (!string.IsNullOrEmpty(job.WarningsJson)) - { - try - { - warnings = JsonSerializer.Deserialize>(job.WarningsJson); - } - catch - { - // Ignore deserialization errors - } - } - - var diagnostics = job.SourceFormat is not null - ? new IngestionDiagnosticsDto( - job.SourceFormat, - job.UnitsCount, - job.TextSource, - job.Confidence, - warnings) - : null; - - return new IngestionJobDetailDto( - job.Id, - job.EditionId, - job.BookFileId, - job.BookFile.OriginalFileName, - job.TargetLanguage, - job.Status, - job.AttemptCount, - job.Error, - job.CreatedAt, - job.StartedAt, - job.FinishedAt, - new IngestionEditionDto(job.Edition.Title, job.Edition.Language, job.Edition.Slug), - diagnostics - ); - } - - public async Task GetChapterPreviewAsync( - Guid jobId, int chapterIndex, int maxChars, CancellationToken ct) - { - maxChars = Math.Min(maxChars, 10000); // Enforce max limit - - var job = await db.IngestionJobs - .FirstOrDefaultAsync(j => j.Id == jobId, ct); - - if (job is null) - return null; - - var chapter = await db.Chapters - .Where(c => c.EditionId == job.EditionId) - .OrderBy(c => c.ChapterNumber) - .Skip(chapterIndex) - .FirstOrDefaultAsync(ct); - - if (chapter is null) - return null; - - var preview = chapter.PlainText.Length <= maxChars - ? chapter.PlainText - : chapter.PlainText[..maxChars] + "..."; - - return new ChapterPreviewDto( - chapter.ChapterNumber, - chapter.Title, - preview, - chapter.PlainText.Length - ); - } - - public async Task<(bool Success, string? Error, IngestionJobDetailDto? Job)> RetryJobAsync( - Guid id, CancellationToken ct) - { - var job = await db.IngestionJobs - .Include(j => j.Edition) - .Include(j => j.BookFile) - .FirstOrDefaultAsync(j => j.Id == id, ct); - - if (job is null) - return (false, "Job not found", null); - - // Idempotency: if already queued or processing, just return current state - if (job.Status == JobStatus.Queued || job.Status == JobStatus.Processing) - { - var currentDto = await GetIngestionJobAsync(id, ct); - return (true, null, currentDto); - } - - // Only allow retry for failed jobs - if (job.Status != JobStatus.Failed) - return (false, "Can only retry failed jobs", null); - - // Reset job for retry - job.Status = JobStatus.Queued; - job.Error = null; - job.StartedAt = null; - job.FinishedAt = null; - // Keep diagnostics from previous attempt for reference - // AttemptCount will be incremented when processing starts - - await db.SaveChangesAsync(ct); - - var dto = await GetIngestionJobAsync(id, ct); - return (true, null, dto); - } - - private async Task GenerateUniqueEditionSlugAsync( - Guid siteId, string title, string language, CancellationToken ct) - { - var baseSlug = SlugGenerator.GenerateSlug(title); - var slug = baseSlug; - var exists = await db.Editions.AnyAsync(e => e.SiteId == siteId && e.Language == language && e.Slug == slug, ct); - - if (exists) - { - slug = $"{baseSlug}-{language}"; - exists = await db.Editions.AnyAsync(e => e.SiteId == siteId && e.Language == language && e.Slug == slug, ct); - } - - var counter = 2; - while (exists) - { - slug = $"{baseSlug}-{language}-{counter}"; - exists = await db.Editions.AnyAsync(e => e.SiteId == siteId && e.Language == language && e.Slug == slug, ct); - counter++; - } - - return slug; - } - - // Edition CRUD - - public async Task GetStatsAsync(Guid? siteId, CancellationToken ct) - { - var editionQuery = db.Editions.AsQueryable(); - var authorQuery = db.Authors.AsQueryable(); - var chapterQuery = db.Chapters.AsQueryable(); - - if (siteId.HasValue) - { - editionQuery = editionQuery.Where(e => e.SiteId == siteId.Value); - authorQuery = authorQuery.Where(a => a.SiteId == siteId.Value); - chapterQuery = chapterQuery.Where(c => c.Edition.SiteId == siteId.Value); - } - - var totalEditions = await editionQuery.CountAsync(ct); - var publishedEditions = await editionQuery.Where(e => e.Status == EditionStatus.Published).CountAsync(ct); - var draftEditions = await editionQuery.Where(e => e.Status == EditionStatus.Draft).CountAsync(ct); - var totalChapters = await chapterQuery.CountAsync(ct); - var totalAuthors = await authorQuery.CountAsync(ct); - var totalUsers = await db.Users.CountAsync(ct); - - return new AdminStatsDto( - TotalEditions: totalEditions, - PublishedEditions: publishedEditions, - DraftEditions: draftEditions, - TotalChapters: totalChapters, - TotalAuthors: totalAuthors, - TotalUsers: totalUsers - ); - } - - public async Task> GetEditionsAsync( - Guid? siteId, int offset, int limit, EditionStatus? status, string? search, string? language, bool? indexable, bool? seoReady, string? sort, string? sortOrder, CancellationToken ct) - { - var query = db.Editions.AsQueryable(); - - if (siteId.HasValue) - query = query.Where(e => e.SiteId == siteId.Value); - - if (status.HasValue) - query = query.Where(e => e.Status == status.Value); - - if (!string.IsNullOrWhiteSpace(search)) - query = query.Where(e => e.Title.Contains(search) || e.EditionAuthors.Any(ea => ea.Author.Name.Contains(search))); - - if (!string.IsNullOrWhiteSpace(language)) - query = query.Where(e => e.Language == language); - - if (indexable.HasValue) - { - if (indexable.Value) - query = query.Where(e => e.Indexable && e.Status == EditionStatus.Published); - else - query = query.Where(e => !e.Indexable || e.Status != EditionStatus.Published); - } - - if (seoReady.HasValue) - { - if (seoReady.Value) - query = query.Where(e => - e.Description != null && e.Description != "" && - e.SeoRelevanceText != null && e.SeoRelevanceText != "" && - e.SeoThemesJson != null && e.SeoThemesJson != "" && - e.SeoFaqsJson != null && e.SeoFaqsJson != "" && - e.Chapters.Any()); - else - query = query.Where(e => - e.Description == null || e.Description == "" || - e.SeoRelevanceText == null || e.SeoRelevanceText == "" || - e.SeoThemesJson == null || e.SeoThemesJson == "" || - e.SeoFaqsJson == null || e.SeoFaqsJson == "" || - !e.Chapters.Any()); - } - - var total = await query.CountAsync(ct); - - var sortField = (sort ?? "createdat").ToLowerInvariant(); - var isDesc = (sortOrder ?? "desc").ToLowerInvariant() == "desc"; - - query = (sortField, isDesc) switch - { - ("title", false) => query.OrderBy(e => e.Title), - ("title", true) => query.OrderByDescending(e => e.Title), - ("createdat", false) => query.OrderBy(e => e.CreatedAt), - _ => query.OrderByDescending(e => e.CreatedAt) - }; - - var items = await query - .Skip(offset) - .Take(limit) - .Select(e => new AdminEditionListDto( - e.Id, - e.Slug, - e.Title, - e.Language, - e.Status.ToString(), - e.Chapters.Count, - e.CreatedAt, - e.PublishedAt, - string.Join(", ", e.EditionAuthors.OrderBy(ea => ea.Order).Select(ea => ea.Author.Name)), - e.Description != null && e.Description != "" && - e.SeoRelevanceText != null && e.SeoRelevanceText != "" && - e.SeoThemesJson != null && e.SeoThemesJson != "" && - e.SeoFaqsJson != null && e.SeoFaqsJson != "" && - e.Chapters.Any() - )) - .ToListAsync(ct); - - return new PaginatedResult(total, items); - } - - public async Task GetEditionDetailAsync(Guid id, CancellationToken ct) - { - return await db.Editions - .Where(e => e.Id == id) - .Select(e => new AdminEditionDetailDto( - e.Id, - e.WorkId, - e.SiteId, - e.Slug, - e.Title, - e.Language, - e.Description, - e.CoverPath, - e.Status.ToString(), - e.IsPublicDomain, - e.CreatedAt, - e.PublishedAt, - e.Chapters - .OrderBy(c => c.ChapterNumber) - .Select(c => new AdminChapterDto(c.Id, c.ChapterNumber, c.Slug ?? "", c.Title, c.WordCount)) - .ToList(), - e.EditionAuthors - .OrderBy(ea => ea.Order) - .Select(ea => new AdminEditionAuthorDto(ea.AuthorId, ea.Author.Slug, ea.Author.Name, ea.Order, ea.Role.ToString())) - .ToList(), - e.Genres - .OrderBy(g => g.Name) - .Select(g => new AdminEditionGenreDto(g.Id, g.Slug, g.Name)) - .ToList(), - e.Indexable, - e.SeoTitle, - e.SeoDescription, - e.CanonicalOverride, - e.SeoRelevanceText, - e.SeoThemesJson, - e.SeoFaqsJson - )) - .FirstOrDefaultAsync(ct); - } - - public async Task<(bool Success, string? Error)> UpdateEditionAsync( - Guid id, UpdateEditionRequest request, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(request.Title)) - return (false, "Title is required"); - - if (request.Title.Length > 500) - return (false, "Title must be 500 characters or less"); - - if (request.Description?.Length > 5000) - return (false, "Description must be 5000 characters or less"); - - var edition = await db.Editions.FindAsync([id], ct); - if (edition is null) - return (false, "Edition not found"); - - edition.Title = request.Title; - edition.Description = request.Description; - edition.UpdatedAt = DateTimeOffset.UtcNow; - - // SEO fields - if (request.Indexable.HasValue) - edition.Indexable = request.Indexable.Value; - edition.SeoTitle = request.SeoTitle; - edition.SeoDescription = request.SeoDescription; - edition.CanonicalOverride = request.CanonicalOverride; - - // SEO content blocks - edition.SeoRelevanceText = request.SeoRelevanceText; - edition.SeoThemesJson = request.SeoThemesJson; - edition.SeoFaqsJson = request.SeoFaqsJson; - - // Handle author assignment - if (request.Authors is not null) - { - // Remove existing author associations - var existingAuthors = await db.EditionAuthors - .Where(ea => ea.EditionId == id) - .ToListAsync(ct); - db.EditionAuthors.RemoveRange(existingAuthors); - - // Add new author associations with order - for (var i = 0; i < request.Authors.Count; i++) - { - var authorDto = request.Authors[i]; - var role = Enum.TryParse(authorDto.Role, true, out var parsedRole) - ? parsedRole - : AuthorRole.Author; - - db.EditionAuthors.Add(new EditionAuthor - { - EditionId = id, - AuthorId = authorDto.AuthorId, - Order = i, - Role = role - }); - } - } - - // Handle genre assignment - if (request.GenreIds is not null) - { - // Load edition with genres for M2M update - var editionWithGenres = await db.Editions - .Include(e => e.Genres) - .FirstAsync(e => e.Id == id, ct); - - // Clear existing genres - editionWithGenres.Genres.Clear(); - - // Add new genres - if (request.GenreIds.Count > 0) - { - var genres = await db.Genres - .Where(g => request.GenreIds.Contains(g.Id) && g.SiteId == edition.SiteId) - .ToListAsync(ct); - - foreach (var genre in genres) - { - editionWithGenres.Genres.Add(genre); - } - } - } - - await db.SaveChangesAsync(ct); - - if (edition.Status == EditionStatus.Published) - _ = EnqueueSsgSafe(edition.SiteId, bookSlugs: [edition.Slug]); - - return (true, null); - } - - public async Task<(bool Success, string? Error)> DeleteEditionAsync(Guid id, CancellationToken ct) - { - var edition = await db.Editions - .Include(e => e.Chapters) - .Include(e => e.BookFiles) - .FirstOrDefaultAsync(e => e.Id == id, ct); - - if (edition is null) - return (false, "Edition not found"); - - if (edition.Status == EditionStatus.Published) - return (false, "Cannot delete published edition. Unpublish first."); - - // Delete related entities - db.Chapters.RemoveRange(edition.Chapters); - db.BookFiles.RemoveRange(edition.BookFiles); - - // Delete ingestion jobs - var jobs = await db.IngestionJobs.Where(j => j.EditionId == id).ToListAsync(ct); - db.IngestionJobs.RemoveRange(jobs); - - db.Editions.Remove(edition); - - await db.SaveChangesAsync(ct); - return (true, null); - } - - public async Task<(bool Success, string? Error)> PublishEditionAsync(Guid id, CancellationToken ct) - { - var edition = await db.Editions - .Include(e => e.Chapters) - .Include(e => e.EditionAuthors) - .ThenInclude(ea => ea.Author) - .FirstOrDefaultAsync(e => e.Id == id, ct); - - if (edition is null) - return (false, "Edition not found"); - - if (edition.Status == EditionStatus.Published) - return (false, "Edition is already published"); - - if (edition.Chapters.Count == 0) - return (false, "Cannot publish edition with no chapters"); - - edition.Status = EditionStatus.Published; - edition.PublishedAt = DateTimeOffset.UtcNow; - edition.UpdatedAt = DateTimeOffset.UtcNow; - - await db.SaveChangesAsync(ct); - - // Index chapters for search - await IndexChaptersAsync(edition, ct); - - // Trigger SSG rebuild for this book (fire and forget) - _ = EnqueueSsgSafe(edition.SiteId, bookSlugs: [edition.Slug]); - - return (true, null); - } - - private async Task IndexChaptersAsync(Edition edition, CancellationToken ct) - { - var searchLang = edition.Language switch - { - "en" => SearchLanguage.En, - _ => SearchLanguage.Auto - }; - - var authors = string.Join(", ", edition.EditionAuthors.OrderBy(ea => ea.Order).Select(ea => ea.Author.Name)); - - var documents = edition.Chapters.Select(chapter => new IndexDocument( - Id: chapter.Id.ToString(), - Title: chapter.Title, - Content: chapter.PlainText, - Language: searchLang, - SiteId: edition.SiteId, - Metadata: new Dictionary - { - ["chapterId"] = chapter.Id, - ["chapterSlug"] = chapter.Slug ?? string.Empty, - ["chapterTitle"] = chapter.Title, - ["chapterNumber"] = chapter.ChapterNumber, - ["editionId"] = edition.Id, - ["editionSlug"] = edition.Slug, - ["editionTitle"] = edition.Title, - ["language"] = edition.Language, - ["authors"] = authors, - ["coverPath"] = edition.CoverPath ?? string.Empty - } - )).ToList(); - - if (documents.Count > 0) - { - await searchIndexer.IndexBatchAsync(documents, ct); - } - } - - public async Task<(bool Success, string? Error)> UnpublishEditionAsync(Guid id, CancellationToken ct) - { - var edition = await db.Editions.FindAsync([id], ct); - - if (edition is null) - return (false, "Edition not found"); - - if (edition.Status != EditionStatus.Published) - return (false, "Edition is not published"); - - edition.Status = EditionStatus.Draft; - edition.UpdatedAt = DateTimeOffset.UtcNow; - - await db.SaveChangesAsync(ct); - - // Full rebuild — book removed from public listings - _ = EnqueueSsgSafe(edition.SiteId); - - return (true, null); - } - - // Chapter CRUD - - public async Task GetChapterDetailAsync(Guid id, CancellationToken ct) - { - return await db.Chapters - .Where(c => c.Id == id) - .Select(c => new AdminChapterDetailDto( - c.Id, - c.EditionId, - c.ChapterNumber, - c.Slug, - c.Title, - c.Html, - c.WordCount, - c.CreatedAt, - c.UpdatedAt - )) - .FirstOrDefaultAsync(ct); - } - - public async Task<(bool Success, string? Error)> UpdateChapterAsync( - Guid id, UpdateChapterRequest request, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(request.Title)) - return (false, "Title is required"); - - if (string.IsNullOrWhiteSpace(request.Html)) - return (false, "Content is required"); - - var chapter = await db.Chapters.FindAsync([id], ct); - if (chapter is null) - return (false, "Chapter not found"); - - chapter.Title = request.Title; - chapter.Html = request.Html; - chapter.PlainText = StripHtml(request.Html); - chapter.WordCount = CountWords(chapter.PlainText); - chapter.UpdatedAt = DateTimeOffset.UtcNow; - - await db.SaveChangesAsync(ct); - return (true, null); - } - - public async Task<(bool Success, string? Error)> DeleteChapterAsync(Guid id, CancellationToken ct) - { - var chapter = await db.Chapters.FindAsync([id], ct); - if (chapter is null) - return (false, "Chapter not found"); - - var editionId = chapter.EditionId; - var deletedNumber = chapter.ChapterNumber; - - db.Chapters.Remove(chapter); - - // Renumber remaining chapters - var remaining = await db.Chapters - .Where(c => c.EditionId == editionId && c.ChapterNumber > deletedNumber) - .OrderBy(c => c.ChapterNumber) - .ToListAsync(ct); - - foreach (var ch in remaining) - { - ch.ChapterNumber--; - ch.UpdatedAt = DateTimeOffset.UtcNow; - } - - await db.SaveChangesAsync(ct); - return (true, null); - } - - private static string StripHtml(string html) - { - if (string.IsNullOrEmpty(html)) - return string.Empty; - - // Simple regex-based HTML stripping - var text = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " "); - text = System.Net.WebUtility.HtmlDecode(text); - text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " "); - return text.Trim(); - } - - private static int CountWords(string text) - { - if (string.IsNullOrWhiteSpace(text)) - return 0; - return text.Split([' ', '\n', '\r', '\t'], StringSplitOptions.RemoveEmptyEntries).Length; - } - - // User Uploads - - public async Task> GetUserUploadsAsync( - UserUploadsQuery query, CancellationToken ct) - { - var q = db.UserBooks.AsQueryable(); - - if (query.Status.HasValue) - q = q.Where(b => b.Status == query.Status.Value); - - if (string.Equals(query.UserType, "guest", StringComparison.OrdinalIgnoreCase)) - q = q.Where(b => b.User.IsGuest); - else if (string.Equals(query.UserType, "registered", StringComparison.OrdinalIgnoreCase)) - q = q.Where(b => !b.User.IsGuest); - - if (!string.IsNullOrWhiteSpace(query.Search)) - q = q.Where(b => b.Title.Contains(query.Search) || - (b.Author != null && b.Author.Contains(query.Search)) || - b.User.Email.Contains(query.Search)); - - var total = await q.CountAsync(ct); - - var items = await q - .OrderByDescending(b => b.CreatedAt) - .Skip(query.Offset) - .Take(query.Limit) - .Select(b => new UserUploadListDto( - b.Id, - b.Title, - b.Author, - b.Language, - b.Status.ToString(), - b.Chapters.Count, - b.TotalWordCount, - b.BookFiles.Sum(f => f.FileSize), - b.BookFiles.Select(f => f.Format.ToString()).FirstOrDefault(), - b.BookFiles.Select(f => f.OriginalFileName).FirstOrDefault(), - b.User.Email, - b.User.IsGuest, - b.ErrorMessage, - b.CreatedAt, - b.TakedownAt, - b.TakedownReason)) - .ToListAsync(ct); - - return new PaginatedResult(total, items); - } - - public async Task GetUserUploadStatsAsync(CancellationToken ct) - { - var total = await db.UserBooks.CountAsync(ct); - var processing = await db.UserBooks.CountAsync(b => b.Status == UserBookStatus.Processing, ct); - var ready = await db.UserBooks.CountAsync(b => b.Status == UserBookStatus.Ready, ct); - var failed = await db.UserBooks.CountAsync(b => b.Status == UserBookStatus.Failed, ct); - var guest = await db.UserBooks.CountAsync(b => b.User.IsGuest, ct); - var registered = total - guest; - var storageBytes = await db.UserBookFiles.Select(f => (long?)f.FileSize).SumAsync(ct) ?? 0; - - return new UserUploadStatsDto(total, processing, ready, failed, guest, registered, storageBytes); - } - - public async Task<(bool Success, string? Error)> DeleteUserUploadAsync(Guid id, CancellationToken ct) - { - var book = await db.UserBooks.FirstOrDefaultAsync(b => b.Id == id, ct); - if (book is null) - return (false, "User book not found"); - - return await userBookService.DeleteAsync(book.UserId, book.Id, ct); - } - - public async Task<(bool Success, string? Error)> TakedownUserUploadAsync(Guid id, string reason, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(reason)) - return (false, "Reason is required"); - - var book = await db.UserBooks.FirstOrDefaultAsync(b => b.Id == id, ct); - if (book is null) - return (false, "User book not found"); - - var trimmed = reason.Trim(); - if (trimmed.Length > 1000) - trimmed = trimmed[..1000]; - - book.TakedownAt = DateTimeOffset.UtcNow; - book.TakedownReason = trimmed; - book.UpdatedAt = DateTimeOffset.UtcNow; - - await db.SaveChangesAsync(ct); - return (true, null); - } - private async Task EnqueueSsgSafe(Guid siteId, string[]? bookSlugs = null, string[]? authorSlugs = null, string[]? genreSlugs = null) { try diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.Catalog.cs b/backend/src/Infrastructure/Persistence/AppDbContext.Catalog.cs new file mode 100644 index 00000000..318cc587 --- /dev/null +++ b/backend/src/Infrastructure/Persistence/AppDbContext.Catalog.cs @@ -0,0 +1,155 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Persistence; + +/// +/// Catalog entity configurations: Site graph + Work/Edition/Chapter + +/// ingestion plumbing (BookFile, IngestionJob) + Author/Genre + +/// import provenance + extracted assets. +/// +/// Split off from the monolithic AppDbContext.cs to keep each domain +/// reviewable in isolation. Logic is byte-identical to the pre-split +/// version — see ADR-006 / migration snapshot for invariants. +/// +public partial class AppDbContext +{ + private static void ConfigureCatalog(ModelBuilder modelBuilder) + { + // Site + modelBuilder.Entity(e => + { + e.HasIndex(x => x.Code).IsUnique(); + e.HasIndex(x => x.PrimaryDomain).IsUnique(); + e.Property(x => x.Code).HasMaxLength(50); + e.Property(x => x.PrimaryDomain).HasMaxLength(255); + e.Property(x => x.DefaultLanguage).HasMaxLength(10); + e.Property(x => x.Theme).HasMaxLength(50); + e.Property(x => x.FeaturesJson).HasColumnType("jsonb"); + }); + + // SiteDomain + modelBuilder.Entity(e => + { + e.HasIndex(x => x.Domain).IsUnique(); + e.Property(x => x.Domain).HasMaxLength(255); + e.HasOne(x => x.Site).WithMany(x => x.Domains).HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Cascade); + }); + + // Work + modelBuilder.Entity(e => + { + e.HasIndex(x => x.SiteId); + e.HasIndex(x => new { x.SiteId, x.Slug }).IsUnique(); + e.HasOne(x => x.Site).WithMany(x => x.Works).HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + }); + + // Edition + modelBuilder.Entity(e => + { + e.HasIndex(x => x.SiteId); + e.HasIndex(x => x.SourceEditionId); + e.HasIndex(x => x.Status); + e.HasIndex(x => new { x.WorkId, x.Language }).IsUnique(); + e.HasIndex(x => new { x.SiteId, x.Language, x.Slug }).IsUnique(); + e.Property(x => x.Language).HasMaxLength(8); + e.Property(x => x.TocJson).HasColumnType("jsonb"); + e.HasOne(x => x.Work).WithMany(x => x.Editions).HasForeignKey(x => x.WorkId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.SourceEdition).WithMany(x => x.TranslatedEditions).HasForeignKey(x => x.SourceEditionId).OnDelete(DeleteBehavior.SetNull); + }); + + // Chapter + modelBuilder.Entity(e => + { + e.HasIndex(x => new { x.EditionId, x.ChapterNumber }).IsUnique(); + e.HasIndex(x => new { x.EditionId, x.Slug }); + e.HasIndex(x => x.SearchVector).HasMethod("GIN"); + e.Property(x => x.SearchVector).HasColumnType("tsvector"); + e.HasOne(x => x.Edition).WithMany(x => x.Chapters).HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + }); + + // BookFile + modelBuilder.Entity(e => + { + e.HasIndex(x => x.EditionId); + e.HasIndex(x => x.Sha256); + e.HasOne(x => x.Edition).WithMany(x => x.BookFiles).HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + }); + + // IngestionJob + modelBuilder.Entity(e => + { + e.HasIndex(x => x.BookFileId); + e.HasIndex(x => x.EditionId); + e.HasIndex(x => x.SourceEditionId); + e.HasIndex(x => x.WorkId); + e.HasIndex(x => x.Status); + e.HasIndex(x => x.CreatedAt); + e.Property(x => x.TargetLanguage).HasMaxLength(8); + e.Property(x => x.SourceFormat).HasMaxLength(20); + e.Property(x => x.TextSource).HasMaxLength(20); + e.Property(x => x.WarningsJson).HasColumnType("jsonb"); + e.HasOne(x => x.BookFile).WithMany(x => x.IngestionJobs).HasForeignKey(x => x.BookFileId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Edition).WithMany(x => x.IngestionJobs).HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.SourceEdition).WithMany().HasForeignKey(x => x.SourceEditionId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.Work).WithMany().HasForeignKey(x => x.WorkId).OnDelete(DeleteBehavior.SetNull); + }); + + // Author + modelBuilder.Entity(e => + { + e.HasIndex(x => x.SiteId); + e.HasIndex(x => new { x.SiteId, x.Slug }).IsUnique(); + e.Property(x => x.Slug).HasMaxLength(255); + e.Property(x => x.Name).HasMaxLength(255); + e.Property(x => x.ExternalLinksJson).HasColumnType("jsonb"); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + }); + + // EditionAuthor (junction table with order + role) + modelBuilder.Entity(e => + { + e.ToTable("edition_authors"); + e.HasKey(x => new { x.EditionId, x.AuthorId }); + e.HasIndex(x => x.AuthorId); + e.Property(x => x.Role).HasConversion().HasMaxLength(50); + e.HasOne(x => x.Edition).WithMany(x => x.EditionAuthors).HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Author).WithMany(x => x.EditionAuthors).HasForeignKey(x => x.AuthorId).OnDelete(DeleteBehavior.Cascade); + }); + + // Genre + modelBuilder.Entity(e => + { + e.HasIndex(x => x.SiteId); + e.HasIndex(x => new { x.SiteId, x.Slug }).IsUnique(); + e.Property(x => x.Slug).HasMaxLength(100); + e.Property(x => x.Name).HasMaxLength(100); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + e.HasMany(x => x.Editions).WithMany(x => x.Genres).UsingEntity("edition_genres"); + }); + + // TextStackImport — provenance link for imported books. + modelBuilder.Entity(e => + { + e.HasIndex(x => x.SiteId); + e.HasIndex(x => x.EditionId); + e.HasIndex(x => new { x.SiteId, x.Identifier }).IsUnique(); + e.Property(x => x.Identifier).HasMaxLength(500); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + }); + + // BookAsset — extracted images/etc tied to an edition. + modelBuilder.Entity(e => + { + e.HasIndex(x => x.EditionId); + e.HasIndex(x => new { x.EditionId, x.OriginalPath }).IsUnique(); + e.Property(x => x.Kind).HasConversion().HasMaxLength(20); + e.Property(x => x.OriginalPath).HasMaxLength(500); + e.Property(x => x.StoragePath).HasMaxLength(500); + e.Property(x => x.ContentType).HasMaxLength(100); + e.HasOne(x => x.Edition).WithMany(x => x.Assets).HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.Collections.cs b/backend/src/Infrastructure/Persistence/AppDbContext.Collections.cs new file mode 100644 index 00000000..9f51abf9 --- /dev/null +++ b/backend/src/Infrastructure/Persistence/AppDbContext.Collections.cs @@ -0,0 +1,35 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Persistence; + +/// +/// Per-user book collections (my-books-v2 slice 13) — user-defined folders +/// that mix catalog editions + user uploads. +/// +public partial class AppDbContext +{ + private static void ConfigureCollections(ModelBuilder modelBuilder) + { + // Collection + modelBuilder.Entity(e => + { + e.HasIndex(x => x.UserId); + e.HasIndex(x => new { x.UserId, x.SortOrder }); + e.Property(x => x.Name).HasMaxLength(100).IsRequired(); + e.Property(x => x.Color).HasMaxLength(20).HasDefaultValue("default"); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + }); + + // BookCollection — composite PK on (CollectionId, BookId, BookType) + // so the same collection can hold both edition IDs and user-book IDs + // without a UUID collision masking double-add of a single source. + modelBuilder.Entity(e => + { + e.HasKey(x => new { x.CollectionId, x.BookId, x.BookType }); + e.HasIndex(x => x.BookId); + e.Property(x => x.BookType).HasMaxLength(20).IsRequired(); + e.HasOne(x => x.Collection).WithMany(c => c.Books).HasForeignKey(x => x.CollectionId).OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.Ops.cs b/backend/src/Infrastructure/Persistence/AppDbContext.Ops.cs new file mode 100644 index 00000000..8c0b4e82 --- /dev/null +++ b/backend/src/Infrastructure/Persistence/AppDbContext.Ops.cs @@ -0,0 +1,55 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Persistence; + +/// +/// Operational entities — admin settings + SSG rebuild bookkeeping + +/// lint result rows. Not tied to a single product domain. +/// +public partial class AppDbContext +{ + private static void ConfigureOps(ModelBuilder modelBuilder) + { + // AdminSettings — key/value store, primary key is the key string. + modelBuilder.Entity(e => + { + e.HasKey(x => x.Key); + e.Property(x => x.Key).HasMaxLength(100); + e.Property(x => x.Value).HasMaxLength(500); + }); + + // SsgRebuildJob + modelBuilder.Entity(e => + { + e.HasIndex(x => x.SiteId); + e.HasIndex(x => x.Status); + e.HasIndex(x => x.CreatedAt); + e.Property(x => x.Mode).HasConversion().HasMaxLength(20); + e.Property(x => x.Status).HasConversion().HasMaxLength(20); + e.Property(x => x.BookSlugsJson).HasColumnType("jsonb"); + e.Property(x => x.AuthorSlugsJson).HasColumnType("jsonb"); + e.Property(x => x.GenreSlugsJson).HasColumnType("jsonb"); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + }); + + // SsgRebuildResult + modelBuilder.Entity(e => + { + e.HasIndex(x => x.JobId); + e.HasIndex(x => new { x.JobId, x.Route }).IsUnique(); + e.Property(x => x.Route).HasMaxLength(500); + e.Property(x => x.RouteType).HasMaxLength(20); + e.HasOne(x => x.Job).WithMany(x => x.Results).HasForeignKey(x => x.JobId).OnDelete(DeleteBehavior.Cascade); + }); + + // LintResult + modelBuilder.Entity(e => + { + e.HasIndex(x => x.EditionId); + e.Property(x => x.Severity).HasConversion().HasMaxLength(20); + e.Property(x => x.Code).HasMaxLength(10); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.Reading.cs b/backend/src/Infrastructure/Persistence/AppDbContext.Reading.cs new file mode 100644 index 00000000..90ec4375 --- /dev/null +++ b/backend/src/Infrastructure/Persistence/AppDbContext.Reading.cs @@ -0,0 +1,107 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Persistence; + +/// +/// Per-user reading interactions: progress, bookmarks, notes, highlights, +/// session telemetry, goals, and achievements. +/// +public partial class AppDbContext +{ + private static void ConfigureReading(ModelBuilder modelBuilder) + { + // ReadingProgress + modelBuilder.Entity(e => + { + e.HasIndex(x => x.ChapterId); + e.HasIndex(x => x.EditionId); + e.HasIndex(x => x.SiteId); + e.HasIndex(x => new { x.UserId, x.SiteId, x.EditionId }).IsUnique(); + e.HasOne(x => x.User).WithMany(x => x.ReadingProgresses).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Chapter).WithMany(x => x.ReadingProgresses).HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + }); + + // Bookmark + modelBuilder.Entity(e => + { + e.HasIndex(x => x.ChapterId); + e.HasIndex(x => x.EditionId); + e.HasIndex(x => x.SiteId); + e.HasIndex(x => new { x.UserId, x.SiteId, x.EditionId }); + e.HasOne(x => x.User).WithMany(x => x.Bookmarks).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Chapter).WithMany(x => x.Bookmarks).HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + }); + + // Note + modelBuilder.Entity(e => + { + e.HasIndex(x => x.ChapterId); + e.HasIndex(x => x.EditionId); + e.HasIndex(x => x.SiteId); + e.HasIndex(x => x.HighlightId); + e.HasIndex(x => new { x.UserId, x.SiteId, x.EditionId }); + e.HasOne(x => x.User).WithMany(x => x.Notes).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Chapter).WithMany(x => x.Notes).HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Highlight).WithOne(x => x.Note).HasForeignKey(x => x.HighlightId).OnDelete(DeleteBehavior.SetNull); + }); + + // Highlight — can attach to either an Edition+Chapter or a UserBook+UserChapter. + modelBuilder.Entity(e => + { + e.HasIndex(x => x.ChapterId); + e.HasIndex(x => x.EditionId); + e.HasIndex(x => x.SiteId); + e.HasIndex(x => x.UserBookId); + e.HasIndex(x => new { x.UserId, x.SiteId, x.EditionId }).HasFilter("edition_id IS NOT NULL"); + e.HasIndex(x => new { x.UserId, x.SiteId, x.UserBookId }).HasFilter("user_book_id IS NOT NULL"); + e.Property(x => x.AnchorJson).HasColumnType("jsonb"); + e.Property(x => x.Color).HasMaxLength(20); + e.HasOne(x => x.User).WithMany(x => x.Highlights).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.Chapter).WithMany().HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.UserChapter).WithMany().HasForeignKey(x => x.UserChapterId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + }); + + // ReadingSession + modelBuilder.Entity(e => + { + e.HasIndex(x => new { x.UserId, x.SiteId }); + e.HasIndex(x => new { x.UserId, x.StartedAt }); + e.HasIndex(x => new { x.UserId, x.EditionId, x.StartedAt }).IsUnique().HasFilter("edition_id IS NOT NULL"); + e.HasIndex(x => new { x.UserId, x.UserBookId, x.StartedAt }).IsUnique().HasFilter("user_book_id IS NOT NULL"); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); + }); + + // ReadingGoal + modelBuilder.Entity(e => + { + e.HasIndex(x => new { x.UserId, x.SiteId }); + e.HasIndex(x => new { x.UserId, x.SiteId, x.GoalType }).IsUnique(); + e.Property(x => x.GoalType).HasMaxLength(50); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + }); + + // UserAchievement + modelBuilder.Entity(e => + { + e.HasIndex(x => new { x.UserId, x.SiteId }); + e.HasIndex(x => new { x.UserId, x.SiteId, x.AchievementCode }).IsUnique(); + e.Property(x => x.AchievementCode).HasMaxLength(50); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + }); + } +} diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.Seo.cs b/backend/src/Infrastructure/Persistence/AppDbContext.Seo.cs new file mode 100644 index 00000000..2bae0507 --- /dev/null +++ b/backend/src/Infrastructure/Persistence/AppDbContext.Seo.cs @@ -0,0 +1,58 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Persistence; + +/// +/// SEO automation: editable Claude templates + per-job audit trail + +/// settings singleton + default `SeoSource = Manual` on SEO-bearing +/// catalog entities (Author, Edition, Genre) so existing rows are +/// flagged as user-curated and protected from auto-overwrite. +/// +public partial class AppDbContext +{ + private static void ConfigureSeo(ModelBuilder modelBuilder) + { + // SeoTemplate — editable Claude prompts per entity_type × field_type × language, version-frozen. + modelBuilder.Entity(e => + { + e.HasIndex(x => new { x.EntityType, x.FieldType, x.LanguageCode, x.IsActive }); + e.Property(x => x.LanguageCode).HasMaxLength(8); + e.Property(x => x.Name).HasMaxLength(200); + e.Property(x => x.Description).HasMaxLength(500); + e.Property(x => x.Model).HasMaxLength(100); + e.Property(x => x.PromptTemplate).HasColumnType("text"); + e.Property(x => x.OutputSchema).HasColumnType("jsonb"); + }); + + // SeoBackfillJob — audit trail for every run, frozen template versions enable replay. + modelBuilder.Entity(e => + { + e.HasIndex(x => new { x.Status, x.CreatedAt }); + e.HasIndex(x => new { x.EntityType, x.EntityId }); + e.Property(x => x.TargetFields).HasColumnType("text[]"); + e.Property(x => x.TemplateIds).HasColumnType("uuid[]"); + e.Property(x => x.TemplateVersions).HasColumnType("integer[]"); + e.Property(x => x.TriggeredBy).HasMaxLength(200); + e.Property(x => x.InputSnapshot).HasColumnType("jsonb"); + e.Property(x => x.RenderedPrompts).HasColumnType("jsonb"); + e.Property(x => x.RawOutputs).HasColumnType("jsonb"); + e.Property(x => x.GeneratedContent).HasColumnType("jsonb"); + e.Property(x => x.BeforeSnapshot).HasColumnType("jsonb"); + e.Property(x => x.AfterSnapshot).HasColumnType("jsonb"); + }); + + // SeoBackfillSettings — singleton. + modelBuilder.Entity(e => + { + e.Property(x => x.LanguageFilter).HasColumnType("text[]"); + e.Property(x => x.EntityTypeFilter).HasColumnType("text[]"); + }); + + // seo_source on SEO-bearing entities — default 'Manual' (0) preserves existing rows. + // These augment the entity configs in Catalog.cs without conflicting; EF merges them. + modelBuilder.Entity().Property(x => x.SeoSource).HasDefaultValue(Domain.Enums.SeoSource.Manual); + modelBuilder.Entity().Property(x => x.SeoSource).HasDefaultValue(Domain.Enums.SeoSource.Manual); + modelBuilder.Entity().Property(x => x.SeoSource).HasDefaultValue(Domain.Enums.SeoSource.Manual); + } +} diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.User.cs b/backend/src/Infrastructure/Persistence/AppDbContext.User.cs new file mode 100644 index 00000000..341d2fd0 --- /dev/null +++ b/backend/src/Infrastructure/Persistence/AppDbContext.User.cs @@ -0,0 +1,73 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Persistence; + +/// +/// User account configurations: end-user + admin user + refresh tokens + +/// password reset. UserLibrary lives here too (per-user "saved books" list). +/// +public partial class AppDbContext +{ + private static void ConfigureUser(ModelBuilder modelBuilder) + { + // User + modelBuilder.Entity(e => + { + e.HasIndex(x => x.Email).IsUnique(); + e.HasIndex(x => x.GoogleSubject).IsUnique().HasFilter("google_subject IS NOT NULL"); + e.HasIndex(x => x.AppleSubject).IsUnique().HasFilter("apple_subject IS NOT NULL"); + e.Property(x => x.Email).HasMaxLength(255); + e.Property(x => x.GoogleSubject).HasMaxLength(255); + e.Property(x => x.AppleSubject).HasMaxLength(255); + e.Property(x => x.PasswordHash).HasMaxLength(255); + e.Property(x => x.Name).HasMaxLength(255); + e.Property(x => x.NativeLanguage).HasMaxLength(16); + e.Property(x => x.IsGuest).HasDefaultValue(false); + e.HasIndex(x => new { x.IsGuest, x.LastActiveAt }) + .HasFilter("is_guest = true") + .HasDatabaseName("ix_users_guest_cleanup"); + }); + + // UserLibrary + modelBuilder.Entity(e => + { + e.HasIndex(x => x.EditionId); + e.HasIndex(x => new { x.UserId, x.EditionId }).IsUnique(); + e.HasOne(x => x.User).WithMany(x => x.UserLibraries).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + }); + + // AdminUser + modelBuilder.Entity(e => + { + e.HasIndex(x => x.Email).IsUnique(); + }); + + // AdminRefreshToken + modelBuilder.Entity(e => + { + e.HasIndex(x => x.AdminUserId); + e.HasIndex(x => x.ExpiresAt); + e.HasIndex(x => x.Token).IsUnique(); + e.HasOne(x => x.AdminUser).WithMany(x => x.RefreshTokens).HasForeignKey(x => x.AdminUserId).OnDelete(DeleteBehavior.Cascade); + }); + + // UserRefreshToken + modelBuilder.Entity(e => + { + e.HasIndex(x => x.UserId); + e.HasIndex(x => x.ExpiresAt); + e.HasIndex(x => x.Token).IsUnique(); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + }); + + // PasswordResetToken + modelBuilder.Entity(e => + { + e.HasIndex(x => x.TokenHash).IsUnique(); + e.Property(x => x.TokenHash).HasMaxLength(128); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.UserBooks.cs b/backend/src/Infrastructure/Persistence/AppDbContext.UserBooks.cs new file mode 100644 index 00000000..6d9529ed --- /dev/null +++ b/backend/src/Infrastructure/Persistence/AppDbContext.UserBooks.cs @@ -0,0 +1,96 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Persistence; + +/// +/// User-uploaded book pipeline: UserBook + chapters/files/ingestion + +/// per-user bookmarks for uploads + BookQualityJob (tightly coupled to +/// the upload quality-scoring loop). +/// +public partial class AppDbContext +{ + private static void ConfigureUserBooks(ModelBuilder modelBuilder) + { + // UserBook + modelBuilder.Entity(e => + { + e.HasIndex(x => x.UserId); + e.HasIndex(x => x.Status); + e.HasIndex(x => new { x.UserId, x.Slug }).IsUnique(); + e.Property(x => x.Title).HasMaxLength(500); + e.Property(x => x.Slug).HasMaxLength(500); + e.Property(x => x.Language).HasMaxLength(10); + e.Property(x => x.Author).HasMaxLength(500); + e.Property(x => x.CoverPath).HasMaxLength(500); + e.Property(x => x.Genre).HasMaxLength(200); + e.Property(x => x.TocJson).HasColumnType("jsonb"); + e.Property(x => x.TakedownReason).HasMaxLength(1000); + e.Property(x => x.SeoSource).HasMaxLength(20).HasDefaultValue("auto"); + e.Property(x => x.MetadataHistoryJson).HasColumnType("jsonb"); + e.Property(x => x.Tags).HasColumnType("text[]").HasDefaultValueSql("ARRAY[]::text[]"); + e.HasIndex(x => x.Tags).HasMethod("gin"); + e.Property(x => x.SuggestedTags).HasColumnType("text[]").HasDefaultValueSql("ARRAY[]::text[]"); + e.HasOne(x => x.User).WithMany(x => x.UserBooks).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + }); + + // UserChapter + modelBuilder.Entity(e => + { + e.HasIndex(x => x.UserBookId); + e.HasIndex(x => new { x.UserBookId, x.ChapterNumber }).IsUnique(); + e.HasIndex(x => new { x.UserBookId, x.Slug }).IsUnique(); + e.Property(x => x.Title).HasMaxLength(500); + e.Property(x => x.Slug).HasMaxLength(255); + e.HasOne(x => x.UserBook).WithMany(x => x.Chapters).HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.Cascade); + }); + + // UserBookFile + modelBuilder.Entity(e => + { + e.HasIndex(x => x.UserBookId); + e.HasIndex(x => x.Sha256); + e.Property(x => x.OriginalFileName).HasMaxLength(500); + e.Property(x => x.StoragePath).HasMaxLength(500); + e.Property(x => x.Sha256).HasMaxLength(64); + e.HasOne(x => x.UserBook).WithMany(x => x.BookFiles).HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.Cascade); + }); + + // UserIngestionJob + modelBuilder.Entity(e => + { + e.HasIndex(x => x.UserBookId); + e.HasIndex(x => x.UserBookFileId); + e.HasIndex(x => x.Status); + e.HasIndex(x => x.CreatedAt); + e.Property(x => x.SourceFormat).HasMaxLength(50); + e.HasOne(x => x.UserBook).WithMany(x => x.IngestionJobs).HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.UserBookFile).WithMany().HasForeignKey(x => x.UserBookFileId).OnDelete(DeleteBehavior.Cascade); + }); + + // UserBookBookmark + modelBuilder.Entity(e => + { + e.ToTable("user_book_bookmarks"); + e.HasKey(x => x.Id); + e.HasIndex(x => x.UserBookId); + e.Property(x => x.Locator).HasMaxLength(1000); + e.Property(x => x.Title).HasMaxLength(500); + e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Chapter).WithMany().HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.Cascade); + }); + + // BookQualityJob — tracks Phase 3 content-quality cleanup runs. + // Lives with user books because most jobs target user uploads; + // edition jobs are admin-side rare. + modelBuilder.Entity(e => + { + e.HasIndex(x => x.Status); + e.HasIndex(x => x.EditionId); + e.HasIndex(x => x.UserBookId); + e.Property(x => x.IssuesJson).HasColumnType("jsonb"); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.Cascade); + }); + } +} diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.Vocabulary.cs b/backend/src/Infrastructure/Persistence/AppDbContext.Vocabulary.cs new file mode 100644 index 00000000..580dbcae --- /dev/null +++ b/backend/src/Infrastructure/Persistence/AppDbContext.Vocabulary.cs @@ -0,0 +1,130 @@ +using Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Persistence; + +/// +/// SRS vocabulary + anti-spiral entities: VocabularyWord + reviews + +/// per-user settings + PendingVocabularyWord (over-cap buffer, F2) + +/// WordLookup (rare-word taps, F1) + WordFrequency (reference data) + +/// WordCluster (LLM-grouped bonus rounds, F3). +/// +public partial class AppDbContext +{ + private static void ConfigureVocabulary(ModelBuilder modelBuilder) + { + // VocabularyWord + modelBuilder.Entity(e => + { + e.HasIndex(x => new { x.UserId, x.SiteId }); + // Phase 1 anti-spiral: retired rows are excluded from queue — index on IsRetired + // so the filtered scan is a tight prefix read, not a full index scan + filter. + e.HasIndex(x => new { x.UserId, x.SiteId, x.IsRetired, x.NextReviewAt }); + e.HasIndex(x => new { x.UserId, x.SiteId, x.Word, x.Language }).IsUnique(); + e.HasIndex(x => x.EditionId); + e.Property(x => x.Word).HasMaxLength(200); + e.Property(x => x.Language).HasMaxLength(8); + e.Property(x => x.Translation).HasMaxLength(500); + e.Property(x => x.Definition).HasMaxLength(2000); + e.Property(x => x.Sentence).HasMaxLength(1000); + e.Property(x => x.BookTitle).HasMaxLength(500); + e.Property(x => x.Hint).HasMaxLength(500); + e.Property(x => x.Explanation).HasMaxLength(1000); + e.Property(x => x.Source).HasMaxLength(40); + e.Property(x => x.RetiredReason).HasMaxLength(60); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.Chapter).WithMany().HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); + }); + + // UserVocabularySettings — one row per (user, site). + modelBuilder.Entity(e => + { + e.HasKey(x => new { x.UserId, x.SiteId }); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + }); + + // VocabularyReview + modelBuilder.Entity(e => + { + e.HasIndex(x => x.VocabularyWordId); + e.HasIndex(x => new { x.UserId, x.SiteId, x.CreatedAt }); + e.Property(x => x.ReviewMode).HasMaxLength(30); + e.HasOne(x => x.VocabularyWord).WithMany(x => x.Reviews).HasForeignKey(x => x.VocabularyWordId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + }); + + // PendingVocabularyWord (F2: over-cap buffer) + modelBuilder.Entity(e => + { + // Promotion-order read: top-N by Priority DESC per user. + e.HasIndex(x => new { x.UserId, x.SiteId, x.Priority }).IsDescending(false, false, true); + // Dedup + list view (newest first). + e.HasIndex(x => new { x.UserId, x.SiteId, x.CreatedAt }); + // Guard against duplicate pending rows for the same word. + e.HasIndex(x => new { x.UserId, x.SiteId, x.Word, x.Language }).IsUnique(); + e.Property(x => x.Word).HasMaxLength(200); + e.Property(x => x.Language).HasMaxLength(8); + e.Property(x => x.Translation).HasMaxLength(500); + e.Property(x => x.Definition).HasMaxLength(2000); + e.Property(x => x.Sentence).HasMaxLength(1000); + e.Property(x => x.BookTitle).HasMaxLength(500); + e.Property(x => x.Source).HasMaxLength(40); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.Chapter).WithMany().HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); + }); + + // WordLookup (F1: rare-word reference bucket — taps that don't enter SRS) + modelBuilder.Entity(e => + { + // Dedup: one row per (user, site, word, language). Tap increments TapCount. + e.HasIndex(x => new { x.UserId, x.SiteId, x.Word, x.Language }).IsUnique(); + // List view (newest tapped first). + e.HasIndex(x => new { x.UserId, x.SiteId, x.LastTappedAt }).IsDescending(false, false, true); + e.Property(x => x.Word).HasMaxLength(200); + e.Property(x => x.Language).HasMaxLength(8); + e.Property(x => x.Sentence).HasMaxLength(1000); + e.Property(x => x.BookTitle).HasMaxLength(500); + e.Property(x => x.LastTranslation).HasMaxLength(500); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.Chapter).WithMany().HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); + }); + + // WordFrequency (F1: reference data — seeded from wordfreq export at startup) + modelBuilder.Entity(e => + { + // Primary classify lookup: (language, word) unique. + e.HasIndex(x => new { x.Language, x.Word }).IsUnique(); + // Rank-ordered scans (e.g., "top 5000 for language X"). + e.HasIndex(x => new { x.Language, x.Rank }); + e.Property(x => x.Language).HasMaxLength(8); + e.Property(x => x.Word).HasMaxLength(200); + e.Property(x => x.Pos).HasMaxLength(20); + }); + + // WordCluster (F3: LLM-grouped thematic bonus rounds) + modelBuilder.Entity(e => + { + // List view — active (undismissed) clusters per user, newest first. + e.HasIndex(x => new { x.UserId, x.SiteId, x.IsDismissed, x.CreatedAt }).IsDescending(false, false, false, true); + e.Property(x => x.Title).HasMaxLength(200); + e.Property(x => x.Theme).HasMaxLength(100); + e.Property(x => x.BookTitle).HasMaxLength(500); + e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); + e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); + e.HasMany(x => x.Words).WithOne().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.SetNull); + }); + } +} diff --git a/backend/src/Infrastructure/Persistence/AppDbContext.cs b/backend/src/Infrastructure/Persistence/AppDbContext.cs index 1861307b..517a63e7 100644 --- a/backend/src/Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/Infrastructure/Persistence/AppDbContext.cs @@ -5,7 +5,25 @@ namespace Infrastructure.Persistence; -public class AppDbContext : DbContext, IAppDbContext +/// +/// Entity Framework DbContext for TextStack. +/// +/// Schema configuration is split across partial files by domain to keep +/// each file under ~150 LOC and reviewable in isolation: +/// +/// - AppDbContext.Catalog.cs Site, Work, Edition, Chapter, BookFile, IngestionJob, Author, Genre, … +/// - AppDbContext.User.cs User, UserLibrary, refresh tokens, password reset, admin user +/// - AppDbContext.Reading.cs ReadingProgress, Bookmark, Note, Highlight, Session, Goal, Achievement +/// - AppDbContext.UserBooks.cs UserBook, UserChapter, UserBookFile, UserIngestionJob, UserBookBookmark, BookQuality +/// - AppDbContext.Vocabulary.cs VocabularyWord, Reviews, Settings, Pending, WordLookup/Frequency/Cluster +/// - AppDbContext.Ops.cs AdminSettings, SsgRebuild, LintResult +/// - AppDbContext.Seo.cs SeoTemplate, SeoBackfillJob/Settings, SeoSource defaults +/// - AppDbContext.Collections.cs Collection, BookCollection +/// +/// All splits use C# `partial class` — compile-identical to the original +/// monolithic file. Migrations are unaffected (model snapshot identical). +/// +public partial class AppDbContext : DbContext, IAppDbContext { public AppDbContext(DbContextOptions options) : base(options) { } @@ -65,586 +83,17 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); - // Site - modelBuilder.Entity(e => - { - e.HasIndex(x => x.Code).IsUnique(); - e.HasIndex(x => x.PrimaryDomain).IsUnique(); - e.Property(x => x.Code).HasMaxLength(50); - e.Property(x => x.PrimaryDomain).HasMaxLength(255); - e.Property(x => x.DefaultLanguage).HasMaxLength(10); - e.Property(x => x.Theme).HasMaxLength(50); - e.Property(x => x.FeaturesJson).HasColumnType("jsonb"); - }); - - // SiteDomain - modelBuilder.Entity(e => - { - e.HasIndex(x => x.Domain).IsUnique(); - e.Property(x => x.Domain).HasMaxLength(255); - e.HasOne(x => x.Site).WithMany(x => x.Domains).HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Cascade); - }); - - // Work - modelBuilder.Entity(e => - { - e.HasIndex(x => x.SiteId); - e.HasIndex(x => new { x.SiteId, x.Slug }).IsUnique(); - e.HasOne(x => x.Site).WithMany(x => x.Works).HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - }); - - // Edition - modelBuilder.Entity(e => - { - e.HasIndex(x => x.SiteId); - e.HasIndex(x => x.SourceEditionId); - e.HasIndex(x => x.Status); - e.HasIndex(x => new { x.WorkId, x.Language }).IsUnique(); - e.HasIndex(x => new { x.SiteId, x.Language, x.Slug }).IsUnique(); - e.Property(x => x.Language).HasMaxLength(8); - e.Property(x => x.TocJson).HasColumnType("jsonb"); - e.HasOne(x => x.Work).WithMany(x => x.Editions).HasForeignKey(x => x.WorkId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - e.HasOne(x => x.SourceEdition).WithMany(x => x.TranslatedEditions).HasForeignKey(x => x.SourceEditionId).OnDelete(DeleteBehavior.SetNull); - }); - - // Chapter - modelBuilder.Entity(e => - { - e.HasIndex(x => new { x.EditionId, x.ChapterNumber }).IsUnique(); - e.HasIndex(x => new { x.EditionId, x.Slug }); - e.HasIndex(x => x.SearchVector).HasMethod("GIN"); - e.Property(x => x.SearchVector).HasColumnType("tsvector"); - e.HasOne(x => x.Edition).WithMany(x => x.Chapters).HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - }); - - // BookFile - modelBuilder.Entity(e => - { - e.HasIndex(x => x.EditionId); - e.HasIndex(x => x.Sha256); - e.HasOne(x => x.Edition).WithMany(x => x.BookFiles).HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - }); - - // IngestionJob - modelBuilder.Entity(e => - { - e.HasIndex(x => x.BookFileId); - e.HasIndex(x => x.EditionId); - e.HasIndex(x => x.SourceEditionId); - e.HasIndex(x => x.WorkId); - e.HasIndex(x => x.Status); - e.HasIndex(x => x.CreatedAt); - e.Property(x => x.TargetLanguage).HasMaxLength(8); - e.Property(x => x.SourceFormat).HasMaxLength(20); - e.Property(x => x.TextSource).HasMaxLength(20); - e.Property(x => x.WarningsJson).HasColumnType("jsonb"); - e.HasOne(x => x.BookFile).WithMany(x => x.IngestionJobs).HasForeignKey(x => x.BookFileId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Edition).WithMany(x => x.IngestionJobs).HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.SourceEdition).WithMany().HasForeignKey(x => x.SourceEditionId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.Work).WithMany().HasForeignKey(x => x.WorkId).OnDelete(DeleteBehavior.SetNull); - }); - - // User - modelBuilder.Entity(e => - { - e.HasIndex(x => x.Email).IsUnique(); - e.HasIndex(x => x.GoogleSubject).IsUnique().HasFilter("google_subject IS NOT NULL"); - e.HasIndex(x => x.AppleSubject).IsUnique().HasFilter("apple_subject IS NOT NULL"); - e.Property(x => x.Email).HasMaxLength(255); - e.Property(x => x.GoogleSubject).HasMaxLength(255); - e.Property(x => x.AppleSubject).HasMaxLength(255); - e.Property(x => x.PasswordHash).HasMaxLength(255); - e.Property(x => x.Name).HasMaxLength(255); - e.Property(x => x.NativeLanguage).HasMaxLength(16); - e.Property(x => x.IsGuest).HasDefaultValue(false); - e.HasIndex(x => new { x.IsGuest, x.LastActiveAt }) - .HasFilter("is_guest = true") - .HasDatabaseName("ix_users_guest_cleanup"); - }); - - // UserLibrary - modelBuilder.Entity(e => - { - e.HasIndex(x => x.EditionId); - e.HasIndex(x => new { x.UserId, x.EditionId }).IsUnique(); - e.HasOne(x => x.User).WithMany(x => x.UserLibraries).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - }); - - // ReadingProgress - modelBuilder.Entity(e => - { - e.HasIndex(x => x.ChapterId); - e.HasIndex(x => x.EditionId); - e.HasIndex(x => x.SiteId); - e.HasIndex(x => new { x.UserId, x.SiteId, x.EditionId }).IsUnique(); - e.HasOne(x => x.User).WithMany(x => x.ReadingProgresses).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Chapter).WithMany(x => x.ReadingProgresses).HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - }); - - // Bookmark - modelBuilder.Entity(e => - { - e.HasIndex(x => x.ChapterId); - e.HasIndex(x => x.EditionId); - e.HasIndex(x => x.SiteId); - e.HasIndex(x => new { x.UserId, x.SiteId, x.EditionId }); - e.HasOne(x => x.User).WithMany(x => x.Bookmarks).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Chapter).WithMany(x => x.Bookmarks).HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - }); - - // Note - modelBuilder.Entity(e => - { - e.HasIndex(x => x.ChapterId); - e.HasIndex(x => x.EditionId); - e.HasIndex(x => x.SiteId); - e.HasIndex(x => x.HighlightId); - e.HasIndex(x => new { x.UserId, x.SiteId, x.EditionId }); - e.HasOne(x => x.User).WithMany(x => x.Notes).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Chapter).WithMany(x => x.Notes).HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - e.HasOne(x => x.Highlight).WithOne(x => x.Note).HasForeignKey(x => x.HighlightId).OnDelete(DeleteBehavior.SetNull); - }); - - // Highlight - modelBuilder.Entity(e => - { - e.HasIndex(x => x.ChapterId); - e.HasIndex(x => x.EditionId); - e.HasIndex(x => x.SiteId); - e.HasIndex(x => x.UserBookId); - e.HasIndex(x => new { x.UserId, x.SiteId, x.EditionId }).HasFilter("edition_id IS NOT NULL"); - e.HasIndex(x => new { x.UserId, x.SiteId, x.UserBookId }).HasFilter("user_book_id IS NOT NULL"); - e.Property(x => x.AnchorJson).HasColumnType("jsonb"); - e.Property(x => x.Color).HasMaxLength(20); - e.HasOne(x => x.User).WithMany(x => x.Highlights).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.Chapter).WithMany().HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.UserChapter).WithMany().HasForeignKey(x => x.UserChapterId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - }); - - // AdminUser - modelBuilder.Entity(e => - { - e.HasIndex(x => x.Email).IsUnique(); - }); - - // AdminRefreshToken - modelBuilder.Entity(e => - { - e.HasIndex(x => x.AdminUserId); - e.HasIndex(x => x.ExpiresAt); - e.HasIndex(x => x.Token).IsUnique(); - e.HasOne(x => x.AdminUser).WithMany(x => x.RefreshTokens).HasForeignKey(x => x.AdminUserId).OnDelete(DeleteBehavior.Cascade); - }); - - // UserRefreshToken - modelBuilder.Entity(e => - { - e.HasIndex(x => x.UserId); - e.HasIndex(x => x.ExpiresAt); - e.HasIndex(x => x.Token).IsUnique(); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - }); - - // Author - modelBuilder.Entity(e => - { - e.HasIndex(x => x.SiteId); - e.HasIndex(x => new { x.SiteId, x.Slug }).IsUnique(); - e.Property(x => x.Slug).HasMaxLength(255); - e.Property(x => x.Name).HasMaxLength(255); - e.Property(x => x.ExternalLinksJson).HasColumnType("jsonb"); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - }); - - // EditionAuthor (junction table with order + role) - modelBuilder.Entity(e => - { - e.ToTable("edition_authors"); - e.HasKey(x => new { x.EditionId, x.AuthorId }); - e.HasIndex(x => x.AuthorId); - e.Property(x => x.Role).HasConversion().HasMaxLength(50); - e.HasOne(x => x.Edition).WithMany(x => x.EditionAuthors).HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Author).WithMany(x => x.EditionAuthors).HasForeignKey(x => x.AuthorId).OnDelete(DeleteBehavior.Cascade); - }); - - // Genre - modelBuilder.Entity(e => - { - e.HasIndex(x => x.SiteId); - e.HasIndex(x => new { x.SiteId, x.Slug }).IsUnique(); - e.Property(x => x.Slug).HasMaxLength(100); - e.Property(x => x.Name).HasMaxLength(100); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - e.HasMany(x => x.Editions).WithMany(x => x.Genres).UsingEntity("edition_genres"); - }); - - // TextStackImport - modelBuilder.Entity(e => - { - e.HasIndex(x => x.SiteId); - e.HasIndex(x => x.EditionId); - e.HasIndex(x => new { x.SiteId, x.Identifier }).IsUnique(); - e.Property(x => x.Identifier).HasMaxLength(500); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - }); - - // BookAsset - modelBuilder.Entity(e => - { - e.HasIndex(x => x.EditionId); - e.HasIndex(x => new { x.EditionId, x.OriginalPath }).IsUnique(); - e.Property(x => x.Kind).HasConversion().HasMaxLength(20); - e.Property(x => x.OriginalPath).HasMaxLength(500); - e.Property(x => x.StoragePath).HasMaxLength(500); - e.Property(x => x.ContentType).HasMaxLength(100); - e.HasOne(x => x.Edition).WithMany(x => x.Assets).HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - }); - - // SsgRebuildJob - modelBuilder.Entity(e => - { - e.HasIndex(x => x.SiteId); - e.HasIndex(x => x.Status); - e.HasIndex(x => x.CreatedAt); - e.Property(x => x.Mode).HasConversion().HasMaxLength(20); - e.Property(x => x.Status).HasConversion().HasMaxLength(20); - e.Property(x => x.BookSlugsJson).HasColumnType("jsonb"); - e.Property(x => x.AuthorSlugsJson).HasColumnType("jsonb"); - e.Property(x => x.GenreSlugsJson).HasColumnType("jsonb"); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - }); - - // SsgRebuildResult - modelBuilder.Entity(e => - { - e.HasIndex(x => x.JobId); - e.HasIndex(x => new { x.JobId, x.Route }).IsUnique(); - e.Property(x => x.Route).HasMaxLength(500); - e.Property(x => x.RouteType).HasMaxLength(20); - e.HasOne(x => x.Job).WithMany(x => x.Results).HasForeignKey(x => x.JobId).OnDelete(DeleteBehavior.Cascade); - }); - - // LintResult - modelBuilder.Entity(e => - { - e.HasIndex(x => x.EditionId); - e.Property(x => x.Severity).HasConversion().HasMaxLength(20); - e.Property(x => x.Code).HasMaxLength(10); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - }); - - // UserBook - modelBuilder.Entity(e => - { - e.HasIndex(x => x.UserId); - e.HasIndex(x => x.Status); - e.HasIndex(x => new { x.UserId, x.Slug }).IsUnique(); - e.Property(x => x.Title).HasMaxLength(500); - e.Property(x => x.Slug).HasMaxLength(500); - e.Property(x => x.Language).HasMaxLength(10); - e.Property(x => x.Author).HasMaxLength(500); - e.Property(x => x.CoverPath).HasMaxLength(500); - e.Property(x => x.Genre).HasMaxLength(200); - e.Property(x => x.TocJson).HasColumnType("jsonb"); - e.Property(x => x.TakedownReason).HasMaxLength(1000); - e.Property(x => x.SeoSource).HasMaxLength(20).HasDefaultValue("auto"); - e.Property(x => x.MetadataHistoryJson).HasColumnType("jsonb"); - e.Property(x => x.Tags).HasColumnType("text[]").HasDefaultValueSql("ARRAY[]::text[]"); - e.HasIndex(x => x.Tags).HasMethod("gin"); - e.Property(x => x.SuggestedTags).HasColumnType("text[]").HasDefaultValueSql("ARRAY[]::text[]"); - e.HasOne(x => x.User).WithMany(x => x.UserBooks).HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - }); - - // Collection (slice 13) - modelBuilder.Entity(e => - { - e.HasIndex(x => x.UserId); - e.HasIndex(x => new { x.UserId, x.SortOrder }); - e.Property(x => x.Name).HasMaxLength(100).IsRequired(); - e.Property(x => x.Color).HasMaxLength(20).HasDefaultValue("default"); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - }); - - // BookCollection (slice 13) — composite PK on (CollectionId, BookId, BookType) - modelBuilder.Entity(e => - { - e.HasKey(x => new { x.CollectionId, x.BookId, x.BookType }); - e.HasIndex(x => x.BookId); - e.Property(x => x.BookType).HasMaxLength(20).IsRequired(); - e.HasOne(x => x.Collection).WithMany(c => c.Books).HasForeignKey(x => x.CollectionId).OnDelete(DeleteBehavior.Cascade); - }); - - // UserChapter - modelBuilder.Entity(e => - { - e.HasIndex(x => x.UserBookId); - e.HasIndex(x => new { x.UserBookId, x.ChapterNumber }).IsUnique(); - e.HasIndex(x => new { x.UserBookId, x.Slug }).IsUnique(); - e.Property(x => x.Title).HasMaxLength(500); - e.Property(x => x.Slug).HasMaxLength(255); - e.HasOne(x => x.UserBook).WithMany(x => x.Chapters).HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.Cascade); - }); - - // UserBookFile - modelBuilder.Entity(e => - { - e.HasIndex(x => x.UserBookId); - e.HasIndex(x => x.Sha256); - e.Property(x => x.OriginalFileName).HasMaxLength(500); - e.Property(x => x.StoragePath).HasMaxLength(500); - e.Property(x => x.Sha256).HasMaxLength(64); - e.HasOne(x => x.UserBook).WithMany(x => x.BookFiles).HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.Cascade); - }); - - // UserIngestionJob - modelBuilder.Entity(e => - { - e.HasIndex(x => x.UserBookId); - e.HasIndex(x => x.UserBookFileId); - e.HasIndex(x => x.Status); - e.HasIndex(x => x.CreatedAt); - e.Property(x => x.SourceFormat).HasMaxLength(50); - e.HasOne(x => x.UserBook).WithMany(x => x.IngestionJobs).HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.UserBookFile).WithMany().HasForeignKey(x => x.UserBookFileId).OnDelete(DeleteBehavior.Cascade); - }); - - // UserBookBookmark - modelBuilder.Entity(e => - { - e.ToTable("user_book_bookmarks"); - e.HasKey(x => x.Id); - e.HasIndex(x => x.UserBookId); - e.Property(x => x.Locator).HasMaxLength(1000); - e.Property(x => x.Title).HasMaxLength(500); - e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Chapter).WithMany().HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.Cascade); - }); - - // AdminSettings - modelBuilder.Entity(e => - { - e.HasKey(x => x.Key); - e.Property(x => x.Key).HasMaxLength(100); - e.Property(x => x.Value).HasMaxLength(500); - }); - - // ReadingSession - modelBuilder.Entity(e => - { - e.HasIndex(x => new { x.UserId, x.SiteId }); - e.HasIndex(x => new { x.UserId, x.StartedAt }); - e.HasIndex(x => new { x.UserId, x.EditionId, x.StartedAt }).IsUnique().HasFilter("edition_id IS NOT NULL"); - e.HasIndex(x => new { x.UserId, x.UserBookId, x.StartedAt }).IsUnique().HasFilter("user_book_id IS NOT NULL"); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); - }); - - // ReadingGoal - modelBuilder.Entity(e => - { - e.HasIndex(x => new { x.UserId, x.SiteId }); - e.HasIndex(x => new { x.UserId, x.SiteId, x.GoalType }).IsUnique(); - e.Property(x => x.GoalType).HasMaxLength(50); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - }); - - // UserAchievement - modelBuilder.Entity(e => - { - e.HasIndex(x => new { x.UserId, x.SiteId }); - e.HasIndex(x => new { x.UserId, x.SiteId, x.AchievementCode }).IsUnique(); - e.Property(x => x.AchievementCode).HasMaxLength(50); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - }); - - // VocabularyWord - modelBuilder.Entity(e => - { - e.HasIndex(x => new { x.UserId, x.SiteId }); - // Phase 1 anti-spiral: retired rows are excluded from queue — index on IsRetired - // so the filtered scan is a tight prefix read, not a full index scan + filter. - e.HasIndex(x => new { x.UserId, x.SiteId, x.IsRetired, x.NextReviewAt }); - e.HasIndex(x => new { x.UserId, x.SiteId, x.Word, x.Language }).IsUnique(); - e.HasIndex(x => x.EditionId); - e.Property(x => x.Word).HasMaxLength(200); - e.Property(x => x.Language).HasMaxLength(8); - e.Property(x => x.Translation).HasMaxLength(500); - e.Property(x => x.Definition).HasMaxLength(2000); - e.Property(x => x.Sentence).HasMaxLength(1000); - e.Property(x => x.BookTitle).HasMaxLength(500); - e.Property(x => x.Hint).HasMaxLength(500); - e.Property(x => x.Explanation).HasMaxLength(1000); - e.Property(x => x.Source).HasMaxLength(40); - e.Property(x => x.RetiredReason).HasMaxLength(60); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.Chapter).WithMany().HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); - }); - - // UserVocabularySettings — one row per (user, site) - modelBuilder.Entity(e => - { - e.HasKey(x => new { x.UserId, x.SiteId }); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - }); - - // VocabularyReview - modelBuilder.Entity(e => - { - e.HasIndex(x => x.VocabularyWordId); - e.HasIndex(x => new { x.UserId, x.SiteId, x.CreatedAt }); - e.Property(x => x.ReviewMode).HasMaxLength(30); - e.HasOne(x => x.VocabularyWord).WithMany(x => x.Reviews).HasForeignKey(x => x.VocabularyWordId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - }); - - // PendingVocabularyWord (F2: over-cap buffer) - modelBuilder.Entity(e => - { - // Promotion-order read: top-N by Priority DESC per user. - e.HasIndex(x => new { x.UserId, x.SiteId, x.Priority }).IsDescending(false, false, true); - // Dedup + list view (newest first). - e.HasIndex(x => new { x.UserId, x.SiteId, x.CreatedAt }); - // Guard against duplicate pending rows for the same word. - e.HasIndex(x => new { x.UserId, x.SiteId, x.Word, x.Language }).IsUnique(); - e.Property(x => x.Word).HasMaxLength(200); - e.Property(x => x.Language).HasMaxLength(8); - e.Property(x => x.Translation).HasMaxLength(500); - e.Property(x => x.Definition).HasMaxLength(2000); - e.Property(x => x.Sentence).HasMaxLength(1000); - e.Property(x => x.BookTitle).HasMaxLength(500); - e.Property(x => x.Source).HasMaxLength(40); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.Chapter).WithMany().HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); - }); - - // WordLookup (F1: rare-word reference bucket — taps that don't enter SRS) - modelBuilder.Entity(e => - { - // Dedup: one row per (user, site, word, language). Tap increments TapCount. - e.HasIndex(x => new { x.UserId, x.SiteId, x.Word, x.Language }).IsUnique(); - // List view (newest tapped first). - e.HasIndex(x => new { x.UserId, x.SiteId, x.LastTappedAt }).IsDescending(false, false, true); - e.Property(x => x.Word).HasMaxLength(200); - e.Property(x => x.Language).HasMaxLength(8); - e.Property(x => x.Sentence).HasMaxLength(1000); - e.Property(x => x.BookTitle).HasMaxLength(500); - e.Property(x => x.LastTranslation).HasMaxLength(500); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.Chapter).WithMany().HasForeignKey(x => x.ChapterId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); - }); - - // WordFrequency (F1: reference data — seeded from wordfreq export at startup) - modelBuilder.Entity(e => - { - // Primary classify lookup: (language, word) unique. - e.HasIndex(x => new { x.Language, x.Word }).IsUnique(); - // Rank-ordered scans (e.g., "top 5000 for language X"). - e.HasIndex(x => new { x.Language, x.Rank }); - e.Property(x => x.Language).HasMaxLength(8); - e.Property(x => x.Word).HasMaxLength(200); - e.Property(x => x.Pos).HasMaxLength(20); - }); - - // WordCluster (F3: LLM-grouped thematic bonus rounds) - modelBuilder.Entity(e => - { - // List view — active (undismissed) clusters per user, newest first. - e.HasIndex(x => new { x.UserId, x.SiteId, x.IsDismissed, x.CreatedAt }).IsDescending(false, false, false, true); - e.Property(x => x.Title).HasMaxLength(200); - e.Property(x => x.Theme).HasMaxLength(100); - e.Property(x => x.BookTitle).HasMaxLength(500); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.Site).WithMany().HasForeignKey(x => x.SiteId).OnDelete(DeleteBehavior.Restrict); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.SetNull); - e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.SetNull); - e.HasMany(x => x.Words).WithOne().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.SetNull); - }); - - // PasswordResetToken - modelBuilder.Entity(e => - { - e.HasIndex(x => x.TokenHash).IsUnique(); - e.Property(x => x.TokenHash).HasMaxLength(128); - e.HasOne(x => x.User).WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); - }); - - modelBuilder.Entity(e => - { - e.HasIndex(x => x.Status); - e.HasIndex(x => x.EditionId); - e.HasIndex(x => x.UserBookId); - e.Property(x => x.IssuesJson).HasColumnType("jsonb"); - e.HasOne(x => x.Edition).WithMany().HasForeignKey(x => x.EditionId).OnDelete(DeleteBehavior.Cascade); - e.HasOne(x => x.UserBook).WithMany().HasForeignKey(x => x.UserBookId).OnDelete(DeleteBehavior.Cascade); - }); - - // SeoTemplate — editable Claude prompts per entity_type × field_type × language, version-frozen. - modelBuilder.Entity(e => - { - e.HasIndex(x => new { x.EntityType, x.FieldType, x.LanguageCode, x.IsActive }); - e.Property(x => x.LanguageCode).HasMaxLength(8); - e.Property(x => x.Name).HasMaxLength(200); - e.Property(x => x.Description).HasMaxLength(500); - e.Property(x => x.Model).HasMaxLength(100); - e.Property(x => x.PromptTemplate).HasColumnType("text"); - e.Property(x => x.OutputSchema).HasColumnType("jsonb"); - }); - - // SeoBackfillJob — audit trail for every run, frozen template versions enable replay. - modelBuilder.Entity(e => - { - e.HasIndex(x => new { x.Status, x.CreatedAt }); - e.HasIndex(x => new { x.EntityType, x.EntityId }); - e.Property(x => x.TargetFields).HasColumnType("text[]"); - e.Property(x => x.TemplateIds).HasColumnType("uuid[]"); - e.Property(x => x.TemplateVersions).HasColumnType("integer[]"); - e.Property(x => x.TriggeredBy).HasMaxLength(200); - e.Property(x => x.InputSnapshot).HasColumnType("jsonb"); - e.Property(x => x.RenderedPrompts).HasColumnType("jsonb"); - e.Property(x => x.RawOutputs).HasColumnType("jsonb"); - e.Property(x => x.GeneratedContent).HasColumnType("jsonb"); - e.Property(x => x.BeforeSnapshot).HasColumnType("jsonb"); - e.Property(x => x.AfterSnapshot).HasColumnType("jsonb"); - }); - - // SeoBackfillSettings — singleton. - modelBuilder.Entity(e => - { - e.Property(x => x.LanguageFilter).HasColumnType("text[]"); - e.Property(x => x.EntityTypeFilter).HasColumnType("text[]"); - }); - - // seo_source on SEO-bearing entities — default 'Manual' (0) preserves existing rows. - modelBuilder.Entity().Property(x => x.SeoSource).HasDefaultValue(Domain.Enums.SeoSource.Manual); - modelBuilder.Entity().Property(x => x.SeoSource).HasDefaultValue(Domain.Enums.SeoSource.Manual); - modelBuilder.Entity().Property(x => x.SeoSource).HasDefaultValue(Domain.Enums.SeoSource.Manual); + // Order doesn't matter — EF builds the model graph from all + // configurations together before applying. We list by ownership + // to make it easier to spot a missing call when adding entities. + ConfigureCatalog(modelBuilder); + ConfigureUser(modelBuilder); + ConfigureReading(modelBuilder); + ConfigureUserBooks(modelBuilder); + ConfigureVocabulary(modelBuilder); + ConfigureOps(modelBuilder); + ConfigureSeo(modelBuilder); + ConfigureCollections(modelBuilder); } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) From 88bc3df0f9e128404112d768288e9ead125960f3 Mon Sep 17 00:00:00 2001 From: Vasyl Vdovychenko Date: Sun, 24 May 2026 00:52:53 -0400 Subject: [PATCH 2/2] =?UTF-8?q?changelog:=20safe=20Tier=200+1=20refactor?= =?UTF-8?q?=20=E2=80=94=20backend=20splits=20+=20util=20tests=20(2026-05-2?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ebac807..0165a000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +### Safe refactor — backend partial-class splits + util tests (2026-05-24) + +Cosmetic refactor pass: three god-class backend files split across C# partial +files (compile-identical to original IL — zero behaviour change, integration +tests verify), plus net-new unit-test coverage on critical web + mobile pure +utilities. Largest non-migration backend file dropped from 1559 → 875 LOC. +All splits use single-domain-per-file boundaries so future review can scope +to one concern without scrolling past nine others. + +- **`AppDbContext.cs` split** ([`1932b21`](https://github.com/mrviduus/textstack/commit/1932b21)) — EF Core model configuration extracted from one 655-LOC `OnModelCreating` to 7 domain-scoped partial files (`Catalog`, `User`, `Reading`, `UserBooks`, `Vocabulary`, `Ops`, `Seo`, `Collections`). Main file is now 104 LOC of DbSet declarations + dispatcher. Migrations unaffected — EF model snapshot is identical. +- **`VocabularyEndpoints.cs` split** ([`1932b21`](https://github.com/mrviduus/textstack/commit/1932b21)) — 1559-LOC single endpoint file split: 6 sub-domain partials (`Stats`, `Settings`, `Pending`, `Lookups`, `Clusters`, `Admin`) for the 24 routes that were drowning each other out. Shared helpers (`TryGetAuth`, `ToDto`, `UpsertLookupAsync`, `QueueEnrichment`) stay in main alongside `MapVocabularyEndpoints` + DTOs. Reviewer can now diff one anti-spiral phase without seeing the others. +- **`AdminService.cs` split** ([`1932b21`](https://github.com/mrviduus/textstack/commit/1932b21)) — 977-LOC god service split into 4 partial files by domain (`Upload`, `Editions`, `Chapters`, `UserUploads`). Primary constructor + DI + shared `EnqueueSsgSafe` helper stay in main (131 LOC). Each domain partial under 400 LOC. +- **Web util tests** ([`1932b21`](https://github.com/mrviduus/textstack/commit/1932b21)) — 40 new Vitest cases covering `analytics.ts` (GA4 event shape contract, gtag fallback, PII boundaries), `dataEvents.ts` (CustomEvent bus + cross-component hook), `errorUtils.ts` (HttpError detection), `formatTime.ts` (hour/minute formatting). Web suite now 474 tests. +- **Mobile Vitest infra + pure-util tests** ([`1932b21`](https://github.com/mrviduus/textstack/commit/1932b21)) — new `vitest.config.ts` with an in-process `AsyncStorage` mock alias so any `lib/` module can be unit-tested without RN runtime. 38 cases covering `searchUtils` (NFD diacritic stripping incl. surprising Cyrillic `й` → `и` behavior, documented), `features` (reader-overlay killswitch cascade), `vocabStatsCache` (TTL + corrupt-JSON defense). Mobile lib code can now be tested without bundling RN. + ### Mobile reader — bug sweep + architecture refactor (2026-05-23) Five user-reported Android bugs blocking Play Store launch, plus a senior