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
@@ -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);
}
}
};