Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e94e440858 |
@@ -95,15 +95,6 @@ jobs:
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
|
||||
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
|
||||
# other owner) fall back to GHCR-only — the Docker Hub image line and login
|
||||
# are gated on this flag so their builds keep working unchanged.
|
||||
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
|
||||
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
@@ -242,15 +233,6 @@ jobs:
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
|
||||
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
|
||||
# other owner) fall back to GHCR-only — the Docker Hub image line and login
|
||||
# are gated on this flag so their builds keep working unchanged.
|
||||
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
|
||||
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -284,24 +266,11 @@ jobs:
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: env.DOCKERHUB_ENABLED == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# GHCR always; Docker Hub (picpeak/backend) added on the canonical repo so
|
||||
# the same tag scheme is mirrored to both registries. metadata-action drops
|
||||
# the blank second line on forks → GHCR-only there.
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/backend' || '' }}
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Backend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform backend service
|
||||
@@ -313,10 +282,6 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
|
||||
# so users can pin the same string as the GitHub release. metadata-action's
|
||||
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
|
||||
# stable release tags). The default branch is now `main` (active dev),
|
||||
@@ -333,15 +298,10 @@ jobs:
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
|
||||
|
||||
- name: Inspect manifest (GHCR)
|
||||
- name: Inspect manifest
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
- name: Inspect manifest (Docker Hub)
|
||||
if: env.DOCKERHUB_ENABLED == 'true'
|
||||
run: |
|
||||
docker buildx imagetools inspect docker.io/picpeak/backend:${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Frontend: per-arch build, then merge into a multi-arch manifest
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -371,15 +331,6 @@ jobs:
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
|
||||
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
|
||||
# other owner) fall back to GHCR-only — the Docker Hub image line and login
|
||||
# are gated on this flag so their builds keep working unchanged.
|
||||
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
|
||||
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
@@ -499,15 +450,6 @@ jobs:
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
|
||||
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
|
||||
# other owner) fall back to GHCR-only — the Docker Hub image line and login
|
||||
# are gated on this flag so their builds keep working unchanged.
|
||||
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
|
||||
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -541,24 +483,11 @@ jobs:
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: env.DOCKERHUB_ENABLED == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# GHCR always; Docker Hub (picpeak/frontend) added on the canonical repo so
|
||||
# the same tag scheme is mirrored to both registries. metadata-action drops
|
||||
# the blank second line on forks → GHCR-only there.
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/frontend' || '' }}
|
||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Frontend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
|
||||
@@ -570,10 +499,6 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
|
||||
# so users can pin the same string as the GitHub release. metadata-action's
|
||||
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
|
||||
# stable release tags). The default branch is now `main` (active dev),
|
||||
@@ -590,15 +515,10 @@ jobs:
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
|
||||
|
||||
- name: Inspect manifest (GHCR)
|
||||
- name: Inspect manifest
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
- name: Inspect manifest (Docker Hub)
|
||||
if: env.DOCKERHUB_ENABLED == 'true'
|
||||
run: |
|
||||
docker buildx imagetools inspect docker.io/picpeak/frontend:${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
summary:
|
||||
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
|
||||
if: always()
|
||||
@@ -612,15 +532,6 @@ jobs:
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
|
||||
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
|
||||
# other owner) fall back to GHCR-only — the Docker Hub image line and login
|
||||
# are gated on this flag so their builds keep working unchanged.
|
||||
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
|
||||
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
@@ -659,10 +570,6 @@ jobs:
|
||||
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
|
||||
echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -25,7 +25,6 @@ jobs:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
target-branch: stable
|
||||
|
||||
# Auto-approve + auto-merge the open stable release PR. See the beta
|
||||
# workflow for the full rationale. Skipped on the release-cutting run and
|
||||
|
||||
@@ -17,9 +17,9 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, beta, stable]
|
||||
branches: [main, beta]
|
||||
pull_request:
|
||||
branches: [main, beta, stable]
|
||||
branches: [main, beta]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.88.1-beta.0"
|
||||
".": "3.82.4-beta.0"
|
||||
}
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
{".":"3.44.0"}
|
||||
{
|
||||
".": "2.6.1"
|
||||
}
|
||||
|
||||
-120
@@ -5,126 +5,6 @@ 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.88.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.88.0-beta.0...v3.88.1-beta.0) (2026-07-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** mask backup credentials on read + unblock MFA login during maintenance ([eadf282](https://github.com/PicPeak/picpeak/commit/eadf282755829cb51e6ea37221be31d8c9af41c5))
|
||||
* **security:** mask backup credentials on read + unblock MFA login during maintenance ([07f2c90](https://github.com/PicPeak/picpeak/commit/07f2c900556738e993fb63764210b541d7692c9d))
|
||||
|
||||
## [3.88.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.87.0-beta.0...v3.88.0-beta.0) (2026-07-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **setup:** event-types step in first-run wizard + un-hardcode event type dependencies ([109aba8](https://github.com/PicPeak/picpeak/commit/109aba859820bf80440d056baf183ecf2657fee3))
|
||||
* **setup:** event-types step in first-run wizard + un-hardcode event type deps ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([7eb6357](https://github.com/PicPeak/picpeak/commit/7eb6357b4a9bf3914674a63afa386a5fcf8c2161))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **event-types:** harden setup window + catalog validation (codex review) ([f8ba669](https://github.com/PicPeak/picpeak/commit/f8ba6697163b4d9aa0fa0014cb5b0810371c04ae))
|
||||
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([d64eef8](https://github.com/PicPeak/picpeak/commit/d64eef8abf2915230b3cdd38a3bbb8af1a12c6d2))
|
||||
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([5da1c3a](https://github.com/PicPeak/picpeak/commit/5da1c3a12f603a230091426b1d7be0eac83da22c))
|
||||
* **gallery:** show feedback filter chips on desktop for galleries without categories ([0751a08](https://github.com/PicPeak/picpeak/commit/0751a08aa661a430c1609cd8c118347291cbaa14))
|
||||
* **gallery:** show feedback filter chips on desktop for galleries without categories ([#802](https://github.com/PicPeak/picpeak/issues/802)) ([b928338](https://github.com/PicPeak/picpeak/commit/b9283386a57431ac8bd395347f9acb9bbdf82e8e))
|
||||
|
||||
## [3.87.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.86.0-beta.0...v3.87.0-beta.0) (2026-07-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **invoices:** configurable VAT note under MwSt. line + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([ffd4a7e](https://github.com/PicPeak/picpeak/commit/ffd4a7eee64b6418df1c9cc6843d86dc0f41d2ec))
|
||||
* **invoices:** configurable VAT/free-text note + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([1476884](https://github.com/PicPeak/picpeak/commit/1476884dd04202f5f18d50d458b6176b0535c71b))
|
||||
|
||||
## [3.86.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.85.0-beta.0...v3.86.0-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([d51112e](https://github.com/PicPeak/picpeak/commit/d51112e761d2fd83f1939841fbf4c05e625fc34d))
|
||||
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([4698402](https://github.com/PicPeak/picpeak/commit/4698402b5493cfbdb1e2b6d81c6f58829e17a703))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **categories:** address PR [#790](https://github.com/PicPeak/picpeak/issues/790) review — event ownership, migration renumber, nits ([a4b4485](https://github.com/PicPeak/picpeak/commit/a4b4485d322514690c5400ca7ab9a91bc25c3e48))
|
||||
|
||||
## [3.85.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.1-beta.0...v3.85.0-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **slideshow:** per-event play order + category filter ([#202](https://github.com/PicPeak/picpeak/issues/202)) ([5467642](https://github.com/PicPeak/picpeak/commit/54676424f2f7ed50e74cb8e144cbdaa5a96e65c3))
|
||||
|
||||
## [3.84.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.0-beta.0...v3.84.1-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([1f3bc3c](https://github.com/PicPeak/picpeak/commit/1f3bc3c3430414b5b6cb2141d887a8b5855a04af))
|
||||
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([39db7bf](https://github.com/PicPeak/picpeak/commit/39db7bf6cb5c39fcdf71c875a4aaf704f34447fa))
|
||||
|
||||
## [3.84.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.1-beta.0...v3.84.0-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([279e047](https://github.com/PicPeak/picpeak/commit/279e0472c71c6a37ba091a9c7a31f5571c0a8df6))
|
||||
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([d3d7df4](https://github.com/PicPeak/picpeak/commit/d3d7df46f214028ba89063079d356bc0430083f5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([2ee4146](https://github.com/PicPeak/picpeak/commit/2ee4146d9a6fd026e7b7be3ba774de9a0cf6e96a))
|
||||
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([784d059](https://github.com/PicPeak/picpeak/commit/784d059c3da5b36e2b6794ebf2e34bc15c8a9824))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **releasing:** align stable version to main on promote (Option A) ([df5aeab](https://github.com/PicPeak/picpeak/commit/df5aeaba416726cc0123f32ddf88e4a30dc28908))
|
||||
* **releasing:** align stable version to main on promote (Option A) ([5dea0c9](https://github.com/PicPeak/picpeak/commit/5dea0c969558f50833973ff742257780f5842612))
|
||||
|
||||
## [3.83.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.0-beta.0...v3.83.1-beta.0) (2026-07-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** target stable in release-please + undo bogus 2.7.0 bump ([274ef0c](https://github.com/PicPeak/picpeak/commit/274ef0cd731765b057a5d62d5f41c14cb3a1564b))
|
||||
* **release:** target stable in release-please.yml + undo the bogus 2.7.0 bump ([65ac6ed](https://github.com/PicPeak/picpeak/commit/65ac6eddacb79857e9a9651d3c869e7bfdd92887))
|
||||
|
||||
## [3.83.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.6-beta.0...v3.83.0-beta.0) (2026-07-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **messages:** create/select quote, contract, invoice, gallery from a message ([0dbf863](https://github.com/PicPeak/picpeak/commit/0dbf863f60b919560b766f78b107ebac9612bd9d))
|
||||
* **messages:** search bar + Archive/Delete with Archived & Deleted folders ([99d5996](https://github.com/PicPeak/picpeak/commit/99d5996561a2dcff2d431692d5bab5c7286d1f6f))
|
||||
* **messages:** unified Messages email client (flag-gated, default off) ([a71b9b5](https://github.com/PicPeak/picpeak/commit/a71b9b5ed721df17b61062ae3a2361d448c95cf7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) nits — server-side search, bare-email recipient, DE i18n ([1e08a4f](https://github.com/PicPeak/picpeak/commit/1e08a4fb156d34ee8ddff69b0a7612001aa6d67e))
|
||||
* **messages:** PR [#769](https://github.com/PicPeak/picpeak/issues/769) review — escape reply sender (XSS), gate backend routes, exact customer match ([bb235e7](https://github.com/PicPeak/picpeak/commit/bb235e72e58359f55f8aeccf2f22c671584fdbd7))
|
||||
* **messages:** show the resolved customer's name in the doc-action modal ([2c5c1d5](https://github.com/PicPeak/picpeak/commit/2c5c1d561bbe567b9d7615e7c2d071d08bb6d63c))
|
||||
|
||||
## [3.82.6-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.5-beta.0...v3.82.6-beta.0) (2026-07-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **workflows:** backfill existing invoices + anchor dunning grace to due date when enabled ([#750](https://github.com/PicPeak/picpeak/issues/750)) ([9596342](https://github.com/PicPeak/picpeak/commit/9596342d6a9ef107193cfc123487a8061f4a91ca))
|
||||
* **workflows:** scope dunning backfill to its own flow via targetWorkflowId ([da3a77d](https://github.com/PicPeak/picpeak/commit/da3a77dac40a892158167aec939a1458d488a951))
|
||||
|
||||
## [3.82.5-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.4-beta.0...v3.82.5-beta.0) (2026-07-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** stop the event-date field crashing the page on backspace ([760a201](https://github.com/PicPeak/picpeak/commit/760a201b6070a4edfe8192bcddce948c5f0c3fec))
|
||||
|
||||
## [3.82.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.3-beta.0...v3.82.4-beta.0) (2026-07-07)
|
||||
|
||||
|
||||
|
||||
+4
-18
@@ -52,19 +52,13 @@ The actual mechanics, in order:
|
||||
- **`.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. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
|
||||
```bash
|
||||
git commit --allow-empty -m "chore: release X.Y.Z" -m "Release-As: X.Y.Z"
|
||||
```
|
||||
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
|
||||
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. **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 `stable`'s log.
|
||||
|
||||
7. **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(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. **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.
|
||||
|
||||
9. **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.
|
||||
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)
|
||||
|
||||
@@ -89,14 +83,6 @@ PicPeak follows [Semantic Versioning](https://semver.org/) with one project-spec
|
||||
|
||||
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
|
||||
|
||||
### Stable ↔ pre-release number alignment
|
||||
|
||||
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
|
||||
|
||||
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
|
||||
|
||||
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
|
||||
|
||||
## Things that don't go through this process
|
||||
|
||||
- **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.
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Backup credential exposure regression tests.
|
||||
*
|
||||
* The generic settings reads (GET /admin/settings, GET /admin/settings/:type)
|
||||
* masked the recaptcha/umami/rybbit keys but returned backup_s3_secret_key
|
||||
* and backup_rsync_ssh_key (an SSH PRIVATE KEY) in plaintext to any
|
||||
* settings.view holder; GET /admin/backup/config returned them too. Both now
|
||||
* mask, and PUT /admin/backup/config skips the mask sentinel so the edit
|
||||
* form round-trips without clobbering stored credentials.
|
||||
*/
|
||||
|
||||
const request = require('supertest');
|
||||
const express = require('express');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => {
|
||||
req.admin = { id: 1, username: 'test-admin' };
|
||||
next();
|
||||
},
|
||||
}));
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
describe('backup credential masking', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
|
||||
// Upsert: several backup_* keys are pre-seeded by the backup migrations.
|
||||
const seed = [
|
||||
{ setting_key: 'backup_destination_type', setting_value: JSON.stringify('s3'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_endpoint', setting_value: JSON.stringify('https://s3.example.com'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_bucket', setting_value: JSON.stringify('backups'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_access_key', setting_value: JSON.stringify('AKIAEXAMPLE'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_s3_secret_key', setting_value: JSON.stringify('super-secret-s3-key'), setting_type: 'backup' },
|
||||
{ setting_key: 'backup_rsync_ssh_key', setting_value: JSON.stringify('-----BEGIN OPENSSH PRIVATE KEY-----abc'), setting_type: 'backup' },
|
||||
];
|
||||
for (const row of seed) {
|
||||
await db('app_settings').insert(row).onConflict('setting_key').merge();
|
||||
}
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
|
||||
app.use('/api/admin/settings', require('../../src/routes/adminSettings'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('masks the credentials in GET /admin/backup/config', async () => {
|
||||
const res = await request(app).get('/api/admin/backup/config').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
// Non-secret fields stay readable for the form.
|
||||
expect(res.body.backup_s3_bucket).toBe('backups');
|
||||
});
|
||||
|
||||
it('masks the credentials in the generic GET /admin/settings/:type read', async () => {
|
||||
const res = await request(app).get('/api/admin/settings/backup').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('masks the credentials in the generic GET /admin/settings read', async () => {
|
||||
const res = await request(app).get('/api/admin/settings').expect(200);
|
||||
expect(res.body.backup_s3_secret_key).toBe('••••••••');
|
||||
expect(res.body.backup_rsync_ssh_key).toBe('••••••••');
|
||||
});
|
||||
|
||||
it('PUT /admin/backup/config keeps the stored secret when the sentinel round-trips', async () => {
|
||||
await request(app)
|
||||
.put('/api/admin/backup/config')
|
||||
.send({
|
||||
backup_destination_type: 's3',
|
||||
backup_s3_endpoint: 'https://s3.example.com',
|
||||
backup_s3_bucket: 'renamed-bucket',
|
||||
backup_s3_access_key: 'AKIAEXAMPLE',
|
||||
backup_s3_secret_key: '••••••••',
|
||||
backup_rsync_ssh_key: '••••••••',
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
||||
expect(JSON.parse(secret.setting_value)).toBe('super-secret-s3-key');
|
||||
const sshKey = await db('app_settings').where({ setting_key: 'backup_rsync_ssh_key' }).first();
|
||||
expect(JSON.parse(sshKey.setting_value)).toBe('-----BEGIN OPENSSH PRIVATE KEY-----abc');
|
||||
const bucket = await db('app_settings').where({ setting_key: 'backup_s3_bucket' }).first();
|
||||
expect(JSON.parse(bucket.setting_value)).toBe('renamed-bucket');
|
||||
});
|
||||
|
||||
it('PUT /admin/backup/config stores a genuinely new secret', async () => {
|
||||
await request(app)
|
||||
.put('/api/admin/backup/config')
|
||||
.send({ backup_s3_secret_key: 'rotated-s3-key' })
|
||||
.expect(200);
|
||||
|
||||
const secret = await db('app_settings').where({ setting_key: 'backup_s3_secret_key' }).first();
|
||||
expect(JSON.parse(secret.setting_value)).toBe('rotated-s3-key');
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
/**
|
||||
* Layered per-event category ordering (#782).
|
||||
*
|
||||
* Two ordering layers, resolved per event:
|
||||
* - GLOBAL default — photo_categories.display_order (migration 159),
|
||||
* set via POST /reorder-global; applies everywhere.
|
||||
* - PER-EVENT override — event_category_order (migration 160), set via
|
||||
* POST /reorder; overrides the default for one gallery.
|
||||
* - DELETE /reorder/:eventId clears an event's override.
|
||||
*
|
||||
* Verified against a real SQLite DB with the full core-migration set applied.
|
||||
*/
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('category ordering (#782)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let token;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const auth = (r) => r.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
async function insertEvent(slug) {
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x',
|
||||
expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug, share_link: slug,
|
||||
event_name: slug, event_date: '2026-01-01',
|
||||
});
|
||||
return (await db('events').where({ slug }).first()).id;
|
||||
}
|
||||
|
||||
async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) {
|
||||
const res = await db('photo_categories').insert({
|
||||
name,
|
||||
slug: name.toLowerCase().replace(/\s+/g, '-'),
|
||||
is_global: is_global ? 1 : 0,
|
||||
event_id,
|
||||
display_order,
|
||||
}).returning('id');
|
||||
return res[0]?.id ?? res[0];
|
||||
}
|
||||
|
||||
const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200);
|
||||
|
||||
describe('migration 159 backfill', () => {
|
||||
it('seeds display_order from alphabetical order, scoped per event', async () => {
|
||||
const eventId = await insertEvent('backfill-ev');
|
||||
await insertCat('Reception', { event_id: eventId });
|
||||
await insertCat('Ceremony', { event_id: eventId });
|
||||
await insertCat('Pre-Ceremony', { event_id: eventId });
|
||||
|
||||
// Re-run the migration: addColumn is guarded (no-op); the backfill loop
|
||||
// re-runs and assigns per-scope alphabetical order — what an upgrade does.
|
||||
await require('../../migrations/core/159_add_category_display_order').up(db);
|
||||
|
||||
const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc');
|
||||
expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']);
|
||||
expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global default order (POST /reorder-global)', () => {
|
||||
it('reverses the global order and every non-customised event follows it', async () => {
|
||||
const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body;
|
||||
expect(before.length).toBeGreaterThan(1);
|
||||
const reversedIds = before.map((c) => c.id).reverse();
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories/reorder-global'))
|
||||
.send({ orderedIds: reversedIds })
|
||||
.expect(200);
|
||||
expect(res.body.map((c) => c.id)).toEqual(reversedIds);
|
||||
|
||||
// A fresh event (no override) shows globals in the new global order.
|
||||
const eventId = await insertEvent('follows-global');
|
||||
const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id);
|
||||
expect(globalsInEvent).toEqual(reversedIds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-event override (POST /reorder)', () => {
|
||||
it('pins a custom order for one event without affecting another', async () => {
|
||||
const eventA = await insertEvent('override-a');
|
||||
const eventB = await insertEvent('override-b');
|
||||
const a1 = await insertCat('A-Ceremony', { event_id: eventA });
|
||||
const a2 = await insertCat('A-Reception', { event_id: eventA });
|
||||
|
||||
// Current resolved list for A (globals + A's two categories).
|
||||
const listA = (await getEvent(eventA)).body;
|
||||
// Put A-Reception first, then A-Ceremony, then the globals in their order.
|
||||
const globalsA = listA.filter((c) => c.is_global).map((c) => c.id);
|
||||
const desired = [a2, a1, ...globalsA];
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventA, orderedIds: desired })
|
||||
.expect(200);
|
||||
expect(res.body.map((c) => c.id)).toEqual(desired);
|
||||
// override_position is set on every row for a customised event.
|
||||
expect(res.body.every((c) => c.override_position != null)).toBe(true);
|
||||
|
||||
// Event B is untouched — no override, follows the global default.
|
||||
const listB = (await getEvent(eventB)).body;
|
||||
expect(listB.every((c) => c.override_position == null)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts global ids but rejects another event’s category', async () => {
|
||||
const eventId = await insertEvent('scope-ev');
|
||||
const own = await insertCat('Own', { event_id: eventId });
|
||||
const global = (await db('photo_categories').where('is_global', 1).first()).id;
|
||||
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
|
||||
|
||||
// A global id is allowed (globals can be arranged per event).
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, global] })
|
||||
.expect(200);
|
||||
|
||||
// A foreign event's category is out of scope.
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, foreign] })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset (DELETE /reorder/:eventId)', () => {
|
||||
it('clears the override and reverts to the global default', async () => {
|
||||
const eventId = await insertEvent('reset-ev');
|
||||
const c1 = await insertCat('R-One', { event_id: eventId });
|
||||
const list = (await getEvent(eventId)).body;
|
||||
const globals = list.filter((c) => c.is_global).map((c) => c.id);
|
||||
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
|
||||
.expect(200);
|
||||
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
|
||||
|
||||
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
|
||||
expect(res.body.every((c) => c.override_position == null)).toBe(true);
|
||||
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('event ownership (PR #790 review)', () => {
|
||||
let limitedToken;
|
||||
let foreignEventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
// A non-super_admin role that DOES hold settings.view + settings.edit —
|
||||
// the exact case the review flagged (settings.edit is grantable).
|
||||
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
|
||||
const roleId = roleRes[0]?.id ?? roleRes[0];
|
||||
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
|
||||
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
|
||||
|
||||
const a2 = await db('admin_users').insert({
|
||||
username: 'limited', email: 'limited@example.com',
|
||||
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
|
||||
must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
|
||||
|
||||
// An event owned by a DIFFERENT admin (the seeded super_admin).
|
||||
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x',
|
||||
expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
|
||||
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
|
||||
});
|
||||
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
|
||||
});
|
||||
|
||||
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
|
||||
|
||||
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
|
||||
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
|
||||
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
|
||||
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST / (create) appends to the end of its scope', () => {
|
||||
it('assigns display_order = max + 1 within the event', async () => {
|
||||
const eventId = await insertEvent('append-ev');
|
||||
await insertCat('First', { event_id: eventId, display_order: 1 });
|
||||
await insertCat('Second', { event_id: eventId, display_order: 2 });
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories'))
|
||||
.send({ name: 'Third', is_global: false, event_id: eventId })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.display_order).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Catalog-driven event-type defaults (#800 follow-up).
|
||||
*
|
||||
* The contract→event conversion used to hardcode `event_type: 'wedding'` and
|
||||
* the v1 API validated against a fixed whitelist. Both now follow the live
|
||||
* event_types catalog; these tests pin the shared resolver.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('resolveDefaultEventType follows the catalog', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventTypeService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Require AFTER bootCrmDb so the service shares this db instance
|
||||
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it("prefers the 'other' catch-all while it is active", async () => {
|
||||
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
|
||||
});
|
||||
|
||||
it('falls over to the first active type when other is deactivated', async () => {
|
||||
const other = await db('event_types').where({ slug_prefix: 'other' }).first();
|
||||
await db('event_types').where({ id: other.id }).update({ is_active: 0 });
|
||||
|
||||
const resolved = await eventTypeService.resolveDefaultEventType();
|
||||
expect(resolved).not.toBe('other');
|
||||
expect(await db('event_types').where({ slug_prefix: resolved }).first()).toBeTruthy();
|
||||
|
||||
await db('event_types').where({ id: other.id }).update({ is_active: 1 });
|
||||
});
|
||||
|
||||
it("returns the literal 'other' only for an empty catalog", async () => {
|
||||
const rows = await db('event_types').select('*');
|
||||
await db('event_types').del();
|
||||
|
||||
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
|
||||
|
||||
await db('event_types').insert(rows);
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Setup-window event type deletion (#800).
|
||||
*
|
||||
* The first-run setup wizard may delete the seeded SYSTEM event types —
|
||||
* but ONLY while the `setup_wizard_completed` flag is unset (migration 161
|
||||
* seeds it false on a fresh install, true when an admin already exists).
|
||||
* These tests pin the whole contract:
|
||||
*
|
||||
* - fresh install → flag false → system types deletable (in-use checks
|
||||
* still apply), and the per-type reminder template goes with the type
|
||||
* - reminder-template self-heal does NOT resurrect templates for slugs
|
||||
* that no longer exist in the catalog
|
||||
* - after markSetupWizardCompleted() → system deletion is refused again
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('event type deletion during the setup window (#800)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventTypeService;
|
||||
let setupService;
|
||||
let ensureEventReminderTemplatesSeeded;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Require AFTER bootCrmDb so every service shares this db instance
|
||||
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ ensureEventReminderTemplatesSeeded } = require('../../src/services/eventReminderTemplates'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('migration 161 seeds the flag false on a fresh (admin-less) install', async () => {
|
||||
const row = await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(JSON.parse(row.setting_value)).toBe(false);
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to delete a system type that events already use, even in the window', async () => {
|
||||
const corporate = await db('event_types').where({ slug_prefix: 'corporate' }).first();
|
||||
await db('events').insert({
|
||||
slug: 'corporate-test-2026-01-01',
|
||||
event_name: 'Test',
|
||||
event_type: 'corporate',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: 'share-corporate-test',
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
});
|
||||
|
||||
await expect(eventTypeService.deleteEventType(corporate.id))
|
||||
.rejects.toMatchObject({ code: 'IN_USE' });
|
||||
});
|
||||
|
||||
it('deletes an unused system type in the window, taking its reminder template along', async () => {
|
||||
// Seed the per-type reminder templates first so there is something to clean up.
|
||||
await ensureEventReminderTemplatesSeeded(db);
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeTruthy();
|
||||
|
||||
const wedding = await db('event_types').where({ slug_prefix: 'wedding' }).first();
|
||||
expect(wedding.is_system).toBeTruthy();
|
||||
|
||||
const result = await eventTypeService.deleteEventType(wedding.id);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy();
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
|
||||
|
||||
// The deleted slug must NOT stay creatable through the legacy fallback —
|
||||
// the live catalog is authoritative while it has rows.
|
||||
expect(await eventTypeService.isValidEventType('wedding')).toBe(false);
|
||||
expect(await eventTypeService.isValidEventType('birthday')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => {
|
||||
// The seeder caches success per process — reset the module to force a
|
||||
// genuine second pass, exactly what a backend restart would run.
|
||||
jest.resetModules();
|
||||
const fresh = require('../../src/services/eventReminderTemplates');
|
||||
await fresh.ensureEventReminderTemplatesSeeded(db);
|
||||
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
|
||||
// Types still in the catalog keep their templates.
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_birthday' }).first()).toBeTruthy();
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_default' }).first()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('re-locks system types once the wizard is marked complete', async () => {
|
||||
await setupService.markSetupWizardCompleted();
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(true);
|
||||
|
||||
const birthday = await db('event_types').where({ slug_prefix: 'birthday' }).first();
|
||||
await expect(eventTypeService.deleteEventType(birthday.id))
|
||||
.rejects.toMatchObject({ code: 'SYSTEM_TYPE' });
|
||||
|
||||
// Custom (non-system) types remain deletable as before.
|
||||
const custom = await eventTypeService.createEventType({ name: 'Family', slug_prefix: 'family' });
|
||||
const result = await eventTypeService.deleteEventType(custom.id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when the completion marker row is missing', async () => {
|
||||
// A portable-backup restore can replace app_settings with a set that
|
||||
// predates migration 161 (which will not rerun) — absence must mean
|
||||
// "configured instance", never an open deletion window.
|
||||
await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(true);
|
||||
await setupService.markSetupWizardCompleted();
|
||||
});
|
||||
|
||||
it('refuses to delete the last remaining event type', async () => {
|
||||
// Reduce the catalog to a single custom type via direct db writes (the
|
||||
// service paths are already covered above), then hit the guard.
|
||||
const solo = await eventTypeService.createEventType({ name: 'Solo', slug_prefix: 'solo' });
|
||||
await db('events').del();
|
||||
await db('event_types').whereNot('id', solo.id).del();
|
||||
|
||||
await expect(eventTypeService.deleteEventType(solo.id))
|
||||
.rejects.toMatchObject({ code: 'LAST_TYPE' });
|
||||
|
||||
// Deactivating it would empty the ACTIVE catalog just the same.
|
||||
await expect(eventTypeService.updateEventType(solo.id, { is_active: false }))
|
||||
.rejects.toMatchObject({ code: 'LAST_ACTIVE' });
|
||||
});
|
||||
});
|
||||
@@ -239,7 +239,7 @@ describe('workflow engine', () => {
|
||||
expect(again.already).toBe(true);
|
||||
});
|
||||
|
||||
test('seeds the invoice-dunning built-in as the delegation graph (disabled for first beta)', async () => {
|
||||
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);
|
||||
@@ -248,7 +248,7 @@ describe('workflow engine', () => {
|
||||
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(7);
|
||||
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);
|
||||
@@ -273,7 +273,7 @@ describe('workflow engine', () => {
|
||||
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(7);
|
||||
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
|
||||
|
||||
@@ -234,66 +234,3 @@ describe('renderInvoiceToBuffer — Storno branch', () => {
|
||||
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
|
||||
});
|
||||
});
|
||||
|
||||
// VAT free-text note (#794) + multi-page page-number placement. Same
|
||||
// constraint as the Storno tests: PDFKit Flate-compresses content streams,
|
||||
// so we can't grep the note text — but the page-TREE objects are NOT
|
||||
// compressed, so `/Type /Page` (not `/Pages`) is countable to assert
|
||||
// pagination, and a byte-size delta proves the note actually rendered.
|
||||
describe('renderInvoiceToBuffer — VAT note + multi-page footer (#794)', () => {
|
||||
function baseCtx(overrides = {}) {
|
||||
return {
|
||||
locale: 'de', currency: 'CHF',
|
||||
issuer: { companyName: 'AcmeCo' },
|
||||
recipient: {
|
||||
companyName: 'KundenCo', addressLine1: 'Strasse 1',
|
||||
city: 'Bern', postalCode: '3000',
|
||||
},
|
||||
lineItems: [{
|
||||
quantity: 1, description: 'Photo session',
|
||||
unitPriceMinor: 30000, lineTotalMinor: 30000,
|
||||
parentLineItemId: null, parentPosition: null,
|
||||
}],
|
||||
totals: {
|
||||
netAmountMinor: 30000, vatRate: 0, vatAmountMinor: 0,
|
||||
shippingAmountMinor: 0, totalAmountMinor: 30000,
|
||||
},
|
||||
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||
qrFormat: 'none',
|
||||
paymentTerm: { netDays: 30 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const pageCount = (buf) => (buf.toString('latin1').match(/\/Type\s*\/Page(?![s])/g) || []).length;
|
||||
const VAT_NOTE = 'Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).';
|
||||
|
||||
it('renders the VAT note on a single-page invoice (adds content, valid PDF)', async () => {
|
||||
const withNote = await pdfService.renderInvoiceToBuffer(baseCtx({ vatNote: VAT_NOTE }));
|
||||
const without = await pdfService.renderInvoiceToBuffer(baseCtx());
|
||||
expect(withNote.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
expect(pageCount(withNote)).toBe(1);
|
||||
expect(withNote.length).toBeGreaterThan(without.length);
|
||||
});
|
||||
|
||||
it('paginates a long invoice (with the note) across multiple pages without a stray blank page', async () => {
|
||||
const manyItems = Array.from({ length: 60 }, (_, i) => ({
|
||||
quantity: 1, description: `Position ${i + 1} — fotografische Leistung`,
|
||||
unitPriceMinor: 3225, lineTotalMinor: 3225,
|
||||
parentLineItemId: null, parentPosition: null,
|
||||
}));
|
||||
const buf = await pdfService.renderInvoiceToBuffer(baseCtx({
|
||||
lineItems: manyItems,
|
||||
totals: {
|
||||
netAmountMinor: 193500, vatRate: 0, vatAmountMinor: 0,
|
||||
shippingAmountMinor: 0, totalAmountMinor: 193500,
|
||||
},
|
||||
vatNote: VAT_NOTE,
|
||||
}));
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
const pages = pageCount(buf);
|
||||
expect(pages).toBeGreaterThanOrEqual(2);
|
||||
// 60 short rows fit in 2–3 pages; a stray blank page (the old margin
|
||||
// bug) or a runaway loop would blow past this.
|
||||
expect(pages).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Messages Phase 2 — additional inbound mailboxes + captured message bodies.
|
||||
*
|
||||
* `mail_accounts` holds inbound mailboxes BEYOND the primary accounting IMAP
|
||||
* that already lives in `email_configs` (e.g. the customer `hello@` mailbox).
|
||||
* The intake poller (emailIntakeService) polls the accounting mailbox AND every
|
||||
* enabled row here; customer mail is logged with its body but not routed to the
|
||||
* accounting inbox.
|
||||
*
|
||||
* The new `received_emails` columns capture the parsed message so the Messages
|
||||
* reading pane can show it: `account_key` tags which mailbox it came from,
|
||||
* `body_html`/`body_text` hold the (server-sanitized) body, `to_address` the
|
||||
* envelope recipient. All additive + guarded.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
const hasAccounts = await knex.schema.hasTable('mail_accounts');
|
||||
if (!hasAccounts) {
|
||||
await knex.schema.createTable('mail_accounts', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.string('account_key', 64).notNullable().unique(); // e.g. 'customers'
|
||||
t.string('label', 120);
|
||||
t.string('imap_host', 255);
|
||||
t.integer('imap_port').defaultTo(993);
|
||||
t.boolean('imap_secure').defaultTo(true);
|
||||
t.string('imap_user', 255);
|
||||
t.string('imap_pass', 512);
|
||||
t.string('imap_folder', 255).defaultTo('INBOX');
|
||||
t.boolean('enabled').defaultTo(false);
|
||||
t.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
t.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
const cols = [
|
||||
['account_key', (t) => t.string('account_key', 64)],
|
||||
['to_address', (t) => t.string('to_address', 512)],
|
||||
['body_html', (t) => t.text('body_html')],
|
||||
['body_text', (t) => t.text('body_text')],
|
||||
];
|
||||
for (const [name, add] of cols) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn('received_emails', name);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!has) await knex.schema.alterTable('received_emails', add);
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
// Non-destructive on the audit log: leave the added columns in place (they're
|
||||
// nullable and harmless). Only drop the new table.
|
||||
await knex.schema.dropTableIfExists('mail_accounts');
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Messages Phase 3 — distinguish human-composed sends from system mail.
|
||||
*
|
||||
* `origin` is 'system' for everything the app queues automatically (invoices,
|
||||
* reminders, gallery notices — the Automated stream) and 'manual' for emails an
|
||||
* admin composed/edited in the Messages composer (replies + document messages —
|
||||
* the Customers ▸ Sent stream). Existing rows default to 'system'.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
const has = await knex.schema.hasColumn('email_queue', 'origin');
|
||||
if (!has) {
|
||||
await knex.schema.alterTable('email_queue', (t) => {
|
||||
t.string('origin', 16).defaultTo('system');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const has = await knex.schema.hasColumn('email_queue', 'origin');
|
||||
if (has) {
|
||||
await knex.schema.alterTable('email_queue', (t) => {
|
||||
t.dropColumn('origin');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Messages Phase 3 follow-up — outgoing (SMTP) settings per mail account.
|
||||
*
|
||||
* The customer mailbox (hello@) needs BOTH incoming (IMAP, migration 154) and
|
||||
* outgoing (SMTP) config, so replies to customers send from hello@ instead of
|
||||
* the global no-reply@ identity. All additive/guarded.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
const cols = [
|
||||
['smtp_host', (t) => t.string('smtp_host', 255)],
|
||||
['smtp_port', (t) => t.integer('smtp_port')],
|
||||
['smtp_secure', (t) => t.boolean('smtp_secure').defaultTo(false)],
|
||||
['smtp_user', (t) => t.string('smtp_user', 255)],
|
||||
['smtp_pass', (t) => t.string('smtp_pass', 512)],
|
||||
['from_email', (t) => t.string('from_email', 255)],
|
||||
['from_name', (t) => t.string('from_name', 120)],
|
||||
];
|
||||
for (const [name, add] of cols) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn('mail_accounts', name);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!has) await knex.schema.alterTable('mail_accounts', add);
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const cols = ['smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_pass', 'from_email', 'from_name'];
|
||||
for (const name of cols) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn('mail_accounts', name);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (has) await knex.schema.alterTable('mail_accounts', (t) => t.dropColumn(name));
|
||||
}
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Messages — Archive / Delete (trash) support.
|
||||
*
|
||||
* `mailbox_state` on both mail tables: 'active' (normal folders), 'archived'
|
||||
* (Archived folder), or 'deleted' (Deleted/trash folder). Delete is soft — the
|
||||
* row moves to 'deleted' and is only removed for good when purged FROM the
|
||||
* Deleted folder. Legacy rows have NULL, treated as 'active'. Additive/guarded.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
for (const table of ['email_queue', 'received_emails']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn(table, 'mailbox_state');
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!has) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable(table, (t) => {
|
||||
t.string('mailbox_state', 16).defaultTo('active');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
for (const table of ['email_queue', 'received_emails']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const has = await knex.schema.hasColumn(table, 'mailbox_state');
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (has) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable(table, (t) => { t.dropColumn('mailbox_state'); });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Migration 158: per-event slideshow ordering + category filter (#202).
|
||||
*
|
||||
* - `show_order` — 'chronological' (default, upload order) | 'random'
|
||||
* (client-side shuffle). Lets the Live Slideshow play
|
||||
* photos in a varied order during an event.
|
||||
* - `show_category_id`— optional FK into `photo_categories`. When set, the
|
||||
* slideshow only shows photos in that category (NULL =
|
||||
* all visible photos, the existing behaviour).
|
||||
*
|
||||
* Both additive + guarded. Defaults preserve today's behaviour (chronological,
|
||||
* all photos), so existing slideshows are unchanged.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
const hasOrder = await knex.schema.hasColumn('events', 'show_order');
|
||||
if (!hasOrder) {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.string('show_order', 20).defaultTo('chronological');
|
||||
});
|
||||
}
|
||||
const hasCat = await knex.schema.hasColumn('events', 'show_category_id');
|
||||
if (!hasCat) {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.integer('show_category_id').nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
for (const col of ['show_order', 'show_category_id']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await knex.schema.hasColumn('events', col)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable('events', (t) => t.dropColumn(col));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Migration 159: per-event category ordering (#782).
|
||||
*
|
||||
* Adds a `display_order` integer to `photo_categories` so photographers can
|
||||
* arrange an event's categories in the flow of the day (Pre-Ceremony →
|
||||
* Ceremony → Reception …) instead of the hard-coded A–Z order. Mirrors the
|
||||
* `display_order` column + reorder pattern already used by `event_types`.
|
||||
*
|
||||
* Preserve existing galleries: backfill `display_order` from the CURRENT
|
||||
* (alphabetical) order, scoped — globals numbered together, event-specific
|
||||
* numbered per event — so nothing reshuffles on upgrade. A custom order is
|
||||
* opt-in via the admin reorder controls. See feedback: migrations should pin
|
||||
* previously-implicit defaults onto existing rows.
|
||||
*
|
||||
* Backfill runs in JS (not a SQL window function) to stay portable across
|
||||
* SQLite (dev) and Postgres (prod).
|
||||
*
|
||||
* Additive + hasColumn-guarded.
|
||||
*/
|
||||
async function addColumn(knex, table, column, builder) {
|
||||
if (!(await knex.schema.hasColumn(table, column))) {
|
||||
await knex.schema.alterTable(table, builder);
|
||||
}
|
||||
}
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('photo_categories'))) return;
|
||||
|
||||
await addColumn(knex, 'photo_categories', 'display_order', (t) => {
|
||||
t.integer('display_order').notNullable().defaultTo(0);
|
||||
t.index('display_order');
|
||||
});
|
||||
|
||||
// Backfill from the current alphabetical order, per scope, so existing
|
||||
// galleries render exactly as before until an admin reorders.
|
||||
const cats = await knex('photo_categories')
|
||||
.select('id', 'name', 'is_global', 'event_id')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
const counters = {};
|
||||
for (const c of cats) {
|
||||
const scope = c.is_global ? 'global' : `event:${c.event_id}`;
|
||||
counters[scope] = (counters[scope] || 0) + 1;
|
||||
await knex('photo_categories')
|
||||
.where('id', c.id)
|
||||
.update({ display_order: counters[scope] });
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('photo_categories'))) return;
|
||||
if (await knex.schema.hasColumn('photo_categories', 'display_order')) {
|
||||
await knex.schema.alterTable('photo_categories', (t) =>
|
||||
t.dropColumn('display_order')
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* Migration 160: per-event category order override (#782).
|
||||
*
|
||||
* Builds on migration 159 (photo_categories.display_order = the GLOBAL default
|
||||
* order) by adding a per-event OVERRIDE layer. Global categories are shared
|
||||
* across every event, so a single display_order can only express one order for
|
||||
* them. This table lets a single gallery arrange its categories — globals AND
|
||||
* event-specific, interleaved into the flow of the day — independently of the
|
||||
* global default.
|
||||
*
|
||||
* Resolution (see adminCategories / gallery):
|
||||
* 1. if the event has override rows -> use override.position;
|
||||
* 2. else fall back to photo_categories.display_order (the global default);
|
||||
* 3. else name.
|
||||
*
|
||||
* An event is either "using the default" (no rows here) or "customised" (a row
|
||||
* per category it shows). No backfill: every existing event starts on the
|
||||
* default order, so nothing reshuffles — a custom order is opt-in per event.
|
||||
*
|
||||
* Additive + hasTable-guarded.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('photo_categories'))) return;
|
||||
if (await knex.schema.hasTable('event_category_order')) return;
|
||||
|
||||
await knex.schema.createTable('event_category_order', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('event_id').notNullable()
|
||||
.references('id').inTable('events').onDelete('CASCADE');
|
||||
t.integer('category_id').notNullable()
|
||||
.references('id').inTable('photo_categories').onDelete('CASCADE');
|
||||
t.integer('position').notNullable().defaultTo(0);
|
||||
t.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
// At most one position per (event, category).
|
||||
t.unique(['event_id', 'category_id']);
|
||||
// Ordered reads are always scoped to one event.
|
||||
t.index(['event_id', 'position']);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasTable('event_category_order')) {
|
||||
await knex.schema.dropTable('event_category_order');
|
||||
}
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Migration 161: `setup_wizard_completed` app setting (#800).
|
||||
*
|
||||
* The setup wizard gains an event-types step that may rename or DELETE the
|
||||
* seeded system event types. That is only safe on a pristine install, so the
|
||||
* backend gates system-type deletion on this flag being unset (plus zero
|
||||
* usage — see eventTypeService.deleteEventType).
|
||||
*
|
||||
* Backfill rule: any install that already has an admin account predates the
|
||||
* wizard step (or already finished the wizard), so it is marked completed
|
||||
* here — the deletion window never opens on existing setups. A genuinely
|
||||
* fresh install runs this migration BEFORE its first admin is created, so
|
||||
* the flag starts false and the wizard's finish call flips it to true.
|
||||
*
|
||||
* Idempotent: skips when the key already exists. Values are JSON-stringified
|
||||
* to match getAppSetting's JSON.parse on read.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
if (!(await knex.schema.hasTable('app_settings'))) return;
|
||||
|
||||
const existing = await knex('app_settings')
|
||||
.where({ setting_key: 'setup_wizard_completed' })
|
||||
.first();
|
||||
if (existing) return;
|
||||
|
||||
let hasAdmin = false;
|
||||
if (await knex.schema.hasTable('admin_users')) {
|
||||
const row = await knex('admin_users').count({ c: '*' }).first();
|
||||
hasAdmin = Number(row?.c || 0) > 0;
|
||||
}
|
||||
|
||||
await knex('app_settings').insert({
|
||||
setting_key: 'setup_wizard_completed',
|
||||
setting_value: JSON.stringify(hasAdmin),
|
||||
setting_type: 'boolean',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
if (!(await knex.schema.hasTable('app_settings'))) return;
|
||||
await knex('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.88.1-beta.0",
|
||||
"version": "3.82.4-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -73,10 +73,6 @@ async function maintenanceMiddleware(req, res, next) {
|
||||
// entries here matched nothing, which is exactly why the lockout happened).
|
||||
const skipPaths = [
|
||||
'/api/auth/admin/login',
|
||||
// The second factor is part of the same login — without this, any
|
||||
// MFA-enrolled admin gets a 503 on the verify step and cannot sign in
|
||||
// at all while maintenance mode is on.
|
||||
'/api/auth/admin/login/mfa',
|
||||
'/api/auth/session',
|
||||
'/api/public/settings',
|
||||
'/health'
|
||||
|
||||
@@ -29,13 +29,7 @@ router.get('/config', adminAuth, requirePermission('backup.view'), async (req, r
|
||||
config[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
});
|
||||
|
||||
// Never return the stored credentials — mask like the email/WhatsApp
|
||||
// config endpoints do. The PUT below skips the mask sentinel, so the
|
||||
// form round-trips without clobbering the real values.
|
||||
if (config.backup_s3_secret_key) config.backup_s3_secret_key = '••••••••';
|
||||
if (config.backup_rsync_ssh_key) config.backup_rsync_ssh_key = '••••••••';
|
||||
|
||||
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to get backup configuration');
|
||||
@@ -71,11 +65,6 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
|
||||
|
||||
// Update settings
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
// An unchanged secret round-trips as the GET mask sentinel — keep the
|
||||
// stored value instead of overwriting it with bullets.
|
||||
if (value === '••••••••') {
|
||||
continue;
|
||||
}
|
||||
if (key.startsWith('backup_')) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
|
||||
@@ -4,8 +4,6 @@ const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
@@ -14,9 +12,8 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
|
||||
try {
|
||||
const categories = await db('photo_categories')
|
||||
.where('is_global', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching categories:', error);
|
||||
@@ -24,12 +21,19 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// Get categories for a specific event (global + event-specific), resolved to
|
||||
// the event's effective order: per-event override, else global default, else
|
||||
// name (#782). Each row carries `override_position` (null when not customised).
|
||||
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), requireEventOwnership, async (req, res) => {
|
||||
// Get categories for a specific event (global + event-specific)
|
||||
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const categories = await getEventCategoriesOrdered(req.params.eventId);
|
||||
const { eventId } = req.params;
|
||||
|
||||
const categories = await db('photo_categories')
|
||||
.where(function() {
|
||||
this.where('is_global', formatBoolean(true))
|
||||
.orWhere('event_id', eventId);
|
||||
})
|
||||
.orderBy('is_global', 'desc')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event categories:', error);
|
||||
@@ -77,27 +81,12 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
return res.status(400).json({ error: 'Category with this slug already exists' });
|
||||
}
|
||||
|
||||
// Append to the end of its scope so a new category doesn't jump to the
|
||||
// top of an admin-defined order (#782).
|
||||
const maxRow = await db('photo_categories')
|
||||
.where(function() {
|
||||
if (is_global) {
|
||||
this.where('is_global', formatBoolean(true));
|
||||
} else {
|
||||
this.where('event_id', event_id);
|
||||
}
|
||||
})
|
||||
.max('display_order as maxOrder')
|
||||
.first();
|
||||
const nextOrder = (maxRow?.maxOrder || 0) + 1;
|
||||
|
||||
// Create category
|
||||
const insertResult = await db('photo_categories').insert({
|
||||
name,
|
||||
slug: categorySlug,
|
||||
is_global,
|
||||
event_id: is_global ? null : event_id,
|
||||
display_order: nextOrder
|
||||
event_id: is_global ? null : event_id
|
||||
}).returning('id');
|
||||
|
||||
const categoryId = insertResult[0]?.id || insertResult[0];
|
||||
@@ -265,133 +254,4 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// Set a per-event category order override (#782). The client sends the full
|
||||
// ordered id list for THIS event — globals + event-specific, interleaved — and
|
||||
// we replace the event's override rows in one transaction. This overrides the
|
||||
// global default order for this gallery only.
|
||||
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
|
||||
body('event_id').isInt().withMessage('event_id must be an integer'),
|
||||
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
|
||||
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const eventId = parseInt(req.body.event_id, 10);
|
||||
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
|
||||
|
||||
// Event ownership (event_id comes from the body, so requireEventOwnership —
|
||||
// which reads req.params — can't be used here). Mirror it: super_admins
|
||||
// bypass; other admins may only reorder events they own (ownerless
|
||||
// legacy/system events allowed).
|
||||
if (req.admin.roleName !== 'super_admin') {
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
if (event.created_by && event.created_by !== req.admin.id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
}
|
||||
|
||||
// Every id must be a category available to this event: a shared global OR
|
||||
// one of the event's own categories. Anything else is out of scope.
|
||||
const available = await db('photo_categories')
|
||||
.where(function() {
|
||||
this.where('is_global', formatBoolean(true)).orWhere('event_id', eventId);
|
||||
})
|
||||
.pluck('id');
|
||||
const availableSet = new Set(available);
|
||||
const invalid = orderedIds.filter((id) => !availableSet.has(id));
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: 'One or more categories are not available for this event' });
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('event_category_order').where('event_id', eventId).del();
|
||||
await trx('event_category_order').insert(
|
||||
orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 }))
|
||||
);
|
||||
});
|
||||
|
||||
// Log activity after commit (avoids a SQLite in-transaction global write).
|
||||
await logActivity('event_category_order_set',
|
||||
{ eventId, count: orderedIds.length },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json(await getEventCategoriesOrdered(eventId));
|
||||
} catch (error) {
|
||||
logger.error('Error reordering categories:', error);
|
||||
res.status(500).json({ error: 'Failed to reorder categories' });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear an event's override — revert this gallery to the global default order.
|
||||
router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.eventId, 10);
|
||||
await db('event_category_order').where('event_id', eventId).del();
|
||||
|
||||
await logActivity('event_category_order_reset',
|
||||
{ eventId },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json(await getEventCategoriesOrdered(eventId));
|
||||
} catch (error) {
|
||||
logger.error('Error resetting category order:', error);
|
||||
res.status(500).json({ error: 'Failed to reset category order' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set the GLOBAL default order for shared (global) categories (#782). Applies
|
||||
// to every gallery that hasn't set its own override. Rewrites display_order.
|
||||
router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
|
||||
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
|
||||
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
|
||||
|
||||
const globals = await db('photo_categories').where('is_global', formatBoolean(true)).pluck('id');
|
||||
const globalsSet = new Set(globals);
|
||||
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: 'One or more categories are not global' });
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
for (let i = 0; i < orderedIds.length; i += 1) {
|
||||
await trx('photo_categories').where('id', orderedIds[i]).update({ display_order: i + 1 });
|
||||
}
|
||||
});
|
||||
|
||||
await logActivity('global_category_order_set',
|
||||
{ count: orderedIds.length },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
const categories = await db('photo_categories')
|
||||
.where('is_global', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc')
|
||||
.orderBy('name', 'asc');
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error reordering global categories:', error);
|
||||
res.status(500).json({ error: 'Failed to reorder global categories' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -4,10 +4,6 @@ const { body, query, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
// Gate the NEW Messages routes on the `messaging` flag (per-route, NOT the whole
|
||||
// /email mount — the pre-existing config/queue/received endpoints stay ungated).
|
||||
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
|
||||
const messagingGate = requireFeatureFlag('messaging');
|
||||
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
|
||||
const { errorResponse } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -264,196 +260,16 @@ router.get('/received', adminAuth, requirePermission('email.view'), async (req,
|
||||
try {
|
||||
const page = Math.max(1, parseInt(req.query.page, 10) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize, 10) || 25));
|
||||
const account = req.query.account ? String(req.query.account) : null;
|
||||
// mailbox_state filter: no param → active (+ legacy NULL); else exact.
|
||||
const state = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
|
||||
// Optional full-table search (sender / subject) so results aren't truncated
|
||||
// to the first page before matching.
|
||||
const q = req.query.q ? String(req.query.q).trim().slice(0, 255) : '';
|
||||
// 'accounting' matches legacy rows too (account_key was NULL before mig 154).
|
||||
const applyAccount = (qb) => {
|
||||
if (account === 'accounting') qb.where((b) => b.where('account_key', 'accounting').orWhereNull('account_key'));
|
||||
else if (account) qb.where('account_key', account);
|
||||
if (state === 'active') qb.where((b) => b.where('mailbox_state', 'active').orWhereNull('mailbox_state'));
|
||||
else qb.where('mailbox_state', state);
|
||||
if (q) qb.where((b) => b.where('from_address', 'like', `%${q}%`).orWhere('subject', 'like', `%${q}%`));
|
||||
return qb;
|
||||
};
|
||||
const countRow = await applyAccount(db('received_emails')).count({ c: '*' }).first();
|
||||
const base = db('received_emails');
|
||||
const countRow = await base.clone().count({ c: '*' }).first();
|
||||
const total = parseInt(countRow?.c || 0, 10);
|
||||
// Bodies are excluded from the list (can be large); fetched per-message.
|
||||
const items = await applyAccount(db('received_emails'))
|
||||
.select('id', 'message_id', 'account_key', 'from_address', 'to_address', 'subject',
|
||||
'received_at', 'attachment_count', 'status', 'inbound_document_id', 'error')
|
||||
.orderBy('received_at', 'desc').limit(pageSize).offset((page - 1) * pageSize);
|
||||
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) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch received emails');
|
||||
}
|
||||
});
|
||||
|
||||
// Single received email WITH its captured (server-sanitized) body — Messages
|
||||
// reading pane. body_html was already sanitized on ingest; the viewer renders
|
||||
// it in a script-less sandboxed iframe as well.
|
||||
router.get('/received/:id', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
const row = await db('received_emails').where({ id }).first();
|
||||
if (!row) return res.status(404).json({ error: 'Email not found' });
|
||||
res.json(row);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to fetch email');
|
||||
}
|
||||
});
|
||||
|
||||
// Move an email between mailbox states: Archive / Delete (soft) or Restore
|
||||
// (back to active). kind = 'queue' | 'received'. Delete is a soft move to the
|
||||
// trash; the row is only removed for good by the DELETE handler below.
|
||||
router.post('/item/:kind/:id/state', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
|
||||
if (!table) return res.status(400).json({ error: 'Invalid kind' });
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
const state = String(req.body?.state || '');
|
||||
if (!['active', 'archived', 'deleted'].includes(state)) return res.status(400).json({ error: 'Invalid state' });
|
||||
const n = await db(table).where({ id }).update({ mailbox_state: state });
|
||||
if (!n) return res.status(404).json({ error: 'Not found' });
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update email');
|
||||
}
|
||||
});
|
||||
|
||||
// Permanently delete an email row — only offered from the Deleted folder.
|
||||
router.delete('/item/:kind/:id', adminAuth, messagingGate, requirePermission('email.edit'), async (req, res) => {
|
||||
try {
|
||||
const table = req.params.kind === 'received' ? 'received_emails' : req.params.kind === 'queue' ? 'email_queue' : null;
|
||||
if (!table) return res.status(400).json({ error: 'Invalid kind' });
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
await db(table).where({ id }).del();
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to delete email');
|
||||
}
|
||||
});
|
||||
|
||||
// Additional inbound mailboxes (beyond the primary accounting IMAP in
|
||||
// email_configs) — e.g. the customer hello@ box. Passwords are masked out.
|
||||
router.get('/accounts', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const rows = await db('mail_accounts').orderBy('id');
|
||||
res.json({ items: rows.map((a) => ({
|
||||
...a,
|
||||
imap_pass: a.imap_pass ? '********' : '',
|
||||
smtp_pass: a.smtp_pass ? '********' : '',
|
||||
})) });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load mail accounts');
|
||||
}
|
||||
});
|
||||
|
||||
// Resolved sender/mailbox addresses for the Messages UI — so the sidebar shows
|
||||
// the REAL configured addresses instead of hardcoded placeholders. Accounting =
|
||||
// the primary IMAP login (rechnungen@); customers = the hello@ mailbox; the
|
||||
// automated stream sends from the global SMTP from-address.
|
||||
router.get('/identities', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const cfg = await db('email_configs').first();
|
||||
let customers = null;
|
||||
try {
|
||||
const cust = await db('mail_accounts').where({ account_key: 'customers' }).first();
|
||||
customers = cust?.imap_user || cust?.from_email || null;
|
||||
} catch (_) { customers = null; }
|
||||
res.json({
|
||||
automated: cfg?.from_email || null,
|
||||
accounting: cfg?.imap_user || null,
|
||||
customers,
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to load mail identities');
|
||||
}
|
||||
});
|
||||
|
||||
// Upsert a mailbox by account_key. A masked password ('********') keeps the
|
||||
// stored value so the admin never has to re-type it.
|
||||
router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit'), async (req, res) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
if (!b.account_key) return res.status(400).json({ error: 'account_key is required' });
|
||||
// SSRF guard — mirror /config + /incoming-config: neither the IMAP nor the
|
||||
// SMTP host may point at a private/internal address.
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (b.imap_host && isPrivateIP(b.imap_host)) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
if (b.smtp_host && isPrivateIP(b.smtp_host)) {
|
||||
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
|
||||
}
|
||||
const patch = {
|
||||
label: b.label || null,
|
||||
imap_host: b.imap_host || null,
|
||||
imap_port: b.imap_port ? parseInt(b.imap_port, 10) : 993,
|
||||
imap_secure: b.imap_secure !== false,
|
||||
imap_user: b.imap_user || null,
|
||||
imap_folder: b.imap_folder || 'INBOX',
|
||||
// Outgoing (SMTP) identity — replies from this mailbox send from here.
|
||||
smtp_host: b.smtp_host || null,
|
||||
smtp_port: b.smtp_port ? parseInt(b.smtp_port, 10) : 587,
|
||||
smtp_secure: b.smtp_secure === true,
|
||||
smtp_user: b.smtp_user || null,
|
||||
from_email: b.from_email || null,
|
||||
from_name: b.from_name || null,
|
||||
enabled: !!b.enabled,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
if (b.imap_pass && b.imap_pass !== '********') patch.imap_pass = b.imap_pass;
|
||||
if (b.smtp_pass && b.smtp_pass !== '********') patch.smtp_pass = b.smtp_pass;
|
||||
const existing = await db('mail_accounts').where({ account_key: b.account_key }).first();
|
||||
if (existing) {
|
||||
await db('mail_accounts').where({ account_key: b.account_key }).update(patch);
|
||||
} else {
|
||||
await db('mail_accounts').insert({
|
||||
account_key: b.account_key,
|
||||
imap_pass: (b.imap_pass && b.imap_pass !== '********') ? b.imap_pass : '',
|
||||
smtp_pass: (b.smtp_pass && b.smtp_pass !== '********') ? b.smtp_pass : '',
|
||||
created_at: new Date(),
|
||||
...patch,
|
||||
});
|
||||
}
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to save mail account');
|
||||
}
|
||||
});
|
||||
|
||||
// Test an inbound mailbox's IMAP connection (before or after saving). Resolves
|
||||
// a masked/blank password from the stored row for the given account_key.
|
||||
router.post('/accounts/test', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
const { isPrivateIP } = require('../utils/networkValidation');
|
||||
if (b.imap_host && isPrivateIP(b.imap_host)) {
|
||||
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
|
||||
}
|
||||
let pass = b.imap_pass;
|
||||
if ((!pass || pass === '********') && b.account_key) {
|
||||
const stored = await db('mail_accounts').where({ account_key: b.account_key }).first();
|
||||
pass = stored?.imap_pass || '';
|
||||
}
|
||||
const emailIntakeService = require('../services/emailIntakeService');
|
||||
const result = await emailIntakeService.testConnection({
|
||||
host: b.imap_host, port: b.imap_port, secure: b.imap_secure,
|
||||
user: b.imap_user, pass, folder: b.imap_folder || 'INBOX',
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(422).json({ ok: false, error: `Mailbox test failed (${error.message}).` });
|
||||
}
|
||||
});
|
||||
|
||||
// Test email configuration
|
||||
router.post('/test', adminAuth, requirePermission('email.send'), async (req, res) => {
|
||||
try {
|
||||
@@ -622,8 +438,6 @@ router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (r
|
||||
router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']),
|
||||
query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
|
||||
query('origin').optional({ values: 'falsy' }).isIn(['system', 'manual']),
|
||||
query('state').optional({ values: 'falsy' }).isIn(['active', 'archived', 'deleted']),
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
|
||||
query('from').optional({ values: 'falsy' }).isISO8601(),
|
||||
query('to').optional({ values: 'falsy' }).isISO8601(),
|
||||
@@ -642,13 +456,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
const applyFilters = (qb) => {
|
||||
if (req.query.status) qb.where('email_queue.status', req.query.status);
|
||||
if (req.query.emailType) qb.where('email_queue.email_type', req.query.emailType);
|
||||
// 'system' includes legacy rows (origin was NULL before migration 155).
|
||||
if (req.query.origin === 'manual') qb.where('email_queue.origin', 'manual');
|
||||
else if (req.query.origin === 'system') qb.where((b) => b.where('email_queue.origin', 'system').orWhereNull('email_queue.origin'));
|
||||
// mailbox_state: default active (+ legacy NULL); Archived/Deleted folders pass it explicitly.
|
||||
const st = ['archived', 'deleted'].includes(String(req.query.state)) ? String(req.query.state) : 'active';
|
||||
if (st === 'active') qb.where((b) => b.where('email_queue.mailbox_state', 'active').orWhereNull('email_queue.mailbox_state'));
|
||||
else qb.where('email_queue.mailbox_state', st);
|
||||
if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from));
|
||||
if (req.query.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to));
|
||||
if (req.query.q) {
|
||||
@@ -677,7 +484,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
'email_queue.sent_at',
|
||||
'email_queue.error_message',
|
||||
'email_queue.retry_count',
|
||||
'email_queue.origin',
|
||||
'email_queue.event_id',
|
||||
'events.event_name as event_name',
|
||||
'events.slug as event_slug'
|
||||
@@ -697,7 +503,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
sentAt: r.sent_at,
|
||||
errorMessage: r.error_message,
|
||||
retryCount: r.retry_count,
|
||||
origin: r.origin || 'system',
|
||||
eventId: r.event_id,
|
||||
eventName: r.event_name || null,
|
||||
eventSlug: r.event_slug || null,
|
||||
@@ -713,111 +518,6 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
|
||||
}
|
||||
});
|
||||
|
||||
// Single queued/sent email WITH its rendered body — powers the Messages
|
||||
// reading pane. `rendered_html` is the exact HTML that was sent (migration
|
||||
// 119); rows sent before that migration have none. Attachment disk paths in
|
||||
// `email_data` are never exposed — only the filenames, so the pane can list
|
||||
// attachments without leaking storage paths (same PII posture as the list).
|
||||
router.get('/queue/:id', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ error: 'Invalid id' });
|
||||
const row = await db('email_queue')
|
||||
.leftJoin('events', 'events.id', 'email_queue.event_id')
|
||||
.select('email_queue.*', 'events.event_name as event_name', 'events.slug as event_slug')
|
||||
.where('email_queue.id', id)
|
||||
.first();
|
||||
if (!row) return res.status(404).json({ error: 'Email not found' });
|
||||
|
||||
let cc = null;
|
||||
let attachments = [];
|
||||
try {
|
||||
const data = row.email_data ? JSON.parse(row.email_data) : {};
|
||||
if (data.cc) cc = Array.isArray(data.cc) ? data.cc.join(', ') : String(data.cc);
|
||||
if (Array.isArray(data.attachments)) {
|
||||
attachments = data.attachments
|
||||
.filter((a) => a && a.filename)
|
||||
.map((a) => ({ filename: a.filename, contentType: a.contentType || null }));
|
||||
}
|
||||
} catch (_) { /* malformed email_data → no cc/attachments, still return the body */ }
|
||||
|
||||
res.json({
|
||||
id: row.id,
|
||||
recipientEmail: row.recipient_email,
|
||||
emailType: row.email_type,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
scheduledAt: row.scheduled_at,
|
||||
sentAt: row.sent_at,
|
||||
errorMessage: row.error_message,
|
||||
retryCount: row.retry_count,
|
||||
eventId: row.event_id,
|
||||
eventName: row.event_name || null,
|
||||
eventSlug: row.event_slug || null,
|
||||
renderedHtml: row.rendered_html || null,
|
||||
cc,
|
||||
attachments,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get email queue item error:', error);
|
||||
res.status(500).json({ error: 'Failed to load email', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Send a human-composed email from the Messages composer. The admin already
|
||||
// edited the body (reply or document message), so it is sent as-is — no
|
||||
// template render — after a sanitize pass. Recorded in email_queue as a
|
||||
// 'manual' send so it surfaces under Customers > Sent.
|
||||
router.post('/send', adminAuth, messagingGate, requirePermission('email.send'), async (req, res) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
const to = String(b.to || '').trim();
|
||||
const subject = String(b.subject || '').trim();
|
||||
if (!to || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) {
|
||||
return res.status(400).json({ error: 'A valid recipient email is required.' });
|
||||
}
|
||||
if (!subject) return res.status(400).json({ error: 'A subject is required.' });
|
||||
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
// Match the stricter inbound sanitizeBody allowlist: no <style> tag, no
|
||||
// data: scheme — inline style/class attributes are enough for composed mail.
|
||||
const html = sanitizeHtml(String(b.html || ''), {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
allowedAttributes: {
|
||||
...sanitizeHtml.defaults.allowedAttributes,
|
||||
img: ['src', 'alt', 'width', 'height'],
|
||||
'*': ['style', 'class'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
|
||||
});
|
||||
const cc = b.cc ? String(b.cc).trim() : null;
|
||||
const accountKey = b.accountKey ? String(b.accountKey) : undefined;
|
||||
|
||||
const emailProcessor = require('../services/emailProcessor');
|
||||
const result = await emailProcessor.sendRawEmail({ to, cc, subject, html, accountKey });
|
||||
|
||||
await db('email_queue').insert({
|
||||
recipient_email: to,
|
||||
email_type: 'manual_message',
|
||||
email_data: JSON.stringify({
|
||||
subject,
|
||||
cc: cc || undefined,
|
||||
replyToReceivedId: b.replyToReceivedId || undefined,
|
||||
messageId: result.messageId,
|
||||
}),
|
||||
status: 'sent',
|
||||
origin: 'manual',
|
||||
rendered_html: html,
|
||||
created_at: new Date(),
|
||||
sent_at: new Date(),
|
||||
});
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Manual send error:', error);
|
||||
res.status(500).json({ error: 'Failed to send message', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper: parse variables JSON safely
|
||||
function parseVariables(template) {
|
||||
try {
|
||||
|
||||
@@ -178,7 +178,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
if (error.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
if (error.code === 'DUPLICATE_SLUG_PREFIX' || error.code === 'LAST_ACTIVE') {
|
||||
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
if (error.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE' || error.code === 'LAST_TYPE') {
|
||||
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
|
||||
@@ -307,9 +307,6 @@ async function deleteEventCascade(eventId, adminContext) {
|
||||
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||
// Allowed per-slide color filters.
|
||||
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||||
// Allowed slideshow play orders (#202). 'chronological' = upload order,
|
||||
// 'random' = client-side shuffle.
|
||||
const SLIDESHOW_ORDERS = ['chronological', 'random'];
|
||||
module.exports = {
|
||||
validateHeroImageAnchor,
|
||||
getStoragePath,
|
||||
@@ -324,7 +321,6 @@ module.exports = {
|
||||
mapEventForApi,
|
||||
hasCustomerContactColumns,
|
||||
deleteEventCascade,
|
||||
SLIDESHOW_ORDERS,
|
||||
SLIDESHOW_TRANSITIONS,
|
||||
SLIDESHOW_COLORFILTERS,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ const { parseBooleanInput } = require('../../utils/parsers');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
|
||||
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
||||
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS, SLIDESHOW_ORDERS } = require('./helpers');
|
||||
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
|
||||
|
||||
// The watermark LOOK (source/position/opacity/style/size) is global-only
|
||||
// (app_settings, Settings → Slideshow); events only carry the show_watermark
|
||||
@@ -105,9 +105,7 @@ module.exports = (router) => {
|
||||
body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS),
|
||||
body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }),
|
||||
body('show_watermark').optional({ nullable: true }),
|
||||
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS),
|
||||
body('show_order').optional().isIn(SLIDESHOW_ORDERS),
|
||||
body('show_category_id').optional({ nullable: true }).isInt({ min: 1 })
|
||||
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS)
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -132,23 +130,6 @@ module.exports = (router) => {
|
||||
: formatBoolean(parseBooleanInput(req.body.show_watermark, false));
|
||||
}
|
||||
if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter;
|
||||
if (req.body.show_order !== undefined) updates.show_order = req.body.show_order;
|
||||
// Category filter (#202). null clears it (all photos). A non-null id must
|
||||
// belong to this event or be a global category — otherwise ignore it so a
|
||||
// stale/foreign id can't leak another event's category selection.
|
||||
if (req.body.show_category_id !== undefined) {
|
||||
if (req.body.show_category_id === null) {
|
||||
updates.show_category_id = null;
|
||||
} else {
|
||||
const catId = parseInt(req.body.show_category_id, 10);
|
||||
const cat = await db('photo_categories')
|
||||
.where({ id: catId })
|
||||
.where(function () { this.where('event_id', event.id).orWhere('is_global', formatBoolean(true)); })
|
||||
.first();
|
||||
if (!cat) return res.status(400).json({ error: 'Category does not belong to this event' });
|
||||
updates.show_category_id = catId;
|
||||
}
|
||||
}
|
||||
|
||||
// Knex throws on an empty update; only write if something changed.
|
||||
if (Object.keys(updates).length > 0) {
|
||||
@@ -160,9 +141,7 @@ module.exports = (router) => {
|
||||
show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
|
||||
show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
|
||||
show_watermark: updates.show_watermark ?? event.show_watermark ?? null,
|
||||
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none',
|
||||
show_order: updates.show_order ?? event.show_order ?? 'chronological',
|
||||
show_category_id: 'show_category_id' in updates ? updates.show_category_id : (event.show_category_id ?? null)
|
||||
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none'
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update slideshow settings');
|
||||
|
||||
@@ -31,19 +31,6 @@ const watermarkGeneratorService = require('../services/watermarkGeneratorService
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Reserved first-run bootstrap keys — never writable through the generic
|
||||
// settings upserts in this file: setup_wizard_completed is a one-way marker
|
||||
// (#800; writing false would reopen system-event-type deletion) and
|
||||
// setup_token is the first-run bootstrap secret. Every handler that loops
|
||||
// arbitrary request keys into app_settings must strip these first.
|
||||
const RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token'];
|
||||
const stripReservedSettingKeys = (settings) => {
|
||||
for (const key of RESERVED_SETTING_KEYS) {
|
||||
delete settings[key];
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
|
||||
// Configure multer for logo uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
@@ -160,16 +147,6 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
}
|
||||
// Backup credentials — the S3 secret key and the rsync SSH PRIVATE KEY
|
||||
// were returned in plaintext to any settings.view holder. Same masking
|
||||
// pattern as the recaptcha/umami/rybbit keys; the dedicated
|
||||
// /admin/backup/config endpoints handle the edit round-trip.
|
||||
if (settingsObject.backup_s3_secret_key) {
|
||||
settingsObject.backup_s3_secret_key = '••••••••';
|
||||
}
|
||||
if (settingsObject.backup_rsync_ssh_key) {
|
||||
settingsObject.backup_rsync_ssh_key = '••••••••';
|
||||
}
|
||||
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
|
||||
// outbound calls to the operator's Umami instance for the device
|
||||
// breakdown. Masked on GET, same pattern as the recaptcha secret.
|
||||
@@ -420,16 +397,6 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
}
|
||||
// Backup credentials — the S3 secret key and the rsync SSH PRIVATE KEY
|
||||
// were returned in plaintext to any settings.view holder. Same masking
|
||||
// pattern as the recaptcha/umami/rybbit keys; the dedicated
|
||||
// /admin/backup/config endpoints handle the edit round-trip.
|
||||
if (settingsObject.backup_s3_secret_key) {
|
||||
settingsObject.backup_s3_secret_key = '••••••••';
|
||||
}
|
||||
if (settingsObject.backup_rsync_ssh_key) {
|
||||
settingsObject.backup_rsync_ssh_key = '••••••••';
|
||||
}
|
||||
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
|
||||
// outbound calls to the operator's Umami instance for the device
|
||||
// breakdown. Masked on GET, same pattern as the recaptcha secret.
|
||||
@@ -939,7 +906,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
// Update general settings
|
||||
router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = stripReservedSettingKeys({ ...req.body });
|
||||
const settings = { ...req.body };
|
||||
let uploadLimitTouched = false;
|
||||
|
||||
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
||||
@@ -1050,7 +1017,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
|
||||
// Update security settings
|
||||
router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = stripReservedSettingKeys({ ...req.body });
|
||||
const settings = req.body;
|
||||
|
||||
// Update or insert each setting
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
@@ -1088,7 +1055,7 @@ router.put('/security', adminAuth, requirePermission('settings.edit'), async (re
|
||||
// Update analytics settings
|
||||
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = stripReservedSettingKeys({ ...req.body });
|
||||
const settings = req.body;
|
||||
|
||||
// Validate the provider switch (#663 Phase 1). Reject unknown values
|
||||
// so the dashboard route's factory doesn't have to defensively guard.
|
||||
@@ -1143,7 +1110,7 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
|
||||
// Update SEO settings
|
||||
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = stripReservedSettingKeys({ ...req.body });
|
||||
const settings = req.body;
|
||||
|
||||
// Validate seo_blocked_ai_agents is an array of strings
|
||||
if (settings.seo_blocked_ai_agents !== undefined) {
|
||||
|
||||
@@ -254,19 +254,6 @@ router.patch('/:id/enabled', requirePermission('workflows.manage'), async (req,
|
||||
// enabled state on the next SEED_VERSION bump (review nit #1).
|
||||
if (await hasColumnCached('workflows', 'admin_toggled_at')) patch.admin_toggled_at = db.fn.now();
|
||||
await db('workflows').where({ id }).update(patch);
|
||||
// Turning dunning ON enrolls existing open/unpaid invoices (anchored to
|
||||
// their due date) so it starts chasing current debtors, not only invoices
|
||||
// sent after enabling (#750). Scoped to this flow's id so the backfill only
|
||||
// enrolls dunning, not any custom invoice.sent flow. Best-effort — never
|
||||
// fail the toggle over it.
|
||||
if (enabled && wf.builtin_key === 'invoice_dunning') {
|
||||
try {
|
||||
const n = await require('../services/workflows').backfillDunningRuns(id);
|
||||
require('../utils/logger').info('[workflow] dunning enabled — enrolled existing invoices', { enrolled: n });
|
||||
} catch (e) {
|
||||
require('../utils/logger').warn('[workflow] dunning backfill failed', { error: e.message });
|
||||
}
|
||||
}
|
||||
res.json({ id, enabled });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
@@ -45,17 +45,6 @@ const router = express.Router();
|
||||
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
|
||||
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
|
||||
|
||||
// A normal login means the first-run wizard is over — the wizard never hits
|
||||
// this route (setup sets its cookie directly). Close the system-event-type
|
||||
// deletion window durably even when the wizard was abandoned mid-way (#800).
|
||||
// Best-effort: a failure here must never block a login.
|
||||
try {
|
||||
const setupService = require('../services/setupService');
|
||||
if (!(await setupService.isSetupWizardCompleted())) {
|
||||
await setupService.markSetupWizardCompleted();
|
||||
}
|
||||
} catch (_) { /* best-effort */ }
|
||||
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
|
||||
@@ -25,7 +25,6 @@ const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
@@ -244,8 +243,8 @@ router.get('/:slug/info', async (req, res) => {
|
||||
|
||||
// Photos a slideshow may display: published, finished, non-hidden. Mirrors the
|
||||
// guest filter in GET /:slug/photos so the live count matches the rendered set.
|
||||
function slideshowPhotosQuery(eventId, categoryId = null) {
|
||||
const q = db('photos')
|
||||
function slideshowPhotosQuery(eventId) {
|
||||
return db('photos')
|
||||
.where('photos.event_id', eventId)
|
||||
.where(function() {
|
||||
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
|
||||
@@ -253,10 +252,6 @@ function slideshowPhotosQuery(eventId, categoryId = null) {
|
||||
.where(function() {
|
||||
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
|
||||
});
|
||||
// Category filter (#202) — keep the /session + /state count in sync with the
|
||||
// photos the kiosk actually renders.
|
||||
if (categoryId) q.where('photos.category_id', categoryId);
|
||||
return q;
|
||||
}
|
||||
|
||||
// Resolve an active slideshow by slug + token. Returns the event row, or null
|
||||
@@ -329,9 +324,6 @@ async function slideshowSettings(event) {
|
||||
transition: event.show_transition || 'crossfade',
|
||||
transition_ms: event.show_transition_ms || 800,
|
||||
colorfilter: event.show_colorfilter || 'none',
|
||||
// Play order (#202): 'chronological' | 'random'. The client shuffles when
|
||||
// 'random' so live-appended uploads keep working.
|
||||
order: event.show_order || 'chronological',
|
||||
fit: g.fit,
|
||||
watermark,
|
||||
};
|
||||
@@ -364,7 +356,7 @@ router.get('/:slug/show/:token/session', handleAsync(async (req, res) => {
|
||||
// here so the kiosk's image requests are authorized with zero extra wiring.
|
||||
setGalleryAuthCookies(res, sessionToken, event.slug);
|
||||
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
|
||||
|
||||
res.json({
|
||||
token: sessionToken,
|
||||
@@ -390,7 +382,7 @@ router.get('/:slug/show/:token/state', handleAsync(async (req, res) => {
|
||||
throw new NotFoundError('Slideshow');
|
||||
}
|
||||
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
|
||||
|
||||
res.json({
|
||||
...(await slideshowSettings(event)),
|
||||
@@ -434,13 +426,6 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
});
|
||||
}
|
||||
|
||||
// Live Slideshow category filter (#202). Enforced server-side so the kiosk
|
||||
// viewer can't widen the set: when the event pins show_category_id, the
|
||||
// slideshow only sees that category. NULL = all photos (unchanged).
|
||||
if (req.accessLevel === 'slideshow' && req.event.show_category_id) {
|
||||
photosQuery = photosQuery.where('photos.category_id', req.event.show_category_id);
|
||||
}
|
||||
|
||||
// Apply sort option
|
||||
if (sort === 'capture_date') {
|
||||
// Sort by capture date, falling back to uploaded_at if capture date is null
|
||||
@@ -588,12 +573,10 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// Fetch category details from photo_categories table
|
||||
let categories = [];
|
||||
if (usedCategoryIds.length > 0) {
|
||||
// Resolved category order (#782): per-event override, else global
|
||||
// default, else name — restricted to categories that have photos.
|
||||
const categoryDetails = await getEventCategoriesOrdered(req.event.id, {
|
||||
onlyIds: usedCategoryIds,
|
||||
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads'],
|
||||
});
|
||||
const categoryDetails = await db('photo_categories')
|
||||
.whereIn('id', usedCategoryIds)
|
||||
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id', 'allow_downloads')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
categories = categoryDetails.map(cat => ({
|
||||
id: cat.id,
|
||||
|
||||
@@ -10,7 +10,6 @@ const { body, validationResult } = require('express-validator');
|
||||
const setupService = require('../services/setupService');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -80,19 +79,4 @@ router.post('/admin', [
|
||||
}
|
||||
});
|
||||
|
||||
// Wizard finish marker — unlike the endpoints above this one runs AFTER the
|
||||
// admin exists (the wizard is authenticated from the account step onward), so
|
||||
// it takes the normal admin auth. One-way: while the flag is unset the seeded
|
||||
// SYSTEM event types may be deleted from the wizard's event-types step; once
|
||||
// set they are permanently protected (#800).
|
||||
router.post('/complete', adminAuth, async (req, res) => {
|
||||
try {
|
||||
await setupService.markSetupWizardCompleted();
|
||||
res.json({ completed: true });
|
||||
} catch (err) {
|
||||
logger.error('[setup] markSetupWizardCompleted failed', { error: err.message });
|
||||
res.status(500).json({ error: 'Failed to mark setup complete' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -80,16 +80,7 @@ jest.mock('../../../services/webhookService', () => ({
|
||||
buildEventSubject: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// event_type is validated against the live event_types catalog (#800) —
|
||||
// that lookup would consume the first queued db() chain and shift the
|
||||
// call sequence these tests pin. Stub it valid; the invalid path has its
|
||||
// own test below.
|
||||
jest.mock('../../../services/eventTypeService', () => ({
|
||||
isValidEventType: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const { db } = require('../../../database/db');
|
||||
const { isValidEventType } = require('../../../services/eventTypeService');
|
||||
const eventsRouter = require('../events');
|
||||
|
||||
const buildApp = () => {
|
||||
@@ -240,16 +231,4 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
|
||||
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects an event_type unknown to the catalog with 400 (#800)', async () => {
|
||||
isValidEventType.mockResolvedValueOnce(false);
|
||||
const res = await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, event_type: 'nope' })
|
||||
.expect(400);
|
||||
|
||||
expect(isValidEventType).toHaveBeenCalledWith('nope');
|
||||
expect(JSON.stringify(res.body.errors)).toContain('event_type');
|
||||
expect(db).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,6 @@ const logger = require('../../utils/logger');
|
||||
const { slugify } = require('../../utils/slug');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
const { isValidEventType } = require('../../services/eventTypeService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -81,7 +80,7 @@ const photoUpload = multer({
|
||||
* event_name: { type: string }
|
||||
* event_type:
|
||||
* type: string
|
||||
* description: "Slug of an active event type from the catalog (Settings → Event Types). Defaults on a fresh install: wedding, birthday, corporate, other. GET /api/v1/event-types lists the live values."
|
||||
* enum: [wedding, birthday, corporate, other, family]
|
||||
* event_date: { type: string, format: date, nullable: true }
|
||||
* customer_name: { type: string, nullable: true }
|
||||
* customer_email: { type: string, format: email, nullable: true }
|
||||
@@ -118,14 +117,7 @@ router.post(
|
||||
requireApiScope('admin'),
|
||||
[
|
||||
body('event_name').isString().trim().notEmpty(),
|
||||
// Validate against the live event_types catalog (admins can rename/delete
|
||||
// the defaults and add custom types), not a hardcoded whitelist (#800).
|
||||
body('event_type').isString().trim().notEmpty().bail().custom(async (value) => {
|
||||
if (!(await isValidEventType(value))) {
|
||||
throw new Error('Unknown event type — must match an active event type slug');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other', 'family']),
|
||||
body('event_date').optional({ nullable: true, checkFalsy: true }).isISO8601(),
|
||||
body('customer_name').optional({ nullable: true }).isString(),
|
||||
body('customer_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
|
||||
@@ -455,48 +447,6 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// GET /event-types — read (catalog discovery for event creation, #800)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /event-types:
|
||||
* get:
|
||||
* tags: [Events]
|
||||
* summary: List active event types
|
||||
* description: The slugs accepted as `event_type` when creating events. The catalog is admin-customizable (Settings → Event Types), so integrations should discover values here instead of hardcoding them.
|
||||
* security: [{ bearerAuth: [] }]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Active event types
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* eventTypes:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* slug_prefix: { type: string }
|
||||
* name: { type: string }
|
||||
* emoji: { type: string }
|
||||
*/
|
||||
router.get('/event-types', apiTokenAuth, requireApiScope('read'), async (req, res) => {
|
||||
try {
|
||||
const types = await db('event_types')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc')
|
||||
.select('slug_prefix', 'name', 'emoji');
|
||||
res.json({ eventTypes: types });
|
||||
} catch (error) {
|
||||
logger.error('v1 GET /event-types failed', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to list event types' });
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// GET /events/:id — read
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,10 +31,7 @@ function buildDunningGraph({ firstDays, gapDays, maxReminders }) {
|
||||
const nodes = [
|
||||
{ node_key: 't', type: 'trigger', config: {}, pos_x: 240, pos_y: 0 },
|
||||
{ node_key: 'waitDue', type: 'wait', config: { untilVar: 'dueDate' }, pos_x: 240, pos_y: 110 },
|
||||
// Anchor the grace period to the invoice's due date (dueDate + firstDays),
|
||||
// not "now + firstDays" — so an already-overdue invoice enrolled via backfill
|
||||
// duns on its real timeline instead of restarting a fresh grace clock (#750).
|
||||
{ node_key: 'waitGrace', type: 'wait', config: { untilVar: 'dueDate', delayDays: firstDays }, pos_x: 240, pos_y: 220 },
|
||||
{ node_key: 'waitGrace', type: 'wait', config: { delayDays: firstDays }, pos_x: 240, pos_y: 220 },
|
||||
{ node_key: 'loop', type: 'loop', config: { maxIterations: maxReminders }, pos_x: 240, pos_y: 330 },
|
||||
{ node_key: 'checkPaid', type: 'condition', config: { condition: 'invoice_paid' }, pos_x: 240, pos_y: 440 },
|
||||
{ node_key: 'paymentCheck', type: 'action', config: { action: 'queue_payment_check' }, pos_x: 240, pos_y: 550 },
|
||||
@@ -221,7 +218,7 @@ function buildGalleryExpiredGraph() {
|
||||
const BUILTINS = [
|
||||
{
|
||||
key: DUNNING_KEY,
|
||||
version: 7,
|
||||
version: 6,
|
||||
enabled: false,
|
||||
name: 'Invoice dunning (built-in)',
|
||||
trigger_type: 'invoice.sent',
|
||||
|
||||
@@ -11,7 +11,6 @@ const businessProfileService = require('../businessProfileService');
|
||||
const { ensureSystemBlocksSeeded } = require('../contractBlocksService');
|
||||
const { ensureInt } = require('../../utils/numericHelpers');
|
||||
const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers');
|
||||
const { resolveDefaultEventType } = require('../eventTypeService');
|
||||
|
||||
|
||||
/**
|
||||
@@ -222,12 +221,6 @@ async function convertToEvent(contractId, adminId) {
|
||||
const placeholderHash = crypto.randomBytes(32).toString('hex');
|
||||
const shareToken = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Event type: the configurable org default, else the resolved catch-all —
|
||||
// same chain as quoteService.convertToEvent. Never a hardcoded slug: the
|
||||
// admin may have renamed or deleted 'wedding' (#800).
|
||||
const eventType = (await getAppSetting('crm_default_event_type'))
|
||||
|| (await resolveDefaultEventType());
|
||||
|
||||
const eventCols = await db('events').columnInfo();
|
||||
const candidate = {
|
||||
slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
|
||||
@@ -243,7 +236,7 @@ async function convertToEvent(contractId, adminId) {
|
||||
customer_email: customerEmail,
|
||||
customer_phone: customer.phone,
|
||||
admin_email: adminEmail,
|
||||
event_type: eventType,
|
||||
event_type: 'wedding',
|
||||
password_hash: placeholderHash,
|
||||
share_link: shareToken,
|
||||
share_token: shareToken,
|
||||
|
||||
@@ -16,7 +16,6 @@ const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const expenseService = require('./expenseService');
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
const { isUniqueViolation } = require('../utils/dbErrors');
|
||||
|
||||
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
@@ -231,36 +230,14 @@ async function roundTripTest({ timeoutMs = 30000, intervalMs = 3000 } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize an inbound HTML body before storing it. Inbound mail is untrusted,
|
||||
// so this strips scripts/handlers/unknown schemes (the viewer ALSO renders it
|
||||
// in a script-less sandboxed iframe — defense in depth). Remote images are kept
|
||||
// (many legit emails embed them) but that is the only tracking-vector allowed.
|
||||
function sanitizeBody(html) {
|
||||
if (!html) return null;
|
||||
try {
|
||||
return sanitizeHtml(html, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
allowedAttributes: {
|
||||
...sanitizeHtml.defaults.allowedAttributes,
|
||||
img: ['src', 'alt', 'width', 'height'],
|
||||
'*': ['style'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto', 'cid'],
|
||||
});
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/** Poll the mailbox once. Safe to call repeatedly; self-skips when busy/off. */
|
||||
async function pollOnce() {
|
||||
if (polling) return { skipped: 'busy' };
|
||||
if (!(await isEnabled())) return { skipped: 'disabled' };
|
||||
const cfg = await getImapConfig();
|
||||
if (!cfg) return { skipped: 'unconfigured' };
|
||||
|
||||
/**
|
||||
* Poll ONE mailbox once and return the count of newly-processed messages.
|
||||
* `opts.accountKey` tags each received_emails row; `opts.routeToExpenses`
|
||||
* controls whether PDF/image attachments are dropped into the accounting inbox
|
||||
* (true for the primary rechnungen@ mailbox) or only logged with the body
|
||||
* (customer mail, e.g. hello@). The claim/dedup/stale-recovery logic is
|
||||
* identical for every mailbox.
|
||||
*/
|
||||
async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses = true } = {}) {
|
||||
polling = true;
|
||||
const client = makeImapClient(cfg);
|
||||
let processed = 0;
|
||||
try {
|
||||
@@ -327,7 +304,6 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
|
||||
try {
|
||||
await db('received_emails').insert({
|
||||
message_id: claimKey,
|
||||
account_key: accountKey,
|
||||
status: 'processing',
|
||||
attachment_count: 0,
|
||||
received_at: new Date(),
|
||||
@@ -339,47 +315,37 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
|
||||
throw ce;
|
||||
}
|
||||
|
||||
// Attachment handling. The accounting mailbox drops PDF/image
|
||||
// attachments into the incoming-invoices inbox (isolated so one bad
|
||||
// file can't prevent the audit row). Customer mailboxes only COUNT
|
||||
// attachments — they aren't supplier invoices.
|
||||
// Ingest attachments. Isolate each so one bad file can't prevent the
|
||||
// audit row (the symptom: doc lands in Incoming invoices but the
|
||||
// email never shows under Received).
|
||||
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
|
||||
let inboundId = null;
|
||||
let count = 0;
|
||||
const attErrors = [];
|
||||
if (routeToExpenses) {
|
||||
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
|
||||
for (const att of atts) {
|
||||
try {
|
||||
const filePath = await saveAttachment(att);
|
||||
const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null);
|
||||
inboundId = doc.id; count += 1;
|
||||
} catch (ae) {
|
||||
attErrors.push(ae.message);
|
||||
logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`);
|
||||
}
|
||||
for (const att of atts) {
|
||||
try {
|
||||
const filePath = await saveAttachment(att);
|
||||
const doc = await expenseService.recordInboundDocument({ source: 'email', filePath, originalFilename: att.filename || 'attachment', mimeType: att.contentType }, null);
|
||||
inboundId = doc.id; count += 1;
|
||||
} catch (ae) {
|
||||
attErrors.push(ae.message);
|
||||
logger.error?.(`emailIntake: attachment "${att.filename}" failed: ${ae.message}`);
|
||||
}
|
||||
} else {
|
||||
count = (parsed.attachments || []).length;
|
||||
}
|
||||
|
||||
// A malformed Date: header yields an Invalid Date, which throws on a
|
||||
// Postgres timestamp insert — coerce to now.
|
||||
const receivedAt = (parsed.date instanceof Date && !Number.isNaN(parsed.date.getTime())) ? parsed.date : new Date();
|
||||
const status = routeToExpenses
|
||||
? (count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment'))
|
||||
: 'received';
|
||||
const status = count > 0 ? 'ingested' : (attErrors.length ? 'error' : 'no_attachment');
|
||||
// Finalise the claimed row — every processed message ends up in the
|
||||
// Received log with its (sanitized) body, even attachment-less ones.
|
||||
// Received tab, even attachment-less ones.
|
||||
await db('received_emails').where({ message_id: claimKey }).update({
|
||||
from_address: ((parsed.from && parsed.from.text) || '').slice(0, 512) || null,
|
||||
to_address: ((parsed.to && parsed.to.text) || '').slice(0, 512) || null,
|
||||
subject: parsed.subject || null,
|
||||
received_at: receivedAt,
|
||||
attachment_count: count,
|
||||
status,
|
||||
inbound_document_id: inboundId,
|
||||
body_html: sanitizeBody(parsed.html || null),
|
||||
body_text: parsed.text || null,
|
||||
error: attErrors.length ? attErrors.join('; ').slice(0, 2000) : null,
|
||||
});
|
||||
await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true });
|
||||
@@ -394,7 +360,7 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
|
||||
await db('received_emails').where({ message_id: claimKey })
|
||||
.update({ status: 'error', error: String(e.message).slice(0, 2000) });
|
||||
} else {
|
||||
await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, account_key: accountKey, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
|
||||
await db('received_emails').insert({ message_id: `err-${cand.uid}-${Date.now()}`, status: 'error', error: e.message, attachment_count: 0, received_at: new Date(), created_at: new Date() });
|
||||
}
|
||||
} catch (ie) {
|
||||
logger.error?.(`emailIntake: could not even write the error row (received_emails insert failing): ${ie.message}`);
|
||||
@@ -407,55 +373,11 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
|
||||
/* eslint-enable no-await-in-loop */
|
||||
await client.logout();
|
||||
} catch (e) {
|
||||
logger.error?.(`emailIntake: poll failed (${accountKey}): ${e.message}`);
|
||||
logger.error?.(`emailIntake: poll failed: ${e.message}`);
|
||||
try { await client.close(); } catch (_e) { /* ignore */ }
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll ALL configured inbound mailboxes once: the primary accounting IMAP
|
||||
* (email_configs) plus every enabled row in mail_accounts (e.g. hello@).
|
||||
* Safe to call repeatedly; self-skips when busy/off.
|
||||
*/
|
||||
async function pollOnce() {
|
||||
if (polling) return { skipped: 'busy' };
|
||||
if (!(await isEnabled())) return { skipped: 'disabled' };
|
||||
polling = true;
|
||||
let processed = 0;
|
||||
let anyConfigured = false;
|
||||
try {
|
||||
// 1) Primary accounting mailbox — routes attachments to the invoices inbox.
|
||||
const acctCfg = await getImapConfig();
|
||||
if (acctCfg) {
|
||||
anyConfigured = true;
|
||||
processed += await pollAccountOnce(acctCfg, { accountKey: 'accounting', routeToExpenses: true });
|
||||
}
|
||||
// 2) Additional mailboxes (customers/hello@) — body captured, no expense
|
||||
// routing. Guarded so a pre-migration DB simply polls the accounting box.
|
||||
let extras = [];
|
||||
try {
|
||||
if (await db.schema.hasTable('mail_accounts')) {
|
||||
extras = await db('mail_accounts').where({ enabled: true });
|
||||
}
|
||||
} catch (_) { extras = []; }
|
||||
for (const a of extras) {
|
||||
if (!a.imap_host || !a.imap_user) continue;
|
||||
anyConfigured = true;
|
||||
const cfg = {
|
||||
host: a.imap_host,
|
||||
port: a.imap_port || 993,
|
||||
secure: a.imap_secure !== false && a.imap_secure !== 0,
|
||||
auth: { user: a.imap_user, pass: a.imap_pass || '' },
|
||||
folder: a.imap_folder || 'INBOX',
|
||||
};
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
processed += await pollAccountOnce(cfg, { accountKey: a.account_key, routeToExpenses: false });
|
||||
}
|
||||
} finally {
|
||||
polling = false;
|
||||
}
|
||||
if (!anyConfigured) return { skipped: 'unconfigured' };
|
||||
return { processed };
|
||||
}
|
||||
|
||||
|
||||
@@ -775,62 +775,6 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a fully-composed email (subject + HTML the admin already edited in the
|
||||
* Messages composer) WITHOUT a template. Used for replies + human-sent document
|
||||
* messages. Uses the configured SMTP identity + from address. Returns
|
||||
* { messageId, html } so the caller can persist rendered_html for the record.
|
||||
*/
|
||||
async function sendRawEmail({ to, cc, subject, html, text, attachments, accountKey } = {}) {
|
||||
let tx = null;
|
||||
let fromEmail = null;
|
||||
let fromName = null;
|
||||
|
||||
// Prefer a per-account outgoing identity (e.g. hello@) when the mail account
|
||||
// has its own SMTP config, so customer replies send from that address instead
|
||||
// of the global no-reply@. Falls back to the global SMTP transport.
|
||||
if (accountKey) {
|
||||
const acct = await db('mail_accounts').where({ account_key: accountKey }).first();
|
||||
if (acct && acct.smtp_host && (acct.smtp_user || acct.from_email)) {
|
||||
const nodemailer = require('nodemailer');
|
||||
tx = nodemailer.createTransport({
|
||||
host: acct.smtp_host,
|
||||
port: parseInt(acct.smtp_port, 10) || 587,
|
||||
secure: acct.smtp_secure === true || acct.smtp_secure === 1,
|
||||
auth: acct.smtp_user && acct.smtp_pass ? { user: acct.smtp_user, pass: acct.smtp_pass } : undefined,
|
||||
tls: { rejectUnauthorized: true },
|
||||
});
|
||||
fromEmail = acct.from_email || acct.smtp_user;
|
||||
fromName = acct.from_name || '';
|
||||
}
|
||||
}
|
||||
if (!tx) {
|
||||
tx = await initializeTransporter();
|
||||
if (!tx) throw new Error('Email service not configured');
|
||||
const config = await db('email_configs').first();
|
||||
if (!config || !config.from_email) throw new Error('Email service not configured');
|
||||
fromEmail = config.from_email;
|
||||
fromName = config.from_name;
|
||||
}
|
||||
|
||||
const ccList = Array.isArray(cc) ? cc.filter(Boolean) : (cc ? [cc] : undefined);
|
||||
const atts = Array.isArray(attachments)
|
||||
? attachments.filter((a) => a && (a.contentPath || a.path || a.content))
|
||||
.map((a) => ({ filename: a.filename, path: a.contentPath || a.path, content: a.content, contentType: a.contentType }))
|
||||
: undefined;
|
||||
const info = await tx.sendMail({
|
||||
from: `${fromName || 'picpeak'} <${fromEmail}>`,
|
||||
to,
|
||||
cc: ccList,
|
||||
subject,
|
||||
html,
|
||||
text: text || htmlToText(html),
|
||||
attachments: atts,
|
||||
});
|
||||
logger.info(`Manual email sent: ${info.messageId}`);
|
||||
return { messageId: info.messageId, html };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a queued email's HTML WITHOUT sending it. Used by the Project
|
||||
* Overview cockpit to preview emails that predate the rendered_html column
|
||||
@@ -1164,7 +1108,6 @@ module.exports = {
|
||||
initializeTransporter,
|
||||
startEmailQueueProcessor,
|
||||
sendTemplateEmail,
|
||||
sendRawEmail,
|
||||
renderQueuedEmail,
|
||||
processEmailQueue,
|
||||
queueEmail,
|
||||
|
||||
@@ -266,22 +266,7 @@ async function ensureEventReminderTemplatesSeeded(db, logger) {
|
||||
}
|
||||
};
|
||||
|
||||
// Per-type templates are only seeded for slugs that still exist in the
|
||||
// event_types catalog — the setup wizard (and admins) can delete the
|
||||
// seeded defaults, and re-inserting event_reminder_<slug> for a removed
|
||||
// type would resurrect an orphan on every boot (#800). The catch-all
|
||||
// event_reminder_default is always seeded.
|
||||
let existingSlugs = null;
|
||||
if (await db.schema.hasTable('event_types')) {
|
||||
const rows = await db('event_types').select('slug_prefix');
|
||||
existingSlugs = new Set(rows.map((r) => r.slug_prefix));
|
||||
}
|
||||
|
||||
for (const [templateKey, def] of Object.entries(EVENT_REMINDER_TEMPLATES)) {
|
||||
const typeSlug = templateKey.replace(/^event_reminder_/, '');
|
||||
if (typeSlug !== 'default' && existingSlugs && !existingSlugs.has(typeSlug)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
let existing = await db('email_templates').where({ template_key: templateKey }).first();
|
||||
|
||||
|
||||
@@ -67,20 +67,13 @@ const getEventTypeBySlugPrefix = async (slugPrefix) => {
|
||||
const isValidEventType = async (slugPrefix) => {
|
||||
const normalized = slugPrefix.toLowerCase();
|
||||
|
||||
// The live catalog is authoritative: a row decides by its active flag, and
|
||||
// a slug the admin deleted (setup wizard, #800) or deactivated must NOT
|
||||
// sneak back in through the legacy list below.
|
||||
// Check in database
|
||||
const eventType = await getEventTypeBySlugPrefix(normalized);
|
||||
if (eventType) {
|
||||
return Boolean(eventType.is_active);
|
||||
}
|
||||
const anyType = await db('event_types').first('id');
|
||||
if (anyType) {
|
||||
return false;
|
||||
if (eventType && eventType.is_active) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy fallback: only for a degenerate install with an EMPTY catalog
|
||||
// (pre-catalog schema drift) — accept the old hardcoded values.
|
||||
// Legacy fallback: Accept old hardcoded values for backward compatibility
|
||||
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
|
||||
return legacyTypes.includes(normalized);
|
||||
};
|
||||
@@ -205,19 +198,6 @@ const updateEventType = async (id, updates) => {
|
||||
}
|
||||
|
||||
if (updates.is_active !== undefined) {
|
||||
// Deactivating the last active type would empty the ACTIVE catalog and
|
||||
// brick event creation (unknown slugs are rejected since #800).
|
||||
if (updates.is_active === false && eventType.is_active) {
|
||||
const otherActive = await db('event_types')
|
||||
.whereNot('id', id)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.first('id');
|
||||
if (!otherActive) {
|
||||
const error = new Error('Cannot deactivate the last active event type — activate another one first.');
|
||||
error.code = 'LAST_ACTIVE';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
updateData.is_active = formatBoolean(updates.is_active);
|
||||
}
|
||||
|
||||
@@ -270,12 +250,6 @@ const updateEventType = async (id, updates) => {
|
||||
|
||||
/**
|
||||
* Delete an event type
|
||||
*
|
||||
* System types are protected — EXCEPT during the first-run setup wizard
|
||||
* (setup_wizard_completed flag unset, see setupService), where the admin may
|
||||
* replace the seeded defaults before anything references them (#800). The
|
||||
* in-use checks below still apply in that window as defense in depth.
|
||||
*
|
||||
* @param {number} id - Event type ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
@@ -287,17 +261,11 @@ const deleteEventType = async (id) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Prevent deletion of system types once the setup wizard has completed.
|
||||
// Prevent deletion of system types
|
||||
if (eventType.is_system) {
|
||||
// Lazy require: keeps the module graph flat (setupService has no
|
||||
// dependency back on this service, but the require is only needed on
|
||||
// this rare path).
|
||||
const { isSetupWizardCompleted } = require('./setupService');
|
||||
if (await isSetupWizardCompleted()) {
|
||||
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
|
||||
error.code = 'SYSTEM_TYPE';
|
||||
throw error;
|
||||
}
|
||||
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
|
||||
error.code = 'SYSTEM_TYPE';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if any events use this type
|
||||
@@ -312,62 +280,7 @@ const deleteEventType = async (id) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Never delete the last remaining type — and never delete the last ACTIVE
|
||||
// one either: event creation and the quote/contract default-type resolution
|
||||
// both need at least one active catalog entry.
|
||||
const remaining = await db('event_types').whereNot('id', id).count('id as count').first();
|
||||
if (!remaining || parseInt(remaining.count) === 0) {
|
||||
const error = new Error('Cannot delete the last event type — at least one must remain.');
|
||||
error.code = 'LAST_TYPE';
|
||||
throw error;
|
||||
}
|
||||
if (eventType.is_active) {
|
||||
const remainingActive = await db('event_types')
|
||||
.whereNot('id', id)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.count('id as count')
|
||||
.first();
|
||||
if (!remainingActive || parseInt(remainingActive.count) === 0) {
|
||||
const error = new Error('Cannot delete the last active event type — activate another one first.');
|
||||
error.code = 'LAST_TYPE';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Quotes carry event_type too (migration 146) — a dangling slug there would
|
||||
// corrupt the quote→event conversion default chain.
|
||||
if (await hasColumnCached('quotes', 'event_type')) {
|
||||
const quotesUsingType = await db('quotes')
|
||||
.where('event_type', eventType.slug_prefix)
|
||||
.count('id as count')
|
||||
.first();
|
||||
if (quotesUsingType && parseInt(quotesUsingType.count) > 0) {
|
||||
const error = new Error(`Cannot delete: ${quotesUsingType.count} quotes are using this type. Deactivate it instead.`);
|
||||
error.code = 'IN_USE';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve schema lookups BEFORE opening the transaction — a global-db read
|
||||
// inside a SQLite transaction (single connection) deadlocks. Same pattern
|
||||
// as the rename cascade in updateEventType above.
|
||||
const hasTranslations = await db.schema.hasTable('email_template_translations');
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('event_types').where('id', id).del();
|
||||
// Drop the per-type reminder template with the type, or it lingers as an
|
||||
// orphan (invisible in the Reminder Emails tab, which derives its rows
|
||||
// from the live catalog).
|
||||
const tpl = await trx('email_templates')
|
||||
.where({ template_key: `event_reminder_${eventType.slug_prefix}` })
|
||||
.first('id');
|
||||
if (tpl) {
|
||||
if (hasTranslations) {
|
||||
await trx('email_template_translations').where({ template_id: tpl.id }).del();
|
||||
}
|
||||
await trx('email_templates').where({ id: tpl.id }).del();
|
||||
}
|
||||
});
|
||||
await db('event_types').where('id', id).del();
|
||||
|
||||
return { success: true, deleted: eventType };
|
||||
};
|
||||
@@ -428,28 +341,6 @@ const getEventTypeForSlug = async (eventTypeIdentifier) => {
|
||||
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the fallback event type for document→event conversions (quotes,
|
||||
* contracts) when the source carries none. Never hardcodes a specific slug
|
||||
* (any of them, incl. 'other', can be disabled by the admin): prefer the
|
||||
* generic 'other' catch-all when it's active, else the first active type by
|
||||
* display order, and only fall back to the literal 'other' if the catalog is
|
||||
* somehow empty/unreadable.
|
||||
* @param {Object} [conn] - Optional knex connection/transaction
|
||||
* @returns {Promise<string>} - slug_prefix to use
|
||||
*/
|
||||
const resolveDefaultEventType = async (conn) => {
|
||||
const q = conn || db;
|
||||
try {
|
||||
const other = await q('event_types').where({ slug_prefix: 'other', is_active: formatBoolean(true) }).first('slug_prefix');
|
||||
if (other) return 'other';
|
||||
const firstActive = await q('event_types').where({ is_active: formatBoolean(true) }).orderBy('display_order', 'asc').first('slug_prefix');
|
||||
return firstActive?.slug_prefix || 'other';
|
||||
} catch (_) {
|
||||
return 'other';
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllEventTypes,
|
||||
getActiveEventTypes,
|
||||
@@ -461,6 +352,5 @@ module.exports = {
|
||||
updateEventType,
|
||||
deleteEventType,
|
||||
reorderEventTypes,
|
||||
getEventTypeForSlug,
|
||||
resolveDefaultEventType
|
||||
getEventTypeForSlug
|
||||
};
|
||||
|
||||
@@ -174,14 +174,6 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
|
||||
? 0
|
||||
: ensureInt(invoice.net_amount_minor) - displayedNetMinor;
|
||||
|
||||
// Optional free-text VAT / legal note printed directly under the MwSt. line
|
||||
// on the invoice PDF (#794). Configured globally in Settings → CRM → Invoices.
|
||||
// Data-driven: the admin types the exact wording (e.g. the Austrian
|
||||
// Kleinunternehmer statement, § 6 Abs. 1 Z 27 UStG 1994), so no jurisdiction
|
||||
// is hardcoded. Empty/whitespace → null (row omitted).
|
||||
const vatNoteRaw = await getAppSetting('crm_invoices_vat_note_text');
|
||||
const vatNote = typeof vatNoteRaw === 'string' && vatNoteRaw.trim() ? vatNoteRaw.trim() : null;
|
||||
|
||||
return {
|
||||
locale: invoice.language || profile?.default_locale || 'de',
|
||||
currency: invoice.currency,
|
||||
@@ -197,8 +189,6 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
|
||||
iban: bank.iban, bic: bank.bic, currency: bank.currency,
|
||||
} : null,
|
||||
paymentTerm,
|
||||
// Free-text VAT/legal note (#794) — rendered under the MwSt. line by drawTotals.
|
||||
vatNote,
|
||||
lineItems: lineItems.map((li) => ({
|
||||
quantity: li.quantity,
|
||||
description: li.description,
|
||||
|
||||
@@ -851,18 +851,6 @@ function drawTotals(doc, ctx, x, y, width) {
|
||||
doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
|
||||
y = doc.y + 4;
|
||||
|
||||
// Free-text VAT / legal note (#794) — printed directly under the MwSt. line
|
||||
// (Benedikt's requested spot). The admin sets the exact wording in
|
||||
// Settings → CRM → Invoices (e.g. the Austrian Kleinunternehmer statement).
|
||||
// Optional; wraps across the totals column. Font size is restored to the row
|
||||
// scale so the Mahngebühr / Rundung / grand-total rows below are unaffected.
|
||||
if (ctx.vatNote) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#555');
|
||||
doc.text(ctx.vatNote, labelX, y, { width: right - labelX });
|
||||
doc.fillColor('#000').fontSize(10);
|
||||
y = doc.y + 4;
|
||||
}
|
||||
|
||||
// Mahngebühr row — only rendered when a late fee has been added
|
||||
// (second reminder onwards). Sits between VAT and the grand-total
|
||||
// divider so the customer sees a clear "VAT + late fee → Total"
|
||||
@@ -1682,16 +1670,7 @@ function renderDocument(type, context) {
|
||||
// VAT + middle divider + Total)
|
||||
const FOOTER_RESERVE = 30;
|
||||
const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50;
|
||||
let TOTALS_BLOCK_HEIGHT = 90;
|
||||
// A free-text VAT note (#794) adds a wrapped row under the MwSt. line —
|
||||
// grow the reserved totals height by its measured height so a long note
|
||||
// can't push the grand total / payment block into the footer.
|
||||
if (ctx.vatNote) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8);
|
||||
const noteWidth = PAGE.contentWidth - ((PAGE.contentWidth - 20) / 2 + 20);
|
||||
TOTALS_BLOCK_HEIGHT += doc.heightOfString(ctx.vatNote, { width: noteWidth }) + 4;
|
||||
doc.fontSize(10);
|
||||
}
|
||||
const TOTALS_BLOCK_HEIGHT = 90;
|
||||
const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT;
|
||||
const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT;
|
||||
|
||||
@@ -1767,23 +1746,14 @@ function renderDocument(type, context) {
|
||||
// grey line in the bottom corner) is negligible.
|
||||
for (let i = 0; i < total; i++) {
|
||||
doc.switchToPage(range.start + i);
|
||||
// Drop this page's bottom margin to 0 so writing the label INTO the
|
||||
// margin band (below the content area the line-item table fills) can't
|
||||
// trigger PDFKit's auto-page-break. Previously the label sat at
|
||||
// marginBottom-12 — INSIDE the content area — so on a full multi-page
|
||||
// invoice the table's last row overlapped the "Seite X von Y" stamp
|
||||
// (#794). The page is already fully laid out (buffered), so zeroing the
|
||||
// margin here is safe.
|
||||
doc.page.margins.bottom = 0;
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
|
||||
const label = t(ctx.locale, 'page_of', {
|
||||
current: i + 1,
|
||||
total,
|
||||
});
|
||||
// Bottom-right corner, INSIDE the bottom margin (below the content
|
||||
// edge the table fills), so a full continuation page's last row can't
|
||||
// overlap it.
|
||||
const labelY = doc.page.height - PAGE.marginBottom + 8;
|
||||
// Bottom-right corner, just above the bottom margin so
|
||||
// it doesn't trigger PDFKit's auto-paging.
|
||||
const labelY = doc.page.height - PAGE.marginBottom - 12;
|
||||
const labelW = 120;
|
||||
const labelX = doc.page.width - PAGE.marginRight - labelW;
|
||||
doc.text(label, labelX, labelY, {
|
||||
@@ -1823,8 +1793,6 @@ function normaliseContext(type, ctx) {
|
||||
totals: ctx.totals || {},
|
||||
doc: ctx.doc || {},
|
||||
qrFormat: ctx.qrFormat || 'none',
|
||||
// Free-text VAT/legal note printed under the MwSt. line on invoices (#794).
|
||||
vatNote: (typeof ctx.vatNote === 'string' && ctx.vatNote.trim()) ? ctx.vatNote.trim() : null,
|
||||
// Date-format config from the `general_date_format` app setting.
|
||||
// Shape: `{ format: 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' |
|
||||
// 'YYYY-MM-DD', locale?: string }`. The service layer hydrates
|
||||
|
||||
@@ -34,7 +34,6 @@ const { cleanNetMinor } = require('../utils/invoiceRounding');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { nextDocumentNumber } = require('../utils/documentSequences');
|
||||
const { resolveDefaultEventType } = require('./eventTypeService');
|
||||
const { formatShortDate } = require('../utils/dateFormatter');
|
||||
const businessProfileService = require('./businessProfileService');
|
||||
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
||||
@@ -309,6 +308,25 @@ async function nextQuoteNumber(trx) {
|
||||
return nextDocumentNumber('quote', 'crm_quotes_number_format', 'Q-{YEAR}-{SEQ:04d}', trx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the fallback event type for a quote→event conversion when the quote
|
||||
* itself carries none. Never hardcodes a specific slug (any of them, incl.
|
||||
* 'other', can be disabled by the admin): prefer the generic 'other' catch-all
|
||||
* when it's active, else the first active type by display order, and only fall
|
||||
* back to the literal 'other' if the catalog is somehow empty/unreadable.
|
||||
*/
|
||||
async function resolveDefaultEventType(conn) {
|
||||
const q = conn || db;
|
||||
try {
|
||||
const other = await q('event_types').where({ slug_prefix: 'other', is_active: true }).first('slug_prefix');
|
||||
if (other) return 'other';
|
||||
const firstActive = await q('event_types').where({ is_active: true }).orderBy('display_order', 'asc').first('slug_prefix');
|
||||
return firstActive?.slug_prefix || 'other';
|
||||
} catch (_) {
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCustomerFeatureEnabled(customer, feature) {
|
||||
// Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`)
|
||||
// is checked at the route layer (feature flag); here we only enforce
|
||||
|
||||
@@ -20,26 +20,6 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
// is permanently closed once setup is done — safe even on a public IP.
|
||||
const SETUP_TOKEN_KEY = 'setup_token';
|
||||
|
||||
// One-way flag flipped when the setup wizard finishes (migration 161 marks it
|
||||
// completed on installs that predate the wizard's event-types step). While it
|
||||
// is unset — i.e. only during the first-run wizard — the seeded SYSTEM event
|
||||
// types may be deleted (eventTypeService.deleteEventType), because nothing
|
||||
// can reference them yet. Once true, system types are permanently protected.
|
||||
const SETUP_WIZARD_COMPLETED_KEY = 'setup_wizard_completed';
|
||||
|
||||
async function isSetupWizardCompleted() {
|
||||
// Fail closed: only an explicit stored `false` (seeded by migration 161 on
|
||||
// a fresh, admin-less install) opens the deletion window. A missing row —
|
||||
// e.g. app_settings replaced by a portable-backup restore that predates the
|
||||
// migration, which will not rerun — means a configured instance, not a
|
||||
// first run.
|
||||
return (await getAppSetting(SETUP_WIZARD_COMPLETED_KEY)) !== false;
|
||||
}
|
||||
|
||||
async function markSetupWizardCompleted() {
|
||||
await upsertAppSetting(SETUP_WIZARD_COMPLETED_KEY, JSON.stringify(true), 'boolean');
|
||||
}
|
||||
|
||||
async function noAdminExists() {
|
||||
const row = await db('admin_users').count({ c: '*' }).first();
|
||||
return Number(row?.c || 0) === 0;
|
||||
@@ -193,11 +173,4 @@ async function createInitialAdmin({ token, email, password, ip }) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getSetupStatus,
|
||||
ensureSetupToken,
|
||||
verifySetupToken,
|
||||
createInitialAdmin,
|
||||
isSetupWizardCompleted,
|
||||
markSetupWizardCompleted,
|
||||
};
|
||||
module.exports = { getSetupStatus, ensureSetupToken, verifySetupToken, createInitialAdmin };
|
||||
|
||||
@@ -46,18 +46,11 @@ function outEdge(edges, fromNode, handle) {
|
||||
|
||||
function computeWakeAt(config = {}, vars = {}) {
|
||||
const cfg = config || {};
|
||||
if (cfg.untilVar && vars[cfg.untilVar]) return new Date(vars[cfg.untilVar]).toISOString();
|
||||
const ms = (Number(cfg.delayDays || 0) * 86400000)
|
||||
+ (Number(cfg.delayHours || 0) * 3600000)
|
||||
+ (Number(cfg.delayMinutes || 0) * 60000);
|
||||
// Anchor to a context var when given (e.g. dueDate), plus any delay offset —
|
||||
// so `{ untilVar: 'dueDate', delayDays: 7 }` means "due date + 7 days"
|
||||
// (absolute), and an already-past anchor resumes immediately. Backward
|
||||
// compatible: untilVar-only → the var; delay-only → now + delay. Behaviour
|
||||
// change for the both-fields case (untilVar + delay): previously the delay was
|
||||
// ignored and only the var returned; now they add (this is the intended
|
||||
// waitGrace semantics — no seeded node relied on the old both-fields path).
|
||||
const base = (cfg.untilVar && vars[cfg.untilVar]) ? new Date(vars[cfg.untilVar]) : new Date();
|
||||
return new Date(base.getTime() + ms).toISOString();
|
||||
return new Date(Date.now() + ms).toISOString();
|
||||
}
|
||||
|
||||
function gateTimeout(config = {}) {
|
||||
@@ -428,51 +421,6 @@ async function isBuiltinFlowActive(builtinKey) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll every open, unpaid invoice into the dunning flow by emitting
|
||||
* `invoice.sent` for it — called when the dunning built-in is turned ON so it
|
||||
* starts chasing invoices that were already sent, not only new ones (#750).
|
||||
* Idempotent: emitWorkflowEvent's per-(flow, entity) dedup means at most one
|
||||
* run per invoice, so re-enabling is safe. Paired with the due-date-anchored
|
||||
* grace wait, already-overdue invoices dun on their real timeline immediately.
|
||||
*
|
||||
* Scoped to `targetWorkflowId` (the dunning flow being enabled) so the backfill
|
||||
* only enrolls invoices into dunning — never into unrelated custom `invoice.sent`
|
||||
* flows an admin may have built, which would fire their actions for every
|
||||
* historical invoice.
|
||||
*/
|
||||
async function backfillDunningRuns(targetWorkflowId) {
|
||||
let enrolled = 0;
|
||||
try {
|
||||
if (!(await db.schema.hasTable('invoices'))) return 0;
|
||||
const invoices = await db('invoices')
|
||||
.whereIn('status', ['sent', 'overdue'])
|
||||
.whereNotNull('due_date')
|
||||
.whereRaw('COALESCE(paid_amount_minor, 0) < total_amount_minor');
|
||||
for (const inv of invoices) {
|
||||
const ids = await emitWorkflowEvent('invoice.sent', {
|
||||
entityType: 'invoice',
|
||||
entityId: inv.id,
|
||||
targetWorkflowId,
|
||||
payload: {
|
||||
invoiceId: inv.id,
|
||||
invoiceNumber: inv.invoice_number,
|
||||
eventId: inv.event_id || null,
|
||||
customerAccountId: inv.customer_account_id,
|
||||
dueDate: inv.due_date,
|
||||
issueDate: inv.issue_date,
|
||||
totalMinor: inv.total_amount_minor,
|
||||
currency: inv.currency,
|
||||
},
|
||||
});
|
||||
if (ids && ids.length) enrolled += 1;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[workflow] dunning backfill failed', { error: e.message });
|
||||
}
|
||||
return enrolled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `event.date_approaching` for events entering an enabled flow's lead
|
||||
* window. This is the trigger source for the pre-event reminder built-in, so it
|
||||
@@ -597,7 +545,6 @@ async function testRun(workflowId, { entityType = null, entityId = null, payload
|
||||
module.exports = {
|
||||
emitWorkflowEvent,
|
||||
isBuiltinFlowActive,
|
||||
backfillDunningRuns,
|
||||
runDueWaits,
|
||||
emitDueEventReminders,
|
||||
recoverStaleRuns,
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* Category order resolution (#782).
|
||||
*
|
||||
* Resolves an event's categories into their effective display order, layering:
|
||||
* 1. per-event override — event_category_order.position, when the event has
|
||||
* been customised;
|
||||
* 2. the global default — photo_categories.display_order (migration 159);
|
||||
* 3. name.
|
||||
*
|
||||
* Globals and event-specific categories are ordered together so a custom order
|
||||
* can interleave them into the flow of the day. Shared by the admin event view
|
||||
* and the public gallery so the two never diverge.
|
||||
*/
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('./dbCompat');
|
||||
const { hasColumnCached } = require('./schemaCache');
|
||||
|
||||
/**
|
||||
* @param {number|string} eventId
|
||||
* @param {object} [opts]
|
||||
* @param {number[]|null} [opts.onlyIds] restrict to these category ids (the
|
||||
* public gallery only shows categories that actually have photos).
|
||||
* @param {string[]|null} [opts.select] qualified columns to select (default
|
||||
* `c.*`). Always aliased to the `photo_categories as c` table.
|
||||
* @returns rows with an added `override_position` (null when not customised).
|
||||
*/
|
||||
async function getEventCategoriesOrdered(eventId, { onlyIds = null, select = null } = {}) {
|
||||
const eid = parseInt(eventId, 10);
|
||||
|
||||
const base = db('photo_categories as c').where(function () {
|
||||
this.where('c.is_global', formatBoolean(true)).orWhere('c.event_id', eid);
|
||||
});
|
||||
if (onlyIds) base.whereIn('c.id', onlyIds);
|
||||
|
||||
// Fail safe: if the override table isn't present yet (half-applied migration),
|
||||
// fall back to the global-default order so the public gallery never 500s.
|
||||
const overrideReady = await hasColumnCached('event_category_order', 'position');
|
||||
if (!overrideReady) {
|
||||
return base
|
||||
.select(select || 'c.*')
|
||||
.orderBy('c.is_global', 'desc')
|
||||
.orderBy('c.display_order', 'asc')
|
||||
.orderBy('c.name', 'asc');
|
||||
}
|
||||
|
||||
const cols = select ? [...select] : ['c.*'];
|
||||
cols.push('o.position as override_position');
|
||||
|
||||
return base
|
||||
.leftJoin('event_category_order as o', function () {
|
||||
this.on('o.category_id', 'c.id').andOnVal('o.event_id', '=', eid);
|
||||
})
|
||||
.select(cols)
|
||||
// Overridden categories first (in their pinned order), then the rest by the
|
||||
// global default. CASE keeps NULL-ordering portable across SQLite + Postgres.
|
||||
.orderByRaw('CASE WHEN o.position IS NULL THEN 1 ELSE 0 END ASC')
|
||||
.orderBy('o.position', 'asc')
|
||||
.orderBy('c.is_global', 'desc')
|
||||
.orderBy('c.display_order', 'asc')
|
||||
.orderBy('c.name', 'asc');
|
||||
}
|
||||
|
||||
module.exports = { getEventCategoriesOrdered };
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.88.1-beta.0",
|
||||
"version": "3.82.4-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -42,7 +42,6 @@ import { HoursLoggingPage } from './pages/admin/clients/HoursLoggingPage';
|
||||
// (carved into its own chunk in vite.config.ts) doesn't ship with the
|
||||
// main app. Only pages that visit /admin/clients/calendar fetch it.
|
||||
const CalendarPage = lazy(() => import('./pages/admin/clients/CalendarPage').then((m) => ({ default: m.CalendarPage })));
|
||||
const MessagesPage = lazy(() => import('./pages/admin/messages/MessagesPage').then((m) => ({ default: m.MessagesPage })));
|
||||
import { QuoteResponsePage } from './pages/public/QuoteResponsePage';
|
||||
import { ContractResponsePage } from './pages/public/ContractResponsePage';
|
||||
import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
|
||||
@@ -242,13 +241,6 @@ function App() {
|
||||
<Route element={<RequireFeature flag="userManagement" />}>
|
||||
<Route path="users" element={<UserManagementPage />} />
|
||||
</Route>
|
||||
<Route element={<RequireFeature flag="messaging" />}>
|
||||
<Route path="messages" element={
|
||||
<Suspense fallback={<Loading />}>
|
||||
<MessagesPage />
|
||||
</Suspense>
|
||||
} />
|
||||
</Route>
|
||||
{/* Clients section (#354 follow-up). Parent route
|
||||
gated by the top-level `clients` flag — when off
|
||||
the sidebar entry is hidden and every /admin/clients/*
|
||||
|
||||
@@ -11,17 +11,14 @@ import {
|
||||
Users,
|
||||
Briefcase,
|
||||
Landmark,
|
||||
Mail,
|
||||
Workflow,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Github,
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { VersionInfo } from './VersionInfo';
|
||||
import { repoUrl } from '../../utils/githubReleaseUrl';
|
||||
import { usePermissions } from '../../contexts/PermissionsContext';
|
||||
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||
@@ -67,7 +64,6 @@ const navigation: NavItem[] = [
|
||||
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false },
|
||||
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
|
||||
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
|
||||
{ nameKey: 'navigation.messages', href: '/admin/messages', icon: Mail, permission: 'email.view', featureFlag: 'messaging' },
|
||||
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
|
||||
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
||||
{ nameKey: 'navigation.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' },
|
||||
@@ -326,20 +322,6 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
|
||||
|
||||
{/* Storage Info */}
|
||||
<StorageInfo />
|
||||
|
||||
{/* Link to the project on GitHub (#778). Subtle footer row so
|
||||
admins can reach the repo — star, source, report an issue —
|
||||
from anywhere in the dashboard, not just the setup screen. */}
|
||||
<a
|
||||
href={repoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mx-4 mb-3 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
|
||||
title={t('admin.viewOnGithub', 'View PicPeak on GitHub')}
|
||||
>
|
||||
<Github className="w-3.5 h-3.5" />
|
||||
<span>{t('admin.viewOnGithub', 'View PicPeak on GitHub')}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, Edit2, Trash2, Loader2, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { Button } from '../common';
|
||||
@@ -13,36 +13,12 @@ export const CategoryManager: React.FC = () => {
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [editingName, setEditingName] = useState('');
|
||||
|
||||
// Fetch global categories (ordered by the global default display_order)
|
||||
// Fetch global categories
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['global-categories'],
|
||||
queryFn: categoriesService.getGlobalCategories,
|
||||
});
|
||||
|
||||
// Local copy so the up/down reorder buttons feel instant; resynced when the
|
||||
// query data changes.
|
||||
const [ordered, setOrdered] = useState<PhotoCategory[]>(categories);
|
||||
useEffect(() => {
|
||||
setOrdered(categories);
|
||||
}, [categories]);
|
||||
|
||||
// Set the GLOBAL default order (#782). Applies to every gallery that hasn't
|
||||
// set its own per-event override.
|
||||
const reorderMutation = useMutationWithToast({
|
||||
mutationFn: (orderedIds: number[]) => categoriesService.reorderGlobalCategories(orderedIds),
|
||||
invalidateKeys: [['global-categories']],
|
||||
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
|
||||
});
|
||||
|
||||
const handleMove = (index: number, dir: -1 | 1) => {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= ordered.length) return;
|
||||
const next = [...ordered];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setOrdered(next); // optimistic
|
||||
reorderMutation.mutate(next.map((c) => c.id));
|
||||
};
|
||||
|
||||
// Create category mutation
|
||||
const createMutation = useMutationWithToast({
|
||||
mutationFn: (name: string) =>
|
||||
@@ -168,12 +144,12 @@ export const CategoryManager: React.FC = () => {
|
||||
|
||||
{/* Categories list */}
|
||||
<div className="space-y-2">
|
||||
{ordered.length === 0 ? (
|
||||
{categories.length === 0 ? (
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||
{t('categories.noCategoriesYet')}
|
||||
</p>
|
||||
) : (
|
||||
ordered.map((category, index) => (
|
||||
categories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors"
|
||||
@@ -213,33 +189,9 @@ export const CategoryManager: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Global default order (#782). The gallery uses this order
|
||||
unless a specific event overrides it. */}
|
||||
<div className="flex flex-col -space-y-1">
|
||||
<button
|
||||
onClick={() => handleMove(index, -1)}
|
||||
disabled={index === 0 || reorderMutation.isPending}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveUp', 'Move up')}
|
||||
aria-label={t('categories.moveUp', 'Move up')}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMove(index, 1)}
|
||||
disabled={index === ordered.length - 1 || reorderMutation.isPending}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveDown', 'Move down')}
|
||||
aria-label={t('categories.moveDown', 'Move down')}
|
||||
>
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 truncate">/{category.slug}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{category.slug}</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* Customer mailbox (hello@) configuration — a second inbound IMAP box beyond
|
||||
* the accounting rechnungen@ one, stored in `mail_accounts` under the fixed
|
||||
* account_key 'customers'. Its mail feeds Messages → Customers ▸ Inbox (body
|
||||
* captured, attachments NOT routed to accounting). Shown when the `messaging`
|
||||
* feature flag is on. Styled to match the Incoming Mail card.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Save, Server, User, Lock, Eye, EyeOff, PlugZap, Inbox } from 'lucide-react';
|
||||
import { Button, Card, Input, Loading } from '../common';
|
||||
import { emailService, type MailAccount } from '../../services/email.service';
|
||||
import { useMutationWithToast, useModal } from '../../hooks';
|
||||
|
||||
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
|
||||
const selectCls = 'w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark';
|
||||
|
||||
const ACCOUNT_KEY = 'customers';
|
||||
|
||||
export const CustomerMailboxCard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading } = useQuery({ queryKey: ['mail-accounts'], queryFn: () => emailService.listMailAccounts() });
|
||||
const [cfg, setCfg] = useState<MailAccount>({ account_key: ACCOUNT_KEY, imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '', imap_folder: 'INBOX', enabled: false });
|
||||
const passwordVisibility = useModal();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
const row = data.find((a) => a.account_key === ACCOUNT_KEY);
|
||||
if (row) setCfg({ ...row, imap_pass: row.imap_pass || '' });
|
||||
}, [data]);
|
||||
|
||||
const set = (k: keyof MailAccount, v: any) => setCfg((c) => ({ ...c, [k]: v }));
|
||||
|
||||
const save = useMutationWithToast({
|
||||
mutationFn: () => {
|
||||
if (!cfg.imap_host || !cfg.imap_port || !cfg.imap_user) {
|
||||
return Promise.reject(new Error(t('email.customerMailbox.requiredFields', 'Host, port and username are required.')));
|
||||
}
|
||||
return emailService.saveMailAccount({ ...cfg, account_key: ACCOUNT_KEY, label: 'Customers' });
|
||||
},
|
||||
successMessage: t('email.customerMailbox.savedToast', 'Customer mailbox saved.'),
|
||||
invalidateKeys: [['mail-accounts']],
|
||||
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
|
||||
});
|
||||
|
||||
const test = useMutationWithToast({
|
||||
mutationFn: () => emailService.testMailAccount({ ...cfg, account_key: ACCOUNT_KEY }),
|
||||
successMessage: (r) => t('email.customerMailbox.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen }),
|
||||
errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.customerMailbox.testFailed', 'Connection failed.'),
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
|
||||
return (
|
||||
<Card padding="md" className="mt-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
|
||||
<Inbox className="w-5 h-5 text-neutral-400" />
|
||||
{t('email.customerMailbox.title', 'Customer mailbox (hello@)')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('email.customerMailbox.subtitle', 'A second inbound mailbox for customer conversations. Its mail appears under Messages → Customers; attachments are not routed to Accounting.')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<input type="checkbox" checked={!!cfg.enabled} onChange={(e) => set('enabled', e.target.checked)} />
|
||||
{t('email.customerMailbox.enabled', 'Poll this mailbox every minute')}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.host', 'IMAP Host')} <span className="text-red-500">*</span></label>
|
||||
<Input type="text" value={cfg.imap_host || ''} onChange={(e) => set('imap_host', e.target.value)} placeholder="imap.example.com" leftIcon={<Server className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.port', 'Port')} <span className="text-red-500">*</span></label>
|
||||
<Input type="number" value={cfg.imap_port ?? 993} onChange={(e) => set('imap_port', parseInt(e.target.value, 10) || 0)} placeholder="993" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
|
||||
<select className={selectCls} value={cfg.imap_secure ? 'ssl' : 'plain'} onChange={(e) => set('imap_secure', e.target.value === 'ssl')}>
|
||||
<option value="ssl">{t('email.incoming.ssl', 'SSL/TLS')}</option>
|
||||
<option value="plain">{t('email.incoming.plain', 'None / STARTTLS')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.user', 'Username')} <span className="text-red-500">*</span></label>
|
||||
<Input type="text" value={cfg.imap_user || ''} onChange={(e) => set('imap_user', e.target.value)} autoComplete="off" placeholder="hello@yourdomain.com" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.pass', 'Password')}</label>
|
||||
<div className="relative">
|
||||
<Input type={passwordVisibility.isOpen ? 'text' : 'password'} value={cfg.imap_pass || ''} onChange={(e) => set('imap_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} />
|
||||
<button type="button" onClick={passwordVisibility.toggle} className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600">
|
||||
{passwordVisibility.isOpen ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.folder', 'Folder')}</label>
|
||||
<Input type="text" value={cfg.imap_folder || 'INBOX'} onChange={(e) => set('imap_folder', e.target.value)} placeholder="INBOX" />
|
||||
</div>
|
||||
|
||||
<div className="pt-4 mt-1 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div className="text-sm font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
{t('email.customerMailbox.outgoing', 'Outgoing (SMTP)')}
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5 mb-3">
|
||||
{t('email.customerMailbox.outgoingHint', 'Replies from this mailbox send from here. Leave blank to fall back to the global outgoing address.')}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.customerMailbox.fromEmail', 'From address')}</label>
|
||||
<Input type="text" value={cfg.from_email || ''} onChange={(e) => set('from_email', e.target.value)} placeholder="hello@yourdomain.com" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.customerMailbox.smtpHost', 'SMTP Host')}</label>
|
||||
<Input type="text" value={cfg.smtp_host || ''} onChange={(e) => set('smtp_host', e.target.value)} placeholder="smtp.example.com" leftIcon={<Server className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.port', 'Port')}</label>
|
||||
<Input type="number" value={cfg.smtp_port ?? 587} onChange={(e) => set('smtp_port', parseInt(e.target.value, 10) || 0)} placeholder="587" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
|
||||
<select className={selectCls} value={cfg.smtp_secure ? 'ssl' : 'starttls'} onChange={(e) => set('smtp_secure', e.target.value === 'ssl')}>
|
||||
<option value="ssl">{t('email.customerMailbox.smtpSsl', 'SSL (465)')}</option>
|
||||
<option value="starttls">{t('email.customerMailbox.smtpStarttls', 'STARTTLS (587)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.customerMailbox.smtpUser', 'SMTP Username')}</label>
|
||||
<Input type="text" value={cfg.smtp_user || ''} onChange={(e) => set('smtp_user', e.target.value)} autoComplete="off" placeholder="hello@yourdomain.com" leftIcon={<User className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>{t('email.customerMailbox.smtpPass', 'SMTP Password')}</label>
|
||||
<Input type={passwordVisibility.isOpen ? 'text' : 'password'} value={cfg.smtp_pass || ''} onChange={(e) => set('smtp_pass', e.target.value)} autoComplete="new-password" placeholder={t('email.enterPassword', 'Enter password')} leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => test.mutate()} isLoading={test.isPending} disabled={!cfg.imap_host || !cfg.imap_user} leftIcon={<PlugZap className="w-5 h-5" />} className="whitespace-nowrap">
|
||||
{t('email.incoming.test', 'Test connection')}
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1 min-w-[12rem]">
|
||||
{t('email.customerMailbox.save', 'Save Customer Mailbox')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomerMailboxCard;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
|
||||
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud } from 'lucide-react';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { Button, Card, AuthenticatedImage } from '../common';
|
||||
@@ -17,8 +17,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
|
||||
|
||||
// Fetch this event's categories (globals + event-specific), already resolved
|
||||
// to the event's effective order by the backend (#782).
|
||||
// Fetch categories for this event
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['event-categories', eventId],
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
@@ -31,21 +30,17 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
enabled: heroPickerCategoryId !== null,
|
||||
});
|
||||
|
||||
// Combined list (globals + event-specific) in the resolved order, kept in
|
||||
// local state so the up/down reorder buttons feel instant; resynced whenever
|
||||
// the query data changes (e.g. after a reorder or reset persists).
|
||||
const [ordered, setOrdered] = useState<PhotoCategory[]>(categories);
|
||||
useEffect(() => {
|
||||
setOrdered(categories);
|
||||
}, [categories]);
|
||||
// Filter to show only event-specific categories
|
||||
const eventCategories = categories.filter(cat => !cat.is_global);
|
||||
|
||||
// The event is "customised" when it has its own per-event override.
|
||||
const isCustomised = ordered.some((c) => c.override_position != null);
|
||||
|
||||
// Create category mutation (always event-specific)
|
||||
// Create category mutation
|
||||
const createMutation = useMutationWithToast({
|
||||
mutationFn: (name: string) =>
|
||||
categoriesService.createCategory({ name, is_global: false, event_id: eventId }),
|
||||
categoriesService.createCategory({
|
||||
name,
|
||||
is_global: false,
|
||||
event_id: eventId
|
||||
}),
|
||||
invalidateKeys: [['event-categories', eventId]],
|
||||
successMessage: t('categories.categoryCreatedSuccess'),
|
||||
onSuccess: () => {
|
||||
@@ -76,7 +71,9 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
errorMessage: t('categories.failedToSetCoverPhoto'),
|
||||
});
|
||||
|
||||
// Toggle per-category download permission (#640). Event-specific only.
|
||||
// Toggle per-category download permission (#640). The backend AND's this
|
||||
// with the event-level `allow_downloads`, so disabling at either level
|
||||
// blocks downloads for this category's photos.
|
||||
const downloadToggleMutation = useMutationWithToast({
|
||||
mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) =>
|
||||
categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }),
|
||||
@@ -88,32 +85,6 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
errorMessage: t('categories.failedToToggleDownloads', 'Failed to update download permission'),
|
||||
});
|
||||
|
||||
// Per-event order override (#782). Sends the full ordered id list; the backend
|
||||
// pins it for this gallery only. Up/down buttons match the invoice line-item
|
||||
// convention (no drag-and-drop dependency).
|
||||
const reorderMutation = useMutationWithToast({
|
||||
mutationFn: (orderedIds: number[]) => categoriesService.reorderCategories(eventId, orderedIds),
|
||||
invalidateKeys: [['event-categories', eventId]],
|
||||
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
|
||||
});
|
||||
|
||||
// Revert this gallery to the global default order.
|
||||
const resetMutation = useMutationWithToast({
|
||||
mutationFn: () => categoriesService.resetEventOrder(eventId),
|
||||
invalidateKeys: [['event-categories', eventId]],
|
||||
successMessage: t('categories.orderReset', 'Reverted to the default order'),
|
||||
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
|
||||
});
|
||||
|
||||
const handleMove = (index: number, dir: -1 | 1) => {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= ordered.length) return;
|
||||
const next = [...ordered];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setOrdered(next); // optimistic — instant feedback
|
||||
reorderMutation.mutate(next.map((c) => c.id));
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newCategoryName.trim()) {
|
||||
createMutation.mutate(newCategoryName.trim());
|
||||
@@ -134,8 +105,6 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
heroMutation.mutate({ categoryId, photoId: null });
|
||||
};
|
||||
|
||||
const busy = reorderMutation.isPending || resetMutation.isPending;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-4">
|
||||
@@ -146,38 +115,23 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.galleryOrder', 'Gallery order')}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCustomised && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={busy}
|
||||
leftIcon={<RotateCcw className="w-3 h-3" />}
|
||||
>
|
||||
{t('categories.resetToDefault', 'Reset to default')}
|
||||
</Button>
|
||||
)}
|
||||
{!addingModal.isOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addingModal.open}
|
||||
leftIcon={<Plus className="w-3 h-3" />}
|
||||
>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
|
||||
{!addingModal.isOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addingModal.open}
|
||||
leftIcon={<Plus className="w-3 h-3" />}
|
||||
>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Explain the two ordering layers */}
|
||||
{/* Hint about hero photo fallback */}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
|
||||
{isCustomised
|
||||
? t('categories.orderCustomisedHint', 'This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).')
|
||||
: t('categories.orderDefaultHint', 'Use the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).')}
|
||||
{t('categories.categoryHeroHint')}
|
||||
</p>
|
||||
|
||||
{/* Add new category form */}
|
||||
@@ -198,7 +152,11 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
onClick={handleCreate}
|
||||
disabled={!newCategoryName.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : t('common.add')}
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
t('common.add')
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -213,14 +171,14 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined, reorderable category list (globals + event-specific) */}
|
||||
{ordered.length === 0 ? (
|
||||
{/* Event categories list */}
|
||||
{eventCategories.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.noEventSpecificCategories')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{ordered.map((category, index) => {
|
||||
{eventCategories.map((category) => {
|
||||
const heroPhoto = category.hero_photo_id
|
||||
? photos.find(p => p.id === category.hero_photo_id)
|
||||
: null;
|
||||
@@ -229,30 +187,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
key={category.id}
|
||||
className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* Reorder controls (#782). The gallery renders categories in
|
||||
this order; changes here override the global default for
|
||||
this event only. */}
|
||||
<div className="flex flex-col -space-y-1">
|
||||
<button
|
||||
onClick={() => handleMove(index, -1)}
|
||||
disabled={index === 0 || busy}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveUp', 'Move up')}
|
||||
aria-label={t('categories.moveUp', 'Move up')}
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMove(index, 1)}
|
||||
disabled={index === ordered.length - 1 || busy}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveDown', 'Move down')}
|
||||
aria-label={t('categories.moveDown', 'Move down')}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* Hero photo thumbnail */}
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(category.id)}
|
||||
@@ -272,56 +207,49 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
|
||||
{category.is_global && (
|
||||
<span className="flex-shrink-0 text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-neutral-500 dark:text-neutral-400">
|
||||
{t('categories.sharedBadge', 'Shared')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Download toggle + delete apply to event-specific categories
|
||||
only. Global categories are managed in Settings. */}
|
||||
{!category.is_global && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => downloadToggleMutation.mutate({
|
||||
category,
|
||||
allow: category.allow_downloads === false,
|
||||
})}
|
||||
className={`p-1 transition-colors ${
|
||||
category.allow_downloads === false
|
||||
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
|
||||
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
|
||||
}`}
|
||||
title={
|
||||
category.allow_downloads === false
|
||||
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
|
||||
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
|
||||
}
|
||||
disabled={downloadToggleMutation.isPending}
|
||||
>
|
||||
{downloadToggleMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : category.allow_downloads === false ? (
|
||||
<Download className="w-3 h-3" />
|
||||
) : (
|
||||
<DownloadCloud className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* Per-category downloads toggle (#640). Green DownloadCloud
|
||||
icon when on, struck-through outline when off. The
|
||||
event-level `allow_downloads` AND's with this — if the
|
||||
whole event has downloads off, this toggle is cosmetic. */}
|
||||
<button
|
||||
onClick={() => downloadToggleMutation.mutate({
|
||||
category,
|
||||
allow: category.allow_downloads === false,
|
||||
})}
|
||||
className={`p-1 transition-colors ${
|
||||
category.allow_downloads === false
|
||||
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
|
||||
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
|
||||
}`}
|
||||
title={
|
||||
category.allow_downloads === false
|
||||
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
|
||||
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
|
||||
}
|
||||
disabled={downloadToggleMutation.isPending}
|
||||
>
|
||||
{downloadToggleMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : category.allow_downloads === false ? (
|
||||
<Download className="w-3 h-3" />
|
||||
) : (
|
||||
<DownloadCloud className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -329,10 +257,41 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint about hero photo fallback */}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.categoryHeroHint')}
|
||||
</p>
|
||||
{/* Show available global categories */}
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
|
||||
<div className="space-y-2">
|
||||
{categories
|
||||
.filter(cat => cat.is_global)
|
||||
.map(cat => {
|
||||
const heroPhoto = cat.hero_photo_id
|
||||
? photos.find(p => p.id === cat.hero_photo_id)
|
||||
: null;
|
||||
return (
|
||||
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md">
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(cat.id)}
|
||||
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark transition-colors flex items-center justify-center"
|
||||
title={t('categories.setCoverPhoto')}
|
||||
>
|
||||
{heroPhoto ? (
|
||||
<AuthenticatedImage
|
||||
src={heroPhoto.thumbnail_url || heroPhoto.url}
|
||||
alt={cat.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : cat.hero_photo_id ? (
|
||||
<ImageIcon className="w-4 h-4 text-accent" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 text-neutral-300" />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">{cat.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Photo Picker Modal */}
|
||||
{heroPickerCategoryId !== null && (
|
||||
@@ -358,7 +317,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{photos.map((photo) => {
|
||||
const currentCategory = ordered.find(c => c.id === heroPickerCategoryId);
|
||||
const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
|
||||
const isSelected = photo.id === currentCategory?.hero_photo_id;
|
||||
return (
|
||||
<div
|
||||
@@ -378,7 +337,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
/>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-accent-dark text-white rounded-full p-1">
|
||||
<div className="absolute top-2 right-2 bg-accent-dark/150 text-white rounded-full p-1">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
@@ -393,7 +352,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-between gap-3">
|
||||
{ordered.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
|
||||
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
|
||||
import { Button, Input, Loading } from '../common';
|
||||
import { eventTypesService, EventType } from '../../services/eventTypes.service';
|
||||
|
||||
interface Props {
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
// One editable row of the wizard's event-type list. Existing rows carry the
|
||||
// catalog id; rows added in the wizard have no id until Continue POSTs them.
|
||||
interface RowState {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug_prefix: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
const normalizeSlug = (value: string) => value.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// First-run event-types step (#800). Shown once, during the setup wizard —
|
||||
// the only window in which the seeded SYSTEM types may be deleted (nothing
|
||||
// references them yet; the backend re-locks them when the wizard finishes).
|
||||
// Deliberately lean: name + URL prefix only. Icons, themes and ordering are
|
||||
// tunable later in Settings → Event Types.
|
||||
export const SetupEventTypesStep: React.FC<Props> = ({ onDone }) => {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<RowState[] | null>(null);
|
||||
const [original, setOriginal] = useState<Map<number, EventType>>(new Map());
|
||||
const [deletedIds, setDeletedIds] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const { isLoading, isError } = useQuery({
|
||||
queryKey: ['setup-event-types'],
|
||||
queryFn: async () => {
|
||||
const types = await eventTypesService.getEventTypes();
|
||||
// Initialize once — a re-run (remount) must not clobber in-progress edits.
|
||||
setOriginal((prev) => (prev.size > 0 ? prev : new Map(types.map((et) => [et.id, et]))));
|
||||
setRows((prev) => prev ?? types.map((et) => ({ id: et.id, name: et.name, slug_prefix: et.slug_prefix, emoji: et.emoji })));
|
||||
return types;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const setRow = (index: number, patch: Partial<RowState>) => {
|
||||
setRows((prev) => (prev ? prev.map((r, i) => (i === index ? { ...r, ...patch } : r)) : prev));
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
// No side effects inside the setRows updater — StrictMode double-invokes
|
||||
// updaters, which would enqueue the same id twice (one DELETE 404s and
|
||||
// shows a false "could not save" warning).
|
||||
const row = rows?.[index];
|
||||
if (!row) return;
|
||||
if (row.id !== undefined) {
|
||||
setDeletedIds((ids) => (ids.includes(row.id!) ? ids : [...ids, row.id!]));
|
||||
}
|
||||
setRows((prev) => (prev ? prev.filter((_, i) => i !== index) : prev));
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
setRows((prev) => (prev ? [...prev, { name: '', slug_prefix: '', emoji: '📷' }] : prev));
|
||||
};
|
||||
|
||||
// Apply the diff, then advance. Ordering matters twice over: deletes first
|
||||
// frees a default's slug for a rename/re-create ("replace Wedding with my
|
||||
// own 'wedding'"), but deleting everything BEFORE a replacement exists could
|
||||
// empty the catalog if the creation then fails. So: when at least one
|
||||
// existing row is kept the catalog can never go empty → delete first; when
|
||||
// the user replaces ALL types → create first and only delete once at least
|
||||
// one replacement actually persisted. (The backend additionally refuses
|
||||
// deleting the last remaining type.) Best-effort like the other wizard
|
||||
// steps — a partial failure warns but never traps the user; everything here
|
||||
// is editable later in Settings → Event Types.
|
||||
const handleContinue = async () => {
|
||||
if (!rows) return;
|
||||
const kept = rows.filter((r) => r.name.trim() && r.slug_prefix.trim());
|
||||
if (kept.length === 0) {
|
||||
toast.error(t('setup.eventTypes.atLeastOne'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
let failures = 0;
|
||||
let deleteFailures = 0;
|
||||
let createdOk = 0;
|
||||
|
||||
const applyDeletes = async () => {
|
||||
for (const id of deletedIds) {
|
||||
try {
|
||||
await eventTypesService.deleteEventType(id);
|
||||
} catch {
|
||||
deleteFailures += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
const applyCreatesAndUpdates = async () => {
|
||||
for (const row of kept) {
|
||||
try {
|
||||
if (row.id !== undefined) {
|
||||
const before = original.get(row.id);
|
||||
const updates: { name?: string; slug_prefix?: string } = {};
|
||||
if (before && row.name.trim() !== before.name) updates.name = row.name.trim();
|
||||
if (before && row.slug_prefix !== before.slug_prefix) updates.slug_prefix = row.slug_prefix;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await eventTypesService.updateEventType(row.id, updates);
|
||||
}
|
||||
} else {
|
||||
await eventTypesService.createEventType({
|
||||
name: row.name.trim(),
|
||||
slug_prefix: row.slug_prefix,
|
||||
emoji: row.emoji,
|
||||
});
|
||||
createdOk += 1;
|
||||
}
|
||||
} catch {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const keptExisting = kept.filter((r) => r.id !== undefined).length;
|
||||
if (keptExisting > 0) {
|
||||
await applyDeletes();
|
||||
await applyCreatesAndUpdates();
|
||||
} else {
|
||||
await applyCreatesAndUpdates();
|
||||
if (deletedIds.length > 0 && createdOk === 0) {
|
||||
// Every replacement failed — deleting now would empty the catalog.
|
||||
// Keep the seeded types and stay on the step.
|
||||
setSaving(false);
|
||||
toast.error(t('setup.eventTypes.atLeastOne'));
|
||||
return;
|
||||
}
|
||||
await applyDeletes();
|
||||
}
|
||||
|
||||
// A failed DELETE must not slip past this step: system types are only
|
||||
// deletable inside this window, so once the wizard finishes the request
|
||||
// can never be retried. Reload the live catalog and stay for a retry.
|
||||
if (deleteFailures > 0) {
|
||||
try {
|
||||
const types = await eventTypesService.getEventTypes();
|
||||
setOriginal(new Map(types.map((et) => [et.id, et])));
|
||||
setRows(types.map((et) => ({ id: et.id, name: et.name, slug_prefix: et.slug_prefix, emoji: et.emoji })));
|
||||
} catch { /* keep the local rows if the reload fails */ }
|
||||
setDeletedIds([]);
|
||||
setSaving(false);
|
||||
toast.error(t('setup.eventTypes.deleteFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
if (failures > 0) toast.warn(t('setup.eventTypes.saveFailed'));
|
||||
onDone();
|
||||
};
|
||||
|
||||
if (isLoading || rows === null) {
|
||||
return isError ? (
|
||||
// Catalog unreadable — don't trap the user; the defaults stay seeded and
|
||||
// remain editable later in Settings → Event Types.
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-neutral-600">{t('setup.eventTypes.loadFailed')}</p>
|
||||
<Button type="button" variant="primary" size="lg" className="w-full" onClick={onDone}>
|
||||
{t('setup.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Loading />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
|
||||
{t('setup.eventTypes.intro')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, index) => (
|
||||
<div key={row.id ?? `new-${index}`} className="flex items-center gap-2">
|
||||
<span className="w-8 text-center text-xl flex-shrink-0" aria-hidden="true">{row.emoji}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(e) => setRow(index, { name: e.target.value })}
|
||||
placeholder={t('setup.eventTypes.namePlaceholder')}
|
||||
aria-label={t('setup.eventTypes.nameLabel')}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32 flex-shrink-0">
|
||||
<Input
|
||||
value={row.slug_prefix}
|
||||
onChange={(e) => setRow(index, { slug_prefix: normalizeSlug(e.target.value) })}
|
||||
placeholder={t('setup.eventTypes.slugPlaceholder')}
|
||||
aria-label={t('setup.eventTypes.slugLabel')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(index)}
|
||||
className="flex-shrink-0 p-2 rounded-lg text-neutral-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
aria-label={t('common.delete', 'Delete')}
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="w-full rounded-lg border border-dashed border-neutral-300 p-3 text-left hover:bg-neutral-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm font-medium text-neutral-800">{t('setup.eventTypes.add')}</span>
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-neutral-500">{t('setup.eventTypes.hint')}</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={saving}
|
||||
className="w-full"
|
||||
onClick={handleContinue}
|
||||
>
|
||||
{t('setup.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SetupEventTypesStep.displayName = 'SetupEventTypesStep';
|
||||
@@ -16,13 +16,11 @@
|
||||
* POST .../slideshow/{generate,disable}.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { MonitorPlay, Copy, CheckCircle, RotateCw, Trash2, Save } from 'lucide-react';
|
||||
import { Button, Card } from '../common';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service';
|
||||
import { SlideshowStyleFields } from './SlideshowStyleFields';
|
||||
|
||||
@@ -37,8 +35,6 @@ export interface SlideshowSettingsCardProps {
|
||||
show_transition_ms?: number;
|
||||
show_watermark?: boolean | null;
|
||||
show_colorfilter?: string;
|
||||
show_order?: string;
|
||||
show_category_id?: number | null;
|
||||
};
|
||||
onChanged?: () => void;
|
||||
}
|
||||
@@ -56,8 +52,6 @@ function styleFromInitial(initial: SlideshowSettingsCardProps['initial']): Slide
|
||||
transition_ms: initial.show_transition_ms ?? DEFAULT_SLIDESHOW_STYLE.transition_ms,
|
||||
watermark: watermarkMode(initial.show_watermark),
|
||||
colorfilter: (initial.show_colorfilter as SlideshowStyle['colorfilter']) ?? DEFAULT_SLIDESHOW_STYLE.colorfilter,
|
||||
order: (initial.show_order as SlideshowStyle['order']) ?? DEFAULT_SLIDESHOW_STYLE.order,
|
||||
category_id: initial.show_category_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,14 +68,6 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
|
||||
|
||||
const link = token ? `${window.location.origin}/gallery/${slug}/show/${token}` : '';
|
||||
|
||||
// Event categories for the slideshow content filter (#202). Global + this
|
||||
// event's own categories; empty for events without any → picker hides.
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['event-categories', eventId],
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const generate = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -143,8 +129,6 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
|
||||
// is global-only (Settings → Slideshow); we only send the mode here.
|
||||
show_watermark: style.watermark === 'inherit' ? null : style.watermark === 'on',
|
||||
show_colorfilter: style.colorfilter,
|
||||
show_order: style.order,
|
||||
show_category_id: style.category_id,
|
||||
});
|
||||
toast.success(t('slideshow.settingsSaved', 'Slideshow settings saved'));
|
||||
onChanged?.();
|
||||
@@ -224,7 +208,7 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
|
||||
|
||||
{/* Live style settings */}
|
||||
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<SlideshowStyleFields value={style} onChange={setStyle} categories={categories} />
|
||||
<SlideshowStyleFields value={style} onChange={setStyle} />
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('slideshow.liveHint', 'Changes apply to a running slideshow within a few seconds — no need to regenerate the link.')}
|
||||
|
||||
@@ -15,17 +15,12 @@ import {
|
||||
SLIDESHOW_TRANSITIONS,
|
||||
SLIDESHOW_COLORFILTERS,
|
||||
SLIDESHOW_WATERMARK_MODES,
|
||||
SLIDESHOW_ORDERS,
|
||||
type SlideshowStyle,
|
||||
} from '../../services/slideshow.service';
|
||||
import type { PhotoCategory } from '../../services/categories.service';
|
||||
|
||||
export interface SlideshowStyleFieldsProps {
|
||||
value: SlideshowStyle;
|
||||
onChange: (next: SlideshowStyle) => void;
|
||||
/** Event categories for the content filter (#202). Omitted/empty → the
|
||||
* category picker is hidden (e.g. events without any categories). */
|
||||
categories?: PhotoCategory[];
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
@@ -34,7 +29,7 @@ const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral
|
||||
|
||||
const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ value, onChange, categories = [] }) => {
|
||||
export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const set = (patch: Partial<SlideshowStyle>) => onChange({ ...value, ...patch });
|
||||
|
||||
@@ -97,39 +92,6 @@ export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ valu
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Play order + content filter (#202) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.orderLabel', 'Play order')}</label>
|
||||
<select
|
||||
value={value.order}
|
||||
onChange={(e) => set({ order: e.target.value as SlideshowStyle['order'] })}
|
||||
className={inputClass}
|
||||
>
|
||||
{SLIDESHOW_ORDERS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{t(`slideshow.order.${o}`, o === 'random' ? 'Random (shuffle)' : 'Chronological')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{categories.length > 0 && (
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.categoryLabel', 'Show only category')}</label>
|
||||
<select
|
||||
value={value.category_id ?? ''}
|
||||
onChange={(e) => set({ category_id: e.target.value === '' ? null : parseInt(e.target.value, 10) })}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">{t('slideshow.categoryAll', 'All photos')}</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Watermark — MODE only. The look (logo/position/opacity/style/size)
|
||||
lives in Settings → Slideshow, so it isn't duplicated here. */}
|
||||
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
||||
|
||||
@@ -154,45 +154,39 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
|
||||
{/* Category and Feedback Filters */}
|
||||
<div className="space-y-3">
|
||||
{/* Categories + desktop feedback row. Rendered whenever EITHER part
|
||||
has content: the desktop feedback chips must not depend on the
|
||||
(optional) categories existing, or category-less galleries show
|
||||
no feedback filter at all on desktop (#802 — the lg:hidden
|
||||
fallback block below only covers mobile/tablet). */}
|
||||
{((categories && categories.length > 0) || (feedbackEnabled && !!onFilterChange)) && (
|
||||
{/* Categories Row */}
|
||||
{categories && categories.length > 0 && (
|
||||
<div className="flex items-start lg:items-center justify-between flex-col lg:flex-row gap-3">
|
||||
{/* Categories: keep in a horizontal scroll container */}
|
||||
{categories && categories.length > 0 && (
|
||||
<div className="w-full overflow-x-auto pb-2 lg:pb-0">
|
||||
<div className="flex items-center gap-2 min-w-max">
|
||||
<Button
|
||||
variant={selectedCategoryId === null ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onCategoryChange(null)}
|
||||
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
|
||||
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length})
|
||||
</Button>
|
||||
{categories.map((category) => {
|
||||
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
|
||||
if (categoryPhotoCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onCategoryChange(category.id)}
|
||||
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{category.name} ({categoryPhotoCount})
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-full overflow-x-auto pb-2 lg:pb-0">
|
||||
<div className="flex items-center gap-2 min-w-max">
|
||||
<Button
|
||||
variant={selectedCategoryId === null ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onCategoryChange(null)}
|
||||
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
|
||||
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length})
|
||||
</Button>
|
||||
{categories.map((category) => {
|
||||
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
|
||||
if (categoryPhotoCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onCategoryChange(category.id)}
|
||||
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{category.name} ({categoryPhotoCount})
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: compact horizontal feedback filter with headline (icons only) */}
|
||||
{feedbackEnabled && onFilterChange && (
|
||||
@@ -250,10 +244,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Without categories this row only carries desktop content (the
|
||||
chips are lg-only; mobile has its own block below), so hide
|
||||
the count below lg to keep the mobile layout unchanged. */}
|
||||
<p className={`text-xs md:text-sm text-muted-theme flex-shrink-0 ml-auto ${categories && categories.length > 0 ? '' : 'hidden lg:block'}`}>
|
||||
<p className="text-xs md:text-sm text-muted-theme flex-shrink-0 ml-auto">
|
||||
{photoCount} {t('common.media', 'media')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Regression coverage for #802: the desktop feedback-filter chips
|
||||
* (All / Likes / Saved / Rated / Commented) were nested inside the
|
||||
* categories row, so a gallery WITHOUT photo categories (the default)
|
||||
* rendered no feedback filter at all on desktop — the standalone
|
||||
* fallback block is lg:hidden (mobile/tablet only). These tests pin
|
||||
* that both chip groups exist in the DOM regardless of categories.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PhotoFilterBar } from '../PhotoFilterBar';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: unknown) =>
|
||||
typeof fallback === 'string' ? fallback : _key,
|
||||
i18n: { language: 'en' }
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
categories: [] as Array<{ id: number; name: string; slug: string }>,
|
||||
photos: [] as never[],
|
||||
selectedCategoryId: null,
|
||||
onCategoryChange: vi.fn(),
|
||||
searchTerm: '',
|
||||
onSearchChange: vi.fn(),
|
||||
sortBy: 'date' as const,
|
||||
onSortChange: vi.fn(),
|
||||
photoCount: 0,
|
||||
};
|
||||
|
||||
describe('PhotoFilterBar feedback chips (#802)', () => {
|
||||
it('renders both chip groups (desktop lg:flex + mobile lg:hidden) with NO categories', () => {
|
||||
render(
|
||||
<PhotoFilterBar
|
||||
{...baseProps}
|
||||
feedbackEnabled
|
||||
currentFilter="all"
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Two groups: the desktop row variant and the mobile fallback. Before
|
||||
// the fix, only the mobile one rendered when categories were empty,
|
||||
// leaving desktop with no feedback filter at all.
|
||||
expect(screen.getAllByText('Feedback Filter')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('still renders both chip groups when categories exist', () => {
|
||||
render(
|
||||
<PhotoFilterBar
|
||||
{...baseProps}
|
||||
categories={[{ id: 1, name: 'Ceremony', slug: 'ceremony' }]}
|
||||
photos={[{ id: 1, category_id: 1 } as never]}
|
||||
feedbackEnabled
|
||||
currentFilter="all"
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText('Feedback Filter')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders no chips when feedback is disabled', () => {
|
||||
render(<PhotoFilterBar {...baseProps} />);
|
||||
expect(screen.queryByText('Feedback Filter')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -235,13 +235,15 @@ export const FeaturesTab: React.FC = () => {
|
||||
title={t('settings.features.messaging.title', 'Messaging')}
|
||||
description={t(
|
||||
'settings.features.messaging.description',
|
||||
'A unified Messages area: your sent + automated mail, the accounting inbox, and a customer mailbox (hello@) in one place — with reply and create-from-template composing. Configure the customer mailbox under Settings → Email; incoming mailboxes need the Incoming mail toggle too.',
|
||||
'In-app threads with guests, attached to a gallery. Email is genuinely fine for most teams — this is for studios that want everything in one place. Coming soon.',
|
||||
)}
|
||||
status="new"
|
||||
statusLabel={statusLabel('new')}
|
||||
status="roadmap"
|
||||
statusLabel={statusLabel('roadmap')}
|
||||
sidebarLabel={t('settings.features.messaging.sidebar', 'Messages')}
|
||||
enabled={staged.messaging}
|
||||
onToggle={(next) => setFlag('messaging', next)}
|
||||
onToggle={() => { /* locked */ }}
|
||||
disabled
|
||||
lockedReason={NOT_YET_AVAILABLE}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -197,7 +197,6 @@
|
||||
"navigation": {
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Veranstaltungen",
|
||||
"messages": "Nachrichten",
|
||||
"settings": "Einstellungen",
|
||||
"systemHealth": "Systemzustand",
|
||||
"archives": "Archive",
|
||||
@@ -947,15 +946,6 @@
|
||||
"coverPhotoRemoved": "Titelbild entfernt",
|
||||
"failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden",
|
||||
"categoryHeroHint": "Wenn kein Titelbild für eine Kategorie festgelegt ist, wird das Standard-Hero-Foto verwendet.",
|
||||
"moveUp": "Nach oben",
|
||||
"moveDown": "Nach unten",
|
||||
"failedToReorder": "Kategorie-Reihenfolge konnte nicht aktualisiert werden",
|
||||
"galleryOrder": "Galerie-Reihenfolge",
|
||||
"resetToDefault": "Auf Standard zurücksetzen",
|
||||
"orderReset": "Auf Standardreihenfolge zurückgesetzt",
|
||||
"orderCustomisedHint": "Diese Galerie verwendet eine eigene Reihenfolge. Zurücksetzen, um der globalen Standardreihenfolge zu folgen (Einstellungen → Fotokategorien).",
|
||||
"orderDefaultHint": "Mit den Pfeilen die Reihenfolge für diese Galerie festlegen. Andernfalls gilt die globale Standardreihenfolge (Einstellungen → Fotokategorien).",
|
||||
"sharedBadge": "Geteilt",
|
||||
"downloadsEnabled": "Downloads für diese Kategorie aktiviert",
|
||||
"downloadsDisabled": "Downloads für diese Kategorie deaktiviert",
|
||||
"enableDownloadsTitle": "Klicken zum Aktivieren der Downloads für diese Kategorie",
|
||||
@@ -2420,7 +2410,6 @@
|
||||
"channelBeta": "Beta",
|
||||
"beta": "BETA",
|
||||
"viewReleaseNotes": "Versionshinweise anzeigen",
|
||||
"viewOnGithub": "PicPeak auf GitHub ansehen",
|
||||
"updateAvailableShort": "v{{version}} verfügbar",
|
||||
"upToDate": "Alles aktuell",
|
||||
"updateNow": "Jetzt aktualisieren",
|
||||
@@ -3146,24 +3135,6 @@
|
||||
"backup": "Backup & Wiederherstellung",
|
||||
"system": "System-Updates",
|
||||
"other": "Sonstige"
|
||||
},
|
||||
"customerMailbox": {
|
||||
"title": "Kunden-Postfach (hello@)",
|
||||
"subtitle": "Ein zweites Eingangspostfach für Kundenkommunikation. Die E-Mails erscheinen unter Nachrichten → Kunden; Anhänge werden nicht an die Buchhaltung weitergeleitet.",
|
||||
"enabled": "Dieses Postfach jede Minute abrufen",
|
||||
"outgoing": "Ausgang (SMTP)",
|
||||
"outgoingHint": "Antworten aus diesem Postfach werden von hier gesendet. Leer lassen, um die globale Absenderadresse zu verwenden.",
|
||||
"fromEmail": "Absenderadresse",
|
||||
"smtpHost": "SMTP-Host",
|
||||
"smtpUser": "SMTP-Benutzername",
|
||||
"smtpPass": "SMTP-Passwort",
|
||||
"smtpSsl": "SSL (465)",
|
||||
"smtpStarttls": "STARTTLS (587)",
|
||||
"save": "Kunden-Postfach speichern",
|
||||
"savedToast": "Kunden-Postfach gespeichert.",
|
||||
"requiredFields": "Host, Port und Benutzername sind erforderlich.",
|
||||
"testOk": "Verbunden mit {{folder}} — {{messages}} Nachrichten, {{unseen}} ungelesen.",
|
||||
"testFailed": "Verbindung fehlgeschlagen."
|
||||
}
|
||||
},
|
||||
"cms": {
|
||||
@@ -3485,13 +3456,6 @@
|
||||
"cool": "Kühl",
|
||||
"vignette": "Vignette"
|
||||
},
|
||||
"orderLabel": "Reihenfolge",
|
||||
"order": {
|
||||
"chronological": "Chronologisch",
|
||||
"random": "Zufällig (mischen)"
|
||||
},
|
||||
"categoryLabel": "Nur Kategorie zeigen",
|
||||
"categoryAll": "Alle Fotos",
|
||||
"watermarkToggle": "Logo-Wasserzeichen anzeigen",
|
||||
"watermarkDescription": "Blendet ein weißes, halbtransparentes Logo in einer Ecke ein (wie ein Senderlogo im TV).",
|
||||
"watermarkSourceLabel": "Logo",
|
||||
@@ -3611,20 +3575,6 @@
|
||||
"finish": "Einrichtung abschließen",
|
||||
"saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen."
|
||||
},
|
||||
"eventTypes": {
|
||||
"subtitle": "Welche Veranstaltungen fotografieren Sie?",
|
||||
"intro": "Veranstaltungsarten ordnen Ihre Galerien — jede Art erhält ein eigenes URL-Präfix und Standard-Theme. Die Vorschläge unten sind nur ein Startpunkt: Benennen Sie sie um, entfernen Sie Unnötiges oder fügen Sie eigene hinzu. Nur jetzt können die mitgelieferten Arten gelöscht werden; später lassen sie sich nur umbenennen oder deaktivieren.",
|
||||
"nameLabel": "Anzeigename",
|
||||
"namePlaceholder": "z.B. Familienshooting",
|
||||
"slugLabel": "URL-Präfix",
|
||||
"slugPlaceholder": "z.B. familie",
|
||||
"add": "Veranstaltungsart hinzufügen",
|
||||
"hint": "Das URL-Präfix erscheint in Galerie-Links (z.B. familie-mueller-2025-06-01). Symbole, Themes und Reihenfolge können Sie später unter Einstellungen → Veranstaltungsarten anpassen.",
|
||||
"atLeastOne": "Behalten Sie mindestens eine Veranstaltungsart — jede Galerie braucht eine.",
|
||||
"loadFailed": "Veranstaltungsarten konnten nicht geladen werden — Sie können sie später unter Einstellungen → Veranstaltungsarten anpassen.",
|
||||
"saveFailed": "Einige Änderungen konnten nicht gespeichert werden — Sie können sie unter Einstellungen → Veranstaltungsarten abschließen.",
|
||||
"deleteFailed": "Eine Löschung ist fehlgeschlagen — die Liste wurde neu geladen. Mitgelieferte Arten können nur hier gelöscht werden; versuchen Sie es erneut oder fahren Sie mit ihnen fort."
|
||||
},
|
||||
"community": {
|
||||
"subtitle": "Alles bereit",
|
||||
"mission": "PicPeak gibt es, damit Fotografinnen und Fotografen ihre Galerien und Kundendaten selbst besitzen — auf dem eigenen Server, ohne monatliche SaaS-Gebühren. Danke, dass du es ausprobierst.",
|
||||
@@ -5247,11 +5197,6 @@
|
||||
"crm_invoices_late_fee_label": {
|
||||
"label": "Bezeichnung Mahngebühr"
|
||||
},
|
||||
"crm_invoices_vat_note_text": {
|
||||
"label": "MwSt.- / Freitext-Hinweis auf Rechnungen",
|
||||
"placeholder": "z. B. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).",
|
||||
"help": "Wird direkt unter der MwSt.-Zeile auf jeder Rechnungs-PDF gedruckt. Leer lassen zum Ausblenden. Bitte den genauen Wortlaut mit deinem Steuerberater abstimmen."
|
||||
},
|
||||
"crm_invoices_skonto_percent_default": {
|
||||
"label": "Standard-Skonto %"
|
||||
},
|
||||
@@ -5588,88 +5533,5 @@
|
||||
"titlePlaceholder": "z. B. Hochzeitsvertrag Doe / Müller",
|
||||
"validUntil": "Unterzeichnen bis (optional)"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"title": "Nachrichten",
|
||||
"subtitle": "Gesendete, automatische und eingehende E-Mails — an einem Ort.",
|
||||
"sync": "Abrufen",
|
||||
"newMessage": "Neue Nachricht",
|
||||
"searchPlaceholder": "In diesem Ordner suchen…",
|
||||
"account": {
|
||||
"all": "Alle E-Mails",
|
||||
"customers": "Kunden",
|
||||
"accounting": "Buchhaltung",
|
||||
"automated": "Automatisch"
|
||||
},
|
||||
"folder": {
|
||||
"inbox": "Posteingang",
|
||||
"sent": "Gesendet",
|
||||
"archived": "Archiviert",
|
||||
"deleted": "Gelöscht"
|
||||
},
|
||||
"unified": "Konten übergreifend",
|
||||
"systemGenerated": "Systemgeneriert",
|
||||
"acrossAccounts": "Über alle Konten",
|
||||
"selectPrompt": "Nachricht zum Lesen auswählen",
|
||||
"noMessages": "Keine Nachrichten",
|
||||
"noSearchResults": "Keine Treffer",
|
||||
"noSubject": "(kein Betreff)",
|
||||
"from": "von",
|
||||
"to": "An",
|
||||
"reply": "Antworten",
|
||||
"replyAll": "Allen antworten",
|
||||
"forward": "Weiterleiten",
|
||||
"archive": "Archivieren",
|
||||
"delete": "Löschen",
|
||||
"deleteForever": "Endgültig löschen",
|
||||
"restore": "Wiederherstellen",
|
||||
"bookExpense": "Als Ausgabe buchen",
|
||||
"rebill": "An Kunden weiterverrechnen",
|
||||
"createQuote": "Angebot",
|
||||
"createContract": "Vertrag",
|
||||
"createGallery": "Galerie",
|
||||
"createInvoice": "Rechnung",
|
||||
"doc": {
|
||||
"quote": "Angebot",
|
||||
"contract": "Vertrag",
|
||||
"invoice": "Rechnung",
|
||||
"gallery": "Galerie"
|
||||
},
|
||||
"soon": "In einer späteren Phase verfügbar",
|
||||
"viewDocument": "Dokument ansehen",
|
||||
"openInAccounting": "Im Buchhaltungs-Posteingang öffnen",
|
||||
"noInboundBody": "Für diese E-Mail wurde kein Nachrichtentext erfasst.",
|
||||
"noBody": "Diese Nachricht wurde gesendet, bevor die Textspeicherung eingeführt wurde — keine Vorschau verfügbar.",
|
||||
"loadError": "Diese Nachricht konnte nicht geladen werden.",
|
||||
"attachments": "Anhang/Anhänge",
|
||||
"notArchived": "noch nicht archiviert",
|
||||
"sentAttachHint": "Gesendete Anhänge werden noch nicht archiviert — Phase 2.",
|
||||
"document": "Dokument",
|
||||
"previewUnavailable": "Vorschau nicht verfügbar",
|
||||
"rasterNote": "Serverseitig gerenderte Vorschau — die Originaldatei erreicht den Browser nie.",
|
||||
"close": "Schliessen",
|
||||
"compose": "Nachricht verfassen",
|
||||
"cancel": "Abbrechen",
|
||||
"send": "Senden",
|
||||
"subject": "Betreff",
|
||||
"optional": "optional",
|
||||
"bodyHint": "Bearbeite die Nachricht frei — füge vor dem Senden an beliebiger Stelle eine Notiz ein.",
|
||||
"sendsFromHint": "Wird von deiner konfigurierten Absenderadresse gesendet.",
|
||||
"sentToast": "Nachricht gesendet.",
|
||||
"sendFailed": "Nachricht konnte nicht gesendet werden.",
|
||||
"onWrote": "Am",
|
||||
"customer": "Kunde",
|
||||
"resolvingCustomer": "Absender wird einem Kunden zugeordnet…",
|
||||
"noCustomerMatch": "Kein Kunde zu diesem Absender gefunden — oben suchen oder neuen Kunden anlegen.",
|
||||
"createNewDoc": "Neues {{label}} erstellen",
|
||||
"existingDocs": "Oder ein bestehendes referenzieren",
|
||||
"noExistingDocs": "Für diesen Kunden gibt es noch keine Dokumente.",
|
||||
"galleryCreateOnly": "Galerien sind event-basiert — dies öffnet den Event-Editor, wo du den Kunden zuweisen kannst.",
|
||||
"syncOk": "Postfächer geprüft — {{count}} neu.",
|
||||
"syncDisabled": "Eingehende E-Mails sind deaktiviert — unter Einstellungen → Funktionen aktivieren.",
|
||||
"syncUnconfigured": "Zuerst ein Postfach unter Einstellungen → E-Mail konfigurieren.",
|
||||
"syncBusy": "Es läuft bereits eine Synchronisierung.",
|
||||
"syncFailed": "Synchronisierung fehlgeschlagen.",
|
||||
"actionFailed": "Aktion fehlgeschlagen."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,6 @@
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Events",
|
||||
"archives": "Archives",
|
||||
"messages": "Messages",
|
||||
"settings": "Settings",
|
||||
"systemHealth": "System health",
|
||||
"eventTypes": "Event Types",
|
||||
@@ -494,15 +493,6 @@
|
||||
"coverPhotoRemoved": "Cover photo removed",
|
||||
"failedToSetCoverPhoto": "Failed to set cover photo",
|
||||
"categoryHeroHint": "If no cover photo is set for a category, the default hero photo will be used.",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"failedToReorder": "Failed to update category order",
|
||||
"galleryOrder": "Gallery order",
|
||||
"resetToDefault": "Reset to default",
|
||||
"orderReset": "Reverted to the default order",
|
||||
"orderCustomisedHint": "This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).",
|
||||
"orderDefaultHint": "Use the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).",
|
||||
"sharedBadge": "Shared",
|
||||
"downloadsEnabled": "Downloads enabled for this category",
|
||||
"downloadsDisabled": "Downloads disabled for this category",
|
||||
"enableDownloadsTitle": "Click to enable downloads for this category",
|
||||
@@ -1996,7 +1986,6 @@
|
||||
"channelBeta": "Beta",
|
||||
"beta": "BETA",
|
||||
"viewReleaseNotes": "View Release Notes",
|
||||
"viewOnGithub": "View PicPeak on GitHub",
|
||||
"updateAvailableShort": "v{{version}} available",
|
||||
"upToDate": "You're up to date",
|
||||
"updateNow": "Update Now",
|
||||
@@ -2708,24 +2697,6 @@
|
||||
"backup": "Backup & restore",
|
||||
"system": "System updates",
|
||||
"other": "Other"
|
||||
},
|
||||
"customerMailbox": {
|
||||
"title": "Customer mailbox (hello@)",
|
||||
"subtitle": "A second inbound mailbox for customer conversations. Its mail appears under Messages → Customers; attachments are not routed to Accounting.",
|
||||
"enabled": "Poll this mailbox every minute",
|
||||
"outgoing": "Outgoing (SMTP)",
|
||||
"outgoingHint": "Replies from this mailbox send from here. Leave blank to fall back to the global outgoing address.",
|
||||
"fromEmail": "From address",
|
||||
"smtpHost": "SMTP Host",
|
||||
"smtpUser": "SMTP Username",
|
||||
"smtpPass": "SMTP Password",
|
||||
"smtpSsl": "SSL (465)",
|
||||
"smtpStarttls": "STARTTLS (587)",
|
||||
"save": "Save Customer Mailbox",
|
||||
"savedToast": "Customer mailbox saved.",
|
||||
"requiredFields": "Host, port and username are required.",
|
||||
"testOk": "Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.",
|
||||
"testFailed": "Connection failed."
|
||||
}
|
||||
},
|
||||
"cms": {
|
||||
@@ -3500,20 +3471,6 @@
|
||||
"finish": "Finish setup",
|
||||
"saveFailed": "Some settings could not be saved — you can finish them in Settings."
|
||||
},
|
||||
"eventTypes": {
|
||||
"subtitle": "Which events do you photograph?",
|
||||
"intro": "Event types organize your galleries — each one gets its own URL prefix and default theme. The suggestions below are just a starting point: rename them, remove what you don't need, or add your own. This is the only time the built-in types can be deleted; later they can only be renamed or deactivated.",
|
||||
"nameLabel": "Display name",
|
||||
"namePlaceholder": "e.g. Family Shoot",
|
||||
"slugLabel": "URL prefix",
|
||||
"slugPlaceholder": "e.g. family",
|
||||
"add": "Add event type",
|
||||
"hint": "The URL prefix appears in gallery links (e.g. family-smith-2025-06-01). Icons, themes and order can be tuned later in Settings → Event Types.",
|
||||
"atLeastOne": "Keep at least one event type — every gallery needs one.",
|
||||
"loadFailed": "Could not load the event types — you can adjust them later in Settings → Event Types.",
|
||||
"saveFailed": "Some event type changes could not be saved — you can finish them in Settings → Event Types.",
|
||||
"deleteFailed": "A deletion failed — the list has been reloaded. Built-in types can only be deleted here, so retry or continue with them kept."
|
||||
},
|
||||
"community": {
|
||||
"subtitle": "You're all set",
|
||||
"mission": "PicPeak exists so photographers can own their galleries and client data — on their own server, without monthly SaaS fees. Thanks for giving it a try.",
|
||||
@@ -3627,13 +3584,6 @@
|
||||
"cool": "Cool",
|
||||
"vignette": "Vignette"
|
||||
},
|
||||
"orderLabel": "Play order",
|
||||
"order": {
|
||||
"chronological": "Chronological",
|
||||
"random": "Random (shuffle)"
|
||||
},
|
||||
"categoryLabel": "Show only category",
|
||||
"categoryAll": "All photos",
|
||||
"watermarkToggle": "Show logo watermark",
|
||||
"watermarkDescription": "Overlay a white, semi-transparent logo in a corner (like a TV station ident).",
|
||||
"watermarkSourceLabel": "Logo",
|
||||
@@ -5245,11 +5195,6 @@
|
||||
"crm_invoices_late_fee_label": {
|
||||
"label": "Late fee label"
|
||||
},
|
||||
"crm_invoices_vat_note_text": {
|
||||
"label": "VAT / free-text note on invoices",
|
||||
"placeholder": "e.g. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).",
|
||||
"help": "Printed directly under the MwSt. line on every invoice PDF. Leave empty to hide. Please confirm the exact wording with your tax advisor."
|
||||
},
|
||||
"crm_invoices_skonto_percent_default": {
|
||||
"label": "Skonto rate (default %)"
|
||||
},
|
||||
@@ -5586,88 +5531,5 @@
|
||||
"titlePlaceholder": "e.g. Wedding contract Doe / Müller",
|
||||
"validUntil": "Sign by (optional)"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"title": "Messages",
|
||||
"subtitle": "Sent, automated and incoming mail — one place.",
|
||||
"sync": "Sync",
|
||||
"newMessage": "New message",
|
||||
"searchPlaceholder": "Search this folder…",
|
||||
"account": {
|
||||
"all": "All mail",
|
||||
"customers": "Customers",
|
||||
"accounting": "Accounting",
|
||||
"automated": "Automated"
|
||||
},
|
||||
"folder": {
|
||||
"inbox": "Inbox",
|
||||
"sent": "Sent",
|
||||
"archived": "Archived",
|
||||
"deleted": "Deleted"
|
||||
},
|
||||
"unified": "Unified across accounts",
|
||||
"systemGenerated": "System-generated",
|
||||
"acrossAccounts": "Across all accounts",
|
||||
"selectPrompt": "Select a message to read",
|
||||
"noMessages": "No messages",
|
||||
"noSearchResults": "No matches",
|
||||
"noSubject": "(no subject)",
|
||||
"from": "from",
|
||||
"to": "To",
|
||||
"reply": "Reply",
|
||||
"replyAll": "Reply all",
|
||||
"forward": "Forward",
|
||||
"archive": "Archive",
|
||||
"delete": "Delete",
|
||||
"deleteForever": "Delete permanently",
|
||||
"restore": "Restore",
|
||||
"bookExpense": "Book as expense",
|
||||
"rebill": "Re-bill to client",
|
||||
"createQuote": "Quote",
|
||||
"createContract": "Contract",
|
||||
"createGallery": "Gallery",
|
||||
"createInvoice": "Invoice",
|
||||
"doc": {
|
||||
"quote": "Quote",
|
||||
"contract": "Contract",
|
||||
"invoice": "Invoice",
|
||||
"gallery": "Gallery"
|
||||
},
|
||||
"soon": "Available in a later phase",
|
||||
"viewDocument": "View document",
|
||||
"openInAccounting": "Open in Accounting inbox",
|
||||
"noInboundBody": "No message body was captured for this email.",
|
||||
"noBody": "This message was sent before body capture was added, so no preview is available.",
|
||||
"loadError": "Could not load this message.",
|
||||
"attachments": "attachment(s)",
|
||||
"notArchived": "not archived yet",
|
||||
"sentAttachHint": "Sent attachments are not archived yet — Phase 2.",
|
||||
"document": "Document",
|
||||
"previewUnavailable": "Preview unavailable",
|
||||
"rasterNote": "Server-rendered preview — the raw file never reaches the browser.",
|
||||
"close": "Close",
|
||||
"compose": "Compose message",
|
||||
"cancel": "Cancel",
|
||||
"send": "Send",
|
||||
"subject": "Subject",
|
||||
"optional": "optional",
|
||||
"bodyHint": "Edit the message freely — add a note anywhere before sending.",
|
||||
"sendsFromHint": "Sends from your configured outgoing address.",
|
||||
"sentToast": "Message sent.",
|
||||
"sendFailed": "Failed to send message.",
|
||||
"onWrote": "On",
|
||||
"customer": "Customer",
|
||||
"resolvingCustomer": "Matching the sender to a customer…",
|
||||
"noCustomerMatch": "No customer matched this sender — search for one or create a new customer above.",
|
||||
"createNewDoc": "Create new {{label}}",
|
||||
"existingDocs": "Or reference an existing one",
|
||||
"noExistingDocs": "No existing documents for this customer yet.",
|
||||
"galleryCreateOnly": "Galleries are event-based — this opens the event editor, where you can assign the customer.",
|
||||
"syncOk": "Checked mailboxes — {{count}} new.",
|
||||
"syncDisabled": "Incoming mail is off — enable it under Settings → Features.",
|
||||
"syncUnconfigured": "Configure a mailbox under Settings → Email first.",
|
||||
"syncBusy": "A sync is already running.",
|
||||
"syncFailed": "Sync failed.",
|
||||
"actionFailed": "Action failed."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { setupService } from '../services/setup.service';
|
||||
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
|
||||
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
|
||||
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
|
||||
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
|
||||
import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
|
||||
import type { AdminUser } from '../types';
|
||||
|
||||
@@ -68,7 +67,7 @@ export const SetupPage: React.FC = () => {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'eventTypes' | 'restore' | 'config' | 'community'>('token');
|
||||
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore' | 'config' | 'community'>('token');
|
||||
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -242,25 +241,19 @@ export const SetupPage: React.FC = () => {
|
||||
toast.warn(t('setup.featuresSaveFailed'));
|
||||
} finally {
|
||||
setIsSavingFeatures(false);
|
||||
// Event types come next (#800) — the wizard is the one window in which
|
||||
// the seeded defaults can be freely renamed or deleted, because nothing
|
||||
// (events, quotes, reminder mails) references them yet.
|
||||
setStep('eventTypes');
|
||||
// If the chosen features need config the wizard can collect (invoicing,
|
||||
// email), go to the config step; otherwise enter the app.
|
||||
const needsConfig =
|
||||
selectedFeatures.has('bills') ||
|
||||
selectedFeatures.has('reminderEmails') ||
|
||||
selectedFeatures.has('incomingMail') ||
|
||||
selectedFeatures.has('whatsapp');
|
||||
// Both the config branch and the no-config path end on the final
|
||||
// community/thank-you step (#732), whose Finish button enters the app.
|
||||
setStep(needsConfig ? 'config' : 'community');
|
||||
}
|
||||
};
|
||||
|
||||
// After the event-types step: if the chosen features need config the wizard
|
||||
// can collect (invoicing, email), go to the config step; otherwise skip to
|
||||
// the final community/thank-you step (#732), whose Finish enters the app.
|
||||
const continueAfterEventTypes = () => {
|
||||
const needsConfig =
|
||||
selectedFeatures.has('bills') ||
|
||||
selectedFeatures.has('reminderEmails') ||
|
||||
selectedFeatures.has('incomingMail') ||
|
||||
selectedFeatures.has('whatsapp');
|
||||
setStep(needsConfig ? 'config' : 'community');
|
||||
};
|
||||
|
||||
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
|
||||
|
||||
return (
|
||||
@@ -285,15 +278,13 @@ export const SetupPage: React.FC = () => {
|
||||
? t('setup.tokenStepSubtitle')
|
||||
: step === 'account'
|
||||
? t('setup.accountStepSubtitle')
|
||||
: step === 'eventTypes'
|
||||
? t('setup.eventTypes.subtitle')
|
||||
: step === 'restore'
|
||||
? t('setup.restoreStepSubtitle')
|
||||
: step === 'config'
|
||||
? t('setup.config.subtitle')
|
||||
: step === 'community'
|
||||
? t('setup.community.subtitle')
|
||||
: t('setup.usageSubtitle')}
|
||||
: step === 'restore'
|
||||
? t('setup.restoreStepSubtitle')
|
||||
: step === 'config'
|
||||
? t('setup.config.subtitle')
|
||||
: step === 'community'
|
||||
? t('setup.community.subtitle')
|
||||
: t('setup.usageSubtitle')}
|
||||
</p>
|
||||
{(step === 'token' || step === 'account' || step === 'usage') && (
|
||||
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
|
||||
@@ -518,8 +509,6 @@ export const SetupPage: React.FC = () => {
|
||||
{t('setup.back')}
|
||||
</Button>
|
||||
</div>
|
||||
) : step === 'eventTypes' ? (
|
||||
<SetupEventTypesStep onDone={continueAfterEventTypes} />
|
||||
) : step === 'config' ? (
|
||||
<SetupConfigStep
|
||||
selectedFeatures={selectedFeatures}
|
||||
@@ -557,14 +546,7 @@ export const SetupPage: React.FC = () => {
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
// One-way marker: re-locks the seeded system event types
|
||||
// (#800). Best-effort — a failure must not trap the user on
|
||||
// the thank-you screen, and the flag re-arms nothing risky
|
||||
// (the delete window also requires zero usage server-side).
|
||||
try { await setupService.completeSetup(); } catch { /* best-effort */ }
|
||||
navigate('/admin/dashboard', { replace: true });
|
||||
}}
|
||||
onClick={() => navigate('/admin/dashboard', { replace: true })}
|
||||
rightIcon={<ArrowRight className="w-4 h-4" />}
|
||||
>
|
||||
{t('setup.community.finish')}
|
||||
|
||||
@@ -182,18 +182,6 @@ export const CreateEventPage: React.FC = () => {
|
||||
[eventTypes]
|
||||
);
|
||||
|
||||
// The hardcoded initial form value ('wedding') may not exist in the live
|
||||
// catalog — the setup wizard can rename or delete the defaults (#800), and
|
||||
// the backend now rejects unknown slugs. Snap to the first active type; a
|
||||
// user-picked value is always in the list, so this never fights the user.
|
||||
useEffect(() => {
|
||||
if (!availableEventTypes.length) return;
|
||||
if (!availableEventTypes.some(t => t.value === formData.event_type)) {
|
||||
setFormData(prev => ({ ...prev, event_type: availableEventTypes[0].value }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [availableEventTypes, formData.event_type]);
|
||||
|
||||
// Fetch default settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
|
||||
@@ -21,7 +21,6 @@ import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor'
|
||||
import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel';
|
||||
import { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel';
|
||||
import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard';
|
||||
import { CustomerMailboxCard } from '../../components/admin/CustomerMailboxCard';
|
||||
import { Palette, RefreshCw, Info } from 'lucide-react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useModal, useMutationWithToast } from '../../hooks';
|
||||
@@ -803,7 +802,6 @@ export const EmailConfigPage: React.FC = () => {
|
||||
{/* Email Templates Tab */}
|
||||
{/* Incoming mail (IMAP) — a second block under SMTP, flag-gated. */}
|
||||
{activeTab === 'smtp' && featureFlags.incomingMail && <IncomingMailConfigCard />}
|
||||
{activeTab === 'smtp' && featureFlags.messaging && <CustomerMailboxCard />}
|
||||
|
||||
{activeTab === 'templates' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useSearchParams, Link } from 'react-router-dom';
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ArrowLeft, Eye, Save } from 'lucide-react';
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from '../../../services/contracts.service';
|
||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||
import { ProjectSelect } from '../../../components/admin/ProjectSelect';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
|
||||
interface BlockRow {
|
||||
blockId: number;
|
||||
@@ -40,7 +39,6 @@ interface BlockRow {
|
||||
export const ContractEditorPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = Boolean(id);
|
||||
const numericId = id ? parseInt(id, 10) : null;
|
||||
@@ -69,28 +67,6 @@ export const ContractEditorPage: React.FC = () => {
|
||||
const [projectId, setProjectId] = useState<number | null>(null);
|
||||
const [blocks, setBlocks] = useState<BlockRow[]>([]);
|
||||
|
||||
// Prefill the customer when opened as "new contract for this customer"
|
||||
// (?customerAccountId=42), e.g. from the Messages view. New contracts only;
|
||||
// mirrors QuoteEditorPage / BillEditorPage.
|
||||
useEffect(() => {
|
||||
if (isEdit || customerAccountId) return;
|
||||
const raw = searchParams.get('customerAccountId');
|
||||
const cid = raw ? parseInt(raw, 10) : NaN;
|
||||
if (!Number.isFinite(cid) || cid <= 0) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const c = await customerAdminService.get(cid);
|
||||
if (cancelled) return;
|
||||
setCustomerAccountId(c.id);
|
||||
setCustomerLabel(c.companyName || c.displayName || [c.firstName, c.lastName].filter(Boolean).join(' ') || c.email);
|
||||
setCustomerIsPassive(Boolean(c.isPassive));
|
||||
if (c.preferredLanguage) setLanguage(c.preferredLanguage);
|
||||
} catch { /* ignore — admin can still pick manually */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [isEdit, searchParams, customerAccountId]);
|
||||
|
||||
// Load existing contract on edit.
|
||||
const { data: existing, isLoading: existingLoading } = useQuery({
|
||||
queryKey: ['contract', numericId],
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { X, Plus, FileText } from 'lucide-react';
|
||||
import { Button, Loading } from '../../../components/common';
|
||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
import { quotesService } from '../../../services/quotes.service';
|
||||
import { contractsService } from '../../../services/contracts.service';
|
||||
import { billsService } from '../../../services/bills.service';
|
||||
|
||||
/**
|
||||
* From a customer message: resolve (or pick/create) the customer, then either
|
||||
* create a NEW document of the given type (jumps to the real editor prefilled
|
||||
* with the customer) or SELECT an existing one to reference in a reply. Reuses
|
||||
* the CRM editors, list endpoints and CustomerPicker — no duplicated doc logic.
|
||||
*/
|
||||
export type DocType = 'quote' | 'contract' | 'invoice' | 'gallery';
|
||||
|
||||
const CONFIG: Record<DocType, { label: string; newRoute: string; hasExisting: boolean }> = {
|
||||
quote: { label: 'Quote', newRoute: '/admin/clients/quotes/new', hasExisting: true },
|
||||
contract: { label: 'Contract', newRoute: '/admin/clients/contracts/new', hasExisting: true },
|
||||
invoice: { label: 'Invoice', newRoute: '/admin/clients/bills/new', hasExisting: true },
|
||||
gallery: { label: 'Gallery', newRoute: '/admin/events/new', hasExisting: false },
|
||||
};
|
||||
|
||||
interface DocRow { id: number; number: string; status: string }
|
||||
|
||||
type SelCustomer = { id: number; email: string; label: string };
|
||||
|
||||
export const DocumentActionModal: React.FC<{
|
||||
docType: DocType;
|
||||
senderEmail: string;
|
||||
onCompose: (init: { to: string; subject: string; html: string }) => void;
|
||||
onClose: () => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ docType, senderEmail, onCompose, onClose, t }) => {
|
||||
const navigate = useNavigate();
|
||||
const cfg = CONFIG[docType];
|
||||
const [customer, setCustomer] = useState<SelCustomer | null>(null);
|
||||
const [resolving, setResolving] = useState(true);
|
||||
|
||||
const pick = (c: { id: number; email: string; displayName?: string | null; companyName?: string | null }) =>
|
||||
setCustomer({ id: c.id, email: c.email, label: c.companyName || c.displayName || c.email });
|
||||
|
||||
// Resolve the customer from the message's sender address (first match).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setResolving(true);
|
||||
customerAdminService.search(senderEmail)
|
||||
.then((rows) => {
|
||||
if (cancelled) return;
|
||||
// search matches email/name/company PREFIXES — only auto-pick on an
|
||||
// EXACT email match so a spoofed/partial sender can't prefill the wrong
|
||||
// customer. Otherwise leave the picker for the admin to choose.
|
||||
const target = senderEmail.trim().toLowerCase();
|
||||
const exact = rows.find((r) => (r.email || '').toLowerCase() === target);
|
||||
if (exact) pick(exact);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => { if (!cancelled) setResolving(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [senderEmail]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
const existing = useQuery({
|
||||
queryKey: ['messages', 'docs', docType, customer?.id],
|
||||
enabled: !!customer && cfg.hasExisting,
|
||||
queryFn: async (): Promise<DocRow[]> => {
|
||||
const customerAccountId = customer!.id;
|
||||
if (docType === 'quote') {
|
||||
const r = await quotesService.list({ customerAccountId, page: 1, pageSize: 20 });
|
||||
return r.quotes.map((q) => ({ id: q.id, number: q.quoteNumber, status: q.status }));
|
||||
}
|
||||
if (docType === 'contract') {
|
||||
const r = await contractsService.list({ customerAccountId, page: 1, pageSize: 20 });
|
||||
return r.contracts.map((c) => ({ id: c.id, number: c.contractNumber, status: c.status }));
|
||||
}
|
||||
const r = await billsService.list({ customerAccountId, page: 1, pageSize: 20 });
|
||||
return r.invoices.map((i) => ({ id: i.id, number: i.invoiceNumber, status: i.status }));
|
||||
},
|
||||
});
|
||||
|
||||
const createNew = () => {
|
||||
if (!customer && docType !== 'gallery') return;
|
||||
navigate(docType === 'gallery' || !customer ? cfg.newRoute : `${cfg.newRoute}?customerAccountId=${customer.id}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const pickExisting = (d: DocRow) => {
|
||||
const html = `<p><br></p><p>${cfg.label} <strong>${d.number}</strong></p><p><br></p>`;
|
||||
onCompose({ to: customer?.email || senderEmail, subject: `${cfg.label} ${d.number}`, html });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(560px,96vw)] max-h-[88vh] flex flex-col overflow-hidden shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<span className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
|
||||
{t(`messages.doc.${docType}`, cfg.label)}
|
||||
</span>
|
||||
<button onClick={onClose} className="ml-auto w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800" aria-label={t('messages.close', 'Close')}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-4 overflow-y-auto">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{t('messages.customer', 'Customer')}</div>
|
||||
<CustomerPicker
|
||||
value={customer?.id ?? null}
|
||||
label={customer?.label || ''}
|
||||
onSelect={pick}
|
||||
onCreate={pick}
|
||||
onClear={() => setCustomer(null)}
|
||||
/>
|
||||
{resolving && <p className="mt-1 text-xs text-neutral-400">{t('messages.resolvingCustomer', 'Matching the sender to a customer…')}</p>}
|
||||
{!resolving && !customer && (
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('messages.noCustomerMatch', 'No customer matched this sender — search for one or create a new customer above.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{customer && (
|
||||
<>
|
||||
<Button variant="primary" onClick={createNew} leftIcon={<Plus className="w-4 h-4" />} className="w-full justify-center">
|
||||
{t('messages.createNewDoc', 'Create new {{label}}', { label: t(`messages.doc.${docType}`, cfg.label) } as any)}
|
||||
</Button>
|
||||
|
||||
{cfg.hasExisting && (
|
||||
<div>
|
||||
<div className="text-[11px] font-bold uppercase tracking-wide text-neutral-400 mb-2">
|
||||
{t('messages.existingDocs', 'Or reference an existing one')}
|
||||
</div>
|
||||
{existing.isLoading ? (
|
||||
<Loading />
|
||||
) : (existing.data && existing.data.length > 0) ? (
|
||||
<div className="flex flex-col gap-1.5 max-h-[38vh] overflow-y-auto">
|
||||
{existing.data.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
onClick={() => pickExisting(d)}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-neutral-200 dark:border-neutral-800 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 text-left"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-neutral-400 flex-none" />
|
||||
<span className="font-mono text-[13px] text-neutral-800 dark:text-neutral-100">{d.number}</span>
|
||||
<span className="ml-auto text-[11px] text-neutral-400">{d.status}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{t('messages.noExistingDocs', 'No existing documents for this customer yet.')}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!cfg.hasExisting && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('messages.galleryCreateOnly', 'Galleries are event-based — this opens the event editor, where you can assign the customer.')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentActionModal;
|
||||
@@ -1,119 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { X, Send as SendIcon } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { emailService } from '../../../services/email.service';
|
||||
import { Button } from '../../../components/common';
|
||||
|
||||
/**
|
||||
* Compose / reply modal. The body is pre-loaded with the rendered template (or a
|
||||
* reply stub) and is FULLY EDITABLE — the admin can rewrite it or drop a note
|
||||
* anywhere before sending. On send it goes out as-is (server-sanitized), no
|
||||
* template re-render, and is recorded as a manual send (Customers ▸ Sent).
|
||||
*/
|
||||
export interface ComposerInit {
|
||||
to: string;
|
||||
cc?: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
replyToReceivedId?: number;
|
||||
}
|
||||
|
||||
const inputCls = 'flex-1 px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-950 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-accent';
|
||||
|
||||
export const MessageComposer: React.FC<{
|
||||
init: ComposerInit;
|
||||
title?: string;
|
||||
accountKey?: string;
|
||||
onClose: () => void;
|
||||
onSent: () => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ init, title, accountKey, onClose, onSent, t }) => {
|
||||
const [to, setTo] = useState(init.to);
|
||||
const [cc, setCc] = useState(init.cc || '');
|
||||
const [subject, setSubject] = useState(init.subject);
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Sanitize before it hits the contentEditable innerHTML — the initial body
|
||||
// can include untrusted text (e.g. an inbound sender name in a reply stub).
|
||||
if (bodyRef.current) bodyRef.current.innerHTML = DOMPurify.sanitize(init.html || '');
|
||||
// Load initial body exactly once; further edits are the admin's.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: () => emailService.sendMessage({
|
||||
to: to.trim(),
|
||||
cc: cc.trim() || undefined,
|
||||
subject: subject.trim(),
|
||||
html: bodyRef.current?.innerHTML || '',
|
||||
replyToReceivedId: init.replyToReceivedId,
|
||||
accountKey,
|
||||
}),
|
||||
onSuccess: () => { toast.success(t('messages.sentToast', 'Message sent.')); onSent(); onClose(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.sendFailed', 'Failed to send message.')),
|
||||
});
|
||||
|
||||
const canSend = !!to.trim() && !!subject.trim() && !send.isPending;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-4" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(920px,97vw)] h-[min(780px,92vh)] flex flex-col overflow-hidden shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<span className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">{title || t('messages.compose', 'Compose message')}</span>
|
||||
<button onClick={onClose} className="ml-auto w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800" aria-label={t('messages.close', 'Close')}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-3 overflow-y-auto flex-1 min-h-0">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="w-16 text-neutral-500 dark:text-neutral-400">{t('messages.to', 'To')}</span>
|
||||
<input className={inputCls} value={to} onChange={(e) => setTo(e.target.value)} placeholder="name@example.com" />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="w-16 text-neutral-500 dark:text-neutral-400">Cc</span>
|
||||
<input className={inputCls} value={cc} onChange={(e) => setCc(e.target.value)} placeholder={t('messages.optional', 'optional')} />
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<span className="w-16 text-neutral-500 dark:text-neutral-400">{t('messages.subject', 'Subject')}</span>
|
||||
<input className={inputCls} value={subject} onChange={(e) => setSubject(e.target.value)} />
|
||||
</label>
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{t('messages.bodyHint', 'Edit the message freely — add a note anywhere before sending.')}
|
||||
</div>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
className="min-h-[240px] flex-1 overflow-y-auto rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-950 p-3 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-t border-neutral-200 dark:border-neutral-800">
|
||||
<span className="text-xs text-neutral-400">{t('messages.sendsFromHint', 'Sends from your configured outgoing address.')}</span>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button variant="outline" onClick={onClose}>{t('messages.cancel', 'Cancel')}</Button>
|
||||
<Button variant="primary" onClick={() => send.mutate()} isLoading={send.isPending} disabled={!canSend} leftIcon={<SendIcon className="w-4 h-4" />}>
|
||||
{t('messages.send', 'Send')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageComposer;
|
||||
@@ -1,822 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
Inbox, Send, Reply, ReplyAll, Forward, Archive, Trash2, Paperclip,
|
||||
FileText, Quote, FileSignature, Image as ImageIcon, ReceiptText,
|
||||
Link2, X, ChevronLeft, ChevronRight, Mail, RefreshCw, PenSquare, Search, RotateCcw, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { emailService, type ReceivedEmail, type MailIdentities } from '../../../services/email.service';
|
||||
import { accountingService } from '../../../services/accounting.service';
|
||||
import { Loading } from '../../../components/common';
|
||||
import { MessageComposer, type ComposerInit } from './MessageComposer';
|
||||
import { DocumentActionModal, type DocType } from './DocumentActionModal';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
|
||||
/**
|
||||
* Admin "Messages" — read-only viewer over the mail picpeak already
|
||||
* has: the Automated stream (email_queue, incl. rendered bodies from migration
|
||||
* 119) and the Accounting inbox (received_emails / supplier invoices). The
|
||||
* Customers (hello@) mailbox and reply/compose land in later phases; those
|
||||
* folders render an explanatory empty state so the full IA is visible now.
|
||||
*/
|
||||
|
||||
type FolderSrc = 'queue' | 'received' | 'empty' | 'state';
|
||||
interface Folder { id: string; name: string; icon: LucideIcon; src: FolderSrc; account?: string; origin?: 'system' | 'manual'; state?: 'archived' | 'deleted'; note?: string; }
|
||||
interface Account { id: string; name: string; addr?: string; color: string; folders: Folder[]; }
|
||||
|
||||
type Selection =
|
||||
| { kind: 'queue'; id: number }
|
||||
| { kind: 'received'; item: ReceivedEmail }
|
||||
| null;
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
invoice_sent: 'Invoice sent',
|
||||
invoice_reminder_first: 'Payment reminder',
|
||||
invoice_reminder_second: 'Payment reminder',
|
||||
invoice_reminder_final: 'Final reminder',
|
||||
invoice_payment_check: 'Payment check',
|
||||
invoice_collections_handoff: 'Collections handoff',
|
||||
invoice_paid_admin_notification: 'Payment received',
|
||||
expiration_warning: 'Gallery expiring',
|
||||
gallery_expired: 'Gallery expired',
|
||||
quote_sent: 'Quote sent',
|
||||
contract_sent: 'Contract sent',
|
||||
};
|
||||
const friendlyType = (t: string) =>
|
||||
TYPE_LABELS[t] || t.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const fmt = (s?: string | null) =>
|
||||
s ? new Date(s).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }) : '';
|
||||
|
||||
// Compact mailbox label — just the local part + '@' (the domain clutters the
|
||||
// narrow sidebar); full address stays in the hover title.
|
||||
const localPart = (addr?: string | null) => (addr ? `${addr.split('@')[0]}@` : '');
|
||||
|
||||
// Escape untrusted text before it goes into an HTML string. The inbound From
|
||||
// header carries an attacker-controlled display name; the reply stub builds raw
|
||||
// HTML for the (contentEditable) composer, so this MUST be escaped there.
|
||||
const escapeHtml = (s: string) =>
|
||||
s.replace(/[&<>"']/g, (c) => (({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } as Record<string, string>)[c]));
|
||||
|
||||
// A From/To header can be "Display Name <addr@x>" — pull the bare address for
|
||||
// use as a recipient / customer-lookup key.
|
||||
const extractEmail = (addr?: string | null) => {
|
||||
if (!addr) return '';
|
||||
const m = addr.match(/<([^>]+)>/);
|
||||
return (m ? m[1] : addr).trim();
|
||||
};
|
||||
|
||||
const STATUS_STYLES: Record<string, string> = {
|
||||
sent: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
ingested: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
received: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
pending: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||
failed: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
error: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||
};
|
||||
|
||||
export const MessagesPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [activeFolder, setActiveFolder] = useState('auto-sent');
|
||||
const [selection, setSelection] = useState<Selection>(null);
|
||||
const [pdfDocId, setPdfDocId] = useState<number | null>(null);
|
||||
const [composer, setComposer] = useState<{ init: ComposerInit; title?: string; accountKey?: string } | null>(null);
|
||||
const [docAction, setDocAction] = useState<{ docType: DocType; senderEmail: string } | null>(null);
|
||||
const { flags } = useFeatureFlags();
|
||||
const [search, setSearch] = useState('');
|
||||
// Debounced copy drives the server-side search (so results aren't truncated to
|
||||
// the first page); the raw `search` still filters the loaded rows instantly.
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebouncedSearch(search.trim()), 250);
|
||||
return () => clearTimeout(id);
|
||||
}, [search]);
|
||||
const sq = debouncedSearch || undefined;
|
||||
|
||||
// "Sync" = poll the inbound mailboxes now instead of waiting for the 60s loop.
|
||||
const sync = useMutation({
|
||||
mutationFn: () => emailService.pollIncoming(),
|
||||
onSuccess: (r) => {
|
||||
if (r.skipped === 'disabled') toast.info(t('messages.syncDisabled', 'Incoming mail is off — enable it under Settings → Features.'));
|
||||
else if (r.skipped === 'unconfigured') toast.info(t('messages.syncUnconfigured', 'Configure a mailbox under Settings → Email first.'));
|
||||
else if (r.skipped === 'busy') toast.info(t('messages.syncBusy', 'A sync is already running.'));
|
||||
else toast.success(t('messages.syncOk', 'Checked mailboxes — {{count}} new.', { count: r.processed || 0 }));
|
||||
acctQuery.refetch(); custQuery.refetch(); queueQuery.refetch();
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.syncFailed', 'Sync failed.')),
|
||||
});
|
||||
|
||||
const openNewMessage = () => setComposer({
|
||||
init: { to: '', subject: '', html: '' },
|
||||
title: t('messages.newMessage', 'New message'),
|
||||
accountKey: 'customers',
|
||||
});
|
||||
|
||||
const queueQuery = useQuery({
|
||||
queryKey: ['messages', 'queue', sq],
|
||||
queryFn: () => emailService.listQueue({ pageSize: 100, q: sq }),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
const acctQuery = useQuery({
|
||||
queryKey: ['messages', 'received', 'accounting', sq],
|
||||
queryFn: () => emailService.listReceived({ account: 'accounting', pageSize: 100, q: sq }),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
const custQuery = useQuery({
|
||||
queryKey: ['messages', 'received', 'customers', sq],
|
||||
queryFn: () => emailService.listReceived({ account: 'customers', pageSize: 100, q: sq }),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
const identitiesQuery = useQuery({
|
||||
queryKey: ['messages', 'identities'],
|
||||
queryFn: () => emailService.getIdentities(),
|
||||
});
|
||||
const identities = identitiesQuery.data;
|
||||
|
||||
// Archived / Deleted system folders — fetch queue + received for that state,
|
||||
// on demand (only when the folder is open).
|
||||
const folderState: 'archived' | 'deleted' | undefined =
|
||||
activeFolder === 'archived' ? 'archived' : activeFolder === 'deleted' ? 'deleted' : undefined;
|
||||
const stateQueueQuery = useQuery({
|
||||
queryKey: ['messages', 'state-queue', folderState, sq],
|
||||
enabled: !!folderState,
|
||||
queryFn: () => emailService.listQueue({ state: folderState as 'archived' | 'deleted', pageSize: 100, q: sq }),
|
||||
});
|
||||
const stateRecvQuery = useQuery({
|
||||
queryKey: ['messages', 'state-received', folderState, sq],
|
||||
enabled: !!folderState,
|
||||
queryFn: () => emailService.listReceived({ state: folderState as 'archived' | 'deleted', pageSize: 100, q: sq }),
|
||||
});
|
||||
|
||||
const refetchAll = () => {
|
||||
queueQuery.refetch(); acctQuery.refetch(); custQuery.refetch();
|
||||
stateQueueQuery.refetch(); stateRecvQuery.refetch();
|
||||
};
|
||||
const stateMut = useMutation({
|
||||
mutationFn: (v: { kind: 'queue' | 'received'; id: number; state: 'active' | 'archived' | 'deleted' }) =>
|
||||
emailService.setItemState(v.kind, v.id, v.state),
|
||||
onSuccess: () => { setSelection(null); refetchAll(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.actionFailed', 'Action failed.')),
|
||||
});
|
||||
const purgeMut = useMutation({
|
||||
mutationFn: (v: { kind: 'queue' | 'received'; id: number }) => emailService.deleteItem(v.kind, v.id),
|
||||
onSuccess: () => { setSelection(null); refetchAll(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('messages.actionFailed', 'Action failed.')),
|
||||
});
|
||||
// Archive / Delete (soft) / Restore, acting on the current selection. Delete
|
||||
// from the Deleted folder is permanent.
|
||||
const doItemAction = (action: 'archive' | 'delete' | 'restore') => {
|
||||
if (!selection) return;
|
||||
const kind = selection.kind;
|
||||
const id = selection.kind === 'queue' ? selection.id : selection.item.id;
|
||||
if (action === 'restore') stateMut.mutate({ kind, id, state: 'active' });
|
||||
else if (action === 'archive') stateMut.mutate({ kind, id, state: 'archived' });
|
||||
else if (folderState === 'deleted') purgeMut.mutate({ kind, id });
|
||||
else stateMut.mutate({ kind, id, state: 'deleted' });
|
||||
};
|
||||
|
||||
const queueTotal = queueQuery.data?.pagination.total;
|
||||
const acctTotal = acctQuery.data?.pagination.total;
|
||||
const custTotal = custQuery.data?.pagination.total;
|
||||
|
||||
const accounts: Account[] = useMemo(() => [
|
||||
{ id: 'all', name: t('messages.account.all', 'All mail'), color: '#64748b', folders: [
|
||||
{ id: 'all-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received' },
|
||||
{ id: 'all-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue' },
|
||||
] },
|
||||
{ id: 'cust', name: t('messages.account.customers', 'Customers'), addr: identities?.customers || undefined, color: '#2563c9', folders: [
|
||||
{ id: 'cust-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received', account: 'customers' },
|
||||
{ id: 'cust-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'manual' },
|
||||
] },
|
||||
{ id: 'acct', name: t('messages.account.accounting', 'Accounting'), addr: identities?.accounting || undefined, color: '#12876a', folders: [
|
||||
{ id: 'acct-in', name: t('messages.folder.inbox', 'Inbox'), icon: Inbox, src: 'received', account: 'accounting' },
|
||||
] },
|
||||
{ id: 'auto', name: t('messages.account.automated', 'Automated'), addr: identities?.automated || undefined, color: '#7a52d6', folders: [
|
||||
{ id: 'auto-sent', name: t('messages.folder.sent', 'Sent'), icon: Send, src: 'queue', origin: 'system' },
|
||||
] },
|
||||
], [t, identities]);
|
||||
|
||||
// Cross-account system folders — Archived + Deleted (trash).
|
||||
const systemFolders: Folder[] = useMemo(() => [
|
||||
{ id: 'archived', name: t('messages.folder.archived', 'Archived'), icon: Archive, src: 'state', state: 'archived' },
|
||||
{ id: 'deleted', name: t('messages.folder.deleted', 'Deleted'), icon: Trash2, src: 'state', state: 'deleted' },
|
||||
], [t]);
|
||||
|
||||
// Sent stream is split client-side by origin: system (Automated) vs manual
|
||||
// (human composed → Customers ▸ Sent). Legacy rows (origin undefined) = system.
|
||||
const queueItemsAll = queueQuery.data?.items || [];
|
||||
const queueFor = (origin?: 'system' | 'manual') =>
|
||||
origin === 'manual' ? queueItemsAll.filter((i) => i.origin === 'manual')
|
||||
: origin === 'system' ? queueItemsAll.filter((i) => i.origin !== 'manual')
|
||||
: queueItemsAll;
|
||||
|
||||
const folder = useMemo(() => {
|
||||
for (const a of accounts) for (const f of a.folders) if (f.id === activeFolder) return { a, f };
|
||||
const sf = systemFolders.find((f) => f.id === activeFolder);
|
||||
if (sf) return { a: { id: 'system', name: sf.name, color: '#94a3b8', folders: [] } as Account, f: sf };
|
||||
return { a: accounts[0], f: accounts[0].folders[0] };
|
||||
}, [accounts, systemFolders, activeFolder]);
|
||||
|
||||
const countFor = (f: Folder): number | undefined => {
|
||||
if (f.src === 'queue') return f.origin ? queueFor(f.origin).length : queueTotal;
|
||||
if (f.src === 'received') {
|
||||
if (f.account === 'customers') return custTotal;
|
||||
if (f.account === 'accounting') return acctTotal;
|
||||
return (acctTotal || 0) + (custTotal || 0);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Which received rows feed the active folder (customer / accounting / union).
|
||||
const receivedItems = useMemo(() => {
|
||||
if (folder.f.src !== 'received') return undefined;
|
||||
const a = acctQuery.data?.items || [];
|
||||
const c = custQuery.data?.items || [];
|
||||
if (folder.f.account === 'customers') return c;
|
||||
if (folder.f.account === 'accounting') return a;
|
||||
return [...a, ...c].sort((x, y) => (y.received_at || '').localeCompare(x.received_at || ''));
|
||||
}, [folder, acctQuery.data, custQuery.data]);
|
||||
|
||||
const receivedLoading = folder.f.account === 'customers'
|
||||
? custQuery.isLoading
|
||||
: folder.f.account === 'accounting'
|
||||
? acctQuery.isLoading
|
||||
: acctQuery.isLoading || custQuery.isLoading;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-8.5rem)] min-h-[540px]">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex-none">
|
||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<Mail className="w-6 h-6 text-neutral-500 dark:text-neutral-400" />
|
||||
{t('messages.title', 'Messages')}
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-0.5">
|
||||
{t('messages.subtitle', 'Sent, automated and incoming mail — one place.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative flex-1 max-w-md ml-auto">
|
||||
<Search className="w-4 h-4 text-neutral-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('messages.searchPlaceholder', 'Search this folder…')}
|
||||
className="w-full h-9 pl-9 pr-3 rounded-lg border border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-none">
|
||||
<button
|
||||
onClick={() => sync.mutate()}
|
||||
disabled={sync.isPending}
|
||||
className="inline-flex items-center gap-2 h-9 px-3 rounded-lg border border-neutral-300 dark:border-neutral-700 text-sm font-medium text-neutral-700 dark:text-neutral-200 hover:bg-neutral-50 dark:hover:bg-neutral-800 disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${sync.isPending ? 'animate-spin' : ''}`} />
|
||||
{t('messages.sync', 'Sync')}
|
||||
</button>
|
||||
<button
|
||||
onClick={openNewMessage}
|
||||
className="inline-flex items-center gap-2 h-9 px-3.5 rounded-lg bg-accent-dark text-white text-sm font-medium hover:opacity-90"
|
||||
>
|
||||
<PenSquare className="w-4 h-4" />
|
||||
{t('messages.newMessage', 'New message')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 min-h-0 rounded-xl border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white dark:bg-neutral-900">
|
||||
{/* ── account tree ── */}
|
||||
<nav className="w-56 flex-none border-r border-neutral-200 dark:border-neutral-800 overflow-y-auto p-2 bg-neutral-50 dark:bg-neutral-950/40">
|
||||
{accounts.map((a) => (
|
||||
<div key={a.id} className="mb-1.5">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-sm font-semibold text-neutral-800 dark:text-neutral-200">
|
||||
<span className="w-2 h-2 rounded-full flex-none" style={{ background: a.color }} />
|
||||
<span>{a.name}</span>
|
||||
{a.addr && <span title={a.addr} className="ml-auto text-[11px] font-medium font-mono text-neutral-400 dark:text-neutral-500 truncate max-w-[7rem]">{localPart(a.addr)}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{a.folders.map((f) => {
|
||||
const c = countFor(f);
|
||||
const active = f.id === activeFolder;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => { setActiveFolder(f.id); setSelection(null); }}
|
||||
className={`flex items-center gap-2 pl-7 pr-2 py-1.5 rounded-lg text-[13.5px] text-left transition-colors ${
|
||||
active
|
||||
? 'bg-accent-soft text-on-accent-soft font-semibold'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/60'
|
||||
}`}
|
||||
>
|
||||
<f.icon className="w-4 h-4 opacity-80" />
|
||||
<span>{f.name}</span>
|
||||
{typeof c === 'number' && c > 0 && (
|
||||
<span className={`ml-auto tabular-nums text-xs ${active ? 'text-on-accent-soft' : 'text-neutral-400'}`}>{c}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* System folders — Archived + Deleted, across all accounts. */}
|
||||
<div className="mt-2 pt-2 border-t border-neutral-200 dark:border-neutral-800 flex flex-col gap-0.5">
|
||||
{systemFolders.map((f) => {
|
||||
const active = f.id === activeFolder;
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => { setActiveFolder(f.id); setSelection(null); }}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[13.5px] text-left transition-colors ${
|
||||
active
|
||||
? 'bg-accent-soft text-on-accent-soft font-semibold'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/60'
|
||||
}`}
|
||||
>
|
||||
<f.icon className="w-4 h-4 opacity-80" />
|
||||
<span>{f.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* ── message list ── */}
|
||||
<section className="w-[22rem] flex-none flex flex-col min-h-0 border-r border-neutral-200 dark:border-neutral-800">
|
||||
<div className="px-4 py-3 border-b border-neutral-200 dark:border-neutral-800 flex-none">
|
||||
<div className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{folder.f.name}</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
|
||||
{folder.f.src === 'state'
|
||||
? t('messages.acrossAccounts', 'Across all accounts')
|
||||
: folder.a.addr || (folder.a.id === 'all' ? t('messages.unified', 'Unified across accounts') : t('messages.systemGenerated', 'System-generated'))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<MessageList
|
||||
folder={folder.f}
|
||||
queue={folder.f.src === 'state' ? stateQueueQuery.data?.items : queueFor(folder.f.origin)}
|
||||
received={folder.f.src === 'state' ? stateRecvQuery.data?.items : receivedItems}
|
||||
loading={folder.f.src === 'state'
|
||||
? (stateQueueQuery.isLoading || stateRecvQuery.isLoading)
|
||||
: folder.f.src === 'queue' ? queueQuery.isLoading : folder.f.src === 'received' ? receivedLoading : false}
|
||||
search={search}
|
||||
selection={selection}
|
||||
onSelect={setSelection}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── reading pane ── */}
|
||||
<section className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||
<ReadingPane
|
||||
selection={selection}
|
||||
account={folder.a}
|
||||
identities={identities}
|
||||
flags={flags}
|
||||
folderState={folderState}
|
||||
onViewDoc={setPdfDocId}
|
||||
onOpenAccounting={() => navigate('/admin/accounting/inbox')}
|
||||
onCompose={(init, title) => setComposer({ init, title, accountKey: 'customers' })}
|
||||
onOpenDoc={(docType, senderEmail) => setDocAction({ docType, senderEmail })}
|
||||
onItemAction={doItemAction}
|
||||
t={t}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{pdfDocId != null && <PdfModal docId={pdfDocId} onClose={() => setPdfDocId(null)} t={t} />}
|
||||
{composer && (
|
||||
<MessageComposer
|
||||
init={composer.init}
|
||||
title={composer.title}
|
||||
accountKey={composer.accountKey}
|
||||
onClose={() => setComposer(null)}
|
||||
onSent={() => { queueQuery.refetch(); setActiveFolder('cust-sent'); }}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
{docAction && (
|
||||
<DocumentActionModal
|
||||
docType={docAction.docType}
|
||||
senderEmail={docAction.senderEmail}
|
||||
onCompose={(init) => { setDocAction(null); setComposer({ init: { to: init.to, subject: init.subject, html: init.html }, title: init.subject, accountKey: 'customers' }); }}
|
||||
onClose={() => setDocAction(null)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────── message list ──
|
||||
const MessageList: React.FC<{
|
||||
folder: Folder;
|
||||
queue?: import('../../../services/email.service').EmailQueueItem[];
|
||||
received?: ReceivedEmail[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
selection: Selection;
|
||||
onSelect: (s: Selection) => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ folder, queue, received, loading, search, selection, onSelect, t }) => {
|
||||
if (folder.src === 'empty') {
|
||||
return (
|
||||
<div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<Inbox className="w-8 h-8 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" />
|
||||
{folder.note}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) return <div className="p-6"><Loading /></div>;
|
||||
|
||||
const qRows = (queue || []).map((m) => ({
|
||||
key: `q${m.id}`,
|
||||
sortKey: m.sentAt || m.createdAt || '',
|
||||
onClick: () => onSelect({ kind: 'queue', id: m.id }),
|
||||
active: selection?.kind === 'queue' && selection.id === m.id,
|
||||
who: m.recipientEmail,
|
||||
subject: friendlyType(m.emailType),
|
||||
when: fmt(m.sentAt || m.createdAt),
|
||||
status: m.status,
|
||||
attach: 0,
|
||||
}));
|
||||
const rRows = (received || []).map((m) => ({
|
||||
key: `r${m.id}`,
|
||||
sortKey: m.received_at || '',
|
||||
onClick: () => onSelect({ kind: 'received', item: m }),
|
||||
active: selection?.kind === 'received' && selection.item.id === m.id,
|
||||
who: m.from_address || '—',
|
||||
subject: m.subject || t('messages.noSubject', '(no subject)'),
|
||||
when: fmt(m.received_at),
|
||||
status: m.status,
|
||||
attach: m.attachment_count,
|
||||
}));
|
||||
|
||||
// Archived/Deleted folders (src 'state') merge both streams by date.
|
||||
let rows = folder.src === 'queue' ? qRows
|
||||
: folder.src === 'received' ? rRows
|
||||
: [...qRows, ...rRows].sort((a, b) => (b.sortKey || '').localeCompare(a.sortKey || ''));
|
||||
|
||||
const q = search.trim().toLowerCase();
|
||||
if (q) rows = rows.filter((r) => r.who.toLowerCase().includes(q) || r.subject.toLowerCase().includes(q));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return <div className="p-8 text-center text-sm text-neutral-500 dark:text-neutral-400">{q ? t('messages.noSearchResults', 'No matches') : t('messages.noMessages', 'No messages')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul>
|
||||
{rows.map((r) => (
|
||||
<li key={r.key}>
|
||||
<button
|
||||
onClick={r.onClick}
|
||||
className={`w-full text-left px-4 py-3 border-b border-neutral-100 dark:border-neutral-800/70 border-l-[3px] transition-colors ${
|
||||
r.active
|
||||
? 'border-l-accent-dark bg-accent-soft'
|
||||
: 'border-l-transparent hover:bg-neutral-50 dark:hover:bg-neutral-800/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-[13.5px] text-neutral-800 dark:text-neutral-100 truncate">{r.who}</span>
|
||||
<span className="ml-auto text-[11px] text-neutral-400 tabular-nums whitespace-nowrap">{r.when}</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-neutral-600 dark:text-neutral-300 truncate mt-0.5">{r.subject}</div>
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<span className={`text-[10.5px] font-semibold px-1.5 py-0.5 rounded-full ${STATUS_STYLES[r.status] || 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-300'}`}>
|
||||
{r.status}
|
||||
</span>
|
||||
{r.attach > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-neutral-400">
|
||||
<Paperclip className="w-3 h-3" />{r.attach}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────── reading pane ──
|
||||
const ReadingPane: React.FC<{
|
||||
selection: Selection;
|
||||
account: Account;
|
||||
identities?: MailIdentities | null;
|
||||
flags: Record<string, boolean>;
|
||||
folderState?: 'archived' | 'deleted';
|
||||
onViewDoc: (id: number) => void;
|
||||
onOpenAccounting: () => void;
|
||||
onCompose: (init: ComposerInit, title?: string) => void;
|
||||
onOpenDoc: (docType: DocType, senderEmail: string) => void;
|
||||
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ selection, account, identities, flags, folderState, onViewDoc, onOpenAccounting, onCompose, onOpenDoc, onItemAction, t }) => {
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
|
||||
queryFn: () => emailService.getQueueItem((selection as { kind: 'queue'; id: number }).id),
|
||||
enabled: selection?.kind === 'queue',
|
||||
});
|
||||
|
||||
if (!selection) {
|
||||
return (
|
||||
<div className="flex-1 grid place-items-center text-center text-neutral-400 dark:text-neutral-500 p-10">
|
||||
<div>
|
||||
<Mail className="w-9 h-9 mx-auto mb-3 text-neutral-300 dark:text-neutral-700" />
|
||||
<div className="text-sm">{t('messages.selectPrompt', 'Select a message to read')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Accounting toolbar only for the rechnungen@ stream; customer mail (inbound
|
||||
// or the automated/sent streams) gets the CRM action set.
|
||||
const isAcct = selection.kind === 'received'
|
||||
? selection.item.account_key !== 'customers'
|
||||
: account.id === 'acct';
|
||||
|
||||
const recipient = extractEmail(selection.kind === 'received'
|
||||
? selection.item.from_address
|
||||
: detailQuery.data?.recipientEmail);
|
||||
|
||||
// Reply only makes sense for an inbound message with a sender.
|
||||
const onReply = selection.kind === 'received' && selection.item.from_address
|
||||
? () => {
|
||||
const it = selection.item;
|
||||
const subj = /^re:/i.test(it.subject || '') ? (it.subject || '') : `Re: ${it.subject || ''}`;
|
||||
const quoted = `<p><br></p><p style="color:#888;font-size:12px">${t('messages.onWrote', 'On')} ${fmt(it.received_at)}, ${escapeHtml(it.from_address || '')}:</p>`;
|
||||
onCompose({ to: extractEmail(it.from_address), subject: subj, html: quoted, replyToReceivedId: it.id }, t('messages.reply', 'Reply'));
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Quote/Contract/Invoice/Gallery open the document-action flow (resolve the
|
||||
// customer, then create-new or select-existing). Customer-facing streams only.
|
||||
const onDoc = !isAcct && recipient
|
||||
? (docType: DocType) => onOpenDoc(docType, recipient)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-0 flex-1">
|
||||
<Toolbar isAcct={isAcct} flags={flags} folderState={folderState} onReply={onReply} onDoc={onDoc} onItemAction={onItemAction} t={t} />
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{selection.kind === 'queue' ? (
|
||||
detailQuery.isLoading ? <Loading /> : detailQuery.data ? (
|
||||
<QueueDetail d={detailQuery.data} fromAddr={identities?.automated} t={t} />
|
||||
) : (
|
||||
<div className="text-sm text-neutral-500">{t('messages.loadError', 'Could not load this message.')}</div>
|
||||
)
|
||||
) : (
|
||||
<ReceivedDetail
|
||||
item={selection.item}
|
||||
mailboxAddr={selection.item.account_key === 'customers' ? identities?.customers : identities?.accounting}
|
||||
onViewDoc={onViewDoc}
|
||||
onOpenAccounting={onOpenAccounting}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const QueueDetail: React.FC<{ d: import('../../../services/email.service').EmailQueueDetail; fromAddr?: string | null; t: (k: string, d?: string) => string }> = ({ d, fromAddr, t }) => (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
||||
{friendlyType(d.emailType)}
|
||||
</h2>
|
||||
<div className="mt-3 pb-4 border-b border-neutral-200 dark:border-neutral-800 text-sm">
|
||||
<div className="text-neutral-600 dark:text-neutral-300">
|
||||
{t('messages.from', 'from')} <span className="font-mono text-xs">{fromAddr || '—'}</span> · {t('messages.to', 'to')}{' '}
|
||||
<span className="font-semibold text-neutral-800 dark:text-neutral-100">{d.recipientEmail}</span>
|
||||
</div>
|
||||
{d.cc && <div className="text-neutral-500 dark:text-neutral-400 text-xs mt-0.5">cc {d.cc}</div>}
|
||||
<div className="text-neutral-400 dark:text-neutral-500 text-xs mt-0.5 tabular-nums">{fmt(d.sentAt || d.createdAt)}</div>
|
||||
</div>
|
||||
|
||||
{d.renderedHtml ? (
|
||||
<div className="mt-4 rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white" style={{ height: '52vh' }}>
|
||||
{/* Our own template output, but rendered with a strict script-less,
|
||||
no-same-origin sandbox anyway — matches the inbound-mail pane. */}
|
||||
<iframe title="Email body" sandbox="" srcDoc={d.renderedHtml} className="w-full h-full border-0" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('messages.noBody', 'This message was sent before body capture was added, so no preview is available.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{d.attachments.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<div className="text-[11px] font-bold uppercase tracking-wide text-neutral-400 mb-2">
|
||||
{d.attachments.length} {t('messages.attachments', 'attachment(s)')}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 max-w-md">
|
||||
{d.attachments.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/40">
|
||||
<FileText className="w-5 h-5 text-red-500 flex-none" />
|
||||
<span className="text-[13.5px] font-medium text-neutral-800 dark:text-neutral-100 truncate">{a.filename}</span>
|
||||
<span className="ml-auto text-[11px] text-neutral-400" title={t('messages.sentAttachHint', 'Sent attachments are not archived yet — Phase 2.')}>
|
||||
{t('messages.notArchived', 'not archived yet')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const ReceivedDetail: React.FC<{
|
||||
item: ReceivedEmail;
|
||||
mailboxAddr?: string | null;
|
||||
onViewDoc: (id: number) => void;
|
||||
onOpenAccounting: () => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ item, mailboxAddr, onViewDoc, onOpenAccounting, t }) => {
|
||||
const detail = useQuery({
|
||||
queryKey: ['messages', 'received', 'item', item.id],
|
||||
queryFn: () => emailService.getReceivedItem(item.id),
|
||||
});
|
||||
const toAddr = detail.data?.to_address || item.to_address || mailboxAddr || '—';
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
||||
{item.subject || t('messages.noSubject', '(no subject)')}
|
||||
</h2>
|
||||
<div className="mt-3 pb-4 border-b border-neutral-200 dark:border-neutral-800 text-sm">
|
||||
<div className="text-neutral-600 dark:text-neutral-300">
|
||||
{t('messages.from', 'from')} <span className="font-semibold text-neutral-800 dark:text-neutral-100">{item.from_address || '—'}</span>
|
||||
{' · '}{t('messages.to', 'to')} <span className="font-mono text-xs">{toAddr}</span>
|
||||
</div>
|
||||
<div className="text-neutral-400 dark:text-neutral-500 text-xs mt-0.5 tabular-nums">{fmt(item.received_at)}</div>
|
||||
</div>
|
||||
|
||||
{detail.isLoading ? (
|
||||
<div className="mt-4"><Loading /></div>
|
||||
) : detail.data?.body_html ? (
|
||||
<div className="mt-4 rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden bg-white" style={{ height: '48vh' }}>
|
||||
{/* Sanitized server-side; rendered with a strict (script-less, no
|
||||
same-origin) sandbox as a second layer against untrusted mail. */}
|
||||
<iframe title="Email body" sandbox="" srcDoc={detail.data.body_html} className="w-full h-full border-0" />
|
||||
</div>
|
||||
) : detail.data?.body_text ? (
|
||||
<pre className="mt-4 whitespace-pre-wrap text-sm text-neutral-700 dark:text-neutral-300 font-sans">{detail.data.body_text}</pre>
|
||||
) : (
|
||||
<div className="mt-4 text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('messages.noInboundBody', 'No message body was captured for this email.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.inbound_document_id != null && (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => onViewDoc(item.inbound_document_id as number)}
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-accent-dark hover:opacity-90 text-white text-sm font-medium"
|
||||
>
|
||||
<FileText className="w-4 h-4" />{t('messages.viewDocument', 'View document')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onOpenAccounting}
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg border border-neutral-300 dark:border-neutral-700 text-neutral-700 dark:text-neutral-200 text-sm font-medium hover:bg-neutral-50 dark:hover:bg-neutral-800"
|
||||
>
|
||||
<Link2 className="w-4 h-4" />{t('messages.openInAccounting', 'Open in Accounting inbox')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{item.error && (
|
||||
<div className="mt-4 text-sm text-red-600 dark:text-red-400">{item.error}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── toolbar ──
|
||||
const Toolbar: React.FC<{
|
||||
isAcct: boolean;
|
||||
flags: Record<string, boolean>;
|
||||
folderState?: 'archived' | 'deleted';
|
||||
onReply?: () => void;
|
||||
onDoc?: (docType: DocType) => void;
|
||||
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
|
||||
t: (k: string, d?: string) => string;
|
||||
}> = ({ isAcct, flags, folderState, onReply, onDoc, onItemAction, t }) => {
|
||||
const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => {
|
||||
const enabled = !!onClick;
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={!enabled}
|
||||
title={enabled ? undefined : t('messages.soon', 'Available in a later phase')}
|
||||
className={`inline-flex items-center gap-1.5 h-8 px-2.5 rounded-lg text-[13px] font-medium ${
|
||||
enabled ? 'hover:bg-neutral-100 dark:hover:bg-neutral-800 ' : 'cursor-not-allowed opacity-50 '
|
||||
}${accent ? 'text-accent-dark font-semibold' : 'text-neutral-600 dark:text-neutral-300'}`}
|
||||
>
|
||||
<Icon className="w-[15px] h-[15px]" />{label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
const doc = (docType: DocType) => (onDoc ? () => onDoc(docType) : undefined);
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-wrap px-3 py-2 border-b border-neutral-200 dark:border-neutral-800 flex-none">
|
||||
<Tb icon={Reply} label={t('messages.reply', 'Reply')} onClick={onReply} />
|
||||
<Tb icon={ReplyAll} label={t('messages.replyAll', 'Reply all')} />
|
||||
<Tb icon={Forward} label={t('messages.forward', 'Forward')} />
|
||||
<span className="w-px h-5 bg-neutral-200 dark:bg-neutral-700 mx-1" />
|
||||
{isAcct ? (
|
||||
<>
|
||||
<Tb icon={ReceiptText} label={t('messages.bookExpense', 'Book as expense')} accent />
|
||||
<Tb icon={Forward} label={t('messages.rebill', 'Re-bill to client')} accent />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{flags.quotes && <Tb icon={Quote} label={t('messages.createQuote', 'Quote')} accent onClick={doc('quote')} />}
|
||||
{flags.contracts && <Tb icon={FileSignature} label={t('messages.createContract', 'Contract')} accent onClick={doc('contract')} />}
|
||||
<Tb icon={ImageIcon} label={t('messages.createGallery', 'Gallery')} accent onClick={doc('gallery')} />
|
||||
{flags.bills && <Tb icon={FileText} label={t('messages.createInvoice', 'Invoice')} accent onClick={doc('invoice')} />}
|
||||
</>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{folderState && <Tb icon={RotateCcw} label={t('messages.restore', 'Restore')} onClick={() => onItemAction('restore')} />}
|
||||
{folderState !== 'archived' && <Tb icon={Archive} label={t('messages.archive', 'Archive')} onClick={() => onItemAction('archive')} />}
|
||||
<Tb
|
||||
icon={Trash2}
|
||||
label={folderState === 'deleted' ? t('messages.deleteForever', 'Delete permanently') : t('messages.delete', 'Delete')}
|
||||
onClick={() => onItemAction('delete')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────── pdf modal ──
|
||||
const PdfModal: React.FC<{ docId: number; onClose: () => void; t: (k: string, d?: string) => string }> = ({ docId, onClose, t }) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [err, setErr] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let revoked: string | null = null;
|
||||
let cancelled = false;
|
||||
setErr(false);
|
||||
setUrl(null);
|
||||
accountingService.getInboundPageBlob(docId, page)
|
||||
.then((blob) => {
|
||||
if (cancelled) return;
|
||||
const u = URL.createObjectURL(blob);
|
||||
revoked = u;
|
||||
setUrl(u);
|
||||
})
|
||||
.catch(() => { if (!cancelled) setErr(true); });
|
||||
return () => { cancelled = true; if (revoked) URL.revokeObjectURL(revoked); };
|
||||
}, [docId, page]);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', h);
|
||||
return () => window.removeEventListener('keydown', h);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 grid place-items-center bg-black/55 p-6" onClick={onClose}>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl w-[min(620px,94vw)] max-h-[90vh] flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-neutral-200 dark:border-neutral-800">
|
||||
<FileText className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-neutral-800 dark:text-neutral-100">{t('messages.document', 'Document')}</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}
|
||||
className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-40">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-neutral-500 w-6 text-center">{page}</span>
|
||||
<button onClick={() => setPage((p) => p + 1)}
|
||||
className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={onClose} aria-label={t('messages.close', 'Close')}
|
||||
className="w-8 h-8 grid place-items-center rounded-lg text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 ml-1">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto p-5 bg-neutral-100 dark:bg-neutral-800 grid place-items-center min-h-[240px]">
|
||||
{err ? (
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400">{t('messages.previewUnavailable', 'Preview unavailable')}</div>
|
||||
) : url ? (
|
||||
<img src={url} alt="" className="max-w-full shadow-lg rounded" />
|
||||
) : (
|
||||
<Loading />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-[11px] text-neutral-400 py-2 border-t border-neutral-200 dark:border-neutral-800">
|
||||
{t('messages.rasterNote', 'Server-rendered preview — the raw file never reaches the browser.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagesPage;
|
||||
@@ -29,8 +29,6 @@ const SETTING_KEYS = [
|
||||
'crm_quotes_tos_url',
|
||||
'crm_invoices_qr_enabled',
|
||||
'crm_invoice_round_total',
|
||||
// Free-text VAT/legal note printed under the MwSt. line on invoice PDFs (#794).
|
||||
'crm_invoices_vat_note_text',
|
||||
'crm_invoices_reminders_enabled',
|
||||
'crm_invoices_reminder_first_days',
|
||||
'crm_invoices_reminder_second_days',
|
||||
@@ -251,25 +249,6 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
|
||||
{checkbox('crm_invoice_round_total', 'Reconcile sub-cent rounding to a clean total (adds a "Rundung" row when per-line rounding drifts from qty × rate)')}
|
||||
|
||||
{/* Free-text VAT / legal note (#794) — printed directly under the MwSt.
|
||||
line on every invoice PDF. Data-driven: the admin types the exact
|
||||
wording (Austrian Kleinunternehmer, German §19, reverse-charge, …). */}
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('crmSettings.crm_invoices_vat_note_text.label', 'VAT / free-text note on invoices')}
|
||||
</label>
|
||||
<textarea
|
||||
value={values.crm_invoices_vat_note_text ?? ''}
|
||||
onChange={(e) => setVal('crm_invoices_vat_note_text', e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t('crmSettings.crm_invoices_vat_note_text.placeholder', 'e.g. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).') as string}
|
||||
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('crmSettings.crm_invoices_vat_note_text.help', 'Printed directly under the MwSt. line on every invoice PDF. Leave empty to hide. Please confirm the exact wording with your tax advisor.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reminder TIMING: owned by the Invoice dunning workflow when the
|
||||
engine is live (callout); otherwise the legacy schedule controls. The
|
||||
late-fee math below is configured here in both cases — it's the fee
|
||||
|
||||
@@ -13,7 +13,6 @@ const DEFAULT_SETTINGS: SlideshowSettings = {
|
||||
transition: 'crossfade',
|
||||
transition_ms: 800,
|
||||
colorfilter: 'none',
|
||||
order: 'chronological',
|
||||
fit: 'cover',
|
||||
watermark: null,
|
||||
};
|
||||
@@ -66,17 +65,6 @@ function watermarkCorner(position: string): React.CSSProperties {
|
||||
|
||||
type Phase = 'splash' | 'running' | 'ended';
|
||||
|
||||
// Fisher–Yates shuffle for the 'random' play order (#202). Used once on the
|
||||
// initial photo set; live-appended uploads keep landing at the end.
|
||||
function shuffle<T>(arr: T[]): T[] {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
export function SlideshowPage() {
|
||||
const { slug = '', token = '' } = useParams<{ slug: string; token: string }>();
|
||||
const { t } = useTranslation();
|
||||
@@ -171,15 +159,13 @@ export function SlideshowPage() {
|
||||
storeGalleryToken(slug, session.token);
|
||||
setActiveGallerySlug(slug);
|
||||
setEventName(session.event.event_name || '');
|
||||
const settings = session.settings || DEFAULT_SETTINGS;
|
||||
setSettings(settings);
|
||||
setSettings(session.settings || DEFAULT_SETTINGS);
|
||||
|
||||
// Load the list and DECODE the first slide (and the next) before we flip
|
||||
// to running, so playback starts on an already-rasterised image instead
|
||||
// of struggling on the first transition.
|
||||
const data = await galleryService.getGalleryPhotos(slug);
|
||||
// 'random' shuffles the initial set once; new uploads still append (#202).
|
||||
const list = settings.order === 'random' ? shuffle(data.photos || []) : (data.photos || []);
|
||||
const list = data.photos || [];
|
||||
setPhotos(list);
|
||||
await preloadDecode(list[0]);
|
||||
void preloadDecode(list[1]);
|
||||
|
||||
@@ -10,12 +10,6 @@ export interface PhotoCategory {
|
||||
// Per-category download permission (#640). Defaults true (server-side) so
|
||||
// categories created before migration 135 keep working.
|
||||
allow_downloads?: boolean;
|
||||
// Global default sort order (#782). Backfilled from the previous alphabetical
|
||||
// order on migration, so existing galleries don't reshuffle.
|
||||
display_order?: number;
|
||||
// Per-event override position (#782). Non-null on the /event/:id response when
|
||||
// this gallery has customised its order; null means it follows the default.
|
||||
override_position?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -67,31 +61,5 @@ export const categoriesService = {
|
||||
// Delete a category
|
||||
async deleteCategory(id: number): Promise<void> {
|
||||
await api.delete(`/admin/categories/${id}`);
|
||||
},
|
||||
|
||||
// Set a per-event order override (#782). Sends the full ordered id list for
|
||||
// this event — globals + event-specific — and returns the resolved order.
|
||||
// Overrides the global default for this gallery only.
|
||||
async reorderCategories(eventId: number, orderedIds: number[]): Promise<PhotoCategory[]> {
|
||||
const response = await api.post<PhotoCategory[]>('/admin/categories/reorder', {
|
||||
event_id: eventId,
|
||||
orderedIds
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Clear an event's override — revert this gallery to the global default order.
|
||||
async resetEventOrder(eventId: number): Promise<PhotoCategory[]> {
|
||||
const response = await api.delete<PhotoCategory[]>(`/admin/categories/reorder/${eventId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Set the GLOBAL default order for shared categories (#782). Applies to every
|
||||
// gallery that hasn't set its own override.
|
||||
async reorderGlobalCategories(orderedIds: number[]): Promise<PhotoCategory[]> {
|
||||
const response = await api.post<PhotoCategory[]>('/admin/categories/reorder-global', {
|
||||
orderedIds
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
@@ -15,8 +15,6 @@ export interface EmailQueueItem {
|
||||
eventId: number | null;
|
||||
eventName: string | null;
|
||||
eventSlug: string | null;
|
||||
/** 'system' = app-generated (Automated), 'manual' = admin-composed (Customers Sent). */
|
||||
origin?: 'system' | 'manual';
|
||||
}
|
||||
|
||||
export interface EmailQueueListResponse {
|
||||
@@ -24,15 +22,6 @@ export interface EmailQueueListResponse {
|
||||
pagination: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}
|
||||
|
||||
/** Single sent/queued email including its rendered body — Messages reading pane. */
|
||||
export interface EmailQueueDetail extends EmailQueueItem {
|
||||
/** Exact HTML that was sent (migration 119); null for pre-migration rows. */
|
||||
renderedHtml: string | null;
|
||||
cc: string | null;
|
||||
/** Attachment filenames only — disk paths are never exposed. */
|
||||
attachments: { filename: string; contentType: string | null }[];
|
||||
}
|
||||
|
||||
export interface EmailConfig {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
@@ -127,9 +116,7 @@ export interface ImapPollResult {
|
||||
export interface ReceivedEmail {
|
||||
id: number;
|
||||
message_id: string | null;
|
||||
account_key?: string | null;
|
||||
from_address: string | null;
|
||||
to_address?: string | null;
|
||||
subject: string | null;
|
||||
received_at: string | null;
|
||||
attachment_count: number;
|
||||
@@ -138,46 +125,11 @@ export interface ReceivedEmail {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Single received email including its captured, server-sanitized body. */
|
||||
export interface ReceivedEmailDetail extends ReceivedEmail {
|
||||
body_html: string | null;
|
||||
body_text: string | null;
|
||||
}
|
||||
|
||||
export interface ReceivedEmailsResponse {
|
||||
items: ReceivedEmail[];
|
||||
pagination: { page: number; pageSize: number; total: number; totalPages: number };
|
||||
}
|
||||
|
||||
/** An additional inbound mailbox beyond the primary accounting IMAP. */
|
||||
export interface MailAccount {
|
||||
id?: number;
|
||||
account_key: string;
|
||||
label?: string | null;
|
||||
imap_host?: string | null;
|
||||
imap_port?: number;
|
||||
imap_secure?: boolean;
|
||||
imap_user?: string | null;
|
||||
imap_pass?: string;
|
||||
imap_folder?: string;
|
||||
// Outgoing (SMTP) identity — replies from this mailbox send from here.
|
||||
smtp_host?: string | null;
|
||||
smtp_port?: number;
|
||||
smtp_secure?: boolean;
|
||||
smtp_user?: string | null;
|
||||
smtp_pass?: string;
|
||||
from_email?: string | null;
|
||||
from_name?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/** Resolved sender/mailbox addresses for the Messages sidebar. */
|
||||
export interface MailIdentities {
|
||||
automated: string | null;
|
||||
accounting: string | null;
|
||||
customers: string | null;
|
||||
}
|
||||
|
||||
export const emailService = {
|
||||
// Get email configuration
|
||||
async getConfig(): Promise<EmailConfig> {
|
||||
@@ -220,34 +172,10 @@ export const emailService = {
|
||||
const response = await api.post<ImapPollResult>('/admin/email/incoming-config/poll', {});
|
||||
return response.data;
|
||||
},
|
||||
async listReceived(params: { page?: number; pageSize?: number; account?: string; state?: 'active' | 'archived' | 'deleted'; q?: string } = {}): Promise<ReceivedEmailsResponse> {
|
||||
async listReceived(params: { page?: number; pageSize?: number } = {}): Promise<ReceivedEmailsResponse> {
|
||||
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
|
||||
return response.data;
|
||||
},
|
||||
/** Archive / Delete (soft) / Restore an email. kind = 'queue' | 'received'. */
|
||||
async setItemState(kind: 'queue' | 'received', id: number, state: 'active' | 'archived' | 'deleted'): Promise<void> {
|
||||
await api.post(`/admin/email/item/${kind}/${id}/state`, { state });
|
||||
},
|
||||
/** Permanently delete an email (only from the Deleted folder). */
|
||||
async deleteItem(kind: 'queue' | 'received', id: number): Promise<void> {
|
||||
await api.delete(`/admin/email/item/${kind}/${id}`);
|
||||
},
|
||||
async getReceivedItem(id: number): Promise<ReceivedEmailDetail> {
|
||||
const response = await api.get<ReceivedEmailDetail>(`/admin/email/received/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
// Additional inbound mailboxes (e.g. the customer hello@ box).
|
||||
async listMailAccounts(): Promise<MailAccount[]> {
|
||||
const response = await api.get<{ items: MailAccount[] }>('/admin/email/accounts');
|
||||
return response.data.items;
|
||||
},
|
||||
async saveMailAccount(account: MailAccount): Promise<void> {
|
||||
await api.post('/admin/email/accounts', account);
|
||||
},
|
||||
async testMailAccount(account: Partial<MailAccount>): Promise<ImapTestResult> {
|
||||
const response = await api.post<ImapTestResult>('/admin/email/accounts/test', account);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Test email configuration
|
||||
async testEmail(testEmail: string): Promise<void> {
|
||||
@@ -269,8 +197,6 @@ export const emailService = {
|
||||
async listQueue(params: {
|
||||
status?: EmailQueueStatus;
|
||||
emailType?: string;
|
||||
origin?: 'system' | 'manual';
|
||||
state?: 'active' | 'archived' | 'deleted';
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -281,23 +207,6 @@ export const emailService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Single sent email with its rendered body + attachment filenames. */
|
||||
async getQueueItem(id: number): Promise<EmailQueueDetail> {
|
||||
const response = await api.get<EmailQueueDetail>(`/admin/email/queue/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Send a human-composed (edited) email — reply or document message. */
|
||||
async sendMessage(payload: { to: string; cc?: string; subject: string; html: string; replyToReceivedId?: number; accountKey?: string }): Promise<void> {
|
||||
await api.post('/admin/email/send', payload);
|
||||
},
|
||||
|
||||
/** Resolved sender/mailbox addresses for the Messages sidebar. */
|
||||
async getIdentities(): Promise<MailIdentities> {
|
||||
const response = await api.get<MailIdentities>('/admin/email/identities');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all email templates
|
||||
async getTemplates(): Promise<EmailTemplate[]> {
|
||||
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
||||
|
||||
@@ -158,8 +158,6 @@ export const eventsService = {
|
||||
show_transition_ms?: number;
|
||||
show_watermark?: boolean | null;
|
||||
show_colorfilter?: string;
|
||||
show_order?: string;
|
||||
show_category_id?: number | null;
|
||||
}
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await api.patch(`/admin/events/${id}/slideshow`, settings);
|
||||
|
||||
@@ -38,11 +38,4 @@ export const setupService = {
|
||||
const response = await api.post<{ user: SetupAdminUser }>('/setup/admin', input);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// One-way wizard-finish marker (authenticated — runs after the admin
|
||||
// exists). While unset, the wizard's event-types step may delete the
|
||||
// seeded system types; afterwards they are permanently protected.
|
||||
async completeSetup(): Promise<void> {
|
||||
await api.post('/setup/complete');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,9 +18,6 @@ export const SLIDESHOW_WATERMARK_STYLES: SlideshowWatermarkStyle[] = ['white', '
|
||||
// (admin Settings → Slideshow); 'on'/'off' = explicit override.
|
||||
export type SlideshowWatermarkMode = 'inherit' | 'on' | 'off';
|
||||
export const SLIDESHOW_WATERMARK_MODES: SlideshowWatermarkMode[] = ['inherit', 'on', 'off'];
|
||||
// Play order (#202): 'chronological' = upload order; 'random' = client shuffle.
|
||||
export type SlideshowOrder = 'chronological' | 'random';
|
||||
export const SLIDESHOW_ORDERS: SlideshowOrder[] = ['chronological', 'random'];
|
||||
|
||||
export const SLIDESHOW_TRANSITIONS: SlideshowTransition[] = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||
export const SLIDESHOW_COLORFILTERS: SlideshowColorFilter[] = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||||
@@ -40,9 +37,6 @@ export interface SlideshowStyle {
|
||||
transition_ms: number;
|
||||
watermark: SlideshowWatermarkMode;
|
||||
colorfilter: SlideshowColorFilter;
|
||||
// Play order + optional category filter (#202). category_id null = all photos.
|
||||
order: SlideshowOrder;
|
||||
category_id: number | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
|
||||
@@ -51,8 +45,6 @@ export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
|
||||
transition_ms: 800,
|
||||
watermark: 'inherit',
|
||||
colorfilter: 'none',
|
||||
order: 'chronological',
|
||||
category_id: null,
|
||||
};
|
||||
|
||||
// Global slideshow defaults (admin Settings → Slideshow). The single source of
|
||||
@@ -89,10 +81,6 @@ export interface SlideshowSettings {
|
||||
transition: SlideshowTransition;
|
||||
transition_ms: number;
|
||||
colorfilter: SlideshowColorFilter;
|
||||
// Play order the kiosk applies (#202): 'random' shuffles client-side so
|
||||
// live-appended uploads keep working. The category filter is enforced
|
||||
// server-side, so it isn't echoed here.
|
||||
order: SlideshowOrder;
|
||||
fit: SlideshowFit;
|
||||
watermark: SlideshowWatermark | null;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,5 @@
|
||||
* notes for the running version (#566) and by the update-available
|
||||
* indicator to link to the upgrade target's notes.
|
||||
*/
|
||||
/** Repository home on GitHub. Single source of truth for the org URL so
|
||||
* links (release notes, the admin "view on GitHub" button, #778) don't
|
||||
* each hardcode it. */
|
||||
export const repoUrl = 'https://github.com/PicPeak/picpeak';
|
||||
|
||||
export const githubReleaseUrl = (version: string): string =>
|
||||
`${repoUrl}/releases/tag/v${version}`;
|
||||
`https://github.com/PicPeak/picpeak/releases/tag/v${version}`;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
Reference in New Issue
Block a user