Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Nothaft e1ae562a37 before/after for stray-0 fix PR 2026-07-03 08:58:55 +02:00
Paul Nothaft c153b5b891 screenshots for refactor/codebase-cleanup PR 2026-07-03 08:28:05 +02:00
270 changed files with 22919 additions and 17259 deletions
+20 -8
View File
@@ -4,8 +4,11 @@
# Environment
NODE_ENV=production
# JWT Secret (generate with: openssl rand -base64 64)
JWT_SECRET=your_very_long_random_jwt_secret_here
# JWT Secret — OPTIONAL. Leave unset and it is auto-generated on first run
# (Docker: the secrets-init service writes it to a private volume and reuses it
# across restarts). Set it explicitly only to pin your own value.
# Generate one with: openssl rand -base64 64
#JWT_SECRET=your_very_long_random_jwt_secret_here
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
@@ -38,19 +41,28 @@ JWT_SECRET=your_very_long_random_jwt_secret_here
# Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg
DB_USER=picpeak
# DB_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run
# (Docker). Set it explicitly to pin your own, e.g. for an external database.
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
DB_PASSWORD=your_secure_postgres_password_here
#DB_PASSWORD=your_secure_postgres_password_here
DB_NAME=picpeak_prod
# Redis Configuration
# REDIS_PASSWORD — OPTIONAL. Leave unset and it is auto-generated on first run (Docker).
# IMPORTANT: Same warning applies - avoid $ or escape as $$
REDIS_PASSWORD=your_secure_redis_password_here
#REDIS_PASSWORD=your_secure_redis_password_here
# Admin Account (initial setup)
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@yourdomain.com
ADMIN_PASSWORD=your_secure_admin_password_here
# Admin Account (initial setup) — OPTIONAL
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
# open /admin and PicPeak shows a setup screen. The one-time setup token is
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
# and saved to data/SETUP_TOKEN.
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
# credentials written to data/ADMIN_CREDENTIALS.txt).
#ADMIN_USERNAME=admin
#ADMIN_EMAIL=admin@yourdomain.com
#ADMIN_PASSWORD=your_secure_admin_password_here
# Email Configuration
# For Gmail: use app-specific password
+3 -3
View File
@@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📚 Documentation
url: https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md
url: https://github.com/PicPeak/picpeak/blob/main/DEPLOYMENT.md
about: Please read the documentation before opening an issue
- name: 💬 Discussions
url: https://github.com/the-luap/picpeak/discussions
url: https://github.com/PicPeak/picpeak/discussions
about: Ask questions and discuss with the community
- name: 🔒 Security Issues
url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md
url: https://github.com/PicPeak/picpeak/blob/main/SECURITY.md
about: Please review our security policy for reporting vulnerabilities
@@ -9,7 +9,7 @@ assignees: ''
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
For minor security improvements or questions, you can use this template:
+9 -9
View File
@@ -42,16 +42,16 @@ Once published, images can be pulled using:
```bash
# Pull backend image
docker pull ghcr.io/the-luap/picpeak/backend:latest
docker pull ghcr.io/picpeak/picpeak/backend:latest
# Pull frontend image
docker pull ghcr.io/the-luap/picpeak/frontend:latest
docker pull ghcr.io/picpeak/picpeak/frontend:latest
# Pull specific version
docker pull ghcr.io/the-luap/picpeak/backend:v1.0.0
docker pull ghcr.io/picpeak/picpeak/backend:v1.0.0
# Pull for specific architecture
docker pull --platform linux/arm64 ghcr.io/the-luap/picpeak/backend:latest
docker pull --platform linux/arm64 ghcr.io/picpeak/picpeak/backend:latest
```
### Using in Docker Compose
@@ -61,14 +61,14 @@ version: '3.8'
services:
backend:
image: ghcr.io/the-luap/picpeak/backend:latest
image: ghcr.io/picpeak/picpeak/backend:latest
environment:
- NODE_ENV=production
ports:
- "3001:3000"
frontend:
image: ghcr.io/the-luap/picpeak/frontend:latest
image: ghcr.io/picpeak/picpeak/frontend:latest
ports:
- "80:80"
```
@@ -86,7 +86,7 @@ spec:
spec:
containers:
- name: backend
image: ghcr.io/the-luap/picpeak/backend:latest
image: ghcr.io/picpeak/picpeak/backend:latest
imagePullPolicy: Always
```
@@ -149,8 +149,8 @@ If images aren't visible after successful push:
### View Packages
Your Docker images are available at:
- Backend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Ffrontend`
- Backend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/orgs/PicPeak/packages/container/package/picpeak%2Ffrontend`
### Delete Old Versions
+70
View File
@@ -0,0 +1,70 @@
name: Bypass size gate
# Caps how large a PR a "review-bypass" collaborator (e.g. @Luca-Timo) can
# self-merge without a maintainer review. The branch-protection bypass list
# alone is binary — once a user is on it they can merge anything without
# review. This workflow reports a REQUIRED status check that fails when a
# bypass user's PR exceeds the configured size threshold, which blocks the
# merge even with bypass enabled. Other contributors are unaffected (the
# check reports success for them so the required-check gate doesn't trip).
#
# To tune: edit LINE_LIMIT or BYPASS_USERS below.
#
# Trigger note: uses `pull_request_target` so the workflow has the elevated
# permissions of the base repo's GITHUB_TOKEN (read PR metadata, write
# checks). The script never executes code FROM the PR — it only reads
# metadata via the API — so this is safe against fork-PR attacks.
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
permissions:
pull-requests: read
checks: write
jobs:
size-gate:
runs-on: ubuntu-latest
steps:
- name: Compute PR size and report check status
uses: actions/github-script@v7
with:
script: |
// Tune these two constants if the policy shifts.
const LINE_LIMIT = 300;
const BYPASS_USERS = ['Luca-Timo'];
const pr = context.payload.pull_request;
const author = pr.user.login;
const linesChanged = pr.additions + pr.deletions;
const filesChanged = pr.changed_files;
let conclusion, title, summary;
if (!BYPASS_USERS.includes(author)) {
// Not a bypass user — this gate doesn't apply to them. They
// go through normal review. Report success so the required
// check doesn't block their merge.
conclusion = 'success';
title = 'Not applicable';
summary = `This gate only restricts review-bypass for: ${BYPASS_USERS.join(', ')}. PRs from other authors (${author} here) go through the normal review path and are unaffected.`;
} else if (linesChanged <= LINE_LIMIT) {
conclusion = 'success';
title = `OK — within bypass limit (${linesChanged} lines)`;
summary = `Small PR: ${linesChanged} lines changed across ${filesChanged} file(s). Within the ${LINE_LIMIT}-line self-merge limit for @${author}. Can be merged without a maintainer review.`;
} else {
conclusion = 'failure';
title = `Too large for bypass (${linesChanged} lines)`;
summary = `Large PR: ${linesChanged} lines changed across ${filesChanged} file(s). Exceeds the ${LINE_LIMIT}-line self-merge limit for @${author} — needs an approving review from a maintainer before merge. Split into smaller PRs or wait for review.`;
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'bypass-size-gate',
head_sha: pr.head.sha,
status: 'completed',
conclusion,
output: { title, summary }
});
+36 -11
View File
@@ -1,7 +1,8 @@
name: Build and Push Docker Images
# This workflow is triggered by:
# - Push to main/beta branches (builds 'latest'/'stable' or 'beta' tagged images)
# - Push to main/stable branches (main → ':main' rolling tag for active-dev
# builds; stable → ':stable' + ':latest' for the curated channel)
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
# - GitHub Releases (created by Release Please)
# - Pull requests (build verification only, no push by default)
@@ -20,10 +21,10 @@ name: Build and Push Docker Images
on:
push:
branches: [ main, beta ]
branches: [ main, stable ]
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
pull_request:
branches: [ main, beta ]
branches: [ main, stable ]
release:
types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch:
@@ -37,6 +38,16 @@ on:
- 'true'
- 'false'
# Once release-please authors releases with a PAT (#719), a new version fires
# BOTH the tag-push and the release-published triggers (GITHUB_TOKEN used to
# suppress them). They build the same immutable version, so collapse them into a
# single run by grouping on the ref. Branch and PR builds use different refs and
# still run independently; a superseding push cancels an in-flight run for the
# same ref (only the newest build per ref is kept).
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
@@ -245,7 +256,9 @@ jobs:
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
# Active-dev branch (`main`, renamed from `beta` per #669) produces
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
@@ -270,9 +283,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
# so `is_default_branch` no longer maps to "stable" — be explicit.
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
# tag remains frozen at its last build — operators should update.
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
@@ -455,7 +473,9 @@ jobs:
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/main ]]; then
# Active-dev branch (`main`, renamed from `beta` per #669) produces
# prereleases; the `-beta.N` version-suffix scheme is unchanged.
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
@@ -480,9 +500,14 @@ jobs:
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
# stable release tags). The default branch is now `main` (active dev),
# so `is_default_branch` no longer maps to "stable" — be explicit.
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/stable' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
# `:beta` is RETIRED post-rename (Option B / #669). Active-dev pulls
# are `:main` (auto via type=ref,event=branch). The pre-rename `:beta`
# tag remains frozen at its last build — operators should update.
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
+9 -16
View File
@@ -16,24 +16,17 @@ name: Fresh-install smoke
# don't pay the build cost.
on:
# No `paths:` filter — branch protection on `main` + `stable` lists
# `fresh-install` as a REQUIRED check, and a path-filtered trigger
# that skipped on unrelated PRs (e.g. frontend-only) would leave the
# required check "missing" forever and block the merge. Better to
# pay the boot cost on every PR than maintain a per-path allowlist
# that drifts as the install surface evolves. (Branches also updated
# post-#669 rename: beta → main, old main → stable.)
push:
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
branches: [main, stable]
pull_request:
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
branches: [main, stable]
workflow_dispatch:
permissions:
+36
View File
@@ -0,0 +1,36 @@
name: PR Title Lint
# Release Please derives version bumps and the changelog from Conventional
# Commit prefixes (feat:, fix:, ...). PRs whose title/commits use other
# conventions (e.g. gitmoji) are silently ignored, so their changes ship
# without a version bump or a changelog entry. This check fails a PR whose
# title is not a valid Conventional Commit so the release stays automated.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
permissions:
pull-requests: read
jobs:
lint-pr-title:
runs-on: ubuntu-latest
steps:
- name: Validate PR title is a Conventional Commit
uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
perf
revert
docs
style
chore
refactor
test
build
ci
+54 -3
View File
@@ -2,7 +2,7 @@ name: Release Please (Beta)
on:
push:
branches: [beta]
branches: [main]
permissions:
contents: write
@@ -20,10 +20,48 @@ jobs:
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
# A dedicated token (fine-grained PAT) makes the release PR run CI
# automatically (no "workflows awaiting approval") and lets it be
# merged without a manual review. Falls back to GITHUB_TOKEN so the
# workflow still works before the secret is added (#719).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config-beta.json
manifest-file: .release-please-manifest-beta.json
target-branch: beta
target-branch: main
# Auto-approve + enable auto-merge on the open release PR so betas publish
# with no manual clicks. Approval uses GITHUB_TOKEN (github-actions[bot]) —
# a different identity than the PR author (RELEASE_PLEASE_TOKEN) — so it is
# a valid review (requires the org's "Allow GitHub Actions to approve pull
# requests" + the repo's "Allow auto-merge"). Only meaningful when a PAT is
# set: without it the PR is bot-authored and can't be self-approved, so we
# skip and leave today's manual flow. Best-effort — never blocks the run.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# This job has no checkout, so gh can't infer the repo from a git
# remote — set it explicitly (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--main --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN) — a different identity
# than the PR author (the PAT) — so it counts as a valid review.
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
# Enable auto-merge as the PAT so the eventual merge commit is
# attributed to a real identity. If enabled via GITHUB_TOKEN the merge
# push is suppressed by recursion prevention and the follow-up run that
# cuts the tag/release never fires (#719).
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -35,3 +73,16 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
+44 -2
View File
@@ -2,7 +2,7 @@ name: Release Please
on:
push:
branches: [main]
branches: [stable]
permissions:
contents: write
@@ -20,10 +20,39 @@ jobs:
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Dedicated token so the release PR runs CI + can auto-merge without a
# manual review. Falls back to GITHUB_TOKEN before the secret is set (#719).
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
# whenever no PAT is configured.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout in this job — set the repo explicitly so gh works
# without a git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
# is a valid review; enable auto-merge as the PAT so the merge commit is
# attributed to a real identity and triggers the tag-cutting run (#719).
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
@@ -34,3 +63,16 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
# Best-effort "What's New" highlights on the freshly-created release. Runs in
# this same workflow run (not a `release:` trigger) because release-please
# creates the release with GITHUB_TOKEN, which never starts new workflow runs.
whatsnew:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
permissions:
contents: write # edit the release body
models: read # GitHub Models (free tier)
uses: ./.github/workflows/whatsnew-highlights.yml
with:
tag: ${{ needs.release-please.outputs.tag_name }}
+9 -12
View File
@@ -30,20 +30,17 @@ name: Schema drift (#530)
# the same shape is caught before merge.
on:
# No `paths:` filter — branch protection on `main` + `stable` lists
# `upgrade-from-bootstrap` as a REQUIRED check. A path-filtered
# trigger that skipped on unrelated PRs would leave the required
# check "missing" forever, blocking every PR that doesn't touch
# migrations. The ~75-second cost on every PR buys an unconditional
# safety net. (Branches also updated post-#669 rename: beta → main,
# old main → stable.)
push:
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
branches: [main, stable]
pull_request:
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
branches: [main, stable]
workflow_dispatch:
permissions:
+98
View File
@@ -0,0 +1,98 @@
# What's New highlights — GitHub Models release step (reusable)
#
# Called by the release-please workflows AFTER a release is created
# (release-please.yml for `stable`, release-please-beta.yml for `main`). It runs
# as a job in the SAME workflow run rather than on its own `release: published`
# trigger, because release-please creates the release with the default
# GITHUB_TOKEN and GitHub does not start new workflow runs from token-generated
# events — a standalone `release:` workflow would simply never fire.
#
# What it does: condenses the new release's "### Features" into <=8 short
# bullets via GitHub Models (free tier, `models: read`) and injects a
# `<!-- whatsnew -->` block at the top of the release notes. The app reads that
# block (backend utils/whatsNew.parseWhatsNew) and falls back to the raw
# Features list for releases without it — so this is purely a quality upgrade,
# never a hard dependency. Failure is isolated by `continue-on-error` + the
# deterministic fallback below, so it can never break a release.
#
# GitHub Models is OPTIONAL. If it is disabled/unavailable for the org the AI
# step fails soft (continue-on-error) and the deterministic fallback produces
# the bullets instead — the feature works either way, Models just polishes them.
#
# Validated end-to-end on a fork (extract -> openai/gpt-4o-mini -> inject into
# real release notes; app parseWhatsNew() reads the block back).
name: What's New highlights
on:
workflow_call:
inputs:
tag:
description: Release tag to annotate (e.g. v2.3.0)
required: true
type: string
jobs:
highlights:
runs-on: ubuntu-latest
permissions:
contents: write # to edit the release body
models: read # GitHub Models (free tier)
# GH_REPO at job scope so every `gh` call targets the right repo without
# needing an actions/checkout step. Without this, `gh` falls back to
# parsing `.git/config` in the runner's empty workspace and dies with
# "fatal: not a git repository" — which hard-fails the whole job before
# any continue-on-error can save it.
env:
GH_REPO: ${{ github.repository }}
steps:
- name: Extract Features from the published release
id: feat
# Belt-and-braces: the job-level comment says "never let highlights
# break a release", but the original wiring only marked the AI +
# inject steps as continue-on-error. A hiccup here (rate limit,
# transient API error) would still hard-fail the job. Match the
# design intent and fail soft.
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
run: |
BODY=$(gh release view "$TAG" --json body -q .body)
FEATURES=$(printf '%s\n' "$BODY" | awk '/^#{2,4} +Features/{f=1;next} /^#{1,4} +\S/{f=0} f')
{ echo "features<<EOF"; printf '%s\n' "$FEATURES"; echo EOF; } >> "$GITHUB_OUTPUT"
- name: Summarize with GitHub Models
if: ${{ steps.feat.outputs.features != '' }}
id: ai
continue-on-error: true # Models may be disabled/unavailable for the org; fall back deterministically below
uses: actions/ai-inference@v1
with:
model: openai/gpt-4o-mini # catalog id (verified present); openai/gpt-4.1-mini or openai/gpt-5-nano also work
system-prompt: >
You write release highlights for the admins of a self-hosted
photo-gallery + CRM app. Given raw changelog "Features" lines, output
AT MOST 8 markdown bullets, each 3-4 words, user-facing, no scopes,
no jargon, no issue numbers. One bullet per distinct user-visible
feature. Output ONLY "- " bullets, nothing else.
prompt: ${{ steps.feat.outputs.features }}
- name: Inject the What's New block
if: ${{ steps.feat.outputs.features != '' }}
continue-on-error: true # never let highlights break a release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ inputs.tag }}
AI: ${{ steps.ai.outputs.response }}
FEATURES: ${{ steps.feat.outputs.features }}
run: |
BULLETS="$AI"
# Deterministic fallback if the model returned nothing (e.g. Models not yet enabled).
if [ -z "$BULLETS" ]; then
BULLETS=$(printf '%s\n' "$FEATURES" | head -8 \
| sed -E 's/^\* \*\*[^:]+:\*\* */- /; s/ \(\[[^]]*\]\([^)]*\)\)//g')
fi
BODY=$(gh release view "$TAG" --json body -q .body)
# Idempotent: strip any prior block before re-injecting.
BODY=$(printf '%s' "$BODY" | perl -0pe 's/<!--\s*whatsnew\s*-->.*?<!--\s*\/whatsnew\s*-->\n*//is')
gh release edit "$TAG" --notes "$(printf '<!-- whatsnew -->\n%s\n<!-- /whatsnew -->\n\n%s' "$BULLETS" "$BODY")"
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.71.0-beta.0"
".": "3.79.1-beta.0"
}
+170
View File
@@ -5,6 +5,176 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.79.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.0-beta.0...v3.79.1-beta.0) (2026-07-02)
### Bug Fixes
* **settings:** remove duplicate Mail import that broke the dev server ([5b535f8](https://github.com/PicPeak/picpeak/commit/5b535f86580275eda768fa2d85a8c94bd701f832))
* **settings:** remove duplicate Mail import that crashes the dev server ([4aa6583](https://github.com/PicPeak/picpeak/commit/4aa6583baef55e2c12e9cde7d391156436de518f))
## [3.79.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.78.0-beta.0...v3.79.0-beta.0) (2026-07-02)
### Features
* setup wizard + argument-driven unattended install ([681619f](https://github.com/PicPeak/picpeak/commit/681619f0a14070309342a9f908a5bbc8a57d47d8))
* **setup:** step-by-step wizard + argument-driven unattended install ([d35c413](https://github.com/PicPeak/picpeak/commit/d35c413651bc10f177a683a8057ad92c03b1cf00))
## [3.78.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.3-beta.0...v3.78.0-beta.0) (2026-07-02)
### Features
* zero-config first run — in-browser admin bootstrap + auto-generated secrets ([bafc96f](https://github.com/PicPeak/picpeak/commit/bafc96f468e3b5cca2ec3291e7b568886755099d))
### Bug Fixes
* **ci:** enable release-PR auto-merge with the PAT, not GITHUB_TOKEN ([e08a33d](https://github.com/PicPeak/picpeak/commit/e08a33d9ea273dc18877743f71f59d64bfc3dfb5))
* enable release-PR auto-merge with the PAT so releases actually publish ([97b9853](https://github.com/PicPeak/picpeak/commit/97b9853709fb59a900d70bb2a6bf365d98ae4f86))
## [3.77.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.2-beta.0...v3.77.3-beta.0) (2026-07-02)
### Bug Fixes
* set GH_REPO in release-please auto-merge step ([d00d52a](https://github.com/PicPeak/picpeak/commit/d00d52a2215dfcae34086cf3e10fe4da0aef09c9))
## [3.77.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.1-beta.0...v3.77.2-beta.0) (2026-07-02)
### Bug Fixes
* auto-publish release-please PRs without manual approval ([fb64ec0](https://github.com/PicPeak/picpeak/commit/fb64ec0910f8c3ecffb40d85e4f3a08f73503671))
* **ci:** auto-publish release-please PRs without manual approval ([#719](https://github.com/PicPeak/picpeak/issues/719)) ([a3e7232](https://github.com/PicPeak/picpeak/commit/a3e7232b8ed012b8449a76d3e4ea3c5daddd5514))
## [3.77.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.77.0-beta.0...v3.77.1-beta.0) (2026-07-02)
### Documentation
* require screenshots for UI changes in PRs ([f5b4aa7](https://github.com/PicPeak/picpeak/commit/f5b4aa7a5bc321ffbd33f1c1b92003435a7ee842))
* require screenshots for UI changes in PRs ([8ca7477](https://github.com/PicPeak/picpeak/commit/8ca74776f4d3f7be930a713afe4ac4de594adedd))
## [3.77.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.76.2-beta.0...v3.77.0-beta.0) (2026-07-01)
### Features
* admin photos list/grid toggle + upload failure report ([#707](https://github.com/PicPeak/picpeak/issues/707), [#708](https://github.com/PicPeak/picpeak/issues/708)) ([e873f7c](https://github.com/PicPeak/picpeak/commit/e873f7c98ce108b090d70a3b7df2d2929699e997))
* admin photos list/grid toggle + upload failure report ([#707](https://github.com/PicPeak/picpeak/issues/707), [#708](https://github.com/PicPeak/picpeak/issues/708)) ([6f95796](https://github.com/PicPeak/picpeak/commit/6f95796b7c19829197eaff0d4934ad9b84d0e2f3))
## [3.76.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.76.1-beta.0...v3.76.2-beta.0) (2026-06-30)
### Bug Fixes
* **ci:** whatsnew highlights — set GH_REPO so gh runs without a checkout ([3feed0f](https://github.com/PicPeak/picpeak/commit/3feed0fae6a5792a7192a529942e08d9872b7e6e))
* **ci:** whatsnew highlights — set GH_REPO so gh runs without a checkout ([2a5f0a8](https://github.com/PicPeak/picpeak/commit/2a5f0a8601ba5cb28243b39278ecdc0892388a96))
## [3.76.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.76.0-beta.0...v3.76.1-beta.0) (2026-06-30)
### Bug Fixes
* **whatsnew:** decode HTML entities and trim em-dash detail in fallback bullets ([5582644](https://github.com/PicPeak/picpeak/commit/5582644dc49330549be2a3a4cdd5b1ba0f21a294))
## [3.76.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.75.1-beta.0...v3.76.0-beta.0) (2026-06-30)
### Features
* **gallery:** branded URL shortener — /s/&lt;slug&gt; with OG injection ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a0f7033](https://github.com/PicPeak/picpeak/commit/a0f7033ffc812f92d56e2eac7bd2f498b95ef83b))
## [3.75.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.75.0-beta.0...v3.75.1-beta.0) (2026-06-30)
### Bug Fixes
* **og:** rich social previews for share-token + slideshow URLs ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([25bf7bb](https://github.com/PicPeak/picpeak/commit/25bf7bb5239420da078749bac270196df6968581))
* **og:** rich social previews for share-token + slideshow URLs ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([1b8747d](https://github.com/PicPeak/picpeak/commit/1b8747dc82763ba6b4da3a55045cab8740da2a13))
## [3.75.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.74.0-beta.0...v3.75.0-beta.0) (2026-06-30)
### Features
* **updates:** "What's New" highlights after update + pre-update teaser ([a1a73bf](https://github.com/PicPeak/picpeak/commit/a1a73bf75ff3fcd0833fdf7922a35ad09f19439b))
* **updates:** "What's New" highlights after update + pre-update teaser ([500cf85](https://github.com/PicPeak/picpeak/commit/500cf8522e556575bd74d4c71d38a83fb2596b5e))
### Documentation
* **readme:** credit [@the-luap](https://github.com/the-luap) as creator/lead maintainer ([3528f6b](https://github.com/PicPeak/picpeak/commit/3528f6b8b7e2b537b111f7787d48459a976ef744))
* **readme:** credit [@the-luap](https://github.com/the-luap) as creator/lead maintainer ([748238e](https://github.com/PicPeak/picpeak/commit/748238e8caf198e3899954804e61a2e179058957))
## [3.74.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.73.0-beta.0...v3.74.0-beta.0) (2026-06-29)
### Features
* **admin:** in-app migration banner for the org rename ([0213347](https://github.com/PicPeak/picpeak/commit/02133478bd2684c562d11cc122cf5059832ff76a))
* **admin:** in-app migration banner for the org rename ([#669](https://github.com/PicPeak/picpeak/issues/669)) ([2a4bf3b](https://github.com/PicPeak/picpeak/commit/2a4bf3b868c6733d0b865c8c0e977ba84d6e6453))
### Documentation
* branch model + migration-to-org guide + PR template ([166ef47](https://github.com/PicPeak/picpeak/commit/166ef47611a248c4d517e26d390d87d21f077ca1))
* branch model + migration-to-org guide + PR-template target hint ([d606fcd](https://github.com/PicPeak/picpeak/commit/d606fcd5a425fed3c968ec06b071a386bf558c28))
* prominent migration banner at the top of README ([14bd3e1](https://github.com/PicPeak/picpeak/commit/14bd3e1a6c6cf74378d6f316024584d8941cbcd5))
* prominent migration banner at the top of README ([#669](https://github.com/PicPeak/picpeak/issues/669)) ([5839bba](https://github.com/PicPeak/picpeak/commit/5839bba72a56cc29077f63f7daa038995fb09dfb))
## [3.73.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.72.0-beta.0...v3.73.0-beta.0) (2026-06-29)
### Features
* **dashboard:** revenue "year" tile toggles 365 days ↔ calendar YTD ([d1c9e02](https://github.com/the-luap/picpeak/commit/d1c9e02bcf50b6c08eebc85acdbfba29bfee84ac))
* **invoices:** surface monthly/manual accumulator drafts in the Bills list ([e457656](https://github.com/the-luap/picpeak/commit/e457656b9d06bb420c9d0985fe15c30d6c88aed9))
### Bug Fixes
* **invoices:** add bank transfer to the mark-paid method list ([e96ef4c](https://github.com/the-luap/picpeak/commit/e96ef4c5a35bc9e575bc3419fb318a3ee9df1bd6))
* **invoices:** badge held (unsent, no send date) invoices as "Draft" ([e4367e0](https://github.com/the-luap/picpeak/commit/e4367e028a5228ef50c4bbd522d0777bc7340b52))
* **invoices:** show "Draft" on the invoice detail page for accumulator drafts ([ca09442](https://github.com/the-luap/picpeak/commit/ca0944293f66b6465a577340e63d592598915092))
* **reminders:** wrap is_active/is_archived wheres in formatBoolean ([b9d9138](https://github.com/the-luap/picpeak/commit/b9d91385b43de7ede508884f7cf78b5cf785f853))
## [3.72.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.3-beta.0...v3.72.0-beta.0) (2026-06-28)
### Features
* **workflows:** booking cutover — wire booking actions + hold documents behind approval gates ([ec33ec7](https://github.com/the-luap/picpeak/commit/ec33ec7670a4feb1108d1bcbfe34727f63cc8cf9))
### Bug Fixes
* **workflows:** defer quote.accepted/declined emit until the 15-min response window locks ([539a837](https://github.com/the-luap/picpeak/commit/539a83711d1996dc9c262365f2c511e7bc445add))
* **workflows:** make the dashboard pending-approvals card items clickable too ([6e20d58](https://github.com/the-luap/picpeak/commit/6e20d58487c5e20b08e1d1b4ddd4e76f9e922a79))
## [3.71.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.2-beta.0...v3.71.3-beta.0) (2026-06-27)
### Bug Fixes
* **events:** wire customer notifications into both public API entry points ([#647](https://github.com/the-luap/picpeak/issues/647)) ([f017542](https://github.com/the-luap/picpeak/commit/f01754247cdb94c5935ad5abbda116841f6c7fba))
## [3.71.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.1-beta.0...v3.71.2-beta.0) (2026-06-27)
### Bug Fixes
* event-reminder, email-language & gallery-publish bugs surfaced during workflow testing ([c8714ca](https://github.com/the-luap/picpeak/commit/c8714ca42f4d82d50fe611b2a630260ebecbe740))
## [3.71.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.71.0-beta.0...v3.71.1-beta.0) (2026-06-26)
### Bug Fixes
* **admin:** stack publish-gallery dialog CTAs so the German label fits ([#670](https://github.com/the-luap/picpeak/issues/670)) ([748af98](https://github.com/the-luap/picpeak/commit/748af98f3d3f8c00695b82e94d741a0e10a39a81))
## [3.71.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.70.0-beta.0...v3.71.0-beta.0) (2026-06-25)
+1 -1
View File
@@ -20,7 +20,7 @@ We are committed to providing a welcoming and inspiring community for all photog
## Enforcement
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/the-luap/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/PicPeak/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
## Attribution
+33 -9
View File
@@ -33,12 +33,12 @@ Enhancement suggestions are tracked as GitHub issues. When creating an enhanceme
Unsure where to begin? You can start by looking through these issues:
* [Good first issues](https://github.com/the-luap/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/the-luap/picpeak/labels/help%20wanted) - issues which need extra attention
* [Good first issues](https://github.com/PicPeak/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/PicPeak/picpeak/labels/help%20wanted) - issues which need extra attention
### Pull Requests
1. **Fork the repo** and create your branch from `beta`
1. **Fork the repo** and create your branch from `main` (active development)
2. **Install dependencies**:
```bash
cd backend && npm install
@@ -50,7 +50,10 @@ Unsure where to begin? You can start by looking through these issues:
- Linting passes: `npm run lint`
4. **Write tests** if you've added code
5. **Update documentation** if needed
6. **Create a Pull Request**
6. **Attach a screenshot for any UI change** (see below)
7. **Create a Pull Request**
> **📸 Screenshots are required for UI changes.** Any PR that changes a user-facing surface — a component, page, layout, style, or in-app copy — must include at least one screenshot of the result in the PR description, showing before/after where it helps reviewers see the difference. PRs that touch the UI without a screenshot will be asked to add one before review. Backend-only or otherwise non-visual changes don't need one.
## 💻 Development Setup
@@ -153,16 +156,37 @@ picpeak/
│ └── public/ # Static assets
```
## 🌿 Branch model
PicPeak runs on two long-lived branches:
| Branch | Role | What targets it |
|---|---|---|
| **`main`** | Active development. The next release is being assembled here. | Feature PRs. Most bugfix PRs. |
| **`stable`** | Curated release channel. Production-recommended. | Urgent bugfix backports only — small, surgical PRs that land cleanly without dragging in unrelated changes. |
### Which branch should my PR target?
- **New feature** → target `main`.
- **Bugfix that ONLY affects active dev** → target `main`.
- **Bugfix that current stable users need** → open a small PR against `main`, AND a separate small PR against `stable` with the same change. Keep both surgical so each lands cleanly.
**Hard rule on PR scope**: bugfix PRs against `stable` must be small enough to backport without conflict. Omnibus PRs (e.g. five unrelated sub-features) are fine for `main`, but never for `stable` — they make the next `main → stable` merge painful and break the "stable is always shippable" invariant.
If you're not sure which branch to target, default to `main` and a maintainer will retarget during review.
## 🔄 Release Process
Releases are cut from the `beta` branch (rolling beta) and promoted to `main` (stable) on a 46 week cadence. `release-please` handles version bumps, changelog generation, and Docker image publication automatically — contributors don't update `package.json` or `CHANGELOG.md` by hand.
Releases are cut independently from `main` (pre-release versions for the active channel) and `stable` (semver releases for the curated channel). `release-please` handles version bumps, changelog generation, and Docker image publication automatically — contributors don't update `package.json` or `CHANGELOG.md` by hand.
See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteria, conflict-resolution checklist for the beta→main merge, hotfix backport path, versioning rules).
Periodic `main → stable` merges promote a batch of `main` work to the stable channel. The maintainer chooses when (typically every ~4 weeks, sooner if a hot bug demands it).
See [RELEASING.md](RELEASING.md) for the full operational doc (promotion criteria, conflict-resolution checklist for the `main → stable` merge, hotfix backport path, versioning rules).
## 📮 Contact
- Create an [issue](https://github.com/the-luap/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/the-luap/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- Create an [issue](https://github.com/PicPeak/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/PicPeak/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
Thank you for contributing! 🎉
+46 -18
View File
@@ -1,5 +1,13 @@
# 📸 PicPeak - Open Source Photo Sharing for Events
> [!IMPORTANT]
> **PicPeak has moved to its own GitHub organization.**
>
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
>
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
@@ -83,21 +91,34 @@ Get PicPeak running in under 5 minutes:
```bash
# Clone the repository
git clone https://github.com/the-luap/picpeak.git
git clone https://github.com/PicPeak/picpeak.git
cd picpeak
# Copy environment template
# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser (see below). Edit .env only to
# customise (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env
# Edit configuration (required: JWT_SECRET)
nano .env
# Start with Docker Compose
docker compose up -d
# Access at http://localhost:3000
```
### First run — create your admin account
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
Note on Docker file permissions
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
@@ -370,17 +391,23 @@ See our [Contributing Guide](CONTRIBUTING.md) for details.
## 📊 Comparison with Alternatives
| Feature | PicPeak | PicDrop | Scrapbook.de |
|---------|---------|---------|--------------|
| Self-Hosted | ✅ | ❌ | ❌ |
| Custom Branding | ✅ Full | Limited | Limited |
| Monthly Cost | $0 | $29-199 | €19-99 |
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
| Client Uploads | ✅ | ✅ | ✅ |
| API Access | ✅ | Paid | ❌ |
| Open Source | ✅ | ❌ | ❌ |
| Feature | PicPeak | PicDrop | Scrapbook.de | Pixieset |
|---------|---------|---------|--------------|----------|
| Self-Hosted | ✅ | ❌ | ❌ | ❌ |
| Custom Branding | ✅ Full | Limited | Limited | ✅ (paid) |
| Monthly Cost | $0* | $29-199 | €19-99 | ~$60 |
| Storage Limit | Unlimited** | 50-500GB | 100-1000GB | 3GBUnlimited*** |
| Client Uploads | ✅ | ✅ | ✅ | Limited |
| API Access | ✅ | Paid | ❌ | ❌ |
| Open Source | ✅ | ❌ | ❌ | ❌ |
| Customer Accounts | ✅ | ❌ | ❌ | ✅ |
| Quotes / Contracts / Invoices | 🧪 Beta | ❌ | ❌ | ✅ |
| Incoming Invoices & Accounting | 🧪 Beta | ❌ | ❌ | ❌ |
*Limited only by your server storage
*You still bring your own server (own hardware or a VPS) and, if you want one, a domain.
**Limited only by your server storage.
***Pixieset's "unlimited" is photos only; video is capped by plan (roughly 010 h depending on tier).
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
## 🛡️ Security
@@ -392,7 +419,7 @@ PicPeak takes security seriously:
- 📝 Activity logging
- 🔒 Secure file access
Found a security issue? Please open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
## 📸 Screenshots
@@ -476,6 +503,7 @@ PicPeak is inspired by the best features of commercial platforms while remaining
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
@@ -537,7 +565,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
<br>
<a href="https://www.picpeak.app">Homepage</a> •
<a href="https://demo.picpeak.app">Live Demo</a> •
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
<a href="https://github.com/PicPeak/picpeak">GitHub</a> •
<a href="https://docs.picpeak.app">Documentation</a> •
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
</p>
+34 -32
View File
@@ -1,74 +1,76 @@
# Release Process
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs beta) see the [Release Channels section in README.md](README.md#-release-channels).
This document describes how PicPeak releases are cut. It's the maintainer's reference, not user documentation — for the user-facing channel choice (stable vs pre-release) see the [Release Channels section in README.md](README.md#-release-channels).
## TL;DR
- **`beta` branch** receives all merged work. Every push triggers a `release-please` PR that proposes the next `vX.Y.Z-beta.N` release. Merging that PR tags the beta and publishes Docker images on the `beta` tag.
- **`main` branch** holds the stable channel. Stable releases are cut from a known-good `beta` point via a `release/X.Y.Z-merge-from-beta` branch and a manual PR to `main`. Merging that PR triggers `release-please` to propose the stable release.
- Target cadence: **a stable release every 46 weeks**, or sooner if a beta has been quiet and ready for promotion.
- **`main` branch** receives all merged work (active development). Every push triggers a `release-please` PR that proposes the next `vX.Y.Z-beta.N` pre-release. Merging that PR tags the pre-release and publishes Docker images under the `:main` rolling tag + the version-specific tag.
- **`stable` branch** holds the curated stable channel. Stable releases are cut from a known-good `main` point via a `release/X.Y.Z-merge-from-main` branch and a manual PR to `stable`. Merging that PR triggers `release-please` to propose the stable release.
- Target cadence: **a stable release every 46 weeks**, or sooner if `main` has been quiet and ready for promotion.
> **Branch model background** — `main` (active dev) was previously called `beta`, and `stable` (curated channel) was previously called `main`. The rename happened with #669 to match the convention every other open-source project uses. The mechanics below all reference the post-rename names.
## Cadence target
46 weeks between stable releases is the working target. Reasoning:
- Long enough that each stable carries meaningful changes worth the upgrade burden.
- Short enough that beta users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
- Aligns with how release-please surfaces beta releases (multiple beta points usually accumulate inside a 46 week window, which gives natural promotion candidates).
- Short enough that pre-release users aren't carrying the "real" project alone for months — the stable channel should actually be usable as the recommended channel for new installs.
- Aligns with how release-please surfaces pre-releases (multiple pre-release points usually accumulate inside a 46 week window, which gives natural promotion candidates).
This is a target, not a hard rule. Cut sooner if a beta has been quiet and stable longer than usual. Cut later if a beta is in flux for security or migration reasons.
This is a target, not a hard rule. Cut sooner if `main` has been quiet and stable longer than usual. Cut later if `main` is in flux for security or migration reasons.
## Promotion criteria
A beta is eligible for promotion to stable when **all** of the following hold:
A `main` tip is eligible for promotion to `stable` when **all** of the following hold:
1. **CI green on the candidate beta tip.** Specifically: `schema-drift` (`upgrade-from-bootstrap`), `fresh-install`, `Tests` (backend Jest + frontend Vitest), the four `Build and Push Docker Images` arch matrices, and `GitGuardian Security Checks`.
2. **No open `bug`-labelled issues against the candidate beta for at least 7 days.** Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidate beta before closing them out.
1. **CI green on the candidate `main` tip.** Specifically: `schema-drift` (`upgrade-from-bootstrap`), `fresh-install`, `Tests` (backend Jest + frontend Vitest), the four `Build and Push Docker Images` arch matrices, and `GitGuardian Security Checks`.
2. **No open `bug`-labelled issues against the candidate for at least 7 days.** Issues fixed-but-not-yet-closed count as fixed; verify their PR is in the candidate `main` tip before closing them out.
3. **An upgrade walk has been done on real production-shaped data** — apply the candidate's migration chain to a snapshot of the previous stable's DB and verify no manual intervention is required. CI proves fresh-install works; the upgrade walk is what proves the upgrade path works.
4. **Operator-time smoke** on the candidate: log in, create event, upload photos, share gallery, open as a customer, log out. Catches binary-incompatibility regressions and UI-level breaks that unit tests don't see.
If any of the four fail, the promotion waits. File any blockers as `bug`-labelled issues and let them bake on beta before re-evaluating.
If any of the four fail, the promotion waits. File any blockers as `bug`-labelled issues and let them bake on `main` before re-evaluating.
## How a stable release is cut
The actual mechanics, in order:
1. **Pick the beta tip.** Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting.
1. **Pick the `main` tip.** Confirm it satisfies the four promotion criteria above. Note the exact SHA — that's what you're promoting.
2. **Create the release branch from the beta tip.**
2. **Create the release branch from the `main` tip.**
```bash
git push origin <beta-tip-sha>:refs/heads/release/X.Y.Z-merge-from-beta
git push origin <main-tip-sha>:refs/heads/release/X.Y.Z-merge-from-main
```
Naming convention: `release/X.Y.Z-merge-from-beta`, where `X.Y.Z` is the stable version you intend to land. release-please will write the actual `X.Y.Z` on merge — the branch name is just a human label.
Naming convention: `release/X.Y.Z-merge-from-main`, where `X.Y.Z` is the stable version you intend to land. release-please will write the actual `X.Y.Z` on merge — the branch name is just a human label.
3. **Open a PR to `main`.** Title: `chore(release): promote beta → main as vX.Y.Z`. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example.
3. **Open a PR to `stable`.** Title: `chore(release): promote main → stable as vX.Y.Z`. Body should summarise the major themes since the previous stable, the migration count, and any operator notes (e.g. "this release adds 22 migrations; existing installs should snapshot before upgrading"). See PR #568 as a worked example (predates the rename; the mechanics are unchanged).
4. **Resolve conflicts.** Main almost always has commits beta doesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:
- **`backend/package.json` / `package-lock.json` + `frontend/package.json` / `package-lock.json`** — usually take beta's version (superset), but verify any security-pinned deps (`axios`, `nodemailer`, `i18next-http-backend`, `multer`, `tar`) on beta are `>=` the pinned versions on main. If main has a newer pinned version (e.g. an emergency CVE backport beta hasn't picked up), take main's pin.
- **`README.md`** — keep main's version if main has had a recent rewrite that beta didn't pick up; otherwise take beta's.
- **`CHANGELOG.md`** — keep main's; release-please regenerates entries on its next stable cut from the commits going forward.
- **`.release-please-manifest.json`** — keep main's; release-please owns this file.
4. **Resolve conflicts.** `stable` almost always has commits `main` doesn't (security backports, release-please's stable-channel release commits, README rewrites). For each conflicting file, decide deliberately:
- **`backend/package.json` / `package-lock.json` + `frontend/package.json` / `package-lock.json`** — usually take `main`'s version (superset), but verify any security-pinned deps (`axios`, `nodemailer`, `i18next-http-backend`, `multer`, `tar`) on `main` are `>=` the pinned versions on `stable`. If `stable` has a newer pinned version (e.g. an emergency CVE backport `main` hasn't picked up), take `stable`'s pin.
- **`README.md`** — keep `stable`'s version if it has had a recent rewrite that `main` didn't pick up; otherwise take `main`'s.
- **`CHANGELOG.md`** — keep `stable`'s; release-please regenerates entries on its next stable cut from the commits going forward.
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on beta — beta has already moved on).
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into main's log.
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(main): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `stable` and `latest` tags, and creates the GitHub Release page.
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
## Hotfix path (backport to current stable)
If a critical bug or security issue affects the current stable and beta has moved too far for a full promotion to be appropriate, backport just the fix:
If a critical bug or security issue affects the current stable and `main` has moved too far for a full promotion to be appropriate, backport just the fix:
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `main`.
1. Create a `security/cve-backport-X.Y.Z` or `fix/critical-X.Y.Z` branch off `stable`.
2. Cherry-pick or hand-write the minimal fix.
3. Open a PR to `main` with the smallest possible diff.
3. Open a PR to `stable` with the smallest possible diff.
4. After merge, release-please will propose a patch-level stable release (e.g. `v3.55.1`).
5. **Forward-port the fix to beta** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
5. **Forward-port the fix to `main`** if it isn't already there. Otherwise the next full promotion will reintroduce the bug.
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path.
PR #412 ("backport 18 dependency CVE patches from beta") is a worked example of this path (predates the rename; the mechanics are unchanged).
## Versioning
@@ -77,15 +79,15 @@ PicPeak follows [Semantic Versioning](https://semver.org/) with one project-spec
- **MAJOR** bumps are reserved for breaking schema changes that require operator action on upgrade (e.g. a migration that's not safe to auto-apply, an env-var rename that can't be auto-detected).
- **MINOR** bumps for new features, additive schema changes, and any change to the public HTTP API surface.
- **PATCH** bumps for bug fixes and operator-invisible internal changes.
- **Beta suffix** (`-beta.N`) for every beta cut; the `N` counter resets on each new MINOR or MAJOR target.
- **Pre-release suffix** (`-beta.N`) for every `main`-channel cut; the `N` counter resets on each new MINOR or MAJOR target. The suffix kept the historical `-beta` literal even after the branch rename — operators were already pinning to `v3.x.y-beta.N` and changing the literal would have broken those pins.
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
## Things that don't go through this process
- **Documentation-only changes** can land on either `main` or `beta` directly (no release cut needed); release-please will pick them up on the next regular release.
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
- **Test-only changes** — same.
- **CI / workflow changes** — same, but be aware they take effect on the branch they land on, so a CI fix targeting beta won't fix a broken stable-channel workflow until the next promotion.
- **CI / workflow changes** — same, but be aware they take effect on the branch they land on, so a CI fix targeting `main` won't fix a broken stable-channel workflow until the next promotion.
## When this doc is wrong
+3 -3
View File
@@ -16,7 +16,7 @@ We take the security of PicPeak seriously. If you have discovered a security vul
### 1. **Do NOT create a public GitHub issue**
### 2. Report the vulnerability privately by:
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- **Alternative:** Email us at **info@picpeak.app** with the details
- Include:
- Description of the vulnerability
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
## Contact
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/PicPeak/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
Thank you for helping keep PicPeak and its users safe!
+16 -3
View File
@@ -8,7 +8,7 @@ This guide provides easy installation instructions for PicPeak on Linux servers
```bash
# Download and run the unified setup script
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
curl -fsSL https://raw.githubusercontent.com/PicPeak/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
@@ -163,6 +163,19 @@ sudo ./picpeak-setup.sh --native --unattended \
- `picpeak-workers` - Background workers
- `caddy` - Web server (optional)
## 🔑 First Login — Create Your Admin
If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your admin account already exists — log in at `/admin` with that email and password.
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
```bash
docker compose logs backend | grep -i "setup token"
```
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods
### Direct Access (Simplest)
@@ -472,7 +485,7 @@ sudo -u picpeak node scripts/reset-admin-password.js
- [Deployment Guide](https://docs.picpeak.app/deployment)
3. **Support:**
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- [GitHub Issues](https://github.com/PicPeak/picpeak/issues)
- Include: Error messages, system info (`uname -a`), installation method
## 🔒 Security Best Practices
@@ -550,4 +563,4 @@ sudo ./picpeak-setup.sh --native \
---
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
**PicPeak Setup v1.0** | [Documentation](https://github.com/PicPeak/picpeak) | [Support](https://github.com/PicPeak/picpeak/issues)
+9 -4
View File
@@ -7,7 +7,7 @@ ARG VCS_REF
ARG VERSION
# Add labels for GitHub Container Registry
LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
LABEL org.opencontainers.image.source="https://github.com/PicPeak/picpeak"
LABEL org.opencontainers.image.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT"
@@ -30,9 +30,14 @@ WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
RUN npm install -g npm@10
# Upgrade the npm CLI in the final image so its bundled deps are patched
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
# dependencies come from the builder stage (COPY --from=builder node_modules
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
# Node >=22.9, satisfied by node:22-alpine.
RUN npm install -g npm@11
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
@@ -0,0 +1,143 @@
/**
* Smoke tests for backupService's config resolution + file-collection
* and manifest validation paths — safety net ahead of the god-file
* decomposition.
*
* Uses the same real-SQLite harness as
* backupService.configurableWalker.test.js (bootCrmDb + a temp
* STORAGE_PATH) rather than the broken deep-mock approach in
* backupService.enhanced.test.js.
*/
const fs = require('fs');
const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('backupService — config + file collection + manifest (smoke)', () => {
let db;
let cleanup;
let storagePath;
let backupService;
let backupManifest;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
storagePath = process.env.STORAGE_PATH;
backupService = require('../../src/services/backupService');
backupManifest = require('../../src/services/backupManifest');
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('app_settings').del();
// Reset the storage tree so each test starts from a pristine walk.
await fs.promises.rm(storagePath, { recursive: true, force: true });
await fs.promises.mkdir(storagePath, { recursive: true });
});
function seedFile(relPath, content = 'dummy bytes') {
const abs = path.join(storagePath, relPath);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
return abs;
}
async function insertBackupSetting(key, value) {
await db('app_settings').insert({
setting_key: key,
setting_value: value,
setting_type: 'backup',
});
}
describe('getBackupConfig', () => {
it('parses booleans, numbers, JSON arrays and plain strings from app_settings', async () => {
await insertBackupSetting('backup_enabled', 'true');
await insertBackupSetting('backup_include_archived', 'false');
await insertBackupSetting('backup_retention_days', '30');
await insertBackupSetting('backup_destination_path', '/backups/picpeak');
await insertBackupSetting('backup_email_recipients', '["a@example.com","b@example.com"]');
// Non-backup settings must not leak into the backup config.
await db('app_settings').insert({
setting_key: 'general_site_name',
setting_value: 'PicPeak',
setting_type: 'general',
});
const config = await backupService.getBackupConfig();
expect(config.backup_enabled).toBe(true);
expect(config.backup_include_archived).toBe(false);
expect(config.backup_retention_days).toBe(30);
expect(config.backup_destination_path).toBe('/backups/picpeak');
expect(config.backup_email_recipients).toEqual(['a@example.com', 'b@example.com']);
expect(config).not.toHaveProperty('general_site_name');
// Raw (unparsed) values are preserved on the non-enumerable __raw.
expect(String(config.__raw.backup_retention_days)).toBe('30');
});
it('returns an empty config object (not null) when nothing is configured', async () => {
const config = await backupService.getBackupConfig();
expect(config).not.toBeNull();
expect(Object.keys(config)).toHaveLength(0);
});
});
describe('getFilesToBackup', () => {
it('returns an empty list on a pristine storage tree', async () => {
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
expect(files).toEqual([]);
});
it('captures path/relativePath/size/modified metadata for backed-up files', async () => {
const content = 'not really a jpeg';
const abs = seedFile('events/active/E9/pic.jpg', content);
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const entry = files.find((f) => f.relativePath === path.join('events/active/E9', 'pic.jpg'));
expect(entry).toBeDefined();
expect(entry.path).toBe(abs);
expect(entry.size).toBe(Buffer.byteLength(content));
// Not toBeInstanceOf(Date) — fs.stat mtime comes from a different
// realm under Jest and fails the cross-realm instanceof check.
expect(Object.prototype.toString.call(entry.modified)).toBe('[object Date]');
});
});
describe('validateBackupManifest', () => {
it('round-trips a generated manifest as valid', async () => {
seedFile('events/active/E1/a.jpg', 'aaa');
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
const manifest = await backupManifest.generateManifest({
backupType: 'full',
backupPath: '/backup/run-1',
files,
});
const manifestPath = path.join(storagePath, 'manifest-smoke.json');
await backupManifest.saveManifest(manifest, manifestPath, 'json');
const result = await backupService.validateBackupManifest(manifestPath);
expect(result.valid).toBe(true);
expect(result.manifest.backup.type).toBe('full');
expect(result.manifest.files.count).toBe(files.length);
expect(result.manifest.verification.total_checksum).toBeTruthy();
});
it('flags a manifest missing required sections as invalid', async () => {
const badPath = path.join(storagePath, 'manifest-broken.json');
fs.writeFileSync(badPath, JSON.stringify({ manifest: { version: '2.0' } }));
const result = await backupService.validateBackupManifest(badPath);
expect(result.valid).toBe(false);
expect(result.error).toMatch(/Missing required section/);
});
});
});
@@ -0,0 +1,170 @@
/**
* Booking cutover — prepare_invoice's draft seam. convertToInvoiceOnly({draft})
* must create the invoice(s) but leave scheduled_send_at NULL so the scheduler
* never auto-sends them before the workflow's review gate + explicit
* send_document.
*/
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(30000);
describe('booking cutover — draft invoices on hold', () => {
let db; let cleanup; let adminId; let customerId; let quoteService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId, customerId } = await seedMinimal(db));
quoteService = require('../../src/services/quoteService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function acceptedQuote() {
const dealUuid = crypto.randomUUID();
const [id] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF',
issue_date: '2026-01-01',
net_amount_minor: 100000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 100000,
// A non-delivery installment so the contrast (scheduled date vs null) is meaningful.
payment_term_snapshot: JSON.stringify({ installments: [{ percent: 100, trigger: 'quote_accepted', offset_days: 0, label: 'Total' }], net_days: 30 }),
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
return id;
}
it('draft mode creates the invoice with scheduled_send_at = NULL (held), and returns its id', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
expect(Array.isArray(res.invoiceIds)).toBe(true);
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled'); // editable + sendInvoice can issue it
expect(inv.scheduled_send_at == null).toBe(true); // held — scheduler won't auto-send
});
it('without draft, the same installment IS scheduled (scheduled_send_at set)', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId);
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled');
expect(inv.scheduled_send_at == null).toBe(false); // normal convert → auto-send date set
});
it('prepare_event path (convertToEvent hold) creates a DRAFT event with held invoices', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true });
expect(res.eventId).toBeGreaterThanOrEqual(1);
expect(Array.isArray(res.invoiceIds)).toBe(true);
expect(res.invoiceIds.length).toBeGreaterThanOrEqual(1);
const ev = await db('events').where({ id: res.eventId }).first();
expect(ev.is_draft == true || ev.is_draft === 1).toBe(true); // created as a draft gallery
// Every invoice the event scheduled is held (no auto-send before the gate).
const invs = await db('invoices').whereIn('id', res.invoiceIds);
for (const inv of invs) expect(inv.scheduled_send_at == null).toBe(true);
// Quote is now linked to the event — convertToInvoiceOnly must NOT be called
// again for it (the flow's prepare_invoice adopts these ids instead).
const q = await db('quotes').where({ id: quoteId }).first();
expect(q.converted_event_id).toBe(res.eventId);
});
it('draft mode with the DEFAULT (after_delivery) payment term yields a SENDABLE scheduled invoice, not pending_delivery', async () => {
// Reproduces the booking_invoice_only flow on a quote with no explicit
// payment timing: the default installment is after_delivery, which would
// otherwise be pending_delivery — a status sendInvoice (send_document) rejects.
const dealUuid = crypto.randomUUID();
const [quoteId] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF',
issue_date: '2026-01-01',
net_amount_minor: 50000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 50000,
// No payment_term_snapshot → spawnInstallmentInvoices falls back to a single
// 100% after_delivery installment.
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
const res = await quoteService.convertToInvoiceOnly(quoteId, adminId, { draft: true });
const inv = await db('invoices').where({ id: res.invoiceIds[0] }).first();
expect(inv.status).toBe('scheduled'); // sendInvoice accepts this
expect(inv.scheduled_send_at == null).toBe(true); // still held — no auto-send
});
it('finalizeQuoteResponses only fires once the 15-min response window has locked', async () => {
const mk = async (lockOffsetMs) => {
const dealUuid = crypto.randomUUID();
const [id] = await db('quotes').insert({
quote_number: `Q-${dealUuid.slice(0, 8)}`,
customer_account_id: customerId,
status: 'accepted',
currency: 'CHF', issue_date: '2026-01-01',
net_amount_minor: 1000, vat_amount_minor: 0, shipping_amount_minor: 0, total_amount_minor: 1000,
responded_at: new Date().toISOString(),
response_locked_at: new Date(Date.now() + lockOffsetMs).toISOString(),
accepted_at: new Date().toISOString(),
deal_uuid: dealUuid,
created_by_admin_id: adminId,
});
return id;
};
const openId = await mk(15 * 60 * 1000); // still inside the window
const lockedId = await mk(-60 * 1000); // window already closed
const emitted = await quoteService.finalizeQuoteResponses();
expect(emitted).toBeGreaterThanOrEqual(1);
const open = await db('quotes').where({ id: openId }).first();
const locked = await db('quotes').where({ id: lockedId }).first();
expect(open.workflow_response_emitted_at == null).toBe(true); // deferred — not yet fired
expect(locked.workflow_response_emitted_at == null).toBe(false); // fired + stamped
// Idempotent: a second sweep doesn't re-fire the already-stamped one.
const again = await db('quotes').where({ id: lockedId })
.whereNull('workflow_response_emitted_at').update({ workflow_response_emitted_at: new Date() });
expect(again).toBe(0);
});
it('reserve_date path (convertToEvent skipInvoices) creates a draft event with NO invoices', async () => {
const quoteId = await acceptedQuote();
const res = await quoteService.convertToEvent(quoteId, adminId, { hold: true, skipInvoices: true });
expect(res.eventId).toBeGreaterThanOrEqual(1);
expect(res.invoiceIds).toEqual([]);
const invCount = await db('invoices').where({ event_id: res.eventId }).count({ c: '*' }).first();
expect(Number(invCount.c)).toBe(0); // pure date hold — no money documents
});
it('prepare_quote path (duplicateQuote) creates a new DRAFT quote — no in-trx deadlock', async () => {
const quoteId = await acceptedQuote();
const newId = await quoteService.duplicateQuote(quoteId, adminId);
expect(newId).toBeGreaterThanOrEqual(1);
expect(newId).not.toBe(quoteId);
const q = await db('quotes').where({ id: newId }).first();
expect(q.status).toBe('draft');
});
it('registers prepare_gallery / reserve_date / prepare_quote as real actions', () => {
const { registry } = require('../../src/services/workflows'); // loads actions.js (side-effect registration)
for (const a of ['prepare_gallery', 'reserve_date', 'prepare_quote', 'prepare_event', 'prepare_invoice', 'send_document']) {
expect(typeof registry.getAction(a)).toBe('function');
}
});
it('prepare_contract path (createFromQuote) completes under SQLite — no in-trx deadlock', async () => {
const contractService = require('../../src/services/contractService');
const quoteId = await acceptedQuote();
const res = await contractService.createFromQuote(quoteId, adminId);
expect(res.contractId).toBeGreaterThanOrEqual(1);
expect(res.alreadyConverted).toBe(false);
const c = await db('contracts').where({ id: res.contractId }).first();
expect(c).toBeTruthy();
});
});
@@ -0,0 +1,231 @@
/**
* HTTP-level tests for the `/s/:shortSlug` public resolver (#699).
*
* Verifies the contract the public route is expected to honour:
* - Browser UA → 302 to target_path
* - Social crawler UA → 200 with OG <meta>, canonical = /s/<slug>
* - Soft-deleted slug → 410 Gone (intentional-delete signal)
* - Unknown slug → 404 Not Found
* - Hit count increments after successful resolutions (both shapes)
*
* Mirrors the production server.js wiring but doesn't load the whole
* server — the surrounding middleware (CORS, helmet, rate limiters)
* isn't part of this route's contract.
*/
const express = require('express');
const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db; let cleanup; let service; let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Persist a business_profile + business_name so buildOgMetadata's
// settings-based fields populate consistently.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('branding_company_name', JSON.stringify('Test Studio'), 'string');
service = require('../../src/services/galleryShortUrlService');
const {
isSocialCrawler, buildOgMetadata, renderOgHtml,
} = require('../../src/services/galleryOgService');
app = express();
app.get('/s/:shortSlug', async (req, res) => {
try {
const row = await service.findByShortSlug(req.params.shortSlug);
if (!row) return res.status(404).type('text/plain').send('Short URL not found');
if (row.deleted_at) return res.status(410).type('text/plain').send('Short URL has been removed');
if (isSocialCrawler(req.get('user-agent'))) {
const event = await db('events').where({ id: row.event_id }).first('slug');
if (event?.slug) {
const meta = await buildOgMetadata(event.slug, req.originalUrl);
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
meta.url = `${base}/s/${row.short_slug}`;
res.set('Cache-Control', 'public, max-age=300');
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(renderOgHtml(meta));
service.recordHit(row.id).catch(() => {});
return;
}
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
}
service.recordHit(row.id).catch(() => {});
return res.redirect(302, row.target_path);
} catch (err) {
return res.status(500).type('text/plain').send(err.message);
}
});
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function seedEventAndShortUrl({ slug = `evt-${Date.now()}`, shortSlug }) {
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [eventId] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: 'Test Event',
event_date: '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const row = await service.createShortUrl({
eventId, customSlug: shortSlug,
});
return { eventId, shortUrl: row };
}
// User-agent strings the production `isSocialCrawler` helper matches.
// Snapshot known-true samples here so the test stays in sync if the
// helper's allowlist evolves.
const BOT_UA_WHATSAPP = 'WhatsApp/2.23.20.0';
const BOT_UA_FACEBOOK = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)';
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
describe('GET /s/:shortSlug — browser (302 redirect)', () => {
it('redirects to the snapshotted target_path with a 302', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'browser-redirect', shortSlug: 'go-here',
});
const res = await request(app)
.get('/s/go-here')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(302);
expect(res.headers.location).toBe(shortUrl.target_path);
expect(res.headers.location).toMatch(/^\/gallery\//);
});
it('increments hit_count on a browser hit (fire-and-forget — wait briefly)', async () => {
await seedEventAndShortUrl({
slug: 'hit-browser', shortSlug: 'hit-from-browser',
});
await request(app).get('/s/hit-from-browser').set('User-Agent', BROWSER_UA);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-browser');
expect(row.hit_count).toBe(1);
expect(row.last_hit_at).toBeTruthy();
});
});
describe('GET /s/:shortSlug — social crawler (OG metadata)', () => {
it('returns 200 with OG HTML for WhatsApp UA', async () => {
await seedEventAndShortUrl({
slug: 'whatsapp-og', shortSlug: 'wa-preview',
});
const res = await request(app)
.get('/s/wa-preview')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/html/);
expect(res.text).toContain('<meta');
expect(res.text).toMatch(/og:title/);
expect(res.text).toMatch(/og:url/);
});
it('og:url canonical points at /s/<slug>, not the underlying gallery URL', async () => {
await seedEventAndShortUrl({
slug: 'canonical-test', shortSlug: 'canonical-short',
});
const res = await request(app)
.get('/s/canonical-short')
.set('User-Agent', BOT_UA_FACEBOOK);
expect(res.status).toBe(200);
// The og:url meta tag must contain the short-URL path, not the
// /gallery/<slug> path — this is the cache-key invariant from #699.
expect(res.text).toMatch(/property="og:url"\s+content="[^"]*\/s\/canonical-short"/);
expect(res.text).not.toMatch(
/property="og:url"\s+content="[^"]*\/gallery\/canonical-test"/
);
});
it('sets a short cache header so scrapers can re-fetch when admin rotates the preview', async () => {
await seedEventAndShortUrl({
slug: 'cache-header', shortSlug: 'cache-test',
});
const res = await request(app)
.get('/s/cache-test')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.headers['cache-control']).toMatch(/public/);
expect(res.headers['cache-control']).toMatch(/max-age=300/);
});
it('increments hit_count on a crawler hit as well', async () => {
await seedEventAndShortUrl({
slug: 'hit-bot', shortSlug: 'hit-from-bot',
});
await request(app).get('/s/hit-from-bot').set('User-Agent', BOT_UA_WHATSAPP);
await new Promise((r) => setTimeout(r, 50));
const row = await service.findByShortSlug('hit-from-bot');
expect(row.hit_count).toBe(1);
});
});
describe('GET /s/:shortSlug — error states', () => {
it('404 for an unknown slug', async () => {
const res = await request(app)
.get('/s/never-existed')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
it('410 for a soft-deleted slug (intentional-delete signal)', async () => {
const { shortUrl } = await seedEventAndShortUrl({
slug: 'gone-test', shortSlug: 'gone-slug',
});
await service.softDelete(shortUrl.id, null);
const res = await request(app)
.get('/s/gone-slug')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(410);
});
it('410 if the event was hard-deleted but the short URL row somehow survives', async () => {
const { eventId } = await seedEventAndShortUrl({
slug: 'orphan-test', shortSlug: 'orphan-slug',
});
// Hard-delete the event row (FK CASCADE would normally clean up the
// short URL too — but if CASCADE didn't fire for whatever reason
// (e.g. SQLite foreign_keys pragma off in a particular runtime), the
// resolver should still degrade safely).
// SQLite's foreign_keys pragma is OFF by default; the migration
// doesn't toggle it, so this delete leaves the short URL row.
await db('events').where({ id: eventId }).delete();
const res = await request(app)
.get('/s/orphan-slug')
.set('User-Agent', BOT_UA_WHATSAPP);
expect(res.status).toBe(410);
});
it('404 for a malformed slug (rejected at validation, no DB hit)', async () => {
const res = await request(app)
.get('/s/UPPER_CASE')
.set('User-Agent', BROWSER_UA);
expect(res.status).toBe(404);
});
});
describe('Regression — existing URL paths must still respond the same', () => {
// The /s/* namespace is additive: it must NOT shadow /gallery/*
// or any of the OG routes. We don't load the whole app here, but we
// can at least pin that the route param doesn't accept slashes —
// i.e. /s/foo/bar must NOT be matched by our handler.
it('the /s/:shortSlug route does not match nested paths', async () => {
const res = await request(app)
.get('/s/foo/bar')
.set('User-Agent', BROWSER_UA);
// Express returns its default 404 when no route matches the path.
expect(res.status).toBe(404);
});
});
@@ -0,0 +1,282 @@
/**
* Integration tests for the branded short-URL service (#699).
*
* Exercises createShortUrl + findByShortSlug + listForEvent + softDelete
* + recordHit against a real SQLite DB, including the contracts that
* matter for production correctness:
*
* - Custom slug + collision detection (409 with `suggested`)
* - Auto-generated slug from event slug + year
* - Soft-delete preserves the row (admin can audit)
* - target_path snapshots at create time (toggling the global
* "Use short gallery URLs" setting later doesn't change existing
* short URLs — backward-compat invariant from #699)
* - hit_count increments idempotently
* - findByShortSlug returns soft-deleted rows (caller decides 410 vs 404)
*
* Boots one DB for the whole file (cheap on SQLite); each test seeds
* its own event row to keep scope clean.
*/
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
let db; let cleanup; let service; let adminId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Minimal admin for created_by audit.
const adminInsert = await db('admin_users').insert({
username: 'shorturl-test',
email: 'shorturl@example.com',
password_hash: 'x',
must_change_password: false,
created_at: new Date(),
}).returning('id');
adminId = adminInsert[0]?.id ?? adminInsert[0];
service = require('../../src/services/galleryShortUrlService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
// Each test seeds a fresh event so collisions / counter state don't leak.
async function seedEvent(overrides = {}) {
const slug = overrides.slug || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [id] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: overrides.event_name || 'Test Wedding',
event_date: overrides.event_date || '2026-06-05',
password_hash: 'x',
expires_at: farFuture,
is_active: true,
is_archived: false,
share_link: slug,
share_token: overrides.share_token || `tok${Math.random().toString(36).slice(2, 12)}`,
welcome_message: null,
});
const event = await db('events').where({ id }).first();
return event;
}
describe('createShortUrl — custom slug', () => {
it('creates with a custom slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-1' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'sofia-graduation-1',
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-1');
expect(row.target_path).toBe(`/gallery/${event.slug}`);
expect(row.event_id).toBe(event.id);
expect(row.hit_count).toBe(0);
});
it('lowercases the input — operators pasting mixed-case still get a clean slug', async () => {
const event = await seedEvent({ slug: 'sofia-grad-2' });
const row = await service.createShortUrl({
eventId: event.id,
customSlug: 'Sofia-GraduAtion-2', // mixed case
createdBy: adminId,
});
expect(row.short_slug).toBe('sofia-graduation-2');
});
it('rejects an invalid slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'invalid-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'invalid slug with spaces',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a reserved slug with INVALID_SLUG code', async () => {
const event = await seedEvent({ slug: 'reserved-test' });
await expect(service.createShortUrl({
eventId: event.id,
customSlug: 'admin',
createdBy: adminId,
})).rejects.toMatchObject({ code: 'INVALID_SLUG' });
});
it('rejects a duplicate slug with SLUG_TAKEN + suggested fallback', async () => {
const event1 = await seedEvent({ slug: 'dup-test-1' });
const event2 = await seedEvent({ slug: 'dup-test-2' });
await service.createShortUrl({ eventId: event1.id, customSlug: 'collide-me' });
await expect(service.createShortUrl({
eventId: event2.id, customSlug: 'collide-me',
})).rejects.toMatchObject({
code: 'SLUG_TAKEN',
suggested: expect.any(String),
});
});
it('throws EVENT_NOT_FOUND when the event id does not exist', async () => {
await expect(service.createShortUrl({
eventId: 9999999, customSlug: 'no-event',
})).rejects.toMatchObject({ code: 'EVENT_NOT_FOUND' });
});
});
describe('createShortUrl — auto-generated slug', () => {
it('uses event slug + year when no custom slug provided', async () => {
const event = await seedEvent({
slug: 'autogen-wedding', event_date: '2026-06-05',
});
const row = await service.createShortUrl({
eventId: event.id,
createdBy: adminId,
});
// First-choice candidate is just the slug; takes that.
expect(row.short_slug).toBe('autogen-wedding');
});
it('falls back to slug-year when the bare slug is already taken', async () => {
// Both events SHARE the same canonical slug so the first-choice
// bare-slug candidate is burned, forcing autoGen to try the
// year-suffixed variant.
const event1 = await seedEvent({
slug: 'collide-base', event_date: '2026-07-01',
});
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base',
});
const event2 = await seedEvent({
slug: 'collide-base-2', event_date: '2026-07-01',
});
// Force the bare candidate of event2 to also collide by burning it.
await service.createShortUrl({
eventId: event1.id, customSlug: 'collide-base-2',
});
const row = await service.createShortUrl({
eventId: event2.id, // No custom — auto-gen from event2.slug
});
// Bare candidate `collide-base-2` is taken → year-suffixed picks.
expect(row.short_slug).toBe('collide-base-2-2026');
});
});
describe('createShortUrl — target_path snapshotting (#699 backward-compat)', () => {
it('uses /gallery/<slug> when the global short-URLs setting is OFF (default)', async () => {
const event = await seedEvent({ slug: 'snapshot-off' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-off',
});
expect(row.target_path).toBe(`/gallery/${event.slug}`);
});
it('uses /gallery/<share_token> when the global setting is ON at create time', async () => {
// Persist the setting.
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(true), 'system');
try {
const event = await seedEvent({ slug: 'snapshot-on', share_token: 'tokenAbc123' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'snap-on',
});
expect(row.target_path).toBe(`/gallery/${event.share_token}`);
// CRITICAL backward-compat invariant: now flip the setting OFF.
// Existing short URLs must still resolve to the same target_path
// they were created with — operator's existing share links don't
// silently change behaviour.
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
const refetched = await service.findByShortSlug('snap-on');
expect(refetched.target_path).toBe(`/gallery/${event.share_token}`);
} finally {
await upsertAppSetting('general_use_short_gallery_urls', JSON.stringify(false), 'system');
}
});
});
describe('findByShortSlug + listForEvent', () => {
it('returns null for an unknown slug', async () => {
expect(await service.findByShortSlug('does-not-exist-xyz')).toBeNull();
});
it('returns null for a malformed slug (no DB hit)', async () => {
expect(await service.findByShortSlug('UPPER_CASE')).toBeNull();
expect(await service.findByShortSlug('with spaces')).toBeNull();
expect(await service.findByShortSlug('')).toBeNull();
});
it('returns soft-deleted rows (caller decides 410 vs 404)', async () => {
const event = await seedEvent({ slug: 'softdel-find' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'find-deleted',
});
await service.softDelete(created.id, adminId);
const fetched = await service.findByShortSlug('find-deleted');
expect(fetched).not.toBeNull();
expect(fetched.deleted_at).toBeTruthy();
});
it('listForEvent excludes soft-deleted rows', async () => {
const event = await seedEvent({ slug: 'list-test' });
const live = await service.createShortUrl({
eventId: event.id, customSlug: 'list-live',
});
const deleted = await service.createShortUrl({
eventId: event.id, customSlug: 'list-deleted',
});
await service.softDelete(deleted.id, adminId);
const list = await service.listForEvent(event.id);
const ids = list.map((r) => r.id);
expect(ids).toContain(live.id);
expect(ids).not.toContain(deleted.id);
});
});
describe('softDelete', () => {
it('returns true on first call, false on second (idempotent admin clicks)', async () => {
const event = await seedEvent({ slug: 'softdel-idem' });
const created = await service.createShortUrl({
eventId: event.id, customSlug: 'idem-delete',
});
expect(await service.softDelete(created.id, adminId)).toBe(true);
expect(await service.softDelete(created.id, adminId)).toBe(false);
});
it('returns false for an unknown id (caller maps to 404)', async () => {
expect(await service.softDelete(9999999, adminId)).toBe(false);
});
});
describe('createShortUrl after soft-delete — slug rotation', () => {
it('re-creating a soft-deleted slug succeeds (purges the deleted row)', async () => {
const event = await seedEvent({ slug: 'rotate' });
const first = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
await service.softDelete(first.id, adminId);
// The slug is now reclaimable for a fresh row.
const second = await service.createShortUrl({
eventId: event.id, customSlug: 'rotate-me',
});
expect(second.id).not.toBe(first.id);
expect(second.short_slug).toBe('rotate-me');
});
});
describe('recordHit', () => {
it('increments hit_count + stamps last_hit_at', async () => {
const event = await seedEvent({ slug: 'hit-counter' });
const row = await service.createShortUrl({
eventId: event.id, customSlug: 'count-me',
});
await service.recordHit(row.id);
await service.recordHit(row.id);
const fetched = await service.findByShortSlug('count-me');
expect(fetched.hit_count).toBe(2);
expect(fetched.last_hit_at).toBeTruthy();
});
it('is fire-and-forget — invalid id does not throw', async () => {
await expect(service.recordHit(9999999)).resolves.not.toThrow();
});
});
@@ -0,0 +1,196 @@
'use strict';
// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service
// so setupService shares this test's db instance (see crmDb.js note).
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
const fs = require('fs');
const path = require('path');
const request = require('supertest');
const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb');
let db;
let cleanup;
let tmpDir;
let setupService;
let getAppSetting;
let upsertAppSetting;
let app;
const VALID_PW = 'Str0ng-Passw0rd!';
// bootCrmDb MUST run before any require of db.js (directly or transitively via a
// service/util), or db.js binds to the default path instead of the temp one.
beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir
setupService = require('../../src/services/setupService');
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
}, 60000);
afterAll(async () => {
await cleanup();
});
beforeEach(async () => {
await db('admin_users').del();
await db('app_settings').where({ setting_key: 'setup_token' }).del();
});
describe('setupService (first-run bootstrap)', () => {
it('reports needsAdmin while no admin exists', async () => {
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
});
it('generates and persists a one-time token while no admin exists', async () => {
const token = await setupService.ensureSetupToken();
expect(token).toEqual(expect.any(String));
expect(token.length).toBeGreaterThan(20);
expect(await getAppSetting('setup_token')).toBe(token);
// Idempotent — a second call returns the same token, not a fresh one.
expect(await setupService.ensureSetupToken()).toBe(token);
});
it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => {
// Regression guard for the SQLite-only miss: a bare token string is rejected
// by Postgres jsonb ("invalid input syntax for type json"). The raw column
// value must be JSON-parseable and round-trip back to the token.
const token = await setupService.ensureSetupToken();
const row = await db('app_settings').where({ setting_key: 'setup_token' }).first();
expect(() => JSON.parse(row.setting_value)).not.toThrow();
expect(JSON.parse(row.setting_value)).toBe(token);
});
it('rejects a wrong token', async () => {
await setupService.ensureSetupToken();
await expect(
setupService.createInitialAdmin({ token: 'nope', email: 'a@b.co', password: VALID_PW })
).rejects.toMatchObject({ statusCode: 400 });
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
});
it('rejects a weak password', async () => {
const token = await setupService.ensureSetupToken();
await expect(
setupService.createInitialAdmin({ token, email: 'a@b.co', password: 'weak' })
).rejects.toMatchObject({ statusCode: 400 });
});
it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => {
const token = await setupService.ensureSetupToken();
const result = await setupService.createInitialAdmin({
token, email: 'Owner@Example.com', password: VALID_PW, ip: '203.0.113.7',
});
expect(result.user.email).toBe('owner@example.com'); // normalised
expect(result.user.role.name).toBe('super_admin');
expect(result.token).toEqual(expect.any(String));
const row = await db('admin_users').first();
const role = await db('roles').where({ name: 'super_admin' }).first();
expect(row.role_id).toBe(role.id);
expect(row.password_hash).not.toBe(VALID_PW); // hashed
// One-time: token burned, status now complete.
expect(await getAppSetting('setup_token')).toBeFalsy();
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
});
it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => {
const tokenFile = path.join(tmpDir, 'SETUP_TOKEN');
const token = await setupService.ensureSetupToken();
expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token);
await setupService.createInitialAdmin({ token, email: 'owner@example.com', password: VALID_PW });
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
});
it('refuses to create a second admin (setup already complete)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
await expect(
setupService.createInitialAdmin({ token, email: 'second@example.com', password: VALID_PW })
).rejects.toMatchObject({ statusCode: 409 });
});
it('serialises a double-submit — two concurrent valid-token calls create only one admin', async () => {
const token = await setupService.ensureSetupToken();
const results = await Promise.allSettled([
setupService.createInitialAdmin({ token, email: 'a@example.com', password: VALID_PW }),
setupService.createInitialAdmin({ token, email: 'b@example.com', password: VALID_PW }),
]);
const fulfilled = results.filter((r) => r.status === 'fulfilled');
expect(fulfilled).toHaveLength(1); // the atomic token claim lets exactly one win
const count = await db('admin_users').count({ c: '*' }).first();
expect(Number(count.c)).toBe(1);
});
it('ensureSetupToken clears any stale token once an admin exists', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
// Simulate a stale token left in settings, then re-run the boot hook.
await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string');
expect(await setupService.ensureSetupToken()).toBeNull();
expect(await getAppSetting('setup_token')).toBeFalsy();
});
});
describe('setup routes', () => {
it('GET /api/setup/status reports needsAdmin', async () => {
const res = await request(app).get('/api/setup/status');
expect(res.status).toBe(200);
expect(res.body).toEqual({ needsAdmin: true, complete: false });
});
it('POST /api/setup/verify-token accepts the right token without burning it (200)', async () => {
const token = await setupService.ensureSetupToken();
const res = await request(app).post('/api/setup/verify-token').send({ token });
expect(res.status).toBe(200);
expect(res.body).toEqual({ valid: true });
// Token is NOT consumed — it still works for the actual create.
expect(await getAppSetting('setup_token')).toBe(token);
});
it('POST /api/setup/verify-token rejects a wrong token (400, field token)', async () => {
await setupService.ensureSetupToken();
const res = await request(app).post('/api/setup/verify-token').send({ token: 'nope' });
expect(res.status).toBe(400);
expect(res.body.field).toBe('token');
});
it('POST /api/setup/verify-token is closed once an admin exists (409)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
const res = await request(app).post('/api/setup/verify-token').send({ token });
expect(res.status).toBe(409);
});
it('POST /api/setup/admin rejects a wrong token (400)', async () => {
await setupService.ensureSetupToken();
const res = await request(app)
.post('/api/setup/admin')
.send({ token: 'nope', email: 'a@b.co', password: VALID_PW });
expect(res.status).toBe(400);
expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true });
});
it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => {
const token = await setupService.ensureSetupToken();
const res = await request(app)
.post('/api/setup/admin')
.send({ token, email: 'owner@example.com', password: VALID_PW });
expect(res.status).toBe(201);
expect(res.body.user.role.name).toBe('super_admin');
expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/);
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
});
it('POST /api/setup/admin is closed once an admin exists (409)', async () => {
const token = await setupService.ensureSetupToken();
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
const res = await request(app)
.post('/api/setup/admin')
.send({ token, email: 'second@example.com', password: VALID_PW });
expect(res.status).toBe(409);
});
});
@@ -415,6 +415,16 @@ describe('workflow engine', () => {
expect(await _internal.resolveTemplateKey('zzznotype', 'promo_')).toBe('promo_default');
});
test('pre-event payload passes the RAW event_date (processor formats it — no "Invalid Date")', async () => {
const { _internal } = require('../../src/services/eventReminderService');
const p = _internal.composePayload({
event: { id: 1, event_name: 'X', event_date: '2026-06-25', customer_name: 'A' },
recipientEmail: 'a@x.test', daysBefore: 2, businessName: 'Biz',
});
expect(p.event_date).toBe('2026-06-25'); // raw, not pre-formatted DD.MM.YYYY
expect(p.event_date).not.toMatch(/invalid/i);
});
test('webhook action enqueues a delivery for a configured subscription (full pipeline)', async () => {
const webhook = engine.registry.getAction('webhook');
expect(typeof webhook).toBe('function'); // registered — no longer a silent no-op
@@ -450,6 +460,29 @@ describe('workflow engine', () => {
expect((await webhook(ctx({ webhookId: whId }))).skipped).toBe(true);
});
test('pre-event falls back to the assigned customer account when the event has no inline email', async () => {
const eventReminderService = require('../../src/services/eventReminderService');
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const [custId] = await db('customer_accounts').insert({
email: 'assigned@x.test', preferred_language: 'en', is_active: true, created_at: new Date(),
});
// Event with NO inline customer_email / host_email.
await db('events').insert({
event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false,
slug: 'rem-assigned', share_link: 'rem-assigned', event_name: 'Assigned',
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
});
const ev = await db('events').where({ slug: 'rem-assigned' }).first();
await db('event_customer_assignments').insert({ event_id: ev.id, customer_account_id: custId, assigned_at: new Date() });
const res = await eventReminderService.sendReminderForEvent(ev.id);
expect(res.sent).toBe(1);
const mail = await db('email_queue').where({ recipient_email: 'assigned@x.test' }).first();
expect(mail).toBeTruthy();
// Queued WITHOUT event_id so the resolver uses the customer's preferred_language.
expect(mail.event_id == null).toBe(true);
});
test('isBuiltinFlowActive reflects the built-in ENABLED state (enabled-based mutex)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
@@ -71,16 +71,37 @@ describe('admin workflows API', () => {
expect(res.body.error).toMatch(/unknown node type/i);
});
test('refuses to enable a flow that uses an unimplemented action', async () => {
test('refuses to enable a flow that uses an unregistered action', async () => {
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
name: 'Stub flow', trigger_type: 'quote.accepted', enabled: false,
nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'prepare_invoice' } }],
nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'a', type: 'action', config: { action: 'totally_not_a_real_action' } }],
edges: [{ from_node: 't', to_node: 'a' }],
});
expect(create.status).toBe(201);
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/not.*implemented|prepare_invoice/i);
expect(res.body.error).toMatch(/not.*implemented|totally_not_a_real_action/i);
});
test('allows enabling a flow using the now-implemented booking invoice actions', async () => {
const create = await request(app).post('/api/admin/workflows').set(auth(token)).send({
name: 'Invoice-only booking', trigger_type: 'quote.accepted', enabled: false,
nodes: [
{ node_key: 't', type: 'trigger' },
{ node_key: 'p', type: 'action', config: { action: 'prepare_invoice' } },
{ node_key: 'g', type: 'gate', config: {} },
{ node_key: 's', type: 'action', config: { action: 'send_document', document: 'invoice' } },
],
edges: [
{ from_node: 't', to_node: 'p' },
{ from_node: 'p', to_node: 'g' },
{ from_node: 'g', from_handle: 'confirm', to_node: 's' },
],
});
expect(create.status).toBe(201);
const res = await request(app).patch(`/api/admin/workflows/${create.body.id}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(true);
});
test('get one returns the graph', async () => {
@@ -0,0 +1,200 @@
/**
* HTTP smoke tests for the core admin event CRUD endpoints:
* POST /api/admin/events (create)
* GET /api/admin/events (list + pagination)
* GET /api/admin/events/:id (detail + stats)
* PUT /api/admin/events/:id (update)
* DELETE /api/admin/events/:id (cascade delete)
*
* Safety net ahead of the adminEvents.js god-file decomposition —
* pins the request/response contracts of the main CRUD paths using
* the same real-SQLite harness as slideshowAdmin.test.js.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-events-smoke-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-events-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
async function insertEvent(db, adminId, over = {}) {
const base = {
slug: `ev-${Math.random().toString(16).slice(2)}`,
event_type: 'wedding',
event_name: 'Test Wedding',
event_date: '2026-05-29',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/share-${Math.random().toString(16).slice(2)}`,
share_token: `st-${Math.random().toString(16).slice(2)}`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_by: adminId,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('admin events CRUD endpoints (smoke)', () => {
let db; let cleanup; let app; let adminId; let token;
// bootCrmDb's full migration run intermittently exceeds Jest's default
// 5s beforeAll timeout on slower CI runners; raise it.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId } = await seedMinimal(db));
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => {
await db('email_queue').del();
await db('events').del();
});
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
it('401s without an admin token', async () => {
const res = await request(app).get('/api/admin/events');
expect(res.status).toBe(401);
});
describe('POST /', () => {
it('creates an event, mints slug + share link and persists the row', async () => {
const res = await auth(request(app).post('/api/admin/events')).send({
event_type: 'wedding',
event_name: 'Smoke Wedding',
event_date: '2026-09-01',
// Field requirements default to ON (getEventFieldRequirements)
// so customer + admin contact data must be supplied.
customer_name: 'Client Person',
customer_email: 'client@example.com',
admin_email: 'admin@example.com',
require_password: false,
is_draft: true,
});
expect(res.status).toBe(200);
expect(res.body.id).toBeDefined();
expect(res.body.slug).toContain('wedding-smoke-wedding');
expect(typeof res.body.share_link).toBe('string');
expect(res.body.is_draft).toBe(true);
const row = await db('events').where({ id: res.body.id }).first();
expect(row).toBeDefined();
expect(row.event_name).toBe('Smoke Wedding');
expect(row.created_by).toBe(adminId);
// Folder structure is created under STORAGE_PATH/events/active/<slug>.
const eventDir = path.join(process.env.STORAGE_PATH, 'events/active', res.body.slug);
expect(fs.existsSync(path.join(eventDir, 'collages'))).toBe(true);
expect(fs.existsSync(path.join(eventDir, 'individual'))).toBe(true);
// Draft creates must NOT queue the gallery_created email.
const queued = await db('email_queue').where({ event_id: res.body.id });
expect(queued).toHaveLength(0);
});
it('400s on an invalid event type', async () => {
const res = await auth(request(app).post('/api/admin/events')).send({
event_type: 'not-a-real-type',
event_name: 'Broken',
require_password: false,
});
expect(res.status).toBe(400);
expect(Array.isArray(res.body.errors)).toBe(true);
});
});
describe('GET /', () => {
it('lists events with pagination metadata and photo counts', async () => {
await insertEvent(db, adminId, { event_name: 'Alpha' });
await insertEvent(db, adminId, { event_name: 'Beta' });
const res = await auth(request(app).get('/api/admin/events'));
expect(res.status).toBe(200);
expect(res.body.events).toHaveLength(2);
expect(res.body.pagination).toMatchObject({ page: 1, total: 2, totalPages: 1 });
for (const ev of res.body.events) {
expect(ev.photo_count).toBe(0);
}
});
});
describe('GET /:id', () => {
it('returns the event with photo/view stats', async () => {
const id = await insertEvent(db, adminId, { event_name: 'Detail Event' });
const res = await auth(request(app).get(`/api/admin/events/${id}`));
expect(res.status).toBe(200);
expect(res.body.event_name).toBe('Detail Event');
expect(res.body.photo_count).toBe(0);
expect(res.body.total_views).toBe(0);
expect(res.body.total_downloads).toBe(0);
expect(Array.isArray(res.body.recent_photos)).toBe(true);
});
it('404s for an unknown event id', async () => {
const res = await auth(request(app).get('/api/admin/events/999999'));
expect(res.status).toBe(404);
});
});
describe('PUT /:id', () => {
it('updates mutable fields and persists them', async () => {
const id = await insertEvent(db, adminId, { event_name: 'Before' });
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
event_name: 'After',
welcome_message: 'Hello guests',
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.event_name).toBe('After');
expect(row.welcome_message).toBe('Hello guests');
});
it('404s when updating a missing event', async () => {
const res = await auth(request(app).put('/api/admin/events/999999')).send({
event_name: 'Ghost',
});
expect(res.status).toBe(404);
});
});
describe('DELETE /:id', () => {
it('cascade-deletes the event row', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).delete(`/api/admin/events/${id}`));
expect(res.status).toBe(200);
expect(res.body.message).toMatch(/deleted/i);
const row = await db('events').where({ id }).first();
expect(row).toBeUndefined();
});
it('404s when deleting a missing event', async () => {
const res = await auth(request(app).delete('/api/admin/events/999999'));
expect(res.status).toBe(404);
});
});
});
@@ -72,9 +72,16 @@ jest.mock('../../src/services/businessProfileService', () => ({
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/utils/documentSequences', () => ({
claimNextSequence: jest.fn(async () => 42),
}));
jest.mock('../../src/utils/documentSequences', () => {
const claimNextSequence = jest.fn(async () => 42);
// Delegates to the claimNextSequence mock so call-count assertions
// below keep observing sequence claims.
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
const seq = await claimNextSequence(kind, 2026, trx);
return `R-2026-${String(seq).padStart(4, '0')}`;
});
return { claimNextSequence, nextDocumentNumber };
});
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
@@ -0,0 +1,259 @@
/**
* Smoke tests for invoiceService's primary flows ahead of the god-file
* decomposition — createInvoice happy path (incl. the line-item
* totals/VAT math), list/get reads, and the status-transition guards
* on cancelInvoice / releaseForDelivery.
*
* Uses the same deep-mocked db pattern as
* invoiceService.installmentPlan.test.js — chains are queued per table
* and assertions probe insert/update call shapes rather than SQL.
*/
const chains = [];
function makeChain() {
const c = {
_firstValue: undefined,
_updateResult: 1,
_insertResult: [{ id: 999 }],
_selectResult: [],
then: function (onResolve, onReject) {
return Promise.resolve(this._selectResult).then(onResolve, onReject);
},
where: jest.fn(function () { return this; }),
whereNot: jest.fn(function () { return this; }),
whereIn: jest.fn(function () { return this; }),
whereNull: jest.fn(function () { return this; }),
whereNotNull: jest.fn(function () { return this; }),
andWhere: jest.fn(function () { return this; }),
orderBy: jest.fn(function () { return this; }),
limit: jest.fn(function () { return this; }),
select: jest.fn(function () { return this; }),
sum: jest.fn(function () { return this; }),
count: jest.fn(function () { return this; }),
clone: jest.fn(function () { return this; }),
clearSelect: jest.fn(function () { return this; }),
clearOrder: jest.fn(function () { return this; }),
offset: jest.fn(function () { return this; }),
first: jest.fn(function () { return Promise.resolve(this._firstValue); }),
update: jest.fn(function () { return Promise.resolve(this._updateResult); }),
insert: jest.fn(function () { return this; }),
returning: jest.fn(function () { return Promise.resolve(this._insertResult); }),
del: jest.fn(function () { return Promise.resolve(1); }),
onConflict: jest.fn(function () { return this; }),
ignore: jest.fn(function () { return Promise.resolve(1); }),
merge: jest.fn(function () { return Promise.resolve(1); }),
increment: jest.fn(function () { return this; }),
forUpdate: jest.fn(function () { return this; }),
leftJoin: jest.fn(function () { return this; }),
};
chains.push(c);
return c;
}
const tableChains = {};
function pickChainFor(name) {
if (!tableChains[name]) tableChains[name] = makeChain();
return tableChains[name];
}
const mockDbFn = jest.fn((name) => pickChainFor(name));
mockDbFn.transaction = jest.fn(async (cb) => cb(mockDbFn));
mockDbFn.schema = { hasTable: jest.fn(async () => false) };
jest.mock('../../src/database/db', () => ({
db: mockDbFn,
withRetry: jest.fn(async (fn) => fn()),
logActivity: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn(async () => null),
}));
jest.mock('../../src/services/businessProfileService', () => ({
getProfile: jest.fn(async () => ({ profile: { default_currency: 'CHF' } })),
resolveBankAccountForCurrency: jest.fn(async () => null),
}));
jest.mock('../../src/utils/documentSequences', () => {
const claimNextSequence = jest.fn(async () => 42);
// Delegates to the claimNextSequence mock so call-count assertions
// below keep observing sequence claims.
const nextDocumentNumber = jest.fn(async (kind, settingKey, defaultFormat, trx) => {
const seq = await claimNextSequence(kind, 2026, trx);
return `R-2026-${String(seq).padStart(4, '0')}`;
});
return { claimNextSequence, nextDocumentNumber };
});
jest.mock('../../src/services/pdfService', () => ({
renderInvoiceToBuffer: jest.fn(async () => Buffer.from('pdf')),
renderQuoteToBuffer: jest.fn(async () => Buffer.from('pdf')),
}));
jest.mock('../../src/services/emailProcessor', () => ({
queueEmail: jest.fn(async () => {}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
const invoiceService = require('../../src/services/invoiceService');
function resetChains() {
for (const k of Object.keys(tableChains)) delete tableChains[k];
jest.clearAllMocks();
}
const activeCustomer = {
id: 5, is_active: 1, feature_bills: 1,
billing_cadence: 'per_event', preferred_language: 'de',
};
describe('createInvoice — happy path + totals', () => {
beforeEach(() => resetChains());
it('creates a single invoice with a claimed sequence number and computed totals/VAT', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
pickChainFor('invoices')._insertResult = [{ id: 777 }];
const result = await invoiceService.createInvoice({
customerAccountId: 5,
vatRate: 8.1,
lineItems: [
// 2 × 100.00 = 200.00
{ position: 1, description: 'Shoot', quantity: 2, unit_price_minor: 10000 },
// 50.00 with 10% discount = 45.00
{ position: 2, description: 'Discounted extra', quantity: 1, unit_price_minor: 5000, discount_percent: 10 },
// Parent header — total auto-resolves from priced sub-items (350.00)
{ position: 3, description: 'Package', quantity: 1, unit_price_minor: 0 },
{ position: 4, description: 'Camera', quantity: 1, unit_price_minor: 15000, parent_position: 3 },
{ position: 5, description: 'Lens', quantity: 1, unit_price_minor: 20000, parent_position: 3 },
],
}, 1);
expect(result.invoiceIds).toEqual([777]);
// Net = 20000 + 4500 + 35000 (resolved parent) — sub-items must NOT
// double-count. VAT = round(59500 × 8.1%) = 4820.
expect(pickChainFor('invoices').insert).toHaveBeenCalledWith(expect.objectContaining({
invoice_number: 'R-2026-0042',
customer_account_id: 5,
currency: 'CHF',
status: 'scheduled',
net_amount_minor: 59500,
vat_rate: 8.1,
vat_amount_minor: 4820,
shipping_amount_minor: 0,
total_amount_minor: 64320,
installment_total: 1,
}));
// Exactly one sequence number claimed for a single-row create.
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).toHaveBeenCalledTimes(1);
// Line items landed in invoice_line_items.
expect(pickChainFor('invoice_line_items').insert).toHaveBeenCalled();
});
it('409s on a deactivated customer before touching the sequence', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer, is_active: 0 };
await expect(invoiceService.createInvoice({
customerAccountId: 5, vatRate: 0, lineItems: [],
}, 1)).rejects.toMatchObject({ statusCode: 409 });
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
it('400s + INVOICE_TOTAL_NEGATIVE when discounts push the total below zero', async () => {
pickChainFor('customer_accounts')._firstValue = { ...activeCustomer };
await expect(invoiceService.createInvoice({
customerAccountId: 5,
vatRate: 7.7,
lineItems: [
{ position: 1, description: 'Shoot', quantity: 1, unit_price_minor: 5000 },
{ position: 2, description: 'Rabatt', quantity: 1, unit_price_minor: -8000 },
],
}, 1)).rejects.toMatchObject({ statusCode: 400, code: 'INVOICE_TOTAL_NEGATIVE' });
const { claimNextSequence } = require('../../src/utils/documentSequences');
expect(claimNextSequence).not.toHaveBeenCalled();
});
});
describe('listInvoices / getInvoiceById — read paths (smoke)', () => {
beforeEach(() => resetChains());
it('lists invoices with total + pagination echo', async () => {
pickChainFor('invoices')._selectResult = [
{ id: 1, invoice_number: 'R-2026-0001' },
{ id: 2, invoice_number: 'R-2026-0002' },
];
pickChainFor('invoices')._firstValue = { total: 7 };
const result = await invoiceService.listInvoices({ page: 2, pageSize: 10 });
expect(result.rows).toHaveLength(2);
expect(result.total).toBe(7);
expect(result.page).toBe(2);
expect(result.pageSize).toBe(10);
expect(pickChainFor('invoices').offset).toHaveBeenCalledWith(10);
expect(pickChainFor('invoices').limit).toHaveBeenCalledWith(10);
});
it('getInvoiceById returns { invoice, lineItems, payments } when found', async () => {
pickChainFor('invoices')._firstValue = { id: 3, invoice_number: 'R-2026-0003' };
pickChainFor('invoice_line_items as li')._selectResult = [
{ id: 30, position: 1, description: 'Shoot' },
];
pickChainFor('invoice_payment_log')._selectResult = [];
const result = await invoiceService.getInvoiceById(3);
expect(result.invoice).toMatchObject({ id: 3, invoice_number: 'R-2026-0003' });
expect(result.lineItems).toHaveLength(1);
expect(result.payments).toEqual([]);
});
it('getInvoiceById returns null for an unknown id', async () => {
pickChainFor('invoices')._firstValue = undefined;
await expect(invoiceService.getInvoiceById(404)).resolves.toBeNull();
});
});
describe('status transitions — cancelInvoice / releaseForDelivery guards', () => {
beforeEach(() => resetChains());
it('soft-cancels a scheduled (never-issued) invoice without a Storno', async () => {
pickChainFor('invoices')._firstValue = {
id: 9, status: 'scheduled', kind: 'invoice', event_id: null,
};
const result = await invoiceService.cancelInvoice(9, 1);
expect(result).toEqual({ cancelled: true, stornoId: null });
expect(pickChainFor('invoices').update).toHaveBeenCalledWith(
expect.objectContaining({ status: 'cancelled' })
);
});
it('409s + ALREADY_CANCELLED on a second cancel', async () => {
pickChainFor('invoices')._firstValue = {
id: 9, status: 'cancelled', kind: 'invoice',
};
await expect(invoiceService.cancelInvoice(9, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'ALREADY_CANCELLED' });
});
it('409s + IS_STORNO when trying to cancel a Storno document', async () => {
pickChainFor('invoices')._firstValue = {
id: 10, status: 'sent', kind: 'storno',
};
await expect(invoiceService.cancelInvoice(10, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'IS_STORNO' });
});
it('releaseForDelivery 409s + NOT_PENDING_DELIVERY on a non-pending invoice', async () => {
pickChainFor('invoices')._firstValue = {
id: 11, status: 'sent', kind: 'invoice',
};
await expect(invoiceService.releaseForDelivery(11, 1))
.rejects.toMatchObject({ statusCode: 409, code: 'NOT_PENDING_DELIVERY' });
});
});
@@ -27,7 +27,7 @@ function release(tag, body = '', publishedAt = '2026-01-01T00:00:00Z') {
name: tag,
body,
published_at: publishedAt,
html_url: `https://github.com/the-luap/picpeak/releases/tag/${tag}`,
html_url: `https://github.com/PicPeak/picpeak/releases/tag/${tag}`,
};
}
@@ -58,7 +58,7 @@ describe('updateCheckService.getReleasesSince', () => {
tag: 'v3.55.0',
name: 'v3.55.0',
body: 'stable notes 3.55.0',
htmlUrl: 'https://github.com/the-luap/picpeak/releases/tag/v3.55.0',
htmlUrl: 'https://github.com/PicPeak/picpeak/releases/tag/v3.55.0',
});
});
@@ -0,0 +1,120 @@
/**
* Pure-function tests for the slug validator in galleryShortUrlService.
* The validator is the security boundary for the `/s/<slug>` public
* route — bad shapes leak into a UNIQUE column that's used in URLs
* without further escaping, so the rules need to be tight.
*/
// Provide a minimal db stub so requiring the service doesn't crash —
// the validator path doesn't touch the DB.
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
jest.mock('../../src/utils/appSettings', () => ({
getAppSetting: jest.fn().mockResolvedValue(false),
}));
const {
validateSlug,
_RESERVED_SLUGS,
} = require('../../src/services/galleryShortUrlService');
describe('validateSlug', () => {
describe('accepts', () => {
test.each([
'sofia-graduation',
'sofia',
'a', // single char (alphanumeric)
'1', // single digit
'abc123',
'123-abc',
'sofia-2026-06-05',
'sofia-2026',
'a-b-c-d',
'wedding-2026',
'xK7p2'.toLowerCase(), // lowercase 5-char
'a'.repeat(64), // exactly at the limit
])('%j', (slug) => {
expect(validateSlug(slug)).toBeNull();
});
});
describe('rejects', () => {
test.each([
['', 'cannot be empty'],
[' ', 'cannot be empty'], // trimmed → empty
['-sofia', 'lowercase letters'], // leading hyphen
['sofia-', 'lowercase letters'], // trailing hyphen
['Sofia', 'lowercase letters'], // uppercase
['sofia_graduation', 'lowercase letters'], // underscore
['sofia.graduation', 'lowercase letters'], // dot
['sofia graduation', 'lowercase letters'], // space
['sofia/graduation', 'lowercase letters'], // slash (path traversal vector)
['sofia%20graduation', 'lowercase letters'],
['a'.repeat(65), 'at most 64'], // one over limit
])('%j → %s', (slug, expectedReason) => {
const result = validateSlug(slug);
expect(result).not.toBeNull();
expect(result.toLowerCase()).toContain(expectedReason);
});
test('null', () => {
expect(validateSlug(null)).toContain('must be a string');
});
test('undefined', () => {
expect(validateSlug(undefined)).toContain('must be a string');
});
test('number', () => {
expect(validateSlug(42)).toContain('must be a string');
});
test('object', () => {
expect(validateSlug({})).toContain('must be a string');
});
});
describe('reserved slugs', () => {
test.each([
'admin',
'api',
'auth',
'gallery',
'og',
'health',
's', // can't shadow the shortener itself
'login',
'favicon.ico', // even with the dot — covered by SLUG_REGEX fail too
])('reserves %j', (slug) => {
expect(_RESERVED_SLUGS.has(slug)).toBe(true);
});
test('"admin" → rejected with "reserved" reason', () => {
// validateSlug short-circuits at the regex for slugs containing
// dots (favicon.ico fails the regex first). Test a clean
// alphanumeric reserved word.
const result = validateSlug('admin');
expect(result).toBe('short_slug is reserved');
});
});
describe('path-traversal + URL-injection vectors are rejected at the regex', () => {
test.each([
'../etc/passwd',
'foo/../bar',
'foo?query=1',
'foo#fragment',
'foo&bar',
'foo bar',
'foo<script>',
'foo>',
'foo"',
'foo\'',
'foo;rm -rf',
])('%j', (slug) => {
expect(validateSlug(slug)).not.toBeNull();
});
});
});
@@ -0,0 +1,66 @@
const { cleanNetMinor, exactLineMinor } = require('../../src/utils/invoiceRounding');
// Sum the per-line ROUNDED totals the way computeTotals / createInvoice do,
// so each test can compare "sum of rounded lines" against cleanNetMinor.
function roundedNet(items, parentKey = 'parent_position') {
return items
.filter((li) => li[parentKey] == null || li[parentKey] === '')
.reduce((s, li) => s + Math.round(li.line_total_minor), 0);
}
function mkLine(position, quantity, unitPriceMinor, extra = {}) {
const discount = extra.discount_percent || 0;
return {
position,
quantity,
unit_price_minor: unitPriceMinor,
discount_percent: discount,
line_total_minor: Math.round(Math.round(quantity * unitPriceMinor) * (1 - discount / 100)),
parent_position: extra.parent_position ?? null,
};
}
describe('cleanNetMinor — sub-cent reconciliation', () => {
it('reconciles the real 68h × 32.25 invoice (sum-of-lines 2193.02 → clean 2193.00)', () => {
const qtys = [5.25, 3.25, 5.25, 2.75, 2, 1, 1.75, 5, 5.25, 5.25, 2.75,
4.5, 3.5, 2.5, 4.5, 2, 1.75, 3.25, 1.75, 3.5, 1.25];
const items = qtys.map((q, i) => mkLine(i + 1, q, 3225));
expect(roundedNet(items)).toBe(219302); // sum of the 21 rounded lines
expect(cleanNetMinor(items)).toBe(219300); // full-precision, rounded once
expect(cleanNetMinor(items) - roundedNet(items)).toBe(-2); // the -0.02 drift
});
it('is a no-op when every line is already cent-exact (adjustment 0)', () => {
const items = [mkLine(1, 2, 5000), mkLine(2, 3, 4000)];
expect(cleanNetMinor(items)).toBe(roundedNet(items));
});
it('is rate-agnostic: mixed hourly rates reconcile to one clean net', () => {
const items = [mkLine(1, 2.5, 3225), mkLine(2, 1.25, 3225), mkLine(3, 3.5, 4850), mkLine(4, 1.75, 4850)];
// sum-of-lines = 80.63 + 40.31 + 169.75 + 84.88 = 375.57; clean = 375.56
expect(roundedNet(items)).toBe(37557);
expect(cleanNetMinor(items)).toBe(37556);
});
it('honours per-line discounts at full precision', () => {
const items = [mkLine(1, 3, 1000, { discount_percent: 33 })];
// exact = 3 × 1000 × 0.67 = 2010 exactly → clean 2010
expect(cleanNetMinor(items)).toBe(2010);
});
it('migration-119 hierarchy: a parent with priced sub-items derives from the children', () => {
// Parent (pos 1) has two priced sub-items; parent own price ignored.
const parent = mkLine(1, 1, 9999); // own price should NOT count
const subA = mkLine(2, 2.5, 3225, { parent_position: 1 });
const subB = mkLine(3, 1.75, 3225, { parent_position: 1 });
const items = [parent, subA, subB];
// exact children = (2.5 + 1.75) × 3225 = 4.25 × 3225 = 13706.25 → 13706
expect(cleanNetMinor(items)).toBe(13706);
// parent's own 9999 must not leak in
expect(cleanNetMinor(items)).not.toBe(9999);
});
it('exactLineMinor returns the un-rounded product', () => {
expect(exactLineMinor({ quantity: 2.5, unit_price_minor: 3225 })).toBeCloseTo(8062.5, 5);
});
});
+69
View File
@@ -0,0 +1,69 @@
const { parseWhatsNew } = require('../../src/utils/whatsNew');
describe('parseWhatsNew', () => {
it('prefers the curated <!-- whatsnew --> block', () => {
const body = [
'<!-- whatsnew -->',
'- Invoice drafts in list',
'- Bank transfer payments',
'<!-- /whatsnew -->',
'',
'### Features',
'* **invoices:** something long that should be ignored ([#1](http://x))',
].join('\n');
expect(parseWhatsNew(body)).toEqual(['Invoice drafts in list', 'Bank transfer payments']);
});
it('falls back to the Features section, stripping scope + commit links', () => {
const body = [
'## [3.73.0-beta.0](http://x) (2026-06-29)',
'',
'### Features',
'',
'* **dashboard:** revenue tile toggles 365 days ([d1c9e02](http://c))',
'* **invoices:** surface monthly drafts in the Bills list ([e457656](http://c))',
'',
'### Bug Fixes',
'',
'* **invoices:** add bank transfer ([e96ef4c](http://c))',
].join('\n');
expect(parseWhatsNew(body)).toEqual([
'revenue tile toggles 365 days',
'surface monthly drafts in the Bills list',
]);
});
it('decodes HTML entities release-please escapes into changelog text', () => {
const body = '### Features\n* **gallery:** supports A &amp; B &lt;tags&gt; &quot;quoted&quot; ([#1](http://x))';
expect(parseWhatsNew(body)).toEqual(['supports A & B <tags> "quoted"']);
});
it('trims a trailing "— implementation detail" clause to the headline', () => {
const body = '### Features\n* **gallery:** branded URL shortener — /s/&lt;slug&gt; with OG injection ([#699](http://x))';
expect(parseWhatsNew(body)).toEqual(['branded URL shortener']);
});
it('leaves hyphenated words and dash-free bullets intact', () => {
const body = '### Features\n* **invoices:** mark-paid now supports bank transfer ([#2](http://x))';
expect(parseWhatsNew(body)).toEqual(['mark-paid now supports bank transfer']);
});
it('excludes Bug Fixes from the fallback', () => {
const body = '### Features\n* **a:** feature one\n### Bug Fixes\n* **b:** fix one';
expect(parseWhatsNew(body)).toEqual(['feature one']);
});
it('caps at 8 bullets and de-dups', () => {
const lines = Array.from({ length: 12 }, (_, i) => `- bullet ${i % 9}`);
const body = `<!-- whatsnew -->\n${lines.join('\n')}\n<!-- /whatsnew -->`;
const out = parseWhatsNew(body);
expect(out.length).toBe(8);
expect(new Set(out).size).toBe(8);
});
it('returns [] for empty / non-string input', () => {
expect(parseWhatsNew('')).toEqual([]);
expect(parseWhatsNew(null)).toEqual([]);
expect(parseWhatsNew(undefined)).toEqual([]);
});
});
+9 -2
View File
@@ -11,9 +11,16 @@ exports.up = async function(knex) {
// Initialize tables
await initializeDatabase();
// Create default admin user if none exists
// Create default admin user if none exists.
//
// Legacy path — only when ADMIN_PASSWORD is explicitly provided (keeps
// existing docker-compose installs working unchanged). When it is NOT set,
// we deliberately leave admin_users empty so the first-run setup wizard
// (setupService / /setup) creates the admin in-browser — no ADMIN_PASSWORD
// in .env. Existing deployments already ran this migration, so this only
// affects fresh installs.
const adminExists = await knex('admin_users').first();
if (!adminExists) {
if (!adminExists && process.env.ADMIN_PASSWORD) {
// Use ADMIN_PASSWORD from environment if set, otherwise generate a random one
const generatedPassword = process.env.ADMIN_PASSWORD || generateReadablePassword();
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
@@ -1,7 +1,7 @@
/**
* Migration: Add Customer Accounts (recurring user logins)
*
* Implements the customer tier from discussion the-luap/picpeak#354.
* Implements the customer tier from discussion PicPeak/picpeak#354.
*
* Three new tables:
* - customer_accounts : the user record (email + bcrypt password)
@@ -0,0 +1,30 @@
/**
* Migration 149: defer the quote.accepted/declined workflow emit past the
* 15-min response window.
*
* A customer's accept/decline can be toggled for crm_quotes_accept_window_minutes
* (default 15) before it locks. The booking workflow used to fire on the FIRST
* click and immediately convert the quote (status -> 'converted'), which made the
* quote un-declinable inside that window — defeating the grace period the public
* page promises ("you can change your answer within 15 minutes").
*
* The fix moves the response emit to AFTER the window locks: the scheduler sweeps
* locked-but-not-yet-emitted responses and fires quote.<final status> once. This
* column is the idempotency marker so each response is emitted exactly once,
* regardless of how many times the customer toggled inside the window.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (!(await knex.schema.hasColumn('quotes', 'workflow_response_emitted_at'))) {
await knex.schema.alterTable('quotes', (t) => {
t.timestamp('workflow_response_emitted_at');
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (await knex.schema.hasColumn('quotes', 'workflow_response_emitted_at')) {
await knex.schema.alterTable('quotes', (t) => t.dropColumn('workflow_response_emitted_at'));
}
};
@@ -0,0 +1,67 @@
/**
* Migration 150: branded URL shortener for gallery share links (#699).
*
* Lets admins create custom-named short URLs that resolve to a gallery's
* full link (e.g. `/s/sofia-graduation` → `/gallery/<slug>`). The short
* URL itself answers bot-UA requests with server-rendered OG metadata,
* so the SHORT URL is the one that shows the rich preview in iMessage /
* Facebook / WhatsApp — not just the destination.
*
* Backward-compat invariant: this migration only ADDS a new table. No
* existing route, table, or column is touched. Operators upgrading
* through this migration can opt into creating short URLs per event,
* but every existing `/gallery/...` link continues to resolve identically
* — the new feature is additive.
*/
exports.up = async function (knex) {
if (await knex.schema.hasTable('gallery_short_urls')) return;
await knex.schema.createTable('gallery_short_urls', (t) => {
t.increments('id').primary();
// Public-facing slug — what appears in /s/<short_slug>. Case-folded
// to lowercase at write time by the service; the UNIQUE index here
// is the last line of defence against collisions.
t.string('short_slug', 64).notNullable().unique();
// Hard FK to events — when an admin deletes an event, its short
// URLs go with it. ON DELETE CASCADE is the natural model: a short
// URL that points at a vanished gallery has no useful behaviour.
t.integer('event_id').notNullable()
.references('id').inTable('events').onDelete('CASCADE');
// Where the short URL resolves to — usually `/gallery/<slug>` or
// `/gallery/<share_token>` depending on the operator's #525
// "Use short gallery URLs" setting at create time. Stored at create
// time so a later flip of the global toggle doesn't silently change
// what existing short URLs redirect to.
t.text('target_path').notNullable();
// For the audit trail + admin UI ("created by Alex two days ago").
t.integer('created_by').references('id').inTable('admin_users');
t.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
// Tiny analytics — admins want to know "is this branded link
// actually being clicked?" without a separate analytics service.
t.integer('hit_count').notNullable().defaultTo(0);
t.timestamp('last_hit_at');
// Soft-delete semantics: a deleted short URL returns 410 Gone (not
// 404) so the admin sees their delete was intentional, and so a
// re-create with the same slug is an explicit "yes, replace" rather
// than accidentally taking over a stale link. The UNIQUE constraint
// on short_slug means re-create after delete requires either NULLing
// the deleted row's slug or hard-deleting it; service layer handles
// that explicitly.
t.timestamp('deleted_at');
t.integer('deleted_by').references('id').inTable('admin_users');
});
// Read patterns:
// - /s/:slug hot path — UNIQUE constraint on short_slug already
// provides the index. No additional index needed.
// - Admin UI "list short URLs for this event" — index event_id.
await knex.schema.alterTable('gallery_short_urls', (t) => {
t.index(['event_id'], 'gallery_short_urls_event_id_idx');
});
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('gallery_short_urls')) {
await knex.schema.dropTable('gallery_short_urls');
}
};
+172 -142
View File
@@ -1,18 +1,18 @@
{
"name": "picpeak-backend",
"version": "3.65.1-beta.0",
"version": "3.74.0-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.65.1-beta.0",
"version": "3.74.0-beta.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
"archiver": "^5.3.1",
"axios": "1.15.2",
"axios": "1.16.0",
"bcrypt": "6.0.0",
"chokidar": "4.0.3",
"cookie-parser": "^1.4.7",
@@ -23,26 +23,27 @@
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4",
"form-data": "4.0.6",
"helmet": "^7.0.0",
"i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"i18next-http-backend": "3.0.5",
"imapflow": "^1.4.0",
"ipaddr.js": "^2.3.0",
"joi": "^17.9.1",
"js-yaml": "^4.1.1",
"joi": "^17.13.4",
"js-yaml": "^4.2.0",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"mailparser": "^3.9.9",
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"multer": "2.2.0",
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^8.0.5",
"nodemailer": "^9.0.1",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"postcss": "8.5.10",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
@@ -51,6 +52,7 @@
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"swissqrbill": "^4.3.0",
"tar": ">=7.5.16",
"uuid": "^11.1.1",
"winston": "^3.8.2",
"zxcvbn": "^4.4.2"
@@ -1013,13 +1015,13 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@@ -1028,9 +1030,9 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
"integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1038,22 +1040,22 @@
}
},
"node_modules/@babel/core": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
"@babel/helper-compilation-targets": "^7.27.2",
"@babel/helper-module-transforms": "^7.28.3",
"@babel/helpers": "^7.28.4",
"@babel/parser": "^7.28.5",
"@babel/template": "^7.27.2",
"@babel/traverse": "^7.28.5",
"@babel/types": "^7.28.5",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
@@ -1070,14 +1072,14 @@
}
},
"node_modules/@babel/generator": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@babel/types": "^7.28.5",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7",
"@jridgewell/gen-mapping": "^0.3.12",
"@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
@@ -1087,14 +1089,14 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/compat-data": "^7.27.2",
"@babel/helper-validator-option": "^7.27.1",
"@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0",
"lru-cache": "^5.1.1",
"semver": "^6.3.1"
@@ -1104,9 +1106,9 @@
}
},
"node_modules/@babel/helper-globals": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1114,29 +1116,29 @@
}
},
"node_modules/@babel/helper-module-imports": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/traverse": "^7.27.1",
"@babel/types": "^7.27.1"
"@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-module-transforms": {
"version": "7.28.3",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
"@babel/traverse": "^7.28.3"
"@babel/helper-module-imports": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7",
"@babel/traverse": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -1156,9 +1158,9 @@
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1166,9 +1168,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1176,9 +1178,9 @@
}
},
"node_modules/@babel/helper-validator-option": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1186,27 +1188,27 @@
}
},
"node_modules/@babel/helpers": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.4"
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.5"
"@babel/types": "^7.29.7"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1464,33 +1466,33 @@
}
},
"node_modules/@babel/template": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/parser": "^7.27.2",
"@babel/types": "^7.27.1"
"@babel/code-frame": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/types": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/traverse": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
"integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
"@babel/helper-globals": "^7.28.0",
"@babel/parser": "^7.28.5",
"@babel/template": "^7.27.2",
"@babel/types": "^7.28.5",
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
"@babel/helper-globals": "^7.29.7",
"@babel/parser": "^7.29.7",
"@babel/template": "^7.29.7",
"@babel/types": "^7.29.7",
"debug": "^4.3.1"
},
"engines": {
@@ -1498,14 +1500,14 @@
}
},
"node_modules/@babel/types": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -4086,12 +4088,12 @@
}
},
"node_modules/axios": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
@@ -4242,13 +4244,16 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/bcrypt": {
@@ -4348,9 +4353,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
"dev": true,
"funding": [
{
@@ -4369,11 +4374,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
"baseline-browser-mapping": "^2.10.38",
"caniuse-lite": "^1.0.30001799",
"electron-to-chromium": "^1.5.376",
"node-releases": "^2.0.48",
"update-browserslist-db": "^1.2.3"
},
"bin": {
"browserslist": "cli.js"
@@ -4574,9 +4579,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
"version": "1.0.30001799",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
"dev": true,
"funding": [
{
@@ -5337,9 +5342,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"version": "1.5.381",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz",
"integrity": "sha512-n9Wa6yB+vDsGuA8AKbl/0z7HbvWqt5jxIdvr1IUicd0ryPrk7/xzwqLv8D9AbbvZ6avVNtXYLTfmgFHkwkyelg==",
"dev": true,
"license": "ISC"
},
@@ -6184,21 +6189,33 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -6811,9 +6828,9 @@
}
},
"node_modules/i18next-http-backend": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.6.tgz",
"integrity": "sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz",
"integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==",
"license": "MIT",
"dependencies": {
"cross-fetch": "4.1.0"
@@ -7825,9 +7842,9 @@
}
},
"node_modules/joi": {
"version": "17.13.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
"integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
"version": "17.13.4",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz",
"integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^9.3.0",
@@ -7852,9 +7869,19 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -8861,9 +8888,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -9041,11 +9068,14 @@
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/node-stream-zip": {
"version": "1.15.0",
@@ -9061,9 +9091,9 @@
}
},
"node_modules/nodemailer": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
"integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -9823,9 +9853,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"funding": [
{
"type": "opencollective",
@@ -11522,9 +11552,9 @@
}
},
"node_modules/tar": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"version": "7.5.19",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz",
"integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
+15 -12
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.71.0-beta.0",
"version": "3.79.1-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -18,7 +18,7 @@
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
"archiver": "^5.3.1",
"axios": "1.15.2",
"axios": "1.16.0",
"bcrypt": "6.0.0",
"chokidar": "4.0.3",
"cookie-parser": "^1.4.7",
@@ -29,23 +29,23 @@
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4",
"form-data": "4.0.6",
"helmet": "^7.0.0",
"i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"i18next-http-backend": "3.0.5",
"imapflow": "^1.4.0",
"ipaddr.js": "^2.3.0",
"joi": "^17.9.1",
"js-yaml": "^4.1.1",
"joi": "^17.13.4",
"js-yaml": "^4.2.0",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"mailparser": "^3.9.9",
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"multer": "2.2.0",
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^8.0.5",
"nodemailer": "^9.0.1",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
@@ -59,7 +59,9 @@
"swissqrbill": "^4.3.0",
"uuid": "^11.1.1",
"winston": "^3.8.2",
"zxcvbn": "^4.4.2"
"zxcvbn": "^4.4.2",
"postcss": "8.5.10",
"tar": ">=7.5.16"
},
"devDependencies": {
"eslint": "^8.40.0",
@@ -73,10 +75,10 @@
"tar-fs": "2.1.4"
},
"glob": "^11.1.0",
"js-yaml": "^4.1.1",
"js-yaml": "^4.2.0",
"fast-xml-parser": ">=5.7.0",
"qs": ">=6.15.2",
"tar": ">=7.5.13",
"tar": ">=7.5.16",
"brace-expansion": ">=5.0.6",
"minimatch": ">=9.0.7",
"path-to-regexp": "0.1.13",
@@ -84,6 +86,7 @@
"follow-redirects": ">=1.16.0",
"@tootallnate/once": ">=3.0.1",
"ip-address": ">=10.1.1",
"uuid": "^11.1.1"
"uuid": "^11.1.1",
"nodemailer": "^9.0.1"
}
}
+98 -2
View File
@@ -43,6 +43,7 @@ const galleryRoutes = require('./src/routes/gallery');
const adminRoutes = require('./src/routes/admin');
const adminAuthRoutes = require('./src/routes/adminAuth');
const secureImagesRoutes = require('./src/routes/secureImages');
const setupRoutes = require('./src/routes/setup');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -397,6 +398,8 @@ async function initializeRateLimiters() {
app.use('/api/auth', authRateLimiter);
app.use('/api/gallery/:slug/verify', authRateLimiter);
app.use('/api/admin/auth/login', authRateLimiter);
app.use('/api/setup/admin', authRateLimiter);
app.use('/api/setup/verify-token', authRateLimiter);
}
// Note: Rate limiters will be initialized after database connection
@@ -543,6 +546,68 @@ app.get('/og/gallery/:slug', handleGalleryOgRequest);
// returns 404 unless the opt-in is on AND a hero_photo_id is set.
app.get('/og/gallery/:slug/cover', handleGalleryOgCover);
// Branded URL shortener (#699). /s/<short_slug> is bot-UA aware:
// - Social crawler → server-render OG for the target event so the
// SHORT URL itself is what scrapes cache against. The og:url canonical
// in the rendered HTML points back at /s/<slug>, not the underlying
// gallery URL — so a re-share of the same short URL keeps the cache
// warm even if the underlying gallery slug rotates.
// - Browser → 302 to the stored target_path. The target_path was
// captured at create time from the event's slug + share_token + the
// global "Use short gallery URLs" setting, so it doesn't silently
// change later.
// - Soft-deleted → 410 Gone so the admin can tell their delete worked
// vs. a typo'd unknown slug (which returns 404).
const galleryShortUrlService = require('./src/services/galleryShortUrlService');
const { buildOgMetadata, renderOgHtml } = require('./src/services/galleryOgService');
app.get('/s/:shortSlug', async (req, res) => {
try {
const row = await galleryShortUrlService.findByShortSlug(req.params.shortSlug);
if (!row) {
return res.status(404).type('text/plain').send('Short URL not found');
}
if (row.deleted_at) {
return res.status(410).type('text/plain').send('Short URL has been removed');
}
// Bot UA → render OG metadata for the target event. We look up the
// event via the short URL's event_id rather than re-parsing the
// target_path so a future migration that adds new target shapes
// (slideshow, client-access) doesn't need to rewrite the URL parser.
if (isSocialCrawler(req.get('user-agent'))) {
const event = await require('./src/database/db').db('events')
.where({ id: row.event_id })
.first('slug');
if (event?.slug) {
const meta = await buildOgMetadata(event.slug, req.originalUrl);
// Override the canonical to point at the SHORT URL itself —
// social platforms cache OG by URL, and the short URL is the
// one operators actually share, so that's the cache key we
// want them to stick with.
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
meta.url = `${base}/s/${row.short_slug}`;
res.set('Cache-Control', 'public, max-age=300');
res.set('Content-Type', 'text/html; charset=utf-8');
res.send(renderOgHtml(meta));
// Hit accounting is fire-and-forget — don't block the bot.
galleryShortUrlService.recordHit(row.id).catch(() => {});
return;
}
// Event disappeared (FK CASCADE in flight, or admin hard-deleted
// outside the normal soft-delete path) — fall through to 410 so
// the scraper sees a clean signal.
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
}
// Browser path: redirect. Hit accounting is fire-and-forget.
galleryShortUrlService.recordHit(row.id).catch(() => {});
return res.redirect(302, row.target_path);
} catch (err) {
logger.error('Short URL resolver failed', { slug: req.params.shortSlug, error: err.message });
return res.status(500).type('text/plain').send('Internal server error');
}
});
// robots.txt endpoint (dynamic, served from DB settings)
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
app.get('/robots.txt', async (req, res) => {
@@ -628,6 +693,7 @@ app.get('/health', async (req, res) => {
});
// Routes
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
app.use('/api/auth', authRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
@@ -638,6 +704,10 @@ app.use('/api/gallery', require('./src/routes/galleryGuests'));
app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes);
app.use('/api/admin/system', require('./src/routes/adminSystem'));
// Branded URL shortener admin CRUD (#699) — list/create/delete short URLs
// per event. Mounted at /api/admin so the routes appear at
// /api/admin/events/:eventId/short-urls and /api/admin/short-urls/:id.
app.use('/api/admin', require('./src/routes/adminShortUrls'));
app.use('/api/admin/feature-flags', require('./src/routes/adminFeatureFlags'));
app.use('/api/admin/whatsapp', require('./src/routes/adminWhatsapp'));
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
@@ -769,12 +839,21 @@ try {
// SPA fallback for admin + gallery routes. For gallery URLs we intercept
// social-crawler User-Agents and serve OG/Twitter-card metadata so link
// previews show the event name + branding instead of the SPA stub.
app.get('/gallery/:slug/:token?', (req, res, next) => {
//
// Two route shapes — 1-2 segments (`/gallery/:slug/:token?`) and the
// 3-segment slideshow form (`/gallery/:slug/show/:token`). The slideshow
// shape was previously falling through to the SPA-catchall below and
// skipping OG injection entirely (#699). Both patterns route to the
// same handler — buildOgMetadata only looks at `slug`, so the extra
// /show/ segment is harmless.
const ogIntercept = (req, res, next) => {
if (isSocialCrawler(req.get('user-agent'))) {
return handleGalleryOgRequest(req, res);
}
return next();
}, (req, res) => res.sendFile(indexPath));
};
app.get('/gallery/:slug/:token?', ogIntercept, (req, res) => res.sendFile(indexPath));
app.get('/gallery/:slug/show/:token', ogIntercept, (req, res) => res.sendFile(indexPath));
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
res.sendFile(indexPath);
@@ -924,6 +1003,16 @@ async function startServer() {
logger.warn('Install-from-backup hook threw:', err.message);
}
// First-run: surface a one-time setup token while no admin account exists.
// Runs AFTER install-from-backup so a restored instance (which repopulates
// admin_users) never prints a throwaway token. Best-effort — never blocks boot.
let setupToken = null;
try {
setupToken = await require('./src/services/setupService').ensureSetupToken();
} catch (err) {
logger.warn(`[setup] ensureSetupToken skipped: ${err.message}`);
}
// Start backup service
await startBackupService();
@@ -939,6 +1028,13 @@ async function startServer() {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
// First-run: print the one-time setup token to STDOUT (the file logger
// doesn't reach `docker logs`), as the last + most visible thing at boot.
if (setupToken) {
const url = `${process.env.ADMIN_URL || 'http://localhost:3000'}/admin`;
const line = '='.repeat(64);
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n One-time setup token: ${setupToken}\n (also saved to data/SETUP_TOKEN)\n${line}\n`);
}
});
} catch (error) {
logger.error('Failed to start server:', error);
@@ -208,6 +208,79 @@ function makeRes() {
return res;
}
// ---- buildOgMetadata: share-token fallback (#699) ----------------------
//
// The public share URL after migration 525's short-URLs option strips the
// slug down to `/gallery/<32-hex-share-token>`. The OG handler was looking
// up that token as if it were a slug, finding nothing, and serving the
// generic site-wide OG instead of the event-specific one (alex's symptom
// in #699 — Cloudflare Worker had to compensate). resolveSlug now falls
// back to events.share_token when the slug shape matches a 32-char hex.
describe('buildOgMetadata — share-token fallback', () => {
it('resolves a 32-char hex slug via the share_token column when no slug match', async () => {
// Obviously-fake 32-hex test fixture — GitGuardian flagged a
// real-looking token (copied from the bug report) as a Generic
// High Entropy Secret. Using a non-entropy literal sidesteps the
// heuristic without changing what the test pins.
const token = '00000000000000000000000000000001';
const event = {
id: 10,
slug: 'senior-2026-06-05',
share_token: token,
event_name: 'Senior Photo Gallery',
event_date: '2026-06-05',
welcome_message: null,
hero_photo_id: null,
og_image_share_enabled: false,
};
// First db() — events.where('slug', token) returns null.
db.mockImplementationOnce(() => chain({ first: null }));
db.schema = { hasTable: jest.fn().mockResolvedValue(false) };
// Second db() — events.where('share_token', token) returns the event.
db.mockImplementationOnce(() => chain({ first: event }));
mockBranding();
const meta = await buildOgMetadata(token, `/gallery/${token}`);
// Rich event-specific OG, not the site-wide fallback.
expect(meta.title).toContain('Senior Photo Gallery');
expect(meta.eventName).toBe('Senior Photo Gallery');
// og:url canonicalises to the slug-based URL even when the share-token
// URL was the entry point — keeps social-share canonicals stable.
expect(meta.url).toBe('https://gallery.example.com/gallery/senior-2026-06-05');
});
it('returns the site-wide fallback when the 32-hex slug matches NO event at all', async () => {
// Defensive: a malformed/expired token shouldn't 500 or leak any
// event info — it must look identical to the generic fallback path.
const token = '00000000000000000000000000000002';
db.mockImplementationOnce(() => chain({ first: null }));
db.schema = { hasTable: jest.fn().mockResolvedValue(false) };
db.mockImplementationOnce(() => chain({ first: null })); // share_token also misses
mockBranding();
const meta = await buildOgMetadata(token, `/gallery/${token}`);
expect(meta.title).toBe('PicPeak');
expect(meta.eventName).toBeUndefined();
});
it('does NOT attempt the share_token lookup for slugs that don\'t look like a 32-char hex', async () => {
// Real slugs are kebab/dot/underscore mixes — never pure 32-hex.
// Skipping the extra query keeps the un-needed-DB-hit cost off the
// hot path for every legitimate slug.
mockResolveSlug(null); // events lookup misses; no redirects table
mockBranding();
await buildOgMetadata('senior-2026-06-05', '/gallery/senior-2026-06-05');
// Only 2 db() calls — events + app_settings. No share_token
// fallback was attempted for a non-hex slug.
expect(db).toHaveBeenCalledTimes(2);
});
});
describe('handleGalleryOgCover — 404 unless explicitly opted in', () => {
it('returns 400 on an invalid slug shape', async () => {
const req = { params: { slug: '../../etc/passwd' }, headers: {} };
+3 -2
View File
@@ -9,6 +9,7 @@ const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { validateFileType } = require('../utils/fileSecurityUtils');
const logger = require('../utils/logger');
/**
* Get the storage path from environment or default
@@ -220,14 +221,14 @@ const createCustomUploader = (config) => {
const uploadTimeoutMiddleware = (timeout = 300000) => {
return (req, res, next) => {
req.setTimeout(timeout, () => {
console.error('Upload request timed out');
logger.error('Upload request timed out');
if (!res.headersSent) {
res.status(408).json({ error: 'Upload request timed out' });
}
});
res.setTimeout(timeout, () => {
console.error('Upload response timed out');
logger.error('Upload response timed out');
});
next();
+8 -2
View File
@@ -645,8 +645,14 @@ async function ensureGlobalCategories() {
}
// Helper function to log activities
async function logActivity(activityType, metadata = {}, eventId = null, actor = null) {
async function logActivity(activityType, metadata = {}, eventId = null, actor = null, executor = null) {
try {
// Callers issuing the log from inside a knex transaction must pass that
// trx as `executor`, otherwise the global-`db` insert tries to grab a
// second connection from the single-connection SQLite pool while the
// trx still holds it → deadlock. Defaults to the global db for the
// common after-commit / outside-trx callers.
const conn = executor || db;
// actor_id is integer-typed; some legacy callers pass a hex-string
// identifier (e.g. a 16-char guest fingerprint) which makes Postgres
// throw "invalid input syntax for type integer" and drop the entire
@@ -659,7 +665,7 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
const actorName = actor?.name
|| (actorIdInt === null && rawId !== undefined && rawId !== null ? String(rawId) : null);
await db('activity_logs').insert({
await conn('activity_logs').insert({
activity_type: activityType,
actor_type: actor?.type || 'system',
actor_id: actorIdInt,
+4 -3
View File
@@ -1,4 +1,5 @@
const { db } = require('../database/db');
const logger = require('../utils/logger');
// Cache maintenance mode status to avoid DB queries on every request
let maintenanceMode = false;
@@ -26,7 +27,7 @@ async function queryWithRetry(queryFn, retries = MAX_RETRIES) {
error.code === 'ECONNRESET';
if (isConnectionError) {
console.warn(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
logger.warn(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
} else {
throw error; // Don't retry non-connection errors
@@ -56,7 +57,7 @@ async function checkMaintenanceMode() {
return maintenanceMode;
} catch (error) {
console.error('Error checking maintenance mode after retries:', error.message);
logger.error('Error checking maintenance mode after retries:', error.message);
// Return cached value or false if no cache
return maintenanceMode;
}
@@ -102,7 +103,7 @@ async function maintenanceMiddleware(req, res, next) {
}
} catch (error) {
// If we can't check maintenance mode, allow the request to proceed
console.error('Failed to check maintenance mode, allowing request:', error.message);
logger.error('Failed to check maintenance mode, allowing request:', error.message);
}
next();
@@ -265,7 +265,7 @@ class SecureImageMiddleware {
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': "default-src 'none'; img-src 'self'",
'Content-Security-Policy': 'default-src \'none\'; img-src \'self\'',
// Custom security headers
'X-Protected-Content': 'true',
@@ -333,7 +333,7 @@ class SecureImageMiddleware {
await db('security_logs').insert(logData).catch(console.error);
}
} catch (error) {
console.error('Error logging security event:', error);
logger.error('Error logging security event:', error);
}
}
@@ -363,7 +363,7 @@ class SecureImageMiddleware {
perHour: config.perHour || 500
};
} catch (error) {
console.error('Error getting rate limit settings:', error);
logger.error('Error getting rate limit settings:', error);
return { perMinute: 30, per5Minutes: 100, perHour: 500 };
}
}
+4 -3
View File
@@ -1,6 +1,7 @@
const path = require('path');
const express = require('express');
const { safePathJoin, isPathSafe } = require('../utils/fileSecurityUtils');
const logger = require('../utils/logger');
/**
* Create a secure static file serving middleware that prevents path traversal attacks
@@ -17,7 +18,7 @@ function secureStatic(basePath, options = {}) {
// Validate the path doesn't contain dangerous patterns
if (!isPathSafe(requestedPath)) {
console.warn(`Potential path traversal attempt blocked: ${requestedPath}`);
logger.warn(`Potential path traversal attempt blocked: ${requestedPath}`);
return res.status(403).json({ error: 'Access denied' });
}
@@ -43,7 +44,7 @@ function secureStatic(basePath, options = {}) {
// `default-src 'none'` already implies script-src 'none';
// style-src + img-src(data:) keep normal SVG rendering working.
if (/\.svg$/i.test(filePath)) {
resp.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:");
resp.setHeader('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\'; img-src \'self\' data:');
resp.setHeader('X-Content-Type-Options', 'nosniff');
}
}
@@ -52,7 +53,7 @@ function secureStatic(basePath, options = {}) {
return staticMiddleware(req, res, next);
} catch (error) {
// Path traversal detected
console.error(`Path traversal blocked: ${requestedPath}`, error.message);
logger.error(`Path traversal blocked: ${requestedPath}`, error.message);
return res.status(403).json({ error: 'Access denied' });
}
};
+2 -1
View File
@@ -1,6 +1,7 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
// In-memory session tracking (in production, use Redis)
const sessions = new Map();
@@ -69,7 +70,7 @@ async function getSessionTimeout() {
} catch (error) {
// Only log if it's not a connection error (to avoid spam)
if (error.code !== 'ECONNRESET' && !error.message?.includes('Connection terminated')) {
console.error('Error getting session timeout:', error.message);
logger.error('Error getting session timeout:', error.message);
}
}
+20 -20
View File
@@ -9,14 +9,14 @@ const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const StreamZip = require('node-stream-zip');
const { requireEventOwnership } = require('../middleware/ownership');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
const router = express.Router();
// Get all archived events
router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const { page, limit, offset } = getPagination(req);
// Get total count
const totalCount = await db('events')
@@ -48,7 +48,7 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
const stats = await fs.stat(fullArchivePath);
archiveFileSize = stats.size;
} catch (error) {
console.error(`Archive file not found: ${archive.archive_path}`);
logger.error(`Archive file not found: ${archive.archive_path}`);
}
}
@@ -78,7 +78,7 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
}
});
} catch (error) {
console.error('Archives list error:', error);
logger.error('Archives list error:', error);
res.status(500).json({ error: 'Failed to fetch archives' });
}
});
@@ -113,7 +113,7 @@ router.get('/:id', adminAuth, requirePermission('archives.view'), requireEventOw
path: archive.archive_path
};
} catch (error) {
console.error('Archive file not found:', error);
logger.error('Archive file not found:', error);
}
}
@@ -134,7 +134,7 @@ router.get('/:id', adminAuth, requirePermission('archives.view'), requireEventOw
archiveFile: archiveFileInfo
});
} catch (error) {
console.error('Archive details error:', error);
logger.error('Archive details error:', error);
res.status(500).json({ error: 'Failed to fetch archive details' });
}
});
@@ -179,9 +179,9 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
await fs.mkdir(eventDir, { recursive: true });
// Log ZIP contents for debugging
console.log(`Extracting archive to: ${eventDir}`);
logger.info(`Extracting archive to: ${eventDir}`);
const entries = Object.values(await zip.entries());
console.log(`Archive contains ${entries.length} entries`);
logger.info(`Archive contains ${entries.length} entries`);
// Stream-extract everything to disk
await zip.extract(null, eventDir);
@@ -203,12 +203,12 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
if (m && m.filename) manifestByFilename.set(m.filename, m);
}
}
console.log(`Loaded photos manifest: ${manifestByFilename.size} entries`);
logger.info(`Loaded photos manifest: ${manifestByFilename.size} entries`);
} catch (e) {
if (e.code !== 'ENOENT') {
console.warn('Photos manifest present but unreadable; falling back to filenames', e.message);
logger.warn('Photos manifest present but unreadable; falling back to filenames', e.message);
} else {
console.log('No photos manifest in archive (older archive); original_filename falls back to filename');
logger.info('No photos manifest in archive (older archive); original_filename falls back to filename');
}
}
@@ -286,9 +286,9 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
});
}
} catch (statError) {
console.error(`Failed to stat file: ${actualFilePath}`);
console.error(`Entry name was: ${entry.name}`);
console.error('Error:', statError.message);
logger.error(`Failed to stat file: ${actualFilePath}`);
logger.error(`Entry name was: ${entry.name}`);
logger.error('Error:', statError.message);
// Skip this file if we can't stat it
continue;
}
@@ -301,7 +301,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
}
} catch (extractError) {
console.error('Archive extraction error:', extractError);
logger.error('Archive extraction error:', extractError);
return res.status(500).json({ error: 'Failed to extract archive: ' + extractError.message });
}
@@ -331,7 +331,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
res.json({ message: 'Archive restored successfully' });
} catch (error) {
console.error('Archive restore error:', error);
logger.error('Archive restore error:', error);
res.status(500).json({ error: 'Failed to restore archive' });
}
});
@@ -380,7 +380,7 @@ router.get('/:id/download', adminAuth, requirePermission('archives.download'), r
metadata: JSON.stringify({ event_name: archive.event_name })
});
} catch (error) {
console.error('Archive download error:', error);
logger.error('Archive download error:', error);
res.status(500).json({ error: 'Failed to download archive' });
}
});
@@ -404,7 +404,7 @@ router.delete('/:id', adminAuth, requirePermission('archives.delete'), requireEv
const fullArchivePath = path.join(storagePath, archive.archive_path);
await fs.unlink(fullArchivePath);
} catch (error) {
console.error('Failed to delete archive file:', error);
logger.error('Failed to delete archive file:', error);
}
}
@@ -440,7 +440,7 @@ router.delete('/:id', adminAuth, requirePermission('archives.delete'), requireEv
res.json({ message: 'Archive deleted permanently' });
} catch (error) {
console.error('Archive delete error:', error);
logger.error('Archive delete error:', error);
res.status(500).json({ error: 'Failed to delete archive' });
}
});
+191 -215
View File
@@ -4,6 +4,8 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
const logger = require('../utils/logger');
const { errorResponse, getPagination } = require('../utils/routeHelpers');
const { formatBytes } = require('../utils/formatBytes');
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
@@ -30,8 +32,7 @@ router.get('/config', adminAuth, requirePermission('backup.view'), async (req, r
res.json(config);
} catch (error) {
logger.error('Failed to get backup configuration:', error);
res.status(500).json({ error: 'Failed to get backup configuration' });
errorResponse(res, error, 500, 'Failed to get backup configuration');
}
});
@@ -43,22 +44,22 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
// Validate required fields based on destination type
if (updates.backup_destination_type) {
switch (updates.backup_destination_type) {
case 'local':
if (!updates.backup_destination_path) {
return res.status(400).json({ error: 'Local backup requires destination path' });
}
break;
case 'rsync':
if (!updates.backup_rsync_host || !updates.backup_rsync_path) {
return res.status(400).json({ error: 'Rsync backup requires host and path' });
}
break;
case 's3':
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
case 'local':
if (!updates.backup_destination_path) {
return res.status(400).json({ error: 'Local backup requires destination path' });
}
break;
case 'rsync':
if (!updates.backup_rsync_host || !updates.backup_rsync_path) {
return res.status(400).json({ error: 'Rsync backup requires host and path' });
}
break;
case 's3':
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
}
break;
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
}
break;
}
}
@@ -92,21 +93,19 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
res.json({ success: true, message: 'Backup configuration updated' });
} catch (error) {
logger.error('Failed to update backup configuration:', error);
res.status(500).json({ error: 'Failed to update backup configuration' });
errorResponse(res, error, 500, 'Failed to update backup configuration');
}
});
// Get backup status and history
router.get('/status', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const { limit } = getPagination(req, { limit: 10 });
const status = await getBackupStatus(limit);
res.json(status);
} catch (error) {
logger.error('Failed to get backup status:', error);
res.status(500).json({ error: 'Failed to get backup status' });
errorResponse(res, error, 500, 'Failed to get backup status');
}
});
@@ -126,8 +125,7 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r
res.json({ success: true, message: 'Backup started' });
} catch (error) {
logger.error('Failed to trigger manual backup:', error);
res.status(500).json({ error: 'Failed to trigger backup' });
errorResponse(res, error, 500, 'Failed to trigger backup');
}
});
@@ -155,8 +153,7 @@ router.get('/runs/:id', adminAuth, requirePermission('backup.view'), async (req,
res.json(run);
} catch (error) {
logger.error('Failed to get backup run details:', error);
res.status(500).json({ error: 'Failed to get backup run details' });
errorResponse(res, error, 500, 'Failed to get backup run details');
}
});
@@ -190,8 +187,7 @@ router.get('/files', adminAuth, requirePermission('backup.view'), async (req, re
}
});
} catch (error) {
logger.error('Failed to get backup file states:', error);
res.status(500).json({ error: 'Failed to get file states' });
errorResponse(res, error, 500, 'Failed to get file states');
}
});
@@ -204,8 +200,7 @@ router.delete('/cleanup', adminAuth, requirePermission('backup.delete'), async (
res.json({ success: true, message: `Cleaned up backup runs older than ${days} days` });
} catch (error) {
logger.error('Failed to cleanup old backup runs:', error);
res.status(500).json({ error: 'Failed to cleanup backup runs' });
errorResponse(res, error, 500, 'Failed to cleanup backup runs');
}
});
@@ -215,129 +210,128 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
const { destination_type, ...config } = req.body;
switch (destination_type) {
case 'local':
// Test local path access
const fs = require('fs').promises;
try {
await fs.access(config.path, fs.constants.W_OK);
res.json({ success: true, message: 'Local path is writable' });
} catch (error) {
logger.warn('Local backup path not writable', {
path: config.path,
error: error.message
});
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
}
break;
case 'local':
// Test local path access
const fs = require('fs').promises;
try {
await fs.access(config.path, fs.constants.W_OK);
res.json({ success: true, message: 'Local path is writable' });
} catch (error) {
logger.warn('Local backup path not writable', {
path: config.path,
error: error.message
});
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
}
break;
case 'rsync':
// Test rsync connection using spawn with argument arrays to prevent command injection
const { spawn } = require('child_process');
case 'rsync':
// Test rsync connection using spawn with argument arrays to prevent command injection
const { spawn } = require('child_process');
// Validate and sanitize inputs to prevent command injection
const sanitizeInput = (input) => {
if (!input || typeof input !== 'string') return null;
// Remove any shell metacharacters and limit length
return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255);
};
// Validate and sanitize inputs to prevent command injection
const sanitizeInput = (input) => {
if (!input || typeof input !== 'string') return null;
// Remove any shell metacharacters and limit length
return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255);
};
const host = sanitizeInput(config.host);
const user = sanitizeInput(config.user);
const sshKeyPath = sanitizeInput(config.ssh_key);
const host = sanitizeInput(config.host);
const user = sanitizeInput(config.user);
const sshKeyPath = sanitizeInput(config.ssh_key);
if (!host) {
res.json({ success: false, message: 'Invalid host specified' });
if (!host) {
res.json({ success: false, message: 'Invalid host specified' });
break;
}
// Validate host format (hostname or IP only)
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!hostRegex.test(host) && !ipRegex.test(host)) {
res.json({ success: false, message: 'Invalid host format' });
break;
}
// SSRF protection: block connections to private/internal addresses
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(host)) {
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
break;
}
// Validate username format if provided
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
res.json({ success: false, message: 'Invalid username format' });
break;
}
// Build SSH arguments as array (safe from injection)
const sshArgs = [];
if (sshKeyPath) {
// Validate SSH key path exists and is a file
const fsSync = require('fs');
if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) {
res.json({ success: false, message: 'SSH key file not found' });
break;
}
sshArgs.push('-i', sshKeyPath);
}
sshArgs.push('-o', 'StrictHostKeyChecking=no');
sshArgs.push('-o', 'ConnectTimeout=10');
sshArgs.push('-o', 'BatchMode=yes');
// Validate host format (hostname or IP only)
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!hostRegex.test(host) && !ipRegex.test(host)) {
res.json({ success: false, message: 'Invalid host format' });
break;
}
// Add target (user@host or just host)
const target = user ? `${user}@${host}` : host;
sshArgs.push(target);
sshArgs.push('echo', 'Connection successful');
// SSRF protection: block connections to private/internal addresses
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(host)) {
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
break;
}
// Validate username format if provided
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
res.json({ success: false, message: 'Invalid username format' });
break;
}
// Build SSH arguments as array (safe from injection)
const sshArgs = [];
if (sshKeyPath) {
// Validate SSH key path exists and is a file
const fsSync = require('fs');
if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) {
res.json({ success: false, message: 'SSH key file not found' });
break;
}
sshArgs.push('-i', sshKeyPath);
}
sshArgs.push('-o', 'StrictHostKeyChecking=no');
sshArgs.push('-o', 'ConnectTimeout=10');
sshArgs.push('-o', 'BatchMode=yes');
// Add target (user@host or just host)
const target = user ? `${user}@${host}` : host;
sshArgs.push(target);
sshArgs.push('echo', 'Connection successful');
try {
const result = await new Promise((resolve, reject) => {
const sshProcess = spawn('ssh', sshArgs, {
timeout: 15000,
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
sshProcess.stdout.on('data', (data) => { stdout += data; });
sshProcess.stderr.on('data', (data) => { stderr += data; });
sshProcess.on('close', (code) => {
if (code === 0) {
resolve({ success: true, stdout });
} else {
reject(new Error(stderr || `SSH exited with code ${code}`));
}
});
sshProcess.on('error', (err) => {
reject(err);
});
try {
const result = await new Promise((resolve, reject) => {
const sshProcess = spawn('ssh', sshArgs, {
timeout: 15000,
stdio: ['ignore', 'pipe', 'pipe']
});
res.json({ success: true, message: 'Rsync connection successful' });
} catch (error) {
logger.warn('Rsync connection test failed', {
destination: host,
error: error.message
let stdout = '';
let stderr = '';
sshProcess.stdout.on('data', (data) => { stdout += data; });
sshProcess.stderr.on('data', (data) => { stderr += data; });
sshProcess.on('close', (code) => {
if (code === 0) {
resolve({ success: true, stdout });
} else {
reject(new Error(stderr || `SSH exited with code ${code}`));
}
});
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
}
break;
sshProcess.on('error', (err) => {
reject(err);
});
});
res.json({ success: true, message: 'Rsync connection successful' });
} catch (error) {
logger.warn('Rsync connection test failed', {
destination: host,
error: error.message
});
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
}
break;
case 's3':
// Test S3 connection (would need AWS SDK)
res.json({ success: false, message: 'S3 testing not implemented yet' });
break;
case 's3':
// Test S3 connection (would need AWS SDK)
res.json({ success: false, message: 'S3 testing not implemented yet' });
break;
default:
res.status(400).json({ error: 'Invalid destination type' });
default:
res.status(400).json({ error: 'Invalid destination type' });
}
} catch (error) {
logger.error('Failed to test backup connection:', error);
res.status(500).json({ error: 'Failed to test connection' });
errorResponse(res, error, 500, 'Failed to test connection');
}
});
@@ -380,8 +374,7 @@ router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), a
manifestPath
});
} catch (error) {
logger.error('Failed to validate manifest:', error);
res.status(500).json({ error: 'Failed to validate manifest' });
errorResponse(res, error, 500, 'Failed to validate manifest');
}
});
@@ -493,8 +486,7 @@ router.post('/manifests/validate', adminAuth, requirePermission('backup.view'),
manifestPath
});
} catch (error) {
logger.error('Failed to validate manifest:', error);
res.status(500).json({ error: 'Failed to validate manifest' });
errorResponse(res, error, 500, 'Failed to validate manifest');
}
});
@@ -525,8 +517,7 @@ router.get('/s3/buckets', adminAuth, requirePermission('backup.view'), async (re
owner: result.Owner || null
});
} catch (error) {
logger.error('Failed to list S3 buckets:', error);
res.status(500).json({ error: 'Failed to list S3 buckets' });
errorResponse(res, error, 500, 'Failed to list S3 buckets');
}
});
@@ -562,8 +553,7 @@ router.get('/s3/files', adminAuth, requirePermission('backup.view'), async (req,
prefix: prefix
});
} catch (error) {
logger.error('Failed to list S3 files:', error);
res.status(500).json({ error: 'Failed to list S3 files' });
errorResponse(res, error, 500, 'Failed to list S3 files');
}
});
@@ -624,8 +614,7 @@ router.delete('/s3/cleanup', adminAuth, requirePermission('backup.delete'), asyn
message: `Cleaned up ${deletedCount} S3 backup files older than ${retentionDays} days`
});
} catch (error) {
logger.error('Failed to cleanup S3 backups:', error);
res.status(500).json({ error: 'Failed to cleanup S3 backups' });
errorResponse(res, error, 500, 'Failed to cleanup S3 backups');
}
});
@@ -676,8 +665,7 @@ router.post('/s3/test-upload', adminAuth, requirePermission('backup.create'), as
message: 'S3 upload test completed successfully'
});
} catch (error) {
logger.error('S3 upload test failed:', error);
res.status(500).json({ error: 'S3 upload test failed' });
errorResponse(res, error, 500, 'S3 upload test failed');
}
});
@@ -703,69 +691,68 @@ router.get('/download/:backupId', adminAuth, requirePermission('backup.view'), a
// Handle different backup types
switch (config.backup_destination_type) {
case 'local':
// Stream local backup as zip
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
const archive = archiver('zip', { zlib: { level: 9 } });
case 'local':
// Stream local backup as zip
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
const archive = archiver('zip', { zlib: { level: 9 } });
res.attachment(`picpeak-backup-${backupRun.id}.zip`);
archive.pipe(res);
res.attachment(`picpeak-backup-${backupRun.id}.zip`);
archive.pipe(res);
// Add backup directory contents
archive.directory(backupPath, false);
// Add backup directory contents
archive.directory(backupPath, false);
// Add manifest if exists
if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) {
archive.file(backupRun.manifest_path, { name: 'manifest.json' });
}
// Add manifest if exists
if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) {
archive.file(backupRun.manifest_path, { name: 'manifest.json' });
}
await archive.finalize();
break;
await archive.finalize();
break;
case 's3':
// For S3, provide pre-signed URLs or stream files
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
case 's3':
// For S3, provide pre-signed URLs or stream files
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
});
// List all files for this backup
const prefix = `backups/${backupRun.id}/`;
const files = await s3Adapter.list(prefix, { maxKeys: 1000 });
// Generate pre-signed URLs
const urls = [];
for (const file of files.objects || []) {
const url = await s3Adapter.getSignedUrl('getObject', file.key, { expiresIn: 3600 }); // 1 hour
urls.push({
key: file.key,
size: file.size,
url: url
});
}
// List all files for this backup
const prefix = `backups/${backupRun.id}/`;
const files = await s3Adapter.list(prefix, { maxKeys: 1000 });
res.json({
backupId: backupRun.id,
type: 's3',
files: urls,
expiresIn: 3600,
message: 'Use the provided URLs to download individual files'
});
break;
// Generate pre-signed URLs
const urls = [];
for (const file of files.objects || []) {
const url = await s3Adapter.getSignedUrl('getObject', file.key, { expiresIn: 3600 }); // 1 hour
urls.push({
key: file.key,
size: file.size,
url: url
});
}
case 'rsync':
return res.status(400).json({ error: 'Direct download not available for rsync backups' });
res.json({
backupId: backupRun.id,
type: 's3',
files: urls,
expiresIn: 3600,
message: 'Use the provided URLs to download individual files'
});
break;
case 'rsync':
return res.status(400).json({ error: 'Direct download not available for rsync backups' });
default:
return res.status(400).json({ error: 'Unknown backup type' });
default:
return res.status(400).json({ error: 'Unknown backup type' });
}
} catch (error) {
logger.error('Failed to download backup:', error);
res.status(500).json({ error: 'Failed to download backup' });
errorResponse(res, error, 500, 'Failed to download backup');
}
});
@@ -837,8 +824,7 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req
path: targetPath || '/'
});
} catch (error) {
logger.error('Failed to get file checksums:', error);
res.status(500).json({ error: 'Failed to get file checksums' });
errorResponse(res, error, 500, 'Failed to get file checksums');
}
});
@@ -940,21 +926,11 @@ router.post('/estimate', adminAuth, requirePermission('backup.view'), async (req
warnings: totalSize > 10 * 1024 * 1024 * 1024 ? ['Backup size exceeds 10GB, may take significant time'] : []
});
} catch (error) {
logger.error('Failed to estimate backup size:', error);
res.status(500).json({ error: 'Failed to estimate backup size' });
errorResponse(res, error, 500, 'Failed to estimate backup size');
}
});
// Helper function to format bytes
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// Helper function to get backup configuration
async function getBackupConfig() {
try {
+6 -5
View File
@@ -7,6 +7,7 @@ const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { validateFileType } = require('../utils/fileSecurityUtils');
const logger = require('../utils/logger');
const router = express.Router();
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -43,7 +44,7 @@ router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res)
const pages = await db('cms_pages').select('*').orderBy('slug', 'asc');
res.json(pages);
} catch (error) {
console.error('Error fetching CMS pages:', error);
logger.error('Error fetching CMS pages:', error);
res.status(500).json({ error: 'Failed to fetch pages' });
}
});
@@ -60,7 +61,7 @@ router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req,
res.json(page);
} catch (error) {
console.error('Error fetching CMS page:', error);
logger.error('Error fetching CMS page:', error);
res.status(500).json({ error: 'Failed to fetch page' });
}
});
@@ -144,7 +145,7 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
res.json(updated);
} catch (error) {
console.error('Error updating CMS page:', error);
logger.error('Error updating CMS page:', error);
res.status(500).json({ error: 'Failed to update page' });
}
});
@@ -184,7 +185,7 @@ router.post(
res.json({ logo_url: logoUrl });
} catch (error) {
console.error('Error uploading CMS page logo:', error);
logger.error('Error uploading CMS page logo:', error);
res.status(500).json({ error: 'Failed to upload logo' });
}
}
@@ -208,7 +209,7 @@ router.delete(
res.json({ logo_url: null });
} catch (error) {
console.error('Error clearing CMS page logo:', error);
logger.error('Error clearing CMS page logo:', error);
res.status(500).json({ error: 'Failed to clear logo' });
}
}
+7 -6
View File
@@ -4,6 +4,7 @@ const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const logger = require('../utils/logger');
const router = express.Router();
// Get all global categories
@@ -15,7 +16,7 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
res.json(categories);
} catch (error) {
console.error('Error fetching categories:', error);
logger.error('Error fetching categories:', error);
res.status(500).json({ error: 'Failed to fetch categories' });
}
});
@@ -35,7 +36,7 @@ router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), asy
res.json(categories);
} catch (error) {
console.error('Error fetching event categories:', error);
logger.error('Error fetching event categories:', error);
res.status(500).json({ error: 'Failed to fetch categories' });
}
});
@@ -101,7 +102,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
res.json(category);
} catch (error) {
console.error('Error creating category:', error);
logger.error('Error creating category:', error);
res.status(500).json({ error: 'Failed to create category' });
}
});
@@ -165,7 +166,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
res.json(updated);
} catch (error) {
console.error('Error updating category:', error);
logger.error('Error updating category:', error);
res.status(500).json({ error: 'Failed to update category' });
}
});
@@ -214,7 +215,7 @@ router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
res.json(updated);
} catch (error) {
console.error('Error updating category hero:', error);
logger.error('Error updating category hero:', error);
res.status(500).json({ error: 'Failed to update category hero' });
}
});
@@ -248,7 +249,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
res.json({ message: 'Category deleted successfully' });
} catch (error) {
console.error('Error deleting category:', error);
logger.error('Error deleting category:', error);
res.status(500).json({ error: 'Failed to delete category' });
}
});
+6 -5
View File
@@ -11,6 +11,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { sanitizeCSS, validateCSS, MAX_CSS_SIZE } = require('../utils/cssSanitizer');
const { DEFAULT_CSS_TEMPLATE } = require('../../migrations/core/052_add_css_templates');
const logger = require('../utils/logger');
/**
* GET /admin/css-templates
@@ -23,7 +24,7 @@ router.get('/', adminAuth, requirePermission('branding.view'), async (req, res)
);
res.json({ success: true, templates });
} catch (error) {
console.error('Get CSS templates error:', error);
logger.error('Get CSS templates error:', error);
res.status(500).json({ error: 'Failed to fetch templates' });
}
});
@@ -42,7 +43,7 @@ router.get('/enabled', adminAuth, requirePermission('branding.view'), async (req
);
res.json({ success: true, templates });
} catch (error) {
console.error('Get enabled templates error:', error);
logger.error('Get enabled templates error:', error);
res.status(500).json({ error: 'Failed to fetch templates' });
}
});
@@ -73,7 +74,7 @@ router.get('/:slotNumber', adminAuth, requirePermission('branding.view'), [
res.json({ success: true, template });
} catch (error) {
console.error('Get template error:', error);
logger.error('Get template error:', error);
res.status(500).json({ error: 'Failed to fetch template' });
}
});
@@ -150,7 +151,7 @@ router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [
sanitization_warnings: warnings
});
} catch (error) {
console.error('Update template error:', error);
logger.error('Update template error:', error);
res.status(500).json({ error: 'Failed to update template' });
}
});
@@ -187,7 +188,7 @@ router.post('/:slotNumber/reset', adminAuth, requirePermission('branding.edit'),
res.json({ success: true, template });
} catch (error) {
console.error('Reset template error:', error);
logger.error('Reset template error:', error);
res.status(500).json({ error: 'Failed to reset template' });
}
});
+22 -18
View File
@@ -6,6 +6,7 @@ const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat');
const { resolveAdapter } = require('../services/trackers');
const logger = require('../utils/logger');
const { errorResponse, getPagination } = require('../utils/routeHelpers');
const router = express.Router();
/**
@@ -126,16 +127,15 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
totalEvents: totalEvents.count || 0
});
} catch (error) {
console.error('Dashboard stats error:', error);
res.status(500).json({ error: 'Failed to fetch dashboard statistics' });
errorResponse(res, error, 500, 'Failed to fetch dashboard statistics');
}
});
// Get recent activity
router.get('/activity', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const { limit } = getPagination(req, { limit: 10 });
const activities = await db('activity_logs')
.select('activity_logs.*', 'events.event_name')
.leftJoin('events', 'activity_logs.event_id', 'events.id')
@@ -155,7 +155,7 @@ router.get('/activity', adminAuth, requirePermission('analytics.view'), async (r
if (typeof activity.metadata === 'object') return activity.metadata;
return JSON.parse(activity.metadata);
} catch (e) {
console.warn('Failed to parse metadata for activity:', activity.id, e.message);
logger.warn('Failed to parse metadata for activity:', activity.id, e.message);
return {};
}
})(),
@@ -164,8 +164,7 @@ router.get('/activity', adminAuth, requirePermission('analytics.view'), async (r
res.json(formattedActivities);
} catch (error) {
console.error('Activity log error:', error);
res.status(500).json({ error: 'Failed to fetch activity log' });
errorResponse(res, error, 500, 'Failed to fetch activity log');
}
});
@@ -233,7 +232,7 @@ router.get('/health', adminAuth, requirePermission('settings.view'), async (req,
}
});
} catch (error) {
console.error('Health check error:', error);
logger.error('Health check error:', error);
res.status(500).json({
overall: 'error',
error: 'Failed to check system health'
@@ -400,8 +399,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
}
});
} catch (error) {
console.error('Analytics error:', error);
res.status(500).json({ error: 'Failed to fetch analytics data' });
errorResponse(res, error, 500, 'Failed to fetch analytics data');
}
});
@@ -439,6 +437,10 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
const monthCutoff = new Date(now - 30 * DAY);
const quarterCutoff = new Date(now - 90 * DAY);
const yearCutoff = new Date(now - 365 * DAY);
// Calendar year-to-date (Jan 1 of the current year, local time) —
// the dashboard's revenue "year" tile can toggle between this and
// the trailing-365-day window.
const calendarYearCutoff = new Date(new Date(now).getFullYear(), 0, 1);
// ---- quotes: counts by status ---------------------------------
let quoteCounts = { draft: 0, sent: 0, accepted: 0, declined: 0, expired: 0, converted: 0 };
@@ -459,6 +461,7 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
let revenueMonthMinor = 0;
let revenueQuarterMinor = 0;
let revenueYearMinor = 0;
let revenueCalendarYearMinor = 0;
let outstandingTotalMinor = 0;
let outstandingCount = 0;
@@ -504,9 +507,10 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
.first();
return Number(row?.total || 0);
};
revenueMonthMinor = await winSum(monthCutoff);
revenueQuarterMinor = await winSum(quarterCutoff);
revenueYearMinor = await winSum(yearCutoff);
revenueMonthMinor = await winSum(monthCutoff);
revenueQuarterMinor = await winSum(quarterCutoff);
revenueYearMinor = await winSum(yearCutoff);
revenueCalendarYearMinor = await winSum(calendarYearCutoff);
// Outstanding: every invoice that's been sent but not fully
// paid (sent + overdue). Outstanding = total - paid. We sum
@@ -568,9 +572,10 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
quotes: quoteCounts,
invoices: invoiceCounts,
revenue: {
monthMinor: revenueMonthMinor,
quarterMinor: revenueQuarterMinor,
yearMinor: revenueYearMinor,
monthMinor: revenueMonthMinor,
quarterMinor: revenueQuarterMinor,
yearMinor: revenueYearMinor,
calendarYearMinor: revenueCalendarYearMinor,
},
outstanding: {
totalMinor: outstandingTotalMinor,
@@ -579,8 +584,7 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
generatedAt: new Date().toISOString(),
});
} catch (error) {
require('../utils/logger').error('CRM stats error:', error);
res.status(500).json({ error: 'Failed to load CRM stats' });
errorResponse(res, error, 500, 'Failed to load CRM stats');
}
});
+3 -4
View File
@@ -5,6 +5,7 @@ const { requirePermission } = require('../middleware/permissions');
const { databaseBackupService } = require('../services/databaseBackup');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
// All routes require admin authentication
router.use(adminAuth);
@@ -154,10 +155,8 @@ router.get('/progress', requirePermission('backup.view'), async (req, res) => {
*/
router.get('/history', requirePermission('backup.view'), async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const { page, limit, offset } = getPagination(req);
const [backups, totalCount] = await Promise.all([
db('database_backup_runs')
.orderBy('started_at', 'desc')
+22 -30
View File
@@ -5,6 +5,8 @@ const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
const { errorResponse } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
const router = express.Router();
// Get email configuration
@@ -31,8 +33,7 @@ router.get('/config', adminAuth, requirePermission('email.view'), async (req, re
smtp_pass: config.smtp_pass ? '********' : ''
});
} catch (error) {
console.error('Email config fetch error:', error);
res.status(500).json({ error: 'Failed to fetch email configuration' });
errorResponse(res, error, 500, 'Failed to fetch email configuration');
}
});
@@ -113,8 +114,7 @@ router.post('/config', [
res.json({ message: 'Email configuration updated successfully' });
} catch (error) {
console.error('Email config update error:', error);
res.status(500).json({ error: 'Failed to update email configuration' });
errorResponse(res, error, 500, 'Failed to update email configuration');
}
});
@@ -131,8 +131,7 @@ router.get('/incoming-config', adminAuth, requirePermission('email.view'), async
imap_folder: c?.imap_folder || 'INBOX',
});
} catch (error) {
console.error('Incoming mail config fetch error:', error);
res.status(500).json({ error: 'Failed to fetch incoming mail configuration' });
errorResponse(res, error, 500, 'Failed to fetch incoming mail configuration');
}
});
@@ -168,8 +167,7 @@ router.post('/incoming-config', [
await logActivity('incoming_mail_config_updated', { imap_host }, null, { type: 'admin', id: req.admin.id, name: req.admin.username });
res.json({ message: 'Incoming mail configuration updated successfully' });
} catch (error) {
console.error('Incoming mail config update error:', error);
res.status(500).json({ error: 'Failed to update incoming mail configuration' });
errorResponse(res, error, 500, 'Failed to update incoming mail configuration');
}
});
@@ -192,7 +190,7 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view
);
res.json({ folders });
} catch (error) {
console.error('IMAP folder detection error:', error);
logger.error('IMAP folder detection error:', error);
res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993) and credentials.` });
}
});
@@ -217,7 +215,7 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'),
}
res.json(result);
} catch (error) {
console.error('IMAP connection test error:', error);
logger.error('IMAP connection test error:', error);
res.status(422).json({ error: `Could not connect to the mailbox (${error.message}). Check host, port (IMAP is usually 993), credentials and folder.` });
}
});
@@ -239,7 +237,7 @@ router.post('/incoming-config/roundtrip', adminAuth, requirePermission('email.se
return res.status(result.reason === 'not_received' ? 504 : 400)
.json({ error: map[result.reason] || 'Round-trip test failed.', sent: !!result.sent, recipient: result.recipient });
} catch (error) {
console.error('Round-trip test error:', error);
logger.error('Round-trip test error:', error);
res.status(422).json({ error: `Round-trip test failed (${error.message}) — check both SMTP and IMAP settings.` });
}
});
@@ -253,7 +251,7 @@ router.post('/incoming-config/poll', adminAuth, requirePermission('email.view'),
const result = await emailIntakeService.pollOnce();
res.json(result); // { processed } or { skipped: 'disabled'|'unconfigured'|'busy' }
} catch (error) {
console.error('Manual poll error:', error);
logger.error('Manual poll error:', error);
res.status(422).json({ error: `Mailbox poll failed (${error.message}).` });
}
});
@@ -268,8 +266,7 @@ router.get('/received', adminAuth, requirePermission('email.view'), async (req,
const items = await base.clone().orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
res.json({ items, pagination: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) } });
} catch (error) {
console.error('Received emails fetch error:', error);
res.status(500).json({ error: 'Failed to fetch received emails' });
errorResponse(res, error, 500, 'Failed to fetch received emails');
}
});
@@ -322,7 +319,7 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
debug: process.env.NODE_ENV === 'development'
};
console.log('Creating email transporter with config:', {
logger.info('Creating email transporter with config:', {
host: transportConfig.host,
port: transportConfig.port,
secure: transportConfig.secure,
@@ -356,8 +353,8 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
res.json({ message: 'Test email sent successfully' });
} catch (error) {
console.error('Test email error:', error);
console.error('Error stack:', error.stack);
logger.error('Test email error:', error);
logger.error('Error stack:', error.stack);
// Provide more specific error messages with translation keys
let errorMessage = 'Error sending email';
@@ -428,7 +425,7 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r
} catch (_) { /* activity logging is best-effort */ }
res.json({ message: 'Email queue flushed', ...summary });
} catch (error) {
console.error('Flush email queue error:', error);
logger.error('Flush email queue error:', error);
res.status(500).json({ error: 'Failed to flush email queue', details: error.message });
}
});
@@ -516,7 +513,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 },
});
} catch (error) {
console.error('List email queue error:', error);
logger.error('List email queue error:', error);
res.status(500).json({ error: 'Failed to load email queue', details: error.message });
}
});
@@ -528,7 +525,7 @@ function parseVariables(template) {
if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
logger.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
}
@@ -611,8 +608,7 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
res.json(formattedTemplates);
} catch (error) {
console.error('Email templates fetch error:', error);
res.status(500).json({ error: 'Failed to fetch email templates' });
errorResponse(res, error, 500, 'Failed to fetch email templates');
}
});
@@ -641,8 +637,7 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
updated_at: template.updated_at,
});
} catch (error) {
console.error('Email template fetch error:', error);
res.status(500).json({ error: 'Failed to fetch email template' });
errorResponse(res, error, 500, 'Failed to fetch email template');
}
});
@@ -730,8 +725,7 @@ router.put('/templates/:key', [
res.json({ message: 'Email template updated successfully' });
} catch (error) {
console.error('Email template update error:', error);
res.status(500).json({ error: 'Failed to update email template' });
errorResponse(res, error, 500, 'Failed to update email template');
}
});
@@ -818,8 +812,7 @@ router.post('/templates', [
return res.status(201).json({ template_key: templateKey, id: templateId });
} catch (error) {
console.error('Email template create error:', error);
return res.status(500).json({ error: 'Failed to create email template' });
return errorResponse(res, error, 500, 'Failed to create email template');
}
});
@@ -897,8 +890,7 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
language
});
} catch (error) {
console.error('Email template preview error:', error);
res.status(500).json({ error: 'Failed to preview email template' });
errorResponse(res, error, 500, 'Failed to preview email template');
}
});
+3 -2
View File
@@ -8,6 +8,7 @@ const { body, validationResult } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const eventRenameService = require('../services/eventRenameService');
const logger = require('../utils/logger');
const router = express.Router();
/**
@@ -50,7 +51,7 @@ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
data: result.data
});
} catch (error) {
console.error('Error renaming event:', error);
logger.error('Error renaming event:', error);
res.status(500).json({ success: false, error: 'Failed to rename event' });
}
});
@@ -81,7 +82,7 @@ router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.ed
res.json(validation);
} catch (error) {
console.error('Error validating rename:', error);
logger.error('Error validating rename:', error);
res.status(500).json({ valid: false, error: 'Validation failed' });
}
});
-134
View File
@@ -1,134 +0,0 @@
// This is a partial file showing the enhanced event creation with password validation
// Only the relevant parts are shown - merge with existing adminEvents.js
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { requirePermission } = require('../middleware/permissions');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
// Enhanced event creation with password validation
// Note: This is a partial/reference file - dynamic event type validation should be implemented
// similar to adminEvents.js using eventTypeService.isValidEventType()
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
body('admin_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
body('password').notEmpty(), // Remove the weak isLength validation
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
body('welcome_message').optional().trim(),
body('color_theme').optional().trim(),
body('allow_user_uploads').optional().isBoolean().toBoolean(),
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
body('customer_name').notEmpty().trim()
], async (req, res) => {
try {
console.log('Create event request body:', req.body);
const errors = validationResult(req);
if (!errors.isEmpty()) {
console.error('Validation errors:', errors.array());
return res.status(400).json({ errors: errors.array() });
}
const {
event_type,
event_name,
event_date,
customer_name,
customer_email,
admin_email,
password,
welcome_message = '',
color_theme = null,
expiration_days = 30,
allow_user_uploads = false,
upload_category_id = null,
photo_cap = null
} = req.body;
// Validate password strength for gallery
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
// Generate unique slug
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
}
// Generate share link based on configured style
const shareToken = crypto.randomBytes(16).toString('hex');
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds
const password_hash = await bcrypt.hash(password, getBcryptRounds());
// Calculate expiration date (days after event date)
const expires_at = new Date(event_date);
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const eventPath = path.join(storagePath, 'events/active', slug);
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Insert into database
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
event_date,
customer_name,
customer_email,
host_name: customer_name,
host_email: customer_email,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
allow_user_uploads,
upload_category_id,
photo_cap: photo_cap || null
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// Log activity
await logActivity('event_created',
{
event_type,
expires_at,
password_strength: passwordValidation.score
},
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Rest of the implementation remains the same...
// Queue creation email, etc.
} catch (error) {
console.error('Error creating event:', error);
res.status(500).json({ error: 'Failed to create event' });
}
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,196 @@
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
// Exports a register function; ./index.js calls the sub-routers in the original
// registration order so Express route matching is unchanged.
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../../database/db');
const { formatBoolean } = require('../../utils/dbCompat');
const { adminAuth } = require('../../middleware/auth');
const { requirePermission } = require('../../middleware/permissions');
const { archiveEvent } = require('../../services/archiveService');
const logger = require('../../utils/logger');
const { errorResponse } = require('../../utils/routeHelpers');
const { requireEventOwnership } = require('../../middleware/ownership');
const { deleteEventCascade } = require('./helpers');
// Bulk delete — destructive, irreversible. Caps at 100 events per request
// to keep request time bounded; the per-event cascade touches 5 DB tables
// + 3 filesystem paths so 1000 events would risk timing out the request.
// Loops via deleteEventCascade so the per-event delete behaviour stays in
// lock-step with DELETE /:id.
//
// Confirmation is enforced client-side via the typed-DELETE pattern in
// BulkDeleteModal (#417). The previous server-side bcrypt-password gate
// was dropped because the destructive single-event DELETE /:id has never
// required a password either — events.delete permission + admin session
// is the auth boundary for both. The typed-literal client gate is the
// "accidental click" safeguard, and unlike a password input it isn't
// affected by passkey/Windows Hello autofill that auto-submits the form.
const BULK_DELETE_MAX = 100;
module.exports = (router) => {
// Archive event
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (event.is_archived) {
return res.status(400).json({ error: 'Event is already archived' });
}
// Use the archive service to create ZIP archive
await archiveEvent(event);
// Log activity
await logActivity('event_archived',
{ eventName: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event archived successfully' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to archive event');
}
});
// Bulk archive events
router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
body('eventIds').isArray().withMessage('eventIds must be an array'),
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { eventIds } = req.body;
if (eventIds.length === 0) {
return res.status(400).json({ error: 'No events selected for archiving' });
}
// Get all events to archive
const events = await db('events')
.whereIn('id', eventIds)
.where('is_archived', formatBoolean(false));
if (events.length === 0) {
return res.status(400).json({ error: 'No valid events found to archive' });
}
const results = {
successful: [],
failed: []
};
// Process each event
for (const event of events) {
try {
// Use the archive service to create ZIP archive
await archiveEvent(event);
// Log activity
await logActivity('event_archived',
{ eventName: event.event_name, bulkOperation: true },
event.id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
results.successful.push({
id: event.id,
name: event.event_name
});
} catch (error) {
logger.error(`Failed to archive event ${event.id}:`, error);
results.failed.push({
id: event.id,
name: event.event_name,
error: 'Failed to archive event. Check server logs for details.'
});
}
}
// Log bulk archive activity
await logActivity('bulk_archive_completed',
{
totalEvents: eventIds.length,
successfulCount: results.successful.length,
failedCount: results.failed.length
},
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: `Bulk archive completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
results
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to perform bulk archive');
}
});
router.post('/bulk-delete', adminAuth, requirePermission('events.delete'), [
body('eventIds').isArray({ min: 1, max: BULK_DELETE_MAX }).withMessage(`eventIds must be an array of 1-${BULK_DELETE_MAX} ids`),
body('eventIds.*').isInt().withMessage('Each eventId must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { eventIds } = req.body;
// Editor-role events.delete permission is already gated by the route
// middleware. We do NOT additionally filter to created_by here because
// the per-event delete-cascade is global (matches DELETE /:id which
// also has no role-based filter — that's why events.delete is a
// sensitive permission).
const results = { successful: [], failed: [] };
const adminContext = { id: req.admin.id, username: req.admin.username };
for (const eventId of eventIds) {
try {
const deleted = await deleteEventCascade(eventId, adminContext);
results.successful.push(deleted);
} catch (err) {
results.failed.push({
id: eventId,
name: null,
error: err.code === 'EVENT_NOT_FOUND' ? 'Event not found' : 'Failed to delete event'
});
logger.warn('Bulk-delete: per-event failure', { eventId, error: err.message });
}
}
await logActivity('bulk_delete_completed',
{
totalEvents: eventIds.length,
successfulCount: results.successful.length,
failedCount: results.failed.length
},
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: `Bulk delete completed: ${results.successful.length} succeeded, ${results.failed.length} failed`,
results
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to perform bulk delete');
}
});
};
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
// Shared helpers + module-level caches used across the adminEvents sub-routers.
const { db, logActivity } = require('../../database/db');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../../utils/logger');
const { parseStringInput } = require('../../utils/parsers');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
if (['top', 'center', 'bottom'].includes(value)) return true;
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
}
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
};
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
// Helper to get event field requirements from settings
const getEventFieldRequirements = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email',
'event_require_event_date',
'event_require_expiration'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try {
value = JSON.parse(value);
} catch (e) {
value = value === 'true';
}
}
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
});
return requirements;
} catch (error) {
logger.error('Failed to get event field requirements', { error: error.message });
return {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
}
};
// Helper to read app_settings booleans by key, used to inherit per-setting
// defaults onto new events. Returns `undefined` for missing/non-boolean rows
// so callers can fall back to a legacy default.
const readBooleanSetting = async (key) => {
try {
const setting = await db('app_settings').where('setting_key', key).first();
if (!setting) return undefined;
let value = setting.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return typeof value === 'boolean' ? value : undefined;
} catch (error) {
logger.error('Failed to read app setting', { key, error: error.message });
return undefined;
}
};
// Helper to read the global "enable_devtools_protection" admin setting so
// new events inherit it instead of always falling back to the DB column default
// (#317 — admin disabled it globally but new events still got it ON).
const getDownloadProtectionDefaults = async () => {
return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') };
};
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance).
//
// Note: `branding_logo_position` (header bar — left/center/right) is a
// different concept from `hero_logo_position` (hero block — top/center/
// bottom) and must NOT be mapped here. A previous version copied the
// branding value over, which wrote 'left'/'right' into per-event
// hero_logo_position columns and broke any subsequent PUT validation
// (#357). Migration 084 heals existing rows.
const getBrandingDefaults = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_logo_display_hero',
'branding_logo_size'
])
.select('setting_key', 'setting_value');
const defaults = {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
}
if (s.setting_key === 'branding_logo_display_hero') {
defaults.hero_logo_visible = value !== false;
}
if (s.setting_key === 'branding_logo_size' && value) {
defaults.hero_logo_size = value;
}
});
return defaults;
} catch (error) {
logger.error('Failed to get branding defaults', { error: error.message });
return {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
}
};
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
// Whether the global "phone field" toggle (#322) is enabled. Cached for
// the request via a module-level read; drift is acceptable since this
// only governs whether to persist the field, not security boundaries.
const isPhoneFieldEnabled = async () => {
try {
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
if (!row) return false;
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return value === true;
} catch (error) {
logger.debug('Failed to read event_phone_field_enabled', { error: error.message });
return false;
}
};
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
customer_phone,
password_hash: _ph,
client_password_hash: _cph,
...rest
} = event;
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null,
customer_phone: customer_phone ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
logger.debug('Failed to detect customer_email column', { error: error.message });
return false;
}
};
// Cascade-delete a single event: photos, audit/access logs, queued emails,
// the event row itself (in one transaction), then the on-disk folder /
// archive zip / hero logo (best-effort — file failures don't unwind the DB
// changes since the source of truth is the database). Used by both the
// per-event DELETE /:id route and the bulk-delete route to avoid drift.
//
// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the
// bulk-delete loop can report it as a per-id failure without aborting the
// whole batch. Any other error propagates and is the caller's problem.
async function deleteEventCascade(eventId, adminContext) {
const event = await db('events').where('id', eventId).first();
if (!event) {
const err = new Error('Event not found');
err.code = 'EVENT_NOT_FOUND';
throw err;
}
await db.transaction(async (trx) => {
// 1. Delete activity logs (audit trail)
await trx('activity_logs').where('event_id', eventId).del();
// 2. Delete access logs
await trx('access_logs').where('event_id', eventId).del();
// 3. Delete email queue entries
await trx('email_queue').where('event_id', eventId).del();
// 4. Delete photos (also handles hero_photo_id foreign key)
await trx('photos').where('event_id', eventId).del();
// 5. Finally delete the event row
await trx('events').where('id', eventId).del();
// Best-effort filesystem cleanup. Failures are logged but don't unwind
// the transaction — the canonical state lives in the DB; orphan files
// are recoverable noise, a half-deleted DB row is a permanent mess.
//
// #608 — previous code read `event.folder_path`, but that column is
// never written anywhere in the codebase (grep confirms: two reads in
// this function, zero writes). It's always undefined, so the
// `if (event.folder_path)` branch silently no-op'd and every event
// delete since this cascade landed left its photos orphaned on disk.
// jodrmx's Pi report (v3.44.0) was the first surfacing.
//
// Files actually live at:
// {STORAGE_PATH}/events/active/{slug}/... (uploaded photos)
// {STORAGE_PATH}/events/archived/{slug}/... (after the event
// was archived — folder copy survives the archive flow)
//
// `event.slug` is NOT NULL on the events table and is slugify-sanitized
// on every write (lower-case ASCII + dashes only via utils/slug.js),
// so path-traversal isn't a concern.
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
for (const sub of ['active', 'archived']) {
const eventFolderPath = path.join(storagePath, 'events', sub, event.slug);
try {
await fs.rm(eventFolderPath, { recursive: true, force: true });
} catch (fsErr) {
logger.warn('Failed to delete event folder during cascade delete', { eventId, path: eventFolderPath, error: fsErr.message });
}
}
if (event.archive_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage');
const archiveFile = path.join(storagePath, event.archive_path);
try {
await fs.unlink(archiveFile);
} catch (fsErr) {
logger.warn('Failed to delete archive file during cascade delete', { eventId, path: archiveFile, error: fsErr.message });
}
}
if (event.hero_logo_path) {
try {
await fs.unlink(event.hero_logo_path);
} catch (fsErr) {
logger.warn('Failed to delete event logo during cascade delete', { eventId, path: event.hero_logo_path, error: fsErr.message });
}
}
});
// Audit trail (outside the transaction so a logging failure can't undo
// the actual delete).
await logActivity('event_deleted',
{ event_name: event.event_name },
null,
{ type: 'admin', id: adminContext.id, name: adminContext.username }
);
return { id: event.id, name: event.event_name };
}
// ---------------------------------------------------------------------------
// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live
// events that auto-picks-up new uploads (migration 138). Mirrors the
// client-access second-token pattern: the link is minted on demand, rotatable
// and disable-able, independent of the gallery password / share link.
// ---------------------------------------------------------------------------
// Allowed slide transition styles (kept in sync with the SlideshowPage).
// dipwhite/dipblack = fade through highlights / lowlights between images.
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
// Allowed per-slide color filters.
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
module.exports = {
validateHeroImageAnchor,
getStoragePath,
getEventFieldRequirements,
readBooleanSetting,
getDownloadProtectionDefaults,
getBrandingDefaults,
getCustomerNameFromPayload,
getCustomerEmailFromPayload,
getCustomerPhoneFromPayload,
isPhoneFieldEnabled,
mapEventForApi,
hasCustomerContactColumns,
deleteEventCascade,
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
};
+17
View File
@@ -0,0 +1,17 @@
// adminEvents router — decomposed move-code refactor of the original
// routes/adminEvents.js god file. Each sub-module attaches its routes onto the
// shared router below. CRITICAL: the require(...)(router) calls preserve the
// original registration order — Express matches in registration order, so
// literal segments and '/:id' patterns must keep their relative positions.
const express = require('express');
const router = express.Router();
require('./crud')(router);
require('./slideshow')(router);
require('./resets')(router);
require('./archiveBulk')(router);
require('./logo')(router);
module.exports = router;
+145
View File
@@ -0,0 +1,145 @@
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
// Exports a register function; ./index.js calls the sub-routers in the original
// registration order so Express route matching is unchanged.
const { db, logActivity } = require('../../database/db');
const { adminAuth } = require('../../middleware/auth');
const { requirePermission } = require('../../middleware/permissions');
const fs = require('fs').promises;
const path = require('path');
const multer = require('multer');
const logger = require('../../utils/logger');
const { errorResponse } = require('../../utils/routeHelpers');
const { validateFileType } = require('../../utils/fileSecurityUtils');
const { requireEventOwnership } = require('../../middleware/ownership');
const { getStoragePath } = require('./helpers');
// Configure multer for event logo uploads
const eventLogoStorage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(getStoragePath(), 'uploads/logos/events');
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
}
});
const eventLogoUpload = multer({
storage: eventLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true);
} else {
cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
}
}
});
module.exports = (router) => {
// Upload event custom logo
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
try {
const { id } = req.params;
// Check if event exists
let eventQuery = db('events').where('id', id);
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (!req.file) {
return res.status(400).json({ error: 'No logo file provided' });
}
// Delete old logo file if exists
if (event.hero_logo_path) {
try {
await fs.unlink(event.hero_logo_path);
logger.debug('Deleted old event logo file', { path: event.hero_logo_path });
} catch (err) {
logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message });
}
}
const logoUrl = `/uploads/logos/events/${req.file.filename}`;
const logoPath = req.file.path;
await db('events')
.where('id', id)
.update({
hero_logo_url: logoUrl,
hero_logo_path: logoPath
});
await logActivity('event_logo_uploaded',
{ eventName: event.event_name, filename: req.file.filename },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: 'Event logo uploaded successfully',
hero_logo_url: logoUrl
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to upload event logo');
}
});
// Delete event custom logo
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
let eventQuery = db('events').where('id', id);
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Delete logo file if exists
if (event.hero_logo_path) {
try {
await fs.unlink(event.hero_logo_path);
logger.debug('Deleted event logo file', { path: event.hero_logo_path });
} catch (err) {
logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message });
}
}
await db('events')
.where('id', id)
.update({
hero_logo_url: null,
hero_logo_path: null
});
await logActivity('event_logo_removed',
{ eventName: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event logo removed successfully' });
} catch (error) {
errorResponse(res, error, 500, 'Failed to delete event logo');
}
});
};
+193
View File
@@ -0,0 +1,193 @@
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
// Exports a register function; ./index.js calls the sub-routers in the original
// registration order so Express route matching is unchanged.
const { db, logActivity } = require('../../database/db');
const { adminAuth } = require('../../middleware/auth');
const { requirePermission } = require('../../middleware/permissions');
const bcrypt = require('bcrypt');
const { queueEmail } = require('../../services/emailProcessor');
const { validatePasswordInContext, getBcryptRounds } = require('../../utils/passwordValidation');
const logger = require('../../utils/logger');
const { errorResponse } = require('../../utils/routeHelpers');
const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { requireEventOwnership } = require('../../middleware/ownership');
module.exports = (router) => {
// Reset event password
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const { sendEmail = true, password: clientPassword } = req.body;
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (event.is_archived) {
return res.status(400).json({ error: 'Cannot reset password for archived event' });
}
// Use the admin-supplied password when provided; otherwise auto-generate
// (preserves the previous one-click behaviour for callers/cron that don't
// pass a body). Validation matches the create-event flow so the same
// strength rules apply both ways.
let newPassword;
if (typeof clientPassword === 'string' && clientPassword.length > 0) {
const passwordValidation = await validatePasswordInContext(clientPassword, 'gallery', {
eventName: event.event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
newPassword = clientPassword;
} else {
const { generateReadablePassword } = require('../../utils/passwordGenerator');
newPassword = generateReadablePassword();
}
const passwordHash = await bcrypt.hash(newPassword, getBcryptRounds());
// Update event with new password
await db('events')
.where('id', id)
.update({
password_hash: passwordHash
});
// Log activity
await logActivity('password_reset',
{ eventName: event.event_name, emailSent: sendEmail },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Queue email notification if requested
if (sendEmail) {
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
// event.share_link is the path-only form (`/gallery/<slug>/<token>`).
// Use the full URL so customers can click straight from the email.
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: newPassword,
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
});
}
res.json({
message: 'Password reset successfully',
newPassword: newPassword,
emailSent: sendEmail
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to reset password');
}
});
// Resend creation email
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
// Get event details
let eventQuery = db('events').where('id', id);
// Editor role can only edit their own events
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// The email processor will determine the language based on:
// 1. Event language setting
// 2. App settings general_default_language
// 3. Email config default language
// 4. Domain-based detection
// So we don't need to determine it here
// For resending creation email, we need the actual password
// First, try to get it from the request body if provided
// Use optional chaining to handle cases where req.body might be undefined
let galleryPassword = req.body?.password;
// If no password provided, we can't decrypt the existing one
// So we'll show a security message
if (!galleryPassword) {
// We'll let the email processor determine the language for the security message
galleryPassword = '{{password_security_message}}';
}
// Dates will be formatted by the email processor based on recipient language
// Queue the email
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
// event.share_link is the path-only form; use the full URL so the
// customer's mail client renders a clickable absolute link.
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: galleryPassword,
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
welcome_message: event.welcome_message || '',
eventId: id,
isResend: true // Flag to indicate this is a resend
});
// Log the activity using the proper schema
try {
await logActivity('email_resent', {
email_type: 'gallery_created',
recipient: recipientEmail,
ip_address: req.ip || '0.0.0.0',
user_agent: req.get('user-agent') || 'Unknown'
}, id, {
type: 'admin',
id: req.admin.id,
name: req.admin.username
});
} catch (logError) {
logger.error('Warning: Failed to log activity:', logError);
// Don't fail the request if activity logging fails
}
res.json({
success: true,
message: 'Creation email has been queued for sending'
});
} catch (error) {
logger.error('Error resending creation email:', error);
errorResponse(res, error, 500, 'Failed to resend creation email');
}
});
};
+151
View File
@@ -0,0 +1,151 @@
// Extracted verbatim from the original routes/adminEvents.js (see ./index.js).
// Exports a register function; ./index.js calls the sub-routers in the original
// registration order so Express route matching is unchanged.
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../../database/db');
const { formatBoolean } = require('../../utils/dbCompat');
const { adminAuth } = require('../../middleware/auth');
const { requirePermission } = require('../../middleware/permissions');
const crypto = require('crypto');
const { errorResponse } = require('../../utils/routeHelpers');
const { parseBooleanInput } = require('../../utils/parsers');
const { requireEventOwnership } = require('../../middleware/ownership');
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
// The watermark LOOK (source/position/opacity/style/size) is global-only
// (app_settings, Settings → Slideshow); events only carry the show_watermark
// mode (NULL=inherit / true / false), so no per-event look enums live here.
// Build the public slideshow URL for a freshly-minted/existing token.
async function buildSlideshowUrl(slug, token) {
if (!token) return null;
const base = await getFrontendBaseUrl();
return `${base.replace(/\/$/, '')}/gallery/${slug}/show/${token}`;
}
// Fetch the event respecting the editor-role ownership scope (requireEventOwnership
// already gates the route; this re-applies the created_by filter for editors so the
// 404 is identical to the rest of this file).
async function loadOwnedEvent(req) {
let q = db('events').where('id', req.params.id);
if (req.admin.roleName === 'editor') {
q = q.where('created_by', req.admin.id);
}
return q.first();
}
module.exports = (router) => {
// Generate (or rotate) the slideshow share token. Idempotent in intent: each
// call mints a fresh token, which both "Generate" (first time) and "Regenerate"
// (rotate, kills the old link) use.
router.post('/:id/slideshow/generate', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, async (req, res) => {
try {
const event = await loadOwnedEvent(req);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const token = crypto.randomBytes(32).toString('hex');
// NB: the events table has no updated_at column (only created_at), so we
// must not set it here or the UPDATE throws.
await db('events').where('id', req.params.id).update({
show_share_token: token
});
await logActivity('slideshow_link_generated',
{ eventName: event.event_name, rotated: Boolean(event.show_share_token) },
req.params.id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
show_share_token: token,
slideshow_url: await buildSlideshowUrl(event.slug, token)
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to generate slideshow link');
}
});
// Disable the slideshow link (null the token). The public /show/ route dies on
// its next poll, killing any projector currently pointed at the old link.
router.post('/:id/slideshow/disable', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const event = await loadOwnedEvent(req);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
await db('events').where('id', req.params.id).update({
show_share_token: null
});
await logActivity('slideshow_link_disabled',
{ eventName: event.event_name },
req.params.id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ show_share_token: null });
} catch (error) {
errorResponse(res, error, 500, 'Failed to disable slideshow link');
}
});
// Update the LIVE slideshow settings (display time / transition style / speed).
// A running projector picks these up via the show-page settings poll within a
// few seconds — no need to regenerate the link.
router.patch('/:id/slideshow', adminAuth, requirePermission('events.edit'), requireFeatureFlag('slideshow'), requireEventOwnership, [
body('show_interval_ms').optional().isInt({ min: 1000, max: 120000 }),
body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS),
body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }),
body('show_watermark').optional({ nullable: true }),
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS)
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() });
}
const event = await loadOwnedEvent(req);
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// events has no updated_at column — don't set it.
const updates = {};
if (req.body.show_interval_ms !== undefined) updates.show_interval_ms = parseInt(req.body.show_interval_ms, 10);
if (req.body.show_transition !== undefined) updates.show_transition = req.body.show_transition;
if (req.body.show_transition_ms !== undefined) updates.show_transition_ms = parseInt(req.body.show_transition_ms, 10);
// Tri-state: explicit null = inherit the global default.
if (req.body.show_watermark !== undefined) {
updates.show_watermark = req.body.show_watermark === null
? null
: formatBoolean(parseBooleanInput(req.body.show_watermark, false));
}
if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter;
// Knex throws on an empty update; only write if something changed.
if (Object.keys(updates).length > 0) {
await db('events').where('id', req.params.id).update(updates);
}
res.json({
show_interval_ms: updates.show_interval_ms ?? event.show_interval_ms ?? 5000,
show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
show_watermark: updates.show_watermark ?? event.show_watermark ?? null,
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none'
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to update slideshow settings');
}
});
};
+15 -23
View File
@@ -8,6 +8,7 @@ const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const feedbackService = require('../services/feedbackService');
const logger = require('../utils/logger');
const { errorResponse } = require('../utils/routeHelpers');
const FRONTEND_URL = process.env.FRONTEND_URL || '';
@@ -73,10 +74,10 @@ router.get(
'gallery_guests.created_at',
'gallery_guests.last_seen_at',
'gallery_guests.email_verified_at',
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'like\' THEN 1 END) AS likes'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'favorite\' THEN 1 END) AS favorites'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'comment\' THEN 1 END) AS comments'),
db.raw('COUNT(CASE WHEN photo_feedback.feedback_type = \'rating\' THEN 1 END) AS ratings'),
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
)
.orderBy('gallery_guests.created_at', 'desc');
@@ -94,8 +95,7 @@ router.get(
res.json({ guests });
} catch (error) {
logger.error('Error listing guests:', error);
res.status(500).json({ error: 'Failed to list guests' });
errorResponse(res, error, 500, 'Failed to list guests');
}
}
);
@@ -117,7 +117,7 @@ router.get(
const photos = await db('photos')
.leftJoin('photo_feedback', function () {
this.on('photo_feedback.photo_id', '=', 'photos.id')
.andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')"))
.andOn(db.raw('photo_feedback.feedback_type IN (\'like\',\'favorite\')'))
.andOnNotNull('photo_feedback.guest_id');
})
.where('photos.event_id', eventId)
@@ -144,8 +144,7 @@ router.get(
})),
});
} catch (error) {
logger.error('Error fetching aggregate view:', error);
res.status(500).json({ error: 'Failed to fetch aggregate view' });
errorResponse(res, error, 500, 'Failed to fetch aggregate view');
}
}
);
@@ -196,8 +195,7 @@ router.get(
res.json({ invites });
} catch (error) {
logger.error('Error listing invites:', error);
res.status(500).json({ error: 'Failed to list invites' });
errorResponse(res, error, 500, 'Failed to list invites');
}
}
);
@@ -266,8 +264,7 @@ router.post(
},
});
} catch (error) {
logger.error('Error creating invite:', error);
res.status(500).json({ error: 'Failed to create invite' });
errorResponse(res, error, 500, 'Failed to create invite');
}
}
);
@@ -302,8 +299,7 @@ router.delete(
res.json({ success: true });
} catch (error) {
logger.error('Error revoking invite:', error);
res.status(500).json({ error: 'Failed to revoke invite' });
errorResponse(res, error, 500, 'Failed to revoke invite');
}
}
);
@@ -457,8 +453,7 @@ router.get(
selections,
});
} catch (error) {
logger.error('Error fetching guest detail:', error);
res.status(500).json({ error: 'Failed to fetch guest detail' });
errorResponse(res, error, 500, 'Failed to fetch guest detail');
}
}
);
@@ -509,8 +504,7 @@ router.get(
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
return res.send(selections.map((s) => s.original_filename || s.filename).join('\n'));
} catch (error) {
logger.error('Error exporting guest:', error);
res.status(500).json({ error: 'Failed to export guest' });
errorResponse(res, error, 500, 'Failed to export guest');
}
}
);
@@ -548,8 +542,7 @@ router.delete(
res.json({ success: true, ...result });
} catch (error) {
logger.error('Error deleting guest:', error);
res.status(500).json({ error: 'Failed to delete guest' });
errorResponse(res, error, 500, 'Failed to delete guest');
}
}
);
@@ -600,8 +593,7 @@ router.post(
res.json({ success: true, ...result });
} catch (error) {
logger.error('Error merging guests:', error);
res.status(500).json({ error: 'Failed to merge guests' });
errorResponse(res, error, 500, 'Failed to merge guests');
}
}
);
+4
View File
@@ -342,6 +342,7 @@ router.get(
query('customerAccountId').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('sourceQuoteId').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('unpaidOnly').optional({ values: 'falsy' }).isBoolean(),
query('includeDrafts').optional({ values: 'falsy' }).isBoolean(),
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc', 'customer_desc']),
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
@@ -358,6 +359,9 @@ router.get(
customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null,
sourceQuoteId: req.query.sourceQuoteId ? parseInt(req.query.sourceQuoteId, 10) : null,
unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true,
// Surface running monthly/manual accumulator drafts (hidden by
// default per migration 128) when the Bills list explicitly asks.
includeMonthlyDrafts: req.query.includeDrafts === 'true' || req.query.includeDrafts === true,
q: req.query.q,
},
sort: req.query.sort || 'issue_desc',
+6 -5
View File
@@ -2,6 +2,7 @@ const express = require('express');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const logger = require('../utils/logger');
const router = express.Router();
// Get notifications (unread activity logs)
@@ -39,7 +40,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
if (typeof notification.metadata === 'object') return notification.metadata;
return JSON.parse(notification.metadata);
} catch (e) {
console.warn('Failed to parse metadata for notification:', notification.id, e.message);
logger.warn('Failed to parse metadata for notification:', notification.id, e.message);
return {};
}
})(),
@@ -59,7 +60,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
unreadCount: unreadCount.count || 0
});
} catch (error) {
console.error('Notifications fetch error:', error);
logger.error('Notifications fetch error:', error);
res.status(500).json({ error: 'Failed to fetch notifications' });
}
});
@@ -77,7 +78,7 @@ router.put('/:id/read', adminAuth, requirePermission('settings.edit'), async (re
res.json({ message: 'Notification marked as read' });
} catch (error) {
console.error('Mark notification read error:', error);
logger.error('Mark notification read error:', error);
res.status(500).json({ error: 'Failed to mark notification as read' });
}
});
@@ -93,7 +94,7 @@ router.put('/read-all', adminAuth, requirePermission('settings.edit'), async (re
res.json({ message: 'All notifications marked as read' });
} catch (error) {
console.error('Mark all notifications read error:', error);
logger.error('Mark all notifications read error:', error);
res.status(500).json({ error: 'Failed to mark all notifications as read' });
}
});
@@ -112,7 +113,7 @@ router.delete('/clear-all', adminAuth, requirePermission('settings.edit'), async
const deletedCount = await db('activity_logs').delete();
res.json({ message: 'All notifications cleared', deletedCount });
} catch (error) {
console.error('Clear notifications error:', error);
logger.error('Clear notifications error:', error);
res.status(500).json({ error: 'Failed to clear notifications' });
}
});
+6 -5
View File
@@ -11,7 +11,9 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
const { getPagination } = require('../utils/routeHelpers');
const { PhotoExportService } = require('../services/photoExportService');
const logger = require('../utils/logger');
const exportService = new PhotoExportService();
@@ -66,8 +68,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
const sort = req.query.sort || 'date';
const order = req.query.order || 'desc';
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const { page, limit } = getPagination(req, { limit: 50 });
// Build filtered query
const filterBuilder = new PhotoFilterBuilder(
@@ -124,7 +125,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
}
});
} catch (error) {
console.error('Filter photos error:', error);
logger.error('Filter photos error:', error);
res.status(500).json({ error: 'Failed to filter photos' });
}
});
@@ -146,7 +147,7 @@ router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view
data: summary
});
} catch (error) {
console.error('Filter summary error:', error);
logger.error('Filter summary error:', error);
res.status(500).json({ error: 'Failed to get filter summary' });
}
});
@@ -206,7 +207,7 @@ router.post('/:eventId/export', adminAuth, requirePermission('photos.download'),
res.send(result.content);
}
} catch (error) {
console.error('Export photos error:', error);
logger.error('Export photos error:', error);
res.status(500).json({ error: error.message || 'Failed to export photos' });
}
});
+48 -62
View File
@@ -22,6 +22,8 @@ const downloadZipService = require('../services/downloadZipService');
const { findReplacementCandidate, replacePhoto } = require('../services/photoReplacementService');
const { requireEventOwnership } = require('../middleware/ownership');
const { getStorage } = require('../services/storage');
const { errorResponse } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
const router = express.Router();
// Get storage path from environment or default
@@ -31,7 +33,7 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
// IMPORTANT: Using synchronous functions to prevent file corruption
const storage = multer.diskStorage({
destination: (req, file, cb) => {
console.log('Multer destination called for file:', file.originalname);
logger.info('Multer destination called for file:', file.originalname);
const { eventId } = req.params;
// We'll validate the event exists in the route handler
@@ -40,7 +42,7 @@ const storage = multer.diskStorage({
// Create directory synchronously
require('fs').mkdirSync(tempPath, { recursive: true });
console.log('Temp destination path:', tempPath);
logger.info('Temp destination path:', tempPath);
// Store temp path for cleanup
req.tempUploadPath = tempPath;
@@ -48,10 +50,10 @@ const storage = multer.diskStorage({
cb(null, tempPath);
},
filename: (req, file, cb) => {
console.log('Multer filename called for file:', file.originalname);
logger.info('Multer filename called for file:', file.originalname);
// Use a simple temporary filename
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
console.log('Temp filename:', tempName);
logger.info('Temp filename:', tempName);
cb(null, tempName);
}
});
@@ -89,7 +91,7 @@ const resolveAllowedTypes = async (req, res, next) => {
try {
req.allowedMimeTypes = await getAllowedMimeTypes();
} catch (error) {
console.error('Failed to resolve allowed MIME types:', error);
logger.error('Failed to resolve allowed MIME types:', error);
req.allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
}
next();
@@ -111,7 +113,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
return (req, res, next) => {
// Set timeout for the request
req.setTimeout(timeout, () => {
console.error('Upload request timed out');
logger.error('Upload request timed out');
if (!res.headersSent) {
res.status(408).json({ error: 'Upload request timed out' });
}
@@ -119,7 +121,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
// Set response timeout as well
res.setTimeout(timeout, () => {
console.error('Upload response timed out');
logger.error('Upload response timed out');
});
next();
@@ -133,13 +135,12 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
} catch (error) {
console.error('Failed to resolve max files per upload:', error);
return res.status(500).json({ error: 'Unable to determine upload limits' });
return errorResponse(res, error, 500, 'Unable to determine upload limits');
}
upload.array('photos', maxFilesPerUpload)(req, res, (err) => {
if (err) {
console.error('Multer error:', err);
logger.error('Multer error:', err);
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 10GB per file.' });
@@ -166,7 +167,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
try {
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
} catch (e) {
console.error('Failed to clean up temp upload directory:', e);
logger.error('Failed to clean up temp upload directory:', e);
}
};
res.on('finish', cleanupTempDir);
@@ -177,16 +178,16 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
const { category_id, replace_by_name } = req.body;
const replaceByName = replace_by_name === 'true' || replace_by_name === true;
console.log('Upload request received for event:', eventId);
console.log('Body:', req.body);
console.log('Files:', req.files ? req.files.length : 'none');
console.log('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype })));
console.log('Category ID received:', category_id);
logger.info('Upload request received for event:', eventId);
logger.info('Body:', req.body);
logger.info('Files:', req.files ? req.files.length : 'none');
logger.info('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype })));
logger.info('Category ID received:', category_id);
// Verify event exists and admin has access
const event = await db('events').where({ id: eventId }).first();
if (!event) {
console.error('Event not found:', eventId);
logger.error('Event not found:', eventId);
return res.status(404).json({ error: 'Event not found' });
}
@@ -213,8 +214,8 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
}
if (!req.files || req.files.length === 0) {
console.error('No files in request. req.files:', req.files);
console.error('Request body keys:', Object.keys(req.body));
logger.error('No files in request. req.files:', req.files);
logger.error('Request body keys:', Object.keys(req.body));
return res.status(400).json({ error: 'No files uploaded' });
}
@@ -389,7 +390,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
category_id: parsedCategoryId,
});
} catch (err) {
console.error(`Error queuing file ${file.originalname}:`, err);
logger.error(`Error queuing file ${file.originalname}:`, err);
errors.push({ filename: file.originalname, error: err.message });
}
}
@@ -451,10 +452,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
// 202 Accepted — files stored, processing happens in background.
res.status(202).json(response);
} catch (error) {
console.error('Error uploading photos:', error);
// Temp directory cleanup is handled by the response finish/close
// listeners above, regardless of which exit path fires.
res.status(500).json({ error: 'Failed to upload photos' });
errorResponse(res, error, 500, 'Failed to upload photos');
}
});
@@ -523,8 +523,7 @@ router.get(
...summariseUpload(group.photos),
});
} catch (error) {
console.error('Error reading upload status:', error);
res.status(500).json({ error: 'Failed to read upload status' });
errorResponse(res, error, 500, 'Failed to read upload status');
}
}
);
@@ -575,7 +574,7 @@ router.get(
return;
}
} catch (e) {
console.error('Upload stream poll error:', e);
logger.error('Upload stream poll error:', e);
}
};
@@ -622,8 +621,7 @@ router.post(
});
res.json({ id: photo.id, status: 'pending' });
} catch (error) {
console.error('Error retrying photo processing:', error);
res.status(500).json({ error: 'Failed to retry photo processing' });
errorResponse(res, error, 500, 'Failed to retry photo processing');
}
}
);
@@ -651,7 +649,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
const originalKey = resolvePhotoStorageKey(event, photo);
if (originalKey) await storage.delete(originalKey);
} catch (error) {
console.error('Error deleting photo file:', error);
logger.error('Error deleting photo file:', error);
}
// photo.thumbnail_path is stored as the canonical storage key
@@ -660,7 +658,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
try {
await storage.delete(photo.thumbnail_path);
} catch (error) {
console.error('Error deleting thumbnail:', error);
logger.error('Error deleting thumbnail:', error);
}
}
if (photo.hero_path) {
@@ -700,8 +698,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
downloadZipService.invalidate(parseInt(eventId));
res.json({ message: 'Photo deleted successfully' });
} catch (error) {
console.error('Error deleting photo:', error);
res.status(500).json({ error: 'Failed to delete photo' });
errorResponse(res, error, 500, 'Failed to delete photo');
}
});
@@ -763,8 +760,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
photo: updatedPhoto
});
} catch (error) {
console.error('Error updating photo:', error);
res.status(500).json({ error: 'Failed to update photo' });
errorResponse(res, error, 500, 'Failed to update photo');
}
});
@@ -797,7 +793,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
const originalKey = resolvePhotoStorageKey(event, photo);
if (originalKey) await storage.delete(originalKey);
} catch (error) {
console.error('Error deleting photo file:', error);
logger.error('Error deleting photo file:', error);
}
if (photo.thumbnail_path) {
@@ -842,8 +838,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
downloadZipService.invalidate(parseInt(eventId));
res.json({ message: `${photos.length} photos deleted successfully` });
} catch (error) {
console.error('Error bulk deleting photos:', error);
res.status(500).json({ error: 'Failed to delete photos' });
errorResponse(res, error, 500, 'Failed to delete photos');
}
});
@@ -905,8 +900,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
res.json({ message: `${photoIds.length} photos updated successfully` });
} catch (error) {
console.error('Error bulk updating photos:', error);
res.status(500).json({ error: 'Failed to update photos' });
errorResponse(res, error, 500, 'Failed to update photos');
}
});
@@ -961,8 +955,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
});
res.sendFile(filePath);
} catch (error) {
console.error('Error downloading photo:', error);
res.status(500).json({ error: 'Failed to download photo' });
errorResponse(res, error, 500, 'Failed to download photo');
}
});
@@ -1096,8 +1089,7 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
}))
});
} catch (error) {
console.error('Error fetching photos:', error);
res.status(500).json({ error: 'Failed to fetch photos' });
errorResponse(res, error, 500, 'Failed to fetch photos');
}
});
@@ -1153,8 +1145,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
}
res.sendFile(path.resolve(filePath));
} catch (error) {
console.error('Error serving photo:', error);
res.status(500).json({ error: 'Failed to serve photo' });
errorResponse(res, error, 500, 'Failed to serve photo');
}
});
@@ -1168,7 +1159,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
.first();
if (!photo) {
console.error(`Photo not found: ${photoId}, event ${eventId}`);
logger.error(`Photo not found: ${photoId}, event ${eventId}`);
return res.status(404).json({ error: 'Photo not found' });
}
@@ -1192,7 +1183,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
const thumbnailPath = await ensureThumbnail(photo);
if (!thumbnailPath) {
console.error(`Failed to generate thumbnail for photo ${photoId}`);
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
return res.status(404).json({ error: 'Thumbnail generation failed' });
}
@@ -1209,10 +1200,9 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
const stream = await storage.get(thumbnailPath);
stream.pipe(res);
} catch (error) {
console.error('Error serving thumbnail:', error);
console.error('Photo ID:', req.params.photoId);
console.error('Event ID:', req.params.eventId);
res.status(500).json({ error: 'Failed to serve thumbnail' });
logger.error('Error serving thumbnail:', error);
logger.error('Photo ID:', req.params.photoId);
errorResponse(res, error, 500, 'Failed to serve thumbnail');
}
});
@@ -1232,8 +1222,7 @@ router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), requi
storagePath: getStoragePath()
});
} catch (error) {
console.error('Error fetching admin photo debug data:', error);
res.status(500).json({ error: 'Failed to fetch photo debug data' });
errorResponse(res, error, 500, 'Failed to fetch photo debug data');
}
});
@@ -1262,7 +1251,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
// Validate file size (max 10GB)
const maxSize = 10 * 1024 * 1024 * 1024;
if (fileSize > maxSize) {
return res.status(400).json({ error: `File too large. Maximum size is 10GB.` });
return res.status(400).json({ error: 'File too large. Maximum size is 10GB.' });
}
const result = await chunkedUpload.initializeUpload({
@@ -1275,8 +1264,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
res.json(result);
} catch (error) {
console.error('Error initializing chunked upload:', error);
res.status(500).json({ error: 'Failed to initialize upload' });
errorResponse(res, error, 500, 'Failed to initialize upload');
}
});
@@ -1296,7 +1284,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
res.json(result);
} catch (error) {
console.error('Error uploading chunk:', error);
logger.error('Error uploading chunk:', error);
res.status(500).json({ error: error.message || 'Failed to upload chunk' });
}
});
@@ -1329,7 +1317,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
try {
await fs.rm(mergedFile.tempDir, { recursive: true, force: true });
} catch (cleanupErr) {
console.warn('Failed to clean up temp directory:', cleanupErr.message);
logger.warn('Failed to clean up temp directory:', cleanupErr.message);
}
res.json({
@@ -1338,7 +1326,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
photos: uploadedPhotos
});
} catch (error) {
console.error('Error completing chunked upload:', error);
logger.error('Error completing chunked upload:', error);
res.status(500).json({ error: error.message || 'Failed to complete upload' });
}
});
@@ -1356,8 +1344,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermis
res.json(status);
} catch (error) {
console.error('Error getting upload status:', error);
res.status(500).json({ error: 'Failed to get upload status' });
errorResponse(res, error, 500, 'Failed to get upload status');
}
});
@@ -1370,8 +1357,7 @@ router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission
res.json({ success: true, message: 'Upload aborted' });
} catch (error) {
console.error('Error aborting upload:', error);
res.status(500).json({ error: 'Failed to abort upload' });
errorResponse(res, error, 500, 'Failed to abort upload');
}
});
+2 -1
View File
@@ -5,6 +5,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { body, query, validationResult } = require('express-validator');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
const { db } = require('../database/db');
const path = require('path');
const fs = require('fs').promises;
@@ -48,7 +49,7 @@ function transformS3Config(body) {
*/
router.get('/status', requirePermission('backup.view'), async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const { limit } = getPagination(req, { limit: 10 });
const history = await restoreService.getRestoreHistory(limit);
const status = {
+59 -78
View File
@@ -22,6 +22,8 @@ const { sanitizeCss } = require('../utils/cssSanitizer');
const { upsertAppSetting } = require('../utils/appSettings');
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { resetSecurityConfigCache } = require('../utils/authSecurity');
const { errorResponse } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
const watermarkService = require('../services/watermarkService');
@@ -158,8 +160,7 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
res.json(settingsObject);
} catch (error) {
console.error('Settings fetch error:', error);
res.status(500).json({ error: 'Failed to fetch settings' });
errorResponse(res, error, 500, 'Failed to fetch settings');
}
});
@@ -200,8 +201,7 @@ router.get('/customer-surface', adminAuth, requirePermission('settings.view'), a
res.json(settings);
} catch (error) {
console.error('Customer surface settings fetch error:', error);
res.status(500).json({ error: 'Failed to fetch customer surface settings' });
errorResponse(res, error, 500, 'Failed to fetch customer surface settings');
}
});
@@ -231,8 +231,7 @@ router.put('/customer-surface', adminAuth, requirePermission('settings.edit'), a
res.json({ message: 'Customer surface settings updated', updated: updates.map((u) => u.setting_key) });
} catch (error) {
console.error('Customer surface settings save error:', error);
res.status(500).json({ error: 'Failed to save customer surface settings' });
errorResponse(res, error, 500, 'Failed to save customer surface settings');
}
});
@@ -295,8 +294,7 @@ router.put('/accounting', adminAuth, requirePermission('settings.edit'), async (
}
res.json({ message: 'Accounting settings updated', updated: updates.map((u) => u.setting_key) });
} catch (error) {
console.error('Accounting settings save error:', error);
res.status(500).json({ error: 'Failed to save accounting settings' });
errorResponse(res, error, 500, 'Failed to save accounting settings');
}
});
@@ -360,8 +358,7 @@ router.put('/slideshow', adminAuth, requirePermission('settings.edit'), async (r
require('../utils/slideshowGlobals').invalidateSlideshowGlobals();
res.json({ message: 'Slideshow settings updated', updated: updates.map((u) => u.setting_key) });
} catch (error) {
console.error('Slideshow settings save error:', error);
res.status(500).json({ error: 'Failed to save slideshow settings' });
errorResponse(res, error, 500, 'Failed to save slideshow settings');
}
});
@@ -413,8 +410,7 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
res.json(settingsObject);
} catch (error) {
console.error('Settings fetch error:', error);
res.status(500).json({ error: 'Failed to fetch settings' });
errorResponse(res, error, 500, 'Failed to fetch settings');
}
});
@@ -434,8 +430,7 @@ router.get('/password/complexity', adminAuth, requirePermission('settings.view')
config
});
} catch (error) {
console.error('Password complexity settings fetch error:', error);
res.status(500).json({ error: 'Failed to fetch password complexity settings' });
errorResponse(res, error, 500, 'Failed to fetch password complexity settings');
}
});
@@ -577,9 +572,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
const faviconPath = path.join(getStoragePath(), relativePath);
try {
await fs.unlink(faviconPath);
console.log('Deleted favicon file:', faviconPath);
logger.info('Deleted favicon file:', faviconPath);
} catch (err) {
console.error('Error deleting favicon file:', err);
logger.error('Error deleting favicon file:', err);
}
}
}
@@ -608,9 +603,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
const logoPath = path.join(getStoragePath(), relativePath);
try {
await fs.unlink(logoPath);
console.log('Deleted logo file:', logoPath);
logger.info('Deleted logo file:', logoPath);
} catch (err) {
console.error('Error deleting logo file:', err);
logger.error('Error deleting logo file:', err);
}
}
}
@@ -656,20 +651,20 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
if (currentSettings && currentSettings.enabled) {
// Start background regeneration of all watermarks
console.log('Watermark settings changed, starting background regeneration');
logger.info('Watermark settings changed, starting background regeneration');
watermarkGeneratorService.regenerateAll()
.then(result => {
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
logger.info(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
})
.catch(err => {
console.error('Watermark regeneration failed:', err);
logger.error('Watermark regeneration failed:', err);
});
watermarkRegenerationStarted = true;
} else {
// Watermarking was disabled, clear all pre-generated watermarks
console.log('Watermarking disabled, clearing pre-generated watermarks');
logger.info('Watermarking disabled, clearing pre-generated watermarks');
watermarkGeneratorService.clearAllWatermarks()
.catch(err => console.error('Failed to clear watermarks:', err));
.catch(err => logger.error('Failed to clear watermarks:', err));
}
}
@@ -678,8 +673,7 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
watermarkRegenerationStarted
});
} catch (error) {
console.error('Branding update error:', error);
res.status(500).json({ error: 'Failed to update branding settings' });
errorResponse(res, error, 500, 'Failed to update branding settings');
}
});
@@ -711,7 +705,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
}
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old logo:', error);
logger.error('Failed to delete old logo:', error);
}
}
@@ -751,8 +745,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
logoUrl: publicPath
});
} catch (error) {
console.error('Logo upload error:', error);
res.status(500).json({ error: 'Failed to upload logo' });
errorResponse(res, error, 500, 'Failed to upload logo');
}
});
@@ -772,7 +765,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
if (p.startsWith('"')) p = JSON.parse(p);
await fs.unlink(p);
} catch (error) {
console.error('Failed to delete logo file:', error);
logger.error('Failed to delete logo file:', error);
}
}
await db('app_settings')
@@ -781,8 +774,7 @@ router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req
res.json({ message: 'Logo removed' });
} catch (error) {
console.error('Logo delete error:', error);
res.status(500).json({ error: 'Failed to remove logo' });
errorResponse(res, error, 500, 'Failed to remove logo');
}
});
@@ -812,7 +804,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
try {
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old watermark logo:', error);
logger.error('Failed to delete old watermark logo:', error);
}
}
}
@@ -854,13 +846,13 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
let watermarkRegenerationStarted = false;
if (currentSettings && currentSettings.enabled) {
console.log('Watermark logo changed, starting background regeneration');
logger.info('Watermark logo changed, starting background regeneration');
watermarkGeneratorService.regenerateAll()
.then(result => {
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
logger.info(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
})
.catch(err => {
console.error('Watermark regeneration failed:', err);
logger.error('Watermark regeneration failed:', err);
});
watermarkRegenerationStarted = true;
}
@@ -871,8 +863,7 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
watermarkRegenerationStarted
});
} catch (error) {
console.error('Watermark logo upload error:', error);
res.status(500).json({ error: 'Failed to upload watermark logo' });
errorResponse(res, error, 500, 'Failed to upload watermark logo');
}
});
@@ -908,8 +899,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
res.json({ message: 'Theme settings updated successfully' });
} catch (error) {
console.error('Theme update error:', error);
res.status(500).json({ error: 'Failed to update theme settings' });
errorResponse(res, error, 500, 'Failed to update theme settings');
}
});
@@ -1005,7 +995,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
require('../services/downloadFilenameService').clearCache();
require('../services/downloadZipService').invalidateAll();
} catch (e) {
console.warn('Failed to invalidate download caches after filename setting change:', e.message);
logger.warn('Failed to invalidate download caches after filename setting change:', e.message);
}
}
@@ -1020,8 +1010,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
res.json({ message: 'General settings updated successfully' });
} catch (error) {
console.error('General settings update error:', error);
res.status(500).json({ error: 'Failed to update general settings' });
errorResponse(res, error, 500, 'Failed to update general settings');
}
});
@@ -1059,8 +1048,7 @@ router.put('/security', adminAuth, requirePermission('settings.edit'), async (re
res.json({ message: 'Security settings updated successfully' });
} catch (error) {
console.error('Security settings update error:', error);
res.status(500).json({ error: 'Failed to update security settings' });
errorResponse(res, error, 500, 'Failed to update security settings');
}
});
@@ -1115,8 +1103,7 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
res.json({ message: 'Analytics settings updated successfully' });
} catch (error) {
console.error('Analytics settings update error:', error);
res.status(500).json({ error: 'Failed to update analytics settings' });
errorResponse(res, error, 500, 'Failed to update analytics settings');
}
});
@@ -1179,8 +1166,7 @@ router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, re
res.json({ message: 'SEO settings updated successfully' });
} catch (error) {
console.error('SEO settings update error:', error);
res.status(500).json({ error: 'Failed to update SEO settings' });
errorResponse(res, error, 500, 'Failed to update SEO settings');
}
});
@@ -1216,7 +1202,7 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
const stats = await fs.stat(fullArchivePath);
archiveStorage += stats.size;
} catch (error) {
console.error('Archive file not found:', archive.archive_path, error.message);
logger.error('Archive file not found:', archive.archive_path, error.message);
}
}
}
@@ -1234,7 +1220,7 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
rawDiskFree = Number(diskStats.bsize) * Number(diskStats.bfree);
rawDiskAvailable = Number(diskStats.bsize) * Number(diskStats.bavail);
} catch (diskError) {
console.error('Disk stats error:', diskError.message);
logger.error('Disk stats error:', diskError.message);
}
const clampDiskValue = (value) => {
@@ -1313,27 +1299,27 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
}
switch (setting.setting_key) {
case 'general_storage_soft_limit_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
configuredSoftLimit = parsedValue;
}
break;
case 'general_storage_capacity_override_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
capacityOverrideDb = parsedValue;
}
break;
case 'general_storage_available_override_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
availableOverrideDb = parsedValue;
}
break;
default:
break;
case 'general_storage_soft_limit_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
configuredSoftLimit = parsedValue;
}
break;
case 'general_storage_capacity_override_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
capacityOverrideDb = parsedValue;
}
break;
case 'general_storage_available_override_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
availableOverrideDb = parsedValue;
}
break;
default:
break;
}
});
} catch (error) {
console.error('Storage settings read error:', error.message);
logger.error('Storage settings read error:', error.message);
}
const capacityOverrideEnv = parseEnvOverride('STORAGE_CAPACITY_OVERRIDE_BYTES', 'STORAGE_CAPACITY_OVERRIDE_GB');
@@ -1407,8 +1393,7 @@ router.get('/storage/info', adminAuth, requirePermission('settings.view'), async
disk_override_source: overrideSource
});
} catch (error) {
console.error('Storage info error:', error);
res.status(500).json({ error: 'Failed to fetch storage information' });
errorResponse(res, error, 500, 'Failed to fetch storage information');
}
});
@@ -1445,8 +1430,7 @@ router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUp
res.json({ faviconUrl });
} catch (error) {
console.error('Error uploading favicon:', error);
res.status(500).json({ error: 'Failed to upload favicon' });
errorResponse(res, error, 500, 'Failed to upload favicon');
}
});
@@ -1509,8 +1493,7 @@ router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit')
res.json({ message: 'Rate limit settings updated successfully' });
} catch (error) {
console.error('Rate limit settings update error:', error);
res.status(500).json({ error: 'Failed to update rate limit settings' });
errorResponse(res, error, 500, 'Failed to update rate limit settings');
}
});
@@ -1530,8 +1513,7 @@ router.get('/public-site/default', adminAuth, requirePermission('settings.view')
}
});
} catch (error) {
console.error('Failed to load public site defaults:', error);
res.status(500).json({ error: 'Failed to load defaults' });
errorResponse(res, error, 500, 'Failed to load defaults');
}
});
@@ -1584,8 +1566,7 @@ router.post('/public-site/reset', adminAuth, requirePermission('settings.edit'),
branding: defaults.branding
});
} catch (error) {
console.error('Failed to reset public site template:', error);
res.status(500).json({ error: 'Failed to reset template' });
errorResponse(res, error, 500, 'Failed to reset template');
}
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Admin CRUD for the branded URL shortener (#699).
*
* - GET /api/admin/events/:eventId/short-urls list per event
* - POST /api/admin/events/:eventId/short-urls create (custom or auto-generated slug)
* - DELETE /api/admin/short-urls/:id soft-delete
*
* All paths require admin auth + `settings.view` permission (read) /
* `events.edit` permission (mutate) short URLs are a per-event admin
* concern, gated by the same permission as editing the event itself.
*/
const express = require('express');
const { body, param, validationResult } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const galleryShortUrlService = require('../services/galleryShortUrlService');
const logger = require('../utils/logger');
const router = express.Router();
router.use(adminAuth);
/**
* GET /api/admin/events/:eventId/short-urls
* List live short URLs for an event.
*/
router.get(
'/events/:eventId/short-urls',
requirePermission('events.view'),
param('eventId').isInt({ min: 1 }),
requireEventOwnership,
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
try {
const rows = await galleryShortUrlService.listForEvent(parseInt(req.params.eventId, 10));
res.json({ shortUrls: rows });
} catch (err) {
logger.error('adminShortUrls.list failed', { error: err.message, eventId: req.params.eventId });
res.status(500).json({ error: 'Failed to list short URLs' });
}
},
);
/**
* POST /api/admin/events/:eventId/short-urls
* Body: { customSlug?: string } omit for auto-generated slug.
*/
router.post(
'/events/:eventId/short-urls',
requirePermission('events.edit'),
param('eventId').isInt({ min: 1 }),
body('customSlug').optional({ nullable: true })
.isString().isLength({ min: 1, max: 64 }),
requireEventOwnership,
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
try {
const row = await galleryShortUrlService.createShortUrl({
eventId: parseInt(req.params.eventId, 10),
customSlug: req.body.customSlug || null,
createdBy: req.admin?.id || null,
});
res.status(201).json(row);
} catch (err) {
// Structured-error fallthrough — the service tags collisions and
// validation failures with a `code` so the UI can surface a
// useful message + a suggested alternative slug.
if (err.code === 'INVALID_SLUG') {
return res.status(400).json({ error: err.message, code: err.code });
}
if (err.code === 'SLUG_TAKEN') {
return res.status(409).json({
error: err.message, code: err.code, suggested: err.suggested,
});
}
if (err.code === 'EVENT_NOT_FOUND') {
return res.status(404).json({ error: err.message, code: err.code });
}
logger.error('adminShortUrls.create failed', { error: err.message });
res.status(500).json({ error: 'Failed to create short URL' });
}
},
);
/**
* DELETE /api/admin/short-urls/:id
* Soft-delete. The public route serves 410 Gone on a deleted row so the
* admin can tell their delete worked (vs. 404 for an unknown slug).
*/
router.delete(
'/short-urls/:id',
requirePermission('events.edit'),
param('id').isInt({ min: 1 }),
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
try {
const ok = await galleryShortUrlService.softDelete(
parseInt(req.params.id, 10),
req.admin?.id || null,
);
if (!ok) return res.status(404).json({ error: 'Short URL not found' });
res.status(204).end();
} catch (err) {
logger.error('adminShortUrls.delete failed', { error: err.message });
res.status(500).json({ error: 'Failed to delete short URL' });
}
},
);
module.exports = router;
+86 -10
View File
@@ -7,7 +7,9 @@ const path = require('path');
const os = require('os');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { checkForUpdates, getCurrentChannel, getReleasesSince } = require('../services/updateCheckService');
const { checkForUpdates, getCurrentChannel, getCurrentVersion, getReleasesSince, compareVersions } = require('../services/updateCheckService');
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
const { parseWhatsNew } = require('../utils/whatsNew');
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
const {
checkAndNotifyUpdates,
@@ -27,7 +29,7 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
const packageJson = JSON.parse(packageContent);
backendVersion = packageJson.version || '1.0.0';
} catch (err) {
console.error('Could not read package.json:', err);
logger.error('Could not read package.json:', err);
}
const channel = getCurrentChannel(backendVersion);
@@ -40,7 +42,7 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
channel: channel
});
} catch (error) {
console.error('Error fetching version:', error);
logger.error('Error fetching version:', error);
res.status(500).json({ error: 'Failed to fetch version information' });
}
});
@@ -61,9 +63,20 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
const forceRefresh = req.query.refresh === 'true';
const updateInfo = await checkForUpdates(forceRefresh);
// Pre-update teaser: the target version's top highlights, so the
// "Update Available" banner can show "New features include …".
let latestHighlights = [];
if (updateInfo.updateAvailable) {
try {
const newer = await getReleasesSince(updateInfo.current, updateInfo.channel);
if (newer[0]) latestHighlights = parseWhatsNew(newer[0].body);
} catch (_) { /* teaser is best-effort */ }
}
res.json({
enabled: true,
...updateInfo
...updateInfo,
latestHighlights
});
} catch (error) {
logger.error('Error checking for updates:', error);
@@ -71,6 +84,69 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
}
});
// What's New — after-update highlights. Returns the curated bullets for
// every release the instance moved THROUGH since it last acknowledged one
// (lastSeen < version <= running). Seen-tracking is per-INSTANCE: the first
// admin to dismiss clears it for everyone (a single app_settings row). A
// brand-new install initialises the marker silently so it never pops
// "what's new" with nothing to compare against. Best-effort: any failure
// (GitHub unreachable, etc.) returns hasNews:false, never errors.
router.get('/updates/whatsnew', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
if (process.env.UPDATE_CHECK_ENABLED === 'false') {
return res.json({ enabled: false, hasNews: false });
}
const running = await getCurrentVersion();
const channel = getCurrentChannel(running);
const lastSeen = await getAppSetting('whatsnew_last_seen_version', null);
if (!lastSeen) {
await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system');
return res.json({ enabled: true, hasNews: false, running });
}
if (compareVersions(running, lastSeen) <= 0) {
return res.json({ enabled: true, hasNews: false, running });
}
// Releases in (lastSeen, running], newest-first, with their highlights.
const releases = (await getReleasesSince(lastSeen, channel))
.filter((r) => compareVersions(r.version, running) <= 0);
const versions = releases
.map((r) => ({
version: r.version,
name: r.name,
publishedAt: r.publishedAt,
htmlUrl: r.htmlUrl,
bullets: parseWhatsNew(r.body),
}))
.filter((v) => v.bullets.length > 0);
return res.json({
enabled: true,
hasNews: versions.length > 0,
fromVersion: lastSeen,
toVersion: running,
versions,
});
} catch (error) {
logger.error('Error building what\'s-new:', error);
res.json({ enabled: true, hasNews: false });
}
});
// Acknowledge the What's New — advance the per-instance marker to the
// running version so it stops showing for every admin.
router.post('/updates/whatsnew/seen', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const running = await getCurrentVersion();
await upsertAppSetting('whatsnew_last_seen_version', JSON.stringify(running), 'system');
res.json({ ok: true, lastSeen: running });
} catch (error) {
logger.error('Error marking what\'s-new seen:', error);
res.status(500).json({ error: 'Failed to update marker' });
}
});
// Aggregated changelog — every release between current and latest in
// the user's channel. Powers the update-available modal (#567) so the
// admin can read release notes for ALL versions they're behind on, not
@@ -131,7 +207,7 @@ router.get('/updates/instructions', adminAuth, requirePermission('settings.view'
channel: updateInfo.channel,
environment: env,
instructions,
releaseNotesUrl: `https://github.com/the-luap/picpeak/releases/tag/v${updateInfo.latest.forChannel}`
releaseNotesUrl: `https://github.com/PicPeak/picpeak/releases/tag/v${updateInfo.latest.forChannel}`
});
} catch (error) {
logger.error('Error generating update instructions:', error);
@@ -155,7 +231,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
`, [dbName]);
dbSize = result.rows[0]?.size || 0;
} catch (error) {
console.error('Error getting PostgreSQL database size:', error);
logger.error('Error getting PostgreSQL database size:', error);
}
} else {
// SQLite - check file size
@@ -164,7 +240,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
const stats = await fs.stat(dbPath);
dbSize = stats.size;
} catch (error) {
console.error('Error getting SQLite database size:', error);
logger.error('Error getting SQLite database size:', error);
}
}
@@ -209,7 +285,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
const stats = await fs.stat(fullArchivePath);
archiveStorage += stats.size;
} catch (error) {
console.error('Archive file not found:', archive.archive_path);
logger.error('Archive file not found:', archive.archive_path);
}
}
}
@@ -269,7 +345,7 @@ router.get('/status', adminAuth, requirePermission('settings.view'), async (req,
res.json(status);
} catch (error) {
console.error('Error fetching system status:', error);
logger.error('Error fetching system status:', error);
res.status(500).json({ error: 'Failed to fetch system status' });
}
});
@@ -331,7 +407,7 @@ router.get('/database', adminAuth, requirePermission('settings.view'), async (re
timestamp: new Date()
});
} catch (error) {
console.error('Error fetching database info:', error);
logger.error('Error fetching database info:', error);
res.status(500).json({ error: 'Failed to fetch database information' });
}
});
+7 -9
View File
@@ -24,7 +24,6 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const workflows = require('../services/workflows');
const { DOCUMENT_ACTIONS } = require('../services/workflows/actions');
const { hasColumnCached } = require('../utils/schemaCache');
router.use(adminAuth, requireFeatureFlag('workflows'));
@@ -35,9 +34,6 @@ const MAX_NODES = 200;
const MAX_EDGES = 500;
const MAX_NODE_CONFIG_BYTES = 16 * 1024;
const VALID_NODE_TYPES = new Set(['trigger', 'action', 'condition', 'branch', 'loop', 'wait', 'gate', 'webhook']);
// Actions registered but not yet wired (return {skipped:true}); a flow that
// uses any of these can't be meaningfully enabled.
const UNIMPLEMENTED_ACTIONS = new Set(DOCUMENT_ACTIONS);
function parseJson(v, fallback) {
if (v == null) return fallback;
@@ -65,13 +61,15 @@ function validateGraph(body) {
return null;
}
// The unimplemented (stub) actions a graph references — used to refuse enabling
// a flow that would silently no-op (e.g. the booking built-ins' prepare_*/send).
// The unimplemented actions a graph references — any action node whose
// `config.action` has no registered handler. Used to refuse enabling a flow
// that would silently no-op at runtime (typo'd or future-but-unwired actions).
// Registry-driven so it can't drift from what the engine can actually run.
function unimplementedActionsIn(nodes = []) {
const found = new Set();
for (const n of nodes) {
const action = n && n.config && n.config.action;
if (action && UNIMPLEMENTED_ACTIONS.has(action)) found.add(action);
const action = n && n.type === 'action' && n.config && n.config.action;
if (action && !workflows.registry.getAction(action)) found.add(action);
}
return [...found];
}
@@ -246,7 +244,7 @@ router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req,
// prepare_*/send_document stubs). Concern #5 from review.
if (enabled) {
const rows = await db('workflow_nodes').where({ workflow_id: id, version: wf.version });
const stubs = unimplementedActionsIn(rows.map((n) => ({ config: parseJson(n.config, {}) })));
const stubs = unimplementedActionsIn(rows.map((n) => ({ type: n.type, config: parseJson(n.config, {}) })));
if (stubs.length) {
return res.status(409).json({ error: `This flow can't be enabled yet — it uses actions that aren't implemented: ${stubs.join(', ')}.` });
}
+8 -14
View File
@@ -15,6 +15,7 @@ const {
const { endSession } = require('../middleware/sessionTimeout');
const { revokeToken } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const { errorResponse } = require('../utils/routeHelpers');
const {
setAdminAuthCookie,
clearAdminAuthCookie,
@@ -132,8 +133,7 @@ router.post('/admin/login', [
}
});
} catch (error) {
logger.error('Login error:', error);
res.status(500).json({ error: 'Login failed' });
errorResponse(res, error, 500, 'Login failed');
}
});
@@ -175,8 +175,7 @@ router.post('/logout', async (req, res) => {
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Logout error:', error);
res.status(500).json({ error: 'Logout failed' });
errorResponse(res, error, 500, 'Logout failed');
}
});
@@ -288,8 +287,7 @@ router.post('/gallery/verify', [
}
});
} catch (error) {
logger.error('Gallery verification error:', error);
res.status(500).json({ error: 'Verification failed' });
errorResponse(res, error, 500, 'Verification failed');
}
});
@@ -364,8 +362,7 @@ router.post('/gallery/:slug/client-login', [
accessLevel: 'client'
});
} catch (error) {
logger.error('Client login error:', error);
res.status(500).json({ error: 'Authentication failed' });
errorResponse(res, error, 500, 'Authentication failed');
}
});
@@ -451,8 +448,7 @@ router.post('/gallery/share-login', [
}
});
} catch (error) {
logger.error('Share link authentication error:', error);
res.status(500).json({ error: 'Share link login failed' });
errorResponse(res, error, 500, 'Share link login failed');
}
});
@@ -467,8 +463,7 @@ router.post('/gallery/logout', async (req, res) => {
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Gallery logout error:', error);
res.status(500).json({ error: 'Logout failed' });
errorResponse(res, error, 500, 'Logout failed');
}
});
@@ -682,8 +677,7 @@ router.post('/admin/change-password', [
score: passwordValidation.score
});
} catch (error) {
logger.error('Password change error:', error);
res.status(500).json({ error: 'Failed to change password' });
errorResponse(res, error, 500, 'Failed to change password');
}
});
+12 -22
View File
@@ -19,6 +19,7 @@ const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const { errorResponse } = require('../utils/routeHelpers');
const { getClientIp } = require('../utils/requestIp');
const { customerAuth } = require('../middleware/customerAuth');
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
@@ -117,8 +118,7 @@ router.get('/events', customerAuth, async (req, res) => {
})),
});
} catch (error) {
logger.error('Customer event list error:', error);
res.status(500).json({ error: 'Failed to load events' });
errorResponse(res, error, 500, 'Failed to load events');
}
});
@@ -225,8 +225,7 @@ router.get('/events/:slug/access-token', [
},
});
} catch (error) {
logger.error('Customer access-token exchange error:', error);
res.status(500).json({ error: 'Failed to issue access token' });
errorResponse(res, error, 500, 'Failed to issue access token');
}
});
@@ -248,8 +247,7 @@ router.get('/profile', customerAuth, async (req, res) => {
}
res.json({ profile: shapeProfile(row) });
} catch (error) {
logger.error('Customer profile read error:', error);
res.status(500).json({ error: 'Failed to load profile' });
errorResponse(res, error, 500, 'Failed to load profile');
}
});
@@ -316,8 +314,7 @@ router.put('/profile', [
res.json({ profile: shapeProfile(row) });
} catch (error) {
logger.error('Customer profile update error:', error);
res.status(500).json({ error: 'Failed to update profile' });
errorResponse(res, error, 500, 'Failed to update profile');
}
});
@@ -376,8 +373,7 @@ router.post('/profile/password', [
res.json({ message: 'Password updated' });
} catch (error) {
logger.error('Customer password change error:', error);
res.status(500).json({ error: 'Failed to change password' });
errorResponse(res, error, 500, 'Failed to change password');
}
});
@@ -457,8 +453,7 @@ router.get('/quotes', customerAuth, async (req, res) => {
})),
});
} catch (error) {
logger.error('Customer quotes list error:', error);
res.status(500).json({ error: 'Failed to load quotes' });
errorResponse(res, error, 500, 'Failed to load quotes');
}
});
@@ -545,8 +540,7 @@ router.get('/invoices', customerAuth, async (req, res) => {
})),
});
} catch (error) {
logger.error('Customer invoice list error:', error);
res.status(500).json({ error: 'Failed to load invoices' });
errorResponse(res, error, 500, 'Failed to load invoices');
}
});
@@ -582,8 +576,7 @@ router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.send(buf);
} catch (error) {
logger.error('Customer quote PDF error:', error);
res.status(500).json({ error: 'Failed to render quote PDF' });
errorResponse(res, error, 500, 'Failed to render quote PDF');
}
});
@@ -614,8 +607,7 @@ router.get('/invoices/:id/pdf', customerAuth, async (req, res) => {
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.send(buf);
} catch (error) {
logger.error('Customer invoice PDF error:', error);
res.status(500).json({ error: 'Failed to render invoice PDF' });
errorResponse(res, error, 500, 'Failed to render invoice PDF');
}
});
@@ -679,8 +671,7 @@ router.get('/contracts', customerAuth, async (req, res) => {
})),
});
} catch (error) {
logger.error('Customer contracts list error:', error);
res.status(500).json({ error: 'Failed to load contracts' });
errorResponse(res, error, 500, 'Failed to load contracts');
}
});
@@ -717,8 +708,7 @@ router.get('/contracts/:id/pdf', customerAuth, async (req, res) => {
res.set('Content-Disposition', `inline; filename="${path.basename(filePath)}"`);
fs.createReadStream(filePath).pipe(res);
} catch (error) {
logger.error('Customer contract PDF error:', error);
res.status(500).json({ error: 'Failed to render contract PDF' });
errorResponse(res, error, 500, 'Failed to render contract PDF');
}
});
+50 -1
View File
@@ -14,10 +14,29 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
const logger = require('../utils/logger');
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone);
// Whether the global "phone field" toggle (#322) is enabled. Same shape as
// the helper in adminEvents.js — kept local so this route doesn't import
// from a sibling route file.
const isPhoneFieldEnabled = async () => {
try {
const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first();
if (!row) return false;
let value = row.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch { /* keep raw */ }
}
return value === true;
} catch {
return false;
}
};
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
@@ -69,6 +88,9 @@ router.post('/', adminAuth, [
body('event_date').isDate(),
body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL),
body('customer_phone').optional({ nullable: true, checkFalsy: true })
.isString().trim()
.isLength({ max: 32 }).withMessage('Phone number must be at most 32 characters'),
body('admin_email').isEmail(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
@@ -109,6 +131,8 @@ router.post('/', adminAuth, [
}
const customerColumnsAvailable = await hasCustomerContactColumns();
const phoneEnabled = await isPhoneFieldEnabled();
const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null;
const requirePassword = parseBooleanInput(requirePasswordInput, true);
@@ -163,6 +187,7 @@ router.post('/', adminAuth, [
event_name,
event_date,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
...(customerPhone ? { customer_phone: customerPhone } : {}),
host_name: customerName,
host_email: customerEmail,
admin_email,
@@ -192,6 +217,29 @@ router.post('/', adminAuth, [
welcome_message: welcome_message || ''
});
// WhatsApp gallery_ready notification (#647 follow-up). Mirrors the
// adminEvents.js path: fires when the customer supplied a phone, the
// feature is enabled, and a config exists. Non-fatal — a queue failure
// must never block gallery creation.
if (customerPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(eventId, customerPhone, 'gallery_created', {
customer_name: customerName || '',
event_name,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : '',
expiry_date: expires_at ? expires_at.toISOString() : null,
language: null,
});
}
} catch (waError) {
logger.warn('Failed to queue WhatsApp notification on create', waError.message);
}
}
// Webhook lifecycle (#327). Legacy public endpoint — events go live
// immediately so created + published fire together. Payload uses the
// canonical event subject (#341) — every event.* webhook now includes
@@ -208,6 +256,7 @@ router.post('/', adminAuth, [
share_token: shareToken,
customer_name: customerName,
customer_email: customerEmail,
customer_phone: customerPhone,
});
await webhookService.fire('event.created', { event: eventSubject });
await webhookService.fire('event.published', { event: eventSubject });
@@ -223,7 +272,7 @@ router.post('/', adminAuth, [
customer_email: customerEmail
});
} catch (error) {
console.error(error);
logger.error(error);
res.status(500).json({ error: 'Failed to create event' });
}
});
+16 -51
View File
@@ -14,7 +14,7 @@ const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync } = require('../utils/routeHelpers');
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService');
@@ -213,8 +213,7 @@ router.get('/:slug/info', async (req, res) => {
promo_markdown: event.promo_markdown || null
});
} catch (error) {
console.error('Error fetching gallery info:', error);
res.status(500).json({ error: 'Failed to fetch gallery info' });
errorResponse(res, error, 500, 'Failed to fetch gallery info');
}
});
@@ -739,8 +738,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
})
});
} catch (error) {
console.error('Error fetching photos:', error);
res.status(500).json({ error: 'Failed to fetch photos' });
errorResponse(res, error, 500, 'Failed to fetch photos');
}
});
@@ -772,8 +770,7 @@ router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (re
res.json({ message: 'Photo visibility updated', visibility });
} catch (error) {
logger.error('Error updating photo visibility:', error);
res.status(500).json({ error: 'Failed to update photo visibility' });
errorResponse(res, error, 500, 'Failed to update photo visibility');
}
});
@@ -801,8 +798,7 @@ router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, r
res.json({ message: `${count} photos updated`, visibility });
} catch (error) {
logger.error('Error bulk updating photo visibility:', error);
res.status(500).json({ error: 'Failed to update photo visibility' });
errorResponse(res, error, 500, 'Failed to update photo visibility');
}
});
@@ -916,13 +912,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
});
}
} catch (error) {
logger.error('Unexpected error processing gallery download', {
slug: req.params.slug,
photoId: req.params.photoId,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to download photo' });
errorResponse(res, error, 500, 'Failed to download photo');
}
});
@@ -1093,12 +1083,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
action: 'download_all'
});
} catch (error) {
logger.error('Error creating bulk gallery download', {
slug: req.params.slug,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to create download archive' });
errorResponse(res, error, 500, 'Failed to create download archive');
}
});
@@ -1215,12 +1200,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
action: 'download_selected'
});
} catch (error) {
logger.error('Error in download-selected:', {
slug: req.params.slug,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to download selected photos' });
errorResponse(res, error, 500, 'Failed to download selected photos');
}
});
@@ -1452,13 +1432,7 @@ router.get('/:slug/photo/:photoId',
}
}
} catch (error) {
logger.error('Error serving photo:', {
error: error.message,
stack: error.stack,
photoId: req.params.photoId,
eventId: req.event?.id
});
res.status(500).json({ error: 'Failed to serve photo' });
errorResponse(res, error, 500, 'Failed to serve photo');
}
}
);
@@ -1549,12 +1523,7 @@ router.get('/:slug/thumbnail/:photoId',
stream.pipe(res);
}
} catch (error) {
logger.error('Error serving thumbnail:', {
error: error.message,
photoId: req.params.photoId,
eventId: req.event?.id
});
res.status(500).json({ error: 'Failed to serve thumbnail' });
errorResponse(res, error, 500, 'Failed to serve thumbnail');
}
}
);
@@ -1763,8 +1732,7 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
identity_mode: settings.identity_mode || 'simple'
});
} catch (error) {
console.error('Error fetching feedback settings:', error);
res.status(500).json({ error: 'Failed to fetch feedback settings' });
errorResponse(res, error, 500, 'Failed to fetch feedback settings');
}
});
@@ -1826,8 +1794,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 });
logger.info('Created temp upload directory:', tempUploadDir);
} catch (mkdirErr) {
logger.error('Failed to create temp upload directory:', mkdirErr);
return res.status(500).json({ error: 'Server configuration error: unable to create upload directory' });
return errorResponse(res, mkdirErr, 500, 'Server configuration error: unable to create upload directory');
}
}
@@ -1877,7 +1844,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
// Handle upload
upload(req, res, async (err) => {
if (err) {
console.error('Upload error:', err);
logger.error('Upload error:', err);
return res.status(400).json({ error: err.message });
}
@@ -1911,13 +1878,11 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
errors: result.errors.length > 0 ? result.errors : undefined,
});
} catch (processError) {
console.error('Photo processing error:', processError);
res.status(500).json({ error: 'Failed to process photos' });
errorResponse(res, processError, 500, 'Failed to process photos');
}
});
} catch (error) {
console.error('Upload route error:', error);
res.status(500).json({ error: 'Failed to upload photos' });
errorResponse(res, error, 500, 'Failed to upload photos');
}
});
@@ -1955,7 +1920,7 @@ router.get('/:slug/css-template', async (req, res) => {
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache
res.send(template.css_content);
} catch (error) {
console.error('Get CSS template error:', error);
logger.error('Get CSS template error:', error);
res.status(500).send('/* Error loading template */');
}
});
+5 -4
View File
@@ -8,6 +8,7 @@ const { getStorage } = require('../services/storage');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
const { withLocalCopy } = require('../services/imageProcessor');
const crypto = require('crypto');
const logger = require('../utils/logger');
const router = express.Router();
@@ -164,7 +165,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
res.send(finalImage);
} catch (error) {
console.error('Error serving protected image:', error);
logger.error('Error serving protected image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
});
@@ -208,7 +209,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
});
} catch (error) {
console.error('Error generating secure token:', error);
logger.error('Error generating secure token:', error);
res.status(500).json({ error: 'Failed to generate token' });
}
});
@@ -242,7 +243,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
});
} catch (error) {
console.error('Error generating signed URL:', error);
logger.error('Error generating signed URL:', error);
res.status(500).json({ error: 'Failed to generate URL' });
}
});
@@ -304,7 +305,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
res.send(imageBuffer);
} catch (error) {
console.error('Error serving signed image:', error);
logger.error('Error serving signed image:', error);
res.status(500).json({ error: 'Failed to serve image' });
}
});
+2 -1
View File
@@ -1,5 +1,6 @@
const express = require('express');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const router = express.Router();
// Get public CMS page
@@ -41,7 +42,7 @@ router.get('/pages/:slug', async (req, res) => {
updated_at: page.updated_at
});
} catch (error) {
console.error('Error fetching public CMS page:', error);
logger.error('Error fetching public CMS page:', error);
res.status(500).json({ error: 'Failed to fetch page' });
}
});
+2 -1
View File
@@ -1,5 +1,6 @@
const express = require('express');
const { db, withRetry } = require('../database/db');
const logger = require('../utils/logger');
const router = express.Router();
// Get public settings (branding and theme)
@@ -205,7 +206,7 @@ router.get('/', async (req, res) => {
res.json(publicSettings);
} catch (error) {
console.error('Public settings fetch error:', error);
logger.error('Public settings fetch error:', error);
res.status(500).json({ error: 'Failed to fetch settings' });
}
});
+2 -2
View File
@@ -412,7 +412,7 @@ async function getRecentAccessStats() {
return acc;
}, {});
} catch (error) {
console.error('Error getting recent access stats:', error);
logger.error('Error getting recent access stats:', error);
return {};
}
}
@@ -440,7 +440,7 @@ async function getSuspiciousActivityStats() {
uniqueIPs: parseInt(uniqueIPs.count)
};
} catch (error) {
console.error('Error getting suspicious activity stats:', error);
logger.error('Error getting suspicious activity stats:', error);
return { suspiciousEvents: 0, uniqueIPs: 0 };
}
}
+82
View File
@@ -0,0 +1,82 @@
'use strict';
// Public first-run setup endpoints. UNAUTHENTICATED by design — they exist so a
// fresh instance can create its first admin from the browser (no ADMIN_PASSWORD
// in .env). Both are hard-gated on "no admin exists yet", and POST /admin also
// requires the one-time setup token, so they self-close after setup. The POST is
// rate-limited at the mount point in server.js (authRateLimiter).
const express = require('express');
const { body, validationResult } = require('express-validator');
const setupService = require('../services/setupService');
const { getClientIp } = require('../utils/requestIp');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
const router = express.Router();
router.get('/status', async (req, res) => {
try {
res.json(await setupService.getSetupStatus());
} catch (err) {
logger.error('[setup] status failed', { error: err.message });
res.status(500).json({ error: 'Failed to read setup status' });
}
});
// Step-1 pre-flight: validate the setup token without consuming it, so the
// two-step wizard can block "Continue" on a wrong token. Rate-limited at the
// mount point in server.js (authRateLimiter), same as POST /admin.
router.post('/verify-token', [
body('token').notEmpty().withMessage('Setup token is required'),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
try {
const valid = await setupService.verifySetupToken(req.body.token);
if (!valid) {
return res.status(400).json({ error: 'Invalid setup token', field: 'token' });
}
return res.json({ valid: true });
} catch (err) {
if (err.statusCode) {
return res.status(err.statusCode).json({ error: err.message, field: err.details || undefined });
}
logger.error('[setup] verifyToken failed', { error: err.message });
return res.status(500).json({ error: 'Setup failed' });
}
});
router.post('/admin', [
body('token').notEmpty().withMessage('Setup token is required'),
body('email').isEmail().withMessage('A valid email is required'),
body('password').notEmpty().withMessage('Password is required'),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
try {
const { token, email, password } = req.body;
const result = await setupService.createInitialAdmin({
token,
email,
password,
ip: getClientIp(req),
});
setAdminAuthCookie(res, result.token);
// Token delivered via HttpOnly cookie only (mirrors admin login).
res.status(201).json({ user: result.user });
} catch (err) {
if (err.statusCode) {
// `field` (token/email/password) lets the client show a translated
// message instead of rendering the raw English error verbatim.
return res.status(err.statusCode).json({ error: err.message, field: err.details || undefined });
}
logger.error('[setup] createInitialAdmin failed', { error: err.message });
return res.status(500).json({ error: 'Setup failed' });
}
});
module.exports = router;
+42
View File
@@ -308,6 +308,48 @@ router.post(
type: 'admin', id: req.admin.id, name: req.admin.username
});
// Customer notifications (#647 follow-up). v1 events go live in the
// same call (not draft-aware), so the gallery_created email + WhatsApp
// fire here — mirroring the adminEvents.js create-and-publish path.
// Both are best-effort: a queue failure must not block the API response.
const expiryIso = expires_at ? new Date(expires_at).toISOString() : null;
if (customer_email) {
try {
const { queueEmail } = require('../../services/emailProcessor');
await queueEmail(id, customer_email, 'gallery_created', {
customer_name: customer_name || '',
customer_email,
host_name: customer_name || '',
event_name,
event_date: event_date || null,
gallery_link: shareUrl,
gallery_password: require_password ? password : 'No password required',
expiry_date: expiryIso,
welcome_message: ''
});
} catch (emailError) {
logger.warn('v1 POST /events: failed to queue gallery_created email', { error: emailError.message });
}
}
if (persistPhone) {
try {
const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor');
const waConfig = await getWhatsAppConfig();
if (waConfig && waConfig.enabled) {
await queueWhatsapp(id, persistPhone, 'gallery_created', {
customer_name: customer_name || '',
event_name,
gallery_link: shareUrl,
gallery_password: require_password ? password : '',
expiry_date: expiryIso,
language: null,
});
}
} catch (waError) {
logger.warn('v1 POST /events: failed to queue WhatsApp notification', { error: waError.message });
}
}
// Webhook lifecycle (#327). v1 events are not draft-aware, so they're
// both created AND published in the same call. Canonical event
// subject (#341) — customer contact + share_token always included.
+2 -14
View File
@@ -10,6 +10,7 @@ const cron = require('node-cron');
const { db } = require('../database/db');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const { formatBytes } = require('../utils/formatBytes');
const { formatBoolean } = require('../utils/dbCompat');
const backupManifest = require('./backupManifest');
const S3StorageAdapter = require('./storage/s3Storage');
@@ -778,19 +779,6 @@ async function performRsyncBackup(config, files) {
};
}
function formatBytes(bytes, decimals = 2) {
if (!bytes) {
return '0 Bytes';
}
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
async function performS3Backup(config, files) {
try {
const bucket = config.backup_s3_bucket;
@@ -1189,7 +1177,7 @@ async function runBackupInternal(isManual = false) {
async function startBackupService() {
try {
const config = await resolveConfigWithFallback();
const config = await resolveConfigWithFallback();
if (!config || !normalizeBoolean(config.backup_enabled)) {
if (backupJob) {
backupJob.stop();
@@ -1,720 +0,0 @@
const cron = require('node-cron');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const { db } = require('../database/db');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const backupManifest = require('./backupManifest');
// Backup job reference
let backupJob = null;
let backupConfig = null;
let isRunning = false;
// Storage paths
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Calculate file checksum using SHA256
*/
async function calculateChecksum(filePath) {
const hash = crypto.createHash('sha256');
const stream = require('fs').createReadStream(filePath);
return new Promise((resolve, reject) => {
stream.on('data', data => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* Get database backup information
*/
async function getDatabaseBackupInfo() {
try {
// Check for recent database backup
const recentDbBackup = await db('database_backup_runs')
.where('status', 'completed')
.orderBy('completed_at', 'desc')
.first();
if (recentDbBackup && recentDbBackup.file_path) {
return {
type: recentDbBackup.backup_type,
backupFile: recentDbBackup.file_path,
size: recentDbBackup.file_size_bytes,
checksum: recentDbBackup.checksum,
tables: recentDbBackup.statistics ? JSON.parse(recentDbBackup.statistics).tables : {},
rowCounts: recentDbBackup.table_checksums ? JSON.parse(recentDbBackup.table_checksums) : {}
};
}
return {
type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite',
backupFile: null,
size: 0,
checksum: null,
tables: {},
rowCounts: {}
};
} catch (error) {
logger.error('Failed to get database backup info:', error);
return {
type: 'unknown',
backupFile: null,
size: 0,
checksum: null,
tables: {},
rowCounts: {}
};
}
}
/**
* Get backup configuration from database
*/
async function getBackupConfig() {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
} catch (error) {
logger.error('Failed to get backup configuration:', error);
return null;
}
}
/**
* Get list of files to backup
*/
async function getFilesToBackup(includeArchived = true) {
const files = [];
const storagePath = getStoragePath();
try {
// Active events
const activePath = path.join(storagePath, 'events/active');
await scanDirectory(activePath, files, storagePath);
// Archived events (if enabled)
if (includeArchived) {
const archivePath = path.join(storagePath, 'events/archived');
await scanDirectory(archivePath, files, storagePath);
}
// Thumbnails
const thumbsPath = path.join(storagePath, 'thumbnails');
await scanDirectory(thumbsPath, files, storagePath);
// Uploads (logos, favicons, etc.)
const uploadsPath = path.join(storagePath, 'uploads');
await scanDirectory(uploadsPath, files, storagePath);
return files;
} catch (error) {
logger.error('Failed to get files to backup:', error);
throw error;
}
}
/**
* Recursively scan directory for files
*/
async function scanDirectory(dirPath, fileList, basePath, excludePatterns = []) {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relativePath = path.relative(basePath, fullPath);
// Check exclude patterns
if (excludePatterns.some(pattern => {
if (pattern.includes('*')) {
return new RegExp(pattern.replace(/\*/g, '.*')).test(entry.name);
}
return entry.name === pattern;
})) {
continue;
}
if (entry.isDirectory()) {
await scanDirectory(fullPath, fileList, basePath, excludePatterns);
} else if (entry.isFile()) {
const stats = await fs.stat(fullPath);
fileList.push({
path: fullPath,
relativePath: relativePath,
size: stats.size,
modified: stats.mtime
});
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
logger.error(`Failed to scan directory ${dirPath}:`, error);
}
}
}
/**
* Check if file has changed since last backup
*/
async function hasFileChanged(filePath, checksum) {
try {
const fileState = await db('backup_file_states')
.where('file_path', filePath)
.first();
return !fileState || fileState.checksum !== checksum;
} catch (error) {
logger.error('Failed to check file state:', error);
return true; // Assume changed if we can't check
}
}
/**
* Update file state in database
*/
async function updateFileState(filePath, checksum, size, modified) {
try {
const existing = await db('backup_file_states')
.where('file_path', filePath)
.first();
const data = {
file_path: filePath,
checksum: checksum,
size_bytes: size,
last_modified: modified,
last_backed_up: new Date()
};
if (existing) {
await db('backup_file_states')
.where('id', existing.id)
.update(data);
} else {
await db('backup_file_states').insert(data);
}
} catch (error) {
logger.error('Failed to update file state:', error);
}
}
/**
* Perform local directory backup
*/
async function performLocalBackup(config, files) {
const destPath = config.backup_destination_path;
const storagePath = getStoragePath();
let backedUpCount = 0;
let backedUpSize = 0;
const backedUpFiles = [];
// Ensure destination exists
await fs.mkdir(destPath, { recursive: true });
for (const file of files) {
try {
// Skip large files if configured
const maxSizeMB = config.backup_max_file_size_mb || 5000;
if (file.size > maxSizeMB * 1024 * 1024) {
logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
continue;
}
// Calculate checksum
const checksum = await calculateChecksum(file.path);
file.checksum = checksum; // Add checksum to file object
// Check if file has changed
const changed = await hasFileChanged(file.relativePath, checksum);
if (!changed) {
continue;
}
// Copy file
const destFilePath = path.join(destPath, file.relativePath);
const destDir = path.dirname(destFilePath);
await fs.mkdir(destDir, { recursive: true });
await fs.copyFile(file.path, destFilePath);
// Update state
await updateFileState(file.relativePath, checksum, file.size, file.modified);
backedUpCount++;
backedUpSize += file.size;
backedUpFiles.push(file.relativePath);
} catch (error) {
logger.error(`Failed to backup file ${file.relativePath}:`, error);
}
}
return { backedUpCount, backedUpSize, backedUpFiles };
}
/**
* Perform rsync backup
*/
async function performRsyncBackup(config, files) {
const storagePath = getStoragePath();
const host = config.backup_rsync_host;
const user = config.backup_rsync_user;
const remotePath = config.backup_rsync_path;
const sshKey = config.backup_rsync_ssh_key;
if (!host || !remotePath) {
throw new Error('Rsync configuration incomplete');
}
// Build rsync command
const rsyncOptions = [
'-avz', // archive, verbose, compress
'--delete', // remove deleted files
'--stats' // show statistics
];
if (sshKey) {
rsyncOptions.push(`-e "ssh -i ${sshKey} -o StrictHostKeyChecking=no"`);
}
// Add exclude patterns
const excludePatterns = config.backup_exclude_patterns || [];
excludePatterns.forEach(pattern => {
rsyncOptions.push(`--exclude="${pattern}"`);
});
const source = `${storagePath}/`;
const destination = user ? `${user}@${host}:${remotePath}` : `${host}:${remotePath}`;
const rsyncCommand = `rsync ${rsyncOptions.join(' ')} "${source}" "${destination}"`;
try {
const { stdout, stderr } = await execAsync(rsyncCommand);
// Parse rsync stats
const stats = parseRsyncStats(stdout);
// Update file states for successfully synced files
for (const file of files) {
try {
const checksum = await calculateChecksum(file.path);
await updateFileState(file.relativePath, checksum, file.size, file.modified);
} catch (error) {
logger.error(`Failed to update state for ${file.relativePath}:`, error);
}
}
return {
backedUpCount: stats.filesTransferred || files.length,
backedUpSize: stats.totalSize || files.reduce((sum, f) => sum + f.size, 0),
backedUpFiles: files.map(f => f.relativePath)
};
} catch (error) {
logger.error('Rsync backup failed:', error);
throw new Error(`Rsync backup failed: ${error.message}`);
}
}
/**
* Parse rsync statistics from output
*/
function parseRsyncStats(output) {
const stats = {};
// Extract files transferred
const filesMatch = output.match(/Number of files transferred: (\d+)/);
if (filesMatch) {
stats.filesTransferred = parseInt(filesMatch[1]);
}
// Extract total size
const sizeMatch = output.match(/Total file size: ([\d,]+) bytes/);
if (sizeMatch) {
stats.totalSize = parseInt(sizeMatch[1].replace(/,/g, ''));
}
return stats;
}
/**
* Perform S3-compatible backup
*/
async function performS3Backup(config, files) {
// This would require AWS SDK or similar
// For now, return a placeholder
throw new Error('S3 backup not implemented yet');
}
/**
* Run backup process
*/
async function runBackup() {
if (isRunning) {
logger.warn('Backup already running, skipping');
return;
}
isRunning = true;
const startTime = new Date();
let backupRun = null;
try {
// Get current configuration
const config = await getBackupConfig();
if (!config.backup_enabled) {
logger.info('Backup is disabled, skipping');
return;
}
// Create backup run record
const [runId] = await db('backup_runs').insert({
started_at: startTime,
status: 'running',
backup_type: 'scheduled'
});
backupRun = { id: runId };
// Get files to backup
const files = await getFilesToBackup(config.backup_include_archived);
logger.info(`Found ${files.length} files to check for backup`);
// Perform backup based on destination type
let result;
switch (config.backup_destination_type) {
case 'local':
result = await performLocalBackup(config, files);
break;
case 'rsync':
result = await performRsyncBackup(config, files);
break;
case 's3':
result = await performS3Backup(config, files);
break;
default:
throw new Error(`Unknown backup destination type: ${config.backup_destination_type}`);
}
// Calculate duration
const endTime = new Date();
const durationSeconds = Math.round((endTime - startTime) / 1000);
// Generate backup manifest
let manifestPath = null;
try {
logger.info('Generating backup manifest...');
// Get database backup info if available
const databaseInfo = await getDatabaseBackupInfo();
// Determine if this is an incremental backup
const lastSuccessfulBackup = await db('backup_runs')
.where('status', 'completed')
.whereNot('id', runId)
.orderBy('completed_at', 'desc')
.first();
let manifest;
const manifestOptions = {
backupType: lastSuccessfulBackup ? 'incremental' : 'full',
backupPath: config.backup_destination_path || config.backup_destination_type,
files: files.filter(f => result.backedUpFiles && result.backedUpFiles.includes(f.relativePath)),
databaseInfo: databaseInfo,
parentBackupId: lastSuccessfulBackup ? lastSuccessfulBackup.manifest_id : null,
format: config.backup_manifest_format || 'json',
customMetadata: {
backup_run_id: runId,
destination_type: config.backup_destination_type,
operator: 'system',
reason: 'scheduled',
retentionDays: config.backup_retention_days || 30
}
};
if (lastSuccessfulBackup && lastSuccessfulBackup.manifest_path) {
try {
const parentManifest = await backupManifest.loadManifest(lastSuccessfulBackup.manifest_path);
manifest = await backupManifest.generateIncrementalManifest(manifestOptions, parentManifest);
} catch (error) {
logger.warn('Failed to load parent manifest, generating full manifest:', error);
manifest = await backupManifest.generateManifest(manifestOptions);
}
} else {
manifest = await backupManifest.generateManifest(manifestOptions);
}
// Save manifest
const manifestDir = config.backup_manifest_path || path.join(config.backup_destination_path || '/backup', 'manifests');
await fs.mkdir(manifestDir, { recursive: true });
const manifestFileName = `backup-manifest-${manifest.backup.id}.${config.backup_manifest_format || 'json'}`;
manifestPath = path.join(manifestDir, manifestFileName);
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
logger.info(`Backup manifest saved to ${manifestPath}`);
} catch (error) {
logger.error('Failed to generate backup manifest:', error);
// Don't fail the entire backup for manifest generation failure
}
// Update backup run record
await db('backup_runs')
.where('id', runId)
.update({
completed_at: endTime,
status: 'completed',
files_backed_up: result.backedUpCount,
total_size_bytes: result.backedUpSize,
duration_seconds: durationSeconds,
manifest_path: manifestPath,
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
statistics: JSON.stringify({
totalFilesChecked: files.length,
filesBackedUp: result.backedUpCount,
totalSize: result.backedUpSize,
averageFileSize: result.backedUpCount > 0 ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
manifestGenerated: !!manifestPath
})
});
logger.info(`Backup completed: ${result.backedUpCount} files, ${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB in ${durationSeconds}s`);
// Send success email if configured
if (config.backup_email_on_success) {
// Get admin emails
const admins = await db('admin_users').where('is_active', formatBoolean(true));
for (const admin of admins) {
await queueEmail(null, admin.email, 'backup_completed', {
start_time: startTime.toISOString(),
duration: `${durationSeconds} seconds`,
files_count: result.backedUpCount.toString(),
total_size: `${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB`,
backup_type: config.backup_destination_type
});
}
}
} catch (error) {
logger.error('Backup failed:', error);
// Update backup run record
if (backupRun) {
await db('backup_runs')
.where('id', backupRun.id)
.update({
completed_at: new Date(),
status: 'failed',
error_message: error.message
});
}
// Send failure email
const config = await getBackupConfig();
if (config && config.backup_email_on_failure) {
const admins = await db('admin_users').where('is_active', formatBoolean(true));
for (const admin of admins) {
await queueEmail(null, admin.email, 'backup_failed', {
start_time: startTime.toISOString(),
backup_type: config.backup_destination_type || 'unknown',
error_message: error.message
});
}
}
} finally {
isRunning = false;
}
}
/**
* Start backup service
*/
async function startBackupService() {
try {
// Get configuration
backupConfig = await getBackupConfig();
if (!backupConfig || !backupConfig.backup_enabled) {
logger.info('Backup service is disabled');
return;
}
// Cancel existing job if any
if (backupJob) {
backupJob.stop();
}
// Schedule backup job
const schedule = backupConfig.backup_schedule || '0 2 * * *'; // Default: 2 AM daily
backupJob = cron.schedule(schedule, async () => {
logger.info('Starting scheduled backup');
await runBackup();
});
logger.info(`Backup service started with schedule: ${schedule}`);
} catch (error) {
logger.error('Failed to start backup service:', error);
}
}
/**
* Stop backup service
*/
function stopBackupService() {
if (backupJob) {
backupJob.stop();
backupJob = null;
logger.info('Backup service stopped');
}
}
/**
* Trigger manual backup
*/
async function triggerManualBackup() {
logger.info('Starting manual backup');
await runBackup();
}
/**
* Get backup status and history
*/
async function getBackupStatus(limit = 10) {
try {
const runs = await db('backup_runs')
.orderBy('started_at', 'desc')
.limit(limit);
const lastRun = runs[0];
const isHealthy = lastRun && lastRun.status === 'completed';
// Validate manifest if exists
let manifestValid = false;
if (lastRun && lastRun.manifest_path) {
try {
const manifest = await backupManifest.loadManifest(lastRun.manifest_path);
backupManifest.validateManifest(manifest);
manifestValid = true;
} catch (error) {
logger.warn('Manifest validation failed:', error);
}
}
return {
isRunning,
isHealthy,
lastRun: lastRun ? {
...lastRun,
manifestValid
} : null,
recentRuns: runs,
nextScheduledRun: backupJob ? getNextScheduledRun() : null
};
} catch (error) {
logger.error('Failed to get backup status:', error);
return {
isRunning,
isHealthy: false,
error: error.message
};
}
}
/**
* Get next scheduled run time
*/
function getNextScheduledRun() {
// This is a simplified version - would need proper cron parsing
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(2, 0, 0, 0); // Assuming default 2 AM schedule
return tomorrow.toISOString();
}
/**
* Clean up old backup runs
*/
async function cleanupOldBackupRuns(retentionDays = 30) {
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const deleted = await db('backup_runs')
.where('started_at', '<', cutoffDate)
.delete();
if (deleted > 0) {
logger.info(`Cleaned up ${deleted} old backup runs`);
}
} catch (error) {
logger.error('Failed to cleanup old backup runs:', error);
}
}
/**
* Get backup manifest for a specific backup run
*/
async function getBackupManifest(backupRunId) {
try {
const run = await db('backup_runs')
.where('id', backupRunId)
.first();
if (!run || !run.manifest_path) {
throw new Error('Backup manifest not found');
}
const manifest = await backupManifest.loadManifest(run.manifest_path);
return {
manifest,
summary: backupManifest.generateSummaryReport(manifest)
};
} catch (error) {
logger.error('Failed to get backup manifest:', error);
throw error;
}
}
/**
* Validate a backup manifest file
*/
async function validateBackupManifest(manifestPath) {
try {
const manifest = await backupManifest.loadManifest(manifestPath);
backupManifest.validateManifest(manifest);
return { valid: true, manifest };
} catch (error) {
return { valid: false, error: error.message };
}
}
module.exports = {
startBackupService,
stopBackupService,
triggerManualBackup,
getBackupStatus,
runBackup,
cleanupOldBackupRuns,
getBackupManifest,
validateBackupManifest
};
@@ -0,0 +1,409 @@
// Extracted verbatim from contractService.js — see ../contractService.js for the
// module-level overview. Do not add behavior here without updating the entry re-exports.
const crypto = require('crypto');
const { db, logActivity } = require('../../database/db');
const logger = require('../../utils/logger');
const { getAppSetting } = require('../../utils/appSettings');
const { AppError } = require('../../utils/errors');
const { hasColumnCached } = require('../../utils/schemaCache');
const businessProfileService = require('../businessProfileService');
const { ensureSystemBlocksSeeded } = require('../contractBlocksService');
const { ensureInt } = require('../../utils/numericHelpers');
const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers');
/**
* Convert an accepted quote into a fresh draft contract, pre-populating
* the customer, language, title, valid-until window, and source_quote_id
* back-pointer. Idempotent if the quote already has a linked contract
* (quote.converted_contract_id set), returns that contract's id without
* creating a duplicate.
*
* Does NOT flip quote.status the quote stays 'accepted' while the
* contract is the active deliverable. The quoteevent / quoteinvoice
* paths are gated against the converted_contract_id back-pointer so an
* admin can't accidentally double-spend the quote.
*/
async function createFromQuote(quoteId, adminId) {
// Same self-heal as createContract — the quote-conversion path seeds
// the contract with every active system block, and the new
// quote_line_items_table block needs to be present for it to land
// in the default inclusion list.
await ensureSystemBlocksSeeded();
const quote = await db('quotes').where({ id: quoteId }).first();
if (!quote) throw new AppError('Quote not found', 404);
if (quote.status !== 'accepted') {
throw new AppError(`Cannot convert a quote with status '${quote.status}'`, 409, 'QUOTE_NOT_ACCEPTED');
}
if (quote.converted_contract_id) {
return { contractId: quote.converted_contract_id, alreadyConverted: true };
}
if (quote.converted_event_id) {
throw new AppError(
'This quote was already converted to an event. Create the contract from the event instead.',
409, 'ALREADY_CONVERTED_TO_EVENT',
);
}
const customer = await db('customer_accounts').where({ id: quote.customer_account_id }).first();
ensureCustomerActive(customer);
const profile = (await businessProfileService.getProfile()).profile;
const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30;
const issueDate = new Date().toISOString().slice(0, 10);
const validUntil = new Date(Date.now() + validDays * 24 * 60 * 60 * 1000)
.toISOString().slice(0, 10);
const title = quote.event_name
? `Contract — ${quote.event_name}`
: `Contract from quote ${quote.quote_number}`;
// Schema-drift safety: the lineage columns landed in migration 130
// as in-place edits. Dev installs that ran 130 BEFORE that edit
// won't have these columns yet. hasColumn() lets us skip the
// affected writes instead of crashing with a generic 500.
const hasContractSourceQuote = await hasColumnCached('contracts', 'source_quote_id');
const hasQuoteContractBackPointer = await hasColumnCached('quotes', 'converted_contract_id');
const hasContractEventCols = await hasColumnCached('contracts', 'event_name');
// Resolve the actor BEFORE opening the transaction — adminActor reads
// admin_users via the global db, which deadlocks the single-connection
// SQLite pool if evaluated inside the trx (prepare_contract runs unattended).
const actor = await adminActor(adminId);
return await db.transaction(async (trx) => {
// Pass trx so the sequence claim joins our outer transaction —
// SQLite deadlocks otherwise (1-connection default).
const contractNumber = await nextContractNumber(trx);
const contractRow = {
contract_number: contractNumber,
customer_account_id: quote.customer_account_id,
status: 'draft',
language: quote.language || customer.preferred_language || profile?.default_locale || 'de',
issue_date: issueDate,
valid_until: validUntil,
title,
intro_text: quote.intro_text || null,
outro_text: quote.outro_text || null,
created_by_admin_id: adminId,
created_at: new Date(),
updated_at: new Date(),
};
if (hasContractSourceQuote) contractRow.source_quote_id = quote.id;
// Migration 140 — contract from quote inherits the quote's
// deal_uuid so both documents belong to the same deal chain.
// Falls back to a fresh UUID only if the source quote predates the
// backfill (shouldn't happen on a migrated install, but defensive).
contractRow.deal_uuid = quote.deal_uuid || crypto.randomUUID();
// Propagate the quote's event snapshot — same fields the quote
// already carries (set by createQuote). Means contract-from-quote
// chains preserve "this contract is for the Wedding Doe / Müller"
// labelling all the way through to the resulting invoice's
// event_name field.
if (hasContractEventCols) {
contractRow.event_name = quote.event_name || null;
contractRow.event_date = quote.event_date || null;
contractRow.event_time_start = quote.event_time_start || null;
contractRow.event_time_end = quote.event_time_end || null;
}
const inserted = await trx('contracts').insert(contractRow).returning('id');
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
// Seed every active system block. Same shape as createContract.
// D.3 — batched insert (one DB round-trip vs N).
const systemBlocks = await trx('contract_blocks')
.where({ is_system: true, is_active: true })
.orderBy(['section', 'display_order']);
const sectionCounters = {};
const inclusionRows = systemBlocks.map((block) => {
sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1;
return {
contract_id: contractId,
block_id: block.id,
section: block.section,
position: sectionCounters[block.section],
body_text_snapshot: null,
body_text_de_snapshot: null,
included: true,
created_at: new Date(),
updated_at: new Date(),
};
});
if (inclusionRows.length > 0) {
await trx('contract_block_inclusions').insert(inclusionRows);
}
// Back-pointer so the quote detail page can deep-link to its
// resulting contract and the convert-to-event/invoice paths know
// to refuse double conversion. Skipped silently when the column
// hasn't migrated — the contract is still created cleanly.
if (hasQuoteContractBackPointer) {
await trx('quotes').where({ id: quote.id }).update({
converted_contract_id: contractId,
updated_at: new Date(),
});
}
try {
// Pass `trx` so the audit insert rides the transaction's connection;
// the global db here deadlocks the single-connection SQLite pool.
await logActivity('contract_created_from_quote',
{ contractId, contractNumber, quoteId: quote.id, quoteNumber: quote.quote_number },
null, actor, trx);
} catch (_) { /* logging is best-effort */ }
logger.info('Contract created from quote', { adminId, contractId, contractNumber, quoteId: quote.id });
return { contractId, alreadyConverted: false };
});
}
/**
* Convert a fully-signed contract into an event + scheduled invoices.
* Delegates to quoteService.convertToEvent using the contract's
* source_quote_id so the line items + payment plan come from the
* original quote. The quote MUST still be in 'accepted' status (i.e.
* not previously converted) createFromQuote keeps it that way.
*
* On success the contract's converted_event_id is set (back-pointer)
* and the source quote flips to 'converted'.
*/
async function convertToEvent(contractId, adminId) {
const contract = await db('contracts').where({ id: contractId }).first();
if (!contract) throw new AppError('Contract not found', 404);
if (contract.status !== 'fully_signed') {
throw new AppError(
`Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`,
409, 'CONTRACT_NOT_FULLY_SIGNED',
);
}
if (contract.converted_event_id) {
return { eventId: contract.converted_event_id, alreadyConverted: true };
}
const hasContractConvertedEvent = await hasColumnCached('contracts', 'converted_event_id');
// Path A: source quote present → delegate to quoteService which
// replays the full installment schedule into invoices alongside
// the event row.
if (contract.source_quote_id) {
const quoteService = require('../quoteService');
const result = await quoteService.convertToEvent(contract.source_quote_id, adminId, { fromContract: true });
if (hasContractConvertedEvent) {
await db('contracts').where({ id: contractId }).update({
converted_event_id: result.eventId,
updated_at: new Date(),
});
}
try {
await logActivity('contract_converted_to_event',
{ contractId, eventId: result.eventId, quoteId: contract.source_quote_id },
result.eventId, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
return result;
}
// Path B: standalone contract → mint an empty placeholder event
// row the admin fleshes out from the events admin page. Same
// column-introspection trick quoteService uses so installs with
// old/new host_*/customer_* column variants both work.
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
ensureCustomerActive(customer);
const adminRow = await db('admin_users').where({ id: adminId }).first();
const today = new Date();
const oneYearFromNow = new Date(today.getTime());
oneYearFromNow.setFullYear(today.getFullYear() + 1);
const fullName = [customer.first_name, customer.last_name].filter(Boolean).join(' ')
|| customer.display_name || customer.company_name || contract.contract_number;
const customerEmail = customer.email || `${contract.contract_number.toLowerCase()}@picpeak.local`;
const adminEmail = adminRow?.email || customer.email || 'admin@picpeak.local';
const placeholderHash = crypto.randomBytes(32).toString('hex');
const shareToken = crypto.randomBytes(32).toString('hex');
const eventCols = await db('events').columnInfo();
const candidate = {
slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
// Prefer the contract's event_name snapshot (set on the contract
// editor or inherited from the source quote) over the contract
// title. Falls back to a deterministic placeholder so the event
// row never has a blank name.
event_name: contract.event_name || contract.title || `Event ${contract.contract_number}`,
event_date: contract.event_date || contract.issue_date,
host_name: fullName,
host_email: customerEmail,
customer_name: fullName,
customer_email: customerEmail,
customer_phone: customer.phone,
admin_email: adminEmail,
event_type: 'wedding',
password_hash: placeholderHash,
share_link: shareToken,
share_token: shareToken,
expires_at: oneYearFromNow,
is_active: true,
is_archived: false,
is_draft: true,
created_by: adminId,
quote_id: null,
created_at: new Date(),
updated_at: new Date(),
};
const eventRow = {};
for (const [k, v] of Object.entries(candidate)) {
if (Object.prototype.hasOwnProperty.call(eventCols, k)) eventRow[k] = v;
}
const inserted = await db('events').insert(eventRow).returning('id');
const eventId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
// Link the customer so they see the event on their portal once
// the admin activates it. Best-effort — older installs without
// the junction table still get the event row.
try {
if (await db.schema.hasTable('event_customer_assignments')) {
await db('event_customer_assignments').insert({
event_id: eventId,
customer_account_id: customer.id,
assigned_by_admin_id: adminId,
assigned_at: new Date(),
});
}
} catch (_) { /* best-effort */ }
if (hasContractConvertedEvent) {
await db('contracts').where({ id: contractId }).update({
converted_event_id: eventId,
updated_at: new Date(),
});
}
try {
await logActivity('contract_converted_to_empty_event',
{ contractId, eventId }, eventId, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
return { eventId, alreadyConverted: false };
}
/**
* Convert a fully-signed contract directly into invoice(s) without
* creating an event row. Same delegation pattern as convertToEvent.
*/
async function convertToInvoiceOnly(contractId, adminId) {
const contract = await db('contracts').where({ id: contractId }).first();
if (!contract) throw new AppError('Contract not found', 404);
if (contract.status !== 'fully_signed') {
throw new AppError(
`Cannot convert a contract with status '${contract.status}'. The contract must be fully signed by both parties first.`,
409, 'CONTRACT_NOT_FULLY_SIGNED',
);
}
// Schema-drift guard — the lineage columns are in-place edits to
// migration 130. Skip the back-pointer update silently when the
// column hasn't migrated yet.
const hasInvoiceContractBackPointer = await hasColumnCached('invoices', 'source_contract_id');
// Path A: contract has a source quote → replay its line items +
// payment plan via quoteService (full installment schedule).
if (contract.source_quote_id) {
const quoteService = require('../quoteService');
const result = await quoteService.convertToInvoiceOnly(contract.source_quote_id, adminId, { fromContract: true });
if (hasInvoiceContractBackPointer) {
await db('invoices')
.where({ source_quote_id: contract.source_quote_id })
.whereNull('source_contract_id')
.update({ source_contract_id: contractId });
}
try {
await logActivity('contract_converted_to_invoices',
{ contractId, quoteId: contract.source_quote_id, installments: result.installmentsCreated },
null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
return result;
}
// Path B: standalone contract (no source quote) → direct DB insert
// of an empty draft. We deliberately bypass invoiceService.createInvoice
// because that runs ensureCustomerCanBill, which throws if the
// customer doesn't have feature_bills enabled. Admin clicking
// "Convert to invoice" on the contract detail page IS the
// authorisation; the admin will fill in line items manually before
// sending.
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
ensureCustomerActive(customer);
const invoiceService = require('../invoiceService');
const profile = (await businessProfileService.getProfile()).profile || {};
const currency = (profile.default_currency || 'CHF').toUpperCase();
const language = contract.language || customer.preferred_language || profile.default_locale || 'de';
const issueDate = new Date().toISOString().slice(0, 10);
const netDays = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30;
const dueDate = new Date(Date.now() + netDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
// Pre-resolve which event-snapshot columns the invoices table has
// (migration 123) so we can copy contract.event_name etc onto the
// new invoice. Falls back to contract.title when event_name is
// empty — gives standalone contracts a useful label even when
// the admin didn't fill out the event field.
const invoiceHasEventName = await hasColumnCached('invoices', 'event_name');
const eventNameSnapshot = (contract.event_name || contract.title || null);
const invoiceNumber = await invoiceService.nextInvoiceNumber();
const invoiceRow = {
invoice_number: invoiceNumber,
customer_account_id: contract.customer_account_id,
source_quote_id: null,
event_id: null,
language,
currency,
issue_date: issueDate,
due_date: dueDate,
installment_index: 0,
installment_total: 1,
status: 'scheduled',
net_amount_minor: 0,
vat_rate: 0,
vat_amount_minor: 0,
shipping_amount_minor: 0,
total_amount_minor: 0,
paid_amount_minor: 0,
reminder_level: 0,
late_fee_amount_minor: 0,
created_by_admin_id: adminId,
created_at: new Date(),
updated_at: new Date(),
};
if (hasInvoiceContractBackPointer) invoiceRow.source_contract_id = contractId;
// Migration 140 — invoice inherits the contract's deal_uuid so the
// contract + invoice belong to the same deal chain. Fresh UUID if
// the contract predates the backfill (defensive).
invoiceRow.deal_uuid = contract.deal_uuid || crypto.randomUUID();
// Snapshot the contract's event fields onto the invoice so the
// BillDetailPage + customer portal show the same "Wedding Doe /
// Müller" label that the contract carries. event_name is also the
// field the dunning emails reference in their templates.
if (invoiceHasEventName) {
invoiceRow.event_name = eventNameSnapshot;
invoiceRow.event_date = contract.event_date || null;
invoiceRow.event_time_start = contract.event_time_start || null;
invoiceRow.event_time_end = contract.event_time_end || null;
}
const inserted = await db('invoices').insert(invoiceRow).returning('id');
const invoiceId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
try {
await logActivity('contract_converted_to_empty_invoice',
{ contractId, invoiceId, invoiceNumber }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
// Match the result shape of the source-quote path so the frontend
// toast can use the same translation key. `installmentsCreated` is
// always 1 here (single empty invoice).
return { installmentsCreated: 1, invoiceId };
}
module.exports = {
createFromQuote,
convertToEvent,
convertToInvoiceOnly,
};
+384
View File
@@ -0,0 +1,384 @@
// Extracted verbatim from contractService.js — see ../contractService.js for the
// module-level overview. Do not add behavior here without updating the entry re-exports.
const crypto = require('crypto');
const { db, withRetry, logActivity } = require('../../database/db');
const logger = require('../../utils/logger');
const { getAppSetting } = require('../../utils/appSettings');
const { AppError } = require('../../utils/errors');
const { hasColumnCached } = require('../../utils/schemaCache');
const businessProfileService = require('../businessProfileService');
const { ensureSystemBlocksSeeded } = require('../contractBlocksService');
const { ensureInt } = require('../../utils/numericHelpers');
const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers');
// ---------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------
async function listContracts({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
return await withRetry(async () => {
let query = db('contracts')
.leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id')
.select(
'contracts.*',
'customer_accounts.email as customer_email',
'customer_accounts.display_name as customer_display_name',
'customer_accounts.first_name as customer_first_name',
'customer_accounts.last_name as customer_last_name',
'customer_accounts.company_name as customer_company_name',
);
if (Array.isArray(filters.status) && filters.status.length > 0) {
query = query.whereIn('contracts.status', filters.status);
}
if (filters.customerAccountId) {
query = query.where('contracts.customer_account_id', filters.customerAccountId);
}
if (filters.q && String(filters.q).trim()) {
const term = `%${String(filters.q).trim()}%`;
query = query.andWhere(function() {
this.where('contracts.contract_number', 'like', term)
.orWhere('contracts.title', 'like', term)
.orWhere('customer_accounts.email', 'like', term)
.orWhere('customer_accounts.company_name', 'like', term);
});
}
const countQuery = query.clone().clearSelect().clearOrder().count('contracts.id as total').first();
const totalRow = await countQuery;
const total = ensureInt(totalRow?.total || 0);
switch (sort) {
case 'oldest':
query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc');
break;
case 'issue_asc':
query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc');
break;
case 'issue_desc':
query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc');
break;
case 'customer_asc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
.orderBy('contracts.id', 'desc');
break;
case 'customer_desc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
.orderBy('contracts.id', 'desc');
break;
case 'newest':
default:
query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc');
break;
}
const offset = Math.max(0, (page - 1) * pageSize);
query = query.offset(offset).limit(pageSize);
const rows = await query;
return { rows, total, page, pageSize };
});
}
async function getContractById(id) {
return await withRetry(async () => {
const contract = await db('contracts')
.leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id')
.where('contracts.id', id)
.select(
'contracts.*',
'customer_accounts.email as customer_email',
'customer_accounts.display_name as customer_display_name',
'customer_accounts.first_name as customer_first_name',
'customer_accounts.last_name as customer_last_name',
'customer_accounts.company_name as customer_company_name',
'customer_accounts.preferred_language as customer_preferred_language',
)
.first();
if (!contract) return null;
const inclusions = await db('contract_block_inclusions as inc')
.leftJoin('contract_blocks as blk', 'blk.id', 'inc.block_id')
.where('inc.contract_id', id)
.orderByRaw(`
CASE inc.section
WHEN 'basics' THEN 1
WHEN 'scope' THEN 2
WHEN 'privacy' THEN 3
WHEN 'commercial' THEN 4
WHEN 'nda' THEN 5
WHEN 'closing' THEN 6
ELSE 99
END
`)
.orderBy('inc.position', 'asc')
.select(
'inc.*',
'blk.slug as block_slug',
'blk.name as block_name',
'blk.description as block_description',
'blk.body_text as block_body_text',
'blk.body_text_de as block_body_text_de',
// Migration 131 — locale variants. Pulled with column-existence
// guard so installs that haven't run migration 131 still load
// contracts (just without the new columns).
...(await hasColumnCached('contract_blocks', 'body_text_ru')
? ['blk.body_text_ru as block_body_text_ru'] : []),
...(await hasColumnCached('contract_blocks', 'body_text_pt')
? ['blk.body_text_pt as block_body_text_pt'] : []),
...(await hasColumnCached('contract_blocks', 'body_text_nl')
? ['blk.body_text_nl as block_body_text_nl'] : []),
...(await hasColumnCached('contract_blocks', 'body_text_fr')
? ['blk.body_text_fr as block_body_text_fr'] : []),
'blk.is_system as block_is_system',
);
return { contract, inclusions };
});
}
/**
* Create a draft contract. Pre-populates `contract_block_inclusions`
* with every active system block toggled ON so the admin sees a
* sensible starting point and just toggles off what they don't need.
*
* Custom (non-system) blocks are NOT auto-included admin opts in to
* those explicitly so a runaway block library doesn't pollute every
* new contract.
*/
async function createContract(payload, adminId) {
// Self-heal: ensure runtime-seeded system blocks (e.g. the
// quote_line_items_table added after migration 131 was deployed)
// exist before we copy active system blocks into the new contract's
// inclusion list. Idempotent — only fires if rows are missing.
await ensureSystemBlocksSeeded();
const customer = await db('customer_accounts').where({ id: payload.customerAccountId }).first();
ensureCustomerActive(customer);
const profile = (await businessProfileService.getProfile()).profile;
const language = payload.language || customer.preferred_language || profile?.default_locale || 'de';
const validDays = ensureInt(await getAppSetting('crm_contracts_default_valid_days')) || 30;
const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10);
const validUntil = payload.validUntil || new Date(Date.now() + validDays * 24 * 60 * 60 * 1000)
.toISOString().slice(0, 10);
// Schema-drift guard for the event-snapshot columns added as
// in-place migration 130 edits. We only write them when the DB
// actually has them; older dev installs that haven't re-migrated
// simply skip these fields (contract still saves successfully).
const hasEventCols = await hasColumnCached('contracts', 'event_name');
return await db.transaction(async (trx) => {
// Pass trx so the sequence claim joins our outer transaction —
// SQLite deadlocks otherwise (1-connection default).
const contractNumber = await nextContractNumber(trx);
const row = {
contract_number: contractNumber,
customer_account_id: payload.customerAccountId,
status: 'draft',
language,
issue_date: issueDate,
valid_until: validUntil,
title: payload.title || null,
intro_text: payload.introText || null,
outro_text: payload.outroText || null,
// Migration 140 — standalone contract is a deal root; mint a
// fresh UUID. The createFromQuote path (line ~1557) sets this
// from the source quote's deal_uuid instead.
deal_uuid: crypto.randomUUID(),
created_by_admin_id: adminId,
created_at: new Date(),
updated_at: new Date(),
};
if (hasEventCols) {
row.event_name = payload.eventName || null;
row.event_date = payload.eventDate || null;
row.event_time_start = payload.eventTimeStart || null;
row.event_time_end = payload.eventTimeEnd || null;
}
// Migration 121 — optional link to a Project Overview project.
if (payload.projectId !== undefined && await hasColumnCached('contracts', 'project_id')) {
row.project_id = payload.projectId || null;
}
const inserted = await trx('contracts').insert(row).returning('id');
if (row.project_id && row.deal_uuid) {
await require('../projectService').linkDealToProject(row.deal_uuid, row.project_id, trx);
}
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
// Seed with every active system block, toggled on. Per-section
// position = display_order from the source block.
//
// D.3 — batched insert. Previously this loop fired one INSERT per
// block (12+ round-trips inside the transaction on a fresh contract).
// Batched into a single `.insert(rows)` since the row count is
// bounded (system block count) and the inserts are independent.
const systemBlocks = await trx('contract_blocks')
.where({ is_system: true, is_active: true })
.orderBy(['section', 'display_order']);
const sectionCounters = {};
const inclusionRows = systemBlocks.map((block) => {
sectionCounters[block.section] = (sectionCounters[block.section] || 0) + 1;
return {
contract_id: contractId,
block_id: block.id,
section: block.section,
position: sectionCounters[block.section],
body_text_snapshot: null,
body_text_de_snapshot: null,
included: true,
created_at: new Date(),
updated_at: new Date(),
};
});
if (inclusionRows.length > 0) {
await trx('contract_block_inclusions').insert(inclusionRows);
}
try {
await logActivity('contract_created', { contractId, contractNumber, customerAccountId: payload.customerAccountId }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
logger.info('Contract created', { adminId, contractId, contractNumber });
return contractId;
});
}
/**
* Update a draft contract. Editing a sent contract is refused admin
* must cancel + create a fresh one (avoids invalidating the customer's
* signed copy).
*
* payload.blocks is an array of `{ blockId, included, position }`
* tuples; the service rewrites the contract_block_inclusions rows
* accordingly.
*/
async function updateContract(id, payload, adminId) {
const existing = await db('contracts').where({ id }).first();
if (!existing) throw new AppError('Contract not found', 404);
if (existing.status !== 'draft') {
throw new AppError(
`Cannot edit a contract with status '${existing.status}'. Cancel and create a new contract for amendments.`,
409,
'CONTRACT_LOCKED',
);
}
const hasEventCols = await hasColumnCached('contracts', 'event_name');
return await db.transaction(async (trx) => {
const updates = { updated_at: new Date() };
const map = {
title: 'title',
introText: 'intro_text',
outroText: 'outro_text',
language: 'language',
validUntil: 'valid_until',
issueDate: 'issue_date',
};
// Event-snapshot fields only flow through when the DB has them
// (in-place migration 130 edit). Guarded so dev installs that
// haven't re-migrated don't crash the update.
if (hasEventCols) {
Object.assign(map, {
eventName: 'event_name',
eventDate: 'event_date',
eventTimeStart: 'event_time_start',
eventTimeEnd: 'event_time_end',
});
}
for (const [api, col] of Object.entries(map)) {
if (api in payload) updates[col] = payload[api] || null;
}
// Migration 121 — optional Project Overview link.
if ('projectId' in payload && await hasColumnCached('contracts', 'project_id')) {
updates.project_id = payload.projectId || null;
}
await trx('contracts').where({ id }).update(updates);
// Cascade across the deal lineage (linked quote / event / invoices).
if (updates.project_id) {
const dealRow = await trx('contracts').where({ id }).select('deal_uuid').first();
await require('../projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx);
}
// Replace inclusions only when the caller sent an explicit list.
// (Editor's "save" sends every row; an inline "toggle" save could
// send a partial update — current frontend always sends full list.)
if (Array.isArray(payload.blocks)) {
await trx('contract_block_inclusions').where({ contract_id: id }).del();
// Recompute per-section position so we don't trust caller order
// for ordering integrity; caller controls only the section
// sequence via the order of items in payload.blocks.
//
// Previously this loop did one SELECT per block to look up its
// section. On a contract with 12 included blocks that's 12
// round-trips inside the transaction — pure N+1. Batch the
// lookup into a single WHERE…IN, build a Map, and read it in
// the loop. The insert itself stays sequential because the
// editor's payload size is bounded (<30 blocks in practice) and
// a single batch insert would lose row-by-row insert ordering
// guarantees we don't actually need.
const blockIds = [
...new Set(payload.blocks.map((e) => e.blockId).filter((id) => Number.isFinite(id))),
];
const blocksFound = blockIds.length > 0
? await trx('contract_blocks').whereIn('id', blockIds).select('id', 'section')
: [];
const sectionByBlockId = new Map(blocksFound.map((b) => [b.id, b.section]));
const sectionCounters = {};
for (const entry of payload.blocks) {
const section = sectionByBlockId.get(entry.blockId);
if (!section) continue;
sectionCounters[section] = (sectionCounters[section] || 0) + 1;
await trx('contract_block_inclusions').insert({
contract_id: id,
block_id: entry.blockId,
section,
position: ensureInt(entry.position) || sectionCounters[section],
body_text_snapshot: null,
body_text_de_snapshot: null,
included: entry.included === false ? false : true,
created_at: new Date(),
updated_at: new Date(),
});
}
}
try {
await logActivity('contract_updated', { contractId: id }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
return id;
});
}
async function cancelContract(id, adminId) {
const contract = await db('contracts').where({ id }).first();
if (!contract) throw new AppError('Contract not found', 404);
if (!['draft', 'sent'].includes(contract.status)) {
throw new AppError(`Cannot cancel a contract with status '${contract.status}'`, 409);
}
await db('contracts').where({ id }).update({
status: 'cancelled',
updated_at: new Date(),
});
// Invalidate any outstanding tokens.
await db('contract_action_tokens').where({ contract_id: id, used_at: null }).update({
expires_at: new Date(),
});
try {
await logActivity('contract_cancelled', { contractId: id }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
return { status: 'cancelled' };
}
module.exports = {
listContracts,
getContractById,
createContract,
updateContract,
cancelContract,
};
+134
View File
@@ -0,0 +1,134 @@
// Extracted verbatim from contractService.js — see ../contractService.js for the
// module-level overview. Do not add behavior here without updating the entry re-exports.
const { db } = require('../../database/db');
const logger = require('../../utils/logger');
const { getAppSetting } = require('../../utils/appSettings');
const { AppError } = require('../../utils/errors');
const { nextDocumentNumber } = require('../../utils/documentSequences');
const SECTIONS_ORDER = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing'];
/**
* Build a proper {id, type, name} actor object for logActivity. The
* db.js helper silently downgrades string actors (e.g. 'admin:1') to
* actor_type='system' with null name, so the audit timeline showed
* "system" for every admin-driven event. Fetching the admin's name
* once per service call is a small read cost on a non-hot path.
*
* Pass `customerPublic()` for events triggered by the public token
* (customer signing, customer wet-signed PDF upload).
*/
async function adminActor(adminId) {
if (!adminId) return { type: 'system' };
try {
// admin_users only carries username + email (no first/last/name
// columns — confirmed from db.js:265). Prefer username for the
// audit timeline because it's the operator-chosen identifier
// shown elsewhere in the admin UI; fall back to email when an
// older install seeded a row without a username.
const row = await db('admin_users')
.where({ id: adminId })
.select('id', 'username', 'email')
.first();
if (!row) return { id: adminId, type: 'admin', name: `Admin #${adminId}` };
const displayName = row.username || row.email || `Admin #${adminId}`;
return { id: adminId, type: 'admin', name: displayName };
} catch (_) {
return { id: adminId, type: 'admin', name: `Admin #${adminId}` };
}
}
function customerPublicActor() {
return { type: 'customer', name: 'Customer (public link)' };
}
/**
* Fire a contract lifecycle event for the workflow engine. Best-effort:
* resolves the customer email (so send_email actions have a recipient) and
* never throws into the caller. No-op when the workflows flag is off (emit
* fails closed). Mirrors quoteService.emitQuoteEvent.
*/
async function emitContractEvent(contract, status) {
try {
let customerEmail = null;
if (contract.customer_account_id) {
const c = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
customerEmail = c?.email || null;
}
await require('../workflows').emitWorkflowEvent(`contract.${status}`, {
entityType: 'contract',
entityId: contract.id,
payload: {
contractId: contract.id,
contractNumber: contract.contract_number,
customerAccountId: contract.customer_account_id || null,
customerEmail,
eventName: contract.event_name || null,
title: contract.title || null,
},
});
} catch (err) {
logger.warn('Failed to emit contract workflow event', { contractId: contract.id, status, error: err.message });
}
}
/**
* Privacy gate for the customer/admin IP captured at signing time.
* The `crm_contracts_store_ip` setting (default true) controls
* whether the IP is persisted into the DB. When off, this helper
* returns null regardless of what the route passed in same shape
* the rest of the code expects, just with no IP data.
*
* Default-true means upgrades preserve current behaviour. Operators
* with strict data-minimisation requirements opt out in Settings
* CRM-Settings Contracts.
*/
async function maybeStoreIp(ip) {
if (!ip) return null;
const enabled = await getAppSetting('crm_contracts_store_ip');
// Default true: only block when EXPLICITLY opted out. The audit
// flagged that `enabled === false` missed legacy installs where
// app_settings stored the toggle as a string ('false', '0') — those
// would slip through and the IP would still get persisted despite
// the operator's intent. Cover string/number/bool variants
// defensively. Anything else (null, undefined, true) preserves
// the default-on behavior.
if (enabled === false) return null;
if (enabled === 0 || enabled === '0') return null;
if (typeof enabled === 'string' && enabled.toLowerCase() === 'false') return null;
return ip;
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
/**
* Gap-free per-year contract number sequence. See
* utils/documentSequences.js for the locking story; migration 132
* created the underlying table. Atomic against concurrent admin
* creates the previous SELECT-MAX-then-INSERT raced and could
* emit `C-2026-AB12C3` after 5 retries.
*/
async function nextContractNumber(trx) {
return nextDocumentNumber('contract', 'crm_contracts_number_format', 'C-{YEAR}-{SEQ:04d}', trx);
}
function ensureCustomerActive(customer) {
if (!customer) throw new AppError('Customer not found', 404);
if (customer.is_active === false || customer.is_active === 0) {
throw new AppError('Customer is deactivated', 409);
}
}
module.exports = {
SECTIONS_ORDER,
adminActor,
customerPublicActor,
emitContractEvent,
maybeStoreIp,
nextContractNumber,
ensureCustomerActive,
};
@@ -0,0 +1,282 @@
// Extracted verbatim from contractService.js — see ../contractService.js for the
// module-level overview. Do not add behavior here without updating the entry re-exports.
const { db } = require('../../database/db');
const { getAppSetting } = require('../../utils/appSettings');
const { formatShortDate } = require('../../utils/dateFormatter');
const businessProfileService = require('../businessProfileService');
const { buildIssuerBlock, buildRecipientBlock } = require('../_renderContext');
const { ensureInt } = require('../../utils/numericHelpers');
const { SECTIONS_ORDER } = require('./helpers');
/**
* Handlebars-lite renderer:
* - `{{#if var}}…{{/if}}` blocks resolved by truthiness of variables[var].
* - `{{var}}` substituted with the matching variable. Missing
* placeholders are left literally as `{{var}}` so the admin
* notices the unresolved field in preview.
*
* Mirrors safeTemplateReplace in emailProcessor.js (lines 424-461) but
* without HTML escaping contract bodies are rendered into PDF via
* pdfService.drawText, which doesn't need HTML safety.
*/
function renderTemplatedBody(template, variables) {
if (typeof template !== 'string' || template.length === 0) return template;
const conditionalsResolved = template.replace(
/\{\{#if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\/if\}\}/g,
(_match, key, inner) => {
const v = variables ? variables[key] : undefined;
const truthy = v !== undefined && v !== null && v !== '' && v !== false && v !== 0;
return truthy ? inner : '';
}
);
return conditionalsResolved.replace(/\{\{(\w+)\}\}/g, (match, key) => {
if (!variables || !Object.prototype.hasOwnProperty.call(variables, key)) return match;
return String(variables[key]);
});
}
/**
* Build the variable bag used by renderTemplatedBody. Reads the
* customer record, business profile, and (when available) the
* customer's active payment-term defaults so block placeholders for
* net_days / skonto_percent / etc. resolve. Returns plain strings
* dates formatted DD.MM.YYYY in DE-CH style, numbers as-is.
*/
async function buildPlaceholderContext(contract, customer) {
const profile = (await businessProfileService.getProfile()).profile || {};
const issuerCompany = profile.company_name || '';
const issuerAddress = [profile.address_line1, profile.postal_code, profile.city]
.filter(Boolean)
.join(', ');
// Resolve net_days + skonto from app_settings defaults so the
// payment_terms_reference block has sensible numbers to substitute
// when the admin hasn't tied the contract to a specific quote.
const netDaysDefault = ensureInt(await getAppSetting('crm_payment_default_net_days')) || 30;
const skontoPercentDefault = await getAppSetting('crm_invoices_skonto_percent_default');
const skontoWithinDaysDefault = ensureInt(await getAppSetting('crm_invoices_skonto_business_days')) || 5;
// {{source_quote_number}} placeholder — substituted into the body of
// the `quote_line_items_table` system block (and any admin-authored
// block that wants to reference the quote). Empty string when the
// contract wasn't generated from a quote.
let sourceQuoteNumber = '';
if (contract.source_quote_id) {
const srcQuote = await db('quotes').where({ id: contract.source_quote_id })
.select('quote_number').first();
if (srcQuote) sourceQuoteNumber = srcQuote.quote_number || '';
}
const customerName = customer
? (customer.company_name
|| [customer.first_name, customer.last_name].filter(Boolean).join(' ')
|| customer.display_name
|| customer.email
|| '')
: '';
const customerAddress = customer
? [customer.address_line1, customer.address_line2, customer.postal_code, customer.city]
.filter(Boolean)
.join(', ')
: '';
return {
customer_name: customerName,
customer_address: customerAddress,
event_name: contract.event_name || '',
event_date: formatShortDate(contract.event_date),
issue_date: formatShortDate(contract.issue_date),
contract_number: contract.contract_number || '',
title: contract.title || '',
net_days: String(netDaysDefault),
skonto_percent: skontoPercentDefault == null ? '0' : String(skontoPercentDefault),
skonto_within_days: String(skontoWithinDaysDefault),
cancellation_30d_percent: '25',
currency: (profile.default_currency || 'CHF').toUpperCase(),
issuer_company_name: issuerCompany,
issuer_address: issuerAddress,
source_quote_number: sourceQuoteNumber,
};
}
// ---------------------------------------------------------------------
// Render-context builder + PDF helpers
// ---------------------------------------------------------------------
/**
* Build the data shape pdfService.renderContractToBuffer expects.
* Sections are emitted in canonical SECTIONS_ORDER; blocks within a
* section are emitted in `position` order. Bodies are run through
* renderTemplatedBody so {{placeholders}} are substituted.
*
* When the contract has been sent, `body_text_snapshot` is used (so
* later edits to the source block don't mutate the rendered document).
* Before send (preview from editor) the live `contract_blocks.body_text`
* is used so the admin can iterate on block bodies and see the result.
*/
async function buildRenderContext(contract, inclusions) {
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
const profile = (await businessProfileService.getProfile()).profile || {};
const placeholders = await buildPlaceholderContext(contract, customer);
// Pull source-quote line items when this contract was generated from a
// quote. Surfaced on the render context so the renderer can draw a real
// table at the location of the `quote_line_items_table` system block.
// Sub-items keep their parent's position via the LEFT JOIN so the
// renderer can indent them with a `↳` prefix.
let quoteLineItems = [];
let quoteCurrency = null;
let quoteNumber = null;
if (contract.source_quote_id) {
const srcQuote = await db('quotes').where({ id: contract.source_quote_id })
.select('quote_number', 'currency').first();
if (srcQuote) {
quoteCurrency = srcQuote.currency;
quoteNumber = srcQuote.quote_number;
quoteLineItems = await db('quote_line_items as li')
.leftJoin('quote_line_items as parent', 'parent.id', 'li.parent_line_item_id')
.where('li.quote_id', contract.source_quote_id)
.orderBy('li.position', 'asc')
.select('li.*', 'parent.position as parent_position');
}
}
const locale = contract.language || customer?.preferred_language || profile.default_locale || 'de';
// Group inclusions by section + render each block body.
const blocksBySection = {};
for (const section of SECTIONS_ORDER) blocksBySection[section] = [];
const sortedInclusions = [...inclusions]
.filter((row) => row.included === true || row.included === 1 || row.included === '1')
.sort((a, b) => {
const sa = SECTIONS_ORDER.indexOf(a.section);
const sb = SECTIONS_ORDER.indexOf(b.section);
if (sa !== sb) return sa - sb;
return (a.position || 0) - (b.position || 0);
});
for (const row of sortedInclusions) {
if (!blocksBySection[row.section]) continue;
// The inclusion row carries the JOINED block columns aliased with
// a `block_` prefix (see getContractById). Pre-send drafts have
// null snapshots, so fall through to the live block body.
// Migration 131 added ru/pt/nl/fr columns. The body resolver
// picks the locale-matching column first, falls back through
// DE → EN, so an admin can stage translations one locale at a
// time without breaking contracts in other languages.
const bodyEn = row.body_text_snapshot || row.block_body_text || '';
const bodyDe = row.body_text_de_snapshot || row.block_body_text_de || '';
const bodyRu = row.block_body_text_ru || '';
const bodyPt = row.block_body_text_pt || '';
const bodyNl = row.block_body_text_nl || '';
const bodyFr = row.block_body_text_fr || '';
const localeBody = ({
de: bodyDe,
ru: bodyRu,
pt: bodyPt,
nl: bodyNl,
fr: bodyFr,
})[locale] || '';
const sourceBody = localeBody || bodyEn || bodyDe;
// Substitute placeholders, then strip any leading `**Title**\n`
// line — the block's `name` field is already rendered as a bold
// sub-heading by the PDF/public layouts, so a bold first line in
// the body produces a duplicated title. Inline `**bold**` markers
// elsewhere in the body are preserved (the PDF renders them as
// actual bold via renderBodyMarkdown; the public route strips
// them since the React page has no inline-bold UI).
const rendered = renderTemplatedBody(sourceBody, placeholders)
.replace(/^\s*\*\*[^*\n]+\*\*\s*\n+/, '');
blocksBySection[row.section].push({
slug: row.block_slug || null,
name: row.block_name,
section: row.section,
body: rendered,
});
}
// Use the same robust logo resolver quote/invoice use — checks
// business_profile.logo_path → app_settings.branding_logo_path →
// app_settings.branding_logo_url, with ~7 disk-location candidates
// before giving up.
const { resolveLogoFile } = require('../../utils/resolveLogoFile');
const resolvedLogoPath = await resolveLogoFile(profile);
// Global date format from Settings → General (general_date_format).
let dateFormat = null;
try {
const raw = await getAppSetting('general_date_format');
if (raw && typeof raw === 'object' && raw.format) dateFormat = raw;
else if (typeof raw === 'string' && raw.trim()) dateFormat = { format: raw.trim() };
} catch (_) { /* fall back to default */ }
return {
locale,
dateFormat,
// Mirror the quote/invoice issuer shape EXACTLY so drawIssuerBlock
// honours the same business-profile toggles (pdf_show_logo,
// pdf_show_company_name, pdf_logo_height, pdf_company_name_inline,
// pdf_folding_marks) across all three document types. Per maintainer:
// contracts reuse the same toggles — no contract-specific knobs.
// Shared issuer + recipient builders. Contracts use the base toggle
// set (no quote-only payment-block fields). The renderer-aware
// recipient gating means contractService's previously-drifted
// local attentionLine logic now matches quote + invoice exactly.
issuer: buildIssuerBlock(profile, resolvedLogoPath),
recipient: buildRecipientBlock(profile, customer),
doc: {
contractNumber: contract.contract_number,
title: contract.title || '',
issueDate: contract.issue_date,
validUntil: contract.valid_until,
introText: contract.intro_text ? renderTemplatedBody(contract.intro_text, placeholders) : null,
outroText: contract.outro_text ? renderTemplatedBody(contract.outro_text, placeholders) : null,
},
// Blocks grouped + ordered by canonical section order.
sections: SECTIONS_ORDER
.map((section) => ({ section, blocks: blocksBySection[section] }))
.filter((s) => s.blocks.length > 0),
// Source-quote line items, surfaced at the top level so the PDF
// renderer can draw a formatted table where the
// `quote_line_items_table` system block is included. Empty array
// when the contract has no source quote.
quoteLineItems,
quoteCurrency,
quoteSourceNumber: quoteNumber,
// Signature evidence (used by the PDF renderer to stamp signatures
// into the closing section when present).
signatures: {
customer: contract.signed_customer_name ? {
name: contract.signed_customer_name,
signedAt: contract.signed_by_customer_at,
ip: contract.signed_customer_ip,
signaturePath: contract.signed_customer_signature_path,
} : null,
admin: contract.signed_admin_name ? {
name: contract.signed_admin_name,
signedAt: contract.signed_by_admin_at,
ip: contract.signed_admin_ip,
signaturePath: contract.signed_admin_signature_path,
} : null,
},
// Audit-trail evidence appended to the rendered PDF as a final
// page (issue #3). The renderer skips the page when this is null
// OR when the contract isn't signed yet, so unsigned PDFs stay
// unchanged. Hashes are best-effort: pdfSha256 may be null on
// installs that haven't migrated to the new schema column yet —
// the page still renders the rest of the evidence.
audit: (contract.signed_customer_name || contract.signed_admin_name) ? {
contractNumber: contract.contract_number,
issuedAt: contract.sent_at,
pdfSha256: contract.pdf_sha256 || null,
signedPdfSha256: contract.signed_pdf_sha256 || null,
} : null,
};
}
module.exports = {
renderTemplatedBody,
buildPlaceholderContext,
buildRenderContext,
};
+135
View File
@@ -0,0 +1,135 @@
// Extracted verbatim from contractService.js — see ../contractService.js for the
// module-level overview. Do not add behavior here without updating the entry re-exports.
const crypto = require('crypto');
const { db, logActivity } = require('../../database/db');
const logger = require('../../utils/logger');
const { getAppSetting } = require('../../utils/appSettings');
const { AppError } = require('../../utils/errors');
const { hasColumnCached } = require('../../utils/schemaCache');
const { formatShortDate } = require('../../utils/dateFormatter');
const pdfService = require('../pdfService');
const emailProcessor = require('../emailProcessor');
const { ensureContractEmailTemplatesSeeded } = require('../contractEmailTemplates');
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const { adminActor, emitContractEvent, ensureCustomerActive } = require('./helpers');
const { buildRenderContext } = require('./renderContext');
const { persistContractPdf } = require('./signatureAssets');
const { getContractById } = require('./crud');
/**
* Render PDF for a saved contract (preview before send, or re-render
* after signing).
*/
async function renderContractPdfBuffer(contractId) {
const data = await getContractById(contractId);
if (!data) throw new AppError('Contract not found', 404);
const ctx = await buildRenderContext(data.contract, data.inclusions);
return await pdfService.renderContractToBuffer(ctx);
}
/**
* Send the contract: snapshot every included block's body, render PDF,
* persist, mint a signing token, queue the customer email.
*/
async function sendContract(id, adminId) {
// Self-heal: dev installs that ran migration 130 BEFORE we added
// contract_fully_signed to the seed list won't have all three
// contract templates in email_templates. Insert any missing rows
// before we queue the email. Idempotent + module-cached.
await ensureContractEmailTemplatesSeeded(db, logger);
const data = await getContractById(id);
if (!data) throw new AppError('Contract not found', 404);
const { contract, inclusions } = data;
if (!['draft'].includes(contract.status)) {
throw new AppError(`Cannot send a contract with status '${contract.status}'`, 409);
}
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
ensureCustomerActive(customer);
// Snapshot every included block's body into the inclusion row so
// future block edits don't mutate the sent contract.
await db.transaction(async (trx) => {
for (const inc of inclusions) {
if (!(inc.included === true || inc.included === 1 || inc.included === '1')) continue;
await trx('contract_block_inclusions').where({ id: inc.id }).update({
body_text_snapshot: inc.block_body_text || null,
body_text_de_snapshot: inc.block_body_text_de || null,
updated_at: new Date(),
});
}
});
// Re-fetch with snapshots populated so the renderer uses the frozen
// bodies (matches post-send reads).
const refreshed = await getContractById(id);
const ctx = await buildRenderContext(refreshed.contract, refreshed.inclusions);
const buffer = await pdfService.renderContractToBuffer(ctx);
const { filePath: pdfPath, sha256: pdfSha256 } = await persistContractPdf(refreshed.contract, buffer);
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = contract.valid_until
? new Date(new Date(contract.valid_until).getTime() + 14 * 24 * 60 * 60 * 1000)
: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000);
// Schema-drift guard for the new pdf_sha256 column (migration 130
// in-place edit). Dev installs that haven't re-migrated skip the
// hash write; the send still succeeds.
const hasPdfSha = await hasColumnCached('contracts', 'pdf_sha256');
await db.transaction(async (trx) => {
await trx('contract_action_tokens').insert({
contract_id: id,
token,
expires_at: expiresAt,
created_at: new Date(),
});
const updates = {
status: 'sent',
sent_at: new Date(),
pdf_path: pdfPath,
updated_at: new Date(),
};
if (hasPdfSha) updates.pdf_sha256 = pdfSha256;
await trx('contracts').where({ id }).update(updates);
});
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
const responseUrl = `${frontendUrl}/contract/${token}`;
// Honour the admin's "Attach contract PDF to email" toggle. Default
// ON; an admin who prefers a link-only email turns it off and the
// customer reaches the PDF via the public sign page instead.
const attachPdf = await getAppSetting('crm_contracts_pdf_attachment_enabled');
await emailProcessor.queueEmail(null, customer.email, 'contract_sent', {
contract_number: contract.contract_number,
customer_name: customer.display_name
|| [customer.first_name, customer.last_name].filter(Boolean).join(' ')
|| customer.email.split('@')[0],
response_url: responseUrl,
title: contract.title || '',
event_name: contract.event_name || '',
valid_until: formatShortDate(contract.valid_until),
attachments: (attachPdf !== false && pdfPath) ? [{
filename: `${contract.contract_number}.pdf`,
contentPath: pdfPath,
contentType: 'application/pdf',
}] : undefined,
});
try {
await logActivity('contract_sent', { contractId: id, token }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
await emitContractEvent(contract, 'sent');
logger.info('Contract sent', { adminId, contractId: id });
return { token, pdfPath };
}
module.exports = {
renderContractPdfBuffer,
sendContract,
};
@@ -0,0 +1,221 @@
// Extracted verbatim from contractService.js — see ../contractService.js for the
// module-level overview. Do not add behavior here without updating the entry re-exports.
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const logger = require('../../utils/logger');
const { AppError } = require('../../utils/errors');
const pdfStampService = require('../pdfStampService');
/**
* SHA-256 hex digest of a Buffer or file path. Used at every PDF
* write so we can persist a content hash alongside the path
* either party can later re-hash the PDF they hold and prove (or
* disprove) it matches what we issued.
*/
function sha256OfBuffer(buffer) {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
function sha256OfFile(filePath) {
try {
return sha256OfBuffer(fs.readFileSync(filePath));
} catch (_) {
return null;
}
}
/**
* Write a contract PDF to disk and return both the path AND the
* SHA-256 hash of the buffer we just wrote. Callers persist BOTH on
* the contracts row so audit defence is single-query: SELECT
* pdf_path, pdf_sha256 FROM contracts WHERE id = ? then re-hash the
* file on disk and compare.
*
* History-preserving (per requirement #6): every write appends a
* deterministic suffix so old versions stay on disk. The contract
* row's `pdf_path` / `signed_pdf_path` always points at the most
* recent one; earlier versions remain available for forensic
* comparison.
*/
async function persistContractPdf(contract, buffer, suffix = '') {
if (!contract.contract_number) return { filePath: null, sha256: null };
const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year));
fs.mkdirSync(root, { recursive: true });
// Always append a millisecond timestamp to the filename so writes
// never overwrite an earlier version on disk. Forensic preservation.
// Example filenames:
// C-2026-0001_2026-05-19T1830-22-413.pdf (unsigned)
// C-2026-0001_signed-by-customer_2026-05-19T1845-10-002.pdf
// C-2026-0001_fully-signed_2026-05-19T1912-44-877.pdf
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = suffix
? `${contract.contract_number}_${suffix}_${stamp}.pdf`
: `${contract.contract_number}_${stamp}.pdf`;
const filePath = path.join(root, fileName);
fs.writeFileSync(filePath, buffer);
return { filePath, sha256: sha256OfBuffer(buffer) };
}
// Maximum decoded signature image size. Defends against a customer
// (or attacker holding a captured signing token) POSTing a multi-MB
// signature data URL to fill the disk. A typical signature_pad PNG
// is 1080 KB; even with retina upscaling we don't expect to see
// 1 MB. The cap is enforced on the BASE64 length before decoding so
// we never allocate the full Buffer for an oversized payload.
//
// The frontend (ContractResponsePage) downscales the canvas to a
// fixed max width before exporting via `toDataURL`, so well-behaved
// clients land well under this cap. This server-side check is the
// authoritative guard.
const MAX_SIGNATURE_BASE64_BYTES = 1024 * 1024; // 1 MB of base64 → ~750 KB decoded
async function persistSignatureImage(contract, role, dataUrl) {
if (!dataUrl || typeof dataUrl !== 'string') return null;
if (dataUrl.length > MAX_SIGNATURE_BASE64_BYTES + 100 /* prefix slack */) {
throw new AppError(
`Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`,
413, 'SIGNATURE_TOO_LARGE',
);
}
const match = dataUrl.match(/^data:image\/(png|jpeg);base64,(.+)$/);
if (!match) {
throw new AppError('Signature must be a base64-encoded PNG or JPEG data URL', 400, 'BAD_SIGNATURE_FORMAT');
}
if (match[2].length > MAX_SIGNATURE_BASE64_BYTES) {
throw new AppError(
`Signature image exceeds the ${Math.round(MAX_SIGNATURE_BASE64_BYTES / 1024)} KB cap`,
413, 'SIGNATURE_TOO_LARGE',
);
}
const ext = match[1] === 'jpeg' ? 'jpg' : 'png';
const root = path.join(
process.cwd(),
'storage',
'business-docs',
'contract',
'signatures',
String(contract.id),
);
fs.mkdirSync(root, { recursive: true });
// Filename already carries Date.now() so re-stamping a signature
// never overwrites an earlier capture — forensic preservation.
// Per role, the contract row's signed_*_signature_path always
// points at the most recent; older files stay alongside.
const filePath = path.join(root, `${role}-${Date.now()}.${ext}`);
fs.writeFileSync(filePath, Buffer.from(match[2], 'base64'));
return filePath;
}
/**
* Build the stamp sequence the pdf-lib stamp service expects from a
* single contract row. Customer first, admin second provenance
* order matches the visual order on the signature page.
*
* Used by the recovery paths (rerenderAndResend, restampSignatures).
* The hot path (recordCustomerSignature / recordAdminCountersignature)
* stamps incrementally so it constructs the stamp inline.
*/
function buildSignatureStamps(contract) {
const locale = contract.language || 'de';
const nameLabel = 'Name';
const dateLabel = locale === 'de' ? 'Datum' : 'Date';
const stamps = [];
if (contract.signed_customer_signature_path) {
stamps.push({
signaturePngPath: contract.signed_customer_signature_path,
role: 'customer',
caption: {
name: contract.signed_customer_name || '',
signedAt: contract.signed_by_customer_at,
nameLabel,
dateLabel,
},
});
}
if (contract.signed_admin_signature_path) {
stamps.push({
signaturePngPath: contract.signed_admin_signature_path,
role: 'admin',
caption: {
name: contract.signed_admin_name || '',
signedAt: contract.signed_by_admin_at,
nameLabel,
dateLabel,
},
});
}
return stamps;
}
/**
* Build the audit-certificate context expected by
* pdfStampService.renderAuditCertificate from a fully-signed
* contract row. Returns null when the contract isn't signed enough
* to warrant a certificate (no customer + no admin signature data).
*/
function buildAuditCertContext(contract) {
const hasCustomerSig = contract.signed_by_customer_at || contract.signed_customer_name;
const hasAdminSig = contract.signed_by_admin_at || contract.signed_admin_name;
if (!hasCustomerSig && !hasAdminSig) return null;
return {
contract: {
contract_number: contract.contract_number,
sent_at: contract.sent_at,
pdf_sha256: contract.pdf_sha256 || null,
signed_pdf_sha256: contract.signed_pdf_sha256 || null,
},
customer: hasCustomerSig ? {
name: contract.signed_customer_name,
signedAt: contract.signed_by_customer_at,
ip: contract.signed_customer_ip,
} : null,
admin: hasAdminSig ? {
name: contract.signed_admin_name,
signedAt: contract.signed_by_admin_at,
ip: contract.signed_admin_ip,
} : null,
locale: contract.language || 'de',
};
}
/**
* Generate the audit certificate PDF, write it to disk under the same
* year directory as the contract PDFs (suffix `audit`), and return
* its file path. Returns null when there's nothing to certify or when
* rendering fails (the email still goes out without the cert the
* stamped PDF alone remains delivered).
*/
async function persistAuditCertificate(contract) {
const ctx = buildAuditCertContext(contract);
if (!ctx) return null;
try {
const { buffer } = await pdfStampService.renderAuditCertificate(ctx);
const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year));
fs.mkdirSync(root, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const filePath = path.join(root, `${contract.contract_number}_audit_${stamp}.pdf`);
fs.writeFileSync(filePath, buffer);
return filePath;
} catch (err) {
logger.error('Failed to render audit certificate', {
contractId: contract.id,
contractNumber: contract.contract_number,
message: err.message,
});
return null;
}
}
module.exports = {
sha256OfBuffer,
sha256OfFile,
persistContractPdf,
MAX_SIGNATURE_BASE64_BYTES,
persistSignatureImage,
buildSignatureStamps,
buildAuditCertContext,
persistAuditCertificate,
};
+861
View File
@@ -0,0 +1,861 @@
// Extracted verbatim from contractService.js — see ../contractService.js for the
// module-level overview. Do not add behavior here without updating the entry re-exports.
const fs = require('fs');
const { db, logActivity } = require('../../database/db');
const logger = require('../../utils/logger');
const { getAppSetting } = require('../../utils/appSettings');
const { AppError } = require('../../utils/errors');
const { hasColumnCached } = require('../../utils/schemaCache');
const businessProfileService = require('../businessProfileService');
const pdfStampService = require('../pdfStampService');
const emailProcessor = require('../emailProcessor');
const { ensureContractEmailTemplatesSeeded } = require('../contractEmailTemplates');
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const { adminActor, customerPublicActor, emitContractEvent, maybeStoreIp } = require('./helpers');
const { buildSignatureStamps, persistAuditCertificate, persistContractPdf, persistSignatureImage, sha256OfFile } = require('./signatureAssets');
const { getContractById } = require('./crud');
/**
* Record a customer's in-browser signature (canvas + typed name +
* "I accept" checkbox). Validates the token, persists the signature
* PNG, re-renders the PDF with the signature stamped, flips status
* to `signed_by_customer`, and queues the admin notification email.
*/
async function recordCustomerSignature({ token, name, ip, signatureDataUrl, accepted }) {
// Self-heal contract email templates. The contract_signed_admin_notification
// email fires from this function — if its row is missing, the admin
// never learns the customer signed.
await ensureContractEmailTemplatesSeeded(db, logger);
if (accepted !== true) {
throw new AppError('You must confirm that you have read and agree to the terms.', 400, 'TOS_REQUIRED');
}
if (!name || !String(name).trim()) {
throw new AppError('Your name is required.', 400, 'NAME_REQUIRED');
}
// Server-side guard for the "require drawn signature" admin toggle.
// The public sign page also enforces this client-side, but the
// server is the source of truth — a malicious caller posting
// directly to /sign with a blank signatureDataUrl would otherwise
// bypass the requirement.
const requireDrawn = await getAppSetting('crm_contracts_require_drawn_signature');
if (requireDrawn === true && (!signatureDataUrl || !String(signatureDataUrl).trim())) {
throw new AppError(
'A drawn signature is required for this contract — typing your name alone is not sufficient.',
400, 'SIGNATURE_REQUIRED',
);
}
const tokenRow = await db('contract_action_tokens').where({ token }).first();
if (!tokenRow) throw new AppError('Token not found', 404);
if (tokenRow.expires_at && new Date(tokenRow.expires_at).getTime() < Date.now()) {
throw new AppError('This signing link has expired', 410);
}
if (tokenRow.used_at) {
throw new AppError('This contract has already been signed', 410, 'TOKEN_ALREADY_USED');
}
const contract = await db('contracts').where({ id: tokenRow.contract_id }).first();
if (!contract) throw new AppError('Contract not found', 404);
if (!['sent'].includes(contract.status)) {
throw new AppError(`Contract cannot be signed in status '${contract.status}'`, 409);
}
const signaturePath = signatureDataUrl
? await persistSignatureImage(contract, 'customer', signatureDataUrl)
: null;
const now = new Date();
// Resolve the IP gate ONCE before the transaction so both writes
// (contracts row + tokens row) agree. Setting flip mid-transaction
// can't happen anyway, but doing it upfront keeps the data
// consistent and saves a redundant read.
const persistedIp = await maybeStoreIp(ip);
try {
await db.transaction(async (trx) => {
await trx('contracts').where({ id: contract.id }).update({
status: 'signed_by_customer',
signed_by_customer_at: now,
signed_customer_name: String(name).trim(),
signed_customer_ip: persistedIp,
signed_customer_signature_path: signaturePath,
updated_at: now,
});
await trx('contract_action_tokens').where({ id: tokenRow.id }).update({
used_at: now,
used_action: 'signed_by_customer',
used_ip: persistedIp,
});
});
} catch (txErr) {
// C.7 — clean up the orphan signature PNG we wrote before the
// transaction. The DB rollback already undid the contract +
// token writes; the file would otherwise sit forever in
// storage/business-docs/contract/.../signatures/. Best-effort
// unlink — if the cleanup itself fails, log and re-throw the
// original transaction error so the caller still sees the real
// failure cause.
if (signaturePath) {
try {
if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath);
} catch (cleanupErr) {
logger.warn('Orphan signature PNG cleanup failed', {
path: signaturePath, message: cleanupErr.message,
});
}
}
throw txErr;
}
// Stamp the customer's signature onto the UNSIGNED PDF on disk.
// Byte-immutable approach (see pdfStampService): we read pdf_path
// (the immutable as-sent PDF), stamp the customer's signature PNG
// at the fixed coordinates on the signature page, save as a new
// timestamped file, and update signed_pdf_path. Original file
// stays untouched on disk.
const refreshed = await getContractById(contract.id);
try {
if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) {
throw new Error(`Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}`);
}
const originalPdfBuffer = fs.readFileSync(refreshed.contract.pdf_path);
const stampedBuffer = await pdfStampService.stampSignature({
pdfBuffer: originalPdfBuffer,
signaturePngPath: signaturePath,
role: 'customer',
caption: {
name: String(name).trim(),
signedAt: now,
nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name',
dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date',
},
});
const { filePath: signedPath, sha256: signedSha256 } = await persistContractPdf(
refreshed.contract, stampedBuffer, 'signed-by-customer',
);
const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256');
const updates = {
signed_pdf_path: signedPath,
updated_at: new Date(),
};
if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256;
// Migration 136 — clear any pre-existing render-failed marker; the
// most recent stamp attempt just succeeded.
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
updates.signed_pdf_render_failed_at = null;
updates.signed_pdf_render_error = null;
}
await db('contracts').where({ id: contract.id }).update(updates);
} catch (err) {
// Signature recorded; PDF re-render is best-effort. The admin can
// re-render manually from the detail page if this fails. Logged as
// error (not warn) so persistent failures surface in monitoring.
logger.error('Failed to re-render contract PDF after customer signature', {
contractId: contract.id,
message: err.message,
stack: err.stack,
});
// Migration 136 — surface the failure on the contract row so the
// admin detail page can render a recovery banner instead of the
// admin only discovering this through monitoring. err.message is
// truncated to 2 KB; the full stack stays in server logs.
try {
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
await db('contracts').where({ id: contract.id }).update({
signed_pdf_render_failed_at: new Date(),
signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048),
updated_at: new Date(),
});
}
} catch (markErr) {
// Marker write itself failed — log + swallow so the customer
// sign response still succeeds. The orphan stays orphan but
// we've at least surfaced both errors.
logger.error('Failed to record signed_pdf_render_failed marker', {
contractId: contract.id, message: markErr.message,
});
}
}
// Notify admin.
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
try {
await emailProcessor.queueEmail(null, null, 'contract_signed_admin_notification', {
contract_number: contract.contract_number,
customer_email: customer?.email || '',
signed_customer_name: String(name).trim(),
admin_dashboard_url: `${frontendUrl}/admin/clients/contracts/${contract.id}`,
});
} catch (err) {
logger.warn('Failed to queue admin notification after customer signature', {
contractId: contract.id, error: err.message,
});
}
try {
await logActivity('contract_signed_by_customer', { contractId: contract.id, token }, null, customerPublicActor());
} catch (_) { /* logging is best-effort */ }
return { status: 'signed_by_customer', signedAt: now };
}
/**
* Admin counter-signature. Bumps status to `fully_signed` (or
* `signed_by_admin` if the customer hasn't signed yet edge case
* where admin signs first, e.g. issuer-side framework agreement).
*/
async function recordAdminCountersignature(contractId, { name, ip, signatureDataUrl }, adminId) {
// Self-heal: ensure the contract_fully_signed template exists
// before we counter-sign. The dual-party send fires from this
// function on the fully_signed transition; without the template
// it silently fails and the customer never receives the PDF.
await ensureContractEmailTemplatesSeeded(db, logger);
if (!name || !String(name).trim()) {
throw new AppError('Your name is required.', 400, 'NAME_REQUIRED');
}
const contract = await db('contracts').where({ id: contractId }).first();
if (!contract) throw new AppError('Contract not found', 404);
if (!['signed_by_customer', 'sent'].includes(contract.status)) {
throw new AppError(`Cannot counter-sign a contract with status '${contract.status}'`, 409);
}
const signaturePath = signatureDataUrl
? await persistSignatureImage(contract, 'admin', signatureDataUrl)
: null;
const now = new Date();
const newStatus = contract.status === 'signed_by_customer' ? 'fully_signed' : 'signed_by_admin';
const persistedAdminIp = await maybeStoreIp(ip);
try {
await db('contracts').where({ id: contract.id }).update({
status: newStatus,
signed_by_admin_at: now,
signed_admin_name: String(name).trim(),
signed_admin_ip: persistedAdminIp,
signed_admin_signature_path: signaturePath,
updated_at: now,
});
} catch (updateErr) {
// C.7 — clean up the orphan signature PNG if the contract row
// update threw. Best-effort; log on cleanup failure and re-throw
// the original update error.
if (signaturePath) {
try {
if (fs.existsSync(signaturePath)) fs.unlinkSync(signaturePath);
} catch (cleanupErr) {
logger.warn('Orphan admin signature PNG cleanup failed', {
path: signaturePath, message: cleanupErr.message,
});
}
}
throw updateErr;
}
// Stamp the admin's signature ON TOP of whatever signed_pdf_path
// currently holds (the customer-stamped PDF, in the normal flow)
// — or directly onto the unsigned pdf_path if the admin is the
// first to sign (edge case). Byte-immutable: each prior PDF stays
// on disk; the new file is a fresh timestamped version.
const refreshed = await getContractById(contract.id);
let signedPath = null;
let signedSha256 = null;
try {
const baseFile = (refreshed.contract.signed_pdf_path && fs.existsSync(refreshed.contract.signed_pdf_path))
? refreshed.contract.signed_pdf_path
: refreshed.contract.pdf_path;
if (!baseFile || !fs.existsSync(baseFile)) {
throw new Error(`Contract base PDF missing on disk for stamping (signed_pdf_path=${refreshed.contract.signed_pdf_path}, pdf_path=${refreshed.contract.pdf_path})`);
}
const baseBuffer = fs.readFileSync(baseFile);
const stampedBuffer = await pdfStampService.stampSignature({
pdfBuffer: baseBuffer,
signaturePngPath: signaturePath,
role: 'admin',
caption: {
name: String(name).trim(),
signedAt: now,
nameLabel: refreshed.contract.language === 'de' ? 'Name' : 'Name',
dateLabel: refreshed.contract.language === 'de' ? 'Datum' : 'Date',
},
});
const suffix = newStatus === 'fully_signed' ? 'fully-signed' : 'signed-by-admin';
const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, suffix);
signedPath = persisted.filePath;
signedSha256 = persisted.sha256;
const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256');
const updates = {
signed_pdf_path: signedPath,
updated_at: new Date(),
};
if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256;
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
updates.signed_pdf_render_failed_at = null;
updates.signed_pdf_render_error = null;
}
await db('contracts').where({ id: contract.id }).update(updates);
} catch (err) {
logger.error('Failed to stamp contract PDF after admin signature', {
contractId: contract.id,
newStatus,
message: err.message,
stack: err.stack,
});
// Migration 136 — mirror the customer-sign branch: persist a
// recovery marker so the admin detail page can surface a banner.
try {
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
await db('contracts').where({ id: contract.id }).update({
signed_pdf_render_failed_at: new Date(),
signed_pdf_render_error: String(err.message || 'Unknown error').slice(0, 2048),
updated_at: new Date(),
});
}
} catch (markErr) {
logger.error('Failed to record signed_pdf_render_failed marker (admin sign)', {
contractId: contract.id, message: markErr.message,
});
}
}
// When the admin's signature is what FINALISED the contract (i.e.
// status flipped to fully_signed), email a copy of the freshly
// re-rendered PDF to both parties. We send two separate queueEmail
// calls so each recipient gets the email rendered with their own
// greeting + name. The admin BCC is delivered as "to the issuer"
// so it lands in the same inbox the contract_sent email originated
// from.
if (newStatus === 'fully_signed') {
try {
// Pick the best available PDF as the attachment, in priority
// order: this counter-sign's freshly-rendered signed copy →
// the customer-only signed copy we wrote earlier → the
// original unsigned PDF. Falling all the way through to no
// attachment is acceptable; the email still goes out with the
// contract number so the customer knows it's binding.
const refetched = await db('contracts').where({ id: contract.id }).first();
const attachmentPath = signedPath
|| refetched?.signed_pdf_path
|| refetched?.pdf_path
|| null;
const customer = await db('customer_accounts').where({ id: contract.customer_account_id }).first();
const profile = (await businessProfileService.getProfile()).profile || {};
const adminRow = await db('admin_users').where({ id: adminId }).first();
const customerName = customer?.display_name
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|| customer?.email?.split('@')[0]
|| '';
// Generate the audit certificate as a SIBLING document (separate
// PDF) and attach it alongside the stamped contract. Audit cert
// captures timestamps, IPs, names, and SHA-256 hashes — the legal
// provenance record. Reproducible from contract data so safe to
// regenerate on demand; we still persist a copy to disk for the
// forensic trail.
const auditCertPath = await persistAuditCertificate(refetched || refreshed.contract);
const attachments = [];
if (attachmentPath) {
attachments.push({
filename: `${refreshed.contract.contract_number}-signed.pdf`,
contentPath: attachmentPath,
contentType: 'application/pdf',
});
}
if (auditCertPath) {
attachments.push({
filename: `${refreshed.contract.contract_number}-audit.pdf`,
contentPath: auditCertPath,
contentType: 'application/pdf',
});
}
const attachmentsArg = attachments.length > 0 ? attachments : undefined;
// 1. Customer copy
if (customer?.email) {
await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', {
contract_number: refreshed.contract.contract_number,
customer_name: customerName,
title: refreshed.contract.title || '',
attachments: attachmentsArg,
});
}
// 2. Admin copy. Prefer business_profile.email (the inbox the
// contract was sent FROM); fall back to the counter-signing
// admin's account email so the audit trail still reaches a
// human even on installs where business_profile.email is blank.
const adminEmail = profile.email || adminRow?.email;
if (adminEmail && adminEmail !== customer?.email) {
await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', {
contract_number: refreshed.contract.contract_number,
customer_name: profile.company_name || adminRow?.first_name || 'Team',
title: refreshed.contract.title || '',
attachments: attachmentsArg,
});
}
} catch (err) {
logger.error('Failed to send contract_fully_signed emails', {
contractId: contract.id,
message: err.message,
stack: err.stack,
});
}
}
try {
await logActivity(`contract_${newStatus}`, { contractId: contract.id }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
// The binding moment — fire contract.signed once the contract is fully signed
// (matches the editor's trigger). Best-effort / fail-closed.
if (newStatus === 'fully_signed') await emitContractEvent(contract, 'signed');
return { status: newStatus, signedAt: now };
}
/**
* Attach a wet-signed PDF as the authoritative signed copy. Either
* party can upload (admin via admin route, customer via public token
* route). When the customer uploads, status flips to `fully_signed`
* because the wet signature is treated as a full agreement (admin
* would normally also sign the wet copy before sending it to the
* customer).
*/
async function attachSignedPdfUpload(contractId, filePath, uploaderRole) {
// Self-heal contract email templates — same reason as the
// sendContract + recordAdminCountersignature paths.
await ensureContractEmailTemplatesSeeded(db, logger);
if (!filePath) throw new AppError('No file uploaded', 400);
const contract = await db('contracts').where({ id: contractId }).first();
if (!contract) throw new AppError('Contract not found', 404);
if (['cancelled', 'draft'].includes(contract.status)) {
throw new AppError(`Cannot attach a signed PDF to a contract in status '${contract.status}'`, 409);
}
const now = new Date();
const updates = {
signed_pdf_path: filePath,
status: 'fully_signed',
updated_at: now,
};
// Migration 135 — durable wet-upload discriminator. Persists the
// "this row holds an authoritative wet upload, do not auto-overwrite"
// signal as a column rather than inferring from the file path. See
// the migration body for the full rationale.
if (await hasColumnCached('contracts', 'signed_pdf_is_wet_upload')) {
updates.signed_pdf_is_wet_upload = true;
}
// Hash the uploaded PDF on disk so we can later prove it wasn't
// tampered with after upload. Multer wrote the file synchronously
// before this handler runs, so reading it here is safe.
if (await hasColumnCached('contracts', 'signed_pdf_sha256')) {
updates.signed_pdf_sha256 = sha256OfFile(filePath);
}
if (uploaderRole === 'customer' && !contract.signed_by_customer_at) {
updates.signed_by_customer_at = now;
}
if (uploaderRole === 'admin' && !contract.signed_by_admin_at) {
updates.signed_by_admin_at = now;
}
await db('contracts').where({ id: contractId }).update(updates);
// attachSignedPdfUpload always transitions to fully_signed (see
// updates.status above), so the dual-party send fires here too —
// same pattern as recordAdminCountersignature. The uploaded PDF
// IS the authoritative copy so we attach it directly.
try {
const refreshedContract = await db('contracts').where({ id: contractId }).first();
const customer = await db('customer_accounts').where({ id: refreshedContract.customer_account_id }).first();
const profile = (await businessProfileService.getProfile()).profile || {};
const customerName = customer?.display_name
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|| customer?.email?.split('@')[0]
|| '';
const attachments = [{
filename: `${refreshedContract.contract_number}-signed.pdf`,
contentPath: filePath,
contentType: 'application/pdf',
}];
// Sibling audit certificate — same legal-provenance record as the
// in-browser sign path. Best-effort; missing cert doesn't block the
// wet-signed PDF from reaching the parties.
const auditCertPath = await persistAuditCertificate(refreshedContract);
if (auditCertPath) {
attachments.push({
filename: `${refreshedContract.contract_number}-audit.pdf`,
contentPath: auditCertPath,
contentType: 'application/pdf',
});
}
if (customer?.email) {
await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', {
contract_number: refreshedContract.contract_number,
customer_name: customerName,
title: refreshedContract.title || '',
attachments,
});
}
if (profile.email && profile.email !== customer?.email) {
await emailProcessor.queueEmail(null, profile.email, 'contract_fully_signed', {
contract_number: refreshedContract.contract_number,
customer_name: profile.company_name || 'Team',
title: refreshedContract.title || '',
attachments,
});
}
} catch (err) {
logger.warn('Failed to send contract_fully_signed emails after PDF upload', {
contractId, error: err.message,
});
}
try {
await logActivity('contract_signed_pdf_uploaded', { contractId, uploaderRole }, null,
uploaderRole === 'admin' ? { type: 'admin', name: 'Admin (PDF upload)' } : customerPublicActor());
} catch (_) { /* logging is best-effort */ }
await emitContractEvent(contract, 'signed');
return { status: 'fully_signed', signedPdfPath: filePath };
}
/**
* Recovery helper: re-render the signed PDF + resend the
* contract_fully_signed email to both parties. Used by the admin
* detail page when:
* - a previous render silently failed (signed_pdf_path is empty
* on a fully_signed contract)
* - the customer reports they didn't receive the email
* - the bodies of the seeded blocks were updated post-signing and
* the admin wants the latest text on file
*
* Only available on fully_signed contracts. The wet-signed PDF path
* is preserved: when signed_pdf_path already points at an uploaded
* file (not a re-render path) we DO NOT overwrite the uploaded PDF
* is the authoritative copy. We still resend the email with that
* uploaded PDF as the attachment.
*/
async function rerenderAndResend(contractId, adminId) {
// Self-heal contract email templates. This is the most likely
// recovery path the admin reaches when a prior dual-party send
// failed silently — including when the failure was caused by the
// template being missing in the first place.
const newlySeeded = await ensureContractEmailTemplatesSeeded(db, logger);
if (newlySeeded.length > 0) {
logger.warn('rerenderAndResend self-healed missing email templates', {
contractId, seeded: newlySeeded,
});
}
const contract = await db('contracts').where({ id: contractId }).first();
if (!contract) throw new AppError('Contract not found', 404);
if (contract.status !== 'fully_signed') {
throw new AppError(
`Re-send is only available on fully-signed contracts (status: ${contract.status})`,
409, 'NOT_FULLY_SIGNED',
);
}
let attachmentPath = contract.signed_pdf_path || null;
// Migration 135 — `signed_pdf_is_wet_upload` is the durable
// authoritative-source discriminator. It's set TRUE only by
// attachSignedPdfUpload, so any non-wet path here is a system
// stamp safe to replace. We still null-check the path so missing
// (re-stamp recovery) cases trigger the re-stamp branch below.
const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload');
const isWetSignedUpload = hasWetFlagColumn
? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1)
// Fallback ONLY for installs where the migration hasn't applied yet:
// preserve the historical substring rule so we don't accidentally
// overwrite uploads on an un-migrated DB.
: !!(attachmentPath && attachmentPath.includes('uploads/contracts/signed'));
if (!attachmentPath || !isWetSignedUpload) {
// Stamp signatures onto the immutable unsigned pdf_path using
// pdf-lib (NOT a full re-render). This preserves the exact bytes
// the customer originally agreed to and side-steps the silent re-
// render failure that left signed_pdf_path NULL on prior contracts.
const refreshed = await getContractById(contract.id);
if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) {
throw new AppError(
`Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`,
500, 'UNSIGNED_PDF_MISSING',
);
}
const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path);
const stamps = buildSignatureStamps(refreshed.contract);
const { buffer: stampedBuffer, sha256: signedSha256 } =
await pdfStampService.stampSignatures(originalBuffer, stamps);
const persisted = await persistContractPdf(refreshed.contract, stampedBuffer, 'fully-signed');
attachmentPath = persisted.filePath;
const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256');
const updates = {
signed_pdf_path: attachmentPath,
updated_at: new Date(),
};
if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256;
// Migration 136 — this branch is a recovery path; clear any
// existing failed-render marker.
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
updates.signed_pdf_render_failed_at = null;
updates.signed_pdf_render_error = null;
}
await db('contracts').where({ id: contract.id }).update(updates);
}
// Resend the dual-party email with the now-guaranteed attachment.
const refetched = await db('contracts').where({ id: contract.id }).first();
const customer = await db('customer_accounts').where({ id: refetched.customer_account_id }).first();
const profile = (await businessProfileService.getProfile()).profile || {};
const adminRow = await db('admin_users').where({ id: adminId }).first();
const customerName = customer?.display_name
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|| customer?.email?.split('@')[0]
|| '';
// Sibling audit certificate (timestamps + IPs + hashes). Best-effort:
// missing certificate doesn't block the email — the stamped contract
// alone is the primary attachment.
const auditCertPath = await persistAuditCertificate(refetched);
const attachments = [{
filename: `${refetched.contract_number}-signed.pdf`,
contentPath: attachmentPath,
contentType: 'application/pdf',
}];
if (auditCertPath) {
attachments.push({
filename: `${refetched.contract_number}-audit.pdf`,
contentPath: auditCertPath,
contentType: 'application/pdf',
});
}
if (customer?.email) {
await emailProcessor.queueEmail(null, customer.email, 'contract_fully_signed', {
contract_number: refetched.contract_number,
customer_name: customerName,
title: refetched.title || '',
attachments,
});
}
const adminEmail = profile.email || adminRow?.email;
if (adminEmail && adminEmail !== customer?.email) {
await emailProcessor.queueEmail(null, adminEmail, 'contract_fully_signed', {
contract_number: refetched.contract_number,
customer_name: profile.company_name || adminRow?.first_name || 'Team',
title: refetched.title || '',
attachments,
});
}
try {
await logActivity('contract_resent_signed', { contractId }, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
return { signedPdfPath: attachmentPath, resent: true };
}
/**
* Recovery helper: admin re-stamps signatures (customer and/or admin)
* on a contract whose signature_path columns are null/broken because
* the original sign happened before the canvas worked correctly.
*
* The admin draws BOTH signatures on the detail page the customer's
* signature is admin-attested in this flow (the customer already
* agreed via the original sign; this just makes the PDF show
* something). Original signed_by_*_at + signed_*_name + signed_*_ip
* stay untouched; only the *_signature_path columns + the rendered
* PDF get refreshed.
*
* Available on contracts in status:
* signed_by_customer (re-stamp customer, optionally admin too)
* signed_by_admin (re-stamp admin, optionally customer too)
* fully_signed (re-stamp either or both)
*/
async function restampSignatures(contractId, { customerSignatureDataUrl, adminSignatureDataUrl }, adminId) {
const contract = await db('contracts').where({ id: contractId }).first();
if (!contract) throw new AppError('Contract not found', 404);
if (!['signed_by_customer', 'signed_by_admin', 'fully_signed'].includes(contract.status)) {
throw new AppError(
`Cannot re-stamp signatures on a contract in status '${contract.status}'.`,
409, 'WRONG_STATUS',
);
}
if (!customerSignatureDataUrl && !adminSignatureDataUrl) {
throw new AppError('At least one signature data URL must be provided.', 400, 'NO_SIGNATURE');
}
const updates = { updated_at: new Date() };
if (customerSignatureDataUrl) {
updates.signed_customer_signature_path = await persistSignatureImage(contract, 'customer', customerSignatureDataUrl);
}
if (adminSignatureDataUrl) {
updates.signed_admin_signature_path = await persistSignatureImage(contract, 'admin', adminSignatureDataUrl);
}
await db('contracts').where({ id: contract.id }).update(updates);
// Re-stamp signature images onto the immutable unsigned pdf_path
// using pdf-lib (NOT a full re-render). This is the recovery path
// for contracts where signature images existed on disk but the
// earlier re-render approach failed silently and left signed_pdf_path
// NULL or pointing at a stale file. We always rebuild the stamp from
// pdf_path (the as-sent bytes) so the result is reproducible from
// the audit record.
//
// Wet-signed PDF uploads remain authoritative — if signed_pdf_path
// already points at an uploaded PDF we still produce a stamped copy
// on disk for the audit trail, but signed_pdf_path is not updated.
const refreshed = await getContractById(contract.id);
if (!refreshed.contract.pdf_path || !fs.existsSync(refreshed.contract.pdf_path)) {
throw new AppError(
`Unsigned PDF missing on disk at ${refreshed.contract.pdf_path}; cannot re-stamp.`,
500, 'UNSIGNED_PDF_MISSING',
);
}
const originalBuffer = fs.readFileSync(refreshed.contract.pdf_path);
const stamps = buildSignatureStamps(refreshed.contract);
const { buffer: stampedBuffer, sha256: signedSha256 } =
await pdfStampService.stampSignatures(originalBuffer, stamps);
const { filePath: signedPath } = await persistContractPdf(refreshed.contract, stampedBuffer,
contract.status === 'fully_signed' ? 'fully-signed' : 'partially-signed');
// Migration 135 — read the discriminator column. Fall back to the
// historical substring rule only when the column is absent (un-
// migrated install) so we never accidentally overwrite a wet upload.
const hasWetFlagColumn = await hasColumnCached('contracts', 'signed_pdf_is_wet_upload');
const isWetSignedUpload = hasWetFlagColumn
? (contract.signed_pdf_is_wet_upload === true || contract.signed_pdf_is_wet_upload === 1)
: !!(contract.signed_pdf_path
&& contract.signed_pdf_path.includes('uploads/contracts/signed'));
if (!isWetSignedUpload) {
const hasSignedPdfSha = await hasColumnCached('contracts', 'signed_pdf_sha256');
const updates = {
signed_pdf_path: signedPath,
updated_at: new Date(),
};
if (hasSignedPdfSha) updates.signed_pdf_sha256 = signedSha256;
// Migration 136 — restamp is a recovery path; clear the marker.
if (await hasColumnCached('contracts', 'signed_pdf_render_failed_at')) {
updates.signed_pdf_render_failed_at = null;
updates.signed_pdf_render_error = null;
}
await db('contracts').where({ id: contract.id }).update(updates);
}
try {
await logActivity('contract_signatures_restamped', {
contractId,
stamped: {
customer: !!customerSignatureDataUrl,
admin: !!adminSignatureDataUrl,
},
}, null, await adminActor(adminId));
} catch (_) { /* logging is best-effort */ }
return {
signedPdfPath: isWetSignedUpload ? contract.signed_pdf_path : signedPath,
stamped: {
customer: !!customerSignatureDataUrl,
admin: !!adminSignatureDataUrl,
},
};
}
/**
* Read the chronological audit trail for a contract from activity_logs.
* Matches every `contract_*` activity_type where metadata.contractId
* equals this contract's id. Ordered oldest newest so the UI can
* render a vertical timeline. Read-only; used by the admin detail
* page's AuditTrailCard.
*/
async function getAuditTrail(contractId) {
if (!(await db.schema.hasTable('activity_logs'))) return [];
// Push the metadata.contractId filter into SQL instead of fetching
// every contract_* row and filtering in JS. The previous shape
// scanned the entire history every time the detail page loaded —
// O(rows-since-CRM-launch) per request. Both Postgres and SQLite
// store metadata as a JSON-encoded string here, so we match on
// a literal substring that covers either compact or whitespaced
// JSON encodings — `"contractId":<n>` or `"contractId": <n>` —
// bounded by the activity_type prefix so the search hits the
// contract_* slice of the index.
//
// The substring patterns intentionally don't anchor on word
// boundaries; activity_logs.metadata never contains a contractId
// key collision with another id-shaped value because logActivity
// serialises only what callers pass.
const id = Number(contractId);
if (!Number.isFinite(id)) return [];
const rows = await db('activity_logs')
.where('activity_type', 'like', 'contract_%')
.andWhere(function () {
this.where('metadata', 'like', `%"contractId":${id}%`)
.orWhere('metadata', 'like', `%"contractId": ${id}%`);
})
.orderBy('created_at', 'asc')
.select('id', 'activity_type', 'actor_type', 'actor_id', 'actor_name', 'metadata', 'created_at');
return rows.map((r) => {
let meta = r.metadata;
if (typeof meta === 'string') {
try { meta = JSON.parse(meta); } catch { meta = {}; }
}
return { ...r, metadata: meta || {} };
});
}
/**
* Re-hash the two on-disk PDFs and compare against the stored hashes
* (pdf_sha256 / signed_pdf_sha256 from migration 131). Lets the admin
* confirm that backups, manual moves, or storage corruption haven't
* silently altered the issued document.
*
* Each leg of the response carries:
* - `path`: the stored path string (so the UI can show what was
* checked even when it's missing)
* - `present`: file exists on disk
* - `expected`: the SHA-256 column value (null if never persisted)
* - `actual`: the freshly-computed hash, or null when file missing
* - `match`: true iff both hashes exist AND they're equal
*
* The customer already has both expected hashes via the audit
* certificate the signing flow ships as a second email attachment, so
* they can verify independently with `shasum -a 256`. This endpoint
* is the admin-side equivalent single click instead of dropping to
* a shell.
*/
async function verifyIntegrity(id) {
const contract = await db('contracts')
.where({ id })
.select('id', 'pdf_path', 'pdf_sha256', 'signed_pdf_path', 'signed_pdf_sha256')
.first();
if (!contract) throw new AppError('Contract not found', 404);
const checkLeg = (filePath, expected) => {
const present = !!filePath && fs.existsSync(filePath);
const actual = present ? sha256OfFile(filePath) : null;
return {
path: filePath || null,
present,
expected: expected || null,
actual,
match: !!(expected && actual && expected === actual),
};
};
return {
unsigned: checkLeg(contract.pdf_path, contract.pdf_sha256),
signed: checkLeg(contract.signed_pdf_path, contract.signed_pdf_sha256),
};
}
module.exports = {
recordCustomerSignature,
recordAdminCountersignature,
attachSignedPdfUpload,
rerenderAndResend,
restampSignatures,
getAuditTrail,
verifyIntegrity,
};
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
* Customer Accounts Service
*
* Recurring user logins (the third user tier alongside admin and guest).
* See discussion the-luap/picpeak#354 and migration 087 for context.
* See discussion PicPeak/picpeak#354 and migration 087 for context.
*
* Mirrors userManagementService.js for invitation lifecycle but operates
* on customer_accounts / customer_invitations / event_customer_assignments
@@ -1456,6 +1456,7 @@ module.exports = {
setAssignmentsForEvent,
setAssignmentsForCustomer,
getAssignmentsForEvent,
notifyCustomerOfNewAssignments,
listEventsForCustomer,
customerHasAccessToEvent,
getPendingInvitations,
@@ -1,147 +0,0 @@
/**
* Database Backup Service Usage Examples
*
* This service provides comprehensive database backup functionality
* with support for both SQLite and PostgreSQL databases.
*/
const { databaseBackupService } = require('./databaseBackup');
// Example 1: Manual backup with default settings
async function manualBackup() {
try {
const result = await databaseBackupService.backup();
console.log('Backup completed:', result);
// Result includes: path, size, duration, checksum, compressionRatio
} catch (error) {
console.error('Backup failed:', error);
}
}
// Example 2: Backup with custom options
async function customBackup() {
try {
const result = await databaseBackupService.backup({
destinationPath: '/custom/backup/path',
compress: true, // Enable gzip compression
validateIntegrity: true, // Validate backup after creation
includeChecksums: true, // Calculate table checksums
noTransaction: false // Use transaction for consistency (PostgreSQL)
});
console.log('Custom backup completed:', result);
} catch (error) {
console.error('Backup failed:', error);
}
}
// Example 3: Check backup progress (useful for long-running backups)
async function backupWithProgress() {
// Start backup asynchronously
const backupPromise = databaseBackupService.backup();
// Poll for progress
const progressInterval = setInterval(() => {
const progress = databaseBackupService.getProgress();
if (progress) {
console.log(`Progress: ${progress.message}`, progress.details);
}
}, 1000);
try {
const result = await backupPromise;
clearInterval(progressInterval);
console.log('Backup completed:', result);
} catch (error) {
clearInterval(progressInterval);
console.error('Backup failed:', error);
}
}
// Example 4: Get backup history
async function getBackupHistory() {
const history = await databaseBackupService.getBackupHistory(10);
history.forEach(backup => {
console.log(`Backup ${backup.id}:`);
console.log(` Started: ${backup.started_at}`);
console.log(` Status: ${backup.status}`);
console.log(` Size: ${(backup.file_size_bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(` Duration: ${backup.duration_seconds}s`);
});
}
// Example 5: Clean up old backups
async function cleanupBackups() {
// Delete backups older than 30 days
await databaseBackupService.cleanupOldBackups(30);
console.log('Old backups cleaned up');
}
// Example 6: Get table checksums (useful for monitoring changes)
async function getTableChecksums() {
const checksums = await databaseBackupService.getTableChecksums();
console.log('Table Checksums:');
Object.entries(checksums).forEach(([table, info]) => {
console.log(` ${table}: ${info.rowCount} rows, checksum: ${info.checksum}`);
});
}
// Example 7: Using the scheduled backup service
const { startScheduledBackups, stopScheduledBackups } = require('./databaseBackup');
async function setupScheduledBackups() {
// Start scheduled backups (reads schedule from database config)
await startScheduledBackups();
console.log('Scheduled backups started');
// Later, if needed, stop scheduled backups
// stopScheduledBackups();
}
// Example 8: Admin API endpoints available
/*
GET /api/admin/database-backup/status - Get backup status and config
PUT /api/admin/database-backup/config - Update backup configuration
POST /api/admin/database-backup/backup - Trigger manual backup
GET /api/admin/database-backup/progress - Get current backup progress
GET /api/admin/database-backup/history - Get backup history with pagination
DELETE /api/admin/database-backup/cleanup - Delete old backup files
POST /api/admin/database-backup/test - Test backup configuration
GET /api/admin/database-backup/checksums - Get current table checksums
*/
// Example 9: Configuration options stored in database
/*
database_backup_enabled: boolean - Enable/disable scheduled backups
database_backup_schedule: string - Cron schedule (default: '0 3 * * *')
database_backup_destination_path: string - Where to store backups
database_backup_compress: boolean - Enable gzip compression
database_backup_validate_integrity: boolean - Validate after backup
database_backup_include_checksums: boolean - Calculate table checksums
database_backup_retention_days: number - Days to keep old backups
database_backup_email_on_failure: boolean - Send email on failure
database_backup_email_on_success: boolean - Send email on success
*/
// Example 10: Production considerations
/*
1. Ensure destination path has sufficient space
2. For large databases, backups may take significant time
3. PostgreSQL backups use single-transaction mode by default
4. Compression typically reduces size by 70-90%
5. Schedule backups during low-traffic periods
6. Monitor backup history for failures
7. Test restore procedures regularly
8. Consider replication for real-time redundancy
*/
module.exports = {
manualBackup,
customBackup,
backupWithProgress,
getBackupHistory,
cleanupBackups,
getTableChecksums,
setupScheduledBackups
};
+11 -2
View File
@@ -860,10 +860,19 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
for (const email of pendingEmails) {
try {
const emailData = typeof email.email_data === 'string'
const emailData = typeof email.email_data === 'string'
? JSON.parse(email.email_data || '{}')
: email.email_data || {};
// Language is resolved from emailData.eventId (event.language is the top
// priority). queueEmail injects it, but direct email_queue inserts (e.g.
// the gallery-publish notification) only set the event_id COLUMN — so
// backfill from the authoritative column so every send path resolves the
// recipient language from the event consistently.
if (emailData.eventId == null && email.event_id != null) {
emailData.eventId = email.event_id;
}
const sendResult = await sendTemplateEmail(
email.recipient_email,
email.email_type,

Some files were not shown because too many files have changed in this diff Show More