Compare commits

...

4 Commits

Author SHA1 Message Date
Gitea Actions Bot 141acd5736 chore: bump version to 1.0.48 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 09:17:20 +00:00
paul 5f4337a18d fix: resolve multiple production issues
Mirror to GitHub / mirror (push) Successful in 23s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 3s
- Fix archive page showing "00" instead of "0" for empty photo counts
- Fix email queue PostgreSQL "updated_at" column error with migration and fallback
- Fix storage calculation discrepancies between system status and archive pages
- Add proper storage info to system status endpoint
- Fix archive path concatenation in settings endpoint

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 11:12:32 +02:00
Gitea Actions Bot fec7b687f7 chore: bump version to 1.0.47 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 08:23:21 +00:00
paul cfa29ad5cb fix: GUI improvements and fixes
Mirror to GitHub / mirror (push) Successful in 19s
Test and Lint / backend-test (push) Successful in 1m12s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m6s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 2s
- Enable JSON module imports in TypeScript config to fix frontend version display
- Fix archive page showing '00' instead of '0' for empty photo counts
- Add null safety to archive photo count calculation
- Update email processor to reinitialize on config changes
- Add auto-retry for failed email transporter initialization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 10:18:52 +02:00
19 changed files with 323 additions and 28 deletions
+3
View File
@@ -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
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.46",
"version": "1.0.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.46",
"version": "1.0.48",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.46",
"version": "1.0.48",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+99
View File
@@ -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();
+9 -5
View File
@@ -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);
}
}
}
+32
View File
@@ -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
@@ -75,6 +76,32 @@ router.get('/status', adminAuth, async (req, res) => {
// 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,6 +132,11 @@ router.get('/status', adminAuth, async (req, res) => {
activityLogs: activityCount.count
}
},
storage: {
totalUsed: totalStorage,
photoStorage: parseInt(totalPhotoStorage) || 0,
archiveStorage: archiveStorage
},
emailQueue: {
pending: pendingEmails.count,
sent: sentEmails.count,
+55 -12
View File
@@ -3,9 +3,17 @@ 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 +22,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,9 +46,14 @@ 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;
}
}
@@ -256,11 +279,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
@@ -304,6 +326,16 @@ async function sendTemplateEmail(to, templateKey, variables) {
// Process email queue
async function processEmailQueue() {
try {
// 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;
}
}
const pendingEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
@@ -337,13 +369,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);
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.45",
"version": "1.0.48",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.45",
"version": "1.0.48",
"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.45",
"version": "1.0.48",
"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;
+1 -1
View File
@@ -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>
)}
+8 -1
View File
@@ -264,6 +264,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 +364,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",
+7
View File
@@ -282,6 +282,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 +341,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",
+4 -1
View File
@@ -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" />
+58 -1
View File
@@ -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';
@@ -232,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">
@@ -47,6 +47,11 @@ export interface SystemStatus {
activityLogs: number;
};
};
storage: {
totalUsed: number;
photoStorage: number;
archiveStorage: number;
};
emailQueue: {
pending: number;
sent: number;
+1
View File
@@ -10,6 +10,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,