Compare commits

...

8 Commits

Author SHA1 Message Date
Gitea Actions Bot 3b7d723c2a chore: bump version to 1.0.17
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 08:18:38 +00:00
paul dc6252ff56 fix: complete email template and CMS page fixes for production
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m7s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 4s
- Fix email templates list endpoint to handle both schema variations
- Add migration for default CMS pages with language-specific columns
- Create debug script to identify 500 error causes
- Update CMS migration to use correct column names (title_en/de, content_en/de)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 10:14:44 +02:00
Gitea Actions Bot 1d4e79a4f9 chore: bump version to 1.0.16
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 08:02:15 +00:00
paul 0b0e3e22d2 fix: handle email templates schema variations in production
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m20s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Update migration to detect and handle both old and new email template schemas
- Fix migration to insert into correct columns based on existing schema
- Update adminEmail routes to handle both schema formats gracefully
- Add proper fallbacks for German language columns

This ensures the application works whether the language migration has been
applied or not, preventing null constraint violations.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 09:58:06 +02:00
Gitea Actions Bot f22e3c133f chore: bump version to 1.0.15
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 07:53:34 +00:00
paul 64c0a58f78 fix: database migration and routing issues for production
Test and Lint / backend-test (push) Successful in 1m19s
Test and Lint / frontend-test (push) Successful in 2m20s
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
- Add migration to fix email_templates column structure after language migration
- Add migration to ensure default email templates exist
- Create diagnostic script to check database issues
- Fix docker-compose configuration for proper routing without path stripping

The backend expects routes with /api prefix, so removing the stripprefix
middleware allows proper routing to work.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 09:49:11 +02:00
Gitea Actions Bot 4182089c17 chore: bump version to 1.0.14
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 07:24:51 +00:00
paul cecf773fb7 fix: database connection stability issues in production
Test and Lint / backend-test (push) Successful in 1m9s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Add robust connection pool configuration with timeouts and retry settings
- Implement retry logic in maintenance middleware for connection errors
- Increase connection stability with keepAlive and proper timeout values
- Handle "Connection terminated unexpectedly" errors gracefully

