Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e94e440858 |
+20
-8
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||
|
||||
buy_me_a_coffee: theluap
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "3.68.0-beta.0"
|
||||
".": "3.82.4-beta.0"
|
||||
}
|
||||
|
||||
+304
@@ -5,6 +5,310 @@ 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.82.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.3-beta.0...v3.82.4-beta.0) (2026-07-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([0c2d319](https://github.com/PicPeak/picpeak/commit/0c2d319fc1ed67843cc60afdcaea5807ea49226f))
|
||||
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([fcc3e91](https://github.com/PicPeak/picpeak/commit/fcc3e9195d6f63b2dffddfa72a867a3e32325e81))
|
||||
* **email:** sibling billing emails follow customer language too ([c0008be](https://github.com/PicPeak/picpeak/commit/c0008be39bc8a9d354e48ce8d6bd89662bc53ebb))
|
||||
|
||||
## [3.82.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.2-beta.0...v3.82.3-beta.0) (2026-07-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([a88da99](https://github.com/PicPeak/picpeak/commit/a88da99c8d35c0c7cb7f96a235e984edad74ac7c))
|
||||
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([96fe478](https://github.com/PicPeak/picpeak/commit/96fe478bf87a3350185206b3d6f15133138b995d))
|
||||
* **branding:** unify hero logo SIZE the same way as visibility ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([60b03b1](https://github.com/PicPeak/picpeak/commit/60b03b17287539b3ad5e5d32f4eda8622f0575e4))
|
||||
|
||||
## [3.82.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.1-beta.0...v3.82.2-beta.0) (2026-07-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **og:** broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.) ([a0a28a4](https://github.com/PicPeak/picpeak/commit/a0a28a47777db9ca9e60a5134c8d86503c060e79))
|
||||
* **og:** route branded short URLs + slideshow links to OG, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([0dffe0c](https://github.com/PicPeak/picpeak/commit/0dffe0ce92339e0608b3ef660e84c31a62f4a98c))
|
||||
* **og:** route branded short URLs + slideshow to OG handler, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a87ad77](https://github.com/PicPeak/picpeak/commit/a87ad77d8d5215c88f5d95cc7aebaa1769938ec0))
|
||||
|
||||
## [3.82.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.0-beta.0...v3.82.1-beta.0) (2026-07-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **invoices:** correct payment-check email template key so dunning email sends ([9a76333](https://github.com/PicPeak/picpeak/commit/9a763337b658299aae0d7c985071c4a775000f99))
|
||||
* **invoices:** correct payment-check email template key so dunning email sends ([3682de1](https://github.com/PicPeak/picpeak/commit/3682de195b46eae692db3ff4a1476b00d3a6e216))
|
||||
|
||||
## [3.82.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.81.0-beta.0...v3.82.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **setup:** final community step ([#732](https://github.com/PicPeak/picpeak/issues/732)) + fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([a5f49e3](https://github.com/PicPeak/picpeak/commit/a5f49e32350564ee4d3894f33e9611e9244cc994))
|
||||
* **setup:** final community/thank-you step ([#732](https://github.com/PicPeak/picpeak/issues/732)); fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([dadaaee](https://github.com/PicPeak/picpeak/commit/dadaaeea7781cb62811256b512003e5c4d6ad95e))
|
||||
|
||||
## [3.81.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.80.0-beta.0...v3.81.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* admin two-factor authentication (TOTP) with recovery codes + CLI reset ([cf07361](https://github.com/PicPeak/picpeak/commit/cf073615effa8a91e19374ad3e9924e6e7322950))
|
||||
* **admin-ui:** TOTP MFA enrollment + two-step login; remove stub 2FA toggle ([96e3c68](https://github.com/PicPeak/picpeak/commit/96e3c68b9d6b35a82abcad664a6da7b19150b4fd))
|
||||
* **auth:** admin TOTP MFA — enrollment, login challenge, recovery, CLI reset ([72e2ef6](https://github.com/PicPeak/picpeak/commit/72e2ef6721b0572ed34455de901aa357eacd8c76))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* event creation 500s on PostgreSQL (NaN slideshow seed) + stray "0" boolean renders ([b187f58](https://github.com/PicPeak/picpeak/commit/b187f588b4d12af7a7849f8558c0085573d4af76))
|
||||
* **security:** close cross-event thumbnail leak, bulk-op ownership bypass, + hardening ([081f3ed](https://github.com/PicPeak/picpeak/commit/081f3edcdffc65a77000cc638e364ea9dc03767f))
|
||||
* **security:** cross-event thumbnail leak, bulk-op ownership bypass + auth hardening ([b732974](https://github.com/PicPeak/picpeak/commit/b732974779803b67097c81ae6bce2de0f2910794))
|
||||
|
||||
## [3.80.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.79.1-beta.0...v3.80.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **backup:** fold .picpeak restore into the Restore wizard's Upload source ([86324e7](https://github.com/PicPeak/picpeak/commit/86324e7da75069e61686b1b77495f02c33b12e1a))
|
||||
* first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip ([e513e83](https://github.com/PicPeak/picpeak/commit/e513e8345b73e37ebedc9c9ec09665ffc5773e23))
|
||||
* **setup:** add restore-from-backup branch to the first-run wizard ([a95ee47](https://github.com/PicPeak/picpeak/commit/a95ee473ae7fb2bf3c94c610724476701145d113))
|
||||
* **setup:** per-feature config step after feature selection ([07b450a](https://github.com/PicPeak/picpeak/commit/07b450a954a53781d23a71749552e4101c637777))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backup:** address .picpeak review — table filter, superuser guard, tests ([fa7665c](https://github.com/PicPeak/picpeak/commit/fa7665c5b1ad18a4db4f0b59eb4c197a3c9a36e2))
|
||||
* **setup:** keep the first-run wizard light regardless of dark mode ([d4b143f](https://github.com/PicPeak/picpeak/commit/d4b143f313d00f2a30abcbc1a880c140d7455e7b))
|
||||
|
||||
## [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/<slug> 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)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -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
@@ -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 4–6 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! 🎉
|
||||
@@ -1,5 +1,13 @@
|
||||
# 📸 PicPeak - Open Source Photo Sharing for Events
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **PicPeak has moved to its own GitHub organization.**
|
||||
>
|
||||
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
|
||||
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
|
||||
>
|
||||
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
|
||||
|
||||
@@ -83,21 +91,34 @@ Get PicPeak running in under 5 minutes:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
git clone https://github.com/PicPeak/picpeak.git
|
||||
cd picpeak
|
||||
|
||||
# Copy environment template
|
||||
# Copy the environment template — the defaults work out of the box.
|
||||
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
|
||||
# admin account is created in the browser (see below). Edit .env only to
|
||||
# customise (domain, SMTP, storage paths, …) — nothing is required.
|
||||
cp .env.example .env
|
||||
|
||||
# Edit configuration (required: JWT_SECRET)
|
||||
nano .env
|
||||
|
||||
# Start with Docker Compose
|
||||
docker compose up -d
|
||||
|
||||
# Access at http://localhost:3000
|
||||
```
|
||||
|
||||
### First run — create your admin account
|
||||
|
||||
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
|
||||
|
||||
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
|
||||
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
|
||||
```bash
|
||||
docker compose logs backend | grep -i "setup token"
|
||||
```
|
||||
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
|
||||
|
||||
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
|
||||
|
||||
Note on Docker file permissions
|
||||
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
|
||||
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
|
||||
@@ -370,17 +391,23 @@ See our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
## 📊 Comparison with Alternatives
|
||||
|
||||
| Feature | PicPeak | PicDrop | Scrapbook.de |
|
||||
|---------|---------|---------|--------------|
|
||||
| Self-Hosted | ✅ | ❌ | ❌ |
|
||||
| Custom Branding | ✅ Full | Limited | Limited |
|
||||
| Monthly Cost | $0 | $29-199 | €19-99 |
|
||||
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
|
||||
| Client Uploads | ✅ | ✅ | ✅ |
|
||||
| API Access | ✅ | Paid | ❌ |
|
||||
| Open Source | ✅ | ❌ | ❌ |
|
||||
| Feature | PicPeak | PicDrop | Scrapbook.de | Pixieset |
|
||||
|---------|---------|---------|--------------|----------|
|
||||
| Self-Hosted | ✅ | ❌ | ❌ | ❌ |
|
||||
| Custom Branding | ✅ Full | Limited | Limited | ✅ (paid) |
|
||||
| Monthly Cost | $0* | $29-199 | €19-99 | ~$60 |
|
||||
| Storage Limit | Unlimited** | 50-500GB | 100-1000GB | 3GB–Unlimited*** |
|
||||
| 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 0–10 h depending on tier).
|
||||
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
|
||||
|
||||
## 🛡️ Security
|
||||
|
||||
@@ -392,7 +419,7 @@ PicPeak takes security seriously:
|
||||
- 📝 Activity logging
|
||||
- 🔒 Secure file access
|
||||
|
||||
Found a security issue? Please open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
@@ -476,6 +503,7 @@ PicPeak is inspired by the best features of commercial platforms while remaining
|
||||
|
||||
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
|
||||
|
||||
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
|
||||
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
|
||||
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
|
||||
|
||||
@@ -537,7 +565,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
||||
<br>
|
||||
<a href="https://www.picpeak.app">Homepage</a> •
|
||||
<a href="https://demo.picpeak.app">Live Demo</a> •
|
||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||
<a href="https://github.com/PicPeak/picpeak">GitHub</a> •
|
||||
<a href="https://docs.picpeak.app">Documentation</a> •
|
||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||
<a href="https://github.com/PicPeak/picpeak/issues">Support</a>
|
||||
</p>
|
||||
|
||||
+34
-32
@@ -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 4–6 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 4–6 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
|
||||
|
||||
4–6 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 4–6 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 4–6 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
@@ -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
@@ -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,6 +9,16 @@ PORT=3001
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
|
||||
|
||||
# Admin 2FA (TOTP) secret encryption key — OPTIONAL.
|
||||
# Admin authenticator secrets are encrypted at rest (AES-256-GCM). By default
|
||||
# the key is derived from JWT_SECRET, so you do NOT need to set this. Set it
|
||||
# only if you want the MFA encryption key decoupled from JWT_SECRET (e.g. so
|
||||
# rotating JWT_SECRET doesn't invalidate enrolled authenticators). If you set
|
||||
# it, changing/losing it makes existing 2FA secrets undecryptable — recover
|
||||
# with: docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
# Generate with: openssl rand -base64 32
|
||||
#MFA_ENCRYPTION_KEY=
|
||||
|
||||
# Auth cookie Secure flag
|
||||
# unset - default: 'auto' in production, false in dev (#427)
|
||||
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access —
|
||||
|
||||
+9
-4
@@ -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,94 @@
|
||||
'use strict';
|
||||
|
||||
// Validates the engine-neutral .picpeak export: it must produce a real zip with
|
||||
// a manifest + per-table NDJSON, exclude knex bookkeeping, and honour the photo
|
||||
// toggle. Uses the shared CRM DB harness (temp SQLite) — no docker needed.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
|
||||
// bootCrmDb MUST run before requiring the service (which transitively requires
|
||||
// db.js) so the export reads this test's DB, not the default path.
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
async function readZip(filePath) {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const entries = Object.keys(await zip.entries());
|
||||
const manifest = JSON.parse((await zip.entryData('manifest.json')).toString('utf8'));
|
||||
await zip.close();
|
||||
return { entries, manifest };
|
||||
}
|
||||
|
||||
describe('picpeak export (.picpeak logical export)', () => {
|
||||
it('produces a .picpeak with a manifest and per-table NDJSON', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
expect(filePath.endsWith('.picpeak')).toBe(true);
|
||||
expect(fs.existsSync(filePath)).toBe(true);
|
||||
|
||||
expect(manifest.format).toBe(1);
|
||||
expect(manifest.kind).toBe('picpeak-backup');
|
||||
expect(manifest.database.engine).toBe('sqlite');
|
||||
expect(manifest.options.includePhotos).toBe(false);
|
||||
expect(manifest.contains_secrets).toBe(true);
|
||||
// Migrations seed real tables (e.g. app_settings) — expect several.
|
||||
expect(Object.keys(manifest.tables).length).toBeGreaterThan(0);
|
||||
expect(Object.keys(manifest.tables)).toContain('app_settings');
|
||||
|
||||
const { entries, manifest: zipped } = await readZip(filePath);
|
||||
expect(entries).toContain('manifest.json');
|
||||
expect(entries.some((n) => n.startsWith('data/') && n.endsWith('.ndjson'))).toBe(true);
|
||||
expect(entries).toContain('data/app_settings.ndjson');
|
||||
// Manifest inside the zip matches the returned one.
|
||||
expect(zipped.tables).toEqual(manifest.tables);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('never exports knex bookkeeping tables', async () => {
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const names = Object.keys(manifest.tables);
|
||||
expect(names).not.toContain('knex_migrations');
|
||||
expect(names).not.toContain('knex_migrations_lock');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('row counts in the manifest match the NDJSON line counts', async () => {
|
||||
// Insert a couple of settings so at least one table is non-empty.
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'picpeak_export_test_a', setting_value: JSON.stringify('1'), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
|
||||
const { filePath, manifest } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
const zip = new StreamZip.async({ file: filePath });
|
||||
const buf = await zip.entryData('data/app_settings.ndjson');
|
||||
await zip.close();
|
||||
const lines = buf.toString('utf8').split('\n').filter((l) => l.trim().length > 0);
|
||||
expect(lines.length).toBe(manifest.tables.app_settings.rowCount);
|
||||
expect(manifest.tables.app_settings.rowCount).toBeGreaterThan(0);
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
'use strict';
|
||||
|
||||
// Full .picpeak roundtrip on a temp SQLite DB:
|
||||
// 1. seed a "backup" instance (admin A + a marker setting)
|
||||
// 2. export → .picpeak
|
||||
// 3. simulate a reinstall: wipe, create a DIFFERENT current admin B, mutate data
|
||||
// 4. import the backup with currentAdminId = B
|
||||
// 5. assert the backup data is restored AND the current account (B) survives,
|
||||
// while the backup's admin (A) is also present (different email → added).
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let tmpDir;
|
||||
let createPicpeak;
|
||||
let importFromPicpeak;
|
||||
let validateManifest;
|
||||
let superAdminRoleId;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup, tmpDir } = await bootCrmDb());
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
({ createPicpeak } = require('../../src/services/picpeakExportService'));
|
||||
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
superAdminRoleId = role.id;
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
const adminRow = (email, hash) => ({
|
||||
username: email,
|
||||
email,
|
||||
password_hash: hash,
|
||||
role_id: superAdminRoleId,
|
||||
is_active: true,
|
||||
must_change_password: false,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
|
||||
async function setMarker(value) {
|
||||
await db('app_settings')
|
||||
.insert({ setting_key: 'roundtrip_marker', setting_value: JSON.stringify(value), setting_type: 'string' })
|
||||
.onConflict('setting_key').merge();
|
||||
}
|
||||
async function getMarker() {
|
||||
const row = await db('app_settings').where({ setting_key: 'roundtrip_marker' }).first();
|
||||
return row ? JSON.parse(row.setting_value) : null;
|
||||
}
|
||||
|
||||
describe('.picpeak roundtrip (export → import)', () => {
|
||||
it('restores backup data and preserves the current account', async () => {
|
||||
// 1. Seed the "source" instance.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('backup-admin@old.example', 'HASH_A'));
|
||||
await setMarker('from_backup');
|
||||
|
||||
// 2. Export.
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// 3. Simulate a reinstall: fresh current admin B, mutated data.
|
||||
await db('admin_users').del();
|
||||
const [bId] = await db('admin_users').insert(adminRow('current-admin@new.example', 'HASH_B')).returning('id');
|
||||
const currentAdminId = typeof bId === 'object' ? bId.id : bId;
|
||||
await setMarker('mutated_after_backup');
|
||||
|
||||
// 4. Import, preserving the current admin.
|
||||
const result = await importFromPicpeak({ filePath: undefined, picpeakPath: filePath, currentAdminId });
|
||||
expect(result.restored).toBe(true);
|
||||
expect(result.tables).toBeGreaterThan(0);
|
||||
|
||||
// 5a. Backup data restored (marker reverted to the backup value).
|
||||
expect(await getMarker()).toBe('from_backup');
|
||||
|
||||
// 5b. The backup's admin is present (different email → added).
|
||||
const a = await db('admin_users').whereRaw('lower(email) = lower(?)', ['backup-admin@old.example']).first();
|
||||
expect(a).toBeTruthy();
|
||||
expect(a.password_hash).toBe('HASH_A');
|
||||
|
||||
// 5c. The current account SURVIVES the override, with its own credentials.
|
||||
const b = await db('admin_users').whereRaw('lower(email) = lower(?)', ['current-admin@new.example']).first();
|
||||
expect(b).toBeTruthy();
|
||||
expect(b.password_hash).toBe('HASH_B');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('overwrites a backup admin that collides with the current account email', async () => {
|
||||
// Source has an admin at the SAME email the current operator will use.
|
||||
await db('admin_users').del();
|
||||
await db('admin_users').insert(adminRow('shared@example.com', 'OLD_HASH'));
|
||||
await setMarker('collision_case');
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
|
||||
try {
|
||||
// Reinstall: current admin uses the same email but a NEW password.
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('shared@example.com', 'NEW_HASH')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
|
||||
// Exactly one admin at that email, and it keeps the CURRENT password.
|
||||
const rows = await db('admin_users').whereRaw('lower(email) = lower(?)', ['shared@example.com']);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].password_hash).toBe('NEW_HASH');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('restores files/ and reports filesRestored', async () => {
|
||||
// A business-doc that lives in storage → travels in the backup.
|
||||
const docDir = path.join(tmpDir, 'business-docs');
|
||||
const marker = path.join(docDir, 'roundtrip-doc.txt');
|
||||
fs.mkdirSync(docDir, { recursive: true });
|
||||
fs.writeFileSync(marker, 'hello');
|
||||
await db('admin_users').del();
|
||||
const [id] = await db('admin_users').insert(adminRow('files@example.com', 'H')).returning('id');
|
||||
const currentAdminId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
const { filePath } = await createPicpeak({ includePhotos: false });
|
||||
try {
|
||||
fs.rmSync(marker); // delete on disk so the restore must bring it back
|
||||
const result = await importFromPicpeak({ picpeakPath: filePath, currentAdminId });
|
||||
expect(result.filesRestored).toBeGreaterThanOrEqual(1);
|
||||
expect(fs.existsSync(marker)).toBe(true);
|
||||
expect(fs.readFileSync(marker, 'utf8')).toBe('hello');
|
||||
} finally {
|
||||
fs.rmSync(path.dirname(filePath), { recursive: true, force: true });
|
||||
fs.rmSync(docDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('.picpeak manifest validation', () => {
|
||||
it('rejects a database-engine mismatch', async () => {
|
||||
// Harness runs on SQLite, so a pg manifest must be refused.
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /engine/i.test(b))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a backup from a newer schema (forward-only)', async () => {
|
||||
// validateManifest reads knex_migrations for the target's latest migration;
|
||||
// the harness has none, so create it with an older migration than the backup.
|
||||
await db.schema.createTable('knex_migrations', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name');
|
||||
t.integer('batch');
|
||||
t.timestamp('migration_time');
|
||||
});
|
||||
try {
|
||||
await db('knex_migrations').insert({ name: '100_baseline', batch: 1 });
|
||||
const blockers = await validateManifest({
|
||||
kind: 'picpeak-backup', format: 1,
|
||||
database: { engine: 'sqlite', latest_migration: '999_from_the_future' },
|
||||
tables: {},
|
||||
});
|
||||
expect(blockers.some((b) => /newer/i.test(b))).toBe(true);
|
||||
} finally {
|
||||
await db.schema.dropTableIfExists('knex_migrations');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a file that is not a PicPeak backup', async () => {
|
||||
const blockers = await validateManifest({ some: 'random-json' });
|
||||
expect(blockers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* CLI test for scripts/reset-admin-mfa.js — break-glass MFA reset (#738).
|
||||
*
|
||||
* Boots a temp-SQLite DB, seeds an admin with MFA fully enabled, then runs
|
||||
* the script in a child process (--email <addr> --yes) pointed at the same
|
||||
* DB file, and asserts the four MFA columns are zeroed. The script runs in
|
||||
* its own process with its own knex connection; the parent connection is
|
||||
* idle during the spawn so the SQLite write lock isn't contended.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, '..', '..', 'scripts', 'reset-admin-mfa.js');
|
||||
|
||||
async function seedEnrolledAdmin(email) {
|
||||
const inserted = await db('admin_users').insert({
|
||||
username: email.split('@')[0],
|
||||
email,
|
||||
password_hash: 'x',
|
||||
is_active: true,
|
||||
two_factor_enabled: true,
|
||||
two_factor_secret: 'iv.tag.ct',
|
||||
two_factor_recovery_codes: JSON.stringify(['$2b$10$fakehashfakehashfakehashfa']),
|
||||
two_factor_enrolled_at: new Date(),
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
return inserted[0]?.id ?? inserted[0];
|
||||
}
|
||||
|
||||
it('zeroes the four MFA columns for the targeted admin', async () => {
|
||||
const email = 'reset-me@example.com';
|
||||
const id = await seedEnrolledAdmin(email);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', email, '--yes'], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'test',
|
||||
TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const row = await db('admin_users').where({ id }).first();
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
expect(row.two_factor_enrolled_at).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a different admin untouched', async () => {
|
||||
const targetEmail = 'target@example.com';
|
||||
const bystanderEmail = 'bystander@example.com';
|
||||
const targetId = await seedEnrolledAdmin(targetEmail);
|
||||
const bystanderId = await seedEnrolledAdmin(bystanderEmail);
|
||||
|
||||
execFileSync('node', [SCRIPT, '--email', targetEmail, '--yes'], {
|
||||
env: { ...process.env, NODE_ENV: 'test', TEST_DATABASE_PATH: process.env.TEST_DATABASE_PATH },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const target = await db('admin_users').where({ id: targetId }).first();
|
||||
const bystander = await db('admin_users').where({ id: bystanderId }).first();
|
||||
expect(Number(target.two_factor_enabled)).toBe(0);
|
||||
expect(Number(bystander.two_factor_enabled)).toBe(1);
|
||||
expect(bystander.two_factor_secret).toBe('iv.tag.ct');
|
||||
});
|
||||
@@ -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,78 @@
|
||||
/**
|
||||
* Regression test for the bulk archive/delete ownership bypass.
|
||||
*
|
||||
* bulk-archive and bulk-delete acted on body-supplied event ids with no
|
||||
* ownership filter, so an admin/editor scoped to their own events (the
|
||||
* single-event routes enforce requireEventOwnership) could archive or
|
||||
* cascade-delete ANY event by id. filterOwnedEventIds is the helper those
|
||||
* routes now use to drop foreign/non-existent ids.
|
||||
*/
|
||||
|
||||
// events owned by admin 7; event 3 owned by someone else; event 4 is
|
||||
// ownerless (legacy). The mock models:
|
||||
// whereIn('id', ids).andWhere(created_by IS NULL OR created_by = admin.id)
|
||||
const EVENTS = [
|
||||
{ id: 1, created_by: 7 },
|
||||
{ id: 2, created_by: 7 },
|
||||
{ id: 3, created_by: 99 }, // foreign
|
||||
{ id: 4, created_by: null }, // ownerless/legacy
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: () => {
|
||||
const q = {
|
||||
_ids: null,
|
||||
_adminId: null,
|
||||
whereIn(_col, ids) { this._ids = ids; return this; },
|
||||
andWhere(cb) {
|
||||
// Emulate the (created_by IS NULL OR created_by = admin.id) builder
|
||||
// by capturing the admin id the callback closes over via a probe.
|
||||
const probe = {
|
||||
_adminId: null,
|
||||
whereNull() { return this; },
|
||||
orWhere(_col, id) { this._adminId = id; return this; },
|
||||
};
|
||||
cb(probe);
|
||||
this._adminId = probe._adminId;
|
||||
return this;
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve(
|
||||
EVENTS
|
||||
.filter((e) => this._ids.includes(e.id))
|
||||
.filter((e) => e.created_by === null || e.created_by === this._adminId)
|
||||
.map((e) => ({ id: e.id }))
|
||||
);
|
||||
},
|
||||
};
|
||||
return q;
|
||||
},
|
||||
}));
|
||||
|
||||
const { filterOwnedEventIds } = require('../../src/middleware/ownership');
|
||||
|
||||
describe('filterOwnedEventIds', () => {
|
||||
it('super_admin gets every id, nothing denied', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'super_admin' }, [1, 3, 4, 999]
|
||||
);
|
||||
expect(allowed).toEqual([1, 3, 4, 999]);
|
||||
expect(denied).toEqual([]);
|
||||
});
|
||||
|
||||
it('non-super_admin keeps owned + ownerless, denies foreign and non-existent', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'admin' }, [1, 2, 3, 4, 999]
|
||||
);
|
||||
expect(allowed.sort()).toEqual([1, 2, 4]); // owns 1,2; 4 is ownerless
|
||||
expect(denied.sort()).toEqual([3, 999]); // 3 foreign, 999 missing
|
||||
});
|
||||
|
||||
it('foreign-only request yields empty allowed', async () => {
|
||||
const { allowed, denied } = await filterOwnedEventIds(
|
||||
{ id: 7, roleName: 'editor' }, [3]
|
||||
);
|
||||
expect(allowed).toEqual([]);
|
||||
expect(denied).toEqual([3]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Regression test for the cross-event thumbnail enumeration leak.
|
||||
*
|
||||
* Thumbnails are served flat from /thumbnails/thumb_<name> with
|
||||
* deterministic, enumerable filenames. photoAuth previously granted any
|
||||
* holder of a gallery token for ANY active event access to ANY thumbnail
|
||||
* (it set eventSlug=null and returned next() as long as the token's event
|
||||
* existed), so a visitor to one gallery could pull another (password-
|
||||
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
|
||||
* access to the token's event by matching the requested file against
|
||||
* photos.thumbnail_path for that event_id.
|
||||
*/
|
||||
|
||||
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// Two events, each owning one thumbnail. The photos mock resolves a row
|
||||
// only when BOTH event_id and thumbnail_path match — i.e. it models the
|
||||
// real ownership query.
|
||||
const EVENTS = [
|
||||
{ id: 10, slug: 'event-a', is_active: 1 },
|
||||
{ id: 20, slug: 'event-b', is_active: 1 },
|
||||
];
|
||||
const PHOTOS = [
|
||||
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
|
||||
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
|
||||
];
|
||||
|
||||
jest.mock('../../src/database/db', () => ({
|
||||
db: (table) => ({
|
||||
_cond: null,
|
||||
where(cond) { this._cond = cond; return this; },
|
||||
first() {
|
||||
if (table === 'events') {
|
||||
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
|
||||
}
|
||||
if (table === 'photos') {
|
||||
return Promise.resolve(
|
||||
PHOTOS.find((p) => p.event_id === this._cond.event_id
|
||||
&& p.thumbnail_path === this._cond.thumbnail_path) || null
|
||||
);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const photoAuth = require('../../src/middleware/photoAuth');
|
||||
|
||||
function galleryToken(eventId) {
|
||||
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
|
||||
}
|
||||
|
||||
function makeReqRes(token, thumbPath) {
|
||||
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
|
||||
const res = {
|
||||
statusCode: null,
|
||||
body: null,
|
||||
status(code) { this.statusCode = code; return this; },
|
||||
json(payload) { this.body = payload; return this; },
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
describe('photoAuth — thumbnail ownership scoping', () => {
|
||||
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
// Access denied: middleware must not pass the request through.
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
|
||||
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(req.event).toMatchObject({ id: 20 });
|
||||
});
|
||||
|
||||
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
|
||||
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
|
||||
const next = jest.fn();
|
||||
|
||||
await photoAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
expect(req.event).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -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,345 @@
|
||||
/**
|
||||
* HTTP-level tests for the admin TOTP MFA feature (#738).
|
||||
*
|
||||
* Two surfaces:
|
||||
* 1. Enrollment (adminAuth-gated) — POST /mfa/setup, /mfa/enable,
|
||||
* GET /mfa/status, POST /mfa/disable — mounted like server.js at
|
||||
* /api/admin/auth (src/routes/adminAuth.js).
|
||||
* 2. Login challenge — POST /admin/login + POST /admin/login/mfa
|
||||
* (src/routes/auth.js, mounted /api/auth).
|
||||
*
|
||||
* Uses the same real-SQLite harness as the CRM route tests
|
||||
* (bootCrmDb + seedMinimal + mintAdminToken). Valid TOTP codes are
|
||||
* generated in-test via otplib's authenticator against the secret the
|
||||
* /setup endpoint returns in plaintext.
|
||||
*
|
||||
* NOTE: env (TEST_DATABASE_PATH / JWT_SECRET) must be set BEFORE the
|
||||
* first require of db.js — mirror adminCrmAuth.test.js exactly.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-adminmfa-test-'));
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'db.sqlite');
|
||||
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
|
||||
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-route-test-secret';
|
||||
// reCAPTCHA disabled (default) → verifyRecaptcha returns true, so login
|
||||
// tests don't need a token. Be explicit so a leaked env can't flip it on.
|
||||
delete process.env.RECAPTCHA_SECRET_KEY;
|
||||
|
||||
const request = require('supertest');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
const {
|
||||
bootCrmDb, mintAdminToken, buildRouteApp,
|
||||
} = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(60000);
|
||||
|
||||
let db;
|
||||
let cleanup;
|
||||
let adminApp; // /api/admin/auth (enrollment)
|
||||
let authApp; // /api/auth (login challenge)
|
||||
|
||||
/**
|
||||
* Seed a bare admin (password known) and return its id + login creds.
|
||||
* seedMinimal always creates username 'tester'; we need distinct rows per
|
||||
* scenario, so insert directly with a unique username/email.
|
||||
*/
|
||||
async function seedAdmin({ username, superAdmin = false } = {}) {
|
||||
const password = 'correct-horse';
|
||||
const passwordHash = await bcrypt.hash(password, 4);
|
||||
const uname = username || `admin-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const row = {
|
||||
username: uname,
|
||||
email: `${uname}@example.com`,
|
||||
password_hash: passwordHash,
|
||||
must_change_password: false,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
};
|
||||
if (superAdmin) {
|
||||
const role = await db('roles').where({ name: 'super_admin' }).first();
|
||||
if (!role) throw new Error('super_admin role not seeded');
|
||||
row.role_id = role.id;
|
||||
}
|
||||
const inserted = await db('admin_users').insert(row).returning('id');
|
||||
const id = inserted[0]?.id ?? inserted[0];
|
||||
return { id, username: uname, password };
|
||||
}
|
||||
|
||||
/** Run the full setup→enable enrollment against the live app. Returns
|
||||
* the plaintext TOTP secret (for later login codes) and recovery codes. */
|
||||
async function enroll(adminId) {
|
||||
const token = mintAdminToken(adminId);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(setup.status).toBe(200);
|
||||
const secret = setup.body.secret;
|
||||
|
||||
const enable = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(enable.status).toBe(200);
|
||||
return { secret, recoveryCodes: enable.body.recoveryCodes, token };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
|
||||
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
describe('MFA enrollment — /api/admin/auth/mfa/*', () => {
|
||||
it('setup returns a secret + otpauth URI + QR and does NOT enable yet', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.secret).toEqual(expect.any(String));
|
||||
expect(res.body.otpauthUri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(res.body.qr).toMatch(/^data:image\/png;base64,/);
|
||||
|
||||
// Not yet enabled: status must still report disabled.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
|
||||
// And the row stores an encrypted secret (not the plaintext one).
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeTruthy();
|
||||
expect(row.two_factor_secret).not.toBe(res.body.secret);
|
||||
expect(Number(row.two_factor_enabled)).toBe(0);
|
||||
});
|
||||
|
||||
it('full flow: setup → enable(valid TOTP) → status shows enabled + 10 recovery codes', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
|
||||
expect(Array.isArray(recoveryCodes)).toBe(true);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.status).toBe(200);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(10);
|
||||
expect(status.body.enrolledAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('enable with a WRONG code is rejected (400) and MFA stays off', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const setup = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/setup')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
const valid = authenticator.generate(setup.body.secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: wrong });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enable before setup is rejected', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const token = mintAdminToken(admin.id);
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/enable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '123456' });
|
||||
// No provisional secret → ValidationError (400).
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('all enrollment endpoints require a valid admin token (401 without one)', async () => {
|
||||
const noToken = await request(adminApp).get('/api/admin/auth/mfa/status');
|
||||
expect(noToken.status).toBe(401);
|
||||
const setup = await request(adminApp).post('/api/admin/auth/mfa/setup');
|
||||
expect(setup.status).toBe(401);
|
||||
});
|
||||
|
||||
// Regression guard for #735: super_admin used to be blocked from enrolling.
|
||||
// Enrollment operates on req.admin.id and is role-agnostic — assert a
|
||||
// super_admin can complete the full setup→enable flow.
|
||||
it('#735 regression — a super_admin can enroll in MFA', async () => {
|
||||
const admin = await seedAdmin({ superAdmin: true });
|
||||
const { recoveryCodes, token } = await enroll(admin.id);
|
||||
expect(recoveryCodes).toHaveLength(10);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA disable — /api/admin/auth/mfa/disable', () => {
|
||||
it('requires a valid code; a wrong code is rejected and state persists', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { token } = await enroll(admin.id);
|
||||
|
||||
const bad = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: '000000' });
|
||||
expect(bad.status).toBe(400);
|
||||
|
||||
const stillOn = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(stillOn.body.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('a valid TOTP disables MFA and clears the stored secret', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret, token } = await enroll(admin.id);
|
||||
|
||||
const res = await request(adminApp)
|
||||
.post('/api/admin/auth/mfa/disable')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ code: authenticator.generate(secret) });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(status.body.enabled).toBe(false);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(0);
|
||||
|
||||
const row = await db('admin_users').where({ id: admin.id }).first();
|
||||
expect(row.two_factor_secret).toBeNull();
|
||||
expect(row.two_factor_recovery_codes).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Admin login challenge — /api/auth/admin/login[/mfa]', () => {
|
||||
it('an enrolled admin gets mfaRequired + mfaToken, NO session cookie', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBe(true);
|
||||
expect(res.body.mfaToken).toEqual(expect.any(String));
|
||||
expect(res.body.user).toBeUndefined(); // no completed session
|
||||
// No admin auth cookie should have been set on the challenge response.
|
||||
const cookies = res.headers['set-cookie'] || [];
|
||||
expect(cookies.join(';')).not.toMatch(/adminToken/i);
|
||||
});
|
||||
|
||||
it('a NON-enrolled admin logs in directly (no mfaRequired)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.mfaRequired).toBeUndefined();
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.username).toBe(admin.username);
|
||||
});
|
||||
|
||||
it('login/mfa with a valid TOTP completes the session', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const { mfaToken } = challenge.body;
|
||||
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken, code: authenticator.generate(secret) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user).toBeDefined();
|
||||
expect(res.body.user.id).toBe(admin.id);
|
||||
});
|
||||
|
||||
it('login/mfa with a wrong code is 401 MFA_INVALID', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { secret } = await enroll(admin.id);
|
||||
const challenge = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
|
||||
const valid = authenticator.generate(secret);
|
||||
const wrong = valid === '000000' ? '111111' : '000000';
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: challenge.body.mfaToken, code: wrong });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.code).toBe('MFA_INVALID');
|
||||
expect(res.body.user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a recovery code logs in and is then single-use (second use fails)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
const { recoveryCodes } = await enroll(admin.id);
|
||||
const recovery = recoveryCodes[0];
|
||||
|
||||
// First challenge + recovery-code exchange succeeds.
|
||||
const c1 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const first = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c1.body.mfaToken, code: recovery });
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.user).toBeDefined();
|
||||
|
||||
// recoveryCodesRemaining dropped by one.
|
||||
const status = await request(adminApp)
|
||||
.get('/api/admin/auth/mfa/status')
|
||||
.set('Authorization', `Bearer ${mintAdminToken(admin.id)}`);
|
||||
expect(status.body.recoveryCodesRemaining).toBe(9);
|
||||
|
||||
// Second use of the SAME recovery code must fail.
|
||||
const c2 = await request(authApp)
|
||||
.post('/api/auth/admin/login')
|
||||
.send({ username: admin.username, password: admin.password });
|
||||
const second = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: c2.body.mfaToken, code: recovery });
|
||||
expect(second.status).toBe(401);
|
||||
expect(second.body.code).toBe('MFA_INVALID');
|
||||
});
|
||||
|
||||
it('login/mfa rejects a non-mfa_pending token (e.g. a normal admin JWT)', async () => {
|
||||
const admin = await seedAdmin();
|
||||
await enroll(admin.id);
|
||||
const res = await request(authApp)
|
||||
.post('/api/auth/admin/login/mfa')
|
||||
.send({ mfaToken: mintAdminToken(admin.id), code: '123456' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,9 @@ async function insertEvent(db, adminId, over = {}) {
|
||||
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));
|
||||
@@ -72,7 +75,7 @@ describe('admin Live Slideshow endpoints', () => {
|
||||
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(); });
|
||||
|
||||
|
||||
@@ -67,6 +67,11 @@ async function insertEvent(db, over = {}) {
|
||||
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);
|
||||
@@ -81,7 +86,7 @@ describe('public Live Slideshow routes', () => {
|
||||
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(); });
|
||||
|
||||
|
||||
@@ -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,193 @@
|
||||
/**
|
||||
* Unit tests for mfaService — admin TOTP MFA (#738).
|
||||
*
|
||||
* Pure unit: no DB, no Express. Exercises the crypto/verification surface
|
||||
* directly. JWT_SECRET is set at the top so getEncryptionKey()'s scrypt
|
||||
* derivation has key material (the service derives the AES key from
|
||||
* MFA_ENCRYPTION_KEY, falling back to JWT_SECRET).
|
||||
*/
|
||||
|
||||
// Must be set BEFORE the service is required — the key is derived lazily per
|
||||
// call, but keep it explicit and stable so encrypt/decrypt round-trips.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mfa-unit-test-secret';
|
||||
delete process.env.MFA_ENCRYPTION_KEY; // ensure we derive from JWT_SECRET
|
||||
|
||||
const { authenticator } = require('otplib');
|
||||
const mfaService = require('../../src/services/mfaService');
|
||||
|
||||
describe('mfaService — secret encryption (AES-256-GCM)', () => {
|
||||
it('round-trips encrypt → decrypt to the original secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const blob = mfaService.encryptSecret(secret);
|
||||
expect(blob).toEqual(expect.any(String));
|
||||
expect(blob).not.toContain(secret); // stored form is not plaintext
|
||||
expect(blob.split('.')).toHaveLength(3); // iv.tag.ciphertext
|
||||
expect(mfaService.decryptSecret(blob)).toBe(secret);
|
||||
});
|
||||
|
||||
it('produces a different ciphertext each time (random IV) but decrypts identically', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const a = mfaService.encryptSecret(secret);
|
||||
const b = mfaService.encryptSecret(secret);
|
||||
expect(a).not.toBe(b);
|
||||
expect(mfaService.decryptSecret(a)).toBe(secret);
|
||||
expect(mfaService.decryptSecret(b)).toBe(secret);
|
||||
});
|
||||
|
||||
it('throws when decrypting a malformed blob (wrong segment count)', () => {
|
||||
expect(() => mfaService.decryptSecret('garbage')).toThrow();
|
||||
expect(() => mfaService.decryptSecret('only.two')).toThrow();
|
||||
});
|
||||
|
||||
it('throws when the auth tag / ciphertext is tampered with', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const [iv, tag, ct] = mfaService.encryptSecret(secret).split('.');
|
||||
// Flip a character in the ciphertext → GCM auth check must fail.
|
||||
const tampered = ct.slice(0, -2) + (ct.slice(-2) === 'AA' ? 'BB' : 'AA');
|
||||
expect(() => mfaService.decryptSecret([iv, tag, tampered].join('.'))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — TOTP verification', () => {
|
||||
it('accepts a freshly generated code for the plaintext secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(code, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates whitespace in the submitted code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotp(` ${code} `, secret)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotp(wrong, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty inputs rather than throwing', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
expect(mfaService.verifyTotp('', secret)).toBe(false);
|
||||
expect(mfaService.verifyTotp('123456', '')).toBe(false);
|
||||
expect(mfaService.verifyTotp(null, secret)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifies through the encrypted blob (verifyTotpEncrypted)', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const stored = mfaService.encryptSecret(secret);
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, stored)).toBe(true);
|
||||
|
||||
const wrong = code === '000000' ? '111111' : '000000';
|
||||
expect(mfaService.verifyTotpEncrypted(wrong, stored)).toBe(false);
|
||||
});
|
||||
|
||||
it('verifyTotpEncrypted returns false (no throw) for a corrupt blob', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const code = authenticator.generate(secret);
|
||||
expect(mfaService.verifyTotpEncrypted(code, 'not-a-valid-blob')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — otpauth URI / QR', () => {
|
||||
it('builds an otpauth:// URI containing issuer, account and secret', () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
expect(uri).toMatch(/^otpauth:\/\/totp\//);
|
||||
expect(uri).toContain(encodeURIComponent(mfaService.ISSUER));
|
||||
expect(uri).toContain(`secret=${secret}`);
|
||||
});
|
||||
|
||||
it('builds a PNG data-URL QR for the URI', async () => {
|
||||
const secret = mfaService.generateSecret();
|
||||
const uri = mfaService.buildOtpauthUri('admin@example.com', secret);
|
||||
const qr = await mfaService.buildQrDataUrl(uri);
|
||||
expect(qr).toMatch(/^data:image\/png;base64,/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — recovery codes', () => {
|
||||
it('generates 10 distinct plaintext codes and 10 distinct hashes', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
expect(plain).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(hashed).toHaveLength(mfaService.RECOVERY_CODE_COUNT);
|
||||
expect(new Set(plain).size).toBe(10);
|
||||
expect(new Set(hashed).size).toBe(10);
|
||||
// Hashes are bcrypt, not the plaintext.
|
||||
hashed.forEach((h) => expect(h).toMatch(/^\$2[aby]\$/));
|
||||
plain.forEach((p) => expect(hashed).not.toContain(p));
|
||||
});
|
||||
|
||||
it('formats a raw code into 4-char groups', () => {
|
||||
expect(mfaService.formatRecoveryCode('abcdefghij')).toBe('abcd-efgh-ij');
|
||||
});
|
||||
|
||||
it('consumes a valid recovery code once and removes it (single-use)', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const target = plain[3];
|
||||
|
||||
const first = await mfaService.consumeRecoveryCode(target, hashed);
|
||||
expect(first.matched).toBe(true);
|
||||
expect(first.remainingHashes).toHaveLength(9);
|
||||
|
||||
// Reusing the same code against the reduced set must now fail.
|
||||
const reuse = await mfaService.consumeRecoveryCode(target, first.remainingHashes);
|
||||
expect(reuse.matched).toBe(false);
|
||||
expect(reuse.remainingHashes).toHaveLength(9);
|
||||
});
|
||||
|
||||
it('matches case-insensitively and trims whitespace', async () => {
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode(` ${plain[0].toUpperCase()} `, hashed);
|
||||
expect(res.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a wrong code and leaves the hash set unchanged', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('zzzz-zzzz-zz', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('handles empty / missing input safely', async () => {
|
||||
const { hashed } = await mfaService.generateRecoveryCodes();
|
||||
const res = await mfaService.consumeRecoveryCode('', hashed);
|
||||
expect(res.matched).toBe(false);
|
||||
expect(res.remainingHashes).toBe(hashed);
|
||||
const noHashes = await mfaService.consumeRecoveryCode('abcd-efgh-ij', null);
|
||||
expect(noHashes.matched).toBe(false);
|
||||
expect(noHashes.remainingHashes).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — parseRecoveryCodes', () => {
|
||||
it('parses a JSON string array', () => {
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify(['a', 'b']))).toEqual(['a', 'b']);
|
||||
});
|
||||
it('passes an already-array through', () => {
|
||||
expect(mfaService.parseRecoveryCodes(['a', 'b'])).toEqual(['a', 'b']);
|
||||
});
|
||||
it('returns [] for null / garbage / non-array JSON', () => {
|
||||
expect(mfaService.parseRecoveryCodes(null)).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes('{not json')).toEqual([]);
|
||||
expect(mfaService.parseRecoveryCodes(JSON.stringify({ a: 1 }))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfaService — isEnrolled coercion', () => {
|
||||
it('treats true / 1 / "1" as enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: true })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 1 })).toBe(true);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: '1' })).toBe(true);
|
||||
});
|
||||
it('treats false / 0 / null / missing as not enrolled', () => {
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: false })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: 0 })).toBe(false);
|
||||
expect(mfaService.isEnrolled({ two_factor_enabled: null })).toBe(false);
|
||||
expect(mfaService.isEnrolled({})).toBe(false);
|
||||
expect(mfaService.isEnrolled(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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,48 @@
|
||||
/**
|
||||
* Regression tests for clampIntOrUndefined — the slideshow-seed NaN bug.
|
||||
*
|
||||
* The event-create route seeds show_interval_ms/show_transition_ms from
|
||||
* app_settings via an int-parse-and-clamp. The old inline guard
|
||||
* (`Number.isFinite(+v) ? parseInt(v) : undefined`) disagreed with itself
|
||||
* for null/''/true: `+null` is 0 (finite) but `parseInt(null)` is NaN, so
|
||||
* NaN flowed through Math.min/Math.max into the INSERT. PostgreSQL
|
||||
* rejects NaN for integer columns ("invalid input syntax for type
|
||||
* integer: NaN") while SQLite silently stores NULL — so POST
|
||||
* /api/admin/events 500'd on PG whenever the slideshow settings rows
|
||||
* were absent (getAppSetting returns its null default).
|
||||
*/
|
||||
|
||||
const { clampIntOrUndefined } = require('../../src/utils/numericHelpers');
|
||||
|
||||
describe('clampIntOrUndefined', () => {
|
||||
it('returns undefined for null (the getAppSetting missing-row default)', () => {
|
||||
expect(clampIntOrUndefined(null, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for undefined, empty string, and booleans', () => {
|
||||
expect(clampIntOrUndefined(undefined, 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined('', 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined(true, 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined(false, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for non-numeric garbage', () => {
|
||||
expect(clampIntOrUndefined('fast', 1000, 120000)).toBeUndefined();
|
||||
expect(clampIntOrUndefined({}, 1000, 120000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('never returns NaN for any of the failure-mode inputs', () => {
|
||||
for (const v of [null, undefined, '', true, false, 'x', {}, []]) {
|
||||
const out = clampIntOrUndefined(v, 100, 5000);
|
||||
expect(Number.isNaN(out)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses and clamps valid values', () => {
|
||||
expect(clampIntOrUndefined('2500', 1000, 120000)).toBe(2500);
|
||||
expect(clampIntOrUndefined(2500, 1000, 120000)).toBe(2500);
|
||||
expect(clampIntOrUndefined('500', 1000, 120000)).toBe(1000);
|
||||
expect(clampIntOrUndefined(999999, 1000, 120000)).toBe(120000);
|
||||
expect(clampIntOrUndefined('2500.9', 1000, 120000)).toBe(2500);
|
||||
});
|
||||
});
|
||||
@@ -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 & B <tags> "quoted" ([#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/<slug> 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,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 quote→event 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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Migration 151: admin MFA (TOTP) enrollment support — issue #738.
|
||||
*
|
||||
* The `admin_users.two_factor_enabled` / `two_factor_secret` columns already
|
||||
* exist from the legacy migration 016 but were never wired to any code. This
|
||||
* migration adds the two columns the real TOTP flow needs on top of them:
|
||||
*
|
||||
* - two_factor_recovery_codes: JSON array of one-time backup codes, stored
|
||||
* HASHED (never plaintext), so a locked-out admin can log in without the
|
||||
* authenticator. Consumed on use.
|
||||
* - two_factor_enrolled_at: when the admin completed enrollment (audit /
|
||||
* display only).
|
||||
*
|
||||
* The TOTP secret itself continues to live in the existing `two_factor_secret`
|
||||
* column, but is now stored ENCRYPTED at rest (AES-256-GCM) by mfaService —
|
||||
* the column type is unchanged (the encrypted blob is short).
|
||||
*
|
||||
* Additive and idempotent: only adds columns, guarded by hasColumn, so it is
|
||||
* safe to re-run and touches no existing data.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
|
||||
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
|
||||
const hasEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled');
|
||||
const hasSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret');
|
||||
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
// Backfill the legacy columns too, in case an install somehow lacks them
|
||||
// (016 is a legacy migration; guard defensively).
|
||||
if (!hasEnabled) {
|
||||
t.boolean('two_factor_enabled').defaultTo(false);
|
||||
}
|
||||
if (!hasSecret) {
|
||||
t.string('two_factor_secret').nullable();
|
||||
}
|
||||
if (!hasRecovery) {
|
||||
t.text('two_factor_recovery_codes').nullable();
|
||||
}
|
||||
if (!hasEnrolledAt) {
|
||||
t.timestamp('two_factor_enrolled_at').nullable();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
const hasRecovery = await knex.schema.hasColumn('admin_users', 'two_factor_recovery_codes');
|
||||
const hasEnrolledAt = await knex.schema.hasColumn('admin_users', 'two_factor_enrolled_at');
|
||||
|
||||
await knex.schema.alterTable('admin_users', (t) => {
|
||||
// Only drop what THIS migration added; leave the legacy 016 columns.
|
||||
if (hasRecovery) {
|
||||
t.dropColumn('two_factor_recovery_codes');
|
||||
}
|
||||
if (hasEnrolledAt) {
|
||||
t.dropColumn('two_factor_enrolled_at');
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Migration 152: make events.hero_logo_visible NULL-able so NULL means
|
||||
* "inherit the global branding_logo_display_hero setting" (#756).
|
||||
*
|
||||
* Before: hero_logo_visible was `boolean NOT NULL DEFAULT true`, and every
|
||||
* event got a concrete true/false snapshotted at creation. The global
|
||||
* "Show logo in hero section" toggle (branding_logo_display_hero) was only a
|
||||
* creation-time default and never affected existing galleries — so disabling
|
||||
* it did nothing to already-published galleries.
|
||||
*
|
||||
* After: NULL = inherit. gallery read-resolution falls back to the global
|
||||
* setting when the per-event value is NULL, so the global toggle controls
|
||||
* every gallery that hasn't been deliberately overridden per-event.
|
||||
*
|
||||
* Data backfill: NULL out the DEFAULTED `true` rows so they start inheriting
|
||||
* the global. A deliberate per-gallery hide (`false`) is kept — we can't tell a
|
||||
* defaulted-true from a chosen-true, but `false` is almost always a conscious
|
||||
* "hide it here", and nulling it could silently re-show a hidden logo.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return;
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP DEFAULT');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP NOT NULL');
|
||||
} else {
|
||||
// SQLite (and others): knex recreates the table without the NOT NULL/default.
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.boolean('hero_logo_visible').nullable().alter();
|
||||
});
|
||||
}
|
||||
|
||||
// Existing defaulted-`true` galleries now inherit the global toggle.
|
||||
await knex('events').where('hero_logo_visible', true).update({ hero_logo_visible: null });
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return;
|
||||
// Re-materialise NULLs as the old default (true) before restoring NOT NULL.
|
||||
await knex('events').whereNull('hero_logo_visible').update({ hero_logo_visible: true });
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET DEFAULT true');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.boolean('hero_logo_visible').notNullable().defaultTo(true).alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Migration 153: make events.hero_logo_size NULL-able so NULL means "inherit
|
||||
* the global branding_logo_size" (#756 follow-up — the size counterpart of 152).
|
||||
*
|
||||
* Before: hero_logo_size was `varchar NOT NULL DEFAULT 'medium'`, snapshotted
|
||||
* from the global branding_logo_size at creation. The two gallery render paths
|
||||
* then disagreed — GalleryLayout read the global size live, while the
|
||||
* hero-header path used the per-event snapshot — so a hero logo could render at
|
||||
* different sizes on different layouts, and changing the global size didn't
|
||||
* update hero-header galleries.
|
||||
*
|
||||
* After: NULL = inherit. gallery read-resolution falls back to
|
||||
* branding_logo_size when the per-event value is NULL, and both render paths
|
||||
* consume that resolved size.
|
||||
*
|
||||
* Data backfill: NULL out ALL existing hero_logo_size so every gallery inherits
|
||||
* the global size going forward. Unlike a boolean we can't tell a defaulted
|
||||
* value from a chosen one — but nulling is the safe choice here: it restores the
|
||||
* live-global behaviour GalleryLayout already had, and the per-event size can be
|
||||
* re-set from the event's edit page.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return;
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP DEFAULT');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.string('hero_logo_size', 20).nullable().alter();
|
||||
});
|
||||
}
|
||||
|
||||
await knex('events').update({ hero_logo_size: null });
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return;
|
||||
await knex('events').whereNull('hero_logo_size').update({ hero_logo_size: 'medium' });
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw("ALTER TABLE events ALTER COLUMN hero_logo_size SET DEFAULT 'medium'");
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size SET NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.string('hero_logo_size', 20).notNullable().defaultTo('medium').alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
Generated
+242
-142
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.65.1-beta.0",
|
||||
"version": "3.80.0-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.65.1-beta.0",
|
||||
"version": "3.80.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,28 @@
|
||||
"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",
|
||||
"otplib": "^12.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 +53,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 +1016,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 +1031,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 +1041,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 +1073,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 +1090,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 +1107,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 +1117,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 +1159,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 +1169,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 +1179,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 +1189,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 +1467,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 +1501,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"
|
||||
@@ -2701,6 +2704,56 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/core": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
|
||||
"integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@otplib/plugin-crypto": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
|
||||
"integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/plugin-thirty-two": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
|
||||
"integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"thirty-two": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-default": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
|
||||
"integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
|
||||
"deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@otplib/preset-v11": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
|
||||
"integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/plugin-crypto": "^12.0.1",
|
||||
"@otplib/plugin-thirty-two": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
@@ -4086,12 +4139,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 +4295,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.9.11",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
|
||||
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
|
||||
"version": "2.10.40",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||
"integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
@@ -4348,9 +4404,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 +4425,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 +4630,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 +5393,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 +6240,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 +6879,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 +7893,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 +7920,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 +8939,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
@@ -9041,11 +9119,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"version": "2.0.50",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
|
||||
"integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/node-stream-zip": {
|
||||
"version": "1.15.0",
|
||||
@@ -9061,9 +9142,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"
|
||||
@@ -9341,6 +9422,17 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/otplib": {
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
|
||||
"integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@otplib/core": "^12.0.1",
|
||||
"@otplib/preset-default": "^12.0.1",
|
||||
"@otplib/preset-v11": "^12.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -9823,9 +9915,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -11522,9 +11614,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",
|
||||
@@ -11638,6 +11730,14 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/thirty-two": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
|
||||
"integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
|
||||
"engines": {
|
||||
"node": ">=0.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||
|
||||
+15
-11
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.68.0-beta.0",
|
||||
"version": "3.82.4-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,26 +29,28 @@
|
||||
"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",
|
||||
"otplib": "^12.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",
|
||||
@@ -57,6 +59,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"
|
||||
@@ -73,10 +76,10 @@
|
||||
"tar-fs": "2.1.4"
|
||||
},
|
||||
"glob": "^11.1.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"js-yaml": "^4.2.0",
|
||||
"fast-xml-parser": ">=5.7.0",
|
||||
"qs": ">=6.15.2",
|
||||
"tar": ">=7.5.13",
|
||||
"tar": ">=7.5.16",
|
||||
"brace-expansion": ">=5.0.6",
|
||||
"minimatch": ">=9.0.7",
|
||||
"path-to-regexp": "0.1.13",
|
||||
@@ -84,6 +87,7 @@
|
||||
"follow-redirects": ">=1.16.0",
|
||||
"@tootallnate/once": ">=3.0.1",
|
||||
"ip-address": ">=10.1.1",
|
||||
"uuid": "^11.1.1"
|
||||
"uuid": "^11.1.1",
|
||||
"nodemailer": "^9.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* reset-admin-mfa.js — disable two-factor auth for a locked-out admin (#738).
|
||||
*
|
||||
* Break-glass recovery for when an admin loses their authenticator AND their
|
||||
* recovery codes. Clears the MFA state so the admin can log in with just their
|
||||
* password and re-enroll from Settings.
|
||||
*
|
||||
* Usage (inside the running backend container):
|
||||
* docker compose exec backend node scripts/reset-admin-mfa.js --email admin@example.com
|
||||
* docker compose exec backend node scripts/reset-admin-mfa.js --all --yes
|
||||
*
|
||||
* Flags:
|
||||
* --email <addr> target a single admin by email (or --username <name>)
|
||||
* --all reset MFA for EVERY admin (full lockout / break-glass)
|
||||
* --yes non-interactive (skip the confirmation prompt)
|
||||
*/
|
||||
|
||||
const readline = require('readline');
|
||||
const { db, logActivity } = require('../src/database/db');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const hasFlag = (f) => args.includes(f);
|
||||
const getOption = (name) => {
|
||||
const i = args.indexOf(`--${name}`);
|
||||
return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
|
||||
};
|
||||
|
||||
const force = hasFlag('--yes') || hasFlag('--force') || hasFlag('--non-interactive');
|
||||
const all = hasFlag('--all');
|
||||
const email = getOption('email');
|
||||
const username = getOption('username');
|
||||
|
||||
const MFA_CLEAR = {
|
||||
two_factor_enabled: false,
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
||||
function ask(prompt) {
|
||||
if (force) return Promise.resolve('yes');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => rl.question(prompt, (a) => { rl.close(); resolve(a); }));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('\n========================================');
|
||||
console.log('PicPeak Admin MFA Reset Tool');
|
||||
console.log('========================================\n');
|
||||
|
||||
if (!all && !email && !username) {
|
||||
console.error('❌ Specify a target: --email <addr>, --username <name>, or --all');
|
||||
console.log(' e.g. node scripts/reset-admin-mfa.js --email admin@example.com');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve target admins.
|
||||
let targets;
|
||||
if (all) {
|
||||
targets = await db('admin_users').select('id', 'username', 'email', 'two_factor_enabled');
|
||||
} else {
|
||||
const q = db('admin_users');
|
||||
if (email) q.where({ email });
|
||||
if (username) q.where({ username });
|
||||
targets = await q.select('id', 'username', 'email', 'two_factor_enabled');
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
console.error('❌ No matching admin user found.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const enrolled = targets.filter((t) => t.two_factor_enabled === true || t.two_factor_enabled === 1);
|
||||
console.log(`Matched ${targets.length} admin(s); ${enrolled.length} currently have MFA enabled:`);
|
||||
for (const t of targets) {
|
||||
const flag = (t.two_factor_enabled === true || t.two_factor_enabled === 1) ? 'MFA ON' : 'mfa off';
|
||||
console.log(` - ${t.username} <${t.email}> [${flag}]`);
|
||||
}
|
||||
|
||||
const confirm = await ask('\nDisable MFA for the above? (yes/no): ');
|
||||
const normalized = String(confirm).trim().toLowerCase();
|
||||
if (normalized !== 'yes' && normalized !== 'y') {
|
||||
console.log('\n❌ Cancelled. No changes made.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const ids = targets.map((t) => t.id);
|
||||
const updated = await db('admin_users').whereIn('id', ids).update(MFA_CLEAR);
|
||||
|
||||
for (const t of targets) {
|
||||
try {
|
||||
await logActivity('admin_mfa_reset_cli',
|
||||
{ admin_id: t.id, via: 'cli' },
|
||||
null,
|
||||
{ type: 'system', id: 0, name: 'reset-admin-mfa.js' }
|
||||
);
|
||||
} catch (_) { /* activity log is best-effort */ }
|
||||
}
|
||||
|
||||
console.log(`\n✅ MFA disabled for ${updated} admin(s). They can now log in with just their password and re-enroll from Settings → Security.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('❌ Failed to reset MFA:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+109
-2
@@ -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: {} };
|
||||
@@ -270,12 +343,34 @@ describe('isSocialCrawler — extended bot coverage (#521)', () => {
|
||||
// 3rd-party preview services used by business-messaging stacks
|
||||
'LinkPreview/1.0',
|
||||
'Slack-ImgProxy/1.0',
|
||||
// Viber + broader crawler set (#699 follow-up)
|
||||
'Mozilla/5.0 (compatible; Viber)',
|
||||
'Mozilla/5.0 (compatible; Bluesky Cardyb/1.1)',
|
||||
'facebookcatalog/1.0',
|
||||
'kakaotalk-scrap/1.0',
|
||||
'Mozilla/5.0 (compatible; Synapse/1.98)',
|
||||
'Rocket.Chat/6.0',
|
||||
];
|
||||
for (const ua of knownBots) {
|
||||
expect(isSocialCrawler(ua)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT match human in-app-browser UAs (our OG response is meta-only, no redirect)', () => {
|
||||
// These share a token with a preview bot but are also sent by real users
|
||||
// browsing inside the app's webview — matching them would serve a human
|
||||
// the bare OG stub. Deliberately excluded; guard against re-adding them.
|
||||
const inAppBrowsers = [
|
||||
'Mozilla/5.0 (iPhone) AppleWebKit MicroMessenger/8.0.0', // WeChat in-app
|
||||
'Mozilla/5.0 (iPhone) AppleWebKit Line/13.0.0', // LINE in-app
|
||||
'Mozilla/5.0 (Linux; Android) Zalo', // Zalo in-app
|
||||
'Mozilla/5.0 (Macintosh) Chrome/120.0 Safari/537.36 boxing', // "XING" substring trap
|
||||
];
|
||||
for (const ua of inAppBrowsers) {
|
||||
expect(isSocialCrawler(ua)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not match a regular browser UA', () => {
|
||||
const browsers = [
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||
|
||||
@@ -101,6 +101,7 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', (
|
||||
it('allows access when the event_customer_assignments row exists', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
via: 'customer',
|
||||
customerId: 7,
|
||||
@@ -131,6 +132,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
|
||||
it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
via: 'customer',
|
||||
customerId: 7,
|
||||
@@ -160,6 +162,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => {
|
||||
// and start 403'ing per-event-password sessions.
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
customerId: 7,
|
||||
// intentionally no `via` claim
|
||||
@@ -191,6 +194,7 @@ describe('verifyGalleryAccess — per-event-password JWT', () => {
|
||||
it('does NOT touch event_customer_assignments and passes through', async () => {
|
||||
getGalleryTokenFromRequest.mockReturnValue('tkn');
|
||||
jwt.verify.mockReturnValue({
|
||||
type: 'gallery',
|
||||
eventId: 42,
|
||||
// No via, no customerId — this is the legacy per-event-password
|
||||
// flow where every guest mints their own JWT after entering the
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,6 +18,7 @@ async function adminAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
@@ -140,6 +141,7 @@ async function galleryAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true
|
||||
});
|
||||
@@ -209,7 +211,7 @@ async function photoAuth(req, res, next) {
|
||||
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ async function customerAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
const verified = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true,
|
||||
});
|
||||
|
||||
@@ -66,18 +66,29 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (error) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
|
||||
|
||||
|
||||
// Only gallery-scoped tokens grant gallery access. Every legitimate
|
||||
// path (password login, share link, client access, customer-minted,
|
||||
// slideshow) mints type:'gallery'. Reject anything else — e.g. a guest
|
||||
// identity token (type:'guest', for feedback attribution) that carries a
|
||||
// matching eventId — instead of relying on other token types incidentally
|
||||
// lacking an eventId to fail the id match below.
|
||||
if (decoded.type !== 'gallery') {
|
||||
return res.status(403).json({ error: 'Invalid token type for gallery access' });
|
||||
}
|
||||
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
|
||||
@@ -23,6 +23,7 @@ async function resolveGuest(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
const verified = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth',
|
||||
complete: true,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -32,4 +32,36 @@ function requireEventOwnership(req, res, next) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { requireEventOwnership };
|
||||
/**
|
||||
* Return the subset of `eventIds` the admin may act on, mirroring
|
||||
* requireEventOwnership for bulk routes that can't use it (they take an
|
||||
* array in the body, not an :id param). super_admin gets everything;
|
||||
* other roles get events they created plus ownerless legacy/system
|
||||
* events (created_by IS NULL). Ids that are foreign OR non-existent both
|
||||
* land in `denied` — deliberately indistinguishable, so bulk routes
|
||||
* don't become an ownership/existence oracle.
|
||||
*
|
||||
* @returns {Promise<{allowed: Array, denied: Array}>}
|
||||
*/
|
||||
async function filterOwnedEventIds(admin, eventIds) {
|
||||
if (admin.roleName === 'super_admin') {
|
||||
return { allowed: [...eventIds], denied: [] };
|
||||
}
|
||||
const rows = await db('events')
|
||||
.whereIn('id', eventIds)
|
||||
.andWhere((q) => q.whereNull('created_by').orWhere('created_by', admin.id))
|
||||
.select('id');
|
||||
const allowedSet = new Set(rows.map((r) => r.id));
|
||||
const allowed = [];
|
||||
const denied = [];
|
||||
for (const id of eventIds) {
|
||||
if (allowedSet.has(id) || allowedSet.has(Number(id))) {
|
||||
allowed.push(id);
|
||||
} else {
|
||||
denied.push(id);
|
||||
}
|
||||
}
|
||||
return { allowed, denied };
|
||||
}
|
||||
|
||||
module.exports = { requireEventOwnership, filterOwnedEventIds };
|
||||
|
||||
@@ -28,12 +28,13 @@ async function photoAuth(req, res, next) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||
algorithms: ['HS256'],
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
} catch (issuerError) {
|
||||
// If verification fails with issuer, try without issuer (backward compatibility)
|
||||
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
} else {
|
||||
throw issuerError;
|
||||
}
|
||||
@@ -43,24 +44,36 @@ async function photoAuth(req, res, next) {
|
||||
if (decoded.type === 'gallery') {
|
||||
// For thumbnails, we need to verify the token is for a valid event
|
||||
if (!eventSlug) {
|
||||
// Extract event ID from the decoded token
|
||||
// Resolve the token's event (by id, or legacy slug fallback)...
|
||||
let event = null;
|
||||
if (decoded.eventId) {
|
||||
const event = await db('events')
|
||||
event = await db('events')
|
||||
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
if (event) {
|
||||
}
|
||||
if (!event && decoded.eventSlug) {
|
||||
event = await db('events')
|
||||
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
}
|
||||
// ...then confirm the REQUESTED thumbnail actually belongs to
|
||||
// that event. Thumbnails are stored flat (thumbnails/thumb_<name>)
|
||||
// with deterministic, enumerable filenames derived from the
|
||||
// public event name + a sequential counter. Without this
|
||||
// ownership check any holder of a gallery token for any event
|
||||
// could enumerate and fetch another (password-protected) event's
|
||||
// entire thumbnail set, defeating the gallery password. A
|
||||
// traversal or foreign filename simply fails to match → denied.
|
||||
if (event) {
|
||||
const requestedKey = `thumbnails${req.path}`;
|
||||
const ownsThumbnail = await db('photos')
|
||||
.where({ event_id: event.id, thumbnail_path: requestedKey })
|
||||
.first();
|
||||
if (ownsThumbnail) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
// Fallback to slug
|
||||
const event = await db('events')
|
||||
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
|
||||
.first();
|
||||
if (event) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
}
|
||||
// For regular photos, check if token matches the event
|
||||
else if (decoded.eventSlug === eventSlug) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +87,7 @@ async function sessionTimeoutMiddleware(req, res, next) {
|
||||
|
||||
try {
|
||||
// Verify token is valid
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
|
||||
// Check if this is an admin token
|
||||
if (!decoded.id) {
|
||||
@@ -127,7 +128,7 @@ async function sessionTimeoutMiddleware(req, res, next) {
|
||||
for (const [oldToken, _] of sessions.entries()) {
|
||||
if (oldToken !== token) {
|
||||
try {
|
||||
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET);
|
||||
const oldDecoded = jwt.verify(oldToken, process.env.JWT_SECRET, { algorithms: ['HS256'] });
|
||||
if (oldDecoded.id === userId) {
|
||||
sessions.delete(oldToken);
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
|
||||
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
|
||||
const mfaService = require('../services/mfaService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get admin profile
|
||||
@@ -184,4 +185,175 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => {
|
||||
successResponse(res, { message: 'Logged out successfully' });
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-factor authentication (TOTP) — issue #738.
|
||||
//
|
||||
// All endpoints operate on the AUTHENTICATED admin's own account
|
||||
// (req.admin.id) — enrollment is per-user and works for every role,
|
||||
// super_admin included (closes #735). The TOTP secret is stored encrypted
|
||||
// at rest and recovery codes are hashed; see services/mfaService.js.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const isMfaEnabled = mfaService.isEnrolled;
|
||||
|
||||
// Current MFA state for the logged-in admin.
|
||||
router.get('/mfa/status', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
const enabled = isMfaEnabled(admin);
|
||||
res.json({
|
||||
enabled,
|
||||
enrolledAt: enabled ? admin.two_factor_enrolled_at || null : null,
|
||||
recoveryCodesRemaining: enabled
|
||||
? mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes).length
|
||||
: 0
|
||||
});
|
||||
}));
|
||||
|
||||
// Begin enrollment: mint a provisional secret, store it encrypted (NOT yet
|
||||
// enabled), and return the otpauth URI + QR for the authenticator app. Calling
|
||||
// this again before /enable simply regenerates the provisional secret.
|
||||
router.post('/mfa/setup', adminAuth, handleAsync(async (req, res) => {
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (isMfaEnabled(admin)) {
|
||||
throw new ConflictError('Two-factor authentication is already enabled');
|
||||
}
|
||||
|
||||
const secret = mfaService.generateSecret();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_secret: mfaService.encryptSecret(secret),
|
||||
two_factor_enabled: false,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
const accountName = admin.email || admin.username;
|
||||
const otpauthUri = mfaService.buildOtpauthUri(accountName, secret);
|
||||
const qr = await mfaService.buildQrDataUrl(otpauthUri);
|
||||
|
||||
res.json({
|
||||
// `secret` is returned for manual entry when a QR can't be scanned.
|
||||
secret,
|
||||
otpauthUri,
|
||||
qr,
|
||||
issuer: mfaService.ISSUER,
|
||||
account: accountName
|
||||
});
|
||||
}));
|
||||
|
||||
// Complete enrollment: verify a code against the provisional secret, enable
|
||||
// MFA, and return one-time recovery codes (shown exactly once).
|
||||
router.post('/mfa/enable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('Verification code is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (isMfaEnabled(admin)) {
|
||||
throw new ConflictError('Two-factor authentication is already enabled');
|
||||
}
|
||||
if (!admin.two_factor_secret) {
|
||||
throw new ValidationError('Start setup before enabling two-factor authentication');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_enabled: true,
|
||||
two_factor_enrolled_at: new Date(),
|
||||
two_factor_recovery_codes: JSON.stringify(hashed),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_enabled',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Two-factor authentication enabled',
|
||||
recoveryCodes: plain
|
||||
});
|
||||
}));
|
||||
|
||||
// Disable MFA. Requires a fresh TOTP or recovery code so a hijacked session
|
||||
// can't silently strip the second factor.
|
||||
router.post('/mfa/disable', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('A current code is required to disable 2FA')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
|
||||
const totpOk = mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret);
|
||||
let recoveryOk = false;
|
||||
if (!totpOk) {
|
||||
const stored = mfaService.parseRecoveryCodes(admin.two_factor_recovery_codes);
|
||||
recoveryOk = (await mfaService.consumeRecoveryCode(req.body.code, stored)).matched;
|
||||
}
|
||||
if (!totpOk && !recoveryOk) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_enabled: false,
|
||||
two_factor_secret: null,
|
||||
two_factor_recovery_codes: null,
|
||||
two_factor_enrolled_at: null,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_disabled',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, { message: 'Two-factor authentication disabled' });
|
||||
}));
|
||||
|
||||
// Regenerate recovery codes (invalidates the old set). Requires a fresh TOTP
|
||||
// code. Returns the new codes once.
|
||||
router.post('/mfa/recovery-codes', [
|
||||
adminAuth,
|
||||
body('code').notEmpty().withMessage('A current authenticator code is required')
|
||||
], handleAsync(async (req, res) => {
|
||||
validateRequest(req);
|
||||
const admin = await db('admin_users').where('id', req.admin.id).first();
|
||||
if (!admin) throw new NotFoundError('Admin user');
|
||||
if (!isMfaEnabled(admin)) {
|
||||
throw new ValidationError('Two-factor authentication is not enabled');
|
||||
}
|
||||
if (!mfaService.verifyTotpEncrypted(req.body.code, admin.two_factor_secret)) {
|
||||
throw new ValidationError('Invalid verification code');
|
||||
}
|
||||
|
||||
const { plain, hashed } = await mfaService.generateRecoveryCodes();
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
two_factor_recovery_codes: JSON.stringify(hashed),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
await logActivity('admin_mfa_recovery_regenerated',
|
||||
{ admin_id: admin.id },
|
||||
null,
|
||||
{ type: 'admin', id: admin.id, name: admin.username }
|
||||
);
|
||||
|
||||
successResponse(res, {
|
||||
message: 'Recovery codes regenerated',
|
||||
recoveryCodes: plain
|
||||
});
|
||||
}));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
+256
-215
@@ -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,72 @@ 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');
|
||||
}
|
||||
});
|
||||
|
||||
// Generate + download a portable ".picpeak" export — an engine-neutral logical
|
||||
// snapshot (DB rows as NDJSON + PDFs/business-docs) that can be re-uploaded to
|
||||
// another instance via the web UI. `?includePhotos=true` also bundles original
|
||||
// gallery photos (larger); otherwise the admin re-uploads them per gallery.
|
||||
//
|
||||
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
|
||||
// hashes, API keys). The download UI must warn before offering it. We surface
|
||||
// the flag as a response header too so the client can double-confirm.
|
||||
router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => {
|
||||
const fsSync = require('fs');
|
||||
try {
|
||||
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
|
||||
const { createPicpeak } = require('../services/picpeakExportService');
|
||||
const { filePath } = await createPicpeak({ includePhotos });
|
||||
const filename = path.basename(filePath);
|
||||
res.setHeader('X-Picpeak-Contains-Secrets', 'true');
|
||||
res.download(filePath, filename, (err) => {
|
||||
// Best-effort cleanup of the temp .picpeak (and its temp dir) after send.
|
||||
fsSync.rm(path.dirname(filePath), { recursive: true, force: true }, () => {});
|
||||
if (err) logger.error('[picpeak-export] download failed', { error: err.message });
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[picpeak-export] failed to create export', { error: error.message });
|
||||
if (!res.headersSent) res.status(500).json({ error: 'Failed to create .picpeak export' });
|
||||
}
|
||||
});
|
||||
|
||||
// Multipart upload for .picpeak restore — streamed to a temp file. Runs AFTER
|
||||
// auth so an unauthenticated request can't push a large file to disk.
|
||||
const os = require('os');
|
||||
const multer = require('multer');
|
||||
const picpeakUpload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, os.tmpdir()),
|
||||
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
|
||||
}),
|
||||
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
|
||||
});
|
||||
|
||||
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
|
||||
// all data except the current logged-in account (the client shows an explicit
|
||||
// confirmation before calling this). Returns `usesExternalMedia` so the UI can
|
||||
// prompt the admin to reconfigure the external-media mount afterwards.
|
||||
router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), picpeakUpload.single('backup'), async (req, res) => {
|
||||
const fsSync = require('fs');
|
||||
if (!req.file) return res.status(400).json({ error: 'No backup file uploaded' });
|
||||
const picpeakPath = req.file.path;
|
||||
try {
|
||||
const { importFromPicpeak } = require('../services/picpeakImportService');
|
||||
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id });
|
||||
res.json({
|
||||
success: true,
|
||||
tables: result.tables,
|
||||
filesRestored: result.filesRestored,
|
||||
usesExternalMedia: result.usesExternalMedia,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error.statusCode || 500;
|
||||
logger.error('[picpeak-import] restore failed', { error: error.message });
|
||||
res.status(status).json({ error: error.message || 'Restore failed', validation: error.validation });
|
||||
} finally {
|
||||
fsSync.unlink(picpeakPath, () => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -155,8 +218,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 +252,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 +265,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 +275,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 +439,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 +551,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 +582,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 +618,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 +679,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 +730,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 +756,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 +889,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 +991,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 {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,14 +7,16 @@ const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const eventRenameService = require('../services/eventRenameService');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/admin/events/:eventId/rename
|
||||
* Rename an event
|
||||
*/
|
||||
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
|
||||
router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||
body('newEventName')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
@@ -50,7 +52,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' });
|
||||
}
|
||||
});
|
||||
@@ -59,7 +61,7 @@ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), [
|
||||
* POST /api/admin/events/:eventId/validate-rename
|
||||
* Validate a potential rename without executing it
|
||||
*/
|
||||
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), [
|
||||
router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
|
||||
body('newEventName')
|
||||
.trim()
|
||||
.isLength({ min: 3, max: 100 })
|
||||
@@ -81,7 +83,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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user