From f6f7219ff201d263a953f94fe716e10d58bbce48 Mon Sep 17 00:00:00 2001 From: delgado-jacob <29643013+delgado-jacob@users.noreply.github.com> Date: Sat, 17 Jan 2026 17:13:53 -0700 Subject: [PATCH 1/6] feat: interlinear export job ui --- .../25-12-06-add-export-job-types.sql | 11 + package-lock.json | 188 +++++++++++ package.json | 1 + .../languages/[code]/exports/page.tsx | 1 + src/app/[locale]/(main)/features/page.tsx | 4 + src/components/Icon.tsx | 2 + src/feature-flags.ts | 2 +- src/messages/ar.json | 49 +++ src/messages/en.json | 49 +++ .../pollInterlinearExportStatus.test.ts | 139 ++++++++ .../actions/pollInterlinearExportStatus.ts | 70 ++++ .../actions/requestInterlinearExport.test.ts | 178 ++++++++++ .../actions/requestInterlinearExport.ts | 116 +++++++ .../export/data-access/BookQueryService.ts | 41 +++ .../data-access/LanguageLookupQueryService.ts | 29 ++ .../export/jobs/exportInterlinearPdfJob.ts | 32 ++ src/modules/export/jobs/jobTypes.ts | 4 + src/modules/export/model.ts | 21 ++ src/modules/export/public/ExportClient.ts | 11 + .../InterlinearExportPanel.client.test.tsx | 125 +++++++ .../export/react/InterlinearExportPanel.tsx | 40 +++ .../react/InterlinearExportPanelClient.tsx | 310 ++++++++++++++++++ .../export/react/LanguageExportsPage.tsx | 39 +++ .../use-cases/GetInterlinearExportStatus.ts | 27 ++ .../use-cases/RequestInterlinearExport.ts | 92 ++++++ .../languages/ui/AdminLanguageLayout.tsx | 13 + src/session.ts | 8 +- src/shared/jobs/jobMap.ts | 6 + tests/vitest/testSetup.ts | 6 +- vitest.config.mts | 8 +- 30 files changed, 1613 insertions(+), 9 deletions(-) create mode 100644 db/migrations/25-12-06-add-export-job-types.sql create mode 100644 src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/page.tsx create mode 100644 src/modules/export/actions/pollInterlinearExportStatus.test.ts create mode 100644 src/modules/export/actions/pollInterlinearExportStatus.ts create mode 100644 src/modules/export/actions/requestInterlinearExport.test.ts create mode 100644 src/modules/export/actions/requestInterlinearExport.ts create mode 100644 src/modules/export/data-access/BookQueryService.ts create mode 100644 src/modules/export/data-access/LanguageLookupQueryService.ts create mode 100644 src/modules/export/jobs/exportInterlinearPdfJob.ts create mode 100644 src/modules/export/jobs/jobTypes.ts create mode 100644 src/modules/export/model.ts create mode 100644 src/modules/export/public/ExportClient.ts create mode 100644 src/modules/export/react/InterlinearExportPanel.client.test.tsx create mode 100644 src/modules/export/react/InterlinearExportPanel.tsx create mode 100644 src/modules/export/react/InterlinearExportPanelClient.tsx create mode 100644 src/modules/export/react/LanguageExportsPage.tsx create mode 100644 src/modules/export/use-cases/GetInterlinearExportStatus.ts create mode 100644 src/modules/export/use-cases/RequestInterlinearExport.ts diff --git a/db/migrations/25-12-06-add-export-job-types.sql b/db/migrations/25-12-06-add-export-job-types.sql new file mode 100644 index 00000000..f15acb97 --- /dev/null +++ b/db/migrations/25-12-06-add-export-job-types.sql @@ -0,0 +1,11 @@ +select setval( + pg_get_serial_sequence('job_type', 'id'), + (select coalesce(max(id), 0) from job_type) +); + +insert into job_type (name) +values + ('export_interlinear_pdf'), + ('cleanup_exports') +on conflict (name) do nothing; + diff --git a/package-lock.json b/package-lock.json index efcb2283..727202ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "@fortawesome/react-fontawesome": "0.2.2", "@headlessui/react": "1.7.19", "@headlessui/tailwindcss": "0.2.1", + "@testing-library/react": "^16.3.0", "@tiptap/react": "2.6.6", "@tiptap/starter-kit": "2.6.6", "@types/aws-lambda": "8.10.147", @@ -399,6 +400,7 @@ "version": "3.662.0", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -451,6 +453,7 @@ "version": "3.662.0", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -896,6 +899,7 @@ "node_modules/@aws-sdk/client-sso-oidc": { "version": "3.678.0", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -947,6 +951,7 @@ "node_modules/@aws-sdk/client-sts": { "version": "3.678.0", "license": "Apache-2.0", + "peer": true, "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", @@ -1590,6 +1595,41 @@ "node": ">=16.0.0" } }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@emnapi/core": { "version": "0.45.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-0.45.0.tgz", @@ -2162,6 +2202,7 @@ "version": "6.6.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" }, @@ -3111,6 +3152,7 @@ "node_modules/@opentelemetry/api": { "version": "1.9.0", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -4658,10 +4700,69 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", + "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tiptap/core": { "version": "2.6.6", "dev": true, "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -4927,6 +5028,7 @@ "version": "2.6.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.2.1", "prosemirror-collab": "^1.3.1", @@ -5020,6 +5122,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/aws-lambda": { "version": "8.10.147", "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.147.tgz", @@ -5124,6 +5233,7 @@ "version": "18.3.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -5133,6 +5243,7 @@ "version": "18.3.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/react": "*" } @@ -5433,6 +5544,7 @@ "node_modules/acorn": { "version": "8.12.1", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6080,6 +6192,7 @@ "version": "4.4.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -6447,6 +6560,7 @@ "node_modules/date-fns": { "version": "4.1.0", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -6555,6 +6669,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "dev": true, @@ -6587,6 +6711,13 @@ "node": ">=6.0.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.1.6", "license": "(MPL-2.0 OR Apache-2.0)" @@ -6906,6 +7037,7 @@ "version": "8.57.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -7064,6 +7196,7 @@ "version": "2.29.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.7", "array.prototype.findlastindex": "^1.2.3", @@ -9329,6 +9462,16 @@ "dev": true, "license": "ISC" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -10115,6 +10258,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.12.0.tgz", "integrity": "sha512-A+LHUSnwnxrnL/tZ+OLfqR1SxLN3c/pgDztZ47Rpbsd4jUytsTtwQo/TLPRzPJMp/1pbhYVhH9cuSZLAajNfjQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.6.4", "pg-pool": "^3.6.2", @@ -10446,6 +10590,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -10644,6 +10789,41 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/process-warning": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", @@ -10770,6 +10950,7 @@ "version": "1.22.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -10796,6 +10977,7 @@ "version": "1.4.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -10840,6 +11022,7 @@ "version": "1.34.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -10954,6 +11137,7 @@ "node_modules/react": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -10964,6 +11148,7 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -11971,6 +12156,7 @@ "version": "3.4.10", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -12335,6 +12521,7 @@ "version": "5.5.4", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12466,6 +12653,7 @@ "integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.24.2", "postcss": "^8.4.49", diff --git a/package.json b/package.json index da3bafcd..122af329 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@fortawesome/react-fontawesome": "0.2.2", "@headlessui/react": "1.7.19", "@headlessui/tailwindcss": "0.2.1", + "@testing-library/react": "^16.3.0", "@tiptap/react": "2.6.6", "@tiptap/starter-kit": "2.6.6", "@types/aws-lambda": "8.10.147", diff --git a/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/page.tsx b/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/page.tsx new file mode 100644 index 00000000..3a14cc96 --- /dev/null +++ b/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/page.tsx @@ -0,0 +1 @@ +export { default } from "@/modules/export/react/LanguageExportsPage"; diff --git a/src/app/[locale]/(main)/features/page.tsx b/src/app/[locale]/(main)/features/page.tsx index c7f6c7fb..96dce010 100644 --- a/src/app/[locale]/(main)/features/page.tsx +++ b/src/app/[locale]/(main)/features/page.tsx @@ -11,6 +11,10 @@ export default function FeaturesPage() { > Features + ); diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 334c6c8e..7348e9b4 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -30,7 +30,9 @@ library.add( FaSolid.faArrowRight, FaSolid.faRightFromBracket, FaSolid.faExclamationTriangle, + FaSolid.faFileArrowDown, FaSolid.faFileImport, + FaSolid.faDownload, FaSolid.faChevronUp, FaSolid.faChevronDown, FaSolid.faChevronRight, diff --git a/src/feature-flags.ts b/src/feature-flags.ts index 53ea333a..79616f01 100644 --- a/src/feature-flags.ts +++ b/src/feature-flags.ts @@ -1,6 +1,6 @@ import { useLayoutEffect, useState } from "react"; -export type Feature = "ff-snapshots"; // string union of available flags +export type Feature = "ff-snapshots" | "ff-interlinear-pdf-export"; // string union of available flags export const features: Feature[] = []; diff --git a/src/messages/ar.json b/src/messages/ar.json index 78284c2e..d6a3db2a 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -156,9 +156,13 @@ "users": "المستخدمون", "reports": "التقارير", "import": "استيراد", + "exports": "التصدير", "snapshots": "لقطات" } }, + "LanguageExportsPage": { + "title": "التصدير" + }, "LanguageSettingsPage": { "title": "الإعدادات", "form": { @@ -652,5 +656,50 @@ "reference": "{bookId, select, 1 {التكوين} 2 {الخروج} 3 {اللاويين} 4 {العدد} 5 {التثنية} 6 {يشوع} 7 {القضاة} 8 {راعوث} 9 {صموئيل الأول} 10 {صموئيل الثاني} 11 {الملوك الأول} 12 {الملوك الثاني} 13 {أخبار الأيام الأول} 14 {أخبار الأيام الثاني} 15 {عزرا} 16 {نحميا} 17 {استير} 18 {أيوب} 19 {المزامير} 20 {الأمثال} 21 {الجامعة} 22 {نشيد الأنشاد} 23 {إشعياء} 24 {أرميا} 25 {مراثي أرميا} 26 {حزقيال} 27 {دانيال} 28 {هوشع} 29 {يوئيل} 30 {عاموس} 31 {عوبديا} 32 {يونان} 33 {ميخا} 34 {ناحوم} 35 {حبقوق} 36 {صفنيا} 37 {حجاي} 38 {زكريا} 39 {ملاخي} 40 {متى} 41 {مرقس} 42 {لوقا} 43 {يوحنا} 44 {أعمال الرسل} 45 {رومية} 46 {كورنثوس الأولى} 47 {كورنثوس الثانية} 48 {غلاطية} 49 {أفسس} 50 {فيلبي} 51 {كولوسي} 52 {تسالونيكي الأولى} 53 {تسالونيكي الثانية} 54 {تيموثاوس الأولى} 55 {تيموثاوس الثانية} 56 {تيطس} 57 {فليمون} 58 {العبرانيين} 59 {يعقوب} 60 {بطرس الأولى} 61 {بطرس الثانية} 62 {يوحنا الأولى} 63 {يوحنا الثانية} 64 {يوحنا الثالثة} 65 {يهوذا} 66 {رؤيا} other {}} {chapterNumber}:{verseNumber}", "not_found": "غير موجود", "close": "إغلاق" + }, + "InterlinearExport": { + "title": "تصدير PDF بين السطور", + "description": "إنشاء ملف PDF بين السطور لكل الأسفار.", + "form": { + "books_label": "الأسفار", + "books_placeholder": "كل الأسفار (الافتراضي)", + "books_help": "اتركه فارغًا لتصدير كل الأسفار.", + "chapters_label": "الإصحاحات (مفصولة بفواصل أو نطاقات)", + "chapters_placeholder": "مثال: 1,2,4-6 (اتركه فارغًا للكل)", + "layout_label": "التخطيط", + "layout_standard": "قياسي (كلمة بكلمة)", + "layout_parallel": "متوازٍ (الأصل | عمود الترجمة)", + "submit": "إنشاء PDF", + "queued": "تمت الإضافة للطابور..." + }, + "status": { + "title": "الحالة", + "all_books": "كل الأسفار", + "download": "تنزيل PDF", + "expires": "ينتهي", + "generating": "جارٍ إنشاء PDF…", + "failed": "فشل التصدير. حاول مرة أخرى.", + "missing": "لم يتم العثور على التصدير. حاول مرة أخرى.", + "labels": { + "pending": "في الطابور", + "in_progress": "قيد المعالجة", + "complete": "مكتمل", + "error": "فشل" + } + }, + "errors": { + "invalid": "غير صالح", + "language_required": "اللغة مطلوبة.", + "language_not_found": "اللغة غير موجودة.", + "no_books_available": "لا توجد أسفار متاحة للتصدير.", + "no_chapters_available": "لا توجد إصحاحات مطابقة للأسفار المحددة.", + "export_failed": "فشل التصدير.", + "chapters_range_invalid": "يجب أن تكون نطاقات الإصحاحات أرقامًا موجبة وأن يكون البدء قبل الانتهاء.", + "chapters_numeric_or_ranges": "يجب أن تكون الإصحاحات أرقامًا أو نطاقات.", + "chapters_positive": "يجب أن تكون الإصحاحات أرقامًا موجبة.", + "chapters_required_or_blank": "أدخل إصحاحًا واحدًا على الأقل أو اتركه فارغًا للكل.", + "books_numeric_ids": "يجب أن تكون الأسفار أرقامًا.", + "books_required": "اختر سفرًا واحدًا على الأقل." + } } } diff --git a/src/messages/en.json b/src/messages/en.json index b8be680b..3a3b15f5 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -156,9 +156,13 @@ "users": "Users", "reports": "Reports", "import": "Import", + "exports": "Exports", "snapshots": "Snapshots" } }, + "LanguageExportsPage": { + "title": "Exports" + }, "LanguageSettingsPage": { "title": "Settings", "form": { @@ -655,5 +659,50 @@ "reference": "{bookId, select, 1 {Genesis} 2 {Exodus} 3 {Leviticus} 4 {Numbers} 5 {Deuteronomy} 6 {Joshua} 7 {Judges} 8 {Ruth} 9 {1 Samuel} 10 {2 Samuel} 11 {1 Kings} 12 {2 Kings} 13 {1 Chronicles} 14 {2 Chronicles} 15 {Ezra} 16 {Nehemiah} 17 {Esther} 18 {Job} 19 {Psalm} 20 {Proverbs} 21 {Ecclesiastes} 22 {Song of Songs} 23 {Isaiah} 24 {Jeremiah} 25 {Lamentations} 26 {Ezekiel} 27 {Daniel} 28 {Hosea} 29 {Joel} 30 {Amos} 31 {Obadiah} 32 {Jonah} 33 {Micah} 34 {Nahum} 35 {Habakkuk} 36 {Zephaniah} 37 {Haggai} 38 {Zechariah} 39 {Malachi} 40 {Matthew} 41 {Mark} 42 {Luke} 43 {John} 44 {Acts} 45 {Romans} 46 {1 Corinthians} 47 {2 Corinthians} 48 {Galatians} 49 {Ephesians} 50 {Philippians} 51 {Colossians} 52 {1 Thessalonians} 53 {2 Thessalonians} 54 {1 Timothy} 55 {2 Timothy} 56 {Titus} 57 {Philemon} 58 {Hebrews} 59 {James} 60 {1 Peter} 61 {2 Peter} 62 {1 John} 63 {2 John} 64 {3 John} 65 {Jude} 66 {Revelation} other {}} {chapterNumber}:{verseNumber}", "not_found": "Not Found", "close": "Close" + }, + "InterlinearExport": { + "title": "Interlinear PDF Export", + "description": "Generate a PDF interlinear for all books.", + "form": { + "books_label": "Books", + "books_placeholder": "All books (default)", + "books_help": "Leave blank to export all books.", + "chapters_label": "Chapters (comma-separated or ranges)", + "chapters_placeholder": "e.g. 1,2,4-6 (leave blank for all)", + "layout_label": "Layout", + "layout_standard": "Standard (word by word)", + "layout_parallel": "Parallel (Original | Gloss column)", + "submit": "Generate PDF", + "queued": "Queued..." + }, + "status": { + "title": "Status", + "all_books": "All books", + "download": "Download PDF", + "expires": "Expires", + "generating": "Generating PDF…", + "failed": "Export failed. Please try again.", + "missing": "Export not found. Please try again.", + "labels": { + "pending": "Queued", + "in_progress": "In progress", + "complete": "Complete", + "error": "Failed" + } + }, + "errors": { + "invalid": "Invalid", + "language_required": "Language is required.", + "language_not_found": "Language not found.", + "no_books_available": "No books available for export.", + "no_chapters_available": "No matching chapters found for the selected books.", + "export_failed": "Export failed.", + "chapters_range_invalid": "Chapter ranges must be positive numbers, and start must be before end.", + "chapters_numeric_or_ranges": "Chapters must be numeric or ranges.", + "chapters_positive": "Chapters must be positive numbers.", + "chapters_required_or_blank": "Please enter at least one chapter or leave blank for all.", + "books_numeric_ids": "Books must be numeric ids.", + "books_required": "Please choose at least one book." + } } } diff --git a/src/modules/export/actions/pollInterlinearExportStatus.test.ts b/src/modules/export/actions/pollInterlinearExportStatus.test.ts new file mode 100644 index 00000000..df23c34c --- /dev/null +++ b/src/modules/export/actions/pollInterlinearExportStatus.test.ts @@ -0,0 +1,139 @@ +import "@/tests/vitest/mocks/nextjs"; +import { initializeDatabase } from "@/tests/vitest/dbUtils"; +import { describe, expect, test } from "vitest"; +import { pollInterlinearExportStatus } from "./pollInterlinearExportStatus"; +import { createScenario } from "@/tests/scenarios"; +import logIn from "@/tests/vitest/login"; +import { query } from "@/db"; +import { ulid } from "@/shared/ulid"; +import { JobStatus } from "@/shared/jobs/model"; +import { EXPORT_JOB_TYPES } from "../jobs/jobTypes"; + +initializeDatabase(); + +async function createExportJob({ + languageId, + languageCode, + requestedBy, + status = JobStatus.Pending, + downloadUrl = null, + expiresAt = null, +}: { + languageId: string; + languageCode: string; + requestedBy: string; + status?: JobStatus; + downloadUrl?: string | null; + expiresAt?: string | null; +}) { + const id = ulid(); + + await query( + `insert into job_type (name) + values ($1) + on conflict (name) do nothing`, + [EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF], + ); + + await query( + `insert into job (id, status, payload, data, created_at, updated_at, type_id) + values ( + $1, + $2, + $3, + $4, + now(), + now(), + (select id from job_type where name = $5) + )`, + [ + id, + status, + { + languageId, + languageCode, + requestedBy, + books: [{ bookId: 1, chapters: [1] }], + layout: "standard", + }, + { + downloadUrl, + expiresAt, + }, + EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF, + ], + ); + return id; +} + +describe("pollInterlinearExportStatus", () => { + test("requires authentication", async () => { + const formData = new FormData(); + formData.set("id", "non-existent"); + await expect(pollInterlinearExportStatus(formData)).toBeNextjsNotFound(); + }); + + test("allows authorized language member to read their request", async () => { + const scenario = await createScenario({ + users: { member: {} }, + languages: { + language: { + members: ["member"], + }, + }, + }); + const language = scenario.languages.language; + const user = scenario.users.member; + await logIn(user.id); + const expiresAt = new Date().toISOString(); + const jobId = await createExportJob({ + languageId: language.id, + languageCode: language.code, + requestedBy: user.id, + status: JobStatus.Complete, + downloadUrl: "https://example.com/file.pdf", + expiresAt, + }); + + const formData = new FormData(); + formData.set("id", jobId); + const response = await pollInterlinearExportStatus(formData); + + expect(response).toEqual({ + id: jobId, + status: JobStatus.Complete, + bookId: 1, + downloadUrl: "https://example.com/file.pdf", + expiresAt, + }); + }); + + test("rejects users not in the language", async () => { + const scenario = await createScenario( + { + users: { member: {} }, + languages: { + language: { + members: ["member"], + }, + }, + }, + { + users: { outsider: {} }, + }, + ); + const language = scenario.languages.language; + const jobId = await createExportJob({ + languageId: language.id, + languageCode: language.code, + requestedBy: scenario.users.member.id, + status: JobStatus.InProgress, + }); + + await logIn(scenario.users.outsider.id); + const formData = new FormData(); + formData.set("id", jobId); + + await expect(pollInterlinearExportStatus(formData)).toBeNextjsNotFound(); + }); +}); diff --git a/src/modules/export/actions/pollInterlinearExportStatus.ts b/src/modules/export/actions/pollInterlinearExportStatus.ts new file mode 100644 index 00000000..8a42e548 --- /dev/null +++ b/src/modules/export/actions/pollInterlinearExportStatus.ts @@ -0,0 +1,70 @@ +"use server"; + +import { z } from "zod"; +import { notFound } from "next/navigation"; +import { verifySession } from "@/session"; +import { Policy } from "@/modules/access"; +import GetInterlinearExportStatus from "../use-cases/GetInterlinearExportStatus"; +import jobRepository from "@/shared/jobs/JobRepository"; +import { JobStatus } from "@/shared/jobs/model"; + +const schema = z.object({ id: z.string().min(1) }); + +export interface ExportRequestStatusRow { + id: string; + status: JobStatus; + bookId: number | null; + downloadUrl?: string | null; + expiresAt?: string | null; +} + +const getInterlinearExportStatusUseCase = new GetInterlinearExportStatus({ + jobRepository, +}); + +export async function pollInterlinearExportStatus( + arg1: FormData, + arg2?: FormData, +) { + const formData = arg2 ?? arg1; + + const session = await verifySession(); + const userId = session?.user?.id; + if (!userId) notFound(); + + const parsed = schema.parse({ id: formData.get("id") }); + const job = await getInterlinearExportStatusUseCase.execute(parsed.id); + if (!job) { + return null; + } + + const languageCode = job.payload?.languageCode; + if (!languageCode) { + return null; + } + + const policy = new Policy({ + systemRoles: [Policy.SystemRole.Admin], + languageMember: true, + }); + const authorized = await policy.authorize({ + actorId: userId, + languageCode, + }); + if (!authorized) { + notFound(); + } + + const bookId = + job.payload.books.length === 1 ? job.payload.books[0].bookId : null; + + return { + id: job.id, + status: job.status, + bookId, + downloadUrl: job.data?.downloadUrl ?? null, + expiresAt: job.data?.expiresAt ?? null, + } satisfies ExportRequestStatusRow; +} + +export default pollInterlinearExportStatus; diff --git a/src/modules/export/actions/requestInterlinearExport.test.ts b/src/modules/export/actions/requestInterlinearExport.test.ts new file mode 100644 index 00000000..6f3dac97 --- /dev/null +++ b/src/modules/export/actions/requestInterlinearExport.test.ts @@ -0,0 +1,178 @@ +import "@/tests/vitest/mocks/nextjs"; +import { initializeDatabase } from "@/tests/vitest/dbUtils"; +import { describe, expect, test, vi } from "vitest"; +import { requestInterlinearExport } from "./requestInterlinearExport"; +import { createScenario } from "@/tests/scenarios"; +import logIn from "@/tests/vitest/login"; +import { enqueueJob } from "@/shared/jobs/enqueueJob"; +import { query } from "@/db"; +import { SystemRoleRaw } from "@/modules/users/model/SystemRole"; + +vi.mock("@/shared/jobs/enqueueJob"); + +initializeDatabase(); + +describe("requestInterlinearExport", () => { + test("rejects unauthenticated requests", async () => { + const formData = new FormData(); + await expect(requestInterlinearExport(formData)).toBeNextjsNotFound(); + expect(enqueueJob).not.toHaveBeenCalled(); + }); + + test("returns validation error for unknown language before enqueue", async () => { + const scenario = await createScenario({ + users: { admin: { systemRoles: [SystemRoleRaw.Admin] } }, + }); + await logIn(scenario.users.admin.id); + + const formData = new FormData(); + formData.set("languageCode", "missing"); + + const response = await requestInterlinearExport(formData); + expect(response).toEqual({ + state: "error", + validation: { languageCode: ["Language not found."] }, + }); + expect(enqueueJob).not.toHaveBeenCalled(); + }); + + test("denies non-members", async () => { + const scenario = await createScenario({ + users: { outsider: {} }, + languages: { language: {} }, + }); + const user = scenario.users.outsider; + const language = scenario.languages.language; + await logIn(user.id); + + const formData = new FormData(); + formData.set("languageCode", language.code); + + await expect(requestInterlinearExport(formData)).toBeNextjsNotFound(); + expect(enqueueJob).not.toHaveBeenCalled(); + }); + + test("creates export request for all books with standard layout", async () => { + const scenario = await createScenario({ + users: { translator: {} }, + languages: { + language: { + members: ["translator"], + }, + }, + }); + const user = scenario.users.translator; + const language = scenario.languages.language; + await logIn(user.id); + + await query( + `insert into book (id, name) values ($1, $2) on conflict (id) do nothing`, + [4, "Test Book"], + ); + await query( + `insert into verse (id, number, book_id, chapter) values ($1, $2, $3, $4) + on conflict (id) do nothing`, + ["4-1-1", 1, 4, 1], + ); + await query( + `insert into verse (id, number, book_id, chapter) values ($1, $2, $3, $4) + on conflict (id) do nothing`, + ["4-2-1", 1, 4, 2], + ); + + const formData = new FormData(); + formData.set("languageCode", language.code); + + (enqueueJob as any).mockResolvedValueOnce({ id: "job-123" }); + const response = await requestInterlinearExport(formData); + expect(response.state).toBe("success"); + expect(response.requestIds?.[0].id).toBe("job-123"); + expect(enqueueJob).toHaveBeenCalledTimes(1); + expect(enqueueJob).toHaveBeenCalledWith("export_interlinear_pdf", { + languageId: language.id, + books: [{ bookId: 4, chapters: [1, 2] }], + languageCode: language.code, + requestedBy: user.id, + layout: "standard", + }); + }); + + test("returns a validation error when no chapters are available", async () => { + const scenario = await createScenario({ + users: { translator: {} }, + languages: { + language: { + members: ["translator"], + }, + }, + }); + const user = scenario.users.translator; + const language = scenario.languages.language; + await logIn(user.id); + + await query( + `insert into book (id, name) values ($1, $2) on conflict (id) do nothing`, + [6, "Test Book"], + ); + + const formData = new FormData(); + formData.set("languageCode", language.code); + const response = await requestInterlinearExport(formData); + expect(response).toEqual({ + state: "error", + validation: { + chapters: ["No matching chapters found for the selected books."], + }, + }); + expect(enqueueJob).not.toHaveBeenCalled(); + }); + + test("defaults to all books and chapters when none provided", async () => { + const scenario = await createScenario({ + users: { translator: {} }, + languages: { + language: { + members: ["translator"], + }, + }, + }); + const user = scenario.users.translator; + const language = scenario.languages.language; + await logIn(user.id); + + await query( + `insert into book (id, name) values (1, 'Book One'), (2, 'Book Two') + on conflict (id) do nothing`, + [], + ); + await query( + `insert into verse (id, number, book_id, chapter) values + ('1-1-1', 1, 1, 1), + ('1-1-2', 2, 1, 2), + ('2-1-1', 1, 2, 1) + on conflict (id) do nothing`, + [], + ); + + const formData = new FormData(); + formData.set("languageCode", language.code); + + (enqueueJob as any).mockResolvedValueOnce({ id: "job-789" }); + const response = await requestInterlinearExport(formData); + expect(response.state).toBe("success"); + expect(response.requestIds).toHaveLength(1); + expect(enqueueJob).toHaveBeenCalledTimes(1); + + expect(response.requestIds?.[0].id).toBe("job-789"); + expect(enqueueJob).toHaveBeenCalledWith("export_interlinear_pdf", { + languageId: language.id, + languageCode: language.code, + requestedBy: user.id, + books: [ + { bookId: 1, chapters: [1, 2] }, + { bookId: 2, chapters: [1] }, + ], + layout: "standard", + }); + }); +}); diff --git a/src/modules/export/actions/requestInterlinearExport.ts b/src/modules/export/actions/requestInterlinearExport.ts new file mode 100644 index 00000000..e4d1fa9c --- /dev/null +++ b/src/modules/export/actions/requestInterlinearExport.ts @@ -0,0 +1,116 @@ +"use server"; + +import * as z from "zod"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { verifySession } from "@/session"; +import { Policy } from "@/modules/access"; +import { FormState } from "@/components/Form"; +import { serverActionLogger } from "@/server-action"; +import bookQueryService from "../data-access/BookQueryService"; +import languageLookupQueryService from "../data-access/LanguageLookupQueryService"; +import RequestInterlinearExport, { + ExportLanguageNotFoundError, + NoBooksAvailableForExportError, + NoChaptersAvailableForExportError, +} from "../use-cases/RequestInterlinearExport"; +import { enqueueJob } from "@/shared/jobs/enqueueJob"; + +const exportPolicy = new Policy({ + systemRoles: [Policy.SystemRole.Admin], + languageMember: true, +}); + +const requestSchema = z.object({ + languageCode: z.string().min(1), +}); + +type RequestInterlinearExportResult = FormState & { + requestIds?: { id: string; bookId: number | null }[]; +}; + +const requestInterlinearExportUseCase = new RequestInterlinearExport({ + bookQueryService, + languageLookupQueryService, + enqueueJob, +}); + +export async function requestInterlinearExport( + arg1: FormState | FormData, + arg2?: FormData, +): Promise { + const formData = arg2 ?? (arg1 as FormData); + const logger = serverActionLogger("requestInterlinearExport"); + const t = await getTranslations("InterlinearExport"); + + const session = await verifySession(); + const userId = session?.user.id; + if (!userId) notFound(); + + const parsed = requestSchema.safeParse( + { + languageCode: formData.get("languageCode"), + }, + { + errorMap: (error) => { + if (error.path.toString() === "languageCode") { + return { message: t("errors.language_required") }; + } + return { message: t("errors.invalid") }; + }, + }, + ); + + if (!parsed.success) { + logger.error("request parse error"); + return { + state: "error", + validation: parsed.error.flatten().fieldErrors, + }; + } + + const authorized = await exportPolicy.authorize({ + actorId: userId, + languageCode: parsed.data.languageCode, + }); + + if (!authorized) { + logger.error("unauthorized"); + notFound(); + } + + try { + const { jobId, bookId } = await requestInterlinearExportUseCase.execute({ + languageCode: parsed.data.languageCode, + requestedBy: userId, + }); + + return { + state: "success", + requestIds: [{ id: jobId, bookId }], + }; + } catch (error) { + if (error instanceof ExportLanguageNotFoundError) { + return { + state: "error", + validation: { languageCode: [t("errors.language_not_found")] }, + }; + } + if (error instanceof NoBooksAvailableForExportError) { + return { + state: "error", + validation: { bookIds: [t("errors.no_books_available")] }, + }; + } + if (error instanceof NoChaptersAvailableForExportError) { + return { + state: "error", + validation: { chapters: [t("errors.no_chapters_available")] }, + }; + } + logger.error({ err: error }, "failed to request export"); + return { state: "error", error: t("errors.export_failed") }; + } +} + +export default requestInterlinearExport; diff --git a/src/modules/export/data-access/BookQueryService.ts b/src/modules/export/data-access/BookQueryService.ts new file mode 100644 index 00000000..b342e0b8 --- /dev/null +++ b/src/modules/export/data-access/BookQueryService.ts @@ -0,0 +1,41 @@ +import { query } from "@/db"; + +export interface BookRow { + id: number; + name: string; +} + +export interface BookChaptersRow { + bookId: number; + chapters: number[]; +} + +const bookQueryService = { + async findAll(): Promise { + const result = await query( + `select id, name from book order by id asc`, + [], + ); + return result.rows; + }, + + async findChapters(bookIds: number[]): Promise { + if (bookIds.length === 0) return []; + + const result = await query( + ` + select book_id as "bookId", + array_agg(distinct chapter order by chapter) as chapters + from verse + where book_id = any($1) + group by book_id + order by book_id + `, + [bookIds], + ); + + return result.rows; + }, +}; + +export default bookQueryService; diff --git a/src/modules/export/data-access/LanguageLookupQueryService.ts b/src/modules/export/data-access/LanguageLookupQueryService.ts new file mode 100644 index 00000000..ebc2f636 --- /dev/null +++ b/src/modules/export/data-access/LanguageLookupQueryService.ts @@ -0,0 +1,29 @@ +import { query } from "@/db"; +import { TextDirectionRaw } from "@/modules/languages/model"; + +export interface ExportLanguageRow { + id: string; + code: string; + name: string; + textDirection: TextDirectionRaw; +} + +const languageLookupQueryService = { + async findByCode(code: string): Promise { + const result = await query( + ` + select id, + code, + name, + text_direction as "textDirection" + from language + where code = $1 + limit 1 + `, + [code], + ); + return result.rows[0]; + }, +}; + +export default languageLookupQueryService; diff --git a/src/modules/export/jobs/exportInterlinearPdfJob.ts b/src/modules/export/jobs/exportInterlinearPdfJob.ts new file mode 100644 index 00000000..5df6f2af --- /dev/null +++ b/src/modules/export/jobs/exportInterlinearPdfJob.ts @@ -0,0 +1,32 @@ +import { Job } from "@/shared/jobs/model"; +import { logger } from "@/logging"; +import { EXPORT_JOB_TYPES } from "./jobTypes"; +import type { + ExportInterlinearPdfJobData, + ExportInterlinearPdfJobPayload, +} from "../model"; + +export async function exportInterlinearPdfJob( + job: Job, +): Promise { + const jobLogger = logger.child({ jobId: job.id, jobType: job.type }); + + if (job.type !== EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF) { + jobLogger.error( + `received job type ${job.type}, expected ${EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF}`, + ); + throw new Error( + `Expected job type ${EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF}, but received ${job.type}`, + ); + } + + try { + jobLogger.info("Interlinear export job completed (noop)"); + return {}; + } catch (error) { + jobLogger.error({ err: error }, "Interlinear export job failed"); + throw error; + } +} + +export default exportInterlinearPdfJob; diff --git a/src/modules/export/jobs/jobTypes.ts b/src/modules/export/jobs/jobTypes.ts new file mode 100644 index 00000000..a46f3546 --- /dev/null +++ b/src/modules/export/jobs/jobTypes.ts @@ -0,0 +1,4 @@ +export const EXPORT_JOB_TYPES = { + EXPORT_INTERLINEAR_PDF: "export_interlinear_pdf", + CLEANUP_EXPORTS: "cleanup_exports", +}; diff --git a/src/modules/export/model.ts b/src/modules/export/model.ts new file mode 100644 index 00000000..2696c38b --- /dev/null +++ b/src/modules/export/model.ts @@ -0,0 +1,21 @@ +export type ExportLayout = "standard" | "parallel"; + +export interface ExportBookSelection { + bookId: number; + chapters: number[]; +} + +export interface ExportInterlinearPdfJobPayload { + languageId: string; + languageCode: string; + requestedBy: string; + books: ExportBookSelection[]; + layout: ExportLayout; +} + +export interface ExportInterlinearPdfJobData { + exportKey?: string; + downloadUrl?: string; + expiresAt?: string; + pages?: number; +} diff --git a/src/modules/export/public/ExportClient.ts b/src/modules/export/public/ExportClient.ts new file mode 100644 index 00000000..b779cf36 --- /dev/null +++ b/src/modules/export/public/ExportClient.ts @@ -0,0 +1,11 @@ +import bookQueryService, { + type BookRow, +} from "../data-access/BookQueryService"; + +export type PublicBookView = BookRow; + +export const exportClient = { + async findAllBooks(): Promise { + return bookQueryService.findAll(); + }, +}; diff --git a/src/modules/export/react/InterlinearExportPanel.client.test.tsx b/src/modules/export/react/InterlinearExportPanel.client.test.tsx new file mode 100644 index 00000000..c4271cdb --- /dev/null +++ b/src/modules/export/react/InterlinearExportPanel.client.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi, beforeEach, MockedFunction } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import InterlinearExportPanelClient, { + PollExportStatusAction, + RequestExportAction, +} from "./InterlinearExportPanelClient"; +import { FormState } from "@/components/Form"; +import { JobStatus } from "@/shared/jobs/model"; +import React from "react"; + +vi.mock("@/components/Button", () => ({ + __esModule: true, + default: ({ children, ...props }: any) => ( + + ), +})); +vi.mock("@/components/Icon", () => ({ + __esModule: true, + Icon: () => null, +})); +vi.mock("@/components/Form", () => { + const React = require("react") as typeof import("react"); + const FormContext = React.createContext({ state: "idle" }); + return { + __esModule: true, + default: ({ + action, + children, + className, + }: { + action: (state: FormState, formData: FormData) => Promise; + children: React.ReactNode; + className?: string; + }) => { + return ( +
{ + event.preventDefault(); + const formData = new FormData(event.currentTarget); + await action({ state: "idle" }, formData); + }} + > + + {children} + +
+ ); + }, + FormState: {}, + useFormContext: () => ({ state: "idle" }), + }; +}); + +describe("InterlinearExportPanelClient", () => { + const requestExport = + vi.fn() as MockedFunction; + const pollExportStatus = + vi.fn() as MockedFunction; + const strings = { + title: "Interlinear PDF Export", + description: "Generate a PDF interlinear for all books.", + submit: "Generate PDF", + queued: "Queued...", + statusTitle: "Status", + allBooksLabel: "All books", + downloadLabel: "Download PDF", + expiresLabel: "Expires", + generatingLabel: "Generating PDF…", + failedLabel: "Export failed. Please try again.", + missingLabel: "Export not found. Please try again.", + statusLabels: { + pending: "Queued", + "in-progress": "In progress", + complete: "Complete", + error: "Failed", + }, + }; + + beforeEach(() => { + requestExport.mockReset(); + pollExportStatus.mockReset(); + }); + + it("uses provided actions and updates status from polling", async () => { + requestExport.mockResolvedValue({ + state: "success", + requestIds: [{ id: "req-123", bookId: 1 }], + }); + pollExportStatus.mockResolvedValue({ + id: "req-123", + status: JobStatus.Complete, + bookId: 1, + downloadUrl: "https://example.com/export.pdf", + expiresAt: null, + }); + + render( + , + ); + + const submitButton = screen.getByRole("button", { + name: /generate pdf/i, + }); + await act(async () => { + fireEvent.click(submitButton); + }); + + expect(requestExport).toHaveBeenCalledTimes(1); + const submittedForm = requestExport.mock.calls[0]?.[0] as FormData; + expect(submittedForm.get("languageCode")).toBe("spa"); + + expect( + await screen.findByText(strings.statusLabels[JobStatus.Complete]), + ).not.toBeNull(); + expect(screen.getByText(/Download PDF/)).not.toBeNull(); + }); +}); diff --git a/src/modules/export/react/InterlinearExportPanel.tsx b/src/modules/export/react/InterlinearExportPanel.tsx new file mode 100644 index 00000000..731c2241 --- /dev/null +++ b/src/modules/export/react/InterlinearExportPanel.tsx @@ -0,0 +1,40 @@ +import { requestInterlinearExport } from "@/modules/export/actions/requestInterlinearExport"; +import { pollInterlinearExportStatus } from "@/modules/export/actions/pollInterlinearExportStatus"; +import InterlinearExportPanelClient from "./InterlinearExportPanelClient"; +import { getTranslations } from "next-intl/server"; +import { JobStatus } from "@/shared/jobs/model"; + +export default async function InterlinearExportPanel({ + languageCode, +}: { + languageCode: string; +}) { + const t = await getTranslations("InterlinearExport"); + + return ( + + ); +} diff --git a/src/modules/export/react/InterlinearExportPanelClient.tsx b/src/modules/export/react/InterlinearExportPanelClient.tsx new file mode 100644 index 00000000..1f176ce7 --- /dev/null +++ b/src/modules/export/react/InterlinearExportPanelClient.tsx @@ -0,0 +1,310 @@ +"use client"; + +import React, { useEffect, useRef, useState } from "react"; +import Button from "@/components/Button"; +import { Icon } from "@/components/Icon"; +import Form, { FormState } from "@/components/Form"; +import { JobStatus } from "@/shared/jobs/model"; + +export type StatusRow = { + id: string; + status: JobStatus; + bookId: number | null; + downloadUrl?: string | null; + expiresAt?: string | Date | null; + missingCount?: number; + error?: "missing" | "poll_failed"; +}; + +export type RequestExportAction = ( + formData: FormData, +) => Promise< + FormState & { requestIds?: { id: string; bookId: number | null }[] } +>; + +export type PollExportStatusAction = ( + formData: FormData, +) => Promise; + +export interface InterlinearExportPanelClientProps { + languageCode: string; + strings: { + title: string; + description: string; + submit: string; + queued: string; + statusTitle: string; + allBooksLabel: string; + downloadLabel: string; + expiresLabel: string; + generatingLabel: string; + failedLabel: string; + missingLabel: string; + statusLabels: Record; + }; + requestExport: RequestExportAction; + pollExportStatus: PollExportStatusAction; +} + +export default function InterlinearExportPanelClient({ + languageCode, + strings, + requestExport, + pollExportStatus, +}: InterlinearExportPanelClientProps) { + const [statuses, setStatuses] = useState>({}); + const [pollingIds, setPollingIds] = useState([]); + const [pending, setPending] = useState(false); + const missingCountsRef = useRef>({}); + const isWorking = + pending || + Object.values(statuses).some( + (status) => + status.status !== JobStatus.Complete && + status.status !== JobStatus.Failed, + ); + + const handleSubmit = async ( + _state: FormState, + formData: FormData, + ): Promise => { + try { + setPending(true); + setStatuses({}); + setPollingIds([]); + missingCountsRef.current = {}; + + const result = await requestExport(formData); + if (result.state === "error") { + return result; + } + + const requestIds = result.requestIds ?? []; + if (requestIds.length === 0) { + return { state: "error", error: strings.failedLabel }; + } + + const nextStatuses: Record = {}; + requestIds.forEach(({ id, bookId }) => { + nextStatuses[id] = { + id, + status: JobStatus.Pending, + bookId, + downloadUrl: null, + expiresAt: null, + }; + }); + setStatuses(nextStatuses); + setPollingIds(requestIds.map((request) => request.id)); + + const poll = new FormData(); + poll.set("id", requestIds[0].id); + const statusRow = await pollExportStatus(poll); + if (statusRow) { + setStatuses((prev) => ({ + ...prev, + [statusRow.id]: { ...prev[statusRow.id], ...statusRow }, + })); + } + + return { state: "success" }; + } catch (error) { + console.error(error); + return { state: "error", error: strings.failedLabel }; + } finally { + setPending(false); + } + }; + + useEffect(() => { + if (pollingIds.length === 0) return; + + let cancelled = false; + let timeoutId: ReturnType | null = null; + const MAX_MISSING_POLLS = 5; + + const poll = async () => { + const nextPendingIds: string[] = []; + try { + for (const id of pollingIds) { + const pollForm = new FormData(); + pollForm.set("id", id); + const statusRow = await pollExportStatus(pollForm); + if (statusRow) { + missingCountsRef.current[id] = 0; + setStatuses((prev) => ({ + ...prev, + [statusRow.id]: { + ...prev[statusRow.id], + ...statusRow, + missingCount: 0, + error: undefined, + }, + })); + if ( + statusRow.status !== JobStatus.Complete && + statusRow.status !== JobStatus.Failed + ) { + nextPendingIds.push(id); + } + } else { + const nextCount = (missingCountsRef.current[id] ?? 0) + 1; + missingCountsRef.current[id] = nextCount; + + if (nextCount >= MAX_MISSING_POLLS) { + setStatuses((prev) => { + const current = prev[id]; + if (!current) return prev; + return { + ...prev, + [id]: { + ...current, + missingCount: nextCount, + status: JobStatus.Failed, + error: "missing", + }, + }; + }); + continue; + } + + setStatuses((prev) => { + const current = prev[id]; + if (!current) return prev; + return { + ...prev, + [id]: { + ...current, + missingCount: nextCount, + }, + }; + }); + nextPendingIds.push(id); + } + } + } catch (error) { + console.error("Failed to poll export status", error); + setStatuses((prev) => { + const next = { ...prev }; + pollingIds.forEach((id) => { + if (!next[id]) return; + next[id] = { + ...next[id], + status: JobStatus.Failed, + error: "poll_failed", + }; + }); + return next; + }); + return; + } + + if (!cancelled) { + if (nextPendingIds.length > 0) { + timeoutId = setTimeout(() => setPollingIds(nextPendingIds), 3000); + } else { + setPollingIds([]); + } + } + }; + + poll(); + + return () => { + cancelled = true; + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [pollExportStatus, pollingIds]); + + return ( +
+
+

+ + {strings.title} +

+

{strings.description}

+
+ +
+
+ + +
+ +
+
+ + {Object.keys(statuses).length > 0 && ( +
+
{strings.statusTitle}
+
+ {Object.values(statuses).map((status) => { + const bookName = + status.bookId && status.bookId > 0 ? + `Book ${status.bookId}` + : strings.allBooksLabel; + const isComplete = status.status === JobStatus.Complete; + const isFailed = status.status === JobStatus.Failed; + const statusLabel = + strings.statusLabels[status.status] ?? status.status; + return ( +
+
+
+ + {status.bookId ? `Book ${status.bookId}` : "Book"} + + {bookName} +
+ + {statusLabel} + +
+ {status.downloadUrl && ( +
+ + {status.expiresAt && ( + + {strings.expiresLabel}:{" "} + {new Date(status.expiresAt).toLocaleString()} + + )} +
+ )} + {isFailed && ( + + {status.error === "missing" ? + strings.missingLabel + : strings.failedLabel} + + )} + {!isComplete && !isFailed && ( + + {strings.generatingLabel} + + )} +
+ ); + })} +
+
+ )} +
+
+ ); +} diff --git a/src/modules/export/react/LanguageExportsPage.tsx b/src/modules/export/react/LanguageExportsPage.tsx new file mode 100644 index 00000000..c7e191e9 --- /dev/null +++ b/src/modules/export/react/LanguageExportsPage.tsx @@ -0,0 +1,39 @@ +import ViewTitle from "@/components/ViewTitle"; +import { getTranslations } from "next-intl/server"; +import { Metadata, ResolvingMetadata } from "next"; +import InterlinearExportPanel from "./InterlinearExportPanel"; +import FeatureFlagged from "@/shared/feature-flags/FeatureFlagged"; + +interface Props { + params: { code: string }; +} + +export async function generateMetadata( + _: any, + parent: ResolvingMetadata, +): Promise { + const t = await getTranslations("LanguageExportsPage"); + const { title } = await parent; + + return { + title: `${t("title")} | ${title?.absolute}`, + }; +} + +export default async function LanguageExportsPage({ params }: Props) { + const t = await getTranslations("LanguageExportsPage"); + + return ( +
+
+ {t("title")} + + } + /> +
+
+ ); +} diff --git a/src/modules/export/use-cases/GetInterlinearExportStatus.ts b/src/modules/export/use-cases/GetInterlinearExportStatus.ts new file mode 100644 index 00000000..7a141310 --- /dev/null +++ b/src/modules/export/use-cases/GetInterlinearExportStatus.ts @@ -0,0 +1,27 @@ +import type jobRepository from "@/shared/jobs/JobRepository"; +import { EXPORT_JOB_TYPES } from "../jobs/jobTypes"; +import type { + ExportInterlinearPdfJobData, + ExportInterlinearPdfJobPayload, +} from "../model"; + +export default class GetInterlinearExportStatus { + constructor( + private readonly deps: { + jobRepository: typeof jobRepository; + }, + ) {} + + async execute(jobId: string) { + const job = await this.deps.jobRepository.getById< + ExportInterlinearPdfJobPayload, + ExportInterlinearPdfJobData + >(jobId); + + if (!job || job.type !== EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF) { + return undefined; + } + + return job; + } +} diff --git a/src/modules/export/use-cases/RequestInterlinearExport.ts b/src/modules/export/use-cases/RequestInterlinearExport.ts new file mode 100644 index 00000000..c8a77611 --- /dev/null +++ b/src/modules/export/use-cases/RequestInterlinearExport.ts @@ -0,0 +1,92 @@ +import { ExportBookSelection } from "../model"; +import type bookQueryService from "../data-access/BookQueryService"; +import type languageLookupQueryService from "../data-access/LanguageLookupQueryService"; +import type { enqueueJob } from "@/shared/jobs/enqueueJob"; +import { EXPORT_JOB_TYPES } from "../jobs/jobTypes"; + +export class NoBooksAvailableForExportError extends Error {} +export class NoChaptersAvailableForExportError extends Error {} +export class ExportLanguageNotFoundError extends Error { + constructor(readonly languageCode: string) { + super(); + } +} + +export interface RequestInterlinearExportRequest { + languageCode: string; + requestedBy: string; +} + +export interface RequestInterlinearExportResult { + jobId: string; + bookId: number | null; + books: ExportBookSelection[]; +} + +export default class RequestInterlinearExport { + constructor( + private readonly deps: { + bookQueryService: typeof bookQueryService; + languageLookupQueryService: typeof languageLookupQueryService; + enqueueJob: typeof enqueueJob; + }, + ) {} + + async execute( + request: RequestInterlinearExportRequest, + ): Promise { + const language = await this.deps.languageLookupQueryService.findByCode( + request.languageCode, + ); + if (!language) { + throw new ExportLanguageNotFoundError(request.languageCode); + } + + const allBooks = await this.deps.bookQueryService.findAll(); + const selectedBookIds = allBooks.map((book) => book.id); + if (selectedBookIds.length === 0) { + throw new NoBooksAvailableForExportError(); + } + + const chaptersByBookRows = + await this.deps.bookQueryService.findChapters(selectedBookIds); + const chaptersByBook = new Map( + chaptersByBookRows.map((row) => [row.bookId, row.chapters]), + ); + + const books: ExportBookSelection[] = []; + for (const bookId of selectedBookIds) { + const available = chaptersByBook.get(bookId) ?? []; + if (available.length === 0) { + continue; + } + + const chapters = available; + if (chapters.length === 0) { + continue; + } + books.push({ bookId, chapters }); + } + + if (books.length === 0) { + throw new NoChaptersAvailableForExportError(); + } + + const job = await this.deps.enqueueJob( + EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF, + { + languageId: language.id, + languageCode: request.languageCode, + requestedBy: request.requestedBy, + books, + layout: "standard", + }, + ); + + return { + jobId: job.id, + bookId: books.length === 1 ? books[0].bookId : null, + books, + }; + } +} diff --git a/src/modules/languages/ui/AdminLanguageLayout.tsx b/src/modules/languages/ui/AdminLanguageLayout.tsx index 980d1b2a..e6fe73d2 100644 --- a/src/modules/languages/ui/AdminLanguageLayout.tsx +++ b/src/modules/languages/ui/AdminLanguageLayout.tsx @@ -91,6 +91,19 @@ export default async function LanguageLayout({ {t("links.import")} + + + + {t("links.exports")} + + + } + /> any>(fn: T) => T = + typeof (React as any).cache === "function" ? + (React as any).cache + : (fn: any) => fn; + const fetchSession = cache( async (sessionId: string): Promise => { const result = await query( diff --git a/src/shared/jobs/jobMap.ts b/src/shared/jobs/jobMap.ts index ad4f99f2..b77cf69d 100644 --- a/src/shared/jobs/jobMap.ts +++ b/src/shared/jobs/jobMap.ts @@ -5,6 +5,8 @@ import { REPORTING_JOB_TYPES } from "@/modules/reporting/jobs/jobTypes"; import { SNAPSHOT_JOB_TYPES } from "@/modules/snapshots/jobs/jobTypes"; import { createSnapshotJob } from "@/modules/snapshots/jobs/createSnapshotJob"; import { restoreSnapshotJob } from "@/modules/snapshots/jobs/restoreSnapshotJob"; +import { EXPORT_JOB_TYPES } from "@/modules/export/jobs/jobTypes"; +import exportInterlinearPdfJob from "@/modules/export/jobs/exportInterlinearPdfJob"; export type JobHandler = ( job: Job, @@ -26,6 +28,10 @@ const jobMap: Record> = { handler: exportAnalyticsJob, timeout: 60 * 5, // 5 minutes }, + [EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF]: { + handler: exportInterlinearPdfJob, + timeout: 60 * 5, // 5 minutes + }, [SNAPSHOT_JOB_TYPES.CREATE_SNAPSHOT]: { handler: createSnapshotJob, timeout: 60 * 15, // 15 minutes diff --git a/tests/vitest/testSetup.ts b/tests/vitest/testSetup.ts index 418fa1cd..c9e4e874 100644 --- a/tests/vitest/testSetup.ts +++ b/tests/vitest/testSetup.ts @@ -3,8 +3,10 @@ import "./matchers"; // Necessary for @oslo/password to run in tests // We can remove this after we upgrade from node 18 -// @ts-ignore -globalThis.crypto = webcrypto; +if (!globalThis.crypto) { + // @ts-ignore + globalThis.crypto = webcrypto; +} process.env.ORIGIN = "globalbibletools.com"; process.env.LOG_LEVEL = "silent"; diff --git a/vitest.config.mts b/vitest.config.mts index 3678fda9..87282984 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -3,15 +3,13 @@ import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ plugins: [tsconfigPaths()], + esbuild: { + jsx: "automatic", + }, test: { include: ["**/*.{unit,test}.ts?(x)"], globalSetup: ["./tests/vitest/dbSetup.ts"], setupFiles: ["./tests/vitest/testSetup.ts"], mockReset: true, }, - resolve: { - alias: { - react: "next/dist/compiled/react/cjs/react.development.js", - }, - }, }); From c767b7a457cb16438f071c032211360b54a3fc73 Mon Sep 17 00:00:00 2001 From: delgado-jacob <29643013+delgado-jacob@users.noreply.github.com> Date: Mon, 19 Jan 2026 12:50:17 -0700 Subject: [PATCH 2/6] refactor: simplify export ui flow --- .../25-12-06-add-export-job-types.sql | 4 +- package-lock.json | 2 +- package.json | 2 +- .../[code]/exports/progress/route.ts | 1 + src/messages/ar.json | 21 +- src/messages/en.json | 21 +- .../pollInterlinearExportStatus.test.ts | 139 -------- .../actions/pollInterlinearExportStatus.ts | 70 ---- .../actions/requestInterlinearExport.test.ts | 102 +----- .../actions/requestInterlinearExport.ts | 40 +-- .../data-access/ExportJobQueryService.ts | 80 +++++ .../data-access/LanguageLookupQueryService.ts | 29 -- src/modules/export/jobs/jobTypes.ts | 1 - src/modules/export/model.ts | 9 - src/modules/export/public/ExportClient.ts | 11 - .../export/react/ExportJobStatusPoller.tsx | 25 ++ .../InterlinearExportPanel.client.test.tsx | 125 ------- .../export/react/InterlinearExportPanel.tsx | 121 +++++-- .../react/InterlinearExportPanelClient.tsx | 310 ------------------ .../export/react/LanguageExportsPage.tsx | 8 +- .../route-handlers/getExportProgress.ts | 36 ++ .../use-cases/GetInterlinearExportStatus.ts | 28 +- .../use-cases/RequestInterlinearExport.ts | 94 +----- src/modules/languages/index.ts | 2 + .../use-cases/resolveLanguageByCode.ts | 28 ++ src/session.ts | 8 +- tests/vitest/testSetup.ts | 5 + 27 files changed, 310 insertions(+), 1012 deletions(-) create mode 100644 src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/progress/route.ts delete mode 100644 src/modules/export/actions/pollInterlinearExportStatus.test.ts delete mode 100644 src/modules/export/actions/pollInterlinearExportStatus.ts create mode 100644 src/modules/export/data-access/ExportJobQueryService.ts delete mode 100644 src/modules/export/data-access/LanguageLookupQueryService.ts delete mode 100644 src/modules/export/public/ExportClient.ts create mode 100644 src/modules/export/react/ExportJobStatusPoller.tsx delete mode 100644 src/modules/export/react/InterlinearExportPanel.client.test.tsx delete mode 100644 src/modules/export/react/InterlinearExportPanelClient.tsx create mode 100644 src/modules/export/route-handlers/getExportProgress.ts create mode 100644 src/modules/languages/index.ts create mode 100644 src/modules/languages/use-cases/resolveLanguageByCode.ts diff --git a/db/migrations/25-12-06-add-export-job-types.sql b/db/migrations/25-12-06-add-export-job-types.sql index f15acb97..02e62a3f 100644 --- a/db/migrations/25-12-06-add-export-job-types.sql +++ b/db/migrations/25-12-06-add-export-job-types.sql @@ -5,7 +5,5 @@ select setval( insert into job_type (name) values - ('export_interlinear_pdf'), - ('cleanup_exports') + ('export_interlinear_pdf') on conflict (name) do nothing; - diff --git a/package-lock.json b/package-lock.json index 727202ae..703e0447 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "@fortawesome/react-fontawesome": "0.2.2", "@headlessui/react": "1.7.19", "@headlessui/tailwindcss": "0.2.1", - "@testing-library/react": "^16.3.0", + "@testing-library/react": "16.3.1", "@tiptap/react": "2.6.6", "@tiptap/starter-kit": "2.6.6", "@types/aws-lambda": "8.10.147", diff --git a/package.json b/package.json index 122af329..fe649937 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "@fortawesome/react-fontawesome": "0.2.2", "@headlessui/react": "1.7.19", "@headlessui/tailwindcss": "0.2.1", - "@testing-library/react": "^16.3.0", + "@testing-library/react": "16.3.1", "@tiptap/react": "2.6.6", "@tiptap/starter-kit": "2.6.6", "@types/aws-lambda": "8.10.147", diff --git a/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/progress/route.ts b/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/progress/route.ts new file mode 100644 index 00000000..61041169 --- /dev/null +++ b/src/app/[locale]/(main)/admin/(language)/languages/[code]/exports/progress/route.ts @@ -0,0 +1 @@ +export { default as GET } from "@/modules/export/route-handlers/getExportProgress"; diff --git a/src/messages/ar.json b/src/messages/ar.json index d6a3db2a..567b6f58 100644 --- a/src/messages/ar.json +++ b/src/messages/ar.json @@ -661,25 +661,15 @@ "title": "تصدير PDF بين السطور", "description": "إنشاء ملف PDF بين السطور لكل الأسفار.", "form": { - "books_label": "الأسفار", - "books_placeholder": "كل الأسفار (الافتراضي)", - "books_help": "اتركه فارغًا لتصدير كل الأسفار.", - "chapters_label": "الإصحاحات (مفصولة بفواصل أو نطاقات)", - "chapters_placeholder": "مثال: 1,2,4-6 (اتركه فارغًا للكل)", - "layout_label": "التخطيط", - "layout_standard": "قياسي (كلمة بكلمة)", - "layout_parallel": "متوازٍ (الأصل | عمود الترجمة)", "submit": "إنشاء PDF", "queued": "تمت الإضافة للطابور..." }, "status": { "title": "الحالة", - "all_books": "كل الأسفار", "download": "تنزيل PDF", "expires": "ينتهي", "generating": "جارٍ إنشاء PDF…", "failed": "فشل التصدير. حاول مرة أخرى.", - "missing": "لم يتم العثور على التصدير. حاول مرة أخرى.", "labels": { "pending": "في الطابور", "in_progress": "قيد المعالجة", @@ -690,16 +680,7 @@ "errors": { "invalid": "غير صالح", "language_required": "اللغة مطلوبة.", - "language_not_found": "اللغة غير موجودة.", - "no_books_available": "لا توجد أسفار متاحة للتصدير.", - "no_chapters_available": "لا توجد إصحاحات مطابقة للأسفار المحددة.", - "export_failed": "فشل التصدير.", - "chapters_range_invalid": "يجب أن تكون نطاقات الإصحاحات أرقامًا موجبة وأن يكون البدء قبل الانتهاء.", - "chapters_numeric_or_ranges": "يجب أن تكون الإصحاحات أرقامًا أو نطاقات.", - "chapters_positive": "يجب أن تكون الإصحاحات أرقامًا موجبة.", - "chapters_required_or_blank": "أدخل إصحاحًا واحدًا على الأقل أو اتركه فارغًا للكل.", - "books_numeric_ids": "يجب أن تكون الأسفار أرقامًا.", - "books_required": "اختر سفرًا واحدًا على الأقل." + "export_failed": "فشل التصدير." } } } diff --git a/src/messages/en.json b/src/messages/en.json index 3a3b15f5..25fa5978 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -664,25 +664,15 @@ "title": "Interlinear PDF Export", "description": "Generate a PDF interlinear for all books.", "form": { - "books_label": "Books", - "books_placeholder": "All books (default)", - "books_help": "Leave blank to export all books.", - "chapters_label": "Chapters (comma-separated or ranges)", - "chapters_placeholder": "e.g. 1,2,4-6 (leave blank for all)", - "layout_label": "Layout", - "layout_standard": "Standard (word by word)", - "layout_parallel": "Parallel (Original | Gloss column)", "submit": "Generate PDF", "queued": "Queued..." }, "status": { "title": "Status", - "all_books": "All books", "download": "Download PDF", "expires": "Expires", "generating": "Generating PDF…", "failed": "Export failed. Please try again.", - "missing": "Export not found. Please try again.", "labels": { "pending": "Queued", "in_progress": "In progress", @@ -693,16 +683,7 @@ "errors": { "invalid": "Invalid", "language_required": "Language is required.", - "language_not_found": "Language not found.", - "no_books_available": "No books available for export.", - "no_chapters_available": "No matching chapters found for the selected books.", - "export_failed": "Export failed.", - "chapters_range_invalid": "Chapter ranges must be positive numbers, and start must be before end.", - "chapters_numeric_or_ranges": "Chapters must be numeric or ranges.", - "chapters_positive": "Chapters must be positive numbers.", - "chapters_required_or_blank": "Please enter at least one chapter or leave blank for all.", - "books_numeric_ids": "Books must be numeric ids.", - "books_required": "Please choose at least one book." + "export_failed": "Export failed." } } } diff --git a/src/modules/export/actions/pollInterlinearExportStatus.test.ts b/src/modules/export/actions/pollInterlinearExportStatus.test.ts deleted file mode 100644 index df23c34c..00000000 --- a/src/modules/export/actions/pollInterlinearExportStatus.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import "@/tests/vitest/mocks/nextjs"; -import { initializeDatabase } from "@/tests/vitest/dbUtils"; -import { describe, expect, test } from "vitest"; -import { pollInterlinearExportStatus } from "./pollInterlinearExportStatus"; -import { createScenario } from "@/tests/scenarios"; -import logIn from "@/tests/vitest/login"; -import { query } from "@/db"; -import { ulid } from "@/shared/ulid"; -import { JobStatus } from "@/shared/jobs/model"; -import { EXPORT_JOB_TYPES } from "../jobs/jobTypes"; - -initializeDatabase(); - -async function createExportJob({ - languageId, - languageCode, - requestedBy, - status = JobStatus.Pending, - downloadUrl = null, - expiresAt = null, -}: { - languageId: string; - languageCode: string; - requestedBy: string; - status?: JobStatus; - downloadUrl?: string | null; - expiresAt?: string | null; -}) { - const id = ulid(); - - await query( - `insert into job_type (name) - values ($1) - on conflict (name) do nothing`, - [EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF], - ); - - await query( - `insert into job (id, status, payload, data, created_at, updated_at, type_id) - values ( - $1, - $2, - $3, - $4, - now(), - now(), - (select id from job_type where name = $5) - )`, - [ - id, - status, - { - languageId, - languageCode, - requestedBy, - books: [{ bookId: 1, chapters: [1] }], - layout: "standard", - }, - { - downloadUrl, - expiresAt, - }, - EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF, - ], - ); - return id; -} - -describe("pollInterlinearExportStatus", () => { - test("requires authentication", async () => { - const formData = new FormData(); - formData.set("id", "non-existent"); - await expect(pollInterlinearExportStatus(formData)).toBeNextjsNotFound(); - }); - - test("allows authorized language member to read their request", async () => { - const scenario = await createScenario({ - users: { member: {} }, - languages: { - language: { - members: ["member"], - }, - }, - }); - const language = scenario.languages.language; - const user = scenario.users.member; - await logIn(user.id); - const expiresAt = new Date().toISOString(); - const jobId = await createExportJob({ - languageId: language.id, - languageCode: language.code, - requestedBy: user.id, - status: JobStatus.Complete, - downloadUrl: "https://example.com/file.pdf", - expiresAt, - }); - - const formData = new FormData(); - formData.set("id", jobId); - const response = await pollInterlinearExportStatus(formData); - - expect(response).toEqual({ - id: jobId, - status: JobStatus.Complete, - bookId: 1, - downloadUrl: "https://example.com/file.pdf", - expiresAt, - }); - }); - - test("rejects users not in the language", async () => { - const scenario = await createScenario( - { - users: { member: {} }, - languages: { - language: { - members: ["member"], - }, - }, - }, - { - users: { outsider: {} }, - }, - ); - const language = scenario.languages.language; - const jobId = await createExportJob({ - languageId: language.id, - languageCode: language.code, - requestedBy: scenario.users.member.id, - status: JobStatus.InProgress, - }); - - await logIn(scenario.users.outsider.id); - const formData = new FormData(); - formData.set("id", jobId); - - await expect(pollInterlinearExportStatus(formData)).toBeNextjsNotFound(); - }); -}); diff --git a/src/modules/export/actions/pollInterlinearExportStatus.ts b/src/modules/export/actions/pollInterlinearExportStatus.ts deleted file mode 100644 index 8a42e548..00000000 --- a/src/modules/export/actions/pollInterlinearExportStatus.ts +++ /dev/null @@ -1,70 +0,0 @@ -"use server"; - -import { z } from "zod"; -import { notFound } from "next/navigation"; -import { verifySession } from "@/session"; -import { Policy } from "@/modules/access"; -import GetInterlinearExportStatus from "../use-cases/GetInterlinearExportStatus"; -import jobRepository from "@/shared/jobs/JobRepository"; -import { JobStatus } from "@/shared/jobs/model"; - -const schema = z.object({ id: z.string().min(1) }); - -export interface ExportRequestStatusRow { - id: string; - status: JobStatus; - bookId: number | null; - downloadUrl?: string | null; - expiresAt?: string | null; -} - -const getInterlinearExportStatusUseCase = new GetInterlinearExportStatus({ - jobRepository, -}); - -export async function pollInterlinearExportStatus( - arg1: FormData, - arg2?: FormData, -) { - const formData = arg2 ?? arg1; - - const session = await verifySession(); - const userId = session?.user?.id; - if (!userId) notFound(); - - const parsed = schema.parse({ id: formData.get("id") }); - const job = await getInterlinearExportStatusUseCase.execute(parsed.id); - if (!job) { - return null; - } - - const languageCode = job.payload?.languageCode; - if (!languageCode) { - return null; - } - - const policy = new Policy({ - systemRoles: [Policy.SystemRole.Admin], - languageMember: true, - }); - const authorized = await policy.authorize({ - actorId: userId, - languageCode, - }); - if (!authorized) { - notFound(); - } - - const bookId = - job.payload.books.length === 1 ? job.payload.books[0].bookId : null; - - return { - id: job.id, - status: job.status, - bookId, - downloadUrl: job.data?.downloadUrl ?? null, - expiresAt: job.data?.expiresAt ?? null, - } satisfies ExportRequestStatusRow; -} - -export default pollInterlinearExportStatus; diff --git a/src/modules/export/actions/requestInterlinearExport.test.ts b/src/modules/export/actions/requestInterlinearExport.test.ts index 6f3dac97..e9d54524 100644 --- a/src/modules/export/actions/requestInterlinearExport.test.ts +++ b/src/modules/export/actions/requestInterlinearExport.test.ts @@ -5,7 +5,6 @@ import { requestInterlinearExport } from "./requestInterlinearExport"; import { createScenario } from "@/tests/scenarios"; import logIn from "@/tests/vitest/login"; import { enqueueJob } from "@/shared/jobs/enqueueJob"; -import { query } from "@/db"; import { SystemRoleRaw } from "@/modules/users/model/SystemRole"; vi.mock("@/shared/jobs/enqueueJob"); @@ -31,7 +30,7 @@ describe("requestInterlinearExport", () => { const response = await requestInterlinearExport(formData); expect(response).toEqual({ state: "error", - validation: { languageCode: ["Language not found."] }, + error: "Export failed.", }); expect(enqueueJob).not.toHaveBeenCalled(); }); @@ -52,7 +51,7 @@ describe("requestInterlinearExport", () => { expect(enqueueJob).not.toHaveBeenCalled(); }); - test("creates export request for all books with standard layout", async () => { + test("creates export request for language", async () => { const scenario = await createScenario({ users: { translator: {} }, languages: { @@ -65,114 +64,17 @@ describe("requestInterlinearExport", () => { const language = scenario.languages.language; await logIn(user.id); - await query( - `insert into book (id, name) values ($1, $2) on conflict (id) do nothing`, - [4, "Test Book"], - ); - await query( - `insert into verse (id, number, book_id, chapter) values ($1, $2, $3, $4) - on conflict (id) do nothing`, - ["4-1-1", 1, 4, 1], - ); - await query( - `insert into verse (id, number, book_id, chapter) values ($1, $2, $3, $4) - on conflict (id) do nothing`, - ["4-2-1", 1, 4, 2], - ); - const formData = new FormData(); formData.set("languageCode", language.code); (enqueueJob as any).mockResolvedValueOnce({ id: "job-123" }); const response = await requestInterlinearExport(formData); expect(response.state).toBe("success"); - expect(response.requestIds?.[0].id).toBe("job-123"); expect(enqueueJob).toHaveBeenCalledTimes(1); - expect(enqueueJob).toHaveBeenCalledWith("export_interlinear_pdf", { - languageId: language.id, - books: [{ bookId: 4, chapters: [1, 2] }], - languageCode: language.code, - requestedBy: user.id, - layout: "standard", - }); - }); - - test("returns a validation error when no chapters are available", async () => { - const scenario = await createScenario({ - users: { translator: {} }, - languages: { - language: { - members: ["translator"], - }, - }, - }); - const user = scenario.users.translator; - const language = scenario.languages.language; - await logIn(user.id); - - await query( - `insert into book (id, name) values ($1, $2) on conflict (id) do nothing`, - [6, "Test Book"], - ); - - const formData = new FormData(); - formData.set("languageCode", language.code); - const response = await requestInterlinearExport(formData); - expect(response).toEqual({ - state: "error", - validation: { - chapters: ["No matching chapters found for the selected books."], - }, - }); - expect(enqueueJob).not.toHaveBeenCalled(); - }); - - test("defaults to all books and chapters when none provided", async () => { - const scenario = await createScenario({ - users: { translator: {} }, - languages: { - language: { - members: ["translator"], - }, - }, - }); - const user = scenario.users.translator; - const language = scenario.languages.language; - await logIn(user.id); - - await query( - `insert into book (id, name) values (1, 'Book One'), (2, 'Book Two') - on conflict (id) do nothing`, - [], - ); - await query( - `insert into verse (id, number, book_id, chapter) values - ('1-1-1', 1, 1, 1), - ('1-1-2', 2, 1, 2), - ('2-1-1', 1, 2, 1) - on conflict (id) do nothing`, - [], - ); - - const formData = new FormData(); - formData.set("languageCode", language.code); - - (enqueueJob as any).mockResolvedValueOnce({ id: "job-789" }); - const response = await requestInterlinearExport(formData); - expect(response.state).toBe("success"); - expect(response.requestIds).toHaveLength(1); - expect(enqueueJob).toHaveBeenCalledTimes(1); - - expect(response.requestIds?.[0].id).toBe("job-789"); expect(enqueueJob).toHaveBeenCalledWith("export_interlinear_pdf", { languageId: language.id, languageCode: language.code, requestedBy: user.id, - books: [ - { bookId: 1, chapters: [1, 2] }, - { bookId: 2, chapters: [1] }, - ], - layout: "standard", }); }); }); diff --git a/src/modules/export/actions/requestInterlinearExport.ts b/src/modules/export/actions/requestInterlinearExport.ts index e4d1fa9c..4c317538 100644 --- a/src/modules/export/actions/requestInterlinearExport.ts +++ b/src/modules/export/actions/requestInterlinearExport.ts @@ -7,14 +7,7 @@ import { verifySession } from "@/session"; import { Policy } from "@/modules/access"; import { FormState } from "@/components/Form"; import { serverActionLogger } from "@/server-action"; -import bookQueryService from "../data-access/BookQueryService"; -import languageLookupQueryService from "../data-access/LanguageLookupQueryService"; -import RequestInterlinearExport, { - ExportLanguageNotFoundError, - NoBooksAvailableForExportError, - NoChaptersAvailableForExportError, -} from "../use-cases/RequestInterlinearExport"; -import { enqueueJob } from "@/shared/jobs/enqueueJob"; +import { requestInterlinearExport } from "../use-cases/RequestInterlinearExport"; const exportPolicy = new Policy({ systemRoles: [Policy.SystemRole.Admin], @@ -25,15 +18,7 @@ const requestSchema = z.object({ languageCode: z.string().min(1), }); -type RequestInterlinearExportResult = FormState & { - requestIds?: { id: string; bookId: number | null }[]; -}; - -const requestInterlinearExportUseCase = new RequestInterlinearExport({ - bookQueryService, - languageLookupQueryService, - enqueueJob, -}); +type RequestInterlinearExportResult = FormState; export async function requestInterlinearExport( arg1: FormState | FormData, @@ -80,34 +65,15 @@ export async function requestInterlinearExport( } try { - const { jobId, bookId } = await requestInterlinearExportUseCase.execute({ + await requestInterlinearExport({ languageCode: parsed.data.languageCode, requestedBy: userId, }); return { state: "success", - requestIds: [{ id: jobId, bookId }], }; } catch (error) { - if (error instanceof ExportLanguageNotFoundError) { - return { - state: "error", - validation: { languageCode: [t("errors.language_not_found")] }, - }; - } - if (error instanceof NoBooksAvailableForExportError) { - return { - state: "error", - validation: { bookIds: [t("errors.no_books_available")] }, - }; - } - if (error instanceof NoChaptersAvailableForExportError) { - return { - state: "error", - validation: { chapters: [t("errors.no_chapters_available")] }, - }; - } logger.error({ err: error }, "failed to request export"); return { state: "error", error: t("errors.export_failed") }; } diff --git a/src/modules/export/data-access/ExportJobQueryService.ts b/src/modules/export/data-access/ExportJobQueryService.ts new file mode 100644 index 00000000..9f58a477 --- /dev/null +++ b/src/modules/export/data-access/ExportJobQueryService.ts @@ -0,0 +1,80 @@ +import { query } from "@/db"; +import { JobStatus } from "@/shared/jobs/model"; +import type { + ExportInterlinearPdfJobData, + ExportInterlinearPdfJobPayload, +} from "../model"; +import { EXPORT_JOB_TYPES } from "../jobs/jobTypes"; + +export interface ExportJobRow { + id: string; + type: string; + status: JobStatus; + payload: ExportInterlinearPdfJobPayload; + data?: ExportInterlinearPdfJobData; + createdAt: Date; + updatedAt: Date; +} + +const exportJobQueryService = { + async findRecentForLanguage( + languageCode: string, + limit = 10, + ): Promise { + const result = await query( + ` + select + job.id, + job.status, + job.payload, + job.data, + job.created_at as "createdAt", + job.updated_at as "updatedAt", + job_type.name as type + from job + join job_type on job_type.id = job.type_id + where job_type.name = $1 + and job.payload->>'languageCode' = $2 + order by job.created_at desc + limit $3 + `, + [EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF, languageCode, limit], + ); + + return result.rows; + }, + + async findPendingForLanguage( + languageCode: string, + ): Promise { + const result = await query( + ` + select + job.id, + job.status, + job.payload, + job.data, + job.created_at as "createdAt", + job.updated_at as "updatedAt", + job_type.name as type + from job + join job_type on job_type.id = job.type_id + where job_type.name = $1 + and job.payload->>'languageCode' = $2 + and job.status in ($3, $4) + order by job.created_at desc + limit 1 + `, + [ + EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF, + languageCode, + JobStatus.Pending, + JobStatus.InProgress, + ], + ); + + return result.rows[0]; + }, +}; + +export default exportJobQueryService; diff --git a/src/modules/export/data-access/LanguageLookupQueryService.ts b/src/modules/export/data-access/LanguageLookupQueryService.ts deleted file mode 100644 index ebc2f636..00000000 --- a/src/modules/export/data-access/LanguageLookupQueryService.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { query } from "@/db"; -import { TextDirectionRaw } from "@/modules/languages/model"; - -export interface ExportLanguageRow { - id: string; - code: string; - name: string; - textDirection: TextDirectionRaw; -} - -const languageLookupQueryService = { - async findByCode(code: string): Promise { - const result = await query( - ` - select id, - code, - name, - text_direction as "textDirection" - from language - where code = $1 - limit 1 - `, - [code], - ); - return result.rows[0]; - }, -}; - -export default languageLookupQueryService; diff --git a/src/modules/export/jobs/jobTypes.ts b/src/modules/export/jobs/jobTypes.ts index a46f3546..f0823794 100644 --- a/src/modules/export/jobs/jobTypes.ts +++ b/src/modules/export/jobs/jobTypes.ts @@ -1,4 +1,3 @@ export const EXPORT_JOB_TYPES = { EXPORT_INTERLINEAR_PDF: "export_interlinear_pdf", - CLEANUP_EXPORTS: "cleanup_exports", }; diff --git a/src/modules/export/model.ts b/src/modules/export/model.ts index 2696c38b..169289af 100644 --- a/src/modules/export/model.ts +++ b/src/modules/export/model.ts @@ -1,16 +1,7 @@ -export type ExportLayout = "standard" | "parallel"; - -export interface ExportBookSelection { - bookId: number; - chapters: number[]; -} - export interface ExportInterlinearPdfJobPayload { languageId: string; languageCode: string; requestedBy: string; - books: ExportBookSelection[]; - layout: ExportLayout; } export interface ExportInterlinearPdfJobData { diff --git a/src/modules/export/public/ExportClient.ts b/src/modules/export/public/ExportClient.ts deleted file mode 100644 index b779cf36..00000000 --- a/src/modules/export/public/ExportClient.ts +++ /dev/null @@ -1,11 +0,0 @@ -import bookQueryService, { - type BookRow, -} from "../data-access/BookQueryService"; - -export type PublicBookView = BookRow; - -export const exportClient = { - async findAllBooks(): Promise { - return bookQueryService.findAll(); - }, -}; diff --git a/src/modules/export/react/ExportJobStatusPoller.tsx b/src/modules/export/react/ExportJobStatusPoller.tsx new file mode 100644 index 00000000..3a54567b --- /dev/null +++ b/src/modules/export/react/ExportJobStatusPoller.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import useSWR from "swr"; + +export default function ExportJobStatusPoller({ code }: { code: string }) { + const router = useRouter(); + + const { data } = useSWR( + ["export-progress", code], + async () => { + const response = await fetch("./exports/progress"); + return (await response.json()) as { done: boolean }; + }, + { + refreshInterval: 15000, + }, + ); + + if (data?.done) { + router.refresh(); + } + + return null; +} diff --git a/src/modules/export/react/InterlinearExportPanel.client.test.tsx b/src/modules/export/react/InterlinearExportPanel.client.test.tsx deleted file mode 100644 index c4271cdb..00000000 --- a/src/modules/export/react/InterlinearExportPanel.client.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -// @vitest-environment jsdom - -import { describe, expect, it, vi, beforeEach, MockedFunction } from "vitest"; -import { render, screen, fireEvent, act } from "@testing-library/react"; -import InterlinearExportPanelClient, { - PollExportStatusAction, - RequestExportAction, -} from "./InterlinearExportPanelClient"; -import { FormState } from "@/components/Form"; -import { JobStatus } from "@/shared/jobs/model"; -import React from "react"; - -vi.mock("@/components/Button", () => ({ - __esModule: true, - default: ({ children, ...props }: any) => ( - - ), -})); -vi.mock("@/components/Icon", () => ({ - __esModule: true, - Icon: () => null, -})); -vi.mock("@/components/Form", () => { - const React = require("react") as typeof import("react"); - const FormContext = React.createContext({ state: "idle" }); - return { - __esModule: true, - default: ({ - action, - children, - className, - }: { - action: (state: FormState, formData: FormData) => Promise; - children: React.ReactNode; - className?: string; - }) => { - return ( -
{ - event.preventDefault(); - const formData = new FormData(event.currentTarget); - await action({ state: "idle" }, formData); - }} - > - - {children} - -
- ); - }, - FormState: {}, - useFormContext: () => ({ state: "idle" }), - }; -}); - -describe("InterlinearExportPanelClient", () => { - const requestExport = - vi.fn() as MockedFunction; - const pollExportStatus = - vi.fn() as MockedFunction; - const strings = { - title: "Interlinear PDF Export", - description: "Generate a PDF interlinear for all books.", - submit: "Generate PDF", - queued: "Queued...", - statusTitle: "Status", - allBooksLabel: "All books", - downloadLabel: "Download PDF", - expiresLabel: "Expires", - generatingLabel: "Generating PDF…", - failedLabel: "Export failed. Please try again.", - missingLabel: "Export not found. Please try again.", - statusLabels: { - pending: "Queued", - "in-progress": "In progress", - complete: "Complete", - error: "Failed", - }, - }; - - beforeEach(() => { - requestExport.mockReset(); - pollExportStatus.mockReset(); - }); - - it("uses provided actions and updates status from polling", async () => { - requestExport.mockResolvedValue({ - state: "success", - requestIds: [{ id: "req-123", bookId: 1 }], - }); - pollExportStatus.mockResolvedValue({ - id: "req-123", - status: JobStatus.Complete, - bookId: 1, - downloadUrl: "https://example.com/export.pdf", - expiresAt: null, - }); - - render( - , - ); - - const submitButton = screen.getByRole("button", { - name: /generate pdf/i, - }); - await act(async () => { - fireEvent.click(submitButton); - }); - - expect(requestExport).toHaveBeenCalledTimes(1); - const submittedForm = requestExport.mock.calls[0]?.[0] as FormData; - expect(submittedForm.get("languageCode")).toBe("spa"); - - expect( - await screen.findByText(strings.statusLabels[JobStatus.Complete]), - ).not.toBeNull(); - expect(screen.getByText(/Download PDF/)).not.toBeNull(); - }); -}); diff --git a/src/modules/export/react/InterlinearExportPanel.tsx b/src/modules/export/react/InterlinearExportPanel.tsx index 731c2241..f7fc02e0 100644 --- a/src/modules/export/react/InterlinearExportPanel.tsx +++ b/src/modules/export/react/InterlinearExportPanel.tsx @@ -1,8 +1,11 @@ import { requestInterlinearExport } from "@/modules/export/actions/requestInterlinearExport"; -import { pollInterlinearExportStatus } from "@/modules/export/actions/pollInterlinearExportStatus"; -import InterlinearExportPanelClient from "./InterlinearExportPanelClient"; import { getTranslations } from "next-intl/server"; import { JobStatus } from "@/shared/jobs/model"; +import Form from "@/components/Form"; +import Button from "@/components/Button"; +import { Icon } from "@/components/Icon"; +import exportJobQueryService from "../data-access/ExportJobQueryService"; +import ExportJobStatusPoller from "./ExportJobStatusPoller"; export default async function InterlinearExportPanel({ languageCode, @@ -10,31 +13,97 @@ export default async function InterlinearExportPanel({ languageCode: string; }) { const t = await getTranslations("InterlinearExport"); + const [jobs, pendingJob] = await Promise.all([ + exportJobQueryService.findRecentForLanguage(languageCode), + exportJobQueryService.findPendingForLanguage(languageCode), + ]); + + const statusLabels = { + [JobStatus.Pending]: t("status.labels.pending"), + [JobStatus.InProgress]: t("status.labels.in_progress"), + [JobStatus.Complete]: t("status.labels.complete"), + [JobStatus.Failed]: t("status.labels.error"), + }; return ( - +
+
+

+ + {t("title")} +

+

{t("description")}

+
+ +
+
+ +
+ +
+
+ + {jobs.length > 0 && ( +
+
{t("status.title")}
+
+ {jobs.map((job) => { + const isComplete = job.status === JobStatus.Complete; + const isFailed = job.status === JobStatus.Failed; + const statusLabel = statusLabels[job.status] ?? job.status; + const createdAt = new Date(job.createdAt).toLocaleString(); + return ( +
+
+
+
{createdAt}
+
+ + {statusLabel} + +
+ {job.data?.downloadUrl && ( +
+ + {job.data.expiresAt && ( + + {t("status.expires")}:{" "} + {new Date(job.data.expiresAt).toLocaleString()} + + )} +
+ )} + {isFailed && ( + + {t("status.failed")} + + )} + {!isComplete && !isFailed && ( + + {t("status.generating")} + + )} +
+ ); + })} +
+
+ )} + + {pendingJob && } +
+
); } diff --git a/src/modules/export/react/InterlinearExportPanelClient.tsx b/src/modules/export/react/InterlinearExportPanelClient.tsx deleted file mode 100644 index 1f176ce7..00000000 --- a/src/modules/export/react/InterlinearExportPanelClient.tsx +++ /dev/null @@ -1,310 +0,0 @@ -"use client"; - -import React, { useEffect, useRef, useState } from "react"; -import Button from "@/components/Button"; -import { Icon } from "@/components/Icon"; -import Form, { FormState } from "@/components/Form"; -import { JobStatus } from "@/shared/jobs/model"; - -export type StatusRow = { - id: string; - status: JobStatus; - bookId: number | null; - downloadUrl?: string | null; - expiresAt?: string | Date | null; - missingCount?: number; - error?: "missing" | "poll_failed"; -}; - -export type RequestExportAction = ( - formData: FormData, -) => Promise< - FormState & { requestIds?: { id: string; bookId: number | null }[] } ->; - -export type PollExportStatusAction = ( - formData: FormData, -) => Promise; - -export interface InterlinearExportPanelClientProps { - languageCode: string; - strings: { - title: string; - description: string; - submit: string; - queued: string; - statusTitle: string; - allBooksLabel: string; - downloadLabel: string; - expiresLabel: string; - generatingLabel: string; - failedLabel: string; - missingLabel: string; - statusLabels: Record; - }; - requestExport: RequestExportAction; - pollExportStatus: PollExportStatusAction; -} - -export default function InterlinearExportPanelClient({ - languageCode, - strings, - requestExport, - pollExportStatus, -}: InterlinearExportPanelClientProps) { - const [statuses, setStatuses] = useState>({}); - const [pollingIds, setPollingIds] = useState([]); - const [pending, setPending] = useState(false); - const missingCountsRef = useRef>({}); - const isWorking = - pending || - Object.values(statuses).some( - (status) => - status.status !== JobStatus.Complete && - status.status !== JobStatus.Failed, - ); - - const handleSubmit = async ( - _state: FormState, - formData: FormData, - ): Promise => { - try { - setPending(true); - setStatuses({}); - setPollingIds([]); - missingCountsRef.current = {}; - - const result = await requestExport(formData); - if (result.state === "error") { - return result; - } - - const requestIds = result.requestIds ?? []; - if (requestIds.length === 0) { - return { state: "error", error: strings.failedLabel }; - } - - const nextStatuses: Record = {}; - requestIds.forEach(({ id, bookId }) => { - nextStatuses[id] = { - id, - status: JobStatus.Pending, - bookId, - downloadUrl: null, - expiresAt: null, - }; - }); - setStatuses(nextStatuses); - setPollingIds(requestIds.map((request) => request.id)); - - const poll = new FormData(); - poll.set("id", requestIds[0].id); - const statusRow = await pollExportStatus(poll); - if (statusRow) { - setStatuses((prev) => ({ - ...prev, - [statusRow.id]: { ...prev[statusRow.id], ...statusRow }, - })); - } - - return { state: "success" }; - } catch (error) { - console.error(error); - return { state: "error", error: strings.failedLabel }; - } finally { - setPending(false); - } - }; - - useEffect(() => { - if (pollingIds.length === 0) return; - - let cancelled = false; - let timeoutId: ReturnType | null = null; - const MAX_MISSING_POLLS = 5; - - const poll = async () => { - const nextPendingIds: string[] = []; - try { - for (const id of pollingIds) { - const pollForm = new FormData(); - pollForm.set("id", id); - const statusRow = await pollExportStatus(pollForm); - if (statusRow) { - missingCountsRef.current[id] = 0; - setStatuses((prev) => ({ - ...prev, - [statusRow.id]: { - ...prev[statusRow.id], - ...statusRow, - missingCount: 0, - error: undefined, - }, - })); - if ( - statusRow.status !== JobStatus.Complete && - statusRow.status !== JobStatus.Failed - ) { - nextPendingIds.push(id); - } - } else { - const nextCount = (missingCountsRef.current[id] ?? 0) + 1; - missingCountsRef.current[id] = nextCount; - - if (nextCount >= MAX_MISSING_POLLS) { - setStatuses((prev) => { - const current = prev[id]; - if (!current) return prev; - return { - ...prev, - [id]: { - ...current, - missingCount: nextCount, - status: JobStatus.Failed, - error: "missing", - }, - }; - }); - continue; - } - - setStatuses((prev) => { - const current = prev[id]; - if (!current) return prev; - return { - ...prev, - [id]: { - ...current, - missingCount: nextCount, - }, - }; - }); - nextPendingIds.push(id); - } - } - } catch (error) { - console.error("Failed to poll export status", error); - setStatuses((prev) => { - const next = { ...prev }; - pollingIds.forEach((id) => { - if (!next[id]) return; - next[id] = { - ...next[id], - status: JobStatus.Failed, - error: "poll_failed", - }; - }); - return next; - }); - return; - } - - if (!cancelled) { - if (nextPendingIds.length > 0) { - timeoutId = setTimeout(() => setPollingIds(nextPendingIds), 3000); - } else { - setPollingIds([]); - } - } - }; - - poll(); - - return () => { - cancelled = true; - if (timeoutId) { - clearTimeout(timeoutId); - } - }; - }, [pollExportStatus, pollingIds]); - - return ( -
-
-

- - {strings.title} -

-

{strings.description}

-
- -
-
- - -
- -
-
- - {Object.keys(statuses).length > 0 && ( -
-
{strings.statusTitle}
-
- {Object.values(statuses).map((status) => { - const bookName = - status.bookId && status.bookId > 0 ? - `Book ${status.bookId}` - : strings.allBooksLabel; - const isComplete = status.status === JobStatus.Complete; - const isFailed = status.status === JobStatus.Failed; - const statusLabel = - strings.statusLabels[status.status] ?? status.status; - return ( -
-
-
- - {status.bookId ? `Book ${status.bookId}` : "Book"} - - {bookName} -
- - {statusLabel} - -
- {status.downloadUrl && ( -
- - {status.expiresAt && ( - - {strings.expiresLabel}:{" "} - {new Date(status.expiresAt).toLocaleString()} - - )} -
- )} - {isFailed && ( - - {status.error === "missing" ? - strings.missingLabel - : strings.failedLabel} - - )} - {!isComplete && !isFailed && ( - - {strings.generatingLabel} - - )} -
- ); - })} -
-
- )} -
-
- ); -} diff --git a/src/modules/export/react/LanguageExportsPage.tsx b/src/modules/export/react/LanguageExportsPage.tsx index c7e191e9..dd27ae5c 100644 --- a/src/modules/export/react/LanguageExportsPage.tsx +++ b/src/modules/export/react/LanguageExportsPage.tsx @@ -2,7 +2,6 @@ import ViewTitle from "@/components/ViewTitle"; import { getTranslations } from "next-intl/server"; import { Metadata, ResolvingMetadata } from "next"; import InterlinearExportPanel from "./InterlinearExportPanel"; -import FeatureFlagged from "@/shared/feature-flags/FeatureFlagged"; interface Props { params: { code: string }; @@ -27,12 +26,7 @@ export default async function LanguageExportsPage({ params }: Props) {
{t("title")} - - } - /> +
); diff --git a/src/modules/export/route-handlers/getExportProgress.ts b/src/modules/export/route-handlers/getExportProgress.ts new file mode 100644 index 00000000..eb8e540d --- /dev/null +++ b/src/modules/export/route-handlers/getExportProgress.ts @@ -0,0 +1,36 @@ +import { NextRequest } from "next/server"; +import { verifySession } from "@/session"; +import { Policy } from "@/modules/access"; +import exportJobQueryService from "../data-access/ExportJobQueryService"; + +const exportPolicy = new Policy({ + systemRoles: [Policy.SystemRole.Admin], + languageMember: true, +}); + +export default async function handleGetExportProgress( + _request: NextRequest, + { params }: { params: { code: string } }, +) { + const session = await verifySession(); + const userId = session?.user?.id; + if (!userId) { + return new Response(null, { status: 404 }); + } + + const authorized = await exportPolicy.authorize({ + actorId: userId, + languageCode: params.code, + }); + if (!authorized) { + return new Response(null, { status: 404 }); + } + + const pending = await exportJobQueryService.findPendingForLanguage( + params.code, + ); + + return Response.json({ + done: !pending, + }); +} diff --git a/src/modules/export/use-cases/GetInterlinearExportStatus.ts b/src/modules/export/use-cases/GetInterlinearExportStatus.ts index 7a141310..00db5078 100644 --- a/src/modules/export/use-cases/GetInterlinearExportStatus.ts +++ b/src/modules/export/use-cases/GetInterlinearExportStatus.ts @@ -1,27 +1,19 @@ -import type jobRepository from "@/shared/jobs/JobRepository"; import { EXPORT_JOB_TYPES } from "../jobs/jobTypes"; import type { ExportInterlinearPdfJobData, ExportInterlinearPdfJobPayload, } from "../model"; +import jobRepository from "@/shared/jobs/JobRepository"; -export default class GetInterlinearExportStatus { - constructor( - private readonly deps: { - jobRepository: typeof jobRepository; - }, - ) {} +export async function getInterlinearExportStatus(jobId: string) { + const job = await jobRepository.getById< + ExportInterlinearPdfJobPayload, + ExportInterlinearPdfJobData + >(jobId); - async execute(jobId: string) { - const job = await this.deps.jobRepository.getById< - ExportInterlinearPdfJobPayload, - ExportInterlinearPdfJobData - >(jobId); - - if (!job || job.type !== EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF) { - return undefined; - } - - return job; + if (!job || job.type !== EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF) { + return undefined; } + + return job; } diff --git a/src/modules/export/use-cases/RequestInterlinearExport.ts b/src/modules/export/use-cases/RequestInterlinearExport.ts index c8a77611..c33f2f27 100644 --- a/src/modules/export/use-cases/RequestInterlinearExport.ts +++ b/src/modules/export/use-cases/RequestInterlinearExport.ts @@ -1,17 +1,8 @@ -import { ExportBookSelection } from "../model"; -import type bookQueryService from "../data-access/BookQueryService"; -import type languageLookupQueryService from "../data-access/LanguageLookupQueryService"; -import type { enqueueJob } from "@/shared/jobs/enqueueJob"; +import { NotFoundError } from "@/shared/errors"; +import { enqueueJob } from "@/shared/jobs/enqueueJob"; +import { resolveLanguageByCode } from "@/modules/languages"; import { EXPORT_JOB_TYPES } from "../jobs/jobTypes"; -export class NoBooksAvailableForExportError extends Error {} -export class NoChaptersAvailableForExportError extends Error {} -export class ExportLanguageNotFoundError extends Error { - constructor(readonly languageCode: string) { - super(); - } -} - export interface RequestInterlinearExportRequest { languageCode: string; requestedBy: string; @@ -19,74 +10,21 @@ export interface RequestInterlinearExportRequest { export interface RequestInterlinearExportResult { jobId: string; - bookId: number | null; - books: ExportBookSelection[]; } -export default class RequestInterlinearExport { - constructor( - private readonly deps: { - bookQueryService: typeof bookQueryService; - languageLookupQueryService: typeof languageLookupQueryService; - enqueueJob: typeof enqueueJob; - }, - ) {} - - async execute( - request: RequestInterlinearExportRequest, - ): Promise { - const language = await this.deps.languageLookupQueryService.findByCode( - request.languageCode, - ); - if (!language) { - throw new ExportLanguageNotFoundError(request.languageCode); - } - - const allBooks = await this.deps.bookQueryService.findAll(); - const selectedBookIds = allBooks.map((book) => book.id); - if (selectedBookIds.length === 0) { - throw new NoBooksAvailableForExportError(); - } - - const chaptersByBookRows = - await this.deps.bookQueryService.findChapters(selectedBookIds); - const chaptersByBook = new Map( - chaptersByBookRows.map((row) => [row.bookId, row.chapters]), - ); - - const books: ExportBookSelection[] = []; - for (const bookId of selectedBookIds) { - const available = chaptersByBook.get(bookId) ?? []; - if (available.length === 0) { - continue; - } - - const chapters = available; - if (chapters.length === 0) { - continue; - } - books.push({ bookId, chapters }); - } - - if (books.length === 0) { - throw new NoChaptersAvailableForExportError(); - } +export async function requestInterlinearExport( + request: RequestInterlinearExportRequest, +): Promise { + const language = await resolveLanguageByCode(request.languageCode); + if (!language) { + throw new NotFoundError("Language not found."); + } - const job = await this.deps.enqueueJob( - EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF, - { - languageId: language.id, - languageCode: request.languageCode, - requestedBy: request.requestedBy, - books, - layout: "standard", - }, - ); + const job = await enqueueJob(EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF, { + languageId: language.id, + languageCode: request.languageCode, + requestedBy: request.requestedBy, + }); - return { - jobId: job.id, - bookId: books.length === 1 ? books[0].bookId : null, - books, - }; - } + return { jobId: job.id }; } diff --git a/src/modules/languages/index.ts b/src/modules/languages/index.ts new file mode 100644 index 00000000..bbd18b8f --- /dev/null +++ b/src/modules/languages/index.ts @@ -0,0 +1,2 @@ +export { resolveLanguageByCode } from "./use-cases/resolveLanguageByCode"; +export type { ResolvedLanguage } from "./use-cases/resolveLanguageByCode"; diff --git a/src/modules/languages/use-cases/resolveLanguageByCode.ts b/src/modules/languages/use-cases/resolveLanguageByCode.ts new file mode 100644 index 00000000..20ebf59d --- /dev/null +++ b/src/modules/languages/use-cases/resolveLanguageByCode.ts @@ -0,0 +1,28 @@ +import { query } from "@/db"; +import { TextDirectionRaw } from "../model"; + +export interface ResolvedLanguage { + id: string; + code: string; + name: string; + textDirection: TextDirectionRaw; +} + +export async function resolveLanguageByCode( + code: string, +): Promise { + const result = await query( + ` + select id, + code, + name, + text_direction as "textDirection" + from language + where code = $1 + limit 1 + `, + [code], + ); + + return result.rows[0]; +} diff --git a/src/session.ts b/src/session.ts index 9d990c95..7898e85a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -75,13 +75,7 @@ interface Session { }; } -// `cache` is provided by Next's React runtime; fallback to no caching in non-Next contexts (e.g. vitest). -const cache: any>(fn: T) => T = - typeof (React as any).cache === "function" ? - (React as any).cache - : (fn: any) => fn; - -const fetchSession = cache( +const fetchSession = React.cache( async (sessionId: string): Promise => { const result = await query( ` diff --git a/tests/vitest/testSetup.ts b/tests/vitest/testSetup.ts index c9e4e874..5dafa8a9 100644 --- a/tests/vitest/testSetup.ts +++ b/tests/vitest/testSetup.ts @@ -1,4 +1,5 @@ import { webcrypto } from "node:crypto"; +import * as React from "react"; import "./matchers"; // Necessary for @oslo/password to run in tests @@ -10,3 +11,7 @@ if (!globalThis.crypto) { process.env.ORIGIN = "globalbibletools.com"; process.env.LOG_LEVEL = "silent"; + +if (typeof React.cache !== "function") { + (React as any).cache = any>(fn: T) => fn; +} From 5141e2893d2d93369791be82ecb73bfaadfa8daa Mon Sep 17 00:00:00 2001 From: delgado-jacob <29643013+delgado-jacob@users.noreply.github.com> Date: Wed, 28 Jan 2026 08:11:20 -0700 Subject: [PATCH 3/6] test: mock react cache in vitest setup --- tests/vitest/testSetup.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/vitest/testSetup.ts b/tests/vitest/testSetup.ts index 5dafa8a9..701f44c9 100644 --- a/tests/vitest/testSetup.ts +++ b/tests/vitest/testSetup.ts @@ -1,5 +1,5 @@ import { webcrypto } from "node:crypto"; -import * as React from "react"; +import { vi } from "vitest"; import "./matchers"; // Necessary for @oslo/password to run in tests @@ -12,6 +12,13 @@ if (!globalThis.crypto) { process.env.ORIGIN = "globalbibletools.com"; process.env.LOG_LEVEL = "silent"; -if (typeof React.cache !== "function") { - (React as any).cache = any>(fn: T) => fn; -} +vi.mock("react", async () => { + const actual = await vi.importActual("react"); + return { + ...actual, + cache: + typeof actual.cache === "function" ? + actual.cache + : any>(fn: T) => fn, + }; +}); From f81346610ef31f75a8c5810f38adc3da188e6e84 Mon Sep 17 00:00:00 2001 From: delgado-jacob <29643013+delgado-jacob@users.noreply.github.com> Date: Wed, 28 Jan 2026 08:12:03 -0700 Subject: [PATCH 4/6] fix: call export use case from action --- src/modules/export/actions/requestInterlinearExport.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/export/actions/requestInterlinearExport.ts b/src/modules/export/actions/requestInterlinearExport.ts index 4c317538..4caafd85 100644 --- a/src/modules/export/actions/requestInterlinearExport.ts +++ b/src/modules/export/actions/requestInterlinearExport.ts @@ -7,7 +7,7 @@ import { verifySession } from "@/session"; import { Policy } from "@/modules/access"; import { FormState } from "@/components/Form"; import { serverActionLogger } from "@/server-action"; -import { requestInterlinearExport } from "../use-cases/RequestInterlinearExport"; +import { requestInterlinearExport as requestInterlinearExportUseCase } from "../use-cases/RequestInterlinearExport"; const exportPolicy = new Policy({ systemRoles: [Policy.SystemRole.Admin], @@ -65,7 +65,7 @@ export async function requestInterlinearExport( } try { - await requestInterlinearExport({ + await requestInterlinearExportUseCase({ languageCode: parsed.data.languageCode, requestedBy: userId, }); From 36a619efc1530d77a198557d8a700c615cf7e0a1 Mon Sep 17 00:00:00 2001 From: delgado-jacob <29643013+delgado-jacob@users.noreply.github.com> Date: Wed, 28 Jan 2026 08:44:05 -0700 Subject: [PATCH 5/6] fix: resolve languages by local name in export --- .../export/actions/requestInterlinearExport.test.ts | 11 +++++++++-- .../languages/use-cases/resolveLanguageByCode.ts | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/modules/export/actions/requestInterlinearExport.test.ts b/src/modules/export/actions/requestInterlinearExport.test.ts index e9d54524..bd855eaf 100644 --- a/src/modules/export/actions/requestInterlinearExport.test.ts +++ b/src/modules/export/actions/requestInterlinearExport.test.ts @@ -1,14 +1,21 @@ import "@/tests/vitest/mocks/nextjs"; import { initializeDatabase } from "@/tests/vitest/dbUtils"; import { describe, expect, test, vi } from "vitest"; + +const { enqueueJobMock } = vi.hoisted(() => ({ + enqueueJobMock: vi.fn(), +})); + +vi.mock("@/shared/jobs/enqueueJob", () => ({ + enqueueJob: enqueueJobMock, +})); + import { requestInterlinearExport } from "./requestInterlinearExport"; import { createScenario } from "@/tests/scenarios"; import logIn from "@/tests/vitest/login"; import { enqueueJob } from "@/shared/jobs/enqueueJob"; import { SystemRoleRaw } from "@/modules/users/model/SystemRole"; -vi.mock("@/shared/jobs/enqueueJob"); - initializeDatabase(); describe("requestInterlinearExport", () => { diff --git a/src/modules/languages/use-cases/resolveLanguageByCode.ts b/src/modules/languages/use-cases/resolveLanguageByCode.ts index 20ebf59d..fc0518cf 100644 --- a/src/modules/languages/use-cases/resolveLanguageByCode.ts +++ b/src/modules/languages/use-cases/resolveLanguageByCode.ts @@ -15,7 +15,7 @@ export async function resolveLanguageByCode( ` select id, code, - name, + local_name as name, text_direction as "textDirection" from language where code = $1 From 4537ff0f58b355163c6fd60e00b5a73dbd684c81 Mon Sep 17 00:00:00 2001 From: delgado-jacob <29643013+delgado-jacob@users.noreply.github.com> Date: Sat, 31 Jan 2026 09:53:25 -0700 Subject: [PATCH 6/6] refactor: align export use cases with review --- .../actions/requestInterlinearExport.test.ts | 108 +++++++++--------- .../actions/requestInterlinearExport.ts | 2 +- .../use-cases/GetInterlinearExportStatus.ts | 19 --- ...rExport.ts => requestInterlinearExport.ts} | 2 +- 4 files changed, 55 insertions(+), 76 deletions(-) delete mode 100644 src/modules/export/use-cases/GetInterlinearExportStatus.ts rename src/modules/export/use-cases/{RequestInterlinearExport.ts => requestInterlinearExport.ts} (94%) diff --git a/src/modules/export/actions/requestInterlinearExport.test.ts b/src/modules/export/actions/requestInterlinearExport.test.ts index bd855eaf..09d918e9 100644 --- a/src/modules/export/actions/requestInterlinearExport.test.ts +++ b/src/modules/export/actions/requestInterlinearExport.test.ts @@ -1,6 +1,6 @@ import "@/tests/vitest/mocks/nextjs"; import { initializeDatabase } from "@/tests/vitest/dbUtils"; -import { describe, expect, test, vi } from "vitest"; +import { expect, test, vi } from "vitest"; const { enqueueJobMock } = vi.hoisted(() => ({ enqueueJobMock: vi.fn(), @@ -18,70 +18,68 @@ import { SystemRoleRaw } from "@/modules/users/model/SystemRole"; initializeDatabase(); -describe("requestInterlinearExport", () => { - test("rejects unauthenticated requests", async () => { - const formData = new FormData(); - await expect(requestInterlinearExport(formData)).toBeNextjsNotFound(); - expect(enqueueJob).not.toHaveBeenCalled(); - }); +test("rejects unauthenticated requests", async () => { + const formData = new FormData(); + await expect(requestInterlinearExport(formData)).toBeNextjsNotFound(); + expect(enqueueJob).not.toHaveBeenCalled(); +}); - test("returns validation error for unknown language before enqueue", async () => { - const scenario = await createScenario({ - users: { admin: { systemRoles: [SystemRoleRaw.Admin] } }, - }); - await logIn(scenario.users.admin.id); +test("returns validation error for unknown language before enqueue", async () => { + const scenario = await createScenario({ + users: { admin: { systemRoles: [SystemRoleRaw.Admin] } }, + }); + await logIn(scenario.users.admin.id); - const formData = new FormData(); - formData.set("languageCode", "missing"); + const formData = new FormData(); + formData.set("languageCode", "missing"); - const response = await requestInterlinearExport(formData); - expect(response).toEqual({ - state: "error", - error: "Export failed.", - }); - expect(enqueueJob).not.toHaveBeenCalled(); + const response = await requestInterlinearExport(formData); + expect(response).toEqual({ + state: "error", + error: "Export failed.", }); + expect(enqueueJob).not.toHaveBeenCalled(); +}); - test("denies non-members", async () => { - const scenario = await createScenario({ - users: { outsider: {} }, - languages: { language: {} }, - }); - const user = scenario.users.outsider; - const language = scenario.languages.language; - await logIn(user.id); +test("denies non-members", async () => { + const scenario = await createScenario({ + users: { outsider: {} }, + languages: { language: {} }, + }); + const user = scenario.users.outsider; + const language = scenario.languages.language; + await logIn(user.id); - const formData = new FormData(); - formData.set("languageCode", language.code); + const formData = new FormData(); + formData.set("languageCode", language.code); - await expect(requestInterlinearExport(formData)).toBeNextjsNotFound(); - expect(enqueueJob).not.toHaveBeenCalled(); - }); + await expect(requestInterlinearExport(formData)).toBeNextjsNotFound(); + expect(enqueueJob).not.toHaveBeenCalled(); +}); - test("creates export request for language", async () => { - const scenario = await createScenario({ - users: { translator: {} }, - languages: { - language: { - members: ["translator"], - }, +test("creates export request for language", async () => { + const scenario = await createScenario({ + users: { translator: {} }, + languages: { + language: { + members: ["translator"], }, - }); - const user = scenario.users.translator; - const language = scenario.languages.language; - await logIn(user.id); + }, + }); + const user = scenario.users.translator; + const language = scenario.languages.language; + await logIn(user.id); - const formData = new FormData(); - formData.set("languageCode", language.code); + const formData = new FormData(); + formData.set("languageCode", language.code); - (enqueueJob as any).mockResolvedValueOnce({ id: "job-123" }); - const response = await requestInterlinearExport(formData); - expect(response.state).toBe("success"); - expect(enqueueJob).toHaveBeenCalledTimes(1); - expect(enqueueJob).toHaveBeenCalledWith("export_interlinear_pdf", { - languageId: language.id, - languageCode: language.code, - requestedBy: user.id, - }); + (enqueueJob as any).mockResolvedValueOnce({ id: "job-123" }); + const response = await requestInterlinearExport(formData); + expect(response.state).toBe("success"); + expect(enqueueJob).toHaveBeenCalledTimes(1); + expect(enqueueJob).toHaveBeenCalledWith("export_interlinear_pdf", { + languageId: language.id, + languageCode: language.code, + requestedBy: user.id, }); }); diff --git a/src/modules/export/actions/requestInterlinearExport.ts b/src/modules/export/actions/requestInterlinearExport.ts index 4caafd85..e681b034 100644 --- a/src/modules/export/actions/requestInterlinearExport.ts +++ b/src/modules/export/actions/requestInterlinearExport.ts @@ -7,7 +7,7 @@ import { verifySession } from "@/session"; import { Policy } from "@/modules/access"; import { FormState } from "@/components/Form"; import { serverActionLogger } from "@/server-action"; -import { requestInterlinearExport as requestInterlinearExportUseCase } from "../use-cases/RequestInterlinearExport"; +import { requestInterlinearExport as requestInterlinearExportUseCase } from "../use-cases/requestInterlinearExport"; const exportPolicy = new Policy({ systemRoles: [Policy.SystemRole.Admin], diff --git a/src/modules/export/use-cases/GetInterlinearExportStatus.ts b/src/modules/export/use-cases/GetInterlinearExportStatus.ts deleted file mode 100644 index 00db5078..00000000 --- a/src/modules/export/use-cases/GetInterlinearExportStatus.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { EXPORT_JOB_TYPES } from "../jobs/jobTypes"; -import type { - ExportInterlinearPdfJobData, - ExportInterlinearPdfJobPayload, -} from "../model"; -import jobRepository from "@/shared/jobs/JobRepository"; - -export async function getInterlinearExportStatus(jobId: string) { - const job = await jobRepository.getById< - ExportInterlinearPdfJobPayload, - ExportInterlinearPdfJobData - >(jobId); - - if (!job || job.type !== EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF) { - return undefined; - } - - return job; -} diff --git a/src/modules/export/use-cases/RequestInterlinearExport.ts b/src/modules/export/use-cases/requestInterlinearExport.ts similarity index 94% rename from src/modules/export/use-cases/RequestInterlinearExport.ts rename to src/modules/export/use-cases/requestInterlinearExport.ts index c33f2f27..d93010f4 100644 --- a/src/modules/export/use-cases/RequestInterlinearExport.ts +++ b/src/modules/export/use-cases/requestInterlinearExport.ts @@ -17,7 +17,7 @@ export async function requestInterlinearExport( ): Promise { const language = await resolveLanguageByCode(request.languageCode); if (!language) { - throw new NotFoundError("Language not found."); + throw new NotFoundError("Language"); } const job = await enqueueJob(EXPORT_JOB_TYPES.EXPORT_INTERLINEAR_PDF, {