This prevents 503 errors when the database connection is temporarily interrupted
and ensures the application can recover from transient connection issues.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 09:20:51 +02:00
18 changed files with 1066 additions and 54 deletions
+134
View File
@@ -0,0 +1,134 @@
# Traefik Troubleshooting Guide
## Common Issues and Solutions
### 1. 404 Errors on API Routes
**Problem**: Getting 404 errors when accessing `/api/*` routes
**Causes**:
- Traefik routing rules not properly configured
- Backend container not healthy
- Path stripping not working correctly
**Solutions**:
1. **Check container health**:
```bash
docker ps # Check if backend is running
docker logs picpeak-backend # Check for startup errors
```
2. **Test backend directly**:
```bash
# Access backend container
docker exec -it picpeak-backend sh
# Test health endpoint
wget -O- http://localhost:3000/health
# Test public settings endpoint
wget -O- http://localhost:3000/public/settings
```
3. **Check Traefik routing**:
```bash
# Check if routes are registered in Traefik
curl https://traefik.yourdomain.com/api/http/routers | jq '.[] | select(.rule | contains("picpeak"))'
```
### 2. Backend Not Accessible Through Traefik
**Key Configuration Points**:
1. **Traefik Labels** (in deploy section):
- `traefik.enable=true` - Enable Traefik for this container
- `traefik.docker.network=proxy` - Specify which network Traefik should use
- `traefik.http.routers.picpeak-backend.priority=100` - Higher priority for API routes
2. **Path Stripping**:
- Frontend expects `/api/*` but backend serves routes without `/api` prefix
- Middleware strips `/api` before forwarding to backend
3. **Network Configuration**:
- Backend must be in both `picpeak` (internal) and `proxy` (Traefik) networks
### 3. Environment Variable Issues
**Critical Variables**:
- `ADMIN_URL` and `FRONTEND_URL` must match your actual domain
- These affect CORS configuration
**Example .env**:
```env
# URLs
ADMIN_URL=https://picpeak.local.nothaft.cloud
FRONTEND_URL=https://picpeak.local.nothaft.cloud
# Database
DB_USER=picpeak
DB_PASSWORD=your_secure_password
DB_NAME=picpeak
# JWT
JWT_SECRET=your_secure_jwt_secret
# Email (optional)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=true
SMTP_USER=noreply@example.com
SMTP_PASS=smtp_password
EMAIL_FROM=noreply@example.com
```
### 4. Debugging Steps
1. **Check if backend is receiving requests**:
```bash
# Watch backend logs
docker logs -f picpeak-backend
# Look for incoming requests when you try to access the admin page
```
2. **Test API routes directly**:
```bash
# From outside
curl -v https://picpeak.local.nothaft.cloud/api/public/settings
# Should see backend logs if request reaches container
```
3. **Verify Traefik middleware**:
```bash
# Check if stripprefix middleware exists
curl https://traefik.yourdomain.com/api/http/middlewares | jq '.[] | select(.name | contains("picpeak"))'
```
### 5. Quick Fix Checklist
- [ ] Backend container is healthy (`docker ps`)
- [ ] Backend is in both networks (`docker inspect picpeak-backend | grep -A 20 Networks`)
- [ ] Traefik labels use correct network (`traefik.docker.network=proxy`)
- [ ] Priority is set correctly (backend: 100, frontend: 10)
- [ ] ADMIN_URL and FRONTEND_URL match your domain
- [ ] Database is accessible from backend
- [ ] Migrations have run successfully
### 6. Alternative Testing
If Traefik routing is problematic, test backend directly:
```bash
# Port forward to test backend directly
docker run --rm -it --network picpeak alpine/curl curl http://backend:3000/health
# Or expose backend port temporarily
docker run -d --name picpeak-backend-test \
--network picpeak \
-p 3001:3000 \
registry.local.nothaft.cloud/picpeak-backend:latest
```
Then access http://localhost:3001/health to verify backend is working.
Binary file not shown.
+15 -3
View File
@@ -32,15 +32,27 @@ const config = {
user: process.env.DB_USER || 'picpeak',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'picpeak',
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
// Connection stability settings
connectionTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
keepAlive: true,
keepAliveInitialDelayMillis: 0
},
pool: {
min: 2,
max: 10
max: 10,
acquireTimeoutMillis: 30000,
createTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
reapIntervalMillis: 1000,
createRetryIntervalMillis: 200,
propagateCreateError: false
},
migrations: {
directory: './migrations'
}
},
acquireConnectionTimeout: 60000
}
};
@@ -0,0 +1,35 @@
exports.up = async function(knex) {
// Check current column structure
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
const hasSubject = await knex.schema.hasColumn('email_templates', 'subject');
if (hasSubjectEn && !hasSubject) {
// The language migration was applied, need to add back basic columns
await knex.schema.alterTable('email_templates', function(table) {
table.string('subject');
table.text('body_html');
table.text('body_text');
});
// Copy English values to the basic columns
await knex('email_templates').update({
subject: knex.raw('subject_en'),
body_html: knex.raw('body_html_en'),
body_text: knex.raw('body_text_en')
});
}
};
exports.down = async function(knex) {
// Check if we have the basic columns
const hasSubject = await knex.schema.hasColumn('email_templates', 'subject');
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
if (hasSubject && hasSubjectEn) {
await knex.schema.alterTable('email_templates', function(table) {
table.dropColumn('subject');
table.dropColumn('body_html');
table.dropColumn('body_text');
});
}
};
@@ -0,0 +1,91 @@
exports.up = async function(knex) {
// Check if we have the default email templates
const templates = await knex('email_templates').select('template_key');
const existingKeys = templates.map(t => t.template_key);
// Check which columns exist in the table
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
const hasSubject = await knex.schema.hasColumn('email_templates', 'subject');
// Determine which columns to use based on schema
const subjectCol = hasSubjectEn ? 'subject_en' : 'subject';
const bodyHtmlCol = hasSubjectEn ? 'body_html_en' : 'body_html';
const bodyTextCol = hasSubjectEn ? 'body_text_en' : 'body_text';
const defaultTemplates = [
{
template_key: 'gallery_created',
[subjectCol]: 'Your Photo Gallery is Ready!',
[bodyHtmlCol]: `<h2>Gallery Created Successfully</h2>
<p>Dear {{host_name}},</p>
<p>Your photo gallery "{{event_name}}" has been created successfully!</p>
<p><strong>Gallery Details:</strong></p>
<ul>
<li>Event Date: {{event_date}}</li>
<li>Gallery Link: {{gallery_link}}</li>
<li>Password: {{gallery_password}}</li>
<li>Expires: {{expiry_date}}</li>
</ul>
<p>Share this link and password with your guests to allow them to view and download photos.</p>`,
[bodyTextCol]: 'Gallery Created Successfully\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has been created successfully!',
variables: JSON.stringify(['host_name', 'event_name', 'event_date', 'gallery_link', 'gallery_password', 'expiry_date'])
},
{
template_key: 'expiration_warning',
[subjectCol]: 'Your Photo Gallery Expires Soon',
[bodyHtmlCol]: `<h2>Gallery Expiring Soon</h2>
<p>Dear {{host_name}},</p>
<p>Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.</p>
<p>After expiration, the gallery will be archived and no longer accessible to guests.</p>
<p><a href="{{gallery_link}}">Visit Gallery</a></p>`,
[bodyTextCol]: 'Gallery Expiring Soon\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" will expire in {{days_remaining}} days.',
variables: JSON.stringify(['host_name', 'event_name', 'days_remaining', 'gallery_link'])
},
{
template_key: 'gallery_expired',
[subjectCol]: 'Your Photo Gallery Has Expired',
[bodyHtmlCol]: `<h2>Gallery Expired</h2>
<p>Dear {{host_name}},</p>
<p>Your photo gallery "{{event_name}}" has expired and been archived.</p>
<p>The photos are safely stored in our archive system. If you need access to the archived photos, please contact support.</p>`,
[bodyTextCol]: 'Gallery Expired\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has expired and been archived.',
variables: JSON.stringify(['host_name', 'event_name'])
},
{
template_key: 'archive_complete',
[subjectCol]: 'Gallery Archive Complete',
[bodyHtmlCol]: `<h2>Archive Complete</h2>
<p>Dear {{host_name}},</p>
<p>Your photo gallery "{{event_name}}" has been successfully archived.</p>
<p>Archive size: {{archive_size}}</p>
<p>The archive is stored securely and can be retrieved if needed.</p>`,
[bodyTextCol]: 'Archive Complete\n\nDear {{host_name}},\n\nYour photo gallery "{{event_name}}" has been successfully archived.',
variables: JSON.stringify(['host_name', 'event_name', 'archive_size'])
}
];
// Insert missing templates
for (const template of defaultTemplates) {
if (!existingKeys.includes(template.template_key)) {
// If we have language columns, also set German versions with same content
if (hasSubjectEn) {
template.subject_de = template[subjectCol];
template.body_html_de = template[bodyHtmlCol];
template.body_text_de = template[bodyTextCol];
// Also ensure we have the basic columns if they exist
if (hasSubject) {
template.subject = template[subjectCol];
template.body_html = template[bodyHtmlCol];
template.body_text = template[bodyTextCol];
}
}
await knex('email_templates').insert(template);
}
}
};
exports.down = async function(knex) {
// Don't remove templates on rollback as they might have been customized
};
@@ -0,0 +1,121 @@
exports.up = async function(knex) {
// Check if CMS pages already exist
const impressumExists = await knex('cms_pages')
.where('slug', 'impressum')
.first();
const datenschutzExists = await knex('cms_pages')
.where('slug', 'datenschutz')
.first();
const pagesToInsert = [];
// Add Impressum page if it doesn't exist
if (!impressumExists) {
pagesToInsert.push({
slug: 'impressum',
title_en: 'Legal Notice',
title_de: 'Impressum',
content_en: `<h1>Legal Notice</h1>
<p>Information according to § 5 TMG</p>
<h2>Responsible for content</h2>
<p>[Your Name]<br>
[Your Address]<br>
[Postal Code City]</p>
<h2>Contact</h2>
<p>Email: [Your Email Address]<br>
Phone: [Your Phone Number]</p>
<h2>Disclaimer</h2>
<h3>Liability for content</h3>
<p>The contents of our pages were created with great care. However, we cannot guarantee the accuracy, completeness and timeliness of the content.</p>
<h3>Liability for links</h3>
<p>Our website contains links to external third-party websites over whose content we have no influence. Therefore, we cannot accept any liability for this third-party content.</p>`,
content_de: `<h1>Impressum</h1>
<p>Angaben gemäß § 5 TMG</p>
<h2>Verantwortlich für den Inhalt</h2>
<p>[Ihr Name]<br>
[Ihre Adresse]<br>
[PLZ Ort]</p>
<h2>Kontakt</h2>
<p>E-Mail: [Ihre E-Mail-Adresse]<br>
Telefon: [Ihre Telefonnummer]</p>
<h2>Haftungsausschluss</h2>
<h3>Haftung für Inhalte</h3>
<p>Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte können wir jedoch keine Gewähr übernehmen.</p>
<h3>Haftung für Links</h3>
<p>Unser Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen.</p>`,
updated_at: new Date()
});
}
// Add Datenschutz page if it doesn't exist
if (!datenschutzExists) {
pagesToInsert.push({
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: `<h1>Privacy Policy</h1>
<h2>1. Privacy at a Glance</h2>
<h3>General Information</h3>
<p>The following information provides a simple overview of what happens to your personal data when you visit this website.</p>
<h3>Data Collection on This Website</h3>
<p><strong>Who is responsible for data collection on this website?</strong></p>
<p>Data processing on this website is carried out by the website operator. Their contact details can be found in the legal notice of this website.</p>
<p><strong>How do we collect your data?</strong></p>
<p>Your data is collected when you provide it to us. This could be data that you enter into a contact form, for example.</p>
<p><strong>What do we use your data for?</strong></p>
<p>Some of the data is collected to ensure error-free provision of the website. Other data may be used to analyze your user behavior.</p>
<h2>2. Hosting</h2>
<p>This website is hosted externally. The personal data collected on this website is stored on the servers of the host.</p>
<h2>3. General Information and Mandatory Information</h2>
<h3>Data Protection</h3>
<p>The operators of these pages take the protection of your personal data very seriously. We treat your personal data confidentially and in accordance with the statutory data protection regulations and this privacy policy.</p>`,
content_de: `<h1>Datenschutzerklärung</h1>
<h2>1. Datenschutz auf einen Blick</h2>
<h3>Allgemeine Hinweise</h3>
<p>Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen.</p>
<h3>Datenerfassung auf dieser Website</h3>
<p><strong>Wer ist verantwortlich für die Datenerfassung auf dieser Website?</strong></p>
<p>Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber. Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.</p>
<p><strong>Wie erfassen wir Ihre Daten?</strong></p>
<p>Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen. Hierbei kann es sich z.B. um Daten handeln, die Sie in ein Kontaktformular eingeben.</p>
<p><strong>Wofür nutzen wir Ihre Daten?</strong></p>
<p>Ein Teil der Daten wird erhoben, um eine fehlerfreie Bereitstellung der Website zu gewährleisten. Andere Daten können zur Analyse Ihres Nutzerverhaltens verwendet werden.</p>
<h2>2. Hosting</h2>
<p>Diese Website wird extern gehostet. Die personenbezogenen Daten, die auf dieser Website erfasst werden, werden auf den Servern des Hosters gespeichert.</p>
<h2>3. Allgemeine Hinweise und Pflichtinformationen</h2>
<h3>Datenschutz</h3>
<p>Die Betreiber dieser Seiten nehmen den Schutz Ihrer persönlichen Daten sehr ernst. Wir behandeln Ihre personenbezogenen Daten vertraulich und entsprechend der gesetzlichen Datenschutzvorschriften sowie dieser Datenschutzerklärung.</p>`,
updated_at: new Date()
});
}
// Insert pages if any need to be added
if (pagesToInsert.length > 0) {
await knex('cms_pages').insert(pagesToInsert);
}
};
exports.down = async function(knex) {
// Don't remove CMS pages on rollback as they might have been customized
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.13",
"version": "1.0.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.13",
"version": "1.0.17",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.13",
"version": "1.0.17",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+60
View File
@@ -0,0 +1,60 @@
require('dotenv').config();
const { db } = require('../src/database/db');
async function checkDatabaseIssues() {
console.log('Checking database issues...\n');
try {
// Check email_templates table structure
console.log('1. Checking email_templates table structure:');
const emailTemplateColumns = await db('email_templates').columnInfo();
console.log('Columns:', Object.keys(emailTemplateColumns));
// Check if any templates exist
const templateCount = await db('email_templates').count('* as count');
console.log('Template count:', templateCount[0].count);
// Check for specific template
const galleryCreatedTemplate = await db('email_templates')
.where('template_key', 'gallery_created')
.first();
console.log('gallery_created template exists:', !!galleryCreatedTemplate);
// Check activity_logs table
console.log('\n2. Checking activity_logs table:');
const activityLogColumns = await db('activity_logs').columnInfo();
console.log('Columns:', Object.keys(activityLogColumns));
// Check migrations table
console.log('\n3. Checking migrations status:');
const migrations = await db('migrations')
.orderBy('id', 'desc')
.limit(10);
console.log('Latest migrations:');
migrations.forEach(m => console.log(` - ${m.filename}`));
// Test a simple query from notifications route
console.log('\n4. Testing notifications query:');
try {
const notifications = await db('activity_logs')
.select(
'activity_logs.*',
'events.event_name'
)
.leftJoin('events', 'activity_logs.event_id', 'events.id')
.orderBy('activity_logs.created_at', 'desc')
.limit(5);
console.log(`Found ${notifications.length} notifications`);
} catch (error) {
console.error('Notifications query failed:', error.message);
}
} catch (error) {
console.error('Error:', error);
} finally {
await db.destroy();
process.exit(0);
}
}
checkDatabaseIssues();
+92
View File
@@ -0,0 +1,92 @@
require('dotenv').config();
const { db } = require('../src/database/db');
async function debugEndpoints() {
console.log('Debugging 500 errors...\n');
try {
// Test email templates query
console.log('1. Testing email templates query:');
try {
const templates = await db('email_templates')
.select('*')
.orderBy('template_key');
console.log(`Found ${templates.length} templates`);
if (templates.length > 0) {
console.log('First template columns:', Object.keys(templates[0]));
console.log('Template keys:', templates.map(t => t.template_key));
}
} catch (error) {
console.error('Email templates query failed:', error.message);
console.error('Error code:', error.code);
}
// Test notifications query
console.log('\n2. Testing notifications query:');
try {
const notifications = await db('activity_logs')
.select(
'activity_logs.*',
'events.event_name'
)
.leftJoin('events', 'activity_logs.event_id', 'events.id')
.whereNull('activity_logs.read_at')
.orderBy('activity_logs.created_at', 'desc')
.limit(5);
console.log(`Found ${notifications.length} unread notifications`);
} catch (error) {
console.error('Notifications query failed:', error.message);
console.error('Error code:', error.code);
// Check if it's a column issue
if (error.message.includes('column')) {
console.log('\nChecking activity_logs columns:');
const columns = await db('activity_logs').columnInfo();
console.log('Columns:', Object.keys(columns));
}
}
// Test specific template query
console.log('\n3. Testing specific template query (gallery_created):');
try {
const template = await db('email_templates')
.where('template_key', 'gallery_created')
.first();
if (template) {
console.log('Template found:', template.template_key);
console.log('Has subject_en?', template.subject_en !== undefined);
console.log('Has subject?', template.subject !== undefined);
} else {
console.log('Template not found');
}
} catch (error) {
console.error('Template query failed:', error.message);
}
// Check CMS pages
console.log('\n4. Checking CMS pages:');
try {
const pages = await db('cms_pages')
.select('slug', 'title', 'is_published')
.orderBy('slug');
console.log(`Found ${pages.length} CMS pages:`);
pages.forEach(page => {
console.log(` - ${page.slug}: ${page.title} (published: ${page.is_published})`);
});
} catch (error) {
console.error('CMS pages query failed:', error.message);
}
} catch (error) {
console.error('General error:', error);
} finally {
await db.destroy();
process.exit(0);
}
}
debugEndpoints();
+132
View File
@@ -0,0 +1,132 @@
const knex = require('knex');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
class ConnectionManager {
constructor() {
this.db = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 5000; // 5 seconds
this.isReconnecting = false;
}
async initialize() {
try {
this.db = knex(knexConfig);
// Test the connection
await this.db.raw('SELECT 1');
logger.info('Database connection established successfully');
// Set up connection error handling
this.setupErrorHandling();
this.reconnectAttempts = 0;
return this.db;
} catch (error) {
logger.error('Failed to initialize database connection:', error);
throw error;
}
}
setupErrorHandling() {
if (!this.db) return;
// Handle connection errors
this.db.on('error', async (error) => {
logger.error('Database connection error:', error);
if (this.shouldReconnect(error)) {
await this.reconnect();
}
});
}
shouldReconnect(error) {
const reconnectableErrors = [
'ECONNREFUSED',
'ETIMEDOUT',
'ECONNRESET',
'Connection terminated unexpectedly',
'Connection terminated'
];
return reconnectableErrors.some(msg =>
error.code === msg || error.message?.includes(msg)
);
}
async reconnect() {
if (this.isReconnecting) {
logger.info('Already attempting to reconnect...');
return;
}
this.isReconnecting = true;
while (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
logger.info(`Attempting to reconnect to database (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
try {
// Destroy the old connection pool
if (this.db) {
await this.db.destroy();
}
// Create new connection
await this.initialize();
logger.info('Successfully reconnected to database');
this.isReconnecting = false;
return;
} catch (error) {
logger.error(`Reconnection attempt ${this.reconnectAttempts} failed:`, error.message);
if (this.reconnectAttempts < this.maxReconnectAttempts) {
await new Promise(resolve => setTimeout(resolve, this.reconnectDelay));
}
}
}
this.isReconnecting = false;
logger.error('Failed to reconnect to database after maximum attempts');
// In production, you might want to alert monitoring systems or restart the process
if (process.env.NODE_ENV === 'production') {
logger.error('Exiting process due to database connection failure');
process.exit(1);
}
}
getConnection() {
if (!this.db) {
throw new Error('Database connection not initialized');
}
return this.db;
}
async healthCheck() {
try {
await this.db.raw('SELECT 1');
return { healthy: true };
} catch (error) {
logger.error('Database health check failed:', error);
return { healthy: false, error: error.message };
}
}
async destroy() {
if (this.db) {
await this.db.destroy();
this.db = null;
}
}
}
// Create singleton instance
const connectionManager = new ConnectionManager();
module.exports = connectionManager;
+1
View File
@@ -1,6 +1,7 @@
const knex = require('knex');
const knexConfig = require('../../knexfile');
// Create database connection with built-in retry logic
const db = knex(knexConfig);
async function initializeDatabase() {
+52 -14
View File
@@ -5,6 +5,36 @@ let maintenanceMode = false;
let lastCheck = 0;
const CACHE_DURATION = 60000; // 1 minute
// Retry configuration for database queries
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // 1 second
async function queryWithRetry(queryFn, retries = MAX_RETRIES) {
for (let i = 0; i < retries; i++) {
try {
return await queryFn();
} catch (error) {
if (i === retries - 1) {
throw error;
}
// Check if it's a connection error that might benefit from retry
const isConnectionError =
error.message?.includes('Connection terminated') ||
error.message?.includes('ECONNREFUSED') ||
error.message?.includes('ETIMEDOUT') ||
error.code === 'ECONNRESET';
if (isConnectionError) {
console.warn(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
} else {
throw error; // Don't retry non-connection errors
}
}
}
}
async function checkMaintenanceMode() {
const now = Date.now();
@@ -14,18 +44,21 @@ async function checkMaintenanceMode() {
}
try {
const setting = await db('app_settings')
.where('setting_key', 'general_maintenance_mode')
.where('setting_type', 'general')
.first();
const setting = await queryWithRetry(async () => {
return await db('app_settings')
.where('setting_key', 'general_maintenance_mode')
.where('setting_type', 'general')
.first();
});
maintenanceMode = setting ? (setting.setting_value === 'true' || setting.setting_value === true) : false;
lastCheck = now;
return maintenanceMode;
} catch (error) {
console.error('Error checking maintenance mode:', error);
return false;
console.error('Error checking maintenance mode after retries:', error.message);
// Return cached value or false if no cache
return maintenanceMode;
}
}
@@ -52,14 +85,19 @@ async function maintenanceMiddleware(req, res, next) {
return next();
}
const inMaintenance = await checkMaintenanceMode();
if (inMaintenance && !isAdminRoute) {
return res.status(503).json({
error: 'Service Unavailable',
message: 'The system is currently undergoing maintenance. Please try again later.',
maintenance: true
});
try {
const inMaintenance = await checkMaintenanceMode();
if (inMaintenance && !isAdminRoute) {
return res.status(503).json({
error: 'Service Unavailable',
message: 'The system is currently undergoing maintenance. Please try again later.',
maintenance: true
});
}
} catch (error) {
// If we can't check maintenance mode, allow the request to proceed
console.error('Failed to check maintenance mode, allowing request:', error.message);
}
next();
+86 -31
View File
@@ -160,20 +160,35 @@ router.get('/templates', adminAuth, async (req, res) => {
.orderBy('template_key');
// Parse variables JSON and format for multi-language support
const formattedTemplates = templates.map(template => ({
id: template.id,
template_key: template.template_key,
// English versions
subject_en: template.subject_en || template.subject,
body_html_en: template.body_html_en || template.body_html,
body_text_en: template.body_text_en || template.body_text,
// German versions
subject_de: template.subject_de || template.subject_en || template.subject,
body_html_de: template.body_html_de || template.body_html_en || template.body_html,
body_text_de: template.body_text_de || template.body_text_en || template.body_text,
variables: template.variables ? JSON.parse(template.variables) : [],
updated_at: template.updated_at
}));
const formattedTemplates = templates.map(template => {
const result = {
id: template.id,
template_key: template.template_key,
variables: template.variables ? JSON.parse(template.variables) : [],
updated_at: template.updated_at
};
// Handle both old and new schema formats
if (template.subject_en !== undefined) {
// New schema with language columns
result.subject_en = template.subject_en;
result.body_html_en = template.body_html_en;
result.body_text_en = template.body_text_en;
result.subject_de = template.subject_de;
result.body_html_de = template.body_html_de;
result.body_text_de = template.body_text_de;
} else {
// Old schema - use basic columns for both languages
result.subject_en = template.subject;
result.body_html_en = template.body_html;
result.body_text_en = template.body_text;
result.subject_de = template.subject;
result.body_html_de = template.body_html;
result.body_text_de = template.body_text;
}
return result;
});
res.json(formattedTemplates);
} catch (error) {
@@ -193,20 +208,34 @@ router.get('/templates/:key', adminAuth, async (req, res) => {
return res.status(404).json({ error: 'Template not found' });
}
res.json({
// Handle both old and new schema formats
const response = {
id: template.id,
template_key: template.template_key,
// English versions
subject_en: template.subject_en || template.subject,
body_html_en: template.body_html_en || template.body_html,
body_text_en: template.body_text_en || template.body_text,
// German versions
subject_de: template.subject_de || template.subject_en || template.subject,
body_html_de: template.body_html_de || template.body_html_en || template.body_html,
body_text_de: template.body_text_de || template.body_text_en || template.body_text,
variables: template.variables ? JSON.parse(template.variables) : [],
updated_at: template.updated_at
});
};
// Check which columns exist and use them appropriately
if (template.subject_en !== undefined) {
// New schema with language columns
response.subject_en = template.subject_en;
response.body_html_en = template.body_html_en;
response.body_text_en = template.body_text_en;
response.subject_de = template.subject_de;
response.body_html_de = template.body_html_de;
response.body_text_de = template.body_text_de;
} else {
// Old schema - use basic columns for both languages
response.subject_en = template.subject;
response.body_html_en = template.body_html;
response.body_text_en = template.body_text;
response.subject_de = template.subject;
response.body_html_de = template.body_html;
response.body_text_de = template.body_text;
}
res.json(response);
} catch (error) {
console.error('Email template fetch error:', error);
res.status(500).json({ error: 'Failed to fetch email template' });
@@ -237,13 +266,39 @@ router.put('/templates/:key', [
updated_at: new Date()
};
// Only update provided fields
if (subject_en !== undefined) updateData.subject_en = subject_en;
if (subject_de !== undefined) updateData.subject_de = subject_de;
if (body_html_en !== undefined) updateData.body_html_en = body_html_en;
if (body_html_de !== undefined) updateData.body_html_de = body_html_de;
if (body_text_en !== undefined) updateData.body_text_en = body_text_en || '';
if (body_text_de !== undefined) updateData.body_text_de = body_text_de || '';
// Check which columns exist in the database
const template = await db('email_templates')
.where('template_key', req.params.key)
.first();
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
// Determine schema type and update accordingly
if (template.subject_en !== undefined) {
// New schema with language columns
if (subject_en !== undefined) updateData.subject_en = subject_en;
if (subject_de !== undefined) updateData.subject_de = subject_de;
if (body_html_en !== undefined) updateData.body_html_en = body_html_en;
if (body_html_de !== undefined) updateData.body_html_de = body_html_de;
if (body_text_en !== undefined) updateData.body_text_en = body_text_en || '';
if (body_text_de !== undefined) updateData.body_text_de = body_text_de || '';
// Also update basic columns if they exist
if (template.subject !== undefined) {
updateData.subject = subject_en || updateData.subject_en;
updateData.body_html = body_html_en || updateData.body_html_en;
updateData.body_text = body_text_en || updateData.body_text_en || '';
}
} else {
// Old schema - only update basic columns
if (subject_en !== undefined) {
updateData.subject = subject_en;
updateData.body_html = body_html_en;
updateData.body_text = body_text_en || '';
}
}
const updated = await db('email_templates')
.where('template_key', req.params.key)
+110
View File
@@ -0,0 +1,110 @@
version: '3.8'
services:
backend:
image: 'registry.local.nothaft.cloud/picpeak-backend:latest'
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_CLIENT=pg
- DB_HOST=db
- DB_PORT=5432
- DB_USER=${DB_USER:-picpeak}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME:-picpeak}
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- ADMIN_URL=https://picpeak.local.nothaft.cloud
- FRONTEND_URL=https://picpeak.local.nothaft.cloud
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM}
- UMAMI_URL=${UMAMI_URL}
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
- STORAGE_PATH=/app/storage
- EVENTS_PATH=/app/storage/events
- ARCHIVE_PATH=/app/storage/events/archived
volumes:
- '/mnt/DockerMount/picpeak/storage:/app/storage'
- '/mnt/DockerMount/picpeak/data:/app/data'
- '/mnt/DockerMount/picpeak/logs:/app/logs'
networks:
- picpeak
- proxy
deploy:
labels:
- traefik.enable=true
- traefik.docker.network=proxy
# Backend API routing WITHOUT path stripping
- 'traefik.http.routers.picpeak-backend.rule=(Host(`picpeak.nothaft.cloud`) || Host(`picpeak.local.nothaft.cloud`)) && PathPrefix(`/api`)'
- traefik.http.routers.picpeak-backend.entrypoints=https
- traefik.http.routers.picpeak-backend.tls.certresolver=dns
- traefik.http.services.picpeak-backend.loadbalancer.server.port=3000
# Remove the stripprefix middleware - backend expects /api prefix
# - traefik.http.middlewares.picpeak-stripprefix.stripprefix.prefixes=/api
# - traefik.http.routers.picpeak-backend.middlewares=picpeak-stripprefix
- traefik.http.routers.picpeak-backend.priority=100
- homepage.group=Public Services
- homepage.name=PicPeak Backend
- homepage.icon=mdi-api
- 'homepage.href=https://picpeak.local.nothaft.cloud/api/health'
- homepage.description=PicPeak API Backend
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
depends_on:
- db
frontend:
image: 'registry.local.nothaft.cloud/picpeak-frontend:latest'
restart: unless-stopped
depends_on:
- backend
networks:
- picpeak
- proxy
deploy:
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.picpeak.rule=Host(`picpeak.nothaft.cloud`) || Host(`picpeak.local.nothaft.cloud`)
- traefik.http.routers.picpeak.entrypoints=https
- traefik.http.routers.picpeak.tls.certresolver=dns
- traefik.http.services.picpeak.loadbalancer.server.port=80
- traefik.http.routers.picpeak.priority=10
- homepage.group=Public Services
- homepage.name=PicPeak
- homepage.icon=mdi-photo
- 'homepage.href=https://picpeak.local.nothaft.cloud/'
- homepage.description=Photo Sharing System
db:
image: 'postgres:14-alpine'
restart: unless-stopped
environment:
- POSTGRES_USER=${DB_USER:-picpeak}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-picpeak}
- POSTGRES_HOST_AUTH_METHOD=${PG_AUTH_METHOD:-scram-sha-256}
- POSTGRES_INITDB_ARGS=${PG_INIT_ARGS:---auth-host=scram-sha-256 --auth-local=trust}
volumes:
- '/mnt/DockerMount/picpeak/db:/var/lib/postgresql/data'
networks:
- picpeak
command: ${PG_COMMANDS:-postgres -c ssl=off}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak}"]
interval: 10s
timeout: 5s
retries: 5
networks:
proxy:
external: true
picpeak:
driver: bridge
+131
View File
@@ -0,0 +1,131 @@
version: '3.8'
services:
backend:
image: 'registry.local.nothaft.cloud/picpeak-backend:latest'
restart: unless-stopped
environment:
- NODE_ENV=production
- DATABASE_CLIENT=pg
- DB_HOST=db
- DB_PORT=5432
- DB_USER=${DB_USER:-picpeak}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME:-picpeak}
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- ADMIN_URL=https://picpeak.local.nothaft.cloud
- FRONTEND_URL=https://picpeak.local.nothaft.cloud
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM}
- UMAMI_URL=${UMAMI_URL}
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
- STORAGE_PATH=/app/storage
- EVENTS_PATH=/app/storage/events
- ARCHIVE_PATH=/app/storage/events/archived
volumes:
- '/mnt/DockerMount/picpeak/storage:/app/storage'
- '/mnt/DockerMount/picpeak/data:/app/data'
- '/mnt/DockerMount/picpeak/logs:/app/logs'
networks:
- picpeak
- proxy
deploy:
labels:
- traefik.enable=true
- traefik.docker.network=proxy
# Backend API routing
- 'traefik.http.routers.picpeak-backend.rule=(Host(`picpeak.nothaft.cloud`) || Host(`picpeak.local.nothaft.cloud`)) && PathPrefix(`/api`)'
- traefik.http.routers.picpeak-backend.entrypoints=https
- traefik.http.routers.picpeak-backend.tls=true
- traefik.http.routers.picpeak-backend.tls.certresolver=dns
- traefik.http.services.picpeak-backend.loadbalancer.server.port=3000
# Strip /api prefix when forwarding to backend
- traefik.http.middlewares.picpeak-stripprefix.stripprefix.prefixes=/api
- traefik.http.routers.picpeak-backend.middlewares=picpeak-stripprefix
# Higher priority for API routes
- traefik.http.routers.picpeak-backend.priority=100
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
depends_on:
- db
frontend:
image: 'registry.local.nothaft.cloud/picpeak-frontend:latest'
restart: unless-stopped
depends_on:
- backend
networks:
- picpeak
- proxy
deploy:
labels:
- traefik.enable=true
- traefik.docker.network=proxy
# Frontend routing (catch-all for non-API routes)
- traefik.http.routers.picpeak.rule=Host(`picpeak.nothaft.cloud`) || Host(`picpeak.local.nothaft.cloud`)
- traefik.http.routers.picpeak.entrypoints=https
- traefik.http.routers.picpeak.tls=true
- traefik.http.routers.picpeak.tls.certresolver=dns
- traefik.http.services.picpeak.loadbalancer.server.port=80
# Lower priority than backend to ensure /api routes go to backend
- traefik.http.routers.picpeak.priority=10
db:
image: 'postgres:14-alpine'
restart: unless-stopped
environment:
- POSTGRES_USER=${DB_USER:-picpeak}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-picpeak}
- POSTGRES_HOST_AUTH_METHOD=scram-sha-256
- POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=trust
volumes:
- '/mnt/DockerMount/picpeak/db:/var/lib/postgresql/data'
# Mount init script to create umami database
- ./docker/postgres-init:/docker-entrypoint-initdb.d:ro
networks:
- picpeak
command: postgres -c ssl=off
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak}"]
interval: 10s
timeout: 5s
retries: 5
# Optional: Umami analytics
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${DB_USER:-picpeak}:${DB_PASSWORD}@db:5432/umami
DATABASE_TYPE: postgresql
HASH_SALT: ${UMAMI_HASH_SALT}
depends_on:
db:
condition: service_healthy
networks:
- picpeak
- proxy
deploy:
labels:
- traefik.enable=true
- traefik.docker.network=proxy
- traefik.http.routers.picpeak-umami.rule=Host(`analytics.picpeak.local.nothaft.cloud`)
- traefik.http.routers.picpeak-umami.entrypoints=https
- traefik.http.routers.picpeak-umami.tls=true
- traefik.http.routers.picpeak-umami.tls.certresolver=dns
- traefik.http.services.picpeak-umami.loadbalancer.server.port=3000
networks:
proxy:
external: true
picpeak:
driver: bridge
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.13",
"version": "1.0.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.13",
"version": "1.0.17",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-link": "^2.25.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.13",
"version": "1.0.17",
"type": "module",
"scripts": {
"dev": "vite",