fix: per-field template guard, LIKE escaping, wait for all uploads

Codex review round 1 on #1266.

Migration 194 gated all three German fields on body_html alone, so an admin
who had translated only the subject would lose it the moment the HTML still
matched English -- and down() is a deliberate no-op, making that loss
unrecoverable. Each field is now judged independently, for both the
translations table and the legacy _de columns.

Archives search escapes LIKE wildcards. % and _ are literal characters to the
client-side includes() this replaced but wildcards to LIKE, so searching
"100%" matched every archive and reported a nonsense total. The ESCAPE clause
is load-bearing: SQLite has no default LIKE escape character, so without it
the escaped pattern matches literal backslashes there while working on PG.

The post-upload poll waits for every queued file. Each is processed
independently, so stopping at the first new photo left the rest of a
multi-file upload hidden until a manual refresh -- the exact symptom the
polling was added to prevent. UserPhotoUpload now reports how many files the
server accepted.

(The latter two are superseded by stronger fixes in #1267 -- the upload-status
endpoint and the shared escape helper -- but each PR has to be correct on its
own.)
This commit is contained in:
Paul Nothaft
2026-09-02 08:41:36 +02:00
parent 6e5755de02
commit da8fcc82ef
5 changed files with 59 additions and 24 deletions
@@ -266,14 +266,18 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ 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();
}
};
@@ -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<UserPhotoUploadProps> = ({
if (successCount > 0) {
toast.success(t('toast.uploadSuccess') + ` (${successCount} ${t('common.photos')})`);
onUploadComplete();
onUploadComplete(successCount);
}
if (failedCount > 0) {
@@ -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');
});