feat: add photo cap per event and Portuguese (pt-BR) locale

- Add photo_cap column to events table (migration 074) to limit photos per event
- Enforce photo cap in upload route, returning 400 when limit exceeded
- Pass photo_cap through all event CRUD routes and frontend forms
- Add complete Portuguese (pt-BR) translation (2300+ strings)
- Register pt locale in i18n config, language selector, date formatting
- Add photoCap/photoCapHelp translation keys to all locale files (en, de, ru, pt)
This commit is contained in:
Paul Nothaft
2026-03-16 17:22:54 +01:00
parent 6aceb40595
commit 1fa222e9c4
19 changed files with 2451 additions and 12 deletions
+4 -2
View File
@@ -42,7 +42,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
color_theme = null,
expiration_days = 30,
allow_user_uploads = false,
upload_category_id = null
upload_category_id = null,
photo_cap = null
} = req.body;
// Validate password strength for gallery
@@ -105,7 +106,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
allow_user_uploads,
upload_category_id
upload_category_id,
photo_cap: photo_cap || null
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
+6 -2
View File
@@ -256,7 +256,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
header_style = 'standard',
hero_divider_style = 'wave',
// Hero image anchor position (#162)
hero_image_anchor = 'center'
hero_image_anchor = 'center',
// Photo cap
photo_cap = null
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
@@ -416,7 +418,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
hero_logo_position: hero_logo_position || 'top',
header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center'
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -479,6 +482,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
photo_cap: photo_cap || null,
share_link: shareUrl,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
+24 -1
View File
@@ -170,7 +170,30 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
}
return res.status(404).json({ error: 'Event not found' });
}
// Enforce photo cap if set
if (event.photo_cap && event.photo_cap > 0) {
const existingPhotoCount = await db('photos')
.where({ event_id: eventId })
.count('id as count')
.first();
const currentCount = parseInt(existingPhotoCount.count) || 0;
const newFilesCount = (req.files && req.files.length) || 0;
if (currentCount + newFilesCount > event.photo_cap) {
// Clean up temp files
if (req.tempUploadPath) {
try {
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
} catch (e) {
console.error('Failed to clean up temp path:', e);
}
}
return res.status(400).json({
error: `Photo cap exceeded. This event allows a maximum of ${event.photo_cap} photos. Currently ${currentCount} photos exist, and you are trying to upload ${newFilesCount} more.`
});
}
}
if (!req.files || req.files.length === 0) {
console.error('No files in request. req.files:', req.files);
console.error('Request body keys:', Object.keys(req.body));
+4 -2
View File
@@ -280,7 +280,8 @@ router.post('/gallery/verify', [
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
require_password: requiresPassword,
photo_cap: event.photo_cap
}
});
} catch (error) {
@@ -353,7 +354,8 @@ router.post('/gallery/share-login', [
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
require_password: requiresPassword,
photo_cap: event.photo_cap
}
});
} catch (error) {
+6 -2
View File
@@ -138,7 +138,9 @@ const createEvent = async (eventData) => {
show_feedback_to_guests,
// Upload settings
allow_user_uploads,
upload_category_id
upload_category_id,
// Photo cap
photo_cap
} = eventData;
const requirePassword = parseBooleanInput(require_password, true);
@@ -207,7 +209,9 @@ const createEvent = async (eventData) => {
show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined,
// Upload settings
allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined,
upload_category_id: upload_category_id || null
upload_category_id: upload_category_id || null,
// Photo cap
photo_cap: photo_cap || null
};
// Remove undefined values
+2
View File
@@ -54,6 +54,8 @@ async function formatDate(date, language = 'en') {
let locale = dateConfig.locale || 'en-GB';
if (language === 'de') {
locale = 'de-DE';
} else if (language === 'pt') {
locale = 'pt-BR';
} else if (language === 'en' && dateConfig.format === 'MM/DD/YYYY') {
locale = 'en-US';
}