Makes `users.email` unique, de-duplicates the addresses an upgrade will find, and builds the self-service change-and-verify flow that did not exist. The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED` column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan specified. Every case-insensitive collation this server offers is also accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are two different mailboxes. The plan's index would have refused the second address forever and the de-duplication would have nulled a legitimate account's. A requested address is STAGED in `email_pending` and only a tokened link installs it, so a typo cannot silently redirect account-recovery mail. `isDuplicateUsername()` now distinguishes the two indexes. All five call sites branch on it; each answers differently on purpose, because a public form, an IdP callback, a half-completed invite and an admin screen do not owe the same person the same amount of truth. SSO reads the IdP's actual `email_verified`/`verified` claim instead of inferring verification from an address merely being present. Co-Authored-By: Claude <noreply@anthropic.com>
23 lines
886 B
JavaScript
23 lines
886 B
JavaScript
const { query } = require('../../utils/db')
|
|
|
|
const COLS = 'id, user_id, username, lost_address, cleared_at, acknowledged_at'
|
|
|
|
// Accounts cleared by the Phase 1b de-duplication, newest first.
|
|
async function list() {
|
|
return query(`SELECT ${COLS} FROM email_dedupe_report ORDER BY cleared_at DESC, id DESC`)
|
|
}
|
|
|
|
async function countUnacknowledged() {
|
|
const rows = await query('SELECT COUNT(*) AS n FROM email_dedupe_report WHERE acknowledged_at IS NULL')
|
|
return Number(rows[0] ? rows[0].n : 0)
|
|
}
|
|
|
|
// Dismiss the whole report. Idempotent — an already-acknowledged row is skipped
|
|
// so a second dismissal cannot rewrite when it happened.
|
|
async function acknowledgeAll() {
|
|
const res = await query('UPDATE email_dedupe_report SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL')
|
|
return res.affectedRows || 0
|
|
}
|
|
|
|
module.exports = { list, countUnacknowledged, acknowledgeAll }
|