From de7a61888b676b4f6b7b8dc4b833aa65b8a13810 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:21:15 -0600 Subject: [PATCH] ci: consolidate release workflow and automate release notes - Hardened multi-arch container release flow with build-once promotion and safer image gating. - Reworked semantic-release execution, permissions, and action pinning for more reliable releases. - Expanded release notes transformation with contributor and artifact metadata, plus cleaner formatting. - Fixed edge cases in notes rendering by preserving commit metadata and guarding author formatting. - Removed redundant release notes sections to keep generated output concise and consistent. --- .github/workflows/container-image.yml | 288 ++++++++++++++---- .github/workflows/release.yml | 187 ++++++++---- .releaserc.json | 38 --- release.config.js | 159 ++++++++++ server/src/db/migrations/0000_baseline.sql | 13 + .../src/db/migrations/meta/0000_snapshot.json | 100 +++++- server/src/db/migrations/meta/_journal.json | 2 +- server/src/db/schema/reader.ts | 26 ++ .../book/book-query-builder.service.ts | 44 ++- server/src/modules/book/book.repository.ts | 31 +- server/src/modules/book/book.service.test.ts | 6 +- server/src/modules/book/book.service.ts | 12 +- .../file-write/file-write.constants.ts | 1 - .../file-write/file-write.repository.test.ts | 1 - .../file-write/file-write.repository.ts | 1 - 15 files changed, 734 insertions(+), 175 deletions(-) delete mode 100644 .releaserc.json create mode 100644 release.config.js diff --git a/.github/workflows/container-image.yml b/.github/workflows/container-image.yml index c43cf4e32..006e0063c 100644 --- a/.github/workflows/container-image.yml +++ b/.github/workflows/container-image.yml @@ -7,6 +7,11 @@ on: branches: [main] # Break-glass: bypasses CI/E2E gate. Use only when CI is broken and a hotfix image is urgently needed. workflow_dispatch: + inputs: + force_rebuild: + description: Rebuild even if image for this SHA already exists in GHCR + type: boolean + default: false workflow_run: workflows: ["CI"] types: [completed] @@ -25,63 +30,17 @@ env: IMAGE_NAME: ${{ github.repository }} jobs: - image: - name: ${{ github.event_name == 'pull_request' && 'Build and Scan Image' || 'Build and Push Image' }} - if: > - github.event_name == 'pull_request' || - github.event_name == 'workflow_dispatch' || - (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push') + # PR path: amd64-only build and scan for fast feedback. No push. + scan: + name: Build and Scan Image + if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 45 permissions: contents: read - packages: write steps: - - name: Resolve source and image tags - id: vars - shell: bash - env: - EVENT_NAME: ${{ github.event_name }} - GH_REF: ${{ github.ref }} - GH_REF_NAME: ${{ github.ref_name }} - GH_SHA: ${{ github.sha }} - WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - if [[ "$EVENT_NAME" == "workflow_dispatch" && "$GH_REF" != "refs/heads/main" ]]; then - echo "workflow_dispatch is only supported from main." - exit 1 - fi - - if [[ "$EVENT_NAME" == "workflow_run" ]]; then - SHA="$WORKFLOW_RUN_HEAD_SHA" - else - SHA="$GH_SHA" - fi - - SHORT_SHA="${SHA:0:12}" - IMAGE="$REGISTRY/$IMAGE_NAME" - - echo "checkout_ref=${SHA}" >> "$GITHUB_OUTPUT" - echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" - echo "scan_tag=${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" - - if [[ "$EVENT_NAME" == "pull_request" ]]; then - echo "should_push=false" >> "$GITHUB_OUTPUT" - echo "tags<> "$GITHUB_OUTPUT" - echo "${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - else - echo "should_push=true" >> "$GITHUB_OUTPUT" - echo "tags<> "$GITHUB_OUTPUT" - echo "${IMAGE}:${GH_REF_NAME}" >> "$GITHUB_OUTPUT" - echo "${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - fi - - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ steps.vars.outputs.checkout_ref }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 @@ -93,36 +52,239 @@ jobs: file: ./Dockerfile push: false load: true - tags: ${{ steps.vars.outputs.tags }} + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-scan platforms: linux/amd64 - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=container-image-yml-pr + cache-to: type=gha,mode=max,scope=container-image-yml-pr - name: Scan image with Trivy uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: - image-ref: ${{ steps.vars.outputs.scan_tag }} + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-scan format: table vuln-type: os,library severity: CRITICAL,HIGH ignore-unfixed: true exit-code: "1" + # Main push path: resolve context once, then build multi-arch natively in parallel. + prepare: + name: Resolve Build Context + if: > + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push') + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + packages: read + outputs: + source_sha: ${{ steps.ctx.outputs.source_sha }} + update_main: ${{ steps.ctx.outputs.update_main }} + skip_build: ${{ steps.ctx.outputs.skip_build }} + steps: + - name: Resolve build context + id: ctx + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + GH_REF: ${{ github.ref }} + WORKFLOW_RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + GH_SHA: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + IMAGE_NAME: ${{ github.repository }} + FORCE_REBUILD: ${{ inputs.force_rebuild }} + run: | + if [[ "$EVENT_NAME" == "workflow_dispatch" && "$GH_REF" != "refs/heads/main" ]]; then + echo "::error::workflow_dispatch is only supported from main." + exit 1 + fi + if [[ "$EVENT_NAME" == "workflow_run" ]]; then + SOURCE_SHA="$WORKFLOW_RUN_HEAD_SHA" + echo "update_main=true" >> "$GITHUB_OUTPUT" + else + SOURCE_SHA="$GH_SHA" + echo "update_main=false" >> "$GITHUB_OUTPUT" + fi + echo "source_sha=${SOURCE_SHA}" >> "$GITHUB_OUTPUT" + IMAGE_LOWER=$(echo "${IMAGE_NAME}" | tr '[:upper:]' '[:lower:]') + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer ${GH_TOKEN}" \ + -H "Accept: application/vnd.oci.image.index.v1+json" \ + "https://ghcr.io/v2/${IMAGE_LOWER}/manifests/sha-${SOURCE_SHA}") + if [[ "$HTTP_STATUS" == "200" && "${FORCE_REBUILD}" != "true" ]]; then + echo "Image sha-${SOURCE_SHA} already exists in GHCR, skipping rebuild." + echo "skip_build=true" >> "$GITHUB_OUTPUT" + else + echo "skip_build=false" >> "$GITHUB_OUTPUT" + fi + + build-amd64: + name: Build and Push amd64 + needs: [prepare] + if: needs.prepare.outputs.skip_build != 'true' + runs-on: ubuntu-latest + timeout-minutes: 45 + permissions: + contents: read + packages: write + outputs: + digest: ${{ steps.build.outputs.digest }} + env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ needs.prepare.outputs.source_sha }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Log in to GHCR - if: steps.vars.outputs.should_push == 'true' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Push image - if: steps.vars.outputs.should_push == 'true' + - name: Build image + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + file: ./Dockerfile + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + platforms: linux/amd64 + cache-from: type=gha,scope=container-image-yml-amd64 + cache-to: type=gha,mode=max,scope=container-image-yml-amd64 + + - name: Scan image with Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + env: + TRIVY_PLATFORM: linux/amd64 + with: + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} + format: table + vuln-type: os,library + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: "1" + + build-arm64: + name: Build and Push arm64 + needs: [prepare] + if: needs.prepare.outputs.skip_build != 'true' + runs-on: ubuntu-24.04-arm + timeout-minutes: 45 + permissions: + contents: read + packages: write + outputs: + digest: ${{ steps.build.outputs.digest }} + env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ needs.prepare.outputs.source_sha }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build image + id: build + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + file: ./Dockerfile + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + platforms: linux/arm64 + cache-from: type=gha,scope=container-image-yml-arm64 + cache-to: type=gha,mode=max,scope=container-image-yml-arm64 + + - name: Scan image with Trivy + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + env: + TRIVY_PLATFORM: linux/arm64 + with: + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} + format: table + vuln-type: os,library + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: "1" + + merge-manifest: + name: Merge and Push Multi-Arch Manifest + needs: [prepare, build-amd64, build-arm64] + if: needs.prepare.outputs.skip_build != 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + packages: write + env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create SHA manifest shell: bash + env: + REGISTRY: ${{ env.REGISTRY }} + IMAGE: ${{ env.IMAGE_NAME }} + SHA: ${{ needs.prepare.outputs.source_sha }} + AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }} + ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }} run: | - while IFS= read -r tag; do - [ -n "$tag" ] || continue - docker push "$tag" - done <<'EOF' - ${{ steps.vars.outputs.tags }} - EOF + FULL_IMAGE="${REGISTRY}/${IMAGE}" + docker buildx imagetools create \ + --tag "${FULL_IMAGE}:sha-${SHA}" \ + "${FULL_IMAGE}@${AMD64_DIGEST}" \ + "${FULL_IMAGE}@${ARM64_DIGEST}" + + - name: Update main tag + if: needs.prepare.outputs.update_main == 'true' + shell: bash + env: + REGISTRY: ${{ env.REGISTRY }} + IMAGE: ${{ env.IMAGE_NAME }} + SHA: ${{ needs.prepare.outputs.source_sha }} + AMD64_DIGEST: ${{ needs.build-amd64.outputs.digest }} + ARM64_DIGEST: ${{ needs.build-arm64.outputs.digest }} + run: | + FULL_IMAGE="${REGISTRY}/${IMAGE}" + docker buildx imagetools create \ + --tag "${FULL_IMAGE}:main" \ + "${FULL_IMAGE}@${AMD64_DIGEST}" \ + "${FULL_IMAGE}@${ARM64_DIGEST}" + + - name: Inspect SHA manifest + shell: bash + env: + REGISTRY: ${{ env.REGISTRY }} + IMAGE: ${{ env.IMAGE_NAME }} + SHA: ${{ needs.prepare.outputs.source_sha }} + run: docker buildx imagetools inspect "${REGISTRY}/${IMAGE}:sha-${SHA}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 861a4d603..0d304bd55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,9 +81,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 permissions: + actions: read contents: write issues: write pull-requests: write + packages: read outputs: published: ${{ steps.semantic.outputs.new_release_published }} version: ${{ steps.semantic.outputs.new_release_version }} @@ -123,6 +125,30 @@ jobs: fi echo "CI verification passed: ${success_count} successful run(s) found." + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Log in to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify SHA image exists in GHCR + shell: bash + env: + COMMIT_SHA: ${{ github.sha }} + IMAGE_NAME: ${{ github.repository }} + run: | + set -euo pipefail + SOURCE_TAG="ghcr.io/${IMAGE_NAME}:sha-${COMMIT_SHA}" + if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then + echo "::error::Multi-arch image sha-${COMMIT_SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing." + exit 1 + fi + echo "Image sha-${COMMIT_SHA} verified in GHCR." + - name: Set up Node.js and pnpm uses: ./.github/actions/setup-node-pnpm with: @@ -131,10 +157,23 @@ jobs: - name: Run semantic-release id: semantic - uses: cycjimmy/semantic-release-action@16ca923e6ccbb50770c415a0ccd43709a8c5f7a4 # v4.2.2 + shell: bash env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + BEFORE_TAG=$(git tag --sort=-v:refname | head -1 || true) + pnpm exec semantic-release + git fetch --tags --quiet + AFTER_TAG=$(git tag --sort=-v:refname | head -1 || true) + if [[ -n "$AFTER_TAG" && "$AFTER_TAG" != "$BEFORE_TAG" ]]; then + VERSION="${AFTER_TAG#v}" + echo "new_release_published=true" >> "$GITHUB_OUTPUT" + echo "new_release_version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "new_release_git_tag=${AFTER_TAG}" >> "$GITHUB_OUTPUT" + else + echo "new_release_published=false" >> "$GITHUB_OUTPUT" + fi - name: Release summary if: steps.semantic.outputs.new_release_published == 'true' @@ -148,64 +187,22 @@ jobs: echo "- **Version:** ${VERSION}" >> "$GITHUB_STEP_SUMMARY" echo "- **Tag:** ${TAG}" >> "$GITHUB_STEP_SUMMARY" - docker-publish: - name: Publish Docker Image + docker-promote: + name: Promote Docker Image needs: [release] if: needs.release.outputs.published == 'true' runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 10 permissions: - contents: read + contents: write packages: write env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - ref: ${{ needs.release.outputs.tag }} - - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - - name: Resolve image tags - id: tags - shell: bash - env: - VERSION: ${{ needs.release.outputs.version }} - REGISTRY: ${{ env.REGISTRY }} - IMAGE: ${{ env.IMAGE_NAME }} - run: | - FULL_IMAGE="${REGISTRY}/${IMAGE}" - echo "scan_tag=${FULL_IMAGE}:${VERSION}" >> "$GITHUB_OUTPUT" - echo "tags<> "$GITHUB_OUTPUT" - echo "${FULL_IMAGE}:${VERSION}" >> "$GITHUB_OUTPUT" - echo "${FULL_IMAGE}:latest" >> "$GITHUB_OUTPUT" - echo "EOF" >> "$GITHUB_OUTPUT" - - - name: Build image - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 - with: - context: . - file: ./Dockerfile - push: false - load: true - tags: ${{ steps.tags.outputs.tags }} - platforms: linux/amd64 - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Scan image with Trivy - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - with: - image-ref: ${{ steps.tags.outputs.scan_tag }} - format: table - vuln-type: os,library - severity: CRITICAL,HIGH - ignore-unfixed: true - exit-code: "1" - - name: Log in to GHCR uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: @@ -213,21 +210,97 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Push image + - name: Verify SHA image and resolve digest + id: source shell: bash + env: + REGISTRY: ${{ env.REGISTRY }} + IMAGE: ${{ env.IMAGE_NAME }} + SHA: ${{ github.sha }} run: | - while IFS= read -r tag; do - [ -n "$tag" ] || continue - docker push "$tag" - done <<'EOF' - ${{ steps.tags.outputs.tags }} - EOF + FULL_IMAGE="${REGISTRY}/${IMAGE}" + SOURCE_TAG="${FULL_IMAGE}:sha-${SHA}" + if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then + echo "::error::Multi-arch image sha-${SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing." + exit 1 + fi + DIGEST=$(docker buildx imagetools inspect --format '{{json .Manifest}}' "${SOURCE_TAG}" | jq -r '.digest') + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + echo "image=${FULL_IMAGE}" >> "$GITHUB_OUTPUT" + + - name: Promote to version and latest + shell: bash + env: + VERSION: ${{ needs.release.outputs.version }} + DIGEST: ${{ steps.source.outputs.digest }} + IMAGE: ${{ steps.source.outputs.image }} + run: | + docker buildx imagetools create \ + --tag "${IMAGE}:${VERSION}" \ + "${IMAGE}@${DIGEST}" + docker buildx imagetools create \ + --tag "${IMAGE}:latest" \ + "${IMAGE}@${DIGEST}" + + - name: Inspect promoted manifests + shell: bash + env: + VERSION: ${{ needs.release.outputs.version }} + IMAGE: ${{ steps.source.outputs.image }} + run: | + docker buildx imagetools inspect "${IMAGE}:${VERSION}" + docker buildx imagetools inspect "${IMAGE}:latest" + + - name: Enrich release notes + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.release.outputs.tag }} + VERSION: ${{ needs.release.outputs.version }} + SHA: ${{ github.sha }} + IMAGE: ${{ steps.source.outputs.image }} + DIGEST: ${{ steps.source.outputs.digest }} + run: | + set -euo pipefail + BODY_FILE="$(mktemp)" + gh release view "${TAG}" --repo "${GH_REPO}" --json body --jq '.body' > "${BODY_FILE}" + + if grep -q "" "${BODY_FILE}"; then + echo "Release metadata already present; skipping." + exit 0 + fi + + PREV_TAG=$(gh release list \ + --repo "${GH_REPO}" \ + --exclude-drafts \ + --exclude-pre-releases \ + --limit 100 \ + --json tagName \ + --jq '.[].tagName' \ + | awk -v current="${TAG}" '$0 != current {print; exit}') + + { + echo "" + echo "" + echo "" + if [[ -n "${PREV_TAG}" ]]; then + echo "[Full diff](https://github.com/${GH_REPO}/compare/${PREV_TAG}...${TAG})" + echo "" + fi + echo "### Release Artifact" + echo "- Image tags: \`${IMAGE}:${VERSION}\`, \`${IMAGE}:latest\`" + } >> "${BODY_FILE}" + + gh release edit "${TAG}" --repo "${GH_REPO}" --notes-file "${BODY_FILE}" - name: Docker publish summary shell: bash env: VERSION: ${{ needs.release.outputs.version }} + SHA: ${{ github.sha }} run: | - echo "## Docker Image Published" >> "$GITHUB_STEP_SUMMARY" + echo "## Docker Image Promoted" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- **Tags:** \`${VERSION}\`, \`latest\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Source:** \`sha-${SHA}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- **Tags:** \`${VERSION}\`, \`latest\` (linux/amd64, linux/arm64)" >> "$GITHUB_STEP_SUMMARY" diff --git a/.releaserc.json b/.releaserc.json deleted file mode 100644 index 83d2aed42..000000000 --- a/.releaserc.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "branches": ["main"], - "tagFormat": "v${version}", - "plugins": [ - [ - "@semantic-release/commit-analyzer", - { - "preset": "conventionalcommits", - "releaseRules": [ - { "type": "feat", "release": "minor" }, - { "type": "fix", "release": "patch" }, - { "type": "perf", "release": "patch" }, - { "type": "security", "release": "patch" }, - { "type": "db", "release": "patch" }, - { "type": "style", "release": "patch" }, - { "type": "revert", "release": false } - ] - } - ], - [ - "@semantic-release/release-notes-generator", - { - "preset": "conventionalcommits", - "presetConfig": { - "types": [ - { "type": "feat", "section": "Features" }, - { "type": "fix", "section": "Bug Fixes" }, - { "type": "security", "section": "Security" }, - { "type": "perf", "section": "Performance" }, - { "type": "db", "section": "Database" }, - { "type": "style", "section": "Visual Changes" } - ] - } - } - ], - "@semantic-release/github" - ] -} diff --git a/release.config.js b/release.config.js new file mode 100644 index 000000000..ec183fe4a --- /dev/null +++ b/release.config.js @@ -0,0 +1,159 @@ +"use strict"; + +const { spawnSync } = require("child_process"); + +const DOCKER_IMAGE = "ghcr.io/neonsolstice/bookorbit"; + +// Maps local git author names to GitHub usernames for @-mentions in release notes. +const AUTHOR_MAP = { + neon: "neonsolstice", +}; + +const TYPES = [ + { type: "feat", section: "Features" }, + { type: "fix", section: "Bug Fixes" }, + { type: "security", section: "Security" }, + { type: "perf", section: "Performance" }, + { type: "db", section: "Database" }, + { type: "style", section: "Visual Changes" }, +]; + +// Commit partial: standard conventional-changelog layout with an optional +// author @-mention inserted before the hash link. +// Based on conventional-changelog-conventionalcommits@8 commit.hbs. +const commitPartial = `\ +*{{#if scope}} **{{scope}}:** +{{~/if}} {{#if subject}} + {{~subject}} +{{~else}} + {{~header}} +{{~/if}} +{{~#if githubUser}} by @{{githubUser}}{{/if}} +{{~!-- commit link --}}{{~#if hash}} {{#if @root.linkReferences~}} + ([{{shortHash}}]({{commitUrlFormat}})) +{{~else}} + {{~shortHash}} +{{~/if}}{{~/if}} + +{{~!-- commit references --}} +{{~#if references~}} + , closes + {{~#each references}} {{#if @root.linkReferences~}} + [ + {{~#if this.owner}} + {{~this.owner}}/ + {{~/if}} + {{~this.repository}}{{this.prefix}}{{this.issue}}]({{issueUrlFormat}}) + {{~else}} + {{~#if this.owner}} + {{~this.owner}}/ + {{~/if}} + {{~this.repository}}{{this.prefix}}{{this.issue}} + {{~/if}}{{/each}} +{{~/if}}`; + +// Main template: removes the version header (GitHub Release already shows it), +// strips the warning emoji from breaking-changes headings, and appends a +// Docker pull block after the commit groups. +const mainTemplate = [ + "{{#if noteGroups}}", + "{{#each noteGroups}}", + "", + "### {{title}}", + "", + "{{#each notes}}", + "* {{#if commit.scope}}**{{commit.scope}}:** {{/if}}{{text}}", + "{{/each}}", + "{{/each}}", + "{{/if}}", + "{{#each commitGroups}}", + "", + "{{#if title}}", + "### {{title}}", + "", + "{{/if}}", + "{{#each commits}}", + "{{> commit root=@root}}", + "{{/each}}", + "", + "{{/each}}", + "---", + "", + "**Docker**", + "", + "```bash", + `docker pull ${DOCKER_IMAGE}:{{version}}`, + "```", + "", + "Multi-arch: `linux/amd64` and `linux/arm64`.", +].join("\n"); + +// Runs after the preset transform. Looks up each commit's author via git log, +// maps git name -> GitHub username, and injects githubUser into each commit +// for the commitPartial. +function finalizeContext(ctx) { + try { + const result = spawnSync("git", ["log", "--format=%h %aN", "--max-count=500"], { + encoding: "utf8", + cwd: process.cwd(), + }); + + const shaToGithubUser = new Map(); + if (result.status === 0) { + for (const line of result.stdout.split("\n").filter(Boolean)) { + const spaceIdx = line.indexOf(" "); + const shortSha = line.substring(0, spaceIdx); + const authorName = line.substring(spaceIdx + 1).trim(); + if (!authorName || /\[bot\]/i.test(authorName)) continue; + shaToGithubUser.set(shortSha, AUTHOR_MAP[authorName] ?? authorName); + } + } + + for (const group of ctx.commitGroups ?? []) { + for (const commit of group.commits ?? []) { + if (commit.shortHash) { + const githubUser = shaToGithubUser.get(commit.shortHash); + if (githubUser) commit.githubUser = githubUser; + } + } + } + } catch { + // non-fatal: commits render without author attribution + } + return ctx; +} + +module.exports = { + branches: ["main"], + tagFormat: "v${version}", + plugins: [ + [ + "@semantic-release/commit-analyzer", + { + preset: "conventionalcommits", + releaseRules: [ + { type: "feat", release: "minor" }, + { type: "fix", release: "patch" }, + { type: "perf", release: "patch" }, + { type: "security", release: "patch" }, + { type: "db", release: "patch" }, + { type: "style", release: "patch" }, + { type: "revert", release: false }, + ], + }, + ], + [ + "@semantic-release/release-notes-generator", + { + preset: "conventionalcommits", + presetConfig: { types: TYPES }, + writerOpts: { + commitPartial, + mainTemplate, + finalizeContext, + }, + }, + ], + "@semantic-release/github", + ], +}; diff --git a/server/src/db/migrations/0000_baseline.sql b/server/src/db/migrations/0000_baseline.sql index a6b3b3dc0..685ce14c7 100644 --- a/server/src/db/migrations/0000_baseline.sql +++ b/server/src/db/migrations/0000_baseline.sql @@ -555,6 +555,15 @@ CREATE TABLE "reading_sessions" ( CONSTRAINT "reading_sessions_ended_after_started_chk" CHECK ("reading_sessions"."ended_at" >= "reading_sessions"."started_at") ); --> statement-breakpoint +CREATE TABLE "user_book_ratings" ( + "user_id" integer NOT NULL, + "book_id" integer NOT NULL, + "rating" integer NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "user_book_ratings_user_id_book_id_pk" PRIMARY KEY("user_id","book_id"), + CONSTRAINT "user_book_ratings_rating_range_chk" CHECK ("user_book_ratings"."rating" >= 1 and "user_book_ratings"."rating" <= 5) +); +--> statement-breakpoint CREATE TABLE "user_book_status" ( "user_id" integer NOT NULL, "book_id" integer NOT NULL, @@ -968,6 +977,8 @@ ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_book_file_id_boo ALTER TABLE "reading_progress" ADD CONSTRAINT "reading_progress_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "reading_sessions" ADD CONSTRAINT "reading_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "reading_sessions" ADD CONSTRAINT "reading_sessions_book_file_id_book_files_id_fk" FOREIGN KEY ("book_file_id") REFERENCES "public"."book_files"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_book_ratings" ADD CONSTRAINT "user_book_ratings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_book_ratings" ADD CONSTRAINT "user_book_ratings_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_book_status" ADD CONSTRAINT "user_book_status_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_book_status" ADD CONSTRAINT "user_book_status_book_id_books_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."books"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_reading_daily_stats" ADD CONSTRAINT "user_reading_daily_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -1103,6 +1114,8 @@ CREATE UNIQUE INDEX "rs_user_session_id_uidx" ON "reading_sessions" USING btree CREATE INDEX "rs_user_started_at_idx" ON "reading_sessions" USING btree ("user_id","started_at");--> statement-breakpoint CREATE INDEX "rs_book_file_started_at_idx" ON "reading_sessions" USING btree ("book_file_id","started_at");--> statement-breakpoint CREATE INDEX "rs_user_book_file_idx" ON "reading_sessions" USING btree ("user_id","book_file_id");--> statement-breakpoint +CREATE INDEX "ubr_user_id_idx" ON "user_book_ratings" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "ubr_book_id_idx" ON "user_book_ratings" USING btree ("book_id");--> statement-breakpoint CREATE INDEX "ubs_user_id_idx" ON "user_book_status" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "ubs_user_status_idx" ON "user_book_status" USING btree ("user_id","status");--> statement-breakpoint CREATE INDEX "urds_user_day_idx" ON "user_reading_daily_stats" USING btree ("user_id","day");--> statement-breakpoint diff --git a/server/src/db/migrations/meta/0000_snapshot.json b/server/src/db/migrations/meta/0000_snapshot.json index c2f9b4ba1..1c46b63ad 100644 --- a/server/src/db/migrations/meta/0000_snapshot.json +++ b/server/src/db/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "2de4dd88-2590-4da4-b868-f9341ffe3e9f", + "id": "e9ca331e-b35a-4d80-a54b-28759d1e1c61", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -5286,6 +5286,104 @@ }, "isRLSEnabled": false }, + "public.user_book_ratings": { + "name": "user_book_ratings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ubr_user_id_idx": { + "name": "ubr_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ubr_book_id_idx": { + "name": "ubr_book_id_idx", + "columns": [ + { + "expression": "book_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_book_ratings_user_id_users_id_fk": { + "name": "user_book_ratings_user_id_users_id_fk", + "tableFrom": "user_book_ratings", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_book_ratings_book_id_books_id_fk": { + "name": "user_book_ratings_book_id_books_id_fk", + "tableFrom": "user_book_ratings", + "tableTo": "books", + "columnsFrom": ["book_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_book_ratings_user_id_book_id_pk": { + "name": "user_book_ratings_user_id_book_id_pk", + "columns": ["user_id", "book_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_book_ratings_rating_range_chk": { + "name": "user_book_ratings_rating_range_chk", + "value": "\"user_book_ratings\".\"rating\" >= 1 and \"user_book_ratings\".\"rating\" <= 5" + } + }, + "isRLSEnabled": false + }, "public.user_book_status": { "name": "user_book_status", "schema": "", diff --git a/server/src/db/migrations/meta/_journal.json b/server/src/db/migrations/meta/_journal.json index ca59a7d0e..ad2eec86e 100644 --- a/server/src/db/migrations/meta/_journal.json +++ b/server/src/db/migrations/meta/_journal.json @@ -5,7 +5,7 @@ { "idx": 0, "version": "7", - "when": 1777215796833, + "when": 1777315276249, "tag": "0000_baseline", "breakpoints": true } diff --git a/server/src/db/schema/reader.ts b/server/src/db/schema/reader.ts index f56098af6..d5f961794 100644 --- a/server/src/db/schema/reader.ts +++ b/server/src/db/schema/reader.ts @@ -42,6 +42,32 @@ export const userBookStatus = pgTable( export type UserBookStatusRow = typeof userBookStatus.$inferSelect; export type NewUserBookStatus = typeof userBookStatus.$inferInsert; +export const userBookRatings = pgTable( + 'user_book_ratings', + { + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + bookId: integer('book_id') + .notNull() + .references(() => books.id, { onDelete: 'cascade' }), + rating: integer('rating').notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }) + .notNull() + .defaultNow() + .$onUpdateFn(() => new Date()), + }, + (t) => [ + primaryKey({ columns: [t.userId, t.bookId] }), + index('ubr_user_id_idx').on(t.userId), + index('ubr_book_id_idx').on(t.bookId), + check('user_book_ratings_rating_range_chk', sql`${t.rating} >= 1 and ${t.rating} <= 5`), + ], +); + +export type UserBookRatingRow = typeof userBookRatings.$inferSelect; +export type NewUserBookRating = typeof userBookRatings.$inferInsert; + export const readingProgress = pgTable( 'reading_progress', { diff --git a/server/src/modules/book/book-query-builder.service.ts b/server/src/modules/book/book-query-builder.service.ts index 9f9351aad..61cce38ad 100644 --- a/server/src/modules/book/book-query-builder.service.ts +++ b/server/src/modules/book/book-query-builder.service.ts @@ -17,6 +17,7 @@ import { collectionBooks, collections, readingProgress, + userBookRatings, genres, libraries, narrators, @@ -33,7 +34,6 @@ const SORT_FIELD_MAP: Partial> = { updatedAt: books.updatedAt, publishedYear: bookMetadata.publishedYear, pageCount: bookMetadata.pageCount, - rating: bookMetadata.rating, publisher: bookMetadata.publisher, }; @@ -119,6 +119,11 @@ export class BookQueryBuilder { result.push( sql`(SELECT ubs.finished_at FROM user_book_status ubs WHERE ubs.book_id = books.id AND ubs.user_id = ${userId}) ${sql.raw(D)} NULLS LAST`, ); + } else if (field === 'rating') { + if (userId === undefined) throw new BadRequestException('rating sort requires an authenticated user'); + result.push( + sql`(SELECT ubr.rating FROM user_book_ratings ubr WHERE ubr.book_id = books.id AND ubr.user_id = ${userId}) ${sql.raw(D)} NULLS LAST`, + ); } else if (field === 'random') { const daySeed = Math.floor(Date.now() / 86_400_000); const scopedSeed = daySeed + (userId ?? 0); @@ -172,7 +177,8 @@ export class BookQueryBuilder { case 'pageCount': return this.numericRuleToSql(bookMetadata.pageCount, operator, value as number, valueTo as number | undefined); case 'rating': - return this.numericRuleToSql(bookMetadata.rating, operator, value as number, valueTo as number | undefined); + if (userId === undefined) throw new BadRequestException('rating filter requires an authenticated user'); + return this.ratingRuleToSql(operator, value as number | undefined, valueTo as number | undefined, userId); case 'author': return this.authorRuleToSql(operator, value as string[]); case 'genre': @@ -277,6 +283,40 @@ export class BookQueryBuilder { } } + private ratingRuleToSql(operator: string, value: number | undefined, valueTo: number | undefined, userId: number): SQL { + const ratingExpr = sql`(SELECT ${userBookRatings.rating} FROM ${userBookRatings} WHERE ${userBookRatings.bookId} = ${books.id} AND ${userBookRatings.userId} = ${userId})`; + switch (operator) { + case 'eq': + this.assertNumber(value, operator, 'value'); + return sql`${ratingExpr} = ${value!}`; + case 'notEq': + this.assertNumber(value, operator, 'value'); + return sql`${ratingExpr} <> ${value!}`; + case 'gt': + this.assertNumber(value, operator, 'value'); + return sql`${ratingExpr} > ${value!}`; + case 'gte': + this.assertNumber(value, operator, 'value'); + return sql`${ratingExpr} >= ${value!}`; + case 'lt': + this.assertNumber(value, operator, 'value'); + return sql`${ratingExpr} < ${value!}`; + case 'lte': + this.assertNumber(value, operator, 'value'); + return sql`${ratingExpr} <= ${value!}`; + case 'between': + this.assertNumber(value, operator, 'value'); + this.assertNumber(valueTo, operator, 'valueTo'); + return sql`${ratingExpr} >= ${value!} and ${ratingExpr} <= ${valueTo!}`; + case 'isEmpty': + return sql`${ratingExpr} is null`; + case 'isNotEmpty': + return sql`${ratingExpr} is not null`; + default: + throw new BadRequestException(`Invalid operator '${operator}' for numeric field`); + } + } + private authorRuleToSql(operator: string, values?: string[]): SQL { const existsAuthor = (whereClause?: SQL) => { const predicates: SQL[] = [eq(bookAuthors.bookId, books.id)]; diff --git a/server/src/modules/book/book.repository.ts b/server/src/modules/book/book.repository.ts index c855d7bd6..a41ec7679 100644 --- a/server/src/modules/book/book.repository.ts +++ b/server/src/modules/book/book.repository.ts @@ -26,6 +26,7 @@ import { narrators, audiobookProgress, readingProgress, + userBookRatings, tags, userBookStatus, } from '../../db/schema'; @@ -93,12 +94,13 @@ export class BookRepository { seriesIndex: bookMetadata.seriesIndex, publishedYear: bookMetadata.publishedYear, language: bookMetadata.language, - rating: bookMetadata.rating, + rating: userBookRatings.rating, coverSource: bookMetadata.coverSource, lockedFields: bookMetadata.lockedFields, }) .from(books) .leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id)) + .leftJoin(userBookRatings, and(eq(userBookRatings.bookId, books.id), eq(userBookRatings.userId, userId))) .where(where) .orderBy(...orderBy) .limit(limit) @@ -268,7 +270,7 @@ export class BookRepository { book_metadata.series_index, book_metadata.published_year, book_metadata.language, - book_metadata.rating, + ubr.rating, book_metadata.cover_source, book_metadata.locked_fields, book_metadata.publisher, @@ -280,6 +282,7 @@ export class BookRepository { sc.cover_book_ids FROM books LEFT JOIN book_metadata ON book_metadata.book_id = books.id + LEFT JOIN user_book_ratings ubr ON ubr.book_id = books.id AND ubr.user_id = ${userId} LEFT JOIN series_agg sa ON sa.norm_series = NULLIF(lower(btrim(book_metadata.series_name)), '') AND sa.library_id = books.library_id @@ -420,6 +423,15 @@ export class BookRepository { return { book, authorRows, genreRows, tagRows, fileRows, narratorRows }; } + async findRatingByBookAndUser(bookId: number, userId: number): Promise { + const [row] = await this.db + .select({ rating: userBookRatings.rating }) + .from(userBookRatings) + .where(and(eq(userBookRatings.bookId, bookId), eq(userBookRatings.userId, userId))) + .limit(1); + return row?.rating ?? null; + } + async findCollectionsByBookId(bookId: number, userId: number): Promise<{ id: number; name: string }[]> { return this.db .select({ id: collections.id, name: collections.name }) @@ -704,9 +716,20 @@ export class BookRepository { await this.db.delete(books).where(inArray(books.id, bookIds)); } - async bulkSetRating(bookIds: number[], rating: number | null): Promise { + async bulkSetRating(bookIds: number[], rating: number | null, userId: number): Promise { if (bookIds.length === 0) return; - await this.db.update(bookMetadata).set({ rating, updatedAt: new Date() }).where(inArray(bookMetadata.bookId, bookIds)); + if (rating === null) { + await this.db.delete(userBookRatings).where(and(eq(userBookRatings.userId, userId), inArray(userBookRatings.bookId, bookIds))); + return; + } + + await this.db + .insert(userBookRatings) + .values(bookIds.map((bookId) => ({ userId, bookId, rating }))) + .onConflictDoUpdate({ + target: [userBookRatings.userId, userBookRatings.bookId], + set: { rating, updatedAt: new Date() }, + }); } async findTagsByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise> { diff --git a/server/src/modules/book/book.service.test.ts b/server/src/modules/book/book.service.test.ts index 529cc47b0..f486f4f6c 100644 --- a/server/src/modules/book/book.service.test.ts +++ b/server/src/modules/book/book.service.test.ts @@ -85,6 +85,7 @@ function makeService() { findTagsByBookIds: vi.fn(), findPrimaryFile: vi.fn(), findById: vi.fn(), + findRatingByBookAndUser: vi.fn().mockResolvedValue(null), findCollectionsByBookId: vi.fn(), findKoboReadingState: vi.fn(), findKoboSnapshotState: vi.fn(), @@ -847,11 +848,11 @@ describe('BookService', () => { 5, expect.objectContaining({ title: null, - rating: 4, updatedAt: expect.any(Date), }), expect.anything(), ); + expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([5], 4, user.id); expect(metadataService.replaceAuthors).toHaveBeenCalledWith( 5, [ @@ -1670,7 +1671,7 @@ describe('BookService', () => { await service.bulkSetRating([3, 5], 4, user); - expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3, 5], 4); + expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3, 5], 4, 42); expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(1, 3, 'auto', 42); expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 5, 'auto', 42); expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 3); @@ -1791,6 +1792,7 @@ describe('BookService', () => { userBookStatusService.findOne.mockResolvedValue({ status: 'reading' }); comicMetadataService.findByBookId.mockResolvedValue({ issueNumber: '1', teams: ['House Atreides'] }); bookRepo.findCollectionsByBookId.mockResolvedValue([{ id: 3, name: 'Favorites' }]); + bookRepo.findRatingByBookAndUser.mockResolvedValue(5); const result = await service.getDetail(9, user); diff --git a/server/src/modules/book/book.service.ts b/server/src/modules/book/book.service.ts index e983b1c56..330022c48 100644 --- a/server/src/modules/book/book.service.ts +++ b/server/src/modules/book/book.service.ts @@ -508,7 +508,6 @@ export class BookService { if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null; if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? null; if (dto.isbn13 !== undefined) scalarFields.isbn13 = dto.isbn13 ?? null; - if (dto.rating !== undefined) scalarFields.rating = dto.rating ?? null; if (dto.googleBooksId !== undefined) scalarFields.googleBooksId = dto.googleBooksId ?? null; if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null; if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null; @@ -565,6 +564,10 @@ export class BookService { this.metadataService.emitAuthorsReplaced(id, replacedAuthorIds); + if (dto.rating !== undefined) { + await this.bookRepo.bulkSetRating([id], dto.rating ?? null, user.id); + } + this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`)); this.fileWriteService?.scheduleWrite(id, 'auto', user.id); this.scoreService.calculateAndSave(id).catch((err: Error) => this.logger.warn(`Score calculation failed for book ${id}: ${err.message}`)); @@ -755,7 +758,7 @@ export class BookService { const startedAt = Date.now(); this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} - bulk set rating started`); await this.verifyLibraryAccessForBookIds(bookIds, user); - await this.bookRepo.bulkSetRating(bookIds, rating); + await this.bookRepo.bulkSetRating(bookIds, rating, user.id); this.triggerPostMetadataUpdateEffects(bookIds, user.id); this.logger.log( `[${event}] [end] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} durationMs=${Date.now() - startedAt} - bulk set rating completed`, @@ -1226,8 +1229,9 @@ export class BookService { async getDetail(id: number, user: RequestUser): Promise { await this.verifyBookAccess(id, user); - const [result, readStatus, comicMeta, collectionRows] = await Promise.all([ + const [result, personalRating, readStatus, comicMeta, collectionRows] = await Promise.all([ this.bookRepo.findById(id), + this.bookRepo.findRatingByBookAndUser(id, user.id), this.userBookStatusService.findOne(user.id, id), this.comicMetadataService.findByBookId(id), this.bookRepo.findCollectionsByBookId(id, user.id), @@ -1257,7 +1261,7 @@ export class BookService { pageCount: meta?.pageCount ?? null, seriesName: meta?.seriesName ?? null, seriesIndex: meta?.seriesIndex ?? null, - rating: meta?.rating ?? null, + rating: personalRating, coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null, lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields), providerIds: { diff --git a/server/src/modules/file-write/file-write.constants.ts b/server/src/modules/file-write/file-write.constants.ts index 0dae15296..3fc2d79d2 100644 --- a/server/src/modules/file-write/file-write.constants.ts +++ b/server/src/modules/file-write/file-write.constants.ts @@ -19,7 +19,6 @@ export const BOOK_WRITE_FIELD_KEYS: readonly BookWritePayloadKey[] = [ 'seriesIndex', 'isbn10', 'isbn13', - 'rating', 'authors', 'genres', 'tags', diff --git a/server/src/modules/file-write/file-write.repository.test.ts b/server/src/modules/file-write/file-write.repository.test.ts index c743a6fb5..aba970fb3 100644 --- a/server/src/modules/file-write/file-write.repository.test.ts +++ b/server/src/modules/file-write/file-write.repository.test.ts @@ -48,7 +48,6 @@ describe('FileWriteRepository', () => { seriesIndex: 1, isbn10: '123', isbn13: '978123', - rating: 8, googleBooksId: 'g', goodreadsId: 'gr', amazonId: 'a', diff --git a/server/src/modules/file-write/file-write.repository.ts b/server/src/modules/file-write/file-write.repository.ts index f63a8ce17..b69a5e423 100644 --- a/server/src/modules/file-write/file-write.repository.ts +++ b/server/src/modules/file-write/file-write.repository.ts @@ -99,7 +99,6 @@ export class FileWriteRepository { seriesIndex: meta.seriesIndex, isbn10: meta.isbn10, isbn13: meta.isbn13, - rating: meta.rating, googleBooksId: meta.googleBooksId, goodreadsId: meta.goodreadsId, amazonId: meta.amazonId,