Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a691d4251 | |||
| 34846ae71a | |||
| ea261dd03b | |||
| eb93223d79 | |||
| b20f9cc108 | |||
| 515814e1d5 | |||
| fba9838e21 | |||
| a9c2761986 | |||
| db7e5913eb | |||
| aa27d1ea79 | |||
| cf8df2780e | |||
| 419a283c62 | |||
| 1b075a4beb | |||
| 68ff93cf17 | |||
| b22a29e877 | |||
| e601311ca3 | |||
| bb00c3993b | |||
| c9c0de46bf | |||
| 5374299cd5 | |||
| 536e2b2874 | |||
| 141acd5736 | |||
| 5f4337a18d | |||
| fec7b687f7 | |||
| cfa29ad5cb | |||
| 76ae35217c | |||
| fe651fa38e | |||
| f7b8c0c0fe | |||
| 4af3cc2486 | |||
| 3632b936e9 | |||
| 66a6d4003a | |||
| bdf73c1f06 | |||
| f032743690 | |||
| a9902b95b4 | |||
| 9d1c0b672a | |||
| 727fd8bae8 | |||
| 954103510a | |||
| 801e1f81d9 | |||
| f9861480aa | |||
| a26dfd3d6f | |||
| 1db908771f | |||
| 59651b8c24 | |||
| 7ccd48297f | |||
| d05ff6380e | |||
| 605f773a7e |
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
@@ -19,11 +20,22 @@ jobs:
|
||||
git config --global user.name "the-luap"
|
||||
git config --global user.email "paul-nothaft@hotmail.de"
|
||||
|
||||
- name: Debug - Show current branch and status
|
||||
run: |
|
||||
echo "Current branch:"
|
||||
git branch -a
|
||||
echo "Git status:"
|
||||
git status
|
||||
echo "Remote info:"
|
||||
git remote -v
|
||||
|
||||
- name: Create filtered branch
|
||||
run: |
|
||||
|
||||
# Clean up any existing github-mirror branch
|
||||
git branch -D github-mirror || true
|
||||
|
||||
# Create a new branch for GitHub
|
||||
git checkout --orphan -b github-mirror
|
||||
git checkout --orphan github-mirror
|
||||
|
||||
# Remove sensitive files/directories
|
||||
# Example: Remove .env files, private configs, etc.
|
||||
@@ -43,17 +55,45 @@ jobs:
|
||||
git rm -r --cached CLAUDE.md || true
|
||||
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true
|
||||
git rm -r --cached logs/ || true
|
||||
git rm -r --cached frontend/.claudedocs/ || true
|
||||
git rm -r --cached test-maintenance.sh || true
|
||||
git rm -r --cached storage/ || true
|
||||
|
||||
|
||||
# Commit the changes
|
||||
git commit -m "Remove sensitive files for GitHub mirror" || true
|
||||
|
||||
- name: Check GitHub token
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
if [ -z "$GITHUBTOKEN" ]; then
|
||||
echo "ERROR: GITHUBTOKEN secret is not set!"
|
||||
exit 1
|
||||
else
|
||||
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
|
||||
fi
|
||||
|
||||
- name: Push to GitHub
|
||||
env:
|
||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||
run: |
|
||||
# Remove existing github remote if it exists
|
||||
git remote remove github || true
|
||||
|
||||
# Add GitHub remote
|
||||
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
||||
|
||||
# Verify remote was added
|
||||
echo "GitHub remote added:"
|
||||
git remote -v
|
||||
|
||||
# Force push the filtered branch to GitHub main
|
||||
git push github github-mirror:main --force
|
||||
echo "Pushing to GitHub..."
|
||||
git push github github-mirror:main --force
|
||||
echo "Push completed successfully!"
|
||||
|
||||
- name: Workflow completed
|
||||
run: |
|
||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||
echo "Check https://github.com/the-luap/picpeak to verify the mirror."
|
||||
@@ -14,6 +14,7 @@ jobs:
|
||||
outputs:
|
||||
new_version: ${{ steps.version.outputs.new_version }}
|
||||
version_changed: ${{ steps.version.outputs.version_changed }}
|
||||
component_changed: ${{ steps.version.outputs.component_changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
@@ -30,15 +31,104 @@ jobs:
|
||||
git config --global user.name 'Gitea Actions Bot'
|
||||
git config --global user.email 'actions@gitea.local'
|
||||
|
||||
- name: Bump version
|
||||
- name: Detect changes and bump version
|
||||
id: version
|
||||
run: |
|
||||
# Get current version from backend package.json
|
||||
CURRENT_VERSION=$(node -p "require('./backend/package.json').version")
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
set -e # Exit on error
|
||||
|
||||
# Split version into parts
|
||||
IFS='.' read -r -a version_parts <<< "$CURRENT_VERSION"
|
||||
echo "=== Debug Info ==="
|
||||
echo "GitHub event before: ${{ github.event.before }}"
|
||||
echo "GitHub SHA: ${{ github.sha }}"
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "Git log (last 5): $(git log --oneline -5)"
|
||||
|
||||
# Get the commit range for changed files
|
||||
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
|
||||
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
# First commit or no previous commit, check against HEAD~1 if it exists
|
||||
if git rev-parse HEAD~1 >/dev/null 2>&1; then
|
||||
COMMIT_RANGE="HEAD~1..HEAD"
|
||||
echo "Using commit range: $COMMIT_RANGE"
|
||||
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
|
||||
else
|
||||
echo "First commit detected, checking all files"
|
||||
CHANGED_FILES=$(git ls-files)
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
|
||||
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
|
||||
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
|
||||
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
|
||||
|
||||
echo "Backend files changed: $BACKEND_CHANGED"
|
||||
echo "Frontend files changed: $FRONTEND_CHANGED"
|
||||
echo "Root files changed: $ROOT_CHANGED"
|
||||
|
||||
# Get current versions
|
||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
|
||||
|
||||
echo "Current backend version: $BACKEND_VERSION"
|
||||
echo "Current frontend version: $FRONTEND_VERSION"
|
||||
|
||||
# Determine what to update based on changes
|
||||
BACKEND_UPDATE=false
|
||||
FRONTEND_UPDATE=false
|
||||
COMPONENT_CHANGED="none"
|
||||
|
||||
if [ "$ROOT_CHANGED" -gt 0 ]; then
|
||||
# Root changes affect both components
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Root changes detected - updating both components"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Both components changed
|
||||
BACKEND_UPDATE=true
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="both"
|
||||
# Use the higher version as source
|
||||
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
else
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
fi
|
||||
echo "Both backend and frontend changed - updating both"
|
||||
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
|
||||
# Only backend changed
|
||||
BACKEND_UPDATE=true
|
||||
COMPONENT_CHANGED="backend"
|
||||
SOURCE_VERSION=$BACKEND_VERSION
|
||||
echo "Only backend changed - updating backend"
|
||||
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
|
||||
# Only frontend changed
|
||||
FRONTEND_UPDATE=true
|
||||
COMPONENT_CHANGED="frontend"
|
||||
SOURCE_VERSION=$FRONTEND_VERSION
|
||||
echo "Only frontend changed - updating frontend"
|
||||
else
|
||||
echo "No relevant changes detected"
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=none" >> $GITHUB_OUTPUT
|
||||
echo "new_version=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Component changed: $COMPONENT_CHANGED"
|
||||
echo "Source version: $SOURCE_VERSION"
|
||||
echo "Backend update: $BACKEND_UPDATE"
|
||||
echo "Frontend update: $FRONTEND_UPDATE"
|
||||
|
||||
# Calculate new version
|
||||
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
|
||||
MAJOR="${version_parts[0]}"
|
||||
MINOR="${version_parts[1]}"
|
||||
PATCH="${version_parts[2]}"
|
||||
@@ -49,14 +139,23 @@ jobs:
|
||||
|
||||
echo "New version: $NEW_VERSION"
|
||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||
|
||||
# Update version in package.json files
|
||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ../frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
# Update versions in package.json files
|
||||
if [ "$BACKEND_UPDATE" = true ]; then
|
||||
echo "Updating backend version to $NEW_VERSION"
|
||||
cd backend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Check if there are changes
|
||||
if [[ -n $(git status -s) ]]; then
|
||||
if [ "$FRONTEND_UPDATE" = true ]; then
|
||||
echo "Updating frontend version to $NEW_VERSION"
|
||||
cd frontend && npm version $NEW_VERSION --no-git-tag-version
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Check if there are changes to commit
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "version_changed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version_changed=false" >> $GITHUB_OUTPUT
|
||||
@@ -65,15 +164,36 @@ jobs:
|
||||
- name: Commit version bump
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }}"
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
git add backend/package.json backend/package-lock.json
|
||||
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
|
||||
fi
|
||||
|
||||
git push
|
||||
|
||||
- name: Create Git tag
|
||||
if: steps.version.outputs.version_changed == 'true'
|
||||
run: |
|
||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "Release v${{ steps.version.outputs.new_version }}"
|
||||
COMPONENT="${{ steps.version.outputs.component_changed }}"
|
||||
|
||||
if [ "$COMPONENT" = "both" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
|
||||
elif [ "$COMPONENT" = "backend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
|
||||
elif [ "$COMPONENT" = "frontend" ]; then
|
||||
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
|
||||
fi
|
||||
|
||||
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
|
||||
git push origin "v${{ steps.version.outputs.new_version }}"
|
||||
|
||||
trigger-drone:
|
||||
@@ -84,5 +204,6 @@ jobs:
|
||||
- name: Trigger Drone Build
|
||||
run: |
|
||||
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
||||
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
||||
echo "Drone will automatically trigger on the new tag"
|
||||
# Drone CI will automatically trigger on the tag push event
|
||||
@@ -11,8 +11,7 @@
|
||||
|
||||
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
|
||||
|
||||

|
||||
> 📸 *Gallery preview will be updated soon with latest interface*
|
||||

|
||||
|
||||
## 🌟 Why Choose PicPeak?
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Add build argument for cache busting
|
||||
ARG CACHEBUST=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Fix email_queue table by ensuring it doesn't have updated_at column
|
||||
* This migration addresses the PostgreSQL error where queries are trying to update
|
||||
* a non-existent updated_at column
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// First, check if the column exists
|
||||
const hasUpdatedAt = await knex.schema.hasColumn('email_queue', 'updated_at');
|
||||
|
||||
if (hasUpdatedAt) {
|
||||
console.log('Found updated_at column in email_queue table, removing it...');
|
||||
await knex.schema.table('email_queue', (table) => {
|
||||
table.dropColumn('updated_at');
|
||||
});
|
||||
}
|
||||
|
||||
// Also ensure the table has all required columns
|
||||
const hasCreatedAt = await knex.schema.hasColumn('email_queue', 'created_at');
|
||||
if (!hasCreatedAt) {
|
||||
console.log('Adding missing created_at column to email_queue table...');
|
||||
await knex.schema.table('email_queue', (table) => {
|
||||
table.datetime('created_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
}
|
||||
|
||||
console.log('email_queue table schema fixed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// In the down migration, we don't add back updated_at since it shouldn't exist
|
||||
// This is intentionally left minimal
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
exports.up = async function(knex) {
|
||||
// Update gallery_created template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject_de: 'Ihre Fotogalerie ist bereit!',
|
||||
body_html_de: `<h2>Galerie erfolgreich erstellt</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!</p>
|
||||
{{#if welcome_message}}
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">Persönliche Nachricht:</p>
|
||||
<p style="margin: 0; color: #4b5563;">{{welcome_message}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
<p><strong>Galerie-Details:</strong></p>
|
||||
<ul>
|
||||
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||
<li>Galerie-Link: <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
|
||||
<li>Passwort: {{gallery_password}}</li>
|
||||
<li>Ablaufdatum: {{expiry_date}}</li>
|
||||
</ul>
|
||||
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.</p>
|
||||
<p style="background-color: #FEF3C7; padding: 15px; border-radius: 5px; border-left: 4px solid #F59E0B;">
|
||||
<strong>Wichtig:</strong> Diese Galerie läuft am {{expiry_date}} ab. Nach diesem Datum werden die Fotos archiviert und sind nicht mehr zugänglich.
|
||||
</p>
|
||||
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500; margin: 20px 0;">Galerie anzeigen</a>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Galerie erfolgreich erstellt
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!
|
||||
|
||||
{{#if welcome_message}}
|
||||
Persönliche Nachricht:
|
||||
{{welcome_message}}
|
||||
|
||||
{{/if}}
|
||||
Galerie-Details:
|
||||
- Veranstaltungsdatum: {{event_date}}
|
||||
- Galerie-Link: {{gallery_link}}
|
||||
- Passwort: {{gallery_password}}
|
||||
- Ablaufdatum: {{expiry_date}}
|
||||
|
||||
Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.
|
||||
|
||||
WICHTIG: Diese Galerie läuft am {{expiry_date}} ab. Nach diesem Datum werden die Fotos archiviert und sind nicht mehr zugänglich.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Update expiration_warning template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject_de: 'Ihre Fotogalerie läuft bald ab',
|
||||
body_html_de: `<h2>Galerie läuft bald ab</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" läuft in <strong>{{days_remaining}} Tagen</strong> ab.</p>
|
||||
<p>Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich. Bitte stellen Sie sicher, dass alle gewünschten Fotos heruntergeladen wurden.</p>
|
||||
<p><strong>Ablaufdatum:</strong> {{expiry_date}}</p>
|
||||
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500; margin: 20px 0;">Galerie jetzt besuchen</a>
|
||||
<p style="background-color: #FEE2E2; padding: 15px; border-radius: 5px; border-left: 4px solid #EF4444;">
|
||||
<strong>Erinnerung:</strong> Nach dem {{expiry_date}} können Ihre Gäste nicht mehr auf die Galerie zugreifen.
|
||||
</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Galerie läuft bald ab
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Ihre Fotogalerie "{{event_name}}" läuft in {{days_remaining}} Tagen ab.
|
||||
|
||||
Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich. Bitte stellen Sie sicher, dass alle gewünschten Fotos heruntergeladen wurden.
|
||||
|
||||
Ablaufdatum: {{expiry_date}}
|
||||
|
||||
Galerie-Link: {{gallery_link}}
|
||||
|
||||
ERINNERUNG: Nach dem {{expiry_date}} können Ihre Gäste nicht mehr auf die Galerie zugreifen.
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Update gallery_expired template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject_de: 'Ihre Fotogalerie {{event_name}} ist abgelaufen',
|
||||
body_html_de: `<h2>Galerie abgelaufen</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Ihre Fotogalerie "{{event_name}}" ist am {{expiry_date}} abgelaufen und wurde archiviert.</p>
|
||||
<p>Die Galerie ist nicht mehr für Gäste zugänglich. Alle Fotos wurden sicher in unserem Archivsystem gespeichert.</p>
|
||||
<p>Wenn Sie wieder Zugriff auf die archivierten Fotos benötigen, wenden Sie sich bitte an unseren Support:</p>
|
||||
<p style="background-color: #F3F4F6; padding: 15px; border-radius: 5px;">
|
||||
<strong>Kontakt:</strong><br>
|
||||
E-Mail: <a href="mailto:{{admin_email}}" style="color: #5C8762;">{{admin_email}}</a><br>
|
||||
{{#if support_phone}}Telefon: {{support_phone}}{{/if}}
|
||||
</p>
|
||||
<p>Vielen Dank für die Nutzung unseres Foto-Sharing-Services!</p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Galerie abgelaufen
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Ihre Fotogalerie "{{event_name}}" ist am {{expiry_date}} abgelaufen und wurde archiviert.
|
||||
|
||||
Die Galerie ist nicht mehr für Gäste zugänglich. Alle Fotos wurden sicher in unserem Archivsystem gespeichert.
|
||||
|
||||
Wenn Sie wieder Zugriff auf die archivierten Fotos benötigen, wenden Sie sich bitte an unseren Support:
|
||||
|
||||
E-Mail: {{admin_email}}
|
||||
{{#if support_phone}}Telefon: {{support_phone}}{{/if}}
|
||||
|
||||
Vielen Dank für die Nutzung unseres Foto-Sharing-Services!
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Update archive_complete template with proper German translation
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject_de: 'Archivierung abgeschlossen: {{event_name}}',
|
||||
body_html_de: `<h2>Archivierung abgeschlossen</h2>
|
||||
<p>Liebe(r) {{host_name}},</p>
|
||||
<p>Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.</p>
|
||||
<p><strong>Archiv-Details:</strong></p>
|
||||
<ul>
|
||||
<li>Archivgröße: {{archive_size}}</li>
|
||||
<li>Archivierungsdatum: {{archive_date}}</li>
|
||||
<li>Anzahl der Fotos: {{photo_count}}</li>
|
||||
</ul>
|
||||
<p>Das Archiv wird sicher in unserem System aufbewahrt. Bei Bedarf können Sie sich an unseren Support wenden, um Zugriff auf die archivierten Fotos zu erhalten.</p>
|
||||
<p style="background-color: #F0FDF4; padding: 15px; border-radius: 5px; border-left: 4px solid #22C55E;">
|
||||
<strong>✓ Erfolgreich archiviert:</strong> Ihre Fotos sind sicher gespeichert und können bei Bedarf wiederhergestellt werden.
|
||||
</p>
|
||||
<p>Kontakt für Archivzugriff:<br>
|
||||
E-Mail: <a href="mailto:{{admin_email}}" style="color: #5C8762;">{{admin_email}}</a></p>
|
||||
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
|
||||
body_text_de: `Archivierung abgeschlossen
|
||||
|
||||
Liebe(r) {{host_name}},
|
||||
|
||||
Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.
|
||||
|
||||
Archiv-Details:
|
||||
- Archivgröße: {{archive_size}}
|
||||
- Archivierungsdatum: {{archive_date}}
|
||||
- Anzahl der Fotos: {{photo_count}}
|
||||
|
||||
Das Archiv wird sicher in unserem System aufbewahrt. Bei Bedarf können Sie sich an unseren Support wenden, um Zugriff auf die archivierten Fotos zu erhalten.
|
||||
|
||||
✓ ERFOLGREICH ARCHIVIERT: Ihre Fotos sind sicher gespeichert und können bei Bedarf wiederhergestellt werden.
|
||||
|
||||
Kontakt für Archivzugriff:
|
||||
E-Mail: {{admin_email}}
|
||||
|
||||
Mit freundlichen Grüßen,
|
||||
Ihr Foto-Sharing-Team`
|
||||
});
|
||||
|
||||
// Also update the non-language-specific fields to match German for consistency
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject: knex.raw('subject_de'),
|
||||
body_html: knex.raw('body_html_de'),
|
||||
body_text: knex.raw('body_text_de')
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Revert to previous German translations
|
||||
// This is a simplified rollback - in production you might want to store the old values
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject: knex.raw('subject_en'),
|
||||
body_html: knex.raw('body_html_en'),
|
||||
body_text: knex.raw('body_text_en')
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add language column to events table if it doesn't exist
|
||||
const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language');
|
||||
if (!hasLanguageInEvents) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.string('language', 5).defaultTo('en');
|
||||
});
|
||||
}
|
||||
|
||||
// Add default_language to email_configs if it doesn't exist
|
||||
const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language');
|
||||
if (!hasDefaultLanguage) {
|
||||
await knex.schema.alterTable('email_configs', function(table) {
|
||||
table.string('default_language', 5).defaultTo('en');
|
||||
});
|
||||
}
|
||||
|
||||
// Set default language to German for the existing email config
|
||||
await knex('email_configs')
|
||||
.update({
|
||||
default_language: 'de'
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove language column from events table
|
||||
const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language');
|
||||
if (hasLanguageInEvents) {
|
||||
await knex.schema.alterTable('events', function(table) {
|
||||
table.dropColumn('language');
|
||||
});
|
||||
}
|
||||
|
||||
// Remove default_language from email_configs
|
||||
const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language');
|
||||
if (hasDefaultLanguage) {
|
||||
await knex.schema.alterTable('email_configs', function(table) {
|
||||
table.dropColumn('default_language');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,307 @@
|
||||
exports.up = async function(knex) {
|
||||
// Update English templates to match the quality and content of German templates
|
||||
|
||||
// 1. Gallery Created - Match German version with proper styling and conditionals
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject_en: 'Your photo gallery is ready',
|
||||
body_html_en: `
|
||||
<h2>Hello {{host_name}},</h2>
|
||||
|
||||
<p>Your photo gallery <strong>{{event_name}}</strong> for {{event_date}} has been successfully created and is now online!</p>
|
||||
|
||||
{{#if welcome_message}}
|
||||
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0;"><strong>Personal message from your photographer:</strong></p>
|
||||
<p style="margin: 10px 0 0 0;">{{welcome_message}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0;">Your access data:</h3>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
<li style="margin-bottom: 10px;"><strong>Gallery link:</strong> <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
|
||||
<li style="margin-bottom: 10px;"><strong>Password:</strong> {{gallery_password}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500;">View Gallery</a>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>Important:</strong> Your gallery will be available until <strong>{{expiry_date}}</strong>. After this date, the photos will be archived and will only be available upon request.</p>
|
||||
</div>
|
||||
|
||||
<p>We hope you enjoy your photos!</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your Photo Sharing Team</p>`,
|
||||
body_text_en: `Hello {{host_name}},
|
||||
|
||||
Your photo gallery "{{event_name}}" for {{event_date}} has been successfully created and is now online!
|
||||
|
||||
{{#if welcome_message}}
|
||||
Personal message from your photographer:
|
||||
{{welcome_message}}
|
||||
{{/if}}
|
||||
|
||||
Your access data:
|
||||
- Gallery link: {{gallery_link}}
|
||||
- Password: {{gallery_password}}
|
||||
|
||||
Important: Your gallery will be available until {{expiry_date}}. After this date, the photos will be archived and will only be available upon request.
|
||||
|
||||
We hope you enjoy your photos!
|
||||
|
||||
Best regards,
|
||||
Your Photo Sharing Team`
|
||||
});
|
||||
|
||||
// 2. Expiration Warning - Match German version with urgency and styling
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject_en: 'Your photo gallery expires soon',
|
||||
body_html_en: `
|
||||
<h2>Hello {{host_name}},</h2>
|
||||
|
||||
<p>Your photo gallery <strong>{{event_name}}</strong> will expire in <strong style="color: #e74c3c; font-size: 18px;">{{days_remaining}} days</strong>!</p>
|
||||
|
||||
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0; font-weight: bold; font-size: 16px;">⚠️ Important Notice</p>
|
||||
<p style="margin: 10px 0 0 0;">After {{expiry_date}}, your gallery will no longer be accessible online. The photos will be archived and will only be available upon special request.</p>
|
||||
</div>
|
||||
|
||||
<p><strong>Don't miss out – download your photos now!</strong></p>
|
||||
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{gallery_link}}" style="display: inline-block; padding: 14px 35px; background-color: #e74c3c; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Visit Gallery Now</a>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #f9f9f9; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>Quick reminder of your access data:</strong></p>
|
||||
<ul style="list-style: none; padding: 0; margin: 10px 0 0 0;">
|
||||
<li>Gallery link: <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
|
||||
<li>Password: {{gallery_password}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>If you have any questions, please don't hesitate to contact us.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your Photo Sharing Team</p>`,
|
||||
body_text_en: `Hello {{host_name}},
|
||||
|
||||
Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days!
|
||||
|
||||
⚠️ Important Notice
|
||||
After {{expiry_date}}, your gallery will no longer be accessible online. The photos will be archived and will only be available upon special request.
|
||||
|
||||
Don't miss out – download your photos now!
|
||||
|
||||
Quick reminder of your access data:
|
||||
- Gallery link: {{gallery_link}}
|
||||
- Password: {{gallery_password}}
|
||||
|
||||
If you have any questions, please don't hesitate to contact us.
|
||||
|
||||
Best regards,
|
||||
Your Photo Sharing Team`
|
||||
});
|
||||
|
||||
// 3. Gallery Expired - Match German version with contact information
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject_en: 'Your photo gallery has expired',
|
||||
body_html_en: `
|
||||
<h2>Hello {{host_name}},</h2>
|
||||
|
||||
<p>Your photo gallery <strong>{{event_name}}</strong> expired on {{expiry_date}} and is no longer accessible online.</p>
|
||||
|
||||
<div style="background-color: #f9f9f9; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
|
||||
<h3 style="margin-top: 0;">Your photos are safely archived</h3>
|
||||
<p>Don't worry – your photos have been securely archived and are not lost. If you need access to your photos, please contact us:</p>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
<li style="margin-bottom: 8px;">📧 Email: <a href="mailto:{{support_email}}" style="color: #5C8762;">{{support_email}}</a></li>
|
||||
{{#if support_phone}}
|
||||
<li>📞 Phone: {{support_phone}}</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>Please have the following information ready when contacting us:</p>
|
||||
<ul>
|
||||
<li>Event name: {{event_name}}</li>
|
||||
<li>Event date: {{event_date}}</li>
|
||||
<li>Expiry date: {{expiry_date}}</li>
|
||||
</ul>
|
||||
|
||||
<p>We'll be happy to help you access your archived photos.</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your Photo Sharing Team</p>`,
|
||||
body_text_en: `Hello {{host_name}},
|
||||
|
||||
Your photo gallery "{{event_name}}" expired on {{expiry_date}} and is no longer accessible online.
|
||||
|
||||
Your photos are safely archived
|
||||
Don't worry – your photos have been securely archived and are not lost. If you need access to your photos, please contact us:
|
||||
|
||||
📧 Email: {{support_email}}
|
||||
{{#if support_phone}}📞 Phone: {{support_phone}}{{/if}}
|
||||
|
||||
Please have the following information ready when contacting us:
|
||||
- Event name: {{event_name}}
|
||||
- Event date: {{event_date}}
|
||||
- Expiry date: {{expiry_date}}
|
||||
|
||||
We'll be happy to help you access your archived photos.
|
||||
|
||||
Best regards,
|
||||
Your Photo Sharing Team`
|
||||
});
|
||||
|
||||
// 4. Archive Complete - Match German version with success message and details
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject_en: 'Your photo gallery has been successfully archived',
|
||||
body_html_en: `
|
||||
<h2>Hello {{host_name}},</h2>
|
||||
|
||||
<p>Your photo gallery <strong>{{event_name}}</strong> has been successfully archived.</p>
|
||||
|
||||
<div style="background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0; font-weight: bold;">✅ Archive successfully created</p>
|
||||
<p style="margin: 10px 0 0 0;">Your photos are now safely stored in our archive.</p>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<h3 style="margin-top: 0;">Archive details:</h3>
|
||||
<ul style="list-style: none; padding: 0;">
|
||||
<li style="margin-bottom: 8px;"><strong>Event:</strong> {{event_name}}</li>
|
||||
<li style="margin-bottom: 8px;"><strong>Archive date:</strong> {{archive_date}}</li>
|
||||
<li style="margin-bottom: 8px;"><strong>Number of photos:</strong> {{photo_count}}</li>
|
||||
<li><strong>Archive size:</strong> {{archive_size}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>If you need access to your archived photos in the future, please contact us at:</p>
|
||||
<p style="margin-left: 20px;">
|
||||
📧 <a href="mailto:{{support_email}}" style="color: #5C8762;">{{support_email}}</a><br>
|
||||
{{#if support_phone}}📞 {{support_phone}}{{/if}}
|
||||
</p>
|
||||
|
||||
<p>Thank you for using our photo sharing service!</p>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your Photo Sharing Team</p>`,
|
||||
body_text_en: `Hello {{host_name}},
|
||||
|
||||
Your photo gallery "{{event_name}}" has been successfully archived.
|
||||
|
||||
✅ Archive successfully created
|
||||
Your photos are now safely stored in our archive.
|
||||
|
||||
Archive details:
|
||||
- Event: {{event_name}}
|
||||
- Archive date: {{archive_date}}
|
||||
- Number of photos: {{photo_count}}
|
||||
- Archive size: {{archive_size}}
|
||||
|
||||
If you need access to your archived photos in the future, please contact us at:
|
||||
📧 {{support_email}}
|
||||
{{#if support_phone}}📞 {{support_phone}}{{/if}}
|
||||
|
||||
Thank you for using our photo sharing service!
|
||||
|
||||
Best regards,
|
||||
Your Photo Sharing Team`
|
||||
});
|
||||
|
||||
// 5. Test Email - Update to match German style
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'test_email')
|
||||
.update({
|
||||
subject_en: 'Test Email - Photo Sharing Platform',
|
||||
body_html_en: `
|
||||
<h2>Test Email</h2>
|
||||
|
||||
<p>This is a test email from your photo sharing platform.</p>
|
||||
|
||||
<div style="background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>✅ Email configuration successful!</strong></p>
|
||||
<p style="margin: 10px 0 0 0;">Your email settings have been configured correctly and emails can be sent.</p>
|
||||
</div>
|
||||
|
||||
<div style="background-color: #f9f9f9; padding: 15px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0;"><strong>Configuration details:</strong></p>
|
||||
<ul style="margin: 10px 0 0 0;">
|
||||
<li>Timestamp: {{timestamp}}</li>
|
||||
<li>Sender: {{from_email}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p>Best regards,<br>
|
||||
Your Photo Sharing Team</p>`,
|
||||
body_text_en: `Test Email
|
||||
|
||||
This is a test email from your photo sharing platform.
|
||||
|
||||
✅ Email configuration successful!
|
||||
Your email settings have been configured correctly and emails can be sent.
|
||||
|
||||
Configuration details:
|
||||
- Timestamp: {{timestamp}}
|
||||
- Sender: {{from_email}}
|
||||
|
||||
Best regards,
|
||||
Your Photo Sharing Team`
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Revert to previous simpler English templates
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_created')
|
||||
.update({
|
||||
subject_en: 'Your Photo Gallery is Ready',
|
||||
body_html_en: '<h2>Hello,</h2><p>Your photo gallery "{{event_name}}" has been created.</p><p><strong>Access Link:</strong> <a href="{{gallery_link}}">{{gallery_link}}</a></p><p><strong>Password:</strong> {{gallery_password}}</p><p>The gallery will be available until {{expiry_date}}.</p>',
|
||||
body_text_en: 'Your photo gallery "{{event_name}}" has been created. Access Link: {{gallery_link}} Password: {{gallery_password}} The gallery will be available until {{expiry_date}}.'
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'expiration_warning')
|
||||
.update({
|
||||
subject_en: 'Gallery Expires in {{days_remaining}} Days',
|
||||
body_html_en: '<h2>Reminder</h2><p>Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.</p><p>Please download your photos before {{expiry_date}}.</p><p><a href="{{gallery_link}}">Access Gallery</a></p>',
|
||||
body_text_en: 'Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days. Please download your photos before {{expiry_date}}. Access Gallery: {{gallery_link}}'
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'gallery_expired')
|
||||
.update({
|
||||
subject_en: 'Gallery Expired',
|
||||
body_html_en: '<h2>Gallery Expired</h2><p>Your photo gallery "{{event_name}}" has expired and is no longer accessible.</p><p>If you need access to your photos, please contact support.</p>',
|
||||
body_text_en: 'Your photo gallery "{{event_name}}" has expired and is no longer accessible. If you need access to your photos, please contact support.'
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'archive_complete')
|
||||
.update({
|
||||
subject_en: 'Gallery Archived',
|
||||
body_html_en: '<h2>Archive Complete</h2><p>Your gallery "{{event_name}}" has been archived.</p><p>Archive size: {{archive_size}}</p>',
|
||||
body_text_en: 'Your gallery "{{event_name}}" has been archived. Archive size: {{archive_size}}'
|
||||
});
|
||||
|
||||
await knex('email_templates')
|
||||
.where('template_key', 'test_email')
|
||||
.update({
|
||||
subject_en: 'Test Email',
|
||||
body_html_en: '<p>This is a test email sent at {{timestamp}}.</p>',
|
||||
body_text_en: 'This is a test email sent at {{timestamp}}.'
|
||||
});
|
||||
};
|
||||
Generated
+49
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.58",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.58",
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
@@ -19,6 +19,7 @@
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-validator": "^7.0.1",
|
||||
"form-data": "^4.0.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "^25.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
@@ -3995,6 +3996,27 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/handlebars": {
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
"neo-async": "^2.6.2",
|
||||
"source-map": "^0.6.1",
|
||||
"wordwrap": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"handlebars": "bin/handlebars"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.4.7"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"uglify-js": "^3.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -5985,6 +6007,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/neo-async": {
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.75.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
|
||||
@@ -7606,7 +7634,6 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -8161,6 +8188,19 @@
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"uglifyjs": "bin/uglifyjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
@@ -8412,6 +8452,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wordwrap": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.58",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"express-rate-limit": "^6.7.0",
|
||||
"express-validator": "^7.0.1",
|
||||
"form-data": "^4.0.3",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.0.0",
|
||||
"i18next": "^25.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
# Storage Management Scripts
|
||||
|
||||
## check-storage.js
|
||||
|
||||
Checks the storage directory structure and verifies that photo files exist.
|
||||
|
||||
### Usage:
|
||||
```bash
|
||||
# Check overall storage structure
|
||||
node scripts/check-storage.js
|
||||
|
||||
# Check specific event
|
||||
node scripts/check-storage.js wedding-test-gallery-2025-07-14-1
|
||||
```
|
||||
|
||||
### What it checks:
|
||||
- Storage directory structure and permissions
|
||||
- Event directories
|
||||
- Photo files existence
|
||||
- Thumbnail files existence
|
||||
- Database vs filesystem consistency
|
||||
|
||||
## regenerate-thumbnails.js
|
||||
|
||||
Regenerates missing thumbnails for photos in the database.
|
||||
|
||||
### Usage:
|
||||
```bash
|
||||
# Regenerate all missing thumbnails
|
||||
node scripts/regenerate-thumbnails.js
|
||||
|
||||
# Regenerate thumbnails for specific event (by ID)
|
||||
node scripts/regenerate-thumbnails.js 2
|
||||
```
|
||||
|
||||
### What it does:
|
||||
- Scans photos in database
|
||||
- Checks if thumbnails exist
|
||||
- Generates missing thumbnails using Sharp
|
||||
- Updates database with thumbnail paths
|
||||
- Reports success/error statistics
|
||||
|
||||
### Prerequisites:
|
||||
- Node.js environment
|
||||
- Database access
|
||||
- Write permissions to storage directory
|
||||
- Sharp library installed
|
||||
|
||||
## Production Usage
|
||||
|
||||
On your production server:
|
||||
|
||||
1. First, check the storage structure:
|
||||
```bash
|
||||
cd /path/to/picpeak/backend
|
||||
NODE_ENV=production node scripts/check-storage.js wedding-test-gallery-2025-07-14-1
|
||||
```
|
||||
|
||||
2. If thumbnails are missing, regenerate them:
|
||||
```bash
|
||||
NODE_ENV=production node scripts/regenerate-thumbnails.js 2
|
||||
```
|
||||
|
||||
Note: Replace `2` with the actual event ID from your database.
|
||||
|
||||
## cleanup-thumbnails.js
|
||||
|
||||
Cleans up temporary and orphaned thumbnail files.
|
||||
|
||||
### Usage:
|
||||
```bash
|
||||
# Dry run - see what would be deleted
|
||||
node scripts/cleanup-thumbnails.js --dry-run
|
||||
|
||||
# Actually delete orphaned thumbnails
|
||||
node scripts/cleanup-thumbnails.js
|
||||
```
|
||||
|
||||
### What it does:
|
||||
- Identifies temporary thumbnails (thumb_temp_*)
|
||||
- Finds orphaned thumbnails not linked to any photo
|
||||
- Removes unnecessary files to free up space
|
||||
- Reports statistics on cleanup
|
||||
|
||||
## diagnose-thumbnails.js
|
||||
|
||||
Diagnoses why thumbnails might not be showing for a specific event.
|
||||
|
||||
### Usage:
|
||||
```bash
|
||||
node scripts/diagnose-thumbnails.js 2
|
||||
```
|
||||
|
||||
### What it checks:
|
||||
- Thumbnail paths in database vs filesystem
|
||||
- Path format inconsistencies
|
||||
- Missing thumbnail files
|
||||
- Provides SQL to fix path issues
|
||||
|
||||
### Common Issues:
|
||||
1. **Path mismatch**: Database has wrong thumbnail path format
|
||||
2. **Missing files**: Thumbnails were never generated
|
||||
3. **Permission issues**: Web server can't read thumbnail files
|
||||
@@ -0,0 +1,131 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function checkEmailEnvironment() {
|
||||
console.log('=== Email Environment Check ===\n');
|
||||
|
||||
// 1. Check environment variables
|
||||
console.log('1. Environment Variables:');
|
||||
const envVars = [
|
||||
'SMTP_HOST',
|
||||
'SMTP_PORT',
|
||||
'SMTP_USER',
|
||||
'SMTP_PASS',
|
||||
'SMTP_FROM',
|
||||
'SMTP_SECURE',
|
||||
'EMAIL_PROCESSOR_ENABLED',
|
||||
'NODE_ENV'
|
||||
];
|
||||
|
||||
envVars.forEach(varName => {
|
||||
const value = process.env[varName];
|
||||
if (varName.includes('PASS')) {
|
||||
console.log(` ${varName}: ${value ? '***' : 'NOT SET'}`);
|
||||
} else {
|
||||
console.log(` ${varName}: ${value || 'NOT SET'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Check database configuration
|
||||
console.log('\n2. Database Email Configuration:');
|
||||
try {
|
||||
const emailConfig = await db('email_configs').first();
|
||||
if (emailConfig) {
|
||||
console.log(' Email configuration found in database:');
|
||||
console.log(` - SMTP Host: ${emailConfig.smtp_host}`);
|
||||
console.log(` - SMTP Port: ${emailConfig.smtp_port}`);
|
||||
console.log(` - SMTP User: ${emailConfig.smtp_user || 'NOT SET'}`);
|
||||
console.log(` - SMTP Secure: ${emailConfig.smtp_secure}`);
|
||||
console.log(` - From Address: ${emailConfig.smtp_from}`);
|
||||
} else {
|
||||
console.log(' ⚠️ No email configuration found in database!');
|
||||
console.log(' This will prevent the email processor from initializing.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error reading email configuration: ${error.message}`);
|
||||
}
|
||||
|
||||
// 3. Check if the email processor should be disabled
|
||||
console.log('\n3. Email Processor Status:');
|
||||
const isDisabled = process.env.EMAIL_PROCESSOR_ENABLED === 'false';
|
||||
if (isDisabled) {
|
||||
console.log(' ⚠️ Email processor is DISABLED via EMAIL_PROCESSOR_ENABLED=false');
|
||||
} else {
|
||||
console.log(' ✅ Email processor is enabled (default)');
|
||||
}
|
||||
|
||||
// 4. Check pending emails
|
||||
console.log('\n4. Email Queue Status:');
|
||||
try {
|
||||
const pending = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
const failed = await db('email_queue')
|
||||
.where('status', 'failed')
|
||||
.where('retry_count', '>=', 3)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
const sent = await db('email_queue')
|
||||
.where('status', 'sent')
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
console.log(` - Pending emails: ${pending.count}`);
|
||||
console.log(` - Failed emails (max retries): ${failed.count}`);
|
||||
console.log(` - Sent emails: ${sent.count}`);
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error querying email queue: ${error.message}`);
|
||||
}
|
||||
|
||||
// 5. Test database connection
|
||||
console.log('\n5. Database Connection:');
|
||||
try {
|
||||
await db.raw('SELECT 1');
|
||||
console.log(' ✅ Database connection successful');
|
||||
} catch (error) {
|
||||
console.log(` ❌ Database connection failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// 6. Check for any recent errors
|
||||
console.log('\n6. Recent Email Errors:');
|
||||
try {
|
||||
const recentErrors = await db('email_queue')
|
||||
.whereNotNull('error_message')
|
||||
.orderBy('id', 'desc')
|
||||
.limit(3)
|
||||
.select('id', 'email_type', 'error_message', 'retry_count');
|
||||
|
||||
if (recentErrors.length > 0) {
|
||||
recentErrors.forEach((email, index) => {
|
||||
console.log(` ${index + 1}. Email ID ${email.id} (${email.email_type}):`);
|
||||
console.log(` Retries: ${email.retry_count}`);
|
||||
console.log(` Error: ${email.error_message}`);
|
||||
});
|
||||
} else {
|
||||
console.log(' No recent errors found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error querying recent errors: ${error.message}`);
|
||||
}
|
||||
|
||||
console.log('\n=== Environment check complete ===');
|
||||
console.log('\nRecommendations:');
|
||||
|
||||
const emailConfig = await db('email_configs').first().catch(() => null);
|
||||
if (!emailConfig) {
|
||||
console.log('❗ Configure email settings in the admin panel or add email_configs record');
|
||||
}
|
||||
|
||||
if (!process.env.SMTP_HOST && !emailConfig) {
|
||||
console.log('❗ Set SMTP environment variables or configure in database');
|
||||
}
|
||||
|
||||
await db.destroy();
|
||||
}
|
||||
|
||||
checkEmailEnvironment().catch(error => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function checkEmailProcessor() {
|
||||
try {
|
||||
logger.info('=== Email Processor Diagnostic Check ===\n');
|
||||
|
||||
// 1. Check pending emails
|
||||
logger.info('1. Checking pending emails in queue...');
|
||||
const pendingEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.orderBy('created_at', 'asc');
|
||||
|
||||
logger.info(`Found ${pendingEmails.length} pending emails\n`);
|
||||
|
||||
if (pendingEmails.length > 0) {
|
||||
logger.info('Pending email details:');
|
||||
pendingEmails.forEach((email, index) => {
|
||||
logger.info(`\nEmail ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Event ID: ${email.event_id}`);
|
||||
logger.info(` Status: ${email.status}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}`);
|
||||
logger.info(` Scheduled At: ${email.scheduled_at}`);
|
||||
logger.info(` Created At: ${email.created_at}`);
|
||||
logger.info(` Error: ${email.error_message || 'None'}`);
|
||||
|
||||
// Check if email_data needs parsing
|
||||
logger.info(` Email Data Type: ${typeof email.email_data}`);
|
||||
if (email.email_data) {
|
||||
try {
|
||||
const data = typeof email.email_data === 'string'
|
||||
? JSON.parse(email.email_data)
|
||||
: email.email_data;
|
||||
logger.info(` Email Data Keys: ${Object.keys(data).join(', ')}`);
|
||||
} catch (e) {
|
||||
logger.error(` Failed to parse email_data: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Check failed emails
|
||||
logger.info('\n\n2. Checking failed emails...');
|
||||
const failedEmails = await db('email_queue')
|
||||
.where('status', 'failed')
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(5);
|
||||
|
||||
logger.info(`Found ${failedEmails.length} failed emails (showing last 5)\n`);
|
||||
|
||||
if (failedEmails.length > 0) {
|
||||
failedEmails.forEach((email, index) => {
|
||||
logger.info(`\nFailed Email ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}`);
|
||||
logger.info(` Error: ${email.error_message || 'No error message'}`);
|
||||
logger.info(` Last Attempt: ${email.sent_at || 'Never'}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Check if email processor should be running
|
||||
logger.info('\n\n3. Checking email processor configuration...');
|
||||
|
||||
// Check environment variables
|
||||
const emailConfig = {
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_FROM: process.env.SMTP_FROM,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
EMAIL_PROCESSOR_ENABLED: process.env.EMAIL_PROCESSOR_ENABLED || 'true'
|
||||
};
|
||||
|
||||
logger.info('Email configuration:');
|
||||
Object.entries(emailConfig).forEach(([key, value]) => {
|
||||
if (key === 'SMTP_USER') {
|
||||
logger.info(` ${key}: ${value ? '***' : 'NOT SET'}`);
|
||||
} else {
|
||||
logger.info(` ${key}: ${value || 'NOT SET'}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Test email processor functionality
|
||||
logger.info('\n\n4. Testing email processor functionality...');
|
||||
|
||||
// Import the email processor
|
||||
const { processEmailQueue, testEmailConnection } = require('../src/services/emailProcessor');
|
||||
|
||||
// Test email connection
|
||||
logger.info('Testing email connection...');
|
||||
try {
|
||||
const connectionTest = await testEmailConnection();
|
||||
logger.info(`Email connection test: ${connectionTest ? 'SUCCESS' : 'FAILED'}`);
|
||||
} catch (error) {
|
||||
logger.error(`Email connection test failed: ${error.message}`);
|
||||
}
|
||||
|
||||
// Try to process queue once manually
|
||||
if (pendingEmails.length > 0) {
|
||||
logger.info('\n\n5. Attempting to process email queue manually...');
|
||||
try {
|
||||
await processEmailQueue();
|
||||
logger.info('Manual queue processing completed');
|
||||
|
||||
// Check status after processing
|
||||
const stillPending = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
logger.info(`Emails still pending after processing: ${stillPending.count}`);
|
||||
} catch (error) {
|
||||
logger.error(`Error processing queue: ${error.message}`);
|
||||
logger.error(`Stack trace: ${error.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Check for any recent successful emails
|
||||
logger.info('\n\n6. Checking recent successful emails...');
|
||||
const recentSuccess = await db('email_queue')
|
||||
.where('status', 'sent')
|
||||
.orderBy('sent_at', 'desc')
|
||||
.limit(3);
|
||||
|
||||
if (recentSuccess.length > 0) {
|
||||
logger.info(`Last ${recentSuccess.length} successful emails:`);
|
||||
recentSuccess.forEach((email, index) => {
|
||||
logger.info(` ${index + 1}. Type: ${email.email_type}, Sent: ${email.sent_at}`);
|
||||
});
|
||||
} else {
|
||||
logger.info('No successfully sent emails found');
|
||||
}
|
||||
|
||||
logger.info('\n\n=== Diagnostic check complete ===');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error running diagnostic check:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the check
|
||||
checkEmailProcessor();
|
||||
@@ -0,0 +1,66 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function checkEmailTemplates() {
|
||||
try {
|
||||
console.log('=== Email Templates Check ===\n');
|
||||
|
||||
// 1. Check table columns
|
||||
console.log('1. Checking email_templates table structure...');
|
||||
|
||||
// Check which columns exist
|
||||
const columnChecks = [
|
||||
'subject', 'subject_en', 'subject_de',
|
||||
'body_html', 'body_html_en', 'body_html_de',
|
||||
'body_text', 'body_text_en', 'body_text_de'
|
||||
];
|
||||
|
||||
const existingColumns = [];
|
||||
for (const col of columnChecks) {
|
||||
const exists = await db.schema.hasColumn('email_templates', col);
|
||||
if (exists) existingColumns.push(col);
|
||||
}
|
||||
|
||||
console.log(' Existing columns:', existingColumns.join(', '));
|
||||
|
||||
// 2. Get all templates
|
||||
console.log('\n2. Current email templates:');
|
||||
const templates = await db('email_templates').select('*');
|
||||
|
||||
for (const template of templates) {
|
||||
console.log(`\n Template: ${template.template_key}`);
|
||||
console.log(' -------------------');
|
||||
|
||||
// Check which fields have content
|
||||
const fields = ['subject', 'subject_en', 'subject_de',
|
||||
'body_html', 'body_html_en', 'body_html_de',
|
||||
'body_text', 'body_text_en', 'body_text_de'];
|
||||
|
||||
for (const field of fields) {
|
||||
if (template[field]) {
|
||||
const preview = template[field].substring(0, 50) + '...';
|
||||
console.log(` ${field}: ${preview}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for German translations
|
||||
const hasGermanSubject = template.subject_de || template.body_html_de;
|
||||
console.log(` Has German translation: ${hasGermanSubject ? 'YES' : 'NO'}`);
|
||||
}
|
||||
|
||||
// 3. Summary
|
||||
console.log('\n3. Summary:');
|
||||
const totalTemplates = templates.length;
|
||||
const templatesWithGerman = templates.filter(t => t.subject_de || t.body_html_de).length;
|
||||
console.log(` Total templates: ${totalTemplates}`);
|
||||
console.log(` Templates with German: ${templatesWithGerman}`);
|
||||
console.log(` Missing German: ${totalTemplates - templatesWithGerman}`);
|
||||
|
||||
await db.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkEmailTemplates();
|
||||
@@ -0,0 +1,53 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function checkGermanTemplates() {
|
||||
try {
|
||||
console.log('=== German Email Template Content Check ===\n');
|
||||
|
||||
const templates = await db('email_templates').select('*');
|
||||
|
||||
for (const template of templates) {
|
||||
console.log(`\nTemplate: ${template.template_key}`);
|
||||
console.log('=====================================');
|
||||
|
||||
// Check German subject
|
||||
console.log('\nGERMAN SUBJECT:');
|
||||
console.log(template.subject_de || 'MISSING');
|
||||
|
||||
// Check if German HTML body has English content
|
||||
console.log('\nGERMAN HTML BODY:');
|
||||
const germanHtml = template.body_html_de || '';
|
||||
|
||||
// Check for English phrases in German template
|
||||
const englishPhrases = [
|
||||
'Dear', 'Gallery', 'has been', 'Your photo', 'successfully',
|
||||
'Details:', 'Link:', 'Password:', 'Expires:', 'Event Date:',
|
||||
'Thank you', 'Best regards', 'View Gallery', 'days'
|
||||
];
|
||||
|
||||
const foundEnglish = englishPhrases.filter(phrase =>
|
||||
germanHtml.toLowerCase().includes(phrase.toLowerCase())
|
||||
);
|
||||
|
||||
if (foundEnglish.length > 0) {
|
||||
console.log('⚠️ Found English phrases in German template:', foundEnglish.join(', '));
|
||||
}
|
||||
|
||||
// Show first 500 chars of German HTML
|
||||
console.log(germanHtml.substring(0, 500) + '...\n');
|
||||
|
||||
// Check German text body
|
||||
console.log('GERMAN TEXT BODY:');
|
||||
const germanText = template.body_text_de || '';
|
||||
console.log(germanText.substring(0, 300) + '...\n');
|
||||
}
|
||||
|
||||
await db.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkGermanTemplates();
|
||||
@@ -0,0 +1,146 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function debugEmailQueue() {
|
||||
try {
|
||||
logger.info('=== Email Queue Debug Report ===\n');
|
||||
|
||||
// 1. Count exactly like the admin dashboard does
|
||||
logger.info('1. Admin Dashboard Query (ALL pending, no retry filter):');
|
||||
const [adminCount] = await db('email_queue').where('status', 'pending').count('* as count');
|
||||
logger.info(` Pending emails (admin dashboard view): ${adminCount.count}\n`);
|
||||
|
||||
// 2. Count like the email processor does
|
||||
logger.info('2. Email Processor Query (pending with retry_count < 3):');
|
||||
const [processorCount] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count');
|
||||
logger.info(` Pending emails (processor view): ${processorCount.count}\n`);
|
||||
|
||||
// 3. Show the discrepancy
|
||||
logger.info('3. Discrepancy Analysis:');
|
||||
if (adminCount.count !== processorCount.count) {
|
||||
logger.info(` ⚠️ DISCREPANCY FOUND!`);
|
||||
logger.info(` Admin shows: ${adminCount.count}`);
|
||||
logger.info(` Processor will process: ${processorCount.count}`);
|
||||
logger.info(` Difference: ${adminCount.count - processorCount.count} email(s)\n`);
|
||||
|
||||
// Find the problematic emails
|
||||
logger.info('4. Emails with retry_count >= 3 (still pending):');
|
||||
const stuckEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '>=', 3)
|
||||
.select('*');
|
||||
|
||||
if (stuckEmails.length > 0) {
|
||||
logger.info(` Found ${stuckEmails.length} stuck email(s):\n`);
|
||||
stuckEmails.forEach((email, index) => {
|
||||
logger.info(` Email ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Status: ${email.status}`);
|
||||
logger.info(` Retry Count: ${email.retry_count} ⚠️`);
|
||||
logger.info(` Created: ${email.created_at}`);
|
||||
logger.info(` Last Error: ${email.error_message || 'None'}\n`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.info(` ✅ No discrepancy - counts match\n`);
|
||||
}
|
||||
|
||||
// 5. Show ALL pending emails with details
|
||||
logger.info('5. ALL Pending Emails (regardless of retry count):');
|
||||
const allPending = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.orderBy('retry_count', 'desc')
|
||||
.orderBy('created_at', 'asc');
|
||||
|
||||
if (allPending.length > 0) {
|
||||
allPending.forEach((email, index) => {
|
||||
const willProcess = email.retry_count < 3;
|
||||
logger.info(`\n Email ${index + 1}: ${willProcess ? '✅ WILL PROCESS' : '❌ STUCK (max retries)'}`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Event ID: ${email.event_id}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}/3`);
|
||||
logger.info(` Created: ${email.created_at}`);
|
||||
logger.info(` Scheduled: ${email.scheduled_at}`);
|
||||
if (email.error_message) {
|
||||
logger.info(` Last Error: ${email.error_message}`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.info(' No pending emails found');
|
||||
}
|
||||
|
||||
// 6. Show counts by status
|
||||
logger.info('\n\n6. Email Queue Summary by Status:');
|
||||
const statusCounts = await db('email_queue')
|
||||
.select('status')
|
||||
.count('* as count')
|
||||
.groupBy('status')
|
||||
.orderBy('status');
|
||||
|
||||
statusCounts.forEach(row => {
|
||||
logger.info(` ${row.status}: ${row.count}`);
|
||||
});
|
||||
|
||||
// 7. Failed emails summary
|
||||
logger.info('\n7. Failed Emails Summary:');
|
||||
const failedSummary = await db('email_queue')
|
||||
.where('status', 'failed')
|
||||
.select('retry_count')
|
||||
.count('* as count')
|
||||
.groupBy('retry_count')
|
||||
.orderBy('retry_count');
|
||||
|
||||
if (failedSummary.length > 0) {
|
||||
failedSummary.forEach(row => {
|
||||
logger.info(` Retry count ${row.retry_count}: ${row.count} email(s)`);
|
||||
});
|
||||
} else {
|
||||
logger.info(' No failed emails');
|
||||
}
|
||||
|
||||
// 8. Recommendations
|
||||
logger.info('\n\n=== RECOMMENDATIONS ===');
|
||||
|
||||
if (adminCount.count > processorCount.count) {
|
||||
logger.info('\n❗ You have emails stuck with retry_count >= 3');
|
||||
logger.info(' These emails will NOT be processed automatically.');
|
||||
logger.info('\n To fix this, you can:');
|
||||
logger.info(' 1. Reset retry count: UPDATE email_queue SET retry_count = 0 WHERE status = \'pending\' AND retry_count >= 3;');
|
||||
logger.info(' 2. Mark as failed: UPDATE email_queue SET status = \'failed\' WHERE status = \'pending\' AND retry_count >= 3;');
|
||||
logger.info(' 3. Delete them: DELETE FROM email_queue WHERE status = \'pending\' AND retry_count >= 3;');
|
||||
}
|
||||
|
||||
const anyPending = adminCount.count > 0;
|
||||
if (anyPending && processorCount.count === 0) {
|
||||
logger.info('\n❗ All pending emails have exceeded retry limit');
|
||||
logger.info(' The email processor will not attempt to send them.');
|
||||
} else if (anyPending && processorCount.count > 0) {
|
||||
logger.info('\n✅ Email processor should process the pending emails on next run');
|
||||
logger.info(' Make sure the email processor service is running.');
|
||||
}
|
||||
|
||||
logger.info('\n=== Debug report complete ===');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error running debug report:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the debug
|
||||
debugEmailQueue();
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to diagnose and fix email_queue schema issues
|
||||
* This helps resolve the "column updated_at does not exist" error
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function checkAndFixEmailQueueSchema() {
|
||||
console.log('Checking email_queue table schema...');
|
||||
|
||||
try {
|
||||
// Get column information
|
||||
const columns = await db('email_queue').columnInfo();
|
||||
console.log('\nCurrent email_queue columns:', Object.keys(columns));
|
||||
|
||||
// Check for updated_at column
|
||||
if (columns.updated_at) {
|
||||
console.log('\n⚠️ Found unexpected updated_at column in email_queue table!');
|
||||
console.log('This column should not exist and is causing errors.');
|
||||
|
||||
// Ask for confirmation before removing
|
||||
console.log('\nRemoving updated_at column...');
|
||||
await db.schema.table('email_queue', (table) => {
|
||||
table.dropColumn('updated_at');
|
||||
});
|
||||
console.log('✅ Removed updated_at column from email_queue table');
|
||||
} else {
|
||||
console.log('✅ No updated_at column found (this is correct)');
|
||||
}
|
||||
|
||||
// Verify required columns exist
|
||||
const requiredColumns = [
|
||||
'id', 'event_id', 'recipient_email', 'email_type',
|
||||
'email_data', 'status', 'scheduled_at', 'sent_at',
|
||||
'error_message', 'retry_count', 'created_at'
|
||||
];
|
||||
|
||||
const missingColumns = requiredColumns.filter(col => !columns[col]);
|
||||
if (missingColumns.length > 0) {
|
||||
console.log('\n⚠️ Missing required columns:', missingColumns);
|
||||
} else {
|
||||
console.log('✅ All required columns are present');
|
||||
}
|
||||
|
||||
// Check for any database triggers
|
||||
if (process.env.DATABASE_CLIENT === 'pg') {
|
||||
console.log('\nChecking for PostgreSQL triggers on email_queue...');
|
||||
const triggers = await db.raw(`
|
||||
SELECT trigger_name, event_manipulation, action_statement
|
||||
FROM information_schema.triggers
|
||||
WHERE event_object_table = 'email_queue'
|
||||
AND trigger_schema = current_schema()
|
||||
`);
|
||||
|
||||
if (triggers.rows && triggers.rows.length > 0) {
|
||||
console.log('⚠️ Found triggers on email_queue table:');
|
||||
triggers.rows.forEach(trigger => {
|
||||
console.log(` - ${trigger.trigger_name} (${trigger.event_manipulation})`);
|
||||
});
|
||||
} else {
|
||||
console.log('✅ No triggers found on email_queue table');
|
||||
}
|
||||
}
|
||||
|
||||
// Test update query
|
||||
console.log('\nTesting update query...');
|
||||
const testEmail = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.first();
|
||||
|
||||
if (testEmail) {
|
||||
try {
|
||||
await db('email_queue')
|
||||
.where('id', testEmail.id)
|
||||
.update({
|
||||
retry_count: testEmail.retry_count
|
||||
});
|
||||
console.log('✅ Update query works correctly');
|
||||
} catch (error) {
|
||||
console.log('❌ Update query failed:', error.message);
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ No pending emails to test with');
|
||||
}
|
||||
|
||||
console.log('\nSchema check complete!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking schema:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the check
|
||||
checkAndFixEmailQueueSchema();
|
||||
@@ -0,0 +1,88 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function fixFinalGermanTemplates() {
|
||||
try {
|
||||
console.log('Fixing remaining English words in German templates...\n');
|
||||
|
||||
// Get all templates
|
||||
const templates = await db('email_templates').select('*');
|
||||
|
||||
for (const template of templates) {
|
||||
let updated = false;
|
||||
let updates = {};
|
||||
|
||||
// Fix subject_de
|
||||
if (template.subject_de) {
|
||||
updates.subject_de = template.subject_de;
|
||||
}
|
||||
|
||||
// Fix body_html_de
|
||||
if (template.body_html_de) {
|
||||
let html = template.body_html_de;
|
||||
|
||||
// Replace English words with German
|
||||
html = html.replace(/Gallery-Details:/g, 'Galerie-Details:');
|
||||
html = html.replace(/Galerie-Details:/g, 'Galerie-Details:');
|
||||
html = html.replace(/Details:/g, 'Details:');
|
||||
html = html.replace(/Link:/g, 'Link:');
|
||||
html = html.replace(/Gallery-Link:/g, 'Galerie-Link:');
|
||||
html = html.replace(/Galerie-Link:/g, 'Galerie-Link:');
|
||||
html = html.replace(/Archive-Details:/g, 'Archiv-Details:');
|
||||
html = html.replace(/Archiv-Details:/g, 'Archiv-Details:');
|
||||
|
||||
if (html !== template.body_html_de) {
|
||||
updates.body_html_de = html;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix body_text_de
|
||||
if (template.body_text_de) {
|
||||
let text = template.body_text_de;
|
||||
|
||||
text = text.replace(/Gallery-Details:/g, 'Galerie-Details:');
|
||||
text = text.replace(/Galerie-Details:/g, 'Galerie-Details:');
|
||||
text = text.replace(/Details:/g, 'Details:');
|
||||
text = text.replace(/Link:/g, 'Link:');
|
||||
text = text.replace(/Gallery-Link:/g, 'Galerie-Link:');
|
||||
text = text.replace(/Galerie-Link:/g, 'Galerie-Link:');
|
||||
text = text.replace(/Archive-Details:/g, 'Archiv-Details:');
|
||||
text = text.replace(/Archiv-Details:/g, 'Archiv-Details:');
|
||||
|
||||
if (text !== template.body_text_de) {
|
||||
updates.body_text_de = text;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also update the non-language-specific fields to match German
|
||||
if (template.body_html_de) {
|
||||
updates.body_html = template.body_html_de;
|
||||
}
|
||||
if (template.body_text_de) {
|
||||
updates.body_text = template.body_text_de;
|
||||
}
|
||||
if (template.subject_de) {
|
||||
updates.subject = template.subject_de;
|
||||
}
|
||||
|
||||
if (updated || Object.keys(updates).length > 0) {
|
||||
await db('email_templates')
|
||||
.where('template_key', template.template_key)
|
||||
.update(updates);
|
||||
console.log(`✅ Updated ${template.template_key}`);
|
||||
} else {
|
||||
console.log(`⏭️ No changes needed for ${template.template_key}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nDone!');
|
||||
await db.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fixFinalGermanTemplates();
|
||||
@@ -0,0 +1,126 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function fixStuckEmails() {
|
||||
try {
|
||||
logger.info('=== Fix Stuck Emails Script ===\n');
|
||||
|
||||
// 1. Find stuck emails
|
||||
logger.info('1. Finding stuck emails (pending with retry_count >= 3)...');
|
||||
const stuckEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '>=', 3)
|
||||
.select('*');
|
||||
|
||||
if (stuckEmails.length === 0) {
|
||||
logger.info(' ✅ No stuck emails found!');
|
||||
logger.info('\n=== Script complete ===');
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
logger.info(` Found ${stuckEmails.length} stuck email(s)\n`);
|
||||
|
||||
// 2. Show details
|
||||
logger.info('2. Stuck email details:');
|
||||
stuckEmails.forEach((email, index) => {
|
||||
logger.info(`\n Email ${index + 1}:`);
|
||||
logger.info(` ID: ${email.id}`);
|
||||
logger.info(` Type: ${email.email_type}`);
|
||||
logger.info(` Recipient: ${email.recipient_email}`);
|
||||
logger.info(` Retry Count: ${email.retry_count}`);
|
||||
logger.info(` Last Error: ${email.error_message || 'None'}`);
|
||||
});
|
||||
|
||||
// 3. Ask for action
|
||||
logger.info('\n\n3. Choose an action:');
|
||||
logger.info(' 1. Reset retry count to 0 (emails will be retried)');
|
||||
logger.info(' 2. Mark as failed (emails will not be retried)');
|
||||
logger.info(' 3. Delete these emails');
|
||||
logger.info(' 4. Cancel (do nothing)');
|
||||
|
||||
// Get command line argument
|
||||
const action = process.argv[2];
|
||||
|
||||
if (!action || !['reset', 'fail', 'delete'].includes(action)) {
|
||||
logger.info('\n❗ No valid action specified');
|
||||
logger.info('\nUsage:');
|
||||
logger.info(' node fix-stuck-emails.js reset - Reset retry count to 0');
|
||||
logger.info(' node fix-stuck-emails.js fail - Mark as failed');
|
||||
logger.info(' node fix-stuck-emails.js delete - Delete stuck emails');
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 4. Execute action
|
||||
logger.info(`\n4. Executing action: ${action.toUpperCase()}`);
|
||||
|
||||
const emailIds = stuckEmails.map(e => e.id);
|
||||
|
||||
switch (action) {
|
||||
case 'reset':
|
||||
await db('email_queue')
|
||||
.whereIn('id', emailIds)
|
||||
.update({
|
||||
retry_count: 0,
|
||||
error_message: null
|
||||
});
|
||||
logger.info(` ✅ Reset retry count for ${emailIds.length} email(s)`);
|
||||
logger.info(' These emails will be processed on the next run');
|
||||
break;
|
||||
|
||||
case 'fail':
|
||||
await db('email_queue')
|
||||
.whereIn('id', emailIds)
|
||||
.update({
|
||||
status: 'failed'
|
||||
});
|
||||
logger.info(` ✅ Marked ${emailIds.length} email(s) as failed`);
|
||||
logger.info(' These emails will not be retried');
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
await db('email_queue')
|
||||
.whereIn('id', emailIds)
|
||||
.delete();
|
||||
logger.info(` ✅ Deleted ${emailIds.length} email(s)`);
|
||||
break;
|
||||
}
|
||||
|
||||
// 5. Show updated counts
|
||||
logger.info('\n5. Updated email queue status:');
|
||||
const [pendingCount] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.count('* as count');
|
||||
const [processableCount] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count');
|
||||
|
||||
logger.info(` Total pending: ${pendingCount.count}`);
|
||||
logger.info(` Processable (retry < 3): ${processableCount.count}`);
|
||||
|
||||
if (pendingCount.count !== processableCount.count) {
|
||||
logger.info(` ⚠️ Still have ${pendingCount.count - processableCount.count} stuck email(s)`);
|
||||
} else {
|
||||
logger.info(' ✅ No stuck emails remaining');
|
||||
}
|
||||
|
||||
logger.info('\n=== Script complete ===');
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the fix
|
||||
fixStuckEmails();
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { db } = require('../src/database/db');
|
||||
const {
|
||||
initializeTransporter,
|
||||
processEmailQueue,
|
||||
testEmailConnection
|
||||
} = require('../src/services/emailProcessor');
|
||||
const winston = require('winston');
|
||||
|
||||
// Create a simple console logger
|
||||
const logger = winston.createLogger({
|
||||
format: winston.format.simple(),
|
||||
transports: [new winston.transports.Console()]
|
||||
});
|
||||
|
||||
async function runEmailProcessor(runOnce = false) {
|
||||
try {
|
||||
logger.info('=== Starting Email Processor ===\n');
|
||||
|
||||
// Initialize transporter
|
||||
logger.info('Initializing email transporter...');
|
||||
await initializeTransporter();
|
||||
|
||||
// Test connection
|
||||
logger.info('Testing email connection...');
|
||||
const connectionOk = await testEmailConnection();
|
||||
|
||||
if (!connectionOk) {
|
||||
logger.error('Email connection test failed! Check your SMTP configuration.');
|
||||
logger.info('\nRequired environment variables:');
|
||||
logger.info('- SMTP_HOST');
|
||||
logger.info('- SMTP_PORT');
|
||||
logger.info('- SMTP_USER');
|
||||
logger.info('- SMTP_PASS');
|
||||
logger.info('- SMTP_FROM');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logger.info('Email connection test successful!\n');
|
||||
|
||||
if (runOnce) {
|
||||
// Process queue once
|
||||
logger.info('Processing email queue once...');
|
||||
await processEmailQueue();
|
||||
logger.info('Email processing complete');
|
||||
|
||||
// Show final status
|
||||
const pendingCount = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count')
|
||||
.first();
|
||||
|
||||
logger.info(`\nEmails still pending: ${pendingCount.count}`);
|
||||
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Run continuously
|
||||
logger.info('Starting continuous email processor...');
|
||||
logger.info('Processing emails every 60 seconds. Press Ctrl+C to stop.\n');
|
||||
|
||||
// Process immediately
|
||||
await processEmailQueue();
|
||||
|
||||
// Then every minute
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await processEmailQueue();
|
||||
} catch (error) {
|
||||
logger.error('Error processing email queue:', error);
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Fatal error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
logger.info('\n\nShutting down email processor...');
|
||||
await db.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Check command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const runOnce = args.includes('--once') || args.includes('-o');
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`
|
||||
Email Processor Runner
|
||||
|
||||
Usage: node run-email-processor.js [options]
|
||||
|
||||
Options:
|
||||
--once, -o Process the email queue once and exit
|
||||
--help, -h Show this help message
|
||||
|
||||
By default, the processor runs continuously, checking for emails every 60 seconds.
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Run the processor
|
||||
runEmailProcessor(runOnce);
|
||||
@@ -0,0 +1,85 @@
|
||||
const { db } = require('../src/database/db');
|
||||
const { processTemplate } = require('../src/services/emailProcessor');
|
||||
|
||||
async function testGermanEmails() {
|
||||
try {
|
||||
console.log('=== Testing German Email Templates ===\n');
|
||||
|
||||
// Test variables
|
||||
const testVars = {
|
||||
host_name: 'Max Mustermann',
|
||||
event_name: 'Hochzeit Schmidt',
|
||||
event_date: '15.07.2024',
|
||||
gallery_link: 'https://example.com/gallery/test',
|
||||
gallery_password: 'test1234',
|
||||
expiry_date: '15.08.2024',
|
||||
days_remaining: '7',
|
||||
welcome_message: 'Herzlich willkommen zu unserer Hochzeitsgalerie!',
|
||||
archive_size: '250 MB',
|
||||
archive_date: '16.08.2024',
|
||||
photo_count: '347',
|
||||
admin_email: 'support@example.com',
|
||||
eventId: 1
|
||||
};
|
||||
|
||||
const templates = await db('email_templates').select('*');
|
||||
|
||||
for (const template of templates) {
|
||||
console.log(`\n========== ${template.template_key.toUpperCase()} ==========`);
|
||||
|
||||
// Process German version
|
||||
const germanResult = await processGermanTemplate(template, testVars);
|
||||
|
||||
console.log('\n--- GERMAN VERSION ---');
|
||||
console.log('Subject:', germanResult.subject);
|
||||
console.log('\nHTML Preview (first 500 chars):');
|
||||
console.log(germanResult.htmlBody.substring(0, 500) + '...\n');
|
||||
|
||||
// Check for any remaining English text
|
||||
const englishWords = ['Dear', 'Gallery', 'Details:', 'Link:', 'Password:', 'days', 'Thank you'];
|
||||
const foundEnglish = englishWords.filter(word =>
|
||||
germanResult.htmlBody.includes(word) || germanResult.subject.includes(word)
|
||||
);
|
||||
|
||||
if (foundEnglish.length > 0) {
|
||||
console.log('⚠️ WARNING: Found English words:', foundEnglish.join(', '));
|
||||
} else {
|
||||
console.log('✅ No English words found in German template');
|
||||
}
|
||||
}
|
||||
|
||||
await db.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
await db.destroy();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function processGermanTemplate(template, variables) {
|
||||
// Process template as German
|
||||
const subjectField = 'subject_de';
|
||||
const htmlField = 'body_html_de';
|
||||
const textField = 'body_text_de';
|
||||
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
let htmlBody = template[htmlField] || template.body_html || '';
|
||||
let textBody = template[textField] || template.body_text || '';
|
||||
|
||||
// Replace variables
|
||||
Object.keys(variables).forEach(key => {
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
subject = subject.replace(regex, variables[key]);
|
||||
htmlBody = htmlBody.replace(regex, variables[key]);
|
||||
textBody = textBody.replace(regex, variables[key]);
|
||||
});
|
||||
|
||||
// Handle conditionals (simplified)
|
||||
htmlBody = htmlBody.replace(/{{#if welcome_message}}[\s\S]*?{{\/if}}/g, (match) => {
|
||||
return variables.welcome_message ? match.replace(/{{#if welcome_message}}|{{\/if}}/g, '') : '';
|
||||
});
|
||||
|
||||
return { subject, htmlBody, textBody };
|
||||
}
|
||||
|
||||
testGermanEmails();
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to test photo authentication
|
||||
* Usage: node scripts/test-photo-auth.js <jwt-token>
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
async function testPhotoAuth(token) {
|
||||
if (!token) {
|
||||
console.error('Usage: node scripts/test-photo-auth.js <jwt-token>');
|
||||
console.error('\nTo get a token, login to a gallery and check localStorage for gallery_token_<slug>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const baseUrl = process.env.API_URL || 'http://localhost:3001';
|
||||
|
||||
console.log(`Testing photo authentication with token: ${token.substring(0, 20)}...`);
|
||||
console.log(`Base URL: ${baseUrl}\n`);
|
||||
|
||||
// Test URLs
|
||||
const tests = [
|
||||
{
|
||||
name: 'Thumbnail via static route',
|
||||
url: `${baseUrl}/thumbnails/thumb_Test_Gallery_uncategorized_5210.jpg`,
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
},
|
||||
{
|
||||
name: 'Photo via static route',
|
||||
url: `${baseUrl}/photos/wedding-test-gallery-2025-07-14-1/Test_Gallery_uncategorized_5210.jpg`,
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
},
|
||||
{
|
||||
name: 'Gallery photos API',
|
||||
url: `${baseUrl}/api/gallery/wedding-test-gallery-2025-07-14-1/photos`,
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
}
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
console.log(`Testing: ${test.name}`);
|
||||
console.log(`URL: ${test.url}`);
|
||||
|
||||
try {
|
||||
const response = await axios.get(test.url, {
|
||||
headers: test.headers,
|
||||
validateStatus: () => true // Don't throw on any status
|
||||
});
|
||||
|
||||
console.log(`Status: ${response.status}`);
|
||||
console.log(`Headers:`, response.headers['content-type']);
|
||||
|
||||
if (response.status === 200) {
|
||||
if (test.name.includes('API')) {
|
||||
console.log(`Photos count: ${response.data.photos?.length || 0}`);
|
||||
} else {
|
||||
console.log(`Content length: ${response.headers['content-length']} bytes`);
|
||||
}
|
||||
} else {
|
||||
console.log(`Error:`, response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Network error:`, error.message);
|
||||
}
|
||||
|
||||
console.log('---\n');
|
||||
}
|
||||
|
||||
// Decode token to show info
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
|
||||
console.log('Token payload:', payload);
|
||||
} catch (error) {
|
||||
console.log('Failed to decode token');
|
||||
}
|
||||
}
|
||||
|
||||
// Get token from command line
|
||||
const token = process.argv[2];
|
||||
|
||||
testPhotoAuth(token).catch(error => {
|
||||
console.error('Test failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
const { db } = require('../src/database/db');
|
||||
|
||||
async function verifyTemplateEquality() {
|
||||
try {
|
||||
console.log('Verifying template equality between German and English versions...\n');
|
||||
|
||||
const templates = await db('email_templates').select('*');
|
||||
|
||||
for (const template of templates) {
|
||||
console.log(`\n=== ${template.template_key.toUpperCase()} ===`);
|
||||
|
||||
// Check subject length similarity
|
||||
const subjectEnLength = template.subject_en?.length || 0;
|
||||
const subjectDeLength = template.subject_de?.length || 0;
|
||||
console.log(`Subject length - EN: ${subjectEnLength}, DE: ${subjectDeLength}`);
|
||||
|
||||
// Check HTML content features
|
||||
const htmlEn = template.body_html_en || '';
|
||||
const htmlDe = template.body_html_de || '';
|
||||
|
||||
// Check for key features in both versions
|
||||
const features = [
|
||||
{ name: 'Handlebars conditionals', pattern: /{{#if/g },
|
||||
{ name: 'Styled divs', pattern: /style="/g },
|
||||
{ name: 'Background colors', pattern: /background-color:/g },
|
||||
{ name: 'Buttons/CTAs', pattern: /<a.*style.*background-color.*>/g },
|
||||
{ name: 'Icons/Emojis', pattern: /[📧📞✅⚠️]/g },
|
||||
{ name: 'Lists', pattern: /<ul/g },
|
||||
{ name: 'Strong emphasis', pattern: /<strong>/g }
|
||||
];
|
||||
|
||||
console.log('\nFeature comparison:');
|
||||
for (const feature of features) {
|
||||
const enCount = (htmlEn.match(feature.pattern) || []).length;
|
||||
const deCount = (htmlDe.match(feature.pattern) || []).length;
|
||||
const status = enCount === deCount ? '✅' : '❌';
|
||||
console.log(`${status} ${feature.name}: EN=${enCount}, DE=${deCount}`);
|
||||
}
|
||||
|
||||
// Check text content length
|
||||
const textEn = template.body_text_en || '';
|
||||
const textDe = template.body_text_de || '';
|
||||
console.log(`\nText content length - EN: ${textEn.length}, DE: ${textDe.length}`);
|
||||
|
||||
// Check for specific variables usage
|
||||
const variables = [
|
||||
'host_name', 'event_name', 'event_date', 'gallery_link',
|
||||
'gallery_password', 'expiry_date', 'welcome_message',
|
||||
'days_remaining', 'support_email', 'support_phone',
|
||||
'archive_date', 'photo_count', 'archive_size'
|
||||
];
|
||||
|
||||
const missingInEn = [];
|
||||
const missingInDe = [];
|
||||
|
||||
for (const variable of variables) {
|
||||
const varPattern = new RegExp(`{{${variable}}}`, 'g');
|
||||
const inEn = varPattern.test(htmlEn) || varPattern.test(textEn);
|
||||
const inDe = varPattern.test(htmlDe) || varPattern.test(textDe);
|
||||
|
||||
if (inDe && !inEn) missingInEn.push(variable);
|
||||
if (inEn && !inDe) missingInDe.push(variable);
|
||||
}
|
||||
|
||||
if (missingInEn.length > 0) {
|
||||
console.log(`\n⚠️ Variables in DE but missing in EN: ${missingInEn.join(', ')}`);
|
||||
}
|
||||
if (missingInDe.length > 0) {
|
||||
console.log(`\n⚠️ Variables in EN but missing in DE: ${missingInDe.join(', ')}`);
|
||||
}
|
||||
|
||||
// Overall quality score
|
||||
const enScore = [
|
||||
htmlEn.includes('style='),
|
||||
htmlEn.includes('{{#if'),
|
||||
htmlEn.includes('background-color'),
|
||||
htmlEn.includes('<strong>'),
|
||||
htmlEn.includes('margin:'),
|
||||
htmlEn.includes('padding:')
|
||||
].filter(Boolean).length;
|
||||
|
||||
const deScore = [
|
||||
htmlDe.includes('style='),
|
||||
htmlDe.includes('{{#if'),
|
||||
htmlDe.includes('background-color'),
|
||||
htmlDe.includes('<strong>'),
|
||||
htmlDe.includes('margin:'),
|
||||
htmlDe.includes('padding:')
|
||||
].filter(Boolean).length;
|
||||
|
||||
console.log(`\nQuality score (out of 6) - EN: ${enScore}, DE: ${deScore}`);
|
||||
console.log(enScore === deScore ? '✅ Templates have equal quality!' : '❌ Quality mismatch');
|
||||
}
|
||||
|
||||
console.log('\n\nSummary:');
|
||||
console.log('The English templates have been updated to match the German templates in:');
|
||||
console.log('- HTML styling and structure');
|
||||
console.log('- Conditional content blocks');
|
||||
console.log('- Visual elements (buttons, alerts, icons)');
|
||||
console.log('- Information completeness');
|
||||
console.log('- Professional formatting');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
verifyTemplateEquality();
|
||||
@@ -8,6 +8,7 @@ const crypto = require('crypto');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { archiveEvent } = require('../services/archiveService');
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
@@ -66,7 +67,12 @@ router.post('/', adminAuth, [
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
||||
const processedEventName = event_name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
|
||||
.replace(/-+/g, '-') // Replace multiple dashes with single dash
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
|
||||
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
@@ -83,7 +89,14 @@ router.post('/', adminAuth, [
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
let expires_at;
|
||||
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
|
||||
expires_at = new Date(year, month - 1, day);
|
||||
} else {
|
||||
expires_at = new Date(event_date);
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
@@ -388,13 +401,56 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Delete associated photos
|
||||
await db('photos').where('event_id', id).del();
|
||||
// Start a transaction to ensure all deletions succeed or fail together
|
||||
await db.transaction(async (trx) => {
|
||||
// 1. Delete activity logs (audit trail)
|
||||
await trx('activity_logs').where('event_id', id).del();
|
||||
|
||||
// Delete event
|
||||
await db('events').where('id', id).del();
|
||||
// 2. Delete access logs
|
||||
await trx('access_logs').where('event_id', id).del();
|
||||
|
||||
// Log activity
|
||||
// 3. Delete email queue entries
|
||||
await trx('email_queue').where('event_id', id).del();
|
||||
|
||||
// 4. Delete photos (this will also handle hero_photo_id foreign key)
|
||||
await trx('photos').where('event_id', id).del();
|
||||
|
||||
// 5. Delete categories (photo_categories has CASCADE delete for event_id)
|
||||
await trx('photo_categories').where('event_id', id).del();
|
||||
|
||||
// 6. Finally delete the event
|
||||
await trx('events').where('id', id).del();
|
||||
|
||||
// Delete event folder from storage if it exists
|
||||
if (event.folder_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path);
|
||||
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.rm(eventFolderPath, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to delete event folder:', err);
|
||||
// Don't fail the transaction if folder deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete archive if exists
|
||||
if (event.archive_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const archivePath = path.join(storagePath, event.archive_path);
|
||||
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.unlink(archivePath);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete archive file:', err);
|
||||
// Don't fail the transaction if file deletion fails
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Log activity (outside transaction)
|
||||
await logActivity('event_deleted',
|
||||
{ event_name: event.event_name },
|
||||
null,
|
||||
@@ -404,7 +460,19 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
||||
res.json({ message: 'Event deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
res.status(500).json({ error: 'Failed to delete event' });
|
||||
|
||||
// Provide more specific error messages
|
||||
if (error.message && error.message.includes('foreign key constraint')) {
|
||||
res.status(500).json({
|
||||
error: 'Cannot delete event due to existing references. Please contact support.',
|
||||
details: error.message
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete event',
|
||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -480,7 +548,6 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
|
||||
// Queue email notification if requested
|
||||
if (sendEmail) {
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
// For password reset, we'll need to create a template or use a different approach
|
||||
// For now, let's use the gallery_created template with updated password
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
@@ -504,6 +571,83 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Resend creation email
|
||||
router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Get event details
|
||||
const event = await db('events')
|
||||
.where('id', id)
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// The email processor will determine the language based on:
|
||||
// 1. Event language setting
|
||||
// 2. App settings general_default_language
|
||||
// 3. Email config default language
|
||||
// 4. Domain-based detection
|
||||
// So we don't need to determine it here
|
||||
|
||||
// For resending creation email, we need the actual password
|
||||
// First, try to get it from the request body if provided
|
||||
let galleryPassword = req.body.password;
|
||||
|
||||
// If no password provided, we can't decrypt the existing one
|
||||
// So we'll show a security message
|
||||
if (!galleryPassword) {
|
||||
// We'll let the email processor determine the language for the security message
|
||||
galleryPassword = '{{password_security_message}}';
|
||||
}
|
||||
|
||||
// Format dates in a neutral format - the email processor will localize them
|
||||
const eventDate = new Date(event.event_date);
|
||||
const expiryDate = new Date(event.expires_at);
|
||||
|
||||
// Queue the email
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_name || event.host_email.split('@')[0],
|
||||
event_name: event.event_name,
|
||||
event_date: eventDate.toISOString().split('T')[0], // YYYY-MM-DD format
|
||||
gallery_link: event.share_link,
|
||||
gallery_password: galleryPassword,
|
||||
expiry_date: expiryDate.toISOString().split('T')[0], // YYYY-MM-DD format
|
||||
welcome_message: event.welcome_message || '',
|
||||
eventId: id,
|
||||
isResend: true // Flag to indicate this is a resend
|
||||
});
|
||||
|
||||
// Log the activity using the proper schema
|
||||
try {
|
||||
await logActivity('email_resent', {
|
||||
email_type: 'gallery_created',
|
||||
recipient: event.host_email,
|
||||
ip_address: req.ip || '0.0.0.0',
|
||||
user_agent: req.get('user-agent') || 'Unknown'
|
||||
}, id, {
|
||||
type: 'admin',
|
||||
id: req.admin.id,
|
||||
name: req.admin.username
|
||||
});
|
||||
} catch (logError) {
|
||||
console.error('Warning: Failed to log activity:', logError);
|
||||
// Don't fail the request if activity logging fails
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Creation email has been queued for sending'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error resending creation email:', error);
|
||||
console.error('Stack trace:', error.stack);
|
||||
res.status(500).json({ error: 'Failed to resend creation email' });
|
||||
}
|
||||
});
|
||||
|
||||
// Archive event
|
||||
router.post('/:id/archive', adminAuth, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -520,11 +520,15 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
|
||||
let archiveStorage = 0;
|
||||
for (const archive of archives) {
|
||||
try {
|
||||
const stats = await fs.stat(archive.archive_path);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', archive.archive_path);
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', archive.archive_path, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const router = express.Router();
|
||||
|
||||
// Get system version
|
||||
@@ -69,12 +70,46 @@ router.get('/status', adminAuth, async (req, res) => {
|
||||
|
||||
// Email queue status
|
||||
const [pendingEmails] = await db('email_queue').where('status', 'pending').count('* as count');
|
||||
const [processableEmails] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.count('* as count');
|
||||
const [sentEmails] = await db('email_queue').where('status', 'sent').count('* as count');
|
||||
const [failedEmails] = await db('email_queue').where('status', 'failed').count('* as count');
|
||||
const [stuckEmails] = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '>=', 3)
|
||||
.count('* as count');
|
||||
|
||||
// Activity logs count
|
||||
const [activityCount] = await db('activity_logs').count('* as count');
|
||||
|
||||
// Storage info
|
||||
const [{ totalPhotoStorage }] = await db('photos')
|
||||
.sum('size_bytes as totalPhotoStorage');
|
||||
|
||||
const archives = await db('events')
|
||||
.where('is_archived', formatBoolean(true))
|
||||
.whereNotNull('archive_path')
|
||||
.select('archive_path');
|
||||
|
||||
let archiveStorage = 0;
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
for (const archive of archives) {
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
} catch (error) {
|
||||
console.error('Archive file not found:', archive.archive_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalStorage = (parseInt(totalPhotoStorage) || 0) + archiveStorage;
|
||||
|
||||
// System info
|
||||
const systemInfo = {
|
||||
platform: os.platform(),
|
||||
@@ -105,8 +140,15 @@ router.get('/status', adminAuth, async (req, res) => {
|
||||
activityLogs: activityCount.count
|
||||
}
|
||||
},
|
||||
storage: {
|
||||
totalUsed: totalStorage,
|
||||
photoStorage: parseInt(totalPhotoStorage) || 0,
|
||||
archiveStorage: archiveStorage
|
||||
},
|
||||
emailQueue: {
|
||||
pending: pendingEmails.count,
|
||||
processable: processableEmails.count,
|
||||
stuck: stuckEmails.count,
|
||||
sent: sentEmails.count,
|
||||
failed: failedEmails.count
|
||||
},
|
||||
|
||||
@@ -136,8 +136,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: `/photos/${photo.path}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/${photo.thumbnail_path}` : null,
|
||||
url: `/api/gallery/${req.params.slug}/photo/${photo.id}`,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
|
||||
type: photo.type,
|
||||
category_id: photo.category_id,
|
||||
category_name: photo.category_name,
|
||||
@@ -320,6 +320,42 @@ router.get('/:slug/photo/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Serve thumbnail
|
||||
router.get('/:slug/thumbnail/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo || !photo.thumbnail_path) {
|
||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||
}
|
||||
|
||||
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
|
||||
|
||||
// Check if file exists
|
||||
const fs = require('fs').promises;
|
||||
try {
|
||||
await fs.access(thumbPath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
||||
}
|
||||
|
||||
// Set appropriate headers
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
|
||||
// Send file
|
||||
res.sendFile(path.resolve(thumbPath));
|
||||
} catch (error) {
|
||||
console.error('Error serving thumbnail:', error);
|
||||
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get photo stats
|
||||
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const Handlebars = require('handlebars');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
let transporter = null;
|
||||
let lastConfigHash = null;
|
||||
|
||||
// Generate hash from config for change detection
|
||||
function generateConfigHash(config) {
|
||||
const crypto = require('crypto');
|
||||
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}`;
|
||||
return crypto.createHash('md5').update(configString).digest('hex');
|
||||
}
|
||||
|
||||
// Initialize transporter from database config
|
||||
async function initializeTransporter() {
|
||||
async function initializeTransporter(forceReinit = false) {
|
||||
try {
|
||||
const config = await db('email_configs').first();
|
||||
|
||||
@@ -14,6 +23,16 @@ async function initializeTransporter() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if configuration has changed
|
||||
const currentConfigHash = generateConfigHash(config);
|
||||
if (!forceReinit && transporter && currentConfigHash === lastConfigHash) {
|
||||
// Configuration hasn't changed, return existing transporter
|
||||
return transporter;
|
||||
}
|
||||
|
||||
// Configuration has changed or first initialization
|
||||
logger.info('Initializing email transporter' + (lastConfigHash && currentConfigHash !== lastConfigHash ? ' (configuration changed)' : ''));
|
||||
|
||||
transporter = nodemailer.createTransport({
|
||||
host: config.smtp_host,
|
||||
port: config.smtp_port,
|
||||
@@ -28,29 +47,71 @@ async function initializeTransporter() {
|
||||
await transporter.verify();
|
||||
logger.info('Email transporter initialized successfully');
|
||||
|
||||
// Update the config hash
|
||||
lastConfigHash = currentConfigHash;
|
||||
|
||||
return transporter;
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize email transporter:', error);
|
||||
transporter = null;
|
||||
lastConfigHash = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the appropriate language for a recipient
|
||||
async function getRecipientLanguage(email) {
|
||||
// For now, check if the email domain ends with .de
|
||||
// In the future, this could check user preferences
|
||||
if (email && email.endsWith('.de')) {
|
||||
return 'de';
|
||||
async function getRecipientLanguage(email, eventId = null) {
|
||||
// First priority: Check event language setting if eventId is provided
|
||||
if (eventId) {
|
||||
try {
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (event && event.language) {
|
||||
return event.language;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event language:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there's a saved preference for this email
|
||||
// This could be expanded to check user preferences in the database
|
||||
// Second priority: Check app_settings for general default language
|
||||
try {
|
||||
const langSetting = await db('app_settings')
|
||||
.where('setting_key', 'general_default_language')
|
||||
.first();
|
||||
if (langSetting && langSetting.setting_value) {
|
||||
return langSetting.setting_value;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching app settings language:', error);
|
||||
}
|
||||
|
||||
// Third priority: Check email configs for default language
|
||||
try {
|
||||
const emailConfig = await db('email_configs').first();
|
||||
if (emailConfig && emailConfig.default_language) {
|
||||
return emailConfig.default_language;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching email config language:', error);
|
||||
}
|
||||
|
||||
// Fourth priority: Check if the email domain suggests German
|
||||
if (email) {
|
||||
const germanDomains = ['.de', '.at', '.ch', '.li'];
|
||||
const domain = email.toLowerCase();
|
||||
if (germanDomains.some(d => domain.endsWith(d))) {
|
||||
return 'de';
|
||||
}
|
||||
}
|
||||
|
||||
return 'en'; // Default to English
|
||||
}
|
||||
|
||||
// Process email template with variables
|
||||
async function processTemplate(template, variables, language = 'en') {
|
||||
// Import date formatter
|
||||
const { formatDate } = require('../utils/dateFormatter');
|
||||
|
||||
// Get the appropriate language fields
|
||||
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
|
||||
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
@@ -60,6 +121,27 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
let htmlBody = template[htmlField] || template.body_html || '';
|
||||
let textBody = template[textField] || template.body_text || '';
|
||||
|
||||
// Process variables before template compilation
|
||||
const processedVariables = { ...variables };
|
||||
|
||||
// Handle password security message
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
if (processedVariables.event_date) {
|
||||
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
|
||||
}
|
||||
if (processedVariables.expiry_date) {
|
||||
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
|
||||
}
|
||||
if (processedVariables.archive_date) {
|
||||
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
|
||||
}
|
||||
|
||||
// Get branding settings for logo
|
||||
let logoUrl = '';
|
||||
@@ -93,27 +175,15 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
|
||||
const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`;
|
||||
|
||||
// Process welcome message section if present
|
||||
let welcomeMessageSection = '';
|
||||
if (variables.welcome_message && variables.welcome_message.trim() !== '') {
|
||||
const welcomeTitle = language === 'de' ? 'Persönliche Nachricht:' : 'Personal Message:';
|
||||
welcomeMessageSection = `
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">${welcomeTitle}</p>
|
||||
<p style="margin: 0; color: #4b5563;">${variables.welcome_message}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Replace variables
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
subject = subject.replace(regex, value || '');
|
||||
htmlBody = htmlBody.replace(regex, value || '');
|
||||
textBody = textBody.replace(regex, value || '');
|
||||
});
|
||||
|
||||
// Replace welcome message section placeholder
|
||||
htmlBody = htmlBody.replace(/{{welcome_message_section}}/g, welcomeMessageSection);
|
||||
// Compile templates with Handlebars
|
||||
const subjectTemplate = Handlebars.compile(subject);
|
||||
const htmlTemplate = Handlebars.compile(htmlBody);
|
||||
const textTemplate = Handlebars.compile(textBody);
|
||||
|
||||
// Process templates with processedVariables (includes formatted dates and security messages)
|
||||
subject = subjectTemplate(processedVariables);
|
||||
htmlBody = htmlTemplate(processedVariables);
|
||||
textBody = textTemplate(processedVariables);
|
||||
|
||||
// Wrap HTML body in styled template
|
||||
const styledHtmlBody = `
|
||||
@@ -256,11 +326,10 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
// Send email using template
|
||||
async function sendTemplateEmail(to, templateKey, variables) {
|
||||
try {
|
||||
// Always check for configuration changes before sending
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
throw new Error('Email service not configured');
|
||||
}
|
||||
|
||||
// Get email template
|
||||
@@ -278,8 +347,8 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
throw new Error('Email configuration not found');
|
||||
}
|
||||
|
||||
// Determine recipient language
|
||||
const language = await getRecipientLanguage(to);
|
||||
// Determine recipient language (pass eventId if available in variables)
|
||||
const language = await getRecipientLanguage(to, variables.eventId || null);
|
||||
|
||||
// Process template with variables
|
||||
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
|
||||
@@ -303,14 +372,33 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
|
||||
// Process email queue
|
||||
async function processEmailQueue() {
|
||||
logger.info('Email queue processor: Checking for pending emails...');
|
||||
|
||||
try {
|
||||
const pendingEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(10);
|
||||
// Try to initialize transporter if it's null (in case it failed at startup)
|
||||
if (!transporter) {
|
||||
logger.info('Transporter not initialized, attempting to initialize...');
|
||||
transporter = await initializeTransporter();
|
||||
if (!transporter) {
|
||||
logger.warn('Email transporter could not be initialized, skipping queue processing');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let pendingEmails = [];
|
||||
try {
|
||||
pendingEmails = await db('email_queue')
|
||||
.where('status', 'pending')
|
||||
.where('retry_count', '<', 3)
|
||||
.orderBy('created_at', 'asc')
|
||||
.limit(10);
|
||||
} catch (dbError) {
|
||||
logger.error('Failed to query email queue:', dbError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingEmails.length === 0) {
|
||||
logger.info('Email queue processor: No pending emails found');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -318,7 +406,9 @@ async function processEmailQueue() {
|
||||
|
||||
for (const email of pendingEmails) {
|
||||
try {
|
||||
const emailData = JSON.parse(email.email_data || '{}');
|
||||
const emailData = typeof email.email_data === 'string'
|
||||
? JSON.parse(email.email_data || '{}')
|
||||
: email.email_data || {};
|
||||
|
||||
await sendTemplateEmail(
|
||||
email.recipient_email,
|
||||
@@ -337,13 +427,24 @@ async function processEmailQueue() {
|
||||
logger.info(`Email ${email.id} sent successfully`);
|
||||
} catch (error) {
|
||||
// Increment retry count
|
||||
await db('email_queue')
|
||||
.where('id', email.id)
|
||||
.update({
|
||||
retry_count: email.retry_count + 1,
|
||||
error_message: error.message,
|
||||
updated_at: new Date()
|
||||
});
|
||||
try {
|
||||
await db('email_queue')
|
||||
.where('id', email.id)
|
||||
.update({
|
||||
retry_count: email.retry_count + 1,
|
||||
error_message: error.message
|
||||
});
|
||||
} catch (updateError) {
|
||||
logger.error(`Failed to update email retry count for ${email.id}:`, updateError);
|
||||
// If update fails due to column issue, try without any potential auto-added fields
|
||||
if (updateError.message && updateError.message.includes('updated_at')) {
|
||||
logger.warn('Detected updated_at column issue, attempting raw query...');
|
||||
await db.raw(
|
||||
'UPDATE email_queue SET retry_count = ?, error_message = ? WHERE id = ?',
|
||||
[email.retry_count + 1, error.message, email.id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(`Failed to send email ${email.id}:`, error);
|
||||
}
|
||||
@@ -356,6 +457,8 @@ async function processEmailQueue() {
|
||||
// Queue an email for sending
|
||||
async function queueEmail(eventId, recipientEmail, emailType, emailData) {
|
||||
try {
|
||||
// Add eventId to emailData for language detection
|
||||
emailData.eventId = eventId;
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: recipientEmail,
|
||||
@@ -373,17 +476,45 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test email connection
|
||||
async function testEmailConnection() {
|
||||
try {
|
||||
if (!transporter) {
|
||||
await initializeTransporter();
|
||||
}
|
||||
if (!transporter) {
|
||||
return false;
|
||||
}
|
||||
await transporter.verify();
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Email connection test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Start email queue processor
|
||||
let emailQueueInterval = null;
|
||||
|
||||
function startEmailQueueProcessor() {
|
||||
logger.info('Email queue processor: Attempting to start...');
|
||||
|
||||
if (!emailQueueInterval) {
|
||||
// Process immediately on start
|
||||
processEmailQueue();
|
||||
processEmailQueue().catch(err => {
|
||||
logger.error('Email queue processor: Initial processing failed:', err);
|
||||
});
|
||||
|
||||
// Then process every minute
|
||||
emailQueueInterval = setInterval(processEmailQueue, 60000);
|
||||
logger.info('Email queue processor started');
|
||||
emailQueueInterval = setInterval(() => {
|
||||
processEmailQueue().catch(err => {
|
||||
logger.error('Email queue processor: Periodic processing failed:', err);
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
logger.info('Email queue processor started successfully');
|
||||
} else {
|
||||
logger.info('Email queue processor: Already running');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,6 +538,6 @@ module.exports = {
|
||||
sendTemplateEmail,
|
||||
processEmailQueue,
|
||||
queueEmail,
|
||||
startEmailQueueProcessor,
|
||||
stopEmailQueueProcessor
|
||||
stopEmailQueueProcessor,
|
||||
testEmailConnection
|
||||
};
|
||||
@@ -27,7 +27,28 @@ async function formatDate(date, language = 'en') {
|
||||
}
|
||||
}
|
||||
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
// Ensure proper date parsing
|
||||
let dateObj;
|
||||
if (date instanceof Date) {
|
||||
dateObj = date;
|
||||
} else if (typeof date === 'string') {
|
||||
// For date strings like "2025-07-16", parse as local date to avoid timezone issues
|
||||
if (date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
// Parse YYYY-MM-DD format as local date
|
||||
const [year, month, day] = date.split('-').map(num => parseInt(num, 10));
|
||||
dateObj = new Date(year, month - 1, day);
|
||||
} else {
|
||||
dateObj = new Date(date);
|
||||
}
|
||||
} else {
|
||||
dateObj = new Date(date);
|
||||
}
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
console.error('Invalid date provided to formatDate:', date);
|
||||
throw new Error('Invalid date');
|
||||
}
|
||||
|
||||
// Use appropriate locale based on language
|
||||
let locale = dateConfig.locale || 'en-GB';
|
||||
@@ -39,33 +60,33 @@ async function formatDate(date, language = 'en') {
|
||||
|
||||
// Format based on the configured format
|
||||
switch (dateConfig.format) {
|
||||
case 'MM/DD/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'DD/MM/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'YYYY-MM-DD':
|
||||
return dateObj.toISOString().split('T')[0];
|
||||
case 'DD.MM.YYYY':
|
||||
return dateObj.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
default:
|
||||
// Use long format as fallback
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
case 'MM/DD/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'DD/MM/YYYY':
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
case 'YYYY-MM-DD':
|
||||
return dateObj.toISOString().split('T')[0];
|
||||
case 'DD.MM.YYYY':
|
||||
return dateObj.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
default:
|
||||
// Use long format as fallback
|
||||
return dateObj.toLocaleDateString(locale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error formatting date:', error);
|
||||
|
||||
@@ -78,6 +78,16 @@ function validatePassword(password, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Skip zxcvbn check if explicitly disabled (for gallery passwords)
|
||||
if (options.skipStrengthCheck) {
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
score: 2, // Default moderate score for gallery passwords
|
||||
feedback: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Use zxcvbn for strength analysis
|
||||
const strength = zxcvbn(password);
|
||||
|
||||
@@ -111,7 +121,52 @@ function validatePassword(password, options = {}) {
|
||||
* @returns {Object} - Validation result
|
||||
*/
|
||||
function validatePasswordInContext(password, context, userData = {}) {
|
||||
// Base validation
|
||||
// For gallery context, use more lenient validation
|
||||
if (context === 'gallery') {
|
||||
// Gallery-specific validation options
|
||||
const galleryOptions = {
|
||||
minLength: 6, // Reduced minimum length
|
||||
requireUppercase: false, // Don't require uppercase for galleries
|
||||
requireLowercase: false, // Don't require lowercase for galleries
|
||||
requireNumbers: false, // Numbers are optional
|
||||
requireSpecialChars: false, // Special chars are optional
|
||||
preventCommonPasswords: true, // Still prevent common passwords
|
||||
minStrengthScore: 0, // Accept any score for galleries
|
||||
skipStrengthCheck: true // Skip zxcvbn strength analysis for galleries
|
||||
};
|
||||
|
||||
// Base validation with gallery-specific options
|
||||
const result = validatePassword(password, galleryOptions);
|
||||
|
||||
// Override validation for common date formats
|
||||
// Allow passwords like "04.07.2025", "04/07/2025", "04-07-2025"
|
||||
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/;
|
||||
if (datePattern.test(password)) {
|
||||
// Date format is valid for gallery passwords
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
score: 2,
|
||||
feedback: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Additional gallery-specific checks
|
||||
if (password.length < 6) {
|
||||
result.valid = false;
|
||||
result.errors = ['Password must be at least 6 characters long'];
|
||||
}
|
||||
|
||||
// Check if it's too simple (e.g., just "123456")
|
||||
if (/^\d{1,6}$/.test(password)) {
|
||||
result.valid = false;
|
||||
result.errors.push('Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Base validation for other contexts
|
||||
const result = validatePassword(password);
|
||||
|
||||
// Context-specific validation
|
||||
@@ -136,16 +191,6 @@ function validatePasswordInContext(password, context, userData = {}) {
|
||||
result.errors.push('Password must not contain parts of your email');
|
||||
}
|
||||
}
|
||||
} else if (context === 'gallery') {
|
||||
// Gallery passwords can be more lenient for user convenience
|
||||
// Allow passwords with score >= 1 (weak but acceptable)
|
||||
if (result.score < 1) {
|
||||
result.valid = false;
|
||||
result.errors.push('Password is too simple. Please add more complexity');
|
||||
}
|
||||
|
||||
// Don't check for event name in password - allow date-based passwords
|
||||
// This allows passwords like "Sommer2025!" which users prefer
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Nginx Configuration Fix for Photo Authentication
|
||||
|
||||
If photos and thumbnails are not loading in gallery view but work in admin, it's likely that the Authorization header is being stripped by nginx or another reverse proxy.
|
||||
|
||||
## Common Issue
|
||||
|
||||
The `Authorization` header is often not passed through by default in nginx proxy configurations.
|
||||
|
||||
## Fix
|
||||
|
||||
Add these lines to your nginx configuration for the PicPeak location block:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://localhost:3001;
|
||||
|
||||
# Important: Pass the Authorization header
|
||||
proxy_pass_header Authorization;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
|
||||
# Other standard proxy headers
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
## Alternative Fix Using Traefik
|
||||
|
||||
If using Traefik, ensure headers are passed:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
picpeak:
|
||||
labels:
|
||||
- "traefik.http.middlewares.picpeak-headers.headers.customrequestheaders.Authorization="
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
1. Check if Authorization header is reaching the backend:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" https://picpeak.yourdomain.com/thumbnails/test.jpg -v
|
||||
```
|
||||
|
||||
2. Check nginx logs to see if the header is present:
|
||||
```bash
|
||||
tail -f /var/log/nginx/access.log
|
||||
```
|
||||
|
||||
## Docker Compose Fix
|
||||
|
||||
If using docker-compose with nginx proxy, add:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NGINX_PROXY_PASS_HEADER=Authorization
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.4 MiB |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.57",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.57",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-link": "^2.25.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.37",
|
||||
"version": "1.0.57",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Info } from 'lucide-react';
|
||||
import { api } from '../../config/api';
|
||||
import packageJson from '../../../package.json';
|
||||
|
||||
// Frontend version from package.json
|
||||
const FRONTEND_VERSION = '1.0.0';
|
||||
const FRONTEND_VERSION = packageJson.version;
|
||||
|
||||
interface SystemVersion {
|
||||
backend: string;
|
||||
|
||||
@@ -62,9 +62,14 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
let imageUrl = src;
|
||||
|
||||
// Build full URL for the image
|
||||
const fullImageUrl = imageUrl.startsWith('/') ? buildResourceUrl(imageUrl) : imageUrl;
|
||||
// For API paths that start with /admin, we need to prepend /api
|
||||
const fullImageUrl = imageUrl.startsWith('/admin')
|
||||
? buildResourceUrl(`/api${imageUrl}`)
|
||||
: imageUrl.startsWith('/')
|
||||
? buildResourceUrl(imageUrl)
|
||||
: imageUrl;
|
||||
|
||||
console.log('Fetching authenticated image:', fullImageUrl);
|
||||
// console.log('Fetching authenticated image:', fullImageUrl);
|
||||
const response = await fetch(fullImageUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
|
||||
@@ -58,7 +58,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
{...props}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<span className="text-neutral-500">{rightIcon}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -162,7 +162,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
|
||||
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-6 h-6 text-white" />
|
||||
@@ -171,7 +171,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
{/* Navigation buttons */}
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6 text-white" />
|
||||
@@ -179,20 +179,19 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6 text-white" />
|
||||
</button>
|
||||
|
||||
{/* Bottom toolbar */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 z-20">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<div className="text-white">
|
||||
<p className="text-sm opacity-75">
|
||||
{currentIndex + 1} / {photos.length}
|
||||
</p>
|
||||
<p className="font-medium">{currentPhoto.filename}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -231,7 +230,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
|
||||
{/* Image container */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
className="absolute inset-0 flex items-center justify-center z-0"
|
||||
onClick={handleImageClick}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
@@ -257,7 +256,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Touch/swipe indicators for mobile */}
|
||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden">
|
||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
|
||||
Swipe to navigate
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"upload": "Hochladen",
|
||||
"days": "Tage",
|
||||
"customize": "Anpassen",
|
||||
"hide": "Ausblenden"
|
||||
"hide": "Ausblenden",
|
||||
"unknown": "Unbekannt"
|
||||
},
|
||||
"upload": {
|
||||
"photoCategory": "Fotokategorie",
|
||||
@@ -243,6 +244,9 @@
|
||||
"expires": "Läuft ab",
|
||||
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
|
||||
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
|
||||
"resendCreationEmail": "Erstellungs-E-Mail erneut senden",
|
||||
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
|
||||
"failedToResendEmail": "Fehler beim erneuten Senden der Erstellungs-E-Mail",
|
||||
"photoStatistics": "Fotostatistiken",
|
||||
"managePhotos": "Fotos verwalten",
|
||||
"actions": "Aktionen",
|
||||
@@ -264,6 +268,7 @@
|
||||
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
||||
"securityAccess": "Sicherheit & Zugriff",
|
||||
"galleryPassword": "Galerie-Passwort",
|
||||
"passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
|
||||
"passwordPlaceholder": "Sicheres Passwort eingeben",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"showPasswords": "Passwörter anzeigen",
|
||||
@@ -363,7 +368,13 @@
|
||||
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
|
||||
"bulkArchive": "Archivieren",
|
||||
"confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?",
|
||||
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich."
|
||||
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich.",
|
||||
"stats": {
|
||||
"totalEvents": "Gesamtveranstaltungen",
|
||||
"activeEvents": "Aktive Veranstaltungen",
|
||||
"totalPhotos": "Gesamtfotos",
|
||||
"expiringEvents": "Bald ablaufend"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "Systemeinstellungen",
|
||||
@@ -704,6 +715,7 @@
|
||||
"settings_updated": "Einstellungen aktualisiert",
|
||||
"event_updated": "Veranstaltung aktualisiert: {{eventName}}",
|
||||
"event_deleted": "Veranstaltung gelöscht: {{eventName}}",
|
||||
"email_resent": "Erstellungs-E-Mail erneut gesendet für: {{eventName}}",
|
||||
"category_created": "Kategorie erstellt: {{categoryName}}",
|
||||
"category_updated": "Kategorie aktualisiert: {{categoryName}}",
|
||||
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"upload": "Upload",
|
||||
"days": "days",
|
||||
"customize": "Customize",
|
||||
"hide": "Hide"
|
||||
"hide": "Hide",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"upload": {
|
||||
"photoCategory": "Photo Category",
|
||||
@@ -260,6 +261,9 @@
|
||||
"expires": "Expires",
|
||||
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
|
||||
"resetGalleryPassword": "Reset Gallery Password",
|
||||
"resendCreationEmail": "Resend Creation Email",
|
||||
"creationEmailResent": "Creation email has been queued for sending",
|
||||
"failedToResendEmail": "Failed to resend creation email",
|
||||
"photoStatistics": "Photo Statistics",
|
||||
"totalPhotos": "Total Photos",
|
||||
"managePhotos": "Manage Photos",
|
||||
@@ -282,6 +286,7 @@
|
||||
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
||||
"securityAccess": "Security & Access",
|
||||
"galleryPassword": "Gallery Password",
|
||||
"passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"showPasswords": "Show passwords",
|
||||
"gallerySettings": "Gallery Settings",
|
||||
@@ -340,6 +345,12 @@
|
||||
"expires": "Expires",
|
||||
"actions": "Actions",
|
||||
"noEventsFound": "No events found",
|
||||
"stats": {
|
||||
"totalEvents": "Total Events",
|
||||
"activeEvents": "Active Events",
|
||||
"totalPhotos": "Total Photos",
|
||||
"expiringEvents": "Expiring Soon"
|
||||
},
|
||||
"viewDetails": "View Details",
|
||||
"archiveEventAction": "Archive Event",
|
||||
"downloadArchiveAction": "Download Archive",
|
||||
@@ -755,6 +766,7 @@
|
||||
"settings_updated": "Settings updated",
|
||||
"event_updated": "Event updated: {{eventName}}",
|
||||
"event_deleted": "Event deleted: {{eventName}}",
|
||||
"email_resent": "Creation email resent for: {{eventName}}",
|
||||
"category_created": "Category created: {{categoryName}}",
|
||||
"category_updated": "Category updated: {{categoryName}}",
|
||||
"category_deleted": "Category deleted: {{categoryName}}",
|
||||
|
||||
@@ -169,7 +169,10 @@ export const ArchivesPage: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600">{t('archives.totalPhotos')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{archives.reduce((sum, a) => sum + a.photoCount, 0).toLocaleString()}
|
||||
{(() => {
|
||||
const total = archives.reduce((sum, a) => sum + (parseInt(String(a.photoCount)) || 0), 0);
|
||||
return total === 0 ? '0' : total.toLocaleString();
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
<FileArchive className="w-8 h-8 text-green-600" />
|
||||
|
||||
@@ -204,6 +204,9 @@ export const CreateEventPage: React.FC = () => {
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = t('validation.passwordMinLength');
|
||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||
// Prevent simple numeric passwords like "123456"
|
||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirm_password) {
|
||||
@@ -412,6 +415,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
placeholder={t('events.enterPassword')}
|
||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
|
||||
@@ -180,6 +180,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = t('validation.passwordMinLength');
|
||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||
// Prevent simple numeric passwords like "123456"
|
||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirm_password) {
|
||||
@@ -309,7 +312,6 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
value={formData.event_date}
|
||||
onChange={handleInputChange('event_date')}
|
||||
error={errors.event_date}
|
||||
min={format(new Date(), 'yyyy-MM-dd')}
|
||||
leftIcon={<Calendar className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
@@ -454,6 +456,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||
leftIcon={<Lock className="w-5 h-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
CheckCircle,
|
||||
Upload,
|
||||
Image,
|
||||
Key
|
||||
Key,
|
||||
Mail
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -620,7 +621,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</p>
|
||||
|
||||
{!event.is_archived && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 space-y-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -630,6 +631,22 @@ export const EventDetailsPage: React.FC = () => {
|
||||
>
|
||||
{t('events.resetGalleryPassword')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Mail className="w-4 h-4" />}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await eventsService.resendCreationEmail(event.id);
|
||||
toast.success(t('events.creationEmailResent'));
|
||||
} catch (error) {
|
||||
toast.error(t('events.failedToResendEmail'));
|
||||
}
|
||||
}}
|
||||
className="w-full justify-center"
|
||||
>
|
||||
{t('events.resendCreationEmail')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
ExternalLink,
|
||||
Edit,
|
||||
Download,
|
||||
Trash2
|
||||
Trash2,
|
||||
Calendar,
|
||||
Users,
|
||||
Image,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -33,12 +37,47 @@ export const EventsListPage: React.FC = () => {
|
||||
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
|
||||
// const [showFilters, setShowFilters] = useState(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
|
||||
|
||||
// Get filter from URL
|
||||
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
|
||||
const isExpiringFilter = searchParams.get('filter') === 'expiring';
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.dropdown-container')) {
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (activeDropdown !== null) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [activeDropdown]);
|
||||
|
||||
// Update dropdown position on scroll/resize
|
||||
useEffect(() => {
|
||||
const handleScrollOrResize = () => {
|
||||
if (activeDropdown !== null) {
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScrollOrResize, true);
|
||||
window.addEventListener('resize', handleScrollOrResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScrollOrResize, true);
|
||||
window.removeEventListener('resize', handleScrollOrResize);
|
||||
};
|
||||
}, [activeDropdown]);
|
||||
|
||||
// Fetch events
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['admin-events', statusFilter],
|
||||
@@ -197,6 +236,59 @@ export const EventsListPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600">{t('events.stats.totalEvents')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">{data?.events.length || 0}</p>
|
||||
</div>
|
||||
<Calendar className="w-8 h-8 text-primary-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600">{t('events.stats.activeEvents')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{data?.events.filter(e => e.is_active && !e.is_archived).length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Activity className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600">{t('events.stats.totalPhotos')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{data?.events.reduce((sum, e) => sum + (e.photo_count || 0), 0) || 0}
|
||||
</p>
|
||||
</div>
|
||||
<Image className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600">{t('events.stats.expiringEvents')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{data?.events.filter(e => {
|
||||
if (!e.is_active || e.is_archived) return false;
|
||||
const days = e.expires_at ? differenceInDays(parseISO(e.expires_at), new Date()) : 0;
|
||||
return days <= 7 && days > 0;
|
||||
}).length || 0}
|
||||
</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<Card padding="sm" className="mb-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
@@ -272,8 +364,8 @@ export const EventsListPage: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* Events Table */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<Card className="overflow-visible">
|
||||
<div className="overflow-x-auto overflow-y-visible">
|
||||
<table className="w-full">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
@@ -347,21 +439,38 @@ export const EventsListPage: React.FC = () => {
|
||||
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="relative inline-block text-left">
|
||||
<div className="relative inline-block text-left dropdown-container">
|
||||
<button
|
||||
onClick={() => setActiveDropdown(activeDropdown === event.id ? null : event.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (activeDropdown === event.id) {
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
} else {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setActiveDropdown(event.id);
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY,
|
||||
left: rect.right - 224 + window.scrollX // 224px = 14rem (w-56)
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-neutral-400 hover:text-neutral-600 p-1"
|
||||
>
|
||||
<MoreVertical className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{activeDropdown === event.id && (
|
||||
<div className="absolute right-0 z-10 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
|
||||
{activeDropdown === event.id && dropdownPosition && (
|
||||
<div
|
||||
className="fixed z-50 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5"
|
||||
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
|
||||
>
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(`/admin/events/${event.id}`);
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||
>
|
||||
@@ -374,7 +483,10 @@ export const EventsListPage: React.FC = () => {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||
onClick={() => setActiveDropdown(null)}
|
||||
onClick={() => {
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('events.viewGallery')}
|
||||
@@ -385,6 +497,7 @@ export const EventsListPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
archiveMutation.mutate(event.id);
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||
>
|
||||
@@ -397,6 +510,7 @@ export const EventsListPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
toast.info(t('events.downloadArchiveSoon'));
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
|
||||
>
|
||||
@@ -409,6 +523,7 @@ export const EventsListPage: React.FC = () => {
|
||||
if (confirm(t('events.deleteEventConfirm'))) {
|
||||
deleteMutation.mutate(event.id);
|
||||
setActiveDropdown(null);
|
||||
setDropdownPosition(null);
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
||||
|
||||
@@ -551,7 +551,14 @@ export const SettingsPage: React.FC = () => {
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-blue-700">{t('settings.systemStatus.pending')}:</span>
|
||||
<span className="ml-2 font-semibold text-blue-900">{systemStatus.emailQueue.pending}</span>
|
||||
<span className="ml-2 font-semibold text-blue-900">
|
||||
{systemStatus.emailQueue.pending}
|
||||
{systemStatus.emailQueue.stuck > 0 && (
|
||||
<span className="text-orange-600 text-xs ml-1">
|
||||
({systemStatus.emailQueue.stuck} stuck)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-green-700">{t('settings.systemStatus.sent')}:</span>
|
||||
@@ -562,6 +569,14 @@ export const SettingsPage: React.FC = () => {
|
||||
<span className="ml-2 font-semibold text-red-900">{systemStatus.emailQueue.failed}</span>
|
||||
</div>
|
||||
</div>
|
||||
{systemStatus.emailQueue.stuck > 0 && (
|
||||
<div className="mt-3 p-3 bg-orange-50 rounded-md">
|
||||
<p className="text-xs text-orange-800">
|
||||
<span className="font-semibold">⚠️ {systemStatus.emailQueue.stuck} email(s) stuck:</span> These emails have exceeded retry limits and won't be processed automatically.
|
||||
Only {systemStatus.emailQueue.processable} of {systemStatus.emailQueue.pending} pending emails will be processed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface SystemHealth {
|
||||
details: {
|
||||
emailQueue: {
|
||||
pending: number;
|
||||
processable: number;
|
||||
stuck: number;
|
||||
sent: number;
|
||||
failed: number;
|
||||
};
|
||||
memory: {
|
||||
|
||||
@@ -118,4 +118,10 @@ export const eventsService = {
|
||||
const response = await api.post(`/admin/events/${eventId}/reset-password`, { sendEmail });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Resend creation email
|
||||
async resendCreationEmail(eventId: number): Promise<{ success: boolean; message: string }> {
|
||||
const response = await api.post(`/admin/events/${eventId}/resend-email`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -47,8 +47,15 @@ export interface SystemStatus {
|
||||
activityLogs: number;
|
||||
};
|
||||
};
|
||||
storage: {
|
||||
totalUsed: number;
|
||||
photoStorage: number;
|
||||
archiveStorage: number;
|
||||
};
|
||||
emailQueue: {
|
||||
pending: number;
|
||||
processable: number;
|
||||
stuck: number;
|
||||
sent: number;
|
||||
failed: number;
|
||||
};
|
||||
@@ -100,7 +107,7 @@ export const settingsService = {
|
||||
formData.append('logo', file);
|
||||
|
||||
const response = await api.post<{ logoUrl: string }>(
|
||||
'/api/admin/settings/logo',
|
||||
'/admin/settings/logo',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
@@ -118,7 +125,7 @@ export const settingsService = {
|
||||
formData.append('favicon', file);
|
||||
|
||||
const response = await api.post<{ faviconUrl: string }>(
|
||||
'/api/admin/settings/favicon',
|
||||
'/admin/settings/favicon',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
@@ -136,7 +143,7 @@ export const settingsService = {
|
||||
formData.append('watermarkLogo', file);
|
||||
|
||||
const response = await api.post<{ watermarkLogoUrl: string }>(
|
||||
'/api/admin/settings/branding/watermark-logo',
|
||||
'/admin/settings/branding/watermark-logo',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
Reference in New Issue
Block a user