mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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.
This commit is contained in:
@@ -7,6 +7,11 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
# Break-glass: bypasses CI/E2E gate. Use only when CI is broken and a hotfix image is urgently needed.
|
# Break-glass: bypasses CI/E2E gate. Use only when CI is broken and a hotfix image is urgently needed.
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
force_rebuild:
|
||||||
|
description: Rebuild even if image for this SHA already exists in GHCR
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["CI"]
|
workflows: ["CI"]
|
||||||
types: [completed]
|
types: [completed]
|
||||||
@@ -25,63 +30,17 @@ env:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
image:
|
# PR path: amd64-only build and scan for fast feedback. No push.
|
||||||
name: ${{ github.event_name == 'pull_request' && 'Build and Scan Image' || 'Build and Push Image' }}
|
scan:
|
||||||
if: >
|
name: Build and Scan Image
|
||||||
github.event_name == 'pull_request' ||
|
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')
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
|
||||||
steps:
|
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<<EOF" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "should_push=true" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "tags<<EOF" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "${IMAGE}:${GH_REF_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "${IMAGE}:sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "EOF" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
with:
|
|
||||||
ref: ${{ steps.vars.outputs.checkout_ref }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||||
@@ -93,36 +52,239 @@ jobs:
|
|||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: false
|
push: false
|
||||||
load: true
|
load: true
|
||||||
tags: ${{ steps.vars.outputs.tags }}
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-scan
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
cache-from: type=gha
|
cache-from: type=gha,scope=container-image-yml-pr
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max,scope=container-image-yml-pr
|
||||||
|
|
||||||
- name: Scan image with Trivy
|
- name: Scan image with Trivy
|
||||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
with:
|
with:
|
||||||
image-ref: ${{ steps.vars.outputs.scan_tag }}
|
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:pr-scan
|
||||||
format: table
|
format: table
|
||||||
vuln-type: os,library
|
vuln-type: os,library
|
||||||
severity: CRITICAL,HIGH
|
severity: CRITICAL,HIGH
|
||||||
ignore-unfixed: true
|
ignore-unfixed: true
|
||||||
exit-code: "1"
|
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
|
- name: Log in to GHCR
|
||||||
if: steps.vars.outputs.should_push == 'true'
|
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Push image
|
- name: Build image
|
||||||
if: steps.vars.outputs.should_push == 'true'
|
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
|
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: |
|
run: |
|
||||||
while IFS= read -r tag; do
|
FULL_IMAGE="${REGISTRY}/${IMAGE}"
|
||||||
[ -n "$tag" ] || continue
|
docker buildx imagetools create \
|
||||||
docker push "$tag"
|
--tag "${FULL_IMAGE}:sha-${SHA}" \
|
||||||
done <<'EOF'
|
"${FULL_IMAGE}@${AMD64_DIGEST}" \
|
||||||
${{ steps.vars.outputs.tags }}
|
"${FULL_IMAGE}@${ARM64_DIGEST}"
|
||||||
EOF
|
|
||||||
|
- 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}"
|
||||||
|
|||||||
+130
-57
@@ -81,9 +81,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: read
|
||||||
contents: write
|
contents: write
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
packages: read
|
||||||
outputs:
|
outputs:
|
||||||
published: ${{ steps.semantic.outputs.new_release_published }}
|
published: ${{ steps.semantic.outputs.new_release_published }}
|
||||||
version: ${{ steps.semantic.outputs.new_release_version }}
|
version: ${{ steps.semantic.outputs.new_release_version }}
|
||||||
@@ -123,6 +125,30 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "CI verification passed: ${success_count} successful run(s) found."
|
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
|
- name: Set up Node.js and pnpm
|
||||||
uses: ./.github/actions/setup-node-pnpm
|
uses: ./.github/actions/setup-node-pnpm
|
||||||
with:
|
with:
|
||||||
@@ -131,10 +157,23 @@ jobs:
|
|||||||
|
|
||||||
- name: Run semantic-release
|
- name: Run semantic-release
|
||||||
id: semantic
|
id: semantic
|
||||||
uses: cycjimmy/semantic-release-action@16ca923e6ccbb50770c415a0ccd43709a8c5f7a4 # v4.2.2
|
shell: bash
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GITHUB_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
|
- name: Release summary
|
||||||
if: steps.semantic.outputs.new_release_published == 'true'
|
if: steps.semantic.outputs.new_release_published == 'true'
|
||||||
@@ -148,64 +187,22 @@ jobs:
|
|||||||
echo "- **Version:** ${VERSION}" >> "$GITHUB_STEP_SUMMARY"
|
echo "- **Version:** ${VERSION}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "- **Tag:** ${TAG}" >> "$GITHUB_STEP_SUMMARY"
|
echo "- **Tag:** ${TAG}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
docker-publish:
|
docker-promote:
|
||||||
name: Publish Docker Image
|
name: Promote Docker Image
|
||||||
needs: [release]
|
needs: [release]
|
||||||
if: needs.release.outputs.published == 'true'
|
if: needs.release.outputs.published == 'true'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 45
|
timeout-minutes: 10
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
||||||
with:
|
|
||||||
ref: ${{ needs.release.outputs.tag }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
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<<EOF" >> "$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
|
- name: Log in to GHCR
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||||
with:
|
with:
|
||||||
@@ -213,21 +210,97 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Push image
|
- name: Verify SHA image and resolve digest
|
||||||
|
id: source
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
IMAGE: ${{ env.IMAGE_NAME }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
while IFS= read -r tag; do
|
FULL_IMAGE="${REGISTRY}/${IMAGE}"
|
||||||
[ -n "$tag" ] || continue
|
SOURCE_TAG="${FULL_IMAGE}:sha-${SHA}"
|
||||||
docker push "$tag"
|
if ! docker buildx imagetools inspect "${SOURCE_TAG}" > /dev/null 2>&1; then
|
||||||
done <<'EOF'
|
echo "::error::Multi-arch image sha-${SHA} not found in GHCR. Wait for the Container Image workflow to complete for this commit before releasing."
|
||||||
${{ steps.tags.outputs.tags }}
|
exit 1
|
||||||
EOF
|
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 "<!-- release-metadata -->" "${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 "<!-- release-metadata -->"
|
||||||
|
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
|
- name: Docker publish summary
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ needs.release.outputs.version }}
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
echo "## Docker Image Published" >> "$GITHUB_STEP_SUMMARY"
|
echo "## Docker Image Promoted" >> "$GITHUB_STEP_SUMMARY"
|
||||||
echo "" >> "$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"
|
||||||
|
|||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -555,6 +555,15 @@ CREATE TABLE "reading_sessions" (
|
|||||||
CONSTRAINT "reading_sessions_ended_after_started_chk" CHECK ("reading_sessions"."ended_at" >= "reading_sessions"."started_at")
|
CONSTRAINT "reading_sessions_ended_after_started_chk" CHECK ("reading_sessions"."ended_at" >= "reading_sessions"."started_at")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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" (
|
CREATE TABLE "user_book_status" (
|
||||||
"user_id" integer NOT NULL,
|
"user_id" integer NOT NULL,
|
||||||
"book_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_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_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 "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_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_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
|
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_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_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 "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_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 "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
|
CREATE INDEX "urds_user_day_idx" ON "user_reading_daily_stats" USING btree ("user_id","day");--> statement-breakpoint
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"id": "2de4dd88-2590-4da4-b868-f9341ffe3e9f",
|
"id": "e9ca331e-b35a-4d80-a54b-28759d1e1c61",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
@@ -5286,6 +5286,104 @@
|
|||||||
},
|
},
|
||||||
"isRLSEnabled": false
|
"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": {
|
"public.user_book_status": {
|
||||||
"name": "user_book_status",
|
"name": "user_book_status",
|
||||||
"schema": "",
|
"schema": "",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1777215796833,
|
"when": 1777315276249,
|
||||||
"tag": "0000_baseline",
|
"tag": "0000_baseline",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,32 @@ export const userBookStatus = pgTable(
|
|||||||
export type UserBookStatusRow = typeof userBookStatus.$inferSelect;
|
export type UserBookStatusRow = typeof userBookStatus.$inferSelect;
|
||||||
export type NewUserBookStatus = typeof userBookStatus.$inferInsert;
|
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(
|
export const readingProgress = pgTable(
|
||||||
'reading_progress',
|
'reading_progress',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
collectionBooks,
|
collectionBooks,
|
||||||
collections,
|
collections,
|
||||||
readingProgress,
|
readingProgress,
|
||||||
|
userBookRatings,
|
||||||
genres,
|
genres,
|
||||||
libraries,
|
libraries,
|
||||||
narrators,
|
narrators,
|
||||||
@@ -33,7 +34,6 @@ const SORT_FIELD_MAP: Partial<Record<SortField, AnyColumn>> = {
|
|||||||
updatedAt: books.updatedAt,
|
updatedAt: books.updatedAt,
|
||||||
publishedYear: bookMetadata.publishedYear,
|
publishedYear: bookMetadata.publishedYear,
|
||||||
pageCount: bookMetadata.pageCount,
|
pageCount: bookMetadata.pageCount,
|
||||||
rating: bookMetadata.rating,
|
|
||||||
publisher: bookMetadata.publisher,
|
publisher: bookMetadata.publisher,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,6 +119,11 @@ export class BookQueryBuilder {
|
|||||||
result.push(
|
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`,
|
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') {
|
} else if (field === 'random') {
|
||||||
const daySeed = Math.floor(Date.now() / 86_400_000);
|
const daySeed = Math.floor(Date.now() / 86_400_000);
|
||||||
const scopedSeed = daySeed + (userId ?? 0);
|
const scopedSeed = daySeed + (userId ?? 0);
|
||||||
@@ -172,7 +177,8 @@ export class BookQueryBuilder {
|
|||||||
case 'pageCount':
|
case 'pageCount':
|
||||||
return this.numericRuleToSql(bookMetadata.pageCount, operator, value as number, valueTo as number | undefined);
|
return this.numericRuleToSql(bookMetadata.pageCount, operator, value as number, valueTo as number | undefined);
|
||||||
case 'rating':
|
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':
|
case 'author':
|
||||||
return this.authorRuleToSql(operator, value as string[]);
|
return this.authorRuleToSql(operator, value as string[]);
|
||||||
case 'genre':
|
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<number>`(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 {
|
private authorRuleToSql(operator: string, values?: string[]): SQL {
|
||||||
const existsAuthor = (whereClause?: SQL) => {
|
const existsAuthor = (whereClause?: SQL) => {
|
||||||
const predicates: SQL[] = [eq(bookAuthors.bookId, books.id)];
|
const predicates: SQL[] = [eq(bookAuthors.bookId, books.id)];
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
narrators,
|
narrators,
|
||||||
audiobookProgress,
|
audiobookProgress,
|
||||||
readingProgress,
|
readingProgress,
|
||||||
|
userBookRatings,
|
||||||
tags,
|
tags,
|
||||||
userBookStatus,
|
userBookStatus,
|
||||||
} from '../../db/schema';
|
} from '../../db/schema';
|
||||||
@@ -93,12 +94,13 @@ export class BookRepository {
|
|||||||
seriesIndex: bookMetadata.seriesIndex,
|
seriesIndex: bookMetadata.seriesIndex,
|
||||||
publishedYear: bookMetadata.publishedYear,
|
publishedYear: bookMetadata.publishedYear,
|
||||||
language: bookMetadata.language,
|
language: bookMetadata.language,
|
||||||
rating: bookMetadata.rating,
|
rating: userBookRatings.rating,
|
||||||
coverSource: bookMetadata.coverSource,
|
coverSource: bookMetadata.coverSource,
|
||||||
lockedFields: bookMetadata.lockedFields,
|
lockedFields: bookMetadata.lockedFields,
|
||||||
})
|
})
|
||||||
.from(books)
|
.from(books)
|
||||||
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||||
|
.leftJoin(userBookRatings, and(eq(userBookRatings.bookId, books.id), eq(userBookRatings.userId, userId)))
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(...orderBy)
|
.orderBy(...orderBy)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
@@ -268,7 +270,7 @@ export class BookRepository {
|
|||||||
book_metadata.series_index,
|
book_metadata.series_index,
|
||||||
book_metadata.published_year,
|
book_metadata.published_year,
|
||||||
book_metadata.language,
|
book_metadata.language,
|
||||||
book_metadata.rating,
|
ubr.rating,
|
||||||
book_metadata.cover_source,
|
book_metadata.cover_source,
|
||||||
book_metadata.locked_fields,
|
book_metadata.locked_fields,
|
||||||
book_metadata.publisher,
|
book_metadata.publisher,
|
||||||
@@ -280,6 +282,7 @@ export class BookRepository {
|
|||||||
sc.cover_book_ids
|
sc.cover_book_ids
|
||||||
FROM books
|
FROM books
|
||||||
LEFT JOIN book_metadata ON book_metadata.book_id = books.id
|
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
|
LEFT JOIN series_agg sa
|
||||||
ON sa.norm_series = NULLIF(lower(btrim(book_metadata.series_name)), '')
|
ON sa.norm_series = NULLIF(lower(btrim(book_metadata.series_name)), '')
|
||||||
AND sa.library_id = books.library_id
|
AND sa.library_id = books.library_id
|
||||||
@@ -420,6 +423,15 @@ export class BookRepository {
|
|||||||
return { book, authorRows, genreRows, tagRows, fileRows, narratorRows };
|
return { book, authorRows, genreRows, tagRows, fileRows, narratorRows };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findRatingByBookAndUser(bookId: number, userId: number): Promise<number | null> {
|
||||||
|
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 }[]> {
|
async findCollectionsByBookId(bookId: number, userId: number): Promise<{ id: number; name: string }[]> {
|
||||||
return this.db
|
return this.db
|
||||||
.select({ id: collections.id, name: collections.name })
|
.select({ id: collections.id, name: collections.name })
|
||||||
@@ -704,9 +716,20 @@ export class BookRepository {
|
|||||||
await this.db.delete(books).where(inArray(books.id, bookIds));
|
await this.db.delete(books).where(inArray(books.id, bookIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkSetRating(bookIds: number[], rating: number | null): Promise<void> {
|
async bulkSetRating(bookIds: number[], rating: number | null, userId: number): Promise<void> {
|
||||||
if (bookIds.length === 0) return;
|
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<Map<number, string[]>> {
|
async findTagsByBookIds(bookIds: number[], executor: MetadataReadExecutor = this.db): Promise<Map<number, string[]>> {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ function makeService() {
|
|||||||
findTagsByBookIds: vi.fn(),
|
findTagsByBookIds: vi.fn(),
|
||||||
findPrimaryFile: vi.fn(),
|
findPrimaryFile: vi.fn(),
|
||||||
findById: vi.fn(),
|
findById: vi.fn(),
|
||||||
|
findRatingByBookAndUser: vi.fn().mockResolvedValue(null),
|
||||||
findCollectionsByBookId: vi.fn(),
|
findCollectionsByBookId: vi.fn(),
|
||||||
findKoboReadingState: vi.fn(),
|
findKoboReadingState: vi.fn(),
|
||||||
findKoboSnapshotState: vi.fn(),
|
findKoboSnapshotState: vi.fn(),
|
||||||
@@ -847,11 +848,11 @@ describe('BookService', () => {
|
|||||||
5,
|
5,
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
title: null,
|
title: null,
|
||||||
rating: 4,
|
|
||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
}),
|
}),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
|
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([5], 4, user.id);
|
||||||
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(
|
expect(metadataService.replaceAuthors).toHaveBeenCalledWith(
|
||||||
5,
|
5,
|
||||||
[
|
[
|
||||||
@@ -1670,7 +1671,7 @@ describe('BookService', () => {
|
|||||||
|
|
||||||
await service.bulkSetRating([3, 5], 4, user);
|
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(1, 3, 'auto', 42);
|
||||||
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 5, 'auto', 42);
|
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 5, 'auto', 42);
|
||||||
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 3);
|
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 3);
|
||||||
@@ -1791,6 +1792,7 @@ describe('BookService', () => {
|
|||||||
userBookStatusService.findOne.mockResolvedValue({ status: 'reading' });
|
userBookStatusService.findOne.mockResolvedValue({ status: 'reading' });
|
||||||
comicMetadataService.findByBookId.mockResolvedValue({ issueNumber: '1', teams: ['House Atreides'] });
|
comicMetadataService.findByBookId.mockResolvedValue({ issueNumber: '1', teams: ['House Atreides'] });
|
||||||
bookRepo.findCollectionsByBookId.mockResolvedValue([{ id: 3, name: 'Favorites' }]);
|
bookRepo.findCollectionsByBookId.mockResolvedValue([{ id: 3, name: 'Favorites' }]);
|
||||||
|
bookRepo.findRatingByBookAndUser.mockResolvedValue(5);
|
||||||
|
|
||||||
const result = await service.getDetail(9, user);
|
const result = await service.getDetail(9, user);
|
||||||
|
|
||||||
|
|||||||
@@ -508,7 +508,6 @@ export class BookService {
|
|||||||
if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null;
|
if (dto.seriesIndex !== undefined) scalarFields.seriesIndex = dto.seriesIndex ?? null;
|
||||||
if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? null;
|
if (dto.isbn10 !== undefined) scalarFields.isbn10 = dto.isbn10 ?? null;
|
||||||
if (dto.isbn13 !== undefined) scalarFields.isbn13 = dto.isbn13 ?? 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.googleBooksId !== undefined) scalarFields.googleBooksId = dto.googleBooksId ?? null;
|
||||||
if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null;
|
if (dto.goodreadsId !== undefined) scalarFields.goodreadsId = dto.goodreadsId ?? null;
|
||||||
if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null;
|
if (dto.amazonId !== undefined) scalarFields.amazonId = dto.amazonId ?? null;
|
||||||
@@ -565,6 +564,10 @@ export class BookService {
|
|||||||
|
|
||||||
this.metadataService.emitAuthorsReplaced(id, replacedAuthorIds);
|
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.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
|
||||||
this.fileWriteService?.scheduleWrite(id, 'auto', user.id);
|
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}`));
|
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();
|
const startedAt = Date.now();
|
||||||
this.logger.log(`[${event}] [start] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} - bulk set rating started`);
|
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.verifyLibraryAccessForBookIds(bookIds, user);
|
||||||
await this.bookRepo.bulkSetRating(bookIds, rating);
|
await this.bookRepo.bulkSetRating(bookIds, rating, user.id);
|
||||||
this.triggerPostMetadataUpdateEffects(bookIds, user.id);
|
this.triggerPostMetadataUpdateEffects(bookIds, user.id);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[${event}] [end] userId=${user.id} count=${bookIds.length} rating=${rating ?? 'null'} durationMs=${Date.now() - startedAt} - bulk set rating completed`,
|
`[${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<BookDetailDto> {
|
async getDetail(id: number, user: RequestUser): Promise<BookDetailDto> {
|
||||||
await this.verifyBookAccess(id, user);
|
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.findById(id),
|
||||||
|
this.bookRepo.findRatingByBookAndUser(id, user.id),
|
||||||
this.userBookStatusService.findOne(user.id, id),
|
this.userBookStatusService.findOne(user.id, id),
|
||||||
this.comicMetadataService.findByBookId(id),
|
this.comicMetadataService.findByBookId(id),
|
||||||
this.bookRepo.findCollectionsByBookId(id, user.id),
|
this.bookRepo.findCollectionsByBookId(id, user.id),
|
||||||
@@ -1257,7 +1261,7 @@ export class BookService {
|
|||||||
pageCount: meta?.pageCount ?? null,
|
pageCount: meta?.pageCount ?? null,
|
||||||
seriesName: meta?.seriesName ?? null,
|
seriesName: meta?.seriesName ?? null,
|
||||||
seriesIndex: meta?.seriesIndex ?? null,
|
seriesIndex: meta?.seriesIndex ?? null,
|
||||||
rating: meta?.rating ?? null,
|
rating: personalRating,
|
||||||
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
|
coverSource: (meta?.coverSource as 'extracted' | 'custom' | null) ?? null,
|
||||||
lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields),
|
lockedFields: this.bookMetadataLockService.normalizeLockedFields(meta?.lockedFields),
|
||||||
providerIds: {
|
providerIds: {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export const BOOK_WRITE_FIELD_KEYS: readonly BookWritePayloadKey[] = [
|
|||||||
'seriesIndex',
|
'seriesIndex',
|
||||||
'isbn10',
|
'isbn10',
|
||||||
'isbn13',
|
'isbn13',
|
||||||
'rating',
|
|
||||||
'authors',
|
'authors',
|
||||||
'genres',
|
'genres',
|
||||||
'tags',
|
'tags',
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ describe('FileWriteRepository', () => {
|
|||||||
seriesIndex: 1,
|
seriesIndex: 1,
|
||||||
isbn10: '123',
|
isbn10: '123',
|
||||||
isbn13: '978123',
|
isbn13: '978123',
|
||||||
rating: 8,
|
|
||||||
googleBooksId: 'g',
|
googleBooksId: 'g',
|
||||||
goodreadsId: 'gr',
|
goodreadsId: 'gr',
|
||||||
amazonId: 'a',
|
amazonId: 'a',
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export class FileWriteRepository {
|
|||||||
seriesIndex: meta.seriesIndex,
|
seriesIndex: meta.seriesIndex,
|
||||||
isbn10: meta.isbn10,
|
isbn10: meta.isbn10,
|
||||||
isbn13: meta.isbn13,
|
isbn13: meta.isbn13,
|
||||||
rating: meta.rating,
|
|
||||||
googleBooksId: meta.googleBooksId,
|
googleBooksId: meta.googleBooksId,
|
||||||
goodreadsId: meta.goodreadsId,
|
goodreadsId: meta.goodreadsId,
|
||||||
amazonId: meta.amazonId,
|
amazonId: meta.amazonId,
|
||||||
|
|||||||
Reference in New Issue
Block a user