diff --git a/backend/migrations/core/194_german_gallery_created_translation.js b/backend/migrations/core/194_german_gallery_created_translation.js index c9ff7ae3..15b97421 100644 --- a/backend/migrations/core/194_german_gallery_created_translation.js +++ b/backend/migrations/core/194_german_gallery_created_translation.js @@ -79,30 +79,44 @@ exports.up = async function(knex) { created_at: now, updated_at: now, }); - } else if (isUntranslated(deRow.body_html, englishHtml)) { - await knex('email_template_translations') - .where({ id: deRow.id }) - .update({ - subject: SUBJECT_DE, - body_html: HTML_DE, - body_text: TEXT_DE, - updated_at: now, - }); + } else { + // Each field is judged on its own. Gating all three on body_html would + // overwrite a subject the admin had already translated whenever the HTML + // still matched English — and down() is a deliberate no-op, so that loss + // would be unrecoverable. + const englishSubject = (enRow && enRow.subject) || master.subject_en || master.subject || ''; + const englishText = (enRow && enRow.body_text) || master.body_text_en || master.body_text || ''; + const patch = {}; + if (isUntranslated(deRow.subject, englishSubject)) patch.subject = SUBJECT_DE; + if (isUntranslated(deRow.body_html, englishHtml)) patch.body_html = HTML_DE; + if (isUntranslated(deRow.body_text, englishText)) patch.body_text = TEXT_DE; + + if (Object.keys(patch).length > 0) { + patch.updated_at = now; + await knex('email_template_translations').where({ id: deRow.id }).update(patch); + } } } // Legacy per-language columns on the master row — still the fallback path in // emailProcessor.processTemplate when the translations table is unavailable. const cols = await knex('email_templates').columnInfo(); - if (cols.body_html_de && isUntranslated(master.body_html_de, master.body_html_en)) { - await knex('email_templates') - .where({ id: master.id }) - .update({ - subject_de: SUBJECT_DE, - body_html_de: HTML_DE, - body_text_de: TEXT_DE, - updated_at: now, - }); + if (cols.body_html_de) { + // Same per-field rule as the translations table above. + const legacyPatch = {}; + if (cols.subject_de && isUntranslated(master.subject_de, master.subject_en)) { + legacyPatch.subject_de = SUBJECT_DE; + } + if (isUntranslated(master.body_html_de, master.body_html_en)) { + legacyPatch.body_html_de = HTML_DE; + } + if (cols.body_text_de && isUntranslated(master.body_text_de, master.body_text_en)) { + legacyPatch.body_text_de = TEXT_DE; + } + if (Object.keys(legacyPatch).length > 0) { + legacyPatch.updated_at = now; + await knex('email_templates').where({ id: master.id }).update(legacyPatch); + } } }; diff --git a/backend/src/routes/adminArchives.js b/backend/src/routes/adminArchives.js index 0aa6a5b8..8fefff4c 100644 --- a/backend/src/routes/adminArchives.js +++ b/backend/src/routes/adminArchives.js @@ -25,9 +25,19 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res) // Search and type filtering run in SQL so both the returned rows and // the total count cover the whole archive table, not just the page the // client happens to be on. Values are bound, never interpolated. + // % and _ are wildcards to LIKE but literal characters to the client-side + // `includes()` this replaced, so searching for "100%" would otherwise match + // every archive and report a nonsense total. The ESCAPE clause is + // load-bearing rather than decorative: SQLite has no default LIKE escape + // character, so without it the escaped pattern matches literal backslashes + // there while working on Postgres. + const escapeLike = (value) => value.replace(/[\\%_]/g, '\\$&'); const applyFilters = (query) => { if (search) { - query.whereRaw('LOWER(events.event_name) LIKE ?', [`%${search.toLowerCase()}%`]); + query.whereRaw( + 'LOWER(events.event_name) LIKE ? ESCAPE \'\\\'', + [`%${escapeLike(search.toLowerCase())}%`] + ); } if (type && type !== 'all') { query.where('events.event_type', type); diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 11687194..cf3f93da 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -266,14 +266,18 @@ export const GalleryView: React.FC = ({ slug, event, requiresP }; useEffect(() => stopUploadRefresh, []); - const handleUploadComplete = () => { + const handleUploadComplete = (queuedCount = 1) => { setShowUploadModal(false); const baseline = data?.photos?.length ?? 0; + // Each file is processed independently, so stopping at the FIRST new photo + // leaves the rest of a multi-file upload hidden until a manual refresh — + // the very symptom this polling exists to prevent. Wait for all of them. + const target = baseline + Math.max(1, queuedCount); const deadline = Date.now() + 60_000; stopUploadRefresh(); const poll = async () => { const result = await refetch(); - if ((result.data?.photos?.length ?? 0) > baseline || Date.now() > deadline) { + if ((result.data?.photos?.length ?? 0) >= target || Date.now() > deadline) { stopUploadRefresh(); } }; diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx index e909526b..736d4d39 100644 --- a/frontend/src/components/gallery/UserPhotoUpload.tsx +++ b/frontend/src/components/gallery/UserPhotoUpload.tsx @@ -10,7 +10,8 @@ import { extensionsToMimeTypes, buildUploadAcceptString, extensionsToLabel } fro interface UserPhotoUploadProps { eventId: number; categoryId: number | null | undefined; - onUploadComplete: () => void; + /** Receives how many files the server accepted, so the caller can wait for all of them. */ + onUploadComplete: (queuedCount: number) => void; onClose: () => void; } @@ -189,7 +190,7 @@ export const UserPhotoUpload: React.FC = ({ if (successCount > 0) { toast.success(t('toast.uploadSuccess') + ` (${successCount} ${t('common.photos')})`); - onUploadComplete(); + onUploadComplete(successCount); } if (failedCount > 0) { diff --git a/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts b/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts index 2e1bcc85..e84631ce 100644 --- a/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts +++ b/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts @@ -36,7 +36,13 @@ describe('post-upload photo refresh', () => { expect(handler).toMatch(/setInterval\(poll/); // Bounded: stop once the new photos land, and stop regardless after the // deadline so a failed background job can't leave a poll running forever. - expect(handler).toContain('> baseline'); + // + // Waits for ALL queued files, not just the first. Each is processed + // independently, so a `> baseline` comparison stops at photo 1 of N and + // leaves the rest hidden until a manual refresh — the exact symptom this + // polling exists to prevent. + expect(handler).toContain('baseline + Math.max(1, queuedCount)'); + expect(handler).toContain('>= target'); expect(handler).toContain('Date.now() > deadline'); });