feat: pre-zip download all and photo replacement by name (#312, #313)

Pre-zip downloads:
- Generate ZIP in background after photo mutations (upload/delete/watermark change)
- Serve cached zip with Content-Length for instant downloads and native progress bar
- Falls back to on-the-fly streaming when no cache exists yet
- Frontend uses browser-native download when zip is ready (no blob buffering)
- New downloadZipService with debounced regeneration and in-memory locking

Photo replacement:
- Admin upload form gets "Replace existing photos with same name" checkbox
- Matches by original_filename (case-insensitive) within the same event
- Preserves photo ID, position, feedback, category, and visibility
- Updates file, thumbnail, dimensions, EXIF capture date on replacement
- Ambiguous matches (multiple photos with same name) skip replacement with warning
- New photoReplacementService with findReplacementCandidate and replacePhoto
This commit is contained in:
Paul Nothaft
2026-04-23 16:49:31 +02:00
parent 4353acebf9
commit e18afd3e6b
16 changed files with 664 additions and 36 deletions
@@ -0,0 +1,26 @@
/**
* Add download ZIP cache columns to events table.
*
* Enables pre-generated ZIP files for "Download All" so guests get
* instant downloads with Content-Length instead of on-the-fly streaming.
*/
exports.up = async function(knex) {
const hasZipPath = await knex.schema.hasColumn('events', 'download_zip_path');
if (!hasZipPath) {
await knex.schema.alterTable('events', (table) => {
table.text('download_zip_path').nullable().defaultTo(null);
table.datetime('download_zip_generated_at').nullable().defaultTo(null);
});
}
};
exports.down = async function(knex) {
const hasZipPath = await knex.schema.hasColumn('events', 'download_zip_path');
if (hasZipPath) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('download_zip_path');
table.dropColumn('download_zip_generated_at');
});
}
};