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:
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user