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
356 changed files with 34468 additions and 17274 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.66.0-beta.0"
".": "3.79.1-beta.0"
}
+287
View File
@@ -5,6 +5,293 @@ 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)
### Features
* admin-configurable workflow engine + dunning/Mahngebühr rework (RFC — feedback welcome) ([15be3b8](https://github.com/the-luap/picpeak/commit/15be3b8d32965eedc08d46ccc525c65a3bf34de6))
* **workflows:** per-quote booking-workflow picker + quote→invoice (no gallery) built-in ([d14f1d8](https://github.com/the-luap/picpeak/commit/d14f1d850cc995b2cb1119ba0424f123feba50ec))
* **workflows:** pre-event reminder picks the template GROUP on the block, type stays automatic ([10d091b](https://github.com/the-luap/picpeak/commit/10d091b55e0c44738b4001a71def6416a8f0aeb0))
* **workflows:** route webhook node through the delivery pipeline (full Option 1) ([675e41a](https://github.com/the-luap/picpeak/commit/675e41a2f72c8c23fa5c36b13bc6b95abcb9d570))
* **workflows:** warn when disabling a built-in (reverts to legacy, not off) ([c5f131c](https://github.com/the-luap/picpeak/commit/c5f131cec32826331722ef3705c5f5422e31726d))
### Bug Fixes
* **crm:** pre-event reminder resolves recipient from the event row, not a non-existent column ([5fbe514](https://github.com/the-luap/picpeak/commit/5fbe514db6e386eee2eeade548bccbb5bbc5b422))
* **event-types:** renaming a type's slug cascades to events, quotes + reminder template ([415c93a](https://github.com/the-luap/picpeak/commit/415c93a512f74898d0225ce2e9298f24cc12f60d))
* **workflows:** close review blockers — prefetch-safe approvals + loud gate-edge failure ([98ab717](https://github.com/the-luap/picpeak/commit/98ab717043e3fdefac0934bf8f4621d523b15e9a))
* **workflows:** harden graph validation + refuse enabling unimplemented flows ([d927464](https://github.com/the-luap/picpeak/commit/d927464778272bd862aa01903179672f4d47368a))
* **workflows:** matchFilter strict equality + accurate comment ([dee8d40](https://github.com/the-luap/picpeak/commit/dee8d40bb3235a908bba514a97a62d3a91a6e131))
* **workflows:** ship built-ins disabled for first beta + enabled-based mutex + admin sentinel ([5893ecb](https://github.com/the-luap/picpeak/commit/5893ecb27a0365a79ec04336c5a122b31d31db0e))
* **workflows:** wire a real, SSRF-guarded webhook action (was a silent no-op) ([af7eea8](https://github.com/the-luap/picpeak/commit/af7eea8b43e37905a79138bcde4b1026dea13050))
## [3.70.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.69.1-beta.0...v3.70.0-beta.0) (2026-06-23)
### Features
* **analytics:** pluggable trackers — Umami + Rybbit + Custom ([#663](https://github.com/the-luap/picpeak/issues/663) Phase 1) ([83461fe](https://github.com/the-luap/picpeak/commit/83461fe5d4d44006482167464d92e70546cf7377))
## [3.69.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.69.0-beta.0...v3.69.1-beta.0) (2026-06-23)
### Bug Fixes
* **analytics:** admin dashboard reads correct fields + Umami device API ([#661](https://github.com/the-luap/picpeak/issues/661)) ([349f566](https://github.com/the-luap/picpeak/commit/349f566e87b33c59f61eb28b8abc5f889e6285d6))
* **analytics:** admin dashboard reads correct fields + Umami device API ([#661](https://github.com/the-luap/picpeak/issues/661)) ([7534447](https://github.com/the-luap/picpeak/commit/7534447b6c0df4290fd8dac12270673097096f1b))
## [3.69.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.68.1-beta.0...v3.69.0-beta.0) (2026-06-22)
### Features
* **feedback:** per-guest favorite + like caps with mobile-friendly limit modal ([#655](https://github.com/the-luap/picpeak/issues/655)) ([3ac7017](https://github.com/the-luap/picpeak/commit/3ac70177efc237b8169278208983b0de3629bc72))
* **feedback:** per-guest favorite + like caps with mobile-friendly limit modal ([#655](https://github.com/the-luap/picpeak/issues/655)) ([f2814e4](https://github.com/the-luap/picpeak/commit/f2814e4a4ce3aa9affc232243d615a15a1aae0c0))
### Bug Fixes
* **i18n:** replace ASCII quote with U+201D in DE perGuestLimitsDesc ([98e97e3](https://github.com/the-luap/picpeak/commit/98e97e3cf214c96cdefd99bfedd6724f0b85c41c))
## [3.68.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.68.0-beta.0...v3.68.1-beta.0) (2026-06-22)
### Bug Fixes
* **gallery:** unbreak password entry in Instagram in-app browser ([#654](https://github.com/the-luap/picpeak/issues/654)) ([6193ab7](https://github.com/the-luap/picpeak/commit/6193ab7f6aafd94b6e2e432ddf170361fd306d4e))
* **gallery:** unbreak password entry in Instagram in-app browser ([#654](https://github.com/the-luap/picpeak/issues/654)) ([b1bfd48](https://github.com/the-luap/picpeak/commit/b1bfd4838e7104e4f85695e180b20222206073ac))
* **test:** raise bootCrmDb beforeAll timeout on slideshow suites ([f4b6b89](https://github.com/the-luap/picpeak/commit/f4b6b8941a30a20615cc87627a0663ff6d03c932))
## [3.68.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.67.1-beta.0...v3.68.0-beta.0) (2026-06-21)
### Features
* **whatsapp:** admin-selectable template parameters + reorder ([#647](https://github.com/the-luap/picpeak/issues/647) follow-up) ([80e8ec5](https://github.com/the-luap/picpeak/commit/80e8ec5bc71f0653d56f1087521f5207aee0ba8f))
## [3.67.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.67.0-beta.0...v3.67.1-beta.0) (2026-06-21)
### Bug Fixes
* **branding+whatsapp:** preserve customCss through preset switches ([#645](https://github.com/the-luap/picpeak/issues/645)) + admin-pinned WhatsApp template language ([#647](https://github.com/the-luap/picpeak/issues/647)) ([cde028e](https://github.com/the-luap/picpeak/commit/cde028e9199a9ddb09957a87590732f4bd4d7a7b))
## [3.67.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.66.1-beta.0...v3.67.0-beta.0) (2026-06-21)
### Features
* Live Slideshow ("Diashow") — fullscreen, auto-updating projector view for live events ([4356393](https://github.com/the-luap/picpeak/commit/4356393b4433dd6b4147388688766b9464294c89))
* **slideshow:** add image fit setting (fill vs black bars) ([b5c73e0](https://github.com/the-luap/picpeak/commit/b5c73e05bd41b262f864e8c700b1d38582b3817f))
* **slideshow:** admin ui for live slideshow ([385b05a](https://github.com/the-luap/picpeak/commit/385b05adcf6a4acb7939e372328a55df7dae5e08))
* **slideshow:** backend api for live slideshow ([dea5e0f](https://github.com/the-luap/picpeak/commit/dea5e0f8a6421c056868c2d9bea11e5bf1ee106a))
* **slideshow:** db columns for live slideshow ([1029dd0](https://github.com/the-luap/picpeak/commit/1029dd05bdb9ca0a97ad86100145221850648651))
* **slideshow:** en/de strings for live slideshow ([cb761ee](https://github.com/the-luap/picpeak/commit/cb761ee621aa553cf210c4224b6cbbf7bf2ef0cb))
* **slideshow:** gate behind a feature flag + move globals to a Settings tab ([69367b4](https://github.com/the-luap/picpeak/commit/69367b45be1c13d87e73e72da34a1f41a5849dfe))
* **slideshow:** public fullscreen slideshow viewer ([fd02254](https://github.com/the-luap/picpeak/commit/fd02254f78bd1860780355ebaa68293d58ce18b3))
### Bug Fixes
* **slideshow:** deny display-only token on download/upload/feedback (PR [#646](https://github.com/the-luap/picpeak/issues/646) review) ([e36b330](https://github.com/the-luap/picpeak/commit/e36b3309ca66404d189d5b218cc1f0eba925e4c7))
* **slideshow:** dip-to-white/black no longer flickers the image ([db8388c](https://github.com/the-luap/picpeak/commit/db8388c79e44f5d254d984810bd62bfb11effd0f))
* **slideshow:** drop updated_at from event writes ([1e40f82](https://github.com/the-luap/picpeak/commit/1e40f8296ca59ff0395f6cc09ee452ab62653cdc))
* **slideshow:** feature flag is a master kill-switch, not just admin UI ([759784a](https://github.com/the-luap/picpeak/commit/759784a4d1cfe7e67c825293760169ad6904f090))
* **slideshow:** fill the viewport instead of black bars ([6ec46de](https://github.com/the-luap/picpeak/commit/6ec46de0e7bb821ea4e4a7fc2318792b810c3f36))
* **slideshow:** read globals from app_settings, not the missing settings table ([0f4388d](https://github.com/the-luap/picpeak/commit/0f4388d68ab85049c46e7af566d35f4fbf6e4d02))
* **slideshow:** surface backend error in the live slideshow card ([056f938](https://github.com/the-luap/picpeak/commit/056f9381de5dbe90243bea409b587b4910050cbf))
### Performance Improvements
* **slideshow:** cache global settings to cut /state DB reads (PR [#646](https://github.com/the-luap/picpeak/issues/646) review) ([a995131](https://github.com/the-luap/picpeak/commit/a995131f4266e112c96c6e8cedd5158995ebe899))
### Documentation
* **slideshow:** add Live Slideshow guide + README entries ([16013d1](https://github.com/the-luap/picpeak/commit/16013d1cf9ad82ee052f905f9702feffde7b67eb))
## [3.66.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.66.0-beta.0...v3.66.1-beta.0) (2026-06-19)
### Bug Fixes
* **deps:** bump qs/brace-expansion overrides + add uuid override for node-cron ([d705059](https://github.com/the-luap/picpeak/commit/d705059d3c2904184f037bbe0208fe128fdb9b63))
* **security:** close BOLA on photo-export + NAT64 SSRF in URL guard ([b8211e9](https://github.com/the-luap/picpeak/commit/b8211e9944da9e7b1c43a25e2f24c8a2425000cf))
* **security:** close NAT64 SSRF + photo-export BOLA + sweep Trivy alerts (GHSA-wmjx-pc37-272r, GHSA-9v4w-jrhx-g5wr) ([6f40db8](https://github.com/the-luap/picpeak/commit/6f40db859751efc2c931bc981a48148808fd3701))
## [3.66.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.65.1-beta.0...v3.66.0-beta.0) (2026-06-19)
+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! 🎉
+49 -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" />
@@ -49,6 +57,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
- 🔐 **Password Protection** - Secure client galleries
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 📽️ **Live Slideshow** - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options ([guide](docs/live-slideshow.md))
- 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
@@ -82,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).
@@ -154,6 +176,7 @@ Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** —
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
- 📽️ [**Live Slideshow**](https://docs.picpeak.app/features/live-slideshow) - Fullscreen projector view that auto-updates during live events
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Backup configuration, restore wizard, full disaster recovery
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
@@ -185,6 +208,7 @@ Perfect for:
- 📸 **Portrait Studios** - Client galleries with download limits
- 🏢 **Corporate Events** - Internal photo sharing with branding
- 🎓 **School Photography** - Secure parent access with expiration
- 📽️ **Live Events** - Put a [Live Slideshow](docs/live-slideshow.md) on the venue projector that updates as you shoot
## 🏗️ Tech Stack
@@ -367,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
@@ -389,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
@@ -473,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.
@@ -534,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,64 @@
/**
* Renaming an event type's slug_prefix must CASCADE to everything keyed on the
* old slug, so a rename behaves like a rename rather than silently detaching
* existing events/quotes and orphaning the per-type pre-event reminder template.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll.
jest.setTimeout(30000);
describe('event type slug rename cascade', () => {
let db;
let cleanup;
let customerId;
let eventTypeService;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ customerId } = await seedMinimal(db));
eventTypeService = require('../../src/services/eventTypeService');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('re-points events + quotes + the reminder template from old slug to new', async () => {
// A non-system event type with slug 'party'.
const [typeId] = await db('event_types').insert({ name: 'Party', slug_prefix: 'party', is_active: true });
// An authored per-type reminder template + an event + a quote, all on 'party'.
await db('email_templates').insert({ template_key: 'event_reminder_party', subject_en: 'Party reminder' });
await db('events').insert({
event_type: 'party', password_hash: 'x', expires_at: new Date(Date.now() + 9e9).toISOString(),
is_active: true, is_archived: false, slug: 'party-ev', share_link: 'party-ev',
event_name: 'A party', event_date: '2026-09-01',
});
await db('quotes').insert({
quote_number: 'Q-PARTY-1', customer_account_id: customerId, issue_date: '2026-01-01', event_type: 'party',
});
// Rename the slug.
await eventTypeService.updateEventType(typeId, { slug_prefix: 'concert' });
// Event + quote follow the rename.
expect((await db('events').where({ slug: 'party-ev' }).first()).event_type).toBe('concert');
expect((await db('quotes').where({ quote_number: 'Q-PARTY-1' }).first()).event_type).toBe('concert');
// The authored reminder template moved (subject/body preserved), old key gone.
expect(await db('email_templates').where({ template_key: 'event_reminder_party' }).first()).toBeUndefined();
const moved = await db('email_templates').where({ template_key: 'event_reminder_concert' }).first();
expect(moved).toBeTruthy();
expect(moved.subject_en).toBe('Party reminder');
});
it('does not clobber an existing template for the new slug', async () => {
const [typeId] = await db('event_types').insert({ name: 'Gala', slug_prefix: 'gala', is_active: true });
await db('email_templates').insert({ template_key: 'event_reminder_gala', subject_en: 'old gala' });
await db('email_templates').insert({ template_key: 'event_reminder_soiree', subject_en: 'existing soiree' });
await eventTypeService.updateEventType(typeId, { slug_prefix: 'soiree' });
// Target already existed → left intact; source not force-merged over it.
expect((await db('email_templates').where({ template_key: 'event_reminder_soiree' }).first()).subject_en)
.toBe('existing soiree');
});
});
@@ -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,115 @@
/**
* Dunning / Mahngebühr logic the tax-sensitive bits added in the dunning
* rework. Covers the fee math (flat / percent), the VAT toggle gating
* (incl. the "no-op when the org has no VAT rate" requirement), per-reminder
* accumulation (2nd = 1×, 3rd = 2×), invoice immutability (the fee never
* changes the issued invoice total), and the 3-reminder cap.
*
* The Mahnung PDF render is stubbed PDF rendering (fonts) is flaky in CI and
* is verified manually; here we assert the data/immutability behaviour.
*/
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let invoiceService;
let ids;
async function setSetting(key, value) {
const { upsertAppSetting } = require('../../src/utils/appSettings');
await upsertAppSetting(key, JSON.stringify(value), 'crm');
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
ids = await seedMinimal(db);
try { await db('customer_accounts').where({ id: ids.customerId }).update({ feature_bills: true }); } catch (_) {}
invoiceService = require('../../src/services/invoiceService');
// Stub the (flaky) PDF render so applyReminder exercises its data path.
// eslint-disable-next-line global-require
const pdfService = require('../../src/services/pdfService');
pdfService.renderInvoiceToBuffer = async () => Buffer.from('%PDF-stub');
});
afterAll(async () => { await cleanup(); });
describe('dunning fee resolvers', () => {
test('flat fee, no VAT', async () => {
await setSetting('crm_invoices_late_fee_enabled', true);
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', false);
const inv = { total_amount_minor: 100000 };
expect(await invoiceService.resolveLateFeeNetMinor(inv)).toBe(2000);
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
expect(await invoiceService.resolvePerReminderFeeMinor(inv)).toBe(2000);
});
test('percent fee = % of the invoice gross', async () => {
await setSetting('crm_invoices_late_fee_type', 'percent');
await setSetting('crm_invoices_late_fee_percent', 5);
expect(await invoiceService.resolveLateFeeNetMinor({ total_amount_minor: 100000 })).toBe(5000);
});
test('VAT toggle applies the org rate, but is a NO-OP when the org has no VAT rate', async () => {
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', true);
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 8.1 });
expect(await invoiceService.resolveLateFeeVatRate()).toBeCloseTo(8.1);
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 }))
.toBe(2000 + Math.round(2000 * 8.1 / 100)); // net + VAT
// Org doesn't charge VAT → toggle adds nothing (Mara's requirement).
await db('business_profile').where({ id: 1 }).update({ vat_rate_default: 0 });
expect(await invoiceService.resolveLateFeeVatRate()).toBe(0);
expect(await invoiceService.resolvePerReminderFeeMinor({ total_amount_minor: 0 })).toBe(2000);
});
});
describe('applyReminder — dunning-document model', () => {
let invoiceId;
let originalTotal;
beforeAll(async () => {
await setSetting('crm_invoices_late_fee_enabled', true);
await setSetting('crm_invoices_late_fee_type', 'flat');
await setSetting('crm_invoices_late_fee_minor', 2000);
await setSetting('crm_invoices_late_fee_vat_enabled', false);
const res = await invoiceService.createInvoice({
customerAccountId: ids.customerId,
currency: 'CHF',
vatRate: 0,
lineItems: [{ description: 'Service', quantity: 1, unit_price_minor: 100000 }],
}, ids.adminId);
invoiceId = res.invoiceIds[0];
originalTotal = Number((await db('invoices').where({ id: invoiceId }).first()).total_amount_minor);
});
test('level 2 tracks one fee and leaves the invoice total immutable', async () => {
const data = await invoiceService.getInvoiceById(invoiceId);
await invoiceService.applyReminder(data.invoice, data.lineItems, 2, ids.adminId);
const inv = await db('invoices').where({ id: invoiceId }).first();
expect(inv.reminder_level).toBe(2);
expect(Number(inv.late_fee_amount_minor)).toBe(2000);
expect(Number(inv.total_amount_minor)).toBe(originalTotal); // never mutated
});
test('level 3 accumulates the fee to 2×, total still immutable', async () => {
const data = await invoiceService.getInvoiceById(invoiceId);
await invoiceService.applyReminder(data.invoice, data.lineItems, 3, ids.adminId);
const inv = await db('invoices').where({ id: invoiceId }).first();
expect(Number(inv.late_fee_amount_minor)).toBe(4000);
expect(Number(inv.total_amount_minor)).toBe(originalTotal);
});
test('sendReminder refuses to exceed level 3', async () => {
await expect(invoiceService.sendReminder(invoiceId, 4, ids.adminId)).rejects.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);
});
});
@@ -0,0 +1,661 @@
/**
* Workflow engine graph execution integration tests.
*
* Exercises the engine against a real (temp SQLite) DB with migration 142
* applied: branching, bounded loops, wait pauses + scheduler-style resume,
* gate pauses + confirm/deny resume, dedup idempotency, and step recording.
*/
const { bootCrmDb } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let engine;
async function makeWorkflow({ nodes, edges, trigger = 'test.event', enabled = true }) {
const ins = await db('workflows').insert({ name: 'wf', trigger_type: trigger, version: 1, enabled });
const workflowId = ins[0];
for (const n of nodes) {
await db('workflow_nodes').insert({
workflow_id: workflowId, version: 1, node_key: n.key, type: n.type,
config: JSON.stringify(n.config || {}),
});
}
for (const e of edges) {
await db('workflow_edges').insert({
workflow_id: workflowId, version: 1, from_node: e.from, from_handle: e.handle || null, to_node: e.to,
loop_back: e.loopBack || false,
});
}
return workflowId;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
// Engine requires the singleton db — require AFTER bootCrmDb wired the test path.
engine = require('../../src/services/workflows');
// Enable the workflows flag so emitWorkflowEvent doesn't fail closed.
await db('feature_flags').insert({ key: 'workflows', value: true });
});
afterAll(async () => { await cleanup(); });
describe('workflow engine', () => {
test('condition + bounded loop + wait pauses, resumes to completion', async () => {
// trigger → set paid=false → condition(paid?) --no--> loop(max2)
// loop --loop--> reminder(noop) → wait → (back to condition)
// loop --exit--> lateFee(noop) → end
// condition --yes--> lateFee (paid path, not taken here)
const wfId = await makeWorkflow({
nodes: [
{ key: 'n1', type: 'trigger' },
{ key: 'n2', type: 'action', config: { action: 'set_context', set: { paid: false } } },
{ key: 'n3', type: 'condition', config: { condition: 'expr', field: 'paid', op: 'truthy' } },
{ key: 'n4', type: 'loop', config: { maxIterations: 2 } },
{ key: 'n5', type: 'action', config: { action: 'noop' } },
{ key: 'n6', type: 'wait', config: { delayMinutes: 0 } },
{ key: 'n7', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'n1', to: 'n2' },
{ from: 'n2', to: 'n3' },
{ from: 'n3', handle: 'no', to: 'n4' },
{ from: 'n3', handle: 'yes', to: 'n7' },
{ from: 'n4', handle: 'loop', to: 'n5' },
{ from: 'n4', handle: 'exit', to: 'n7' },
{ from: 'n5', to: 'n6' },
{ from: 'n6', to: 'n3', loopBack: true },
],
});
const runIds = await engine.emitWorkflowEvent('test.event', { entityType: 'invoice', entityId: 1 });
expect(runIds.length).toBe(1);
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting'); // paused at first wait (loop iter 1)
expect(run.current_node).toBe('n6');
await engine.resumeRun(runId);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting'); // paused again (loop iter 2)
await engine.resumeRun(runId);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done'); // loop exhausted → exit → end
const ctx = JSON.parse(run.context);
expect(ctx.vars.__loop_n4).toBe(3); // counter incremented past the cap
void wfId;
const steps = await db('workflow_run_steps').where({ run_id: runId });
expect(steps.length).toBeGreaterThan(0);
});
test('emit is idempotent on dedup_key', async () => {
await makeWorkflow({
trigger: 'dedup.event',
nodes: [{ key: 'n1', type: 'trigger' }, { key: 'n2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'n1', to: 'n2' }],
});
const first = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
const second = await engine.emitWorkflowEvent('dedup.event', { entityType: 'x', entityId: 9 });
expect(first.length).toBe(1);
expect(second.length).toBe(0); // same entity → no duplicate run
});
test('gate pauses and resumes via the confirm edge', async () => {
const wfId = await makeWorkflow({
trigger: 'gate.event',
nodes: [
{ key: 'g1', type: 'trigger' },
{ key: 'g2', type: 'gate', config: { type: 'payment_confirm' } },
{ key: 'g3', type: 'action', config: { action: 'noop' } },
{ key: 'g4', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g1', to: 'g2' },
{ from: 'g2', handle: 'confirm', to: 'g3' },
{ from: 'g2', handle: 'deny', to: 'g4' },
],
});
// create + start a run directly
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'gate.event', status: 'pending',
context: JSON.stringify({ vars: {} }), dedup_key: 'gate-test',
});
const run0 = await db('workflow_runs').where({ dedup_key: 'gate-test' }).first();
await engine.startRun(run0.id);
let run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g2');
await engine.resumeRun(run0.id, { decisionHandle: 'confirm' });
run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('done');
});
test('runDueWaits resumes only elapsed wait nodes', async () => {
await makeWorkflow({
trigger: 'wait.event',
nodes: [
{ key: 'w1', type: 'trigger' },
{ key: 'w2', type: 'wait', config: { delayMinutes: 60 } },
{ key: 'w3', type: 'action', config: { action: 'noop' } },
],
edges: [{ from: 'w1', to: 'w2' }, { from: 'w2', to: 'w3' }],
});
const runIds = await engine.emitWorkflowEvent('wait.event', { entityType: 'e', entityId: 7 });
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(await engine.runDueWaits()).toBe(0); // wake_at ~60min out → not due
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
const resumed = await engine.runDueWaits();
expect(resumed).toBeGreaterThanOrEqual(1);
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
});
test('send_email queues a customer mail with business-hours routing', async () => {
await makeWorkflow({
trigger: 'mail.event',
nodes: [
{ key: 'm1', type: 'trigger' },
{ key: 'm2', type: 'action', config: { action: 'send_email', recipientClass: 'customer', emailType: 'workflow_test' } },
],
edges: [{ from: 'm1', to: 'm2' }],
});
const runIds = await engine.emitWorkflowEvent('mail.event', {
entityType: 'invoice', entityId: 3, payload: { customerEmail: 'cust@example.com' },
});
const run = await db('workflow_runs').where({ id: runIds[0] }).first();
expect(run.status).toBe('done');
const queued = await db('email_queue').where({ recipient_email: 'cust@example.com' }).first();
expect(queued).toBeTruthy();
const step = await db('workflow_run_steps').where({ run_id: runIds[0], node_key: 'm2' }).first();
expect(JSON.parse(step.result).respectBusinessHours).toBe(true);
});
test('invoice_paid condition reads the entity', async () => {
const registry = require('../../src/services/workflows/registry');
const cond = registry.getCondition('invoice_paid');
const makeCtx = (row) => ({ run: { entity_id: 1 }, db: () => ({ where: () => ({ first: async () => row }) }) });
expect(await cond(makeCtx({ paid_at: '2026-01-01', status: 'sent' }))).toBe(true);
expect(await cond(makeCtx({ paid_at: null, status: 'paid' }))).toBe(true);
expect(await cond(makeCtx({ paid_at: null, status: 'sent', paid_amount_minor: 0, total_amount_minor: 1000 }))).toBe(false);
});
test('gate creates a pending approval + admin email, token confirm resumes the run', async () => {
await makeWorkflow({
trigger: 'approval.event',
nodes: [
{ key: 'a1', type: 'trigger' },
{ key: 'a2', type: 'gate', config: { type: 'payment_confirm', prompt: 'No payment yet?' } },
{ key: 'a3', type: 'action', config: { action: 'noop' } }, // confirm path
{ key: 'a4', type: 'action', config: { action: 'noop' } }, // deny path
],
edges: [
{ from: 'a1', to: 'a2' },
{ from: 'a2', handle: 'confirm', to: 'a3' },
{ from: 'a2', handle: 'deny', to: 'a4' },
],
});
const runIds = await engine.emitWorkflowEvent('approval.event', {
entityType: 'invoice', entityId: 42, payload: { adminEmail: 'admin@example.com' },
});
const runId = runIds[0];
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('a2');
const approval = await db('workflow_approvals').where({ run_id: runId }).first();
expect(approval).toBeTruthy();
expect(approval.status).toBe('pending');
const adminMail = await db('email_queue').where({ recipient_email: 'admin@example.com' }).first();
expect(adminMail).toBeTruthy();
// Extract the raw token from the emailed confirm link and act on it.
const data = JSON.parse(adminMail.email_data);
const rawToken = data.confirm_url.split('/').slice(-2)[0];
const res = await engine.actByToken(rawToken, 'confirm');
expect(res.ok).toBe(true);
expect(res.status).toBe('confirmed');
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
// A second click is idempotent (already recorded).
const again = await engine.actByToken(rawToken, 'confirm');
expect(again.already).toBe(true);
});
test('seeds the invoice-dunning built-in as the delegation graph (v6, disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
expect(wf).toBeTruthy();
expect(!!wf.is_builtin).toBe(true);
expect(!!wf.enabled).toBe(false); // first beta: ships disabled; legacy ladder runs until enabled
expect(JSON.parse(wf.trigger_config).seedVersion).toBe(6);
const nodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: wf.version });
expect(nodes.filter((n) => n.type === 'trigger')).toHaveLength(1);
expect(nodes.some((n) => n.type === 'gate')).toBe(false); // payment-check email IS the gate
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'queue_payment_check')).toBe(true);
expect(nodes.some((n) => JSON.parse(n.config || '{}').action === 'escalate_to_collections')).toBe(true);
await seedBuiltinWorkflowsAtBoot(db, noopLogger); // idempotent at current seed version
const all = await db('workflows').where({ builtin_key: DUNNING_KEY });
expect(all.length).toBe(1);
});
test('re-seeds a stale built-in on version bump, but never an admin-owned one', async () => {
const { seedBuiltinWorkflowsAtBoot, DUNNING_KEY } = require('../../src/services/_workflowSeedBoot');
const noopLogger = { info() {}, warn() {} };
// Simulate an older, never-touched seed (v1, with a legacy gate node).
const wf = await db('workflows').where({ builtin_key: DUNNING_KEY }).first();
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: null, trigger_config: JSON.stringify({ seedVersion: 1 }) });
await db('workflow_nodes').insert({ workflow_id: wf.id, version: wf.version, node_key: 'legacyGate', type: 'gate', config: '{}', pos_x: 0, pos_y: 0 });
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const reseeded = await db('workflows').where({ id: wf.id }).first();
expect(reseeded.version).toBe(wf.version + 1); // bumped
expect(JSON.parse(reseeded.trigger_config).seedVersion).toBe(6);
expect(!!reseeded.enabled).toBe(false); // seed default re-applied (not admin-owned → flips enabled→disabled)
const newNodes = await db('workflow_nodes').where({ workflow_id: wf.id, version: reseeded.version });
expect(newNodes.some((n) => n.type === 'gate')).toBe(false); // legacy graph replaced
// Admin-owned (admin_toggled_at set) + stale → must NOT be touched.
await db('workflows').where({ id: wf.id }).update({ enabled: true, admin_toggled_at: new Date().toISOString(), trigger_config: JSON.stringify({ seedVersion: 1 }) });
const before = await db('workflows').where({ id: wf.id }).first();
await seedBuiltinWorkflowsAtBoot(db, noopLogger);
const after = await db('workflows').where({ id: wf.id }).first();
expect(after.version).toBe(before.version); // unchanged
expect(!!after.enabled).toBe(true); // admin's choice preserved
});
test('seeds the gallery, pre-event + booking built-ins (all disabled for first beta)', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} });
// First beta: cutover flows ship DISABLED (legacy paths run until enabled);
// they delegate to the proven send functions once turned on.
const expiring = await db('workflows').where({ builtin_key: 'gallery_expiring' }).first();
expect(expiring).toBeTruthy();
expect(!!expiring.enabled).toBe(false);
expect(expiring.trigger_type).toBe('gallery.expiring');
const expiringNodes = await db('workflow_nodes').where({ workflow_id: expiring.id, version: expiring.version });
expect(expiringNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expiring')).toBe(true);
const expired = await db('workflows').where({ builtin_key: 'gallery_expired' }).first();
expect(expired).toBeTruthy();
expect(!!expired.enabled).toBe(false);
expect(expired.trigger_type).toBe('gallery.expired');
const expiredNodes = await db('workflow_nodes').where({ workflow_id: expired.id, version: expired.version });
expect(expiredNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_gallery_expired')).toBe(true);
// Invoice-only booking variant (quote → invoice, no gallery).
const invoiceOnly = await db('workflows').where({ builtin_key: 'booking_invoice_only' }).first();
expect(invoiceOnly).toBeTruthy();
expect(!!invoiceOnly.enabled).toBe(false);
expect(invoiceOnly.trigger_type).toBe('quote.accepted');
const ioNodes = await db('workflow_nodes').where({ workflow_id: invoiceOnly.id, version: invoiceOnly.version });
expect(ioNodes.some((n) => n.type === 'wait')).toBe(false); // no event wait — sends on approval
expect(ioNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_event')).toBe(false); // no gallery
const bookingFull = await db('workflows').where({ builtin_key: 'booking_full' }).first();
expect(bookingFull).toBeTruthy();
expect(!!bookingFull.enabled).toBe(false); // illustrative/stub — stays disabled
expect(bookingFull.trigger_type).toBe('quote.accepted');
const fullNodes = await db('workflow_nodes').where({ workflow_id: bookingFull.id, version: bookingFull.version });
expect(fullNodes.some((n) => JSON.parse(n.config || '{}').action === 'prepare_contract')).toBe(true);
// Admin review gate guards BOTH document sends (adjust line items, then OK).
const fullGateKeys = fullNodes.filter((n) => n.type === 'gate').map((n) => n.node_key);
expect(fullGateKeys).toEqual(expect.arrayContaining(['reviewContract', 'reviewInvoice']));
const fullEdges = await db('workflow_edges').where({ workflow_id: bookingFull.id, version: bookingFull.version });
// reviewContract --confirm--> sendContract. The invoice is prepared + approved
// EARLY; reviewInvoice --confirm--> waitEvent, and the wait --> sendInvoice, so
// dispatch is held until the event date after the admin's early OK.
expect(fullEdges.some((e) => e.from_node === 'reviewContract' && e.from_handle === 'confirm' && e.to_node === 'sendContract')).toBe(true);
expect(fullEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
expect(fullEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
const bookingSimple = await db('workflows').where({ builtin_key: 'booking_simple' }).first();
expect(bookingSimple).toBeTruthy();
expect(bookingSimple.trigger_type).toBe('quote.accepted');
const simpleEdges = await db('workflow_edges').where({ workflow_id: bookingSimple.id, version: bookingSimple.version });
expect(simpleEdges.some((e) => e.from_node === 'reviewInvoice' && e.from_handle === 'confirm' && e.to_node === 'waitEvent')).toBe(true);
expect(simpleEdges.some((e) => e.from_node === 'waitEvent' && e.to_node === 'sendInvoice')).toBe(true);
const preEvent = await db('workflows').where({ builtin_key: 'pre_event_email' }).first();
expect(preEvent).toBeTruthy();
expect(!!preEvent.enabled).toBe(false); // first beta: ships disabled
expect(preEvent.trigger_type).toBe('event.date_approaching');
expect(JSON.parse(preEvent.trigger_config).daysBefore).toBe(2); // default when global setting unset
const preNodes = await db('workflow_nodes').where({ workflow_id: preEvent.id, version: preEvent.version });
expect(preNodes.some((n) => JSON.parse(n.config || '{}').action === 'notify_pre_event')).toBe(true);
});
test('emitDueEventReminders starts a run for an event inside the lead window', async () => {
const wfId = await makeWorkflow({
trigger: 'event.date_approaching',
enabled: true,
nodes: [{ key: 'pe1', type: 'trigger' }, { key: 'pe2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'pe1', to: 'pe2' }],
});
// Park the workflow's trigger window at 5 days so our event (2 days out) is in range.
await db('workflows').where({ id: wfId }).update({ trigger_config: JSON.stringify({ daysBefore: 5 }) });
const inWindow = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10);
const tooFar = new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10);
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
const evt = { event_type: 'wedding', password_hash: 'x', expires_at: farFuture, is_active: true, is_archived: false, customer_email: 'c@x.test' };
await db('events').insert({ ...evt, slug: 'pe-soon', share_link: 'pe-soon', event_name: 'Soon', event_date: inWindow });
await db('events').insert({ ...evt, slug: 'pe-far', share_link: 'pe-far', event_name: 'Far', event_date: tooFar });
const emitted = await engine.emitDueEventReminders();
expect(emitted).toBeGreaterThanOrEqual(1);
const runs = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
expect(runs.length).toBe(1); // only the in-window event, not the far one
// Idempotent: a second pass dedups (no duplicate run for the same event).
await engine.emitDueEventReminders();
const runs2 = await db('workflow_runs').where({ workflow_id: wfId, entity_type: 'event' });
expect(runs2.length).toBe(1);
});
test('notify_pre_event / sendReminderForEvent sends to an event with a direct email (no CRM account)', async () => {
// Regression: the reminder query used events.customer_account_id, which does
// not exist — so an event with only customer_email/host_email got no mail.
const farFuture = new Date(Date.now() + 365 * 86400000).toISOString();
await db('events').insert({
event_type: 'wedding', password_hash: 'x', expires_at: farFuture,
is_active: true, is_archived: false,
slug: 'rem-direct', share_link: 'rem-direct', event_name: 'Direct',
event_date: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 10),
customer_email: 'direct@x.test', // event-level email, NOT a customer_account
});
const ev = await db('events').where({ slug: 'rem-direct' }).first();
const res = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
expect(res.sent).toBe(1);
const mail = await db('email_queue').where({ event_id: ev.id }).first();
expect(mail).toBeTruthy();
expect(mail.recipient_email).toBe('direct@x.test');
// Idempotent: sent_at stamped → a second call is a no-op.
const again = await require('../../src/services/eventReminderService').sendReminderForEvent(ev.id);
expect(again.sent).toBe(0);
expect(again.reason).toBe('already_sent');
});
test('reminder template resolves per event type within the chosen group, else group default', async () => {
const { _internal } = require('../../src/services/eventReminderService');
// Per-type template exists within a custom group → used.
await db('email_templates').insert({ template_key: 'promo_wedding' });
expect(await _internal.resolveTemplateKey('wedding', 'promo')).toBe('promo_wedding');
// A type with no authored template (in any group) → the group's default.
expect(await _internal.resolveTemplateKey('zzznotype', 'promo')).toBe('promo_default');
// Blank group → the default event_reminder group.
expect(await _internal.resolveTemplateKey('zzznotype')).toBe('event_reminder_default');
// Trailing underscore on the group is tolerated.
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
const ctx = (config, vars = {}) => ({
run: { id: 1, workflow_id: 1, version: 1, trigger_event: 'invoice.sent', entity_type: 'invoice', entity_id: 5 },
node: { config }, vars, db, logger: { warn() {} },
});
// No webhook selected → observable skip, not a crash.
expect(await webhook(ctx({}))).toMatchObject({ skipped: true });
// A configured, active webhook subscription.
const [adminId] = await db('admin_users').insert({ username: 'wfhook', email: 'wf@x.test', password_hash: 'x' });
const [whId] = await db('webhooks').insert({
name: 'Flow hook', url: 'https://example.com/hook', secret: 'whsec_test',
events: JSON.stringify([]), active: true, created_by: adminId,
});
// Dry run does not enqueue.
expect(await webhook(ctx({ webhookId: whId }, { __dryRun: true }))).toMatchObject({ dryRun: true, would: 'webhook' });
expect(await db('webhook_deliveries').where({ webhook_id: whId }).count('id as c').first()).toMatchObject({ c: 0 });
// Real run → a pending delivery is enqueued for the worker (which does the
// signing + SSRF re-validation + retries).
const res = await webhook(ctx({ webhookId: whId }));
expect(res.webhook_enqueued).toBe(whId);
const del = await db('webhook_deliveries').where({ webhook_id: whId }).first();
expect(del).toBeTruthy();
expect(del.status).toBe('pending');
expect(del.event_type).toBe('workflow.invoice.sent');
// Inactive / missing subscription → skip.
await db('webhooks').where({ id: whId }).update({ active: false });
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() {} });
// All built-ins ship disabled → inactive until the admin enables one.
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(false);
expect(await engine.isBuiltinFlowActive('does_not_exist')).toBe(false);
// Enable one → now active.
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: true });
expect(await engine.isBuiltinFlowActive('gallery_expiring')).toBe(true);
await db('workflows').where({ builtin_key: 'gallery_expiring' }).update({ enabled: false }); // restore
});
test('legacy event-reminder pass stands down ONLY when the pre_event_email flow is enabled', async () => {
const { seedBuiltinWorkflowsAtBoot } = require('../../src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, { info() {}, warn() {} }); // pre_event_email seeded DISABLED
// crm_event_reminders_enabled must be on to reach the mutex guard.
await db('app_settings')
.insert({ setting_key: 'crm_event_reminders_enabled', setting_value: JSON.stringify(true), setting_type: 'boolean' })
.onConflict('setting_key').merge();
const eventReminderService = require('../../src/services/eventReminderService');
// Flow disabled → guard does NOT fire (legacy pass owns reminders).
expect(await engine.isBuiltinFlowActive('pre_event_email')).toBe(false);
// Flow enabled → the pass stands down before doing any work (byWorkflow).
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: true });
const after = await eventReminderService.runEventReminderPass();
expect(after.byWorkflow).toBe(true);
expect(after.sent).toBe(0);
await db('workflows').where({ builtin_key: 'pre_event_email' }).update({ enabled: false }); // restore
});
test('targetWorkflowId runs only the selected flow, not every matching one', async () => {
// Two enabled flows on the same trigger — the quote picks one.
const chosen = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'c1', to: 'c2' }],
});
const other = await makeWorkflow({
trigger: 'pick.event', enabled: true,
nodes: [{ key: 'o1', type: 'trigger' }, { key: 'o2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'o1', to: 'o2' }],
});
const runIds = await engine.emitWorkflowEvent('pick.event', { entityType: 'quote', entityId: 99, targetWorkflowId: chosen });
expect(runIds.length).toBe(1);
const chosenRuns = await db('workflow_runs').where({ workflow_id: chosen, entity_id: 99 });
const otherRuns = await db('workflow_runs').where({ workflow_id: other, entity_id: 99 });
expect(chosenRuns.length).toBe(1); // only the selected flow ran
expect(otherRuns.length).toBe(0); // the other matching flow did NOT
});
test('gate decision with no matching edge FAILS the run (not a silent done)', async () => {
// Gate has a confirm edge but the deny edge was lost (e.g. a bad import).
const wfId = await makeWorkflow({
trigger: 'noedge.event', enabled: true,
nodes: [
{ key: 'g0', type: 'trigger' },
{ key: 'g1', type: 'gate', config: {} },
{ key: 'g2', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g0', to: 'g1' },
{ from: 'g1', handle: 'confirm', to: 'g2' }, // no deny edge
],
});
const [runId] = await engine.emitWorkflowEvent('noedge.event', { entityType: 'x', entityId: 1 });
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
await engine.actById(approval.id, 'deny'); // deny has no edge
const run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('failed'); // loud failure, not a green 'done'
expect(run.error).toMatch(/deny.*no matching edge/i);
});
test('admin confirms a gate early; the following wait holds dispatch until its date', async () => {
// The booking pattern: prepare → REVIEW GATE → WAIT(event date) → send. The
// admin can approve at the gate whenever; the run then parks at the wait and
// the scheduler dispatches when the date arrives.
const wfId = await makeWorkflow({
trigger: 'gatewait.event',
nodes: [
{ key: 'g0', type: 'trigger' },
{ key: 'g1', type: 'gate', config: { prompt: 'Approve invoice?' } },
{ key: 'g2', type: 'wait', config: { delayDays: 5 } },
{ key: 'g3', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 'g0', to: 'g1' },
{ from: 'g1', handle: 'confirm', to: 'g2' },
{ from: 'g2', to: 'g3' },
],
});
const [runId] = await engine.emitWorkflowEvent('gatewait.event', { entityType: 'invoice', entityId: 7 });
let run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g1'); // parked at the review gate
// Admin confirms EARLY (before the wait date).
const approval = await db('workflow_approvals').where({ run_id: runId, status: 'pending' }).first();
await engine.actById(approval.id, 'confirm');
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('waiting');
expect(run.current_node).toBe('g2'); // now holding at the wait, not yet dispatched
// Date arrives → scheduler dispatches.
await db('workflow_runs').where({ id: runId }).update({ wake_at: new Date(Date.now() - 1000).toISOString() });
await engine.runDueWaits();
run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done');
});
test('recoverStaleRuns resumes a run orphaned mid-flow (crash recovery)', async () => {
const wfId = await makeWorkflow({
trigger: 'recover.event',
nodes: [{ key: 'r1', type: 'trigger' }, { key: 'r2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'r1', to: 'r2' }],
});
// Simulate a run left 'running' at r2 with a stale heartbeat (crash mid-flow).
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'recover.event', status: 'running', current_node: 'r2',
context: JSON.stringify({ vars: {} }), dedup_key: 'recover-1',
updated_at: new Date(Date.now() - 3600000).toISOString(),
});
const run0 = await db('workflow_runs').where({ dedup_key: 'recover-1' }).first();
const n = await engine.recoverStaleRuns({ staleMs: 1000 });
expect(n).toBeGreaterThanOrEqual(1);
const run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('done');
});
test('recoverStaleRuns abandons a crash-looping run after the attempts cap', async () => {
const wfId = await makeWorkflow({
trigger: 'crashloop.event',
nodes: [{ key: 'c1', type: 'trigger' }, { key: 'c2', type: 'action', config: { action: 'noop' } }],
edges: [{ from: 'c1', to: 'c2' }],
});
await db('workflow_runs').insert({
workflow_id: wfId, version: 1, trigger_event: 'crashloop.event', status: 'running', current_node: 'c2',
context: JSON.stringify({ vars: {} }), dedup_key: 'crash-1', attempts: 5,
updated_at: new Date(Date.now() - 3600000).toISOString(),
});
const run0 = await db('workflow_runs').where({ dedup_key: 'crash-1' }).first();
await engine.recoverStaleRuns({ staleMs: 1000 });
const run = await db('workflow_runs').where({ id: run0.id }).first();
expect(run.status).toBe('failed');
});
test('testRun dry-run walks the whole flow (waits skipped, gate auto-confirmed, actions mocked)', async () => {
const wfId = await makeWorkflow({
trigger: 'testfire.event',
nodes: [
{ key: 't', type: 'trigger' },
{ key: 'w', type: 'wait', config: { delayDays: 14 } },
{ key: 'g', type: 'gate', config: { type: 'payment_confirm' } },
{ key: 'a', type: 'action', config: { action: 'send_email', recipientClass: 'customer' } },
{ key: 'end', type: 'action', config: { action: 'noop' } },
],
edges: [
{ from: 't', to: 'w' },
{ from: 'w', to: 'g' },
{ from: 'g', handle: 'confirm', to: 'a' },
{ from: 'g', handle: 'deny', to: 'end' },
{ from: 'a', to: 'end' },
],
});
const runId = await engine.testRun(wfId, { dryRun: true });
const run = await db('workflow_runs').where({ id: runId }).first();
expect(run.status).toBe('done'); // walked to completion — no parking at the wait/gate
const steps = await db('workflow_run_steps').where({ run_id: runId });
expect(steps.find((s) => s.node_key === 'w').status).toBe('skipped'); // wait passed through
const emailStep = steps.find((s) => s.node_key === 'a');
expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail
});
});
@@ -0,0 +1,147 @@
/**
* Admin workflow API route tests (CRUD, versioning, RBAC gate, approvals).
*/
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
let db;
let cleanup;
let app;
let token;
let noPermToken;
const sampleGraph = {
name: 'Test flow',
trigger_type: 'invoice.sent',
enabled: false,
nodes: [
{ node_key: 'n1', type: 'trigger' },
{ node_key: 'n2', type: 'action', config: { action: 'noop' } },
],
edges: [{ from_node: 'n1', to_node: 'n2' }],
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'super_admin');
token = mintAdminToken(adminId);
const ins = await db('admin_users').insert({
username: 'norole', email: 'nr@example.com', password_hash: 'x',
must_change_password: false, created_at: new Date(),
}).returning('id');
noPermToken = mintAdminToken(ins[0]?.id ?? ins[0]);
await db('feature_flags').insert({ key: 'workflows', value: true });
app = buildRouteApp('/api/admin/workflows', require('../../src/routes/adminWorkflows'));
});
afterAll(async () => { await cleanup(); });
const auth = (t) => ({ Authorization: `Bearer ${t}` });
describe('admin workflows API', () => {
let createdId;
test('create → 201 with id', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token)).send(sampleGraph);
expect(res.status).toBe(201);
expect(res.body.id).toBeGreaterThan(0);
createdId = res.body.id;
});
test('rejects a graph without exactly one trigger', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token))
.send({ ...sampleGraph, nodes: [{ node_key: 'x', type: 'action' }], edges: [] });
expect(res.status).toBe(400);
});
test('rejects an unknown node type', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(token))
.send({ ...sampleGraph, nodes: [{ node_key: 't', type: 'trigger' }, { node_key: 'x', type: 'actoin' }], edges: [] });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/unknown node type/i);
});
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: '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|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 () => {
const res = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
expect(res.status).toBe(200);
expect(res.body.nodes).toHaveLength(2);
expect(res.body.edges).toHaveLength(1);
expect(res.body.version).toBe(1);
});
test('list includes it', async () => {
const res = await request(app).get('/api/admin/workflows').set(auth(token));
expect(res.status).toBe(200);
expect(res.body.some((w) => w.id === createdId)).toBe(true);
});
test('update bumps the version', async () => {
const res = await request(app).put(`/api/admin/workflows/${createdId}`).set(auth(token))
.send({ ...sampleGraph, name: 'Renamed' });
expect(res.status).toBe(200);
expect(res.body.version).toBe(2);
const get = await request(app).get(`/api/admin/workflows/${createdId}`).set(auth(token));
expect(get.body.name).toBe('Renamed');
expect(get.body.version).toBe(2);
});
test('enable toggle', async () => {
const res = await request(app).patch(`/api/admin/workflows/${createdId}/enabled`).set(auth(token)).send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.enabled).toBe(true);
});
test('approvals inbox returns an array', async () => {
const res = await request(app).get('/api/admin/workflows/approvals').set(auth(token));
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
test('a role without workflows.manage is forbidden from writing', async () => {
const res = await request(app).post('/api/admin/workflows').set(auth(noPermToken)).send(sampleGraph);
expect(res.status).toBe(403);
});
});
@@ -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);
});
});
});
@@ -0,0 +1,203 @@
/**
* HTTP route tests for the ADMIN Live Slideshow endpoints:
* POST /api/admin/events/:id/slideshow/generate
* POST /api/admin/events/:id/slideshow/disable
* PATCH /api/admin/events/:id/slideshow
* PUT /api/admin/settings/slideshow (global preset + watermark + fit)
*
* Pins the contracts + the two regressions hit during the build:
* - the events table has NO `updated_at` column, so these writes must NOT set
* it (else every call 500s that was the original "Generate" failure);
* - the `slideshow` feature flag gates these endpoints (403 when off);
* - PUT /admin/settings/slideshow validates + clamps every key.
*/
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-show-admin-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
async function setFlag(db, key, on) {
await db('feature_flags').where({ key }).del();
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
invalidateFeatureFlagCache();
}
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 Live Slideshow endpoints', () => {
let db; let cleanup; let app; let adminId; let token;
// Match slideshowPublic.test.js — bootCrmDb's full migration run intermittently
// exceeds Jest's default 5s `beforeAll` timeout on slower CI runners; raise
// it so this doesn't block PRs.
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'));
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
// 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 });
});
}, 30000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => {
await db('events').del();
await db('app_settings').del();
await setFlag(db, 'slideshow', true);
});
const auth = (req) => req.set('Authorization', `Bearer ${token}`);
describe('generate / disable', () => {
it('mints a share token (no updated_at column → must not 500)', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(200);
expect(typeof res.body.show_share_token).toBe('string');
expect(res.body.show_share_token).toHaveLength(64);
expect(res.body.slideshow_url).toContain(`/show/${res.body.show_share_token}`);
const row = await db('events').where({ id }).first();
expect(row.show_share_token).toBe(res.body.show_share_token);
});
it('regenerate rotates the token', async () => {
const id = await insertEvent(db, adminId, { show_share_token: 'old-token' });
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(200);
expect(res.body.show_share_token).not.toBe('old-token');
});
it('disable nulls the token', async () => {
const id = await insertEvent(db, adminId, { show_share_token: 'live-token' });
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/disable`));
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_share_token == null).toBe(true);
});
it('403 when the slideshow feature is off', async () => {
const id = await insertEvent(db, adminId);
await setFlag(db, 'slideshow', false);
const res = await auth(request(app).post(`/api/admin/events/${id}/slideshow/generate`));
expect(res.status).toBe(403);
});
it('401 without an admin token', async () => {
const id = await insertEvent(db, adminId);
const res = await request(app).post(`/api/admin/events/${id}/slideshow/generate`);
expect(res.status).toBe(401);
});
});
describe('PATCH /:id/slideshow', () => {
it('persists display + watermark mode (no updated_at column → must not 500)', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({
show_interval_ms: 9000,
show_transition: 'cut',
show_transition_ms: 300,
show_watermark: true,
show_colorfilter: 'bw',
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_interval_ms).toBe(9000);
expect(row.show_transition).toBe('cut');
expect(row.show_transition_ms).toBe(300);
expect(row.show_colorfilter).toBe('bw');
expect(row.show_watermark === 1 || row.show_watermark === true).toBe(true);
});
it('show_watermark=null sets the column to NULL (inherit global)', async () => {
const id = await insertEvent(db, adminId, { show_watermark: 1 });
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_watermark: null });
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.show_watermark == null).toBe(true);
});
it('400 on an invalid transition', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).patch(`/api/admin/events/${id}/slideshow`)).send({ show_transition: 'wormhole' });
expect(res.status).toBe(400);
});
});
describe('PUT /api/admin/settings/slideshow', () => {
const getSetting = async (key) => {
const row = await db('app_settings').where({ setting_key: key }).first();
return row ? JSON.parse(row.setting_value) : undefined;
};
it('persists the global preset + watermark + fit, clamping out-of-range values', async () => {
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
slideshow_fit: 'contain',
slideshow_interval_ms: 9000,
slideshow_transition: 'slide',
slideshow_transition_ms: 250,
slideshow_colorfilter: 'sepia',
slideshow_watermark_enabled: true,
slideshow_watermark_opacity: 999, // clamp -> 100
slideshow_watermark_size: 99, // clamp -> 40
});
expect(res.status).toBe(200);
expect(await getSetting('slideshow_fit')).toBe('contain');
expect(await getSetting('slideshow_interval_ms')).toBe(9000);
expect(await getSetting('slideshow_transition')).toBe('slide');
expect(await getSetting('slideshow_transition_ms')).toBe(250);
expect(await getSetting('slideshow_colorfilter')).toBe('sepia');
expect(await getSetting('slideshow_watermark_enabled')).toBe(true);
expect(await getSetting('slideshow_watermark_opacity')).toBe(100);
expect(await getSetting('slideshow_watermark_size')).toBe(40);
});
it('coerces an invalid fit / transition to the safe default', async () => {
const res = await auth(request(app).put('/api/admin/settings/slideshow')).send({
slideshow_fit: 'banana',
slideshow_transition: 'wormhole',
});
expect(res.status).toBe(200);
expect(await getSetting('slideshow_fit')).toBe('cover');
expect(await getSetting('slideshow_transition')).toBe('crossfade');
});
});
});
@@ -0,0 +1,286 @@
/**
* HTTP route tests for the PUBLIC Live Slideshow surface (backend/src/routes/gallery.js):
* GET /:slug/show/:token/state (cheap settings + photo-count poll)
* GET /:slug/show/:token/session (mints the gallery JWT + cookie)
*
* These pin the two pieces of logic where real bugs lived during the build:
* - resolveSlideshow: the `slideshow` feature flag is a MASTER kill-switch
* (404 when off), plus token / expiry / draft / archived / inactive guards.
* - slideshowSettings: the watermark cascade (global look + per-event on/off),
* image fit, and the fact that globals are read from `app_settings`
* (regression for the getSettingnonexistent-`settings`-table bug).
*/
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-show-pub-')), 'db.sqlite'
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
const { invalidateSlideshowGlobals } = require('../../src/utils/slideshowGlobals');
const SLUG = 'wedding-test';
const TOKEN = 'show-tok-abcdef';
async function setFlag(db, key, on) {
await db('feature_flags').where({ key }).del();
await db('feature_flags').insert({ key, value: on ? 1 : 0 });
invalidateFeatureFlagCache();
}
async function setSetting(db, key, value, type = 'slideshow') {
await db('app_settings').where({ setting_key: key }).del();
await db('app_settings').insert({ setting_key: key, setting_value: JSON.stringify(value), setting_type: type, updated_at: new Date() });
}
async function insertEvent(db, over = {}) {
const base = {
slug: SLUG,
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/${SLUG}/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,
show_share_token: TOKEN,
created_at: new Date().toISOString(),
...over,
};
const r = await db('events').insert(base).returning('id');
return r[0]?.id ?? r[0];
}
describe('public Live Slideshow routes', () => {
let db; let cleanup; let app;
// bootCrmDb runs the full migration set against a fresh SQLite file, which
// takes <2s locally but has been observed to exceed Jest's default 5s
// `beforeAll` timeout on slower GitHub Actions runners (~5.4s — runner-to-
// runner I/O variance). Raise the hook timeout so this doesn't intermittently
// block PRs on CI; doesn't affect happy-path local runs.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = express();
app.use(express.json());
app.use(cookieParser());
// Both routers mount under /api/gallery in production; the display-only
// guard lives on download routes (gallery) + the feedback POST (galleryFeedback).
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
// 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 });
});
}, 30000);
afterAll(async () => { await cleanup(); });
beforeEach(async () => {
await db('events').del();
await db('app_settings').del();
await db('feature_flags').del();
invalidateFeatureFlagCache();
invalidateSlideshowGlobals();
await setFlag(db, 'slideshow', true);
});
const stateUrl = (token = TOKEN) => `/api/gallery/${SLUG}/show/${token}/state`;
describe('resolveSlideshow guards', () => {
it('200 + per-event display settings on a live link', async () => {
await insertEvent(db, {
show_interval_ms: 8000,
show_transition: 'kenburns',
show_transition_ms: 1200,
show_colorfilter: 'sepia',
});
const res = await request(app).get(stateUrl());
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
interval_ms: 8000,
transition: 'kenburns',
transition_ms: 1200,
colorfilter: 'sepia',
fit: 'cover',
photo_count: 0,
watermark: null,
});
});
it('404 when the slideshow feature flag is OFF (master kill-switch)', async () => {
await insertEvent(db);
await setFlag(db, 'slideshow', false);
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 on an unknown token', async () => {
await insertEvent(db);
const res = await request(app).get(stateUrl('not-the-token'));
expect(res.status).toBe(404);
});
it('404 when the share token is null (link never minted / disabled)', async () => {
await insertEvent(db, { show_share_token: null });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event has expired', async () => {
await insertEvent(db, { expires_at: new Date(Date.now() - 1000).toISOString() });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event is a draft', async () => {
await insertEvent(db, { is_draft: 1 });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
it('404 when the event is archived', async () => {
await insertEvent(db, { is_archived: 1 });
const res = await request(app).get(stateUrl());
expect(res.status).toBe(404);
});
});
describe('slideshowSettings — image fit (global, live)', () => {
it('reflects the global slideshow_fit setting', async () => {
await insertEvent(db);
await setSetting(db, 'slideshow_fit', 'contain');
const res = await request(app).get(stateUrl());
expect(res.status).toBe(200);
expect(res.body.fit).toBe('contain');
});
});
describe('slideshowSettings — watermark cascade (global look + per-event on/off)', () => {
async function enableGlobalWatermark() {
await setSetting(db, 'slideshow_watermark_enabled', true);
await setSetting(db, 'slideshow_watermark_source', 'logo');
await setSetting(db, 'slideshow_watermark_position', 'top-left');
await setSetting(db, 'slideshow_watermark_opacity', 40);
await setSetting(db, 'slideshow_watermark_style', 'original');
await setSetting(db, 'slideshow_watermark_size', 9);
await setSetting(db, 'branding_logo_url', '/uploads/logos/light.svg', 'branding');
}
it('inherits the global watermark when show_watermark is NULL', async () => {
await insertEvent(db, { show_watermark: null });
await enableGlobalWatermark();
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toEqual({
url: '/uploads/logos/light.svg',
position: 'top-left',
opacity: 40,
style: 'original',
size: 9,
});
});
it('resolves the dark logo / favicon sources', async () => {
await insertEvent(db, { show_watermark: null });
await enableGlobalWatermark();
await setSetting(db, 'slideshow_watermark_source', 'favicon');
await setSetting(db, 'branding_favicon_url', '/uploads/favicons/f.png', 'branding');
const res = await request(app).get(stateUrl());
expect(res.body.watermark.url).toBe('/uploads/favicons/f.png');
});
it('per-event OFF override hides the watermark even when the global is on', async () => {
await insertEvent(db, { show_watermark: 0 });
await enableGlobalWatermark();
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toBeNull();
});
it('per-event ON override shows the watermark even when the global is off', async () => {
await insertEvent(db, { show_watermark: 1 });
await enableGlobalWatermark();
await setSetting(db, 'slideshow_watermark_enabled', false);
const res = await request(app).get(stateUrl());
expect(res.body.watermark).not.toBeNull();
expect(res.body.watermark.url).toBe('/uploads/logos/light.svg');
});
it('null when enabled but no logo URL is configured', async () => {
await insertEvent(db, { show_watermark: null });
await setSetting(db, 'slideshow_watermark_enabled', true);
// no branding_logo_url set
const res = await request(app).get(stateUrl());
expect(res.body.watermark).toBeNull();
});
});
describe('display-only token guards (#646 review concern 1)', () => {
// Mint a real slideshow JWT, then prove it is denied on the
// download / upload / feedback routes (display-only contract).
async function slideshowJwt() {
await insertEvent(db);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(200);
return res.body.token;
}
it('403 on whole-gallery download', async () => {
const jwt = await slideshowJwt();
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`).set('Authorization', `Bearer ${jwt}`);
expect(res.status).toBe(403);
});
it('403 on single-photo download', async () => {
const jwt = await slideshowJwt();
const res = await request(app).get(`/api/gallery/${SLUG}/download/1`).set('Authorization', `Bearer ${jwt}`);
expect(res.status).toBe(403);
});
it('403 on bulk download-selected', async () => {
const jwt = await slideshowJwt();
const res = await request(app).post(`/api/gallery/${SLUG}/download-selected`).set('Authorization', `Bearer ${jwt}`).send({ photoIds: [1] });
expect(res.status).toBe(403);
});
it('403 on feedback POST', async () => {
const jwt = await slideshowJwt();
const res = await request(app).post(`/api/gallery/${SLUG}/photos/1/feedback`).set('Authorization', `Bearer ${jwt}`).send({ feedback_type: 'like' });
expect(res.status).toBe(403);
});
});
describe('GET /session', () => {
it('mints a token + sets the gallery cookie on a valid link', async () => {
await insertEvent(db);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(200);
expect(typeof res.body.token).toBe('string');
expect(res.body.token.length).toBeGreaterThan(20);
expect(res.body.event).toMatchObject({ event_name: 'Test Wedding' });
expect(res.body).toHaveProperty('settings');
expect(res.body).toHaveProperty('photo_count', 0);
expect(res.headers['set-cookie']).toBeDefined();
});
it('404 when the feature is off', async () => {
await insertEvent(db);
await setFlag(db, 'slideshow', false);
const res = await request(app).get(`/api/gallery/${SLUG}/show/${TOKEN}/session`);
expect(res.status).toBe(404);
});
});
});
@@ -0,0 +1,100 @@
/**
* Tests for the custom-tracker HTML sanitiser (#663 Phase 1).
*
* The field accepts admin-pasted `<head>`-style snippets for arbitrary
* trackers (Plausible / Matomo / Pirsch / GA4 / GoatCounter / Fathom /
* Cloudflare Web Analytics). We sanitise on save with a narrow allowlist
* tuned for tracker scripts defence-in-depth, even though the field is
* admin-only.
*/
const { sanitizeTrackerSnippet } = require('../../src/services/trackers/customScriptSanitiser');
describe('sanitizeTrackerSnippet (#663)', () => {
test('returns empty string for non-string / empty / whitespace input', () => {
expect(sanitizeTrackerSnippet(null)).toBe('');
expect(sanitizeTrackerSnippet(undefined)).toBe('');
expect(sanitizeTrackerSnippet(42)).toBe('');
expect(sanitizeTrackerSnippet('')).toBe('');
expect(sanitizeTrackerSnippet(' ')).toBe('');
});
test('passes through a Plausible-style script tag with data-domain', () => {
const input = '<script defer data-domain="example.com" src="https://plausible.io/js/script.js"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('src="https://plausible.io/js/script.js"');
expect(out).toContain('data-domain="example.com"');
expect(out).toContain('defer');
});
test('passes through a Umami-style script with data-website-id', () => {
const input = '<script async defer src="https://analytics.example.com/script.js" data-website-id="aaa-bbb-ccc"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('src="https://analytics.example.com/script.js"');
expect(out).toContain('data-website-id="aaa-bbb-ccc"');
});
test('passes through inline script body unchanged', () => {
const input = '<script>window.GA = "x"; window.tracker = function() { console.log("init"); };</script>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('window.GA = "x"');
expect(out).toContain('console.log("init")');
});
test('allows <noscript> fallback', () => {
const input = '<noscript><img src="https://t.example/?nojs=1" /></noscript>';
const out = sanitizeTrackerSnippet(input);
expect(out).toContain('<noscript>');
});
test('allows <link rel="preconnect"> and <link rel="dns-prefetch">', () => {
const out = sanitizeTrackerSnippet(
'<link rel="preconnect" href="https://t.example.com">'
+ '<link rel="dns-prefetch" href="https://t.example.com">',
);
expect(out).toContain('rel="preconnect"');
expect(out).toContain('rel="dns-prefetch"');
expect(out).toContain('href="https://t.example.com"');
});
test('strips <link rel="stylesheet"> (not tracker-related)', () => {
const out = sanitizeTrackerSnippet('<link rel="stylesheet" href="https://evil.example/x.css">');
expect(out).not.toContain('stylesheet');
expect(out).not.toContain('href');
});
test('strips disallowed tags entirely', () => {
const input = '<div><iframe src="https://evil.example/x.html"></iframe><h1>hi</h1></div>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('iframe');
expect(out).not.toContain('<div');
expect(out).not.toContain('<h1');
});
test('strips javascript: URLs from script src', () => {
const input = '<script src="javascript:alert(1)"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('javascript:');
});
test('strips data: URLs from script src', () => {
const input = '<script src="data:text/javascript,alert(1)"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('data:text/javascript');
});
test('strips on* event-handler attributes (defence-in-depth)', () => {
// event-handler attrs are not in our allowlist; sanitize-html strips them.
const input = '<script src="https://t.example/x.js" onload="evil()"></script>';
const out = sanitizeTrackerSnippet(input);
expect(out).not.toContain('onload');
expect(out).toContain('src="https://t.example/x.js"');
});
test('returns empty string on unparseable input rather than throwing', () => {
// sanitize-html is fault-tolerant — pass deliberately malformed and
// confirm we don't blow up.
expect(typeof sanitizeTrackerSnippet('<<<>>>')).toBe('string');
expect(typeof sanitizeTrackerSnippet('<script')).toBe('string');
});
});
@@ -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' });
});
});
@@ -0,0 +1,115 @@
/**
* Tests for the Rybbit metrics-API adapter (#663 Phase 1). Mirrors the
* `umamiAdapter` test contract: missing config / URL shape / encoding /
* normalisation / unknown-bucket drop / failure modes.
*
* Rybbit's documented endpoint is `/api/site/{websiteId}/breakdown` with
* `dimension=device`; we accept both bare-array and `{ data: [...] }`
* envelopes since their docs hint at minor v0 v1 shape variation.
*/
const { buildAdapter } = require('../../src/services/trackers/rybbitAdapter');
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
function mockJson(body, { status = 200 } = {}) {
global.fetch = jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
const valid = { baseUrl: 'https://r.example.com', websiteId: 'rsite-789', apiKey: 'rkey' };
describe('rybbitAdapter.fetchDeviceBreakdown (#663)', () => {
test('returns null when config is incomplete', async () => {
expect(await buildAdapter({}).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends Bearer auth', async () => {
mockJson([{ device: 'desktop', sessions: 10 }]);
await buildAdapter({ ...valid, baseUrl: 'https://r.example.com/' })
.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
const [calledUrl, init] = global.fetch.mock.calls[0];
expect(calledUrl).toMatch(/^https:\/\/r\.example\.com\/api\/site\/rsite-789\/breakdown\?dimension=device&start=.*&end=.*$/);
expect(init.headers.Authorization).toBe('Bearer rkey');
expect(init.method).toBe('GET');
});
test('URL-encodes the websiteId for reserved chars', async () => {
mockJson([{ device: 'desktop', sessions: 1 }]);
await buildAdapter({ ...valid, websiteId: 'a/b?c' }).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/site/a%2Fb%3Fc/breakdown');
});
test('normalises a typical {device, sessions} payload into percentages', async () => {
mockJson([
{ device: 'desktop', sessions: 60 },
{ device: 'mobile', sessions: 30 },
{ device: 'tablet', sessions: 10 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('accepts the {data: [...]} envelope variant', async () => {
mockJson({ data: [
{ device: 'desktop', sessions: 1 },
{ device: 'mobile', sessions: 3 },
] });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 25, mobile: 75, tablet: 0 });
});
test('falls back to `visitors` when `sessions` is absent', async () => {
mockJson([
{ device: 'desktop', visitors: 80 },
{ device: 'mobile', visitors: 20 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('tolerates a `dimension` key as the bucket label', async () => {
mockJson([
{ dimension: 'desktop', sessions: 50 },
{ dimension: 'mobile', sessions: 50 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 50, mobile: 50, tablet: 0 });
});
test('drops unknown buckets', async () => {
mockJson([
{ device: 'desktop', sessions: 80 },
{ device: 'mobile', sessions: 20 },
{ device: 'fridge', sessions: 100 },
]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 }))
.toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload, non-2xx, invalid JSON, and network error', async () => {
mockJson([]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
mockJson({ error: 'unauthorized' }, { status: 401 });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
global.fetch = jest.fn(async () => ({
ok: true, status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
});
@@ -0,0 +1,91 @@
/**
* Factory tests for the pluggable-tracker registry (#663 Phase 1).
*
* Pins the contract that drives `adminDashboard.js` analytics route:
* - Returns null for 'none' / 'custom' / unset route falls back to access_logs.
* - Returns an Umami adapter shape for provider='umami'.
* - Returns a Rybbit adapter shape for provider='rybbit'.
* - Back-compat: when `analytics_tracker_provider` is unset, infers
* 'umami' from the legacy `analytics_umami_enabled` flag.
* - Invalid provider strings fall through to the legacy back-compat path
* rather than crashing (defensive).
*/
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-tracker-fact-')), 'db.sqlite',
);
const { bootCrmDb } = require('../integration/helpers/crmDb');
const trackers = require('../../src/services/trackers');
let db; let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 30000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('app_settings').del();
});
async function setSetting(key, value) {
await db('app_settings').insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'analytics',
updated_at: new Date(),
});
}
describe('resolveAdapter (#663)', () => {
test('returns null when provider=\'none\'', async () => {
await setSetting('analytics_tracker_provider', 'none');
expect(await trackers.resolveAdapter()).toBeNull();
});
test('returns null when provider=\'custom\' (no metrics adapter, just a script slot)', async () => {
await setSetting('analytics_tracker_provider', 'custom');
expect(await trackers.resolveAdapter()).toBeNull();
});
test('back-compat: provider unset + legacy umami_enabled=true → umami adapter', async () => {
await setSetting('analytics_umami_enabled', true);
await setSetting('analytics_umami_url', 'https://u.example');
await setSetting('analytics_umami_website_id', 'w-1');
await setSetting('analytics_umami_api_key', 'k-1');
const adapter = await trackers.resolveAdapter();
expect(adapter).not.toBeNull();
expect(adapter.provider).toBe('umami');
});
test('provider=\'umami\' explicit → umami adapter with stored secrets', async () => {
await setSetting('analytics_tracker_provider', 'umami');
await setSetting('analytics_umami_url', 'https://u.example');
await setSetting('analytics_umami_website_id', 'w-1');
await setSetting('analytics_umami_api_key', 'k-1');
const adapter = await trackers.resolveAdapter();
expect(adapter.provider).toBe('umami');
});
test('provider=\'rybbit\' → rybbit adapter with stored secrets', async () => {
await setSetting('analytics_tracker_provider', 'rybbit');
await setSetting('analytics_rybbit_url', 'https://r.example');
await setSetting('analytics_rybbit_website_id', 'r-1');
await setSetting('analytics_rybbit_api_key', 'rk-1');
const adapter = await trackers.resolveAdapter();
expect(adapter.provider).toBe('rybbit');
});
test('garbage provider value falls through to legacy back-compat (defensive)', async () => {
await setSetting('analytics_tracker_provider', 'plausible-not-yet-supported');
// No legacy umami_enabled → resolves to null (= 'none')
expect(await trackers.resolveAdapter()).toBeNull();
});
});
@@ -0,0 +1,112 @@
/**
* Adapter-style tests for the Umami metrics client (#663 Phase 1, replaces
* the old `umamiClient.test.js` from #662 same contract, new shape).
*
* Pins the same 10 cases that protected the original implementation: missing
* config / URL shape / encoding / payload normalisation / `laptop` mapping /
* unknown-bucket drop / empty / non-2xx / invalid JSON / network error.
*/
const { buildAdapter } = require('../../src/services/trackers/umamiAdapter');
const ORIGINAL_FETCH = global.fetch;
afterEach(() => {
global.fetch = ORIGINAL_FETCH;
});
function mockJson(body, { status = 200 } = {}) {
global.fetch = jest.fn(async () => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
}
const valid = { baseUrl: 'https://u.example.com', websiteId: 'site-123', apiKey: 'secret' };
describe('umamiAdapter.fetchDeviceBreakdown (#663)', () => {
test('returns null when config is incomplete (back-compat path)', async () => {
const a = buildAdapter({});
expect(await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
const b = buildAdapter({ baseUrl: 'https://u' });
expect(await b.fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
expect(global.fetch).toBe(ORIGINAL_FETCH);
});
test('builds the expected URL + sends `x-umami-api-key` header', async () => {
mockJson([{ x: 'desktop', y: 10 }]);
const a = buildAdapter({ ...valid, baseUrl: 'https://u.example.com/' });
await a.fetchDeviceBreakdown({ startMs: 1700000000000, endMs: 1700003600000 });
expect(global.fetch).toHaveBeenCalledTimes(1);
const [calledUrl, init] = global.fetch.mock.calls[0];
expect(calledUrl).toBe(
'https://u.example.com/api/websites/site-123/metrics?type=device&startAt=1700000000000&endAt=1700003600000',
);
expect(init.headers['x-umami-api-key']).toBe('secret');
expect(init.method).toBe('GET');
});
test('URL-encodes the websiteId for reserved chars', async () => {
mockJson([{ x: 'desktop', y: 1 }]);
const a = buildAdapter({ baseUrl: 'https://u', websiteId: 'a/b?c', apiKey: 'k' });
await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
const [calledUrl] = global.fetch.mock.calls[0];
expect(calledUrl).toContain('/api/websites/a%2Fb%3Fc/metrics');
});
test('normalises { x, y } payload into integer percentages', async () => {
mockJson([
{ x: 'desktop', y: 60 },
{ x: 'mobile', y: 30 },
{ x: 'tablet', y: 10 },
]);
const a = buildAdapter(valid);
const out = await a.fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 60, mobile: 30, tablet: 10 });
});
test('maps `laptop` into `desktop` (matches our 3-bucket UI)', async () => {
mockJson([
{ x: 'desktop', y: 50 },
{ x: 'laptop', y: 20 },
{ x: 'mobile', y: 30 },
]);
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 70, mobile: 30, tablet: 0 });
});
test('drops unknown buckets (no silent miscategorisation)', async () => {
mockJson([
{ x: 'desktop', y: 80 },
{ x: 'mobile', y: 20 },
{ x: 'unknown-future-bucket', y: 100 },
]);
const out = await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 });
expect(out).toEqual({ desktop: 80, mobile: 20, tablet: 0 });
});
test('returns null on empty payload', async () => {
mockJson([]);
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on non-2xx', async () => {
mockJson({ error: 'unauthorized' }, { status: 401 });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on invalid JSON', async () => {
global.fetch = jest.fn(async () => ({
ok: true,
status: 200,
json: async () => { throw new SyntaxError('not json'); },
}));
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
test('returns null on network error', async () => {
global.fetch = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
expect(await buildAdapter(valid).fetchDeviceBreakdown({ startMs: 0, endMs: 0 })).toBeNull();
});
});
@@ -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,58 @@
/**
* Pins the date-merge fix in `adminDashboard.js` /analytics route
* (#661 Bug A). The merge previously failed on Postgres because pg's
* driver returns `DATE(timestamp)` as a JS Date object, while SQLite
* returns a string the old `dateObj.date === row.date` comparison
* was false on Postgres so chartData stayed all-zero even with traffic.
*
* We test the normalisation helper here in isolation. The route-level
* integration is covered by the existing dashboard route test.
*/
// The helper is internal to the route file; reimport via a small wrapper
// so we don't need to export everything publicly.
const path = require('path');
const fs = require('fs');
const ROUTE_SRC = fs.readFileSync(
path.join(__dirname, '../../src/routes/adminDashboard.js'),
'utf8',
);
// Tiny evaluator that grabs the normaliseDateKey function definition from
// the route source so the test pins the actual shipping implementation,
// not a copy.
function extractNormaliseDateKey() {
const match = ROUTE_SRC.match(/function normaliseDateKey\([\s\S]*?\n\}/);
if (!match) throw new Error('normaliseDateKey not found in adminDashboard.js');
// eslint-disable-next-line no-new-func
return new Function(`${match[0]}; return normaliseDateKey;`)();
}
const normaliseDateKey = extractNormaliseDateKey();
describe('analytics route — normaliseDateKey (#661 Bug A)', () => {
test('passes through a YYYY-MM-DD string unchanged', () => {
expect(normaliseDateKey('2026-06-22')).toBe('2026-06-22');
});
test('slices off a time component on a longer ISO string', () => {
expect(normaliseDateKey('2026-06-22T00:00:00.000Z')).toBe('2026-06-22');
});
test('normalises a JS Date object (Postgres pg-driver shape) to YYYY-MM-DD', () => {
const d = new Date('2026-06-22T12:34:56Z');
expect(normaliseDateKey(d)).toBe('2026-06-22');
});
test('returns null for null / undefined / empty', () => {
expect(normaliseDateKey(null)).toBeNull();
expect(normaliseDateKey(undefined)).toBeNull();
expect(normaliseDateKey('')).toBeNull();
});
test('coerces unexpected types via String() to avoid throwing', () => {
// We don't expect to receive a number from either driver, but the
// helper should not crash if it does — date merge will simply miss.
expect(normaliseDateKey(20260622)).toBe('20260622');
});
});
@@ -0,0 +1,240 @@
/**
* Unit tests for the per-guest favorite/like cap (#655).
*
* Pins the contract of `feedbackService.submitFeedback` around the cap:
* - null / 0 cap means unlimited (back-compat for installs that don't
* enable the feature).
* - At-cap ADD returns `{ limit_reached, limit, current_count }` rather
* than inserting the route layer translates that into the structured
* 403 the UI listens for.
* - Toggle-off (un-favoriting) is ALWAYS allowed, regardless of cap state.
* A guest at 10/10 can still free a slot.
* - Limit reduction (admin lowers 20 10 while a guest has 15 already)
* grandfathers existing rows new adds blocked, removals always allowed.
* - Caps are per-feedback-type: filling the favorite quota doesn't block
* likes on the same photo, and vice versa.
*/
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-feedback-limit-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-limit-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const feedbackService = require('../../src/services/feedbackService');
const EVENT_SLUG = 'cap-test-event';
const GUEST_A = 'guest-a-identifier';
const GUEST_B = 'guest-b-identifier';
let db;
let cleanup;
let eventId;
let photoIds;
async function setEventFeedbackSettings(overrides) {
const base = {
feedback_enabled: 1,
allow_ratings: 1,
allow_likes: 1,
allow_comments: 0,
allow_favorites: 1,
require_name_email: 0,
moderate_comments: 0,
show_feedback_to_guests: 1,
identity_mode: 'simple',
max_favorites_per_guest: null,
max_likes_per_guest: null,
...overrides,
};
const existing = await db('event_feedback_settings').where('event_id', eventId).first();
if (existing) {
await db('event_feedback_settings').where('event_id', eventId).update(base);
} else {
await db('event_feedback_settings').insert({
event_id: eventId,
...base,
created_at: new Date(),
updated_at: new Date(),
});
}
}
async function favorite(photoId, guestIdentifier = GUEST_A) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'favorite',
ip_address: '127.0.0.1',
user_agent: 'jest',
}, guestIdentifier);
}
async function like(photoId, guestIdentifier = GUEST_A) {
return feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like',
ip_address: '127.0.0.1',
user_agent: 'jest',
}, guestIdentifier);
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: EVENT_SLUG,
event_type: 'wedding',
event_name: 'Cap Test',
event_date: '2026-06-22',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${EVENT_SLUG}/share`,
share_token: 'cap-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
// Seed 15 photos so we can test caps comfortably up to that count.
photoIds = [];
for (let i = 1; i <= 15; i += 1) {
const r = await db('photos').insert({
event_id: eventId,
filename: `photo-${i}.jpg`,
path: `events/cap/${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(r[0]?.id ?? r[0]);
}
}, 30000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(async () => {
await db('photo_feedback').where('event_id', eventId).del();
});
describe('per-guest favorite cap (#655)', () => {
test('null cap = unlimited (back-compat for installs without #655)', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: null });
for (const id of photoIds.slice(0, 12)) {
const r = await favorite(id);
expect(r.limit_reached).toBeFalsy();
expect(r.created).toBe(true);
}
});
test('cap = 0 also = unlimited (UI convenience for "no limit")', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 0 });
for (const id of photoIds.slice(0, 12)) {
const r = await favorite(id);
expect(r.limit_reached).toBeFalsy();
}
});
test('cap = 10: favorites 1..10 succeed, 11 returns limit_reached', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
for (const id of photoIds.slice(0, 10)) {
const r = await favorite(id);
expect(r.created).toBe(true);
}
const r11 = await favorite(photoIds[10]);
expect(r11.limit_reached).toBe(true);
expect(r11.limit).toBe(10);
expect(r11.current_count).toBe(10);
expect(r11.feedback_type).toBe('favorite');
});
test('toggle-off at the cap frees a slot (un-favoriting always allowed)', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
for (const id of photoIds.slice(0, 5)) {
await favorite(id);
}
const blocked = await favorite(photoIds[5]);
expect(blocked.limit_reached).toBe(true);
// Un-favorite one — toggle off path returns { removed: true }
const removed = await favorite(photoIds[0]);
expect(removed.removed).toBe(true);
// Now the previously-blocked slot fits
const after = await favorite(photoIds[5]);
expect(after.created).toBe(true);
});
test('limit reduction grandfathers existing rows; new adds blocked', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 10 });
for (const id of photoIds.slice(0, 10)) {
await favorite(id);
}
// Admin lowers the cap to 5 while the guest already has 10
await setEventFeedbackSettings({ max_favorites_per_guest: 5 });
// Existing 10 stay
const count = await db('photo_feedback')
.where({ event_id: eventId, feedback_type: 'favorite', guest_identifier: GUEST_A })
.count('* as c').first();
expect(parseInt(count.c, 10)).toBe(10);
// New adds blocked
const blocked = await favorite(photoIds[10]);
expect(blocked.limit_reached).toBe(true);
expect(blocked.limit).toBe(5);
expect(blocked.current_count).toBe(10);
// Removals still allowed
const removed = await favorite(photoIds[0]);
expect(removed.removed).toBe(true);
});
test('cap is per-guest: guest B is unaffected by guest A hitting the cap', async () => {
await setEventFeedbackSettings({ max_favorites_per_guest: 3 });
for (const id of photoIds.slice(0, 3)) {
await favorite(id, GUEST_A);
}
expect((await favorite(photoIds[3], GUEST_A)).limit_reached).toBe(true);
// Guest B starts at 0
for (const id of photoIds.slice(0, 3)) {
const r = await favorite(id, GUEST_B);
expect(r.created).toBe(true);
}
expect((await favorite(photoIds[3], GUEST_B)).limit_reached).toBe(true);
});
});
describe('per-guest like cap (#655)', () => {
test('favorite cap does NOT block likes on the same photo (per-type)', async () => {
await setEventFeedbackSettings({
max_favorites_per_guest: 3,
max_likes_per_guest: null,
});
for (const id of photoIds.slice(0, 3)) {
await favorite(id);
}
expect((await favorite(photoIds[3])).limit_reached).toBe(true);
// Likes still unlimited
for (const id of photoIds.slice(0, 10)) {
const r = await like(id);
expect(r.created).toBe(true);
}
});
test('like cap returns LIKE_LIMIT_REACHED-shaped payload', async () => {
await setEventFeedbackSettings({ max_likes_per_guest: 2 });
await like(photoIds[0]);
await like(photoIds[1]);
const r = await like(photoIds[2]);
expect(r.limit_reached).toBe(true);
expect(r.feedback_type).toBe('like');
expect(r.limit).toBe(2);
expect(r.current_count).toBe(2);
});
});
@@ -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);
});
});
@@ -0,0 +1,113 @@
/**
* Tests for the SSRF guard in `networkValidation.js`.
*
* Regression coverage for GHSA-wmjx-pc37-272r the original `isPrivateIPv6`
* was a string-prefix check that missed NAT64 (`64:ff9b::/96` per RFC 6052,
* `64:ff9b:1::/48` per RFC 8215), so a webhook URL like
* `http://[64:ff9b:1::a9fe:a9fe]/` could reach 169.254.169.254 on instances
* with NAT64/DNS64 egress.
*/
const { validateExternalUrl, isPrivateIP } = require('../../src/utils/networkValidation');
describe('validateExternalUrl — NAT64 + embedded-IPv4 SSRF', () => {
describe('NAT64 well-known prefix (RFC 6052, 64:ff9b::/96)', () => {
test.each([
['http://[64:ff9b::a9fe:a9fe]/latest/meta-data/', 'AWS metadata via NAT64 hex'],
['http://[64:ff9b::169.254.169.254]/', 'AWS metadata via NAT64 mixed notation'],
['http://[64:ff9b::7f00:1]/', 'loopback via NAT64'],
['http://[64:ff9b::a00:1]/', '10.0.0.1 via NAT64'],
])('blocks %s (%s)', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('NAT64 local-use prefix (RFC 8215, 64:ff9b:1::/48)', () => {
test.each([
['http://[64:ff9b:1::a9fe:a9fe]/', 'AWS metadata via local-use NAT64'],
['http://[64:ff9b:1::169.254.169.254]/', 'AWS metadata via mixed notation'],
['http://[64:ff9b:1::7f00:1]/', 'loopback via local-use NAT64'],
['http://[64:ff9b:1:abcd::1]/', 'arbitrary host inside the /48'],
])('blocks %s (%s)', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('IPv4-mapped IPv6 (::ffff:0:0/96)', () => {
test.each([
'http://[::ffff:127.0.0.1]/',
'http://[::ffff:7f00:1]/',
'http://[::ffff:169.254.169.254]/',
'http://[::ffff:a9fe:a9fe]/',
'http://[::ffff:10.0.0.1]/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('deprecated IPv4-compatible IPv6 (::/96)', () => {
test('blocks ::127.0.0.1', () => {
expect(validateExternalUrl('http://[::127.0.0.1]/').valid).toBe(false);
});
test('blocks ::169.254.169.254', () => {
expect(validateExternalUrl('http://[::169.254.169.254]/').valid).toBe(false);
});
});
describe('existing IPv6 private-range coverage stays intact', () => {
test.each([
'http://[::1]/',
'http://[fc00::1]/',
'http://[fd12:3456:789a::1]/',
'http://[fe80::1]/',
'http://[feb0::1]/',
'http://[::]/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('public IPv6 hosts stay allowed', () => {
test.each([
'https://[2001:4860:4860::8888]/',
'https://[2606:4700:4700::1111]/',
'https://[2a00:1450:4001:830::200e]/',
])('allows %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(true);
});
});
describe('existing IPv4 private-range coverage stays intact', () => {
test.each([
'http://127.0.0.1/',
'http://10.0.0.1/',
'http://172.16.0.1/',
'http://192.168.0.1/',
'http://169.254.169.254/',
'http://0.0.0.0/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('blocked hostnames', () => {
test.each([
'http://localhost/',
'http://metadata.google.internal/',
])('blocks %s', (url) => {
expect(validateExternalUrl(url).valid).toBe(false);
});
});
describe('fail-closed parsing', () => {
test('isPrivateIP returns true for non-string', () => {
expect(isPrivateIP(null)).toBe(true);
expect(isPrivateIP(undefined)).toBe(true);
expect(isPrivateIP(42)).toBe(true);
});
test('invalid URLs are rejected', () => {
expect(validateExternalUrl('not a url').valid).toBe(false);
expect(validateExternalUrl('').valid).toBe(false);
});
});
});
+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([]);
});
});
@@ -0,0 +1,136 @@
/**
* Unit tests for the WhatsApp template-parameter selection (#647 follow-up).
*
* Pins:
* - parseTemplateParams sanitizes unknown / non-string / duplicate keys,
* and falls back to the default 5-slot shape on empty / malformed input.
* - buildComponents emits ONLY the listed slots, in the listed order, so
* a 2-parameter template (event_name + gallery_link) sends exactly 2
* positional values the reporter's exact case from issue #647.
* - The legacy 5-slot default still works unchanged for installs that
* haven't reconfigured.
*/
const {
buildComponents,
parseTemplateParams,
DEFAULT_TEMPLATE_PARAMS,
} = require('../../src/services/whatsappProcessor');
const baseData = {
customer_name: 'Aisha',
event_name: 'Wedding 2026',
gallery_link: 'https://picpeak.example/wedding-2026',
gallery_password: 'StrongPass!',
expiry_date: '2026-12-31T00:00:00Z',
};
describe('parseTemplateParams', () => {
test('returns the default 5-slot shape for empty / null / undefined input', () => {
expect(parseTemplateParams('')).toEqual(DEFAULT_TEMPLATE_PARAMS);
expect(parseTemplateParams(null)).toEqual(DEFAULT_TEMPLATE_PARAMS);
expect(parseTemplateParams(undefined)).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('returns the default shape for malformed JSON', () => {
expect(parseTemplateParams('{not json')).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('returns the default shape when JSON parses to a non-array', () => {
expect(parseTemplateParams('"event_name"')).toEqual(DEFAULT_TEMPLATE_PARAMS);
expect(parseTemplateParams('{"a":1}')).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('preserves the reporter\'s 2-slot shape', () => {
const out = parseTemplateParams(JSON.stringify(['event_name', 'gallery_link']));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('drops unknown slot keys', () => {
const out = parseTemplateParams(JSON.stringify([
'event_name', 'unknown_slot', 'gallery_link', '__proto__',
]));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('drops duplicate slot keys (first wins)', () => {
const out = parseTemplateParams(JSON.stringify([
'event_name', 'gallery_link', 'event_name',
]));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('drops non-string entries', () => {
const out = parseTemplateParams(JSON.stringify([
'event_name', 42, null, { a: 1 }, 'gallery_link',
]));
expect(out).toEqual(['event_name', 'gallery_link']);
});
test('falls back to default when every entry is invalid', () => {
const out = parseTemplateParams(JSON.stringify([
'unknown_a', 'unknown_b', null, 7,
]));
expect(out).toEqual(DEFAULT_TEMPLATE_PARAMS);
});
test('also accepts an already-parsed array (defensive)', () => {
const out = parseTemplateParams(['event_name', 'gallery_link']);
expect(out).toEqual(['event_name', 'gallery_link']);
});
});
describe('buildComponents', () => {
test('legacy default shape emits 5 positional values, gallery_ready order', () => {
const out = buildComponents(baseData, 'en_US');
expect(out).toHaveLength(5);
expect(out[0]).toBe('Aisha');
expect(out[1]).toBe('Wedding 2026');
expect(out[2]).toBe('https://picpeak.example/wedding-2026');
expect(out[3]).toBe('🔒 Password: StrongPass!');
// expiry date is locale-formatted but always non-empty for a valid date
expect(out[4]).toMatch(/\d{2}/);
});
test('reporter\'s 2-slot shape — event_name + gallery_link, in that order', () => {
const out = buildComponents(baseData, 'ar', ['event_name', 'gallery_link']);
expect(out).toEqual(['Wedding 2026', 'https://picpeak.example/wedding-2026']);
});
test('reorder: gallery_link first, event_name second', () => {
const out = buildComponents(baseData, 'en_US', ['gallery_link', 'event_name']);
expect(out).toEqual(['https://picpeak.example/wedding-2026', 'Wedding 2026']);
});
test('empty slot list emits an empty components array (admin opted into nothing)', () => {
const out = buildComponents(baseData, 'en_US', []);
expect(out).toEqual([]);
});
test('password_line uses the locale-specific label when included', () => {
const out = buildComponents(baseData, 'ar', ['password_line']);
expect(out).toEqual(['🔒 كلمة المرور: StrongPass!']);
});
test('password_line is empty when no real password is set', () => {
const out = buildComponents(
{ ...baseData, gallery_password: '' },
'en_US',
['password_line'],
);
expect(out).toEqual(['']);
});
test('password_line is empty for the "No password required" sentinel', () => {
const out = buildComponents(
{ ...baseData, gallery_password: 'No password required' },
'en_US',
['password_line'],
);
expect(out).toEqual(['']);
});
test('omits expiry_date when omitted from the slot list', () => {
const out = buildComponents(baseData, 'en_US', ['event_name']);
expect(out).toEqual(['Wedding 2026']);
});
});
+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,31 @@
/**
* Migration 137: WhatsApp template language (#647).
*
* Adds a `template_language` column to `whatsapp_configs` so admins can
* pin their Meta-approved template's language code (e.g. `ar`, `en_US`,
* `de_DE`) directly in Settings WhatsApp. Without this column the only
* resolution paths were per-message `data.language` (always null in our
* own callers) and `app_settings.general_default_language` both of
* which are tied to the *system* UI language, not the *template's* language
* registered with Meta. Reporter @Rekoo-PS hit this with an Arabic
* template against the test-send route.
*
* Additive + hasColumn-guarded. Empty string default means "fall through
* to general_default_language" preserves current behaviour for installs
* that don't set it.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('whatsapp_configs'))) return;
if (await knex.schema.hasColumn('whatsapp_configs', 'template_language')) return;
await knex.schema.alterTable('whatsapp_configs', (table) => {
table.string('template_language', 20).notNullable().defaultTo('');
});
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('whatsapp_configs'))) return;
if (!(await knex.schema.hasColumn('whatsapp_configs', 'template_language'))) return;
await knex.schema.alterTable('whatsapp_configs', (table) => {
table.dropColumn('template_language');
});
};
@@ -0,0 +1,42 @@
const { addColumnIfNotExists, createIndexIfNotExists } = require('../helpers');
// Live Slideshow ("Diashow") link for live events. A SECOND, token-only
// share surface that mirrors the client-access pattern (migration 074):
// a dedicated fullscreen kiosk URL `/gallery/:slug/show/:token` that
// auto-picks-up newly uploaded photos while it runs.
//
// Opt-in by design: `show_share_token` stays NULL until the admin clicks
// "Generate slideshow link", and the public `/show/` route 404s while it
// is null. The three settings columns drive the running projector and can
// be changed LIVE from the admin panel (a short settings poll on the show
// page picks them up within a few seconds) — they carry sensible defaults
// so an existing event is fully configured the moment a token is minted.
exports.up = async function up(knex) {
// Token IS the secret (no gallery password). Unique so a stray collision
// can never point two events at one link.
await addColumnIfNotExists(knex, 'events', 'show_share_token', (table) => {
table.string('show_share_token', 64).nullable().unique();
});
// Per-slide display time in ms (how long each photo stays on screen).
await addColumnIfNotExists(knex, 'events', 'show_interval_ms', (table) => {
table.integer('show_interval_ms').defaultTo(5000);
});
// Transition style between slides: crossfade | cut | slide | kenburns.
await addColumnIfNotExists(knex, 'events', 'show_transition', (table) => {
table.string('show_transition', 20).defaultTo('crossfade');
});
// Transition animation duration in ms (how fast the transition plays).
await addColumnIfNotExists(knex, 'events', 'show_transition_ms', (table) => {
table.integer('show_transition_ms').defaultTo(800);
});
// Lookup is always by token; index it for the public /show/ route.
await createIndexIfNotExists(knex, 'events', ['show_share_token'], 'idx_events_show_share_token');
};
exports.down = async function down() {
// Safe rollback - intentionally no-op to avoid data loss (mirrors 074).
};
@@ -0,0 +1,58 @@
const { addColumnIfNotExists } = require('../helpers');
// Live Slideshow styling (migration 138 follow-up):
// - a ZDF/ARD-ident-style watermark: a white, semi-transparent logo in a
// corner of the projected slideshow (sourced from the site branding logo
// or the event's own logo).
// - a color filter applied to every slide (none / b&w / sepia / warm / cool
// / vignette).
// - per-EVENT-TYPE slideshow presets: a JSON blob on event_types that new
// events of that type seed their slideshow settings from, so an admin sets
// "weddings fade slowly with our white logo, sepia" once.
//
// All opt-in: watermark defaults OFF, colorfilter defaults 'none', and the
// type preset is NULL until configured — existing events/types are unchanged.
exports.up = async function up(knex) {
// --- per-event live styling (seeded from the type preset on create) ---
// Tri-state: NULL = inherit the global default (app_settings
// slideshow_watermark_*), true/false = explicit per-event override.
await addColumnIfNotExists(knex, 'events', 'show_watermark', (table) => {
table.boolean('show_watermark').nullable();
});
// Which logo to overlay: 'logo' (light branding logo) | 'logo_dark' (dark-mode
// branding logo) | 'favicon' | 'event' (event hero logo).
await addColumnIfNotExists(knex, 'events', 'show_watermark_source', (table) => {
table.string('show_watermark_source', 20).defaultTo('logo');
});
// Corner: top-left | top-right | bottom-left | bottom-right.
await addColumnIfNotExists(knex, 'events', 'show_watermark_position', (table) => {
table.string('show_watermark_position', 20).defaultTo('bottom-right');
});
// 0-100; rendered semi-transparent like a TV station ident.
await addColumnIfNotExists(knex, 'events', 'show_watermark_opacity', (table) => {
table.integer('show_watermark_opacity').defaultTo(60);
});
// 'white' = recolor the logo white (for dark/transparent marks, the TV-ident
// look); 'original' = render as-is (for logos with their own filled box /
// colors, e.g. a boxed badge that would otherwise become a white blob).
await addColumnIfNotExists(knex, 'events', 'show_watermark_style', (table) => {
table.string('show_watermark_style', 20).defaultTo('white');
});
// none | bw | sepia | warm | cool | vignette.
await addColumnIfNotExists(knex, 'events', 'show_colorfilter', (table) => {
table.string('show_colorfilter', 20).defaultTo('none');
});
// --- per-event-type slideshow preset (JSON; null = no preset) ---
// Shape: { interval_ms, transition, transition_ms, watermark, watermark_source,
// watermark_position, watermark_opacity, colorfilter }. Single column
// keeps the type table tidy; the create-event path reads it and seeds the new
// event's show_* columns.
await addColumnIfNotExists(knex, 'event_types', 'slideshow_preset', (table) => {
table.text('slideshow_preset').nullable();
});
};
exports.down = async function down() {
// Safe rollback - intentionally no-op to avoid data loss (mirrors 074/138).
};
@@ -0,0 +1,40 @@
/**
* Migration 140: WhatsApp template parameter selection (#647 follow-up).
*
* Adds a `template_params` column to `whatsapp_configs` that stores an
* ordered JSON array of slot keys naming which built-in values are sent
* as positional parameters to the configured Meta template (and in what
* order). Reporter @Rekoo-PS hit the gap that motivated this: their
* template body uses only `{{1}} = event_name` + `{{2}} = gallery_link`,
* but the hardcoded `buildComponents` shape always emitted 5 parameters
* matching `gallery_ready` so Meta rejected with a parameter-count
* mismatch even after the language fix landed (migration 137).
*
* Schema: TEXT column, empty/null means "fall back to the legacy 5-slot
* shape" so installs that haven't reconfigured continue to work without
* intervention. The processor's `buildComponents` reads this column,
* parses the array, and emits only the listed slots in the listed order.
*
* Known slot keys (any other keys are ignored): `customer_name`,
* `event_name`, `gallery_link`, `password_line`, `expiry_date`.
*
* Slot 140: lands after PR #649 (137 whatsapp_template_language) and
* PR #646 (138 slideshow_share, 139 slideshow_styling). Additive +
* `hasColumn`-guarded so re-running on an already-migrated DB is a
* safe no-op.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('whatsapp_configs'))) return;
if (await knex.schema.hasColumn('whatsapp_configs', 'template_params')) return;
await knex.schema.alterTable('whatsapp_configs', (table) => {
table.text('template_params').notNullable().defaultTo('');
});
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('whatsapp_configs'))) return;
if (!(await knex.schema.hasColumn('whatsapp_configs', 'template_params'))) return;
await knex.schema.alterTable('whatsapp_configs', (table) => {
table.dropColumn('template_params');
});
};
@@ -0,0 +1,43 @@
/**
* Migration 141: Per-guest favorite + like caps (#655).
*
* Reporter wants to cap how many photos a guest can favorite per event
* the classic photographer-culling workflow ("pick your top 10 for the
* album"). Currently the photographer has to enforce this with a verbal
* instruction; this column lets the gallery enforce it server-side so
* the 11th favorite click returns a clear "limit reached" response.
*
* Two columns, one per feedback type: favorites + likes. Both nullable +
* additive null/0 = unlimited, preserving current behaviour for every
* existing install with no operator action. The route layer enforces in
* `feedbackService.submitFeedback` (on the INSERT branch only, so a
* guest at the cap can still toggle off an existing favorite and free a
* slot). Limit *reduction* (e.g. admin lowers 20 10) grandfathers any
* over-cap rows already in place new adds blocked, removals always
* allowed to avoid surprising bulk-deletes on the admin save.
*
* Hooks into the existing per-event `event_feedback_settings` table
* alongside `allow_favorites` / `allow_likes`, so the admin surface is
* the same Event Feedback settings card.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('event_feedback_settings'))) return;
const hasFav = await knex.schema.hasColumn('event_feedback_settings', 'max_favorites_per_guest');
const hasLike = await knex.schema.hasColumn('event_feedback_settings', 'max_likes_per_guest');
if (hasFav && hasLike) return;
await knex.schema.alterTable('event_feedback_settings', (table) => {
if (!hasFav) table.integer('max_favorites_per_guest').nullable();
if (!hasLike) table.integer('max_likes_per_guest').nullable();
});
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('event_feedback_settings'))) return;
const hasFav = await knex.schema.hasColumn('event_feedback_settings', 'max_favorites_per_guest');
const hasLike = await knex.schema.hasColumn('event_feedback_settings', 'max_likes_per_guest');
if (!hasFav && !hasLike) return;
await knex.schema.alterTable('event_feedback_settings', (table) => {
if (hasFav) table.dropColumn('max_favorites_per_guest');
if (hasLike) table.dropColumn('max_likes_per_guest');
});
};
@@ -0,0 +1,213 @@
/**
* Migration 142: Workflow / automation engine schema + permissions.
*
* An admin-configurable visual flow engine (trigger conditions ordered
* steps with branching, loops, waits and approval gates). Strictly opt-in via
* the `workflows` feature flag (default off; no run is created/resumed while
* off). See docs / project_workflow_engine_requirements.
*
* Graph model (canvas, not a list):
* - workflows : one row per flow (name, enabled, current `version`,
* trigger_type + trigger_config). Built-ins (e.g. the
* dunning ladder) carry is_builtin + builtin_key.
* - workflow_nodes : nodes of a flow VERSION (node_key, type, config, x/y).
* - workflow_edges : edges of a flow VERSION (from_node[+handle] to_node).
* Versioned so in-flight runs keep executing the version they started on
* (editing bumps workflows.version and writes a fresh node/edge set).
* - workflow_runs : one execution (pinned version, entity, status,
* current_node, context JSON, wake_at for delays,
* dedup_key to prevent double-fire on re-tick).
* - workflow_run_steps: per-node audit trail (observability + System Health).
* - workflow_approvals: human gates token_hash for the email confirm/deny
* link (hashed at rest) + the webview inbox.
*
* Loose-FK integers (no DB-level FK) by design, matching whatsapp_queue /
* inbound_documents / expenses the service cascades child deletes in a
* transaction. Idempotent: every createTable is hasTable-guarded; the
* permission seed mirrors migration 123.
*/
const NEW_PERMISSIONS = [
{
name: 'workflows.view',
display_name: 'View Workflows',
category: 'workflows',
description: 'View automation workflows, their runs and pending approvals',
},
{
name: 'workflows.manage',
display_name: 'Manage Workflows',
category: 'workflows',
description: 'Create, edit, enable/disable workflows and act on approval gates',
},
];
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('workflows'))) {
await knex.schema.createTable('workflows', (table) => {
table.increments('id').primary();
table.string('name', 255).notNullable();
table.text('description');
table.boolean('enabled').notNullable().defaultTo(false);
// Current/latest graph version. Editing bumps this; runs pin the value
// they started on so an edit never rewrites a flow mid-run.
table.integer('version').notNullable().defaultTo(1);
table.string('trigger_type', 64).notNullable();
table.json('trigger_config');
// Seeded built-ins (e.g. the converted reminder ladder) are flagged so a
// boot self-heal can find/upsert them by a stable key.
table.boolean('is_builtin').notNullable().defaultTo(false);
table.string('builtin_key', 64);
table.integer('created_by').unsigned();
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
table.index(['enabled', 'trigger_type'], 'workflows_trigger_index');
table.index(['builtin_key']);
});
}
if (!(await knex.schema.hasTable('workflow_nodes'))) {
await knex.schema.createTable('workflow_nodes', (table) => {
table.increments('id').primary();
table.integer('workflow_id').unsigned().notNullable();
table.integer('version').notNullable().defaultTo(1);
// Stable id within the graph (edges + runs.current_node reference it).
table.string('node_key', 64).notNullable();
// trigger | condition | branch | loop | wait | action | gate | webhook
table.string('type', 32).notNullable();
table.json('config');
table.integer('pos_x').notNullable().defaultTo(0);
table.integer('pos_y').notNullable().defaultTo(0);
table.unique(['workflow_id', 'version', 'node_key'], 'workflow_nodes_key_unique');
table.index(['workflow_id', 'version'], 'workflow_nodes_graph_index');
});
}
if (!(await knex.schema.hasTable('workflow_edges'))) {
await knex.schema.createTable('workflow_edges', (table) => {
table.increments('id').primary();
table.integer('workflow_id').unsigned().notNullable();
table.integer('version').notNullable().defaultTo(1);
table.string('from_node', 64).notNullable();
// Output handle for multi-path nodes (yes/no, confirm/deny, ≥max/continue).
table.string('from_handle', 32);
table.string('to_node', 64).notNullable();
table.string('label', 64);
// True for the loop-back edge so the canvas can render it distinctly.
table.boolean('loop_back').notNullable().defaultTo(false);
table.index(['workflow_id', 'version'], 'workflow_edges_graph_index');
});
}
if (!(await knex.schema.hasTable('workflow_runs'))) {
await knex.schema.createTable('workflow_runs', (table) => {
table.increments('id').primary();
table.integer('workflow_id').unsigned().notNullable();
// Pinned graph version this run executes.
table.integer('version').notNullable();
table.string('trigger_event', 64).notNullable();
table.string('entity_type', 64);
table.integer('entity_id').unsigned();
// pending | running | waiting | done | failed | cancelled
table.string('status', 20).notNullable().defaultTo('pending');
table.string('current_node', 64);
table.json('context');
// Idempotency: prevents a re-emitted/re-ticked trigger from double-firing.
table.string('dedup_key', 191).unique();
// When a waiting run (delay or gate timeout) should be resumed by the
// scheduler. NULL while running/done.
table.timestamp('wake_at');
table.timestamp('started_at').defaultTo(knex.fn.now());
table.timestamp('finished_at');
table.text('error');
// Scheduler poll path: waiting runs whose wake_at has passed.
table.index(['status', 'wake_at'], 'workflow_runs_wake_index');
table.index(['entity_type', 'entity_id'], 'workflow_runs_entity_index');
table.index(['workflow_id']);
});
}
if (!(await knex.schema.hasTable('workflow_run_steps'))) {
await knex.schema.createTable('workflow_run_steps', (table) => {
table.increments('id').primary();
table.integer('run_id').unsigned().notNullable();
table.string('node_key', 64).notNullable();
table.string('node_type', 32);
// done | failed | skipped | waiting
table.string('status', 20).notNullable().defaultTo('pending');
table.json('result');
table.text('error');
table.timestamp('started_at').defaultTo(knex.fn.now());
table.timestamp('finished_at');
table.index(['run_id'], 'workflow_run_steps_run_index');
});
}
if (!(await knex.schema.hasTable('workflow_approvals'))) {
await knex.schema.createTable('workflow_approvals', (table) => {
table.increments('id').primary();
table.integer('run_id').unsigned().notNullable();
table.string('node_key', 64).notNullable();
table.string('type', 32).notNullable().defaultTo('payment_confirm');
// pending | confirmed | denied | expired
table.string('status', 20).notNullable().defaultTo('pending');
// SHA-256 hex of the single-use email confirm/deny token (hash-on-store).
table.string('token_hash', 128).notNullable();
table.json('payload');
table.timestamp('expires_at');
table.integer('acted_by').unsigned();
table.string('acted_via', 16);
table.timestamp('acted_at');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.unique(['token_hash'], 'workflow_approvals_token_unique');
table.index(['status'], 'workflow_approvals_status_index');
table.index(['run_id']);
});
}
// --- Permissions (idempotent, mirrors migration 123) ---
if (await knex.schema.hasTable('permissions')) {
const names = NEW_PERMISSIONS.map((p) => p.name);
const existing = await knex('permissions').whereIn('name', names).select('name');
const existingSet = new Set(existing.map((r) => r.name));
const toInsert = NEW_PERMISSIONS.filter((p) => !existingSet.has(p.name));
if (toInsert.length > 0) await knex('permissions').insert(toInsert);
if ((await knex.schema.hasTable('roles')) && (await knex.schema.hasTable('role_permissions'))) {
const roles = await knex('roles').whereIn('name', ['super_admin', 'admin']).select('id');
const perms = await knex('permissions').whereIn('name', names).select('id');
if (roles.length && perms.length) {
const existingGrants = await knex('role_permissions')
.whereIn('role_id', roles.map((r) => r.id))
.whereIn('permission_id', perms.map((p) => p.id))
.select('role_id', 'permission_id');
const grantSet = new Set(existingGrants.map((g) => `${g.role_id}:${g.permission_id}`));
const toGrant = [];
for (const r of roles) {
for (const p of perms) {
if (!grantSet.has(`${r.id}:${p.id}`)) {
toGrant.push({ role_id: r.id, permission_id: p.id });
}
}
}
if (toGrant.length > 0) await knex('role_permissions').insert(toGrant);
}
}
}
};
exports.down = async function (knex) {
if (await knex.schema.hasTable('permissions')) {
const names = NEW_PERMISSIONS.map((p) => p.name);
const perms = await knex('permissions').whereIn('name', names).select('id');
if (perms.length && (await knex.schema.hasTable('role_permissions'))) {
await knex('role_permissions').whereIn('permission_id', perms.map((p) => p.id)).del();
}
await knex('permissions').whereIn('name', names).del();
}
await knex.schema.dropTableIfExists('workflow_approvals');
await knex.schema.dropTableIfExists('workflow_run_steps');
await knex.schema.dropTableIfExists('workflow_runs');
await knex.schema.dropTableIfExists('workflow_edges');
await knex.schema.dropTableIfExists('workflow_nodes');
await knex.schema.dropTableIfExists('workflows');
};
@@ -0,0 +1,38 @@
/**
* Migration 143: late-fee (Mahngebühr) type flat amount OR percentage.
*
* Extends the existing flat `crm_invoices_late_fee_minor` with a type switch so
* the dunning fee can be a percentage of the invoice gross instead of a fixed
* amount. The fee is charged from the 2nd reminder onwards (the 1st is
* fee-free), accumulating per fee-bearing reminder (2nd = 1×, 3rd = 2×).
*
* Seeds conservative defaults that PRESERVE current behaviour: type='flat'
* (so the existing flat fee keeps applying) and percent=0. Idempotent
* only inserts keys that don't already exist, never clobbers an admin value.
*
* A late fee is only legally enforceable if the concrete amount is stated in
* the AGB (Liechtenstein/Swiss law) the admin UI surfaces this; verify with a
* Treuhänder. See docs/crm-disclaimers / [[feedback_legal_financial_examples_only]].
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
const seeds = [
{ setting_key: 'crm_invoices_late_fee_type', setting_value: JSON.stringify('flat'), setting_type: 'crm' },
{ setting_key: 'crm_invoices_late_fee_percent', setting_value: JSON.stringify(0), setting_type: 'crm' },
// VAT on the late fee is jurisdiction-dependent (CH: yes; DE/AT: no), so it's
// a toggle. Default OFF (preserve current no-VAT behaviour). No-op anyway
// when the org doesn't charge VAT (business_profile.vat_rate_default = 0).
{ setting_key: 'crm_invoices_late_fee_vat_enabled', setting_value: JSON.stringify(false), setting_type: 'crm' },
];
for (const s of seeds) {
const exists = await knex('app_settings').where({ setting_key: s.setting_key }).first();
if (!exists) await knex('app_settings').insert({ ...s, updated_at: new Date() });
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
await knex('app_settings')
.whereIn('setting_key', ['crm_invoices_late_fee_type', 'crm_invoices_late_fee_percent', 'crm_invoices_late_fee_vat_enabled'])
.del();
};
@@ -0,0 +1,24 @@
/**
* Migration 144: track the VAT portion of the Mahngebühr separately.
*
* The dunning rework keeps the fee on the invoice ROW as dunning state (gross
* in late_fee_amount_minor) but renders it on a separate Mahnung document, NOT
* on the immutable invoice. `late_fee_vat_minor` records the VAT component
* (0 when VAT-exempt DE/AT, or the org has no VAT) so the Mahnung can show
* the breakdown and the tax report can later book the Mahngebühr VAT (CH).
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('invoices'))) return;
if (!(await knex.schema.hasColumn('invoices', 'late_fee_vat_minor'))) {
await knex.schema.alterTable('invoices', (t) => {
t.bigInteger('late_fee_vat_minor').notNullable().defaultTo(0);
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('invoices'))) return;
if (await knex.schema.hasColumn('invoices', 'late_fee_vat_minor')) {
await knex.schema.alterTable('invoices', (t) => t.dropColumn('late_fee_vat_minor'));
}
};
@@ -0,0 +1,33 @@
/**
* Migration 145: crash-recovery fields for workflow runs.
*
* A run left in 'running'/'pending' by a crash has nothing to resume it (the
* scheduler only wakes 'waiting' runs). Add a heartbeat (`updated_at`, stamped
* on every step) so a recovery sweep can detect stale runs, plus an `attempts`
* counter so a node that reliably crashes the process can't be recovered
* forever (crash-loop backstop marked failed after a cap).
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('workflow_runs'))) return;
const hasUpdated = await knex.schema.hasColumn('workflow_runs', 'updated_at');
const hasAttempts = await knex.schema.hasColumn('workflow_runs', 'attempts');
await knex.schema.alterTable('workflow_runs', (t) => {
if (!hasUpdated) t.timestamp('updated_at').defaultTo(knex.fn.now());
if (!hasAttempts) t.integer('attempts').notNullable().defaultTo(0);
});
// Recovery sweep queries by (status, updated_at).
if (!hasUpdated) {
try { await knex.schema.alterTable('workflow_runs', (t) => t.index(['status', 'updated_at'], 'workflow_runs_recovery_index')); } catch (_) {}
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('workflow_runs'))) return;
try { await knex.schema.alterTable('workflow_runs', (t) => t.dropIndex(['status', 'updated_at'], 'workflow_runs_recovery_index')); } catch (_) {}
if (await knex.schema.hasColumn('workflow_runs', 'updated_at')) {
await knex.schema.alterTable('workflow_runs', (t) => t.dropColumn('updated_at'));
}
if (await knex.schema.hasColumn('workflow_runs', 'attempts')) {
await knex.schema.alterTable('workflow_runs', (t) => t.dropColumn('attempts'));
}
};
@@ -0,0 +1,25 @@
/**
* Migration 146: carry an event type on the quote.
*
* Quotes already snapshot event_name + event_date, but not the TYPE. Without it
* the quoteevent conversion (convertToEvent) had to hardcode 'wedding'. This
* column lets the admin pick the type on the quote (from the event_types
* catalog, stored as its slug_prefix same shape as events.event_type), so the
* conversion / booking flow's prepare_event can carry it through. Nullable: old
* quotes and the "didn't pick one" case fall back to a configurable default.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (!(await knex.schema.hasColumn('quotes', 'event_type'))) {
await knex.schema.alterTable('quotes', (t) => {
t.string('event_type', 64);
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (await knex.schema.hasColumn('quotes', 'event_type')) {
await knex.schema.alterTable('quotes', (t) => t.dropColumn('event_type'));
}
};
@@ -0,0 +1,25 @@
/**
* Migration 147: let a quote pick the booking workflow it runs on acceptance.
*
* Today quote.accepted fans out to every enabled flow with that trigger. This
* column lets the admin choose ONE workflow per quote (e.g. "with contract" vs
* "invoice only, no gallery"); emitQuoteEvent passes it as targetWorkflowId so
* only the chosen flow runs. Plain nullable integer (not a hard FK) the emit
* re-checks the workflow exists + is enabled + matches the trigger at fire time,
* so a deleted/disabled selection just runs nothing.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (!(await knex.schema.hasColumn('quotes', 'booking_workflow_id'))) {
await knex.schema.alterTable('quotes', (t) => {
t.integer('booking_workflow_id');
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (await knex.schema.hasColumn('quotes', 'booking_workflow_id')) {
await knex.schema.alterTable('quotes', (t) => t.dropColumn('booking_workflow_id'));
}
};
@@ -0,0 +1,24 @@
/**
* Migration 148: mark when an admin has taken ownership of a (built-in) workflow.
*
* The boot seeder re-seeds a built-in on a SEED_VERSION bump and applies the new
* default `enabled` state. Without a sentinel that would re-flip a flow the
* admin had deliberately enabled/disabled. `admin_toggled_at` is stamped on any
* admin enable/disable or edit; the seeder then leaves that flow alone. Nullable
* existing rows are treated as never-touched (seed defaults apply once).
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('workflows'))) return;
if (!(await knex.schema.hasColumn('workflows', 'admin_toggled_at'))) {
await knex.schema.alterTable('workflows', (t) => {
t.timestamp('admin_toggled_at');
});
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('workflows'))) return;
if (await knex.schema.hasColumn('workflows', 'admin_toggled_at')) {
await knex.schema.alterTable('workflows', (t) => t.dropColumn('admin_toggled_at'));
}
};
@@ -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');
}
};
+178 -157
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": {
@@ -4305,9 +4310,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -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",
@@ -8964,15 +8991,6 @@
"node": ">=6.0.0"
}
},
"node_modules/node-cron/node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -9050,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",
@@ -9070,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"
@@ -9832,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",
@@ -10259,9 +10280,9 @@
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
@@ -11531,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",
+18 -14
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.66.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,16 +75,18 @@
"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.14.2",
"tar": ">=7.5.13",
"brace-expansion": ">=5.0.5",
"qs": ">=6.15.2",
"tar": ">=7.5.16",
"brace-expansion": ">=5.0.6",
"minimatch": ">=9.0.7",
"path-to-regexp": "0.1.13",
"lodash": ">=4.18.1",
"follow-redirects": ">=1.16.0",
"@tootallnate/once": ">=3.0.1",
"ip-address": ">=10.1.1"
"ip-address": ">=10.1.1",
"uuid": "^11.1.1",
"nodemailer": "^9.0.1"
}
}
+109 -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'));
@@ -707,6 +777,7 @@ app.use('/api/admin/contracts', require('./src/routes/adminContracts'));
app.use('/api/admin/projects', require('./src/routes/adminProjects'));
app.use('/api/admin/calendar', require('./src/routes/adminCalendar'));
app.use('/api/admin/deals', require('./src/routes/adminDeals'));
app.use('/api/admin/workflows', require('./src/routes/adminWorkflows'));
app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport'));
app.use('/api/admin/expenses', require('./src/routes/adminExpenses'));
app.use('/api/admin/ledger', require('./src/routes/adminLedger'));
@@ -718,6 +789,7 @@ app.use('/api/admin/dev', require('./src/routes/adminDev'));
app.use('/api/public/quotes', require('./src/routes/publicQuotes'));
app.use('/api/public/contracts', require('./src/routes/publicContracts'));
app.use('/api/public/payment-check', require('./src/routes/publicPaymentCheck'));
app.use('/api/public/workflow-approvals', require('./src/routes/publicWorkflowApprovals'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
@@ -767,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);
@@ -895,6 +976,15 @@ async function startServer() {
logger.warn('restore-settings self-heal failed at boot:', err.message);
}
// Seed built-in workflows (the editable invoice-dunning flow). Disabled by
// default — live reminder behaviour is unchanged. See _workflowSeedBoot.js.
try {
const { seedBuiltinWorkflowsAtBoot } = require('./src/services/_workflowSeedBoot');
await seedBuiltinWorkflowsAtBoot(db, logger);
} catch (err) {
logger.warn('built-in workflow seed failed at boot:', err.message);
}
// Install-from-backup trigger. If `RESTORE_ON_INSTALL` (or
// `.txt`) exists in the /backup mount AND the DB is empty, run
// the restore HERE before any admin UI surfaces. Lets admins
@@ -913,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();
@@ -928,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,
+18
View File
@@ -171,7 +171,25 @@ async function verifyGalleryAccess(req, res, next) {
}
}
/**
* Deny a slideshow-scoped JWT. The Live Slideshow token (accessLevel
* 'slideshow') is reused as a `type:'gallery'` token so it can read photos for
* the kiosk, which means every verifyGalleryAccess-protected route would
* otherwise accept it. A projector URL is meant to be display-only and is
* comparatively easy to leak (browser history, venue laptop, USB), so this
* gate is placed AFTER verifyGalleryAccess on the write/bulk-download routes to
* keep a leaked slideshow link from downloading, uploading, or posting
* feedback. (#646 review)
*/
function denySlideshowToken(req, res, next) {
if (req.accessLevel === 'slideshow') {
return res.status(403).json({ error: 'Slideshow tokens are display-only' });
}
next();
}
module.exports = {
verifyGalleryAccess,
denySlideshowToken,
isAdminPreview
};
+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' });
}
});
+104 -48
View File
@@ -4,8 +4,26 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
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();
/**
* Normalise a value coming back from `DATE(timestamp)` into a YYYY-MM-DD
* string. SQLite returns this column as a string already; Postgres' pg
* driver auto-converts it to a JavaScript Date object, which broke the
* old `dateObj.date === row.date` merge below every Postgres install saw
* an all-zero `chartData[]` even with real traffic (#661 Bug A). Always
* normalise before comparing.
*/
function normaliseDateKey(value) {
if (!value) return null;
if (value instanceof Date) return value.toISOString().slice(0, 10);
// Strings might arrive with time component, slice defensively.
return String(value).slice(0, 10);
}
// Get dashboard statistics
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
@@ -109,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')
@@ -138,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 {};
}
})(),
@@ -147,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');
}
});
@@ -216,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'
@@ -265,20 +281,25 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Merge data into dates array
// Merge data into dates array. row.date is normalised because Postgres
// returns DATE() as a JS Date while SQLite returns a string (#661 Bug A).
// Counts come back as strings on Postgres too, so coerce via Number.
viewsData.forEach(row => {
const dateObj = dates.find(d => d.date === row.date);
if (dateObj) dateObj.views = row.count;
const key = normaliseDateKey(row.date);
const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.views = Number(row.count) || 0;
});
downloadsData.forEach(row => {
const dateObj = dates.find(d => d.date === row.date);
if (dateObj) dateObj.downloads = row.count;
const key = normaliseDateKey(row.date);
const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.downloads = Number(row.count) || 0;
});
visitorsData.forEach(row => {
const dateObj = dates.find(d => d.date === row.date);
if (dateObj) dateObj.uniqueVisitors = row.count;
const key = normaliseDateKey(row.date);
const dateObj = dates.find(d => d.date === key);
if (dateObj) dateObj.uniqueVisitors = Number(row.count) || 0;
});
// Get top galleries by views with additional metrics
@@ -293,31 +314,60 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
.orderBy('views', 'desc')
.limit(5);
// Get device breakdown (simplified - based on user agent)
const deviceData = await db('access_logs')
.select(
db.raw(`
CASE
WHEN user_agent LIKE '%Mobile%' THEN 'mobile'
WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet'
ELSE 'desktop'
END as device_type
`),
db.raw('COUNT(*) as count')
)
.where('timestamp', '>=', startDateStr)
.groupBy('device_type');
// Device breakdown — prefer the operator's analytics tracker (Umami /
// Rybbit) when configured (#661 Bug C + #663 Phase 1). The local
// access_logs heuristic below produces 0% on installs where guest user
// agents don't reliably contain "Mobile" / "Tablet" tokens; the tracker
// adapters track devices natively. Falls back to access_logs when no
// tracker is configured (provider=none/custom), the upstream call fails,
// or the response shape doesn't match what we expect.
let devices = { desktop: 0, mobile: 0, tablet: 0 };
let devicesSource = 'access_logs';
const totalDevices = deviceData.reduce((sum, d) => sum + d.count, 0);
const devices = {
desktop: 0,
mobile: 0,
tablet: 0
};
const adapter = await resolveAdapter();
if (adapter) {
try {
const trackerDevices = await adapter.fetchDeviceBreakdown({
startMs: startDate.getTime(),
endMs: Date.now(),
});
if (trackerDevices) {
devices = trackerDevices;
devicesSource = adapter.provider;
}
} catch (err) {
logger.warn(`Analytics: ${adapter.provider} device-breakdown fetch failed; falling back to access_logs`, {
error: err.message,
});
}
}
deviceData.forEach(d => {
devices[d.device_type] = Math.round((d.count / totalDevices) * 100);
});
if (devicesSource === 'access_logs') {
// Local heuristic on access_logs user_agent. Coarse — `LIKE` doesn't
// cover every UA shape (some Android browsers, embedded webviews, etc.)
// — and counts come back as strings on Postgres, hence Number() below.
const deviceData = await db('access_logs')
.select(
db.raw(`
CASE
WHEN user_agent LIKE '%Mobile%' THEN 'mobile'
WHEN user_agent LIKE '%Tablet%' OR user_agent LIKE '%iPad%' THEN 'tablet'
ELSE 'desktop'
END as device_type
`),
db.raw('COUNT(*) as count')
)
.where('timestamp', '>=', startDateStr)
.whereNotNull('user_agent')
.groupBy('device_type');
const totalDevices = deviceData.reduce((sum, d) => sum + (Number(d.count) || 0), 0);
if (totalDevices > 0) {
deviceData.forEach(d => {
devices[d.device_type] = Math.round(((Number(d.count) || 0) / totalDevices) * 100);
});
}
}
// Calculate totals for the period (matching /stats logic)
const totalViews = await db('access_logs')
@@ -341,6 +391,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
chartData: dates,
topGalleries,
devices,
devicesSource,
totals: {
views: totalViews?.count || 0,
downloads: totalDownloadsCount?.count || 0,
@@ -348,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');
}
});
@@ -387,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 };
@@ -407,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;
@@ -452,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
@@ -516,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,
@@ -527,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');
}
});
};
+11
View File
@@ -84,6 +84,15 @@ const KNOWN_FLAGS = [
// opt-in — operators must register a Meta-approved template before turning
// it on. Independent of email; both can fire on the same event.
'whatsapp',
// Live Slideshow ("Diashow") — the per-event fullscreen kiosk link + its
// per-event-type presets and global watermark defaults tab. Strictly opt-in;
// gates all slideshow admin UI (per-event card, type preset, settings tab).
'slideshow',
// Workflow / automation engine — admin-configurable visual flows (triggers,
// conditions, branches, loops, approval gates). Strictly opt-in; master
// kill-switch for the Workflows admin area AND the engine's runtime side
// effects (no run is created/resumed while off).
'workflows',
];
// Spec defaults for any flag missing from the DB (e.g. a row added by a
@@ -112,6 +121,8 @@ const DEFAULT_FLAGS = {
expenses: false,
projects: false,
whatsapp: false,
slideshow: false,
workflows: false,
};
async function readAllFlags() {
+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' });
}
});
+10 -8
View File
@@ -9,8 +9,11 @@ const { body, query, validationResult } = require('express-validator');
const { db, withRetry } = require('../database/db');
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();
@@ -18,7 +21,7 @@ const exportService = new PhotoExportService();
* GET /admin/photos/:eventId/filtered
* Get filtered photos with pagination
*/
router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), requireEventOwnership, [
query('min_rating').optional().isFloat({ min: 0, max: 5 }),
query('max_rating').optional().isFloat({ min: 0, max: 5 }),
query('has_likes').optional().isBoolean(),
@@ -65,8 +68,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
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(
@@ -123,7 +125,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
}
});
} catch (error) {
console.error('Filter photos error:', error);
logger.error('Filter photos error:', error);
res.status(500).json({ error: 'Failed to filter photos' });
}
});
@@ -132,7 +134,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
* GET /admin/photos/:eventId/filter-summary
* Get just the summary counts for filter UI
*/
router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view'), async (req, res) => {
router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId);
@@ -145,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' });
}
});
@@ -154,7 +156,7 @@ router.get('/:eventId/filter-summary', adminAuth, requirePermission('photos.view
* POST /admin/photos/:eventId/export
* Export selected or filtered photos
*/
router.post('/:eventId/export', adminAuth, requirePermission('photos.download'), [
router.post('/:eventId/export', adminAuth, requirePermission('photos.download'), requireEventOwnership, [
body('photo_ids').optional().isArray(),
body('photo_ids.*').optional().isInt(),
body('filter').optional().isObject(),
@@ -205,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');
}
});
+4 -1
View File
@@ -86,6 +86,8 @@ function transformQuote(q) {
validUntil: q.valid_until,
eventName: q.event_name,
eventDate: q.event_date,
eventType: q.event_type ?? null,
bookingWorkflowId: q.booking_workflow_id ?? null,
eventTimeStart: q.event_time_start,
eventTimeEnd: q.event_time_end,
expectedDurationHours: q.expected_duration_hours == null ? null : Number(q.expected_duration_hours),
@@ -212,7 +214,8 @@ function mapPayloadToService(body) {
customerAccountId: 'customerAccountId',
language: 'language', currency: 'currency',
issueDate: 'issueDate', validUntil: 'validUntil',
eventName: 'eventName', eventDate: 'eventDate',
eventName: 'eventName', eventDate: 'eventDate', eventType: 'eventType',
bookingWorkflowId: 'bookingWorkflowId',
eventTimeStart: 'eventTimeStart', eventTimeEnd: 'eventTimeEnd',
expectedDurationHours: 'expectedDurationHours',
paymentTermTemplateId: 'paymentTermTemplateId',
+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 = {

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