From 247277e139971126456c5e53bbd9ae02ceae46f5 Mon Sep 17 00:00:00 2001 From: kristianwind Date: Sun, 30 Aug 2026 09:25:50 +0200 Subject: [PATCH] feat(users): always capitalise a display name's first letter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Andreas signed up as "andreas" and was shown that way everywhere. A name is now capitalised on the way in — the first letter of each word raised, the rest left as typed — in CreateUser (signup and invite-accept) and UpdateProfile, so display, mail and export all agree. Existing accounts are fixed by an idempotent startup pass (NormalizeUserNames), the same shape as SealNotes: the rule is Unicode-aware and per word, which SQLite's UPPER cannot spell, so it lives in Go rather than a migration. Co-Authored-By: Claude Opus 4.8 --- cmd/verdande/main.go | 10 +++++ internal/store/names_test.go | 81 ++++++++++++++++++++++++++++++++++++ internal/store/users.go | 73 ++++++++++++++++++++++++++++++++ web/e2e/smoke.spec.js | 8 ++-- 4 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 internal/store/names_test.go diff --git a/cmd/verdande/main.go b/cmd/verdande/main.go index b75b67c..e423288 100644 --- a/cmd/verdande/main.go +++ b/cmd/verdande/main.go @@ -80,6 +80,16 @@ func run() error { log.Info("notes sealed", "count", sealed) } + // Names created before the app capitalised them on the way in — "andreas" + // rather than "Andreas". Idempotent, so it is a cheap pass of names once it has + // caught up. A failure here does not stop the start: a lower-case first name is + // a blemish, not a broken instance. + if fixed, err := db.NormalizeUserNames(context.Background()); err != nil { + log.Error("normalize user names", "err", err) + } else if fixed > 0 { + log.Info("user names normalized", "count", fixed) + } + httpapi.Version = version api := httpapi.New(cfg, db, log, webAssets(log)) diff --git a/internal/store/names_test.go b/internal/store/names_test.go new file mode 100644 index 0000000..db40cd2 --- /dev/null +++ b/internal/store/names_test.go @@ -0,0 +1,81 @@ +package store + +import ( + "context" + "testing" +) + +func TestNormalizeName(t *testing.T) { + cases := map[string]string{ + "andreas": "Andreas", + "Andreas": "Andreas", + "anne mette": "Anne Mette", + " kristian ": "Kristian", + "anne mette": "Anne Mette", + "McDonald": "McDonald", // an existing inner capital is kept + "óscar": "Óscar", // Unicode initial + "æblegaard": "Æblegaard", + "": "", + " ": "", + "mette-marie sø": "Mette-marie Sø", // only word-initials are raised + "iPhone listen": "IPhone Listen", // deliberately: the first letter is raised + } + for in, want := range cases { + if got := NormalizeName(in); got != want { + t.Errorf("NormalizeName(%q) = %q, want %q", in, got, want) + } + } +} + +func TestCreateUserCapitalisesName(t *testing.T) { + db := openTest(t) + ctx := context.Background() + + u := &User{Email: "andreas@example.dk", Name: "andreas", PasswordHash: "x"} + if err := db.CreateUser(ctx, u, "Inbox"); err != nil { + t.Fatalf("create user: %v", err) + } + got, err := db.UserByEmail(ctx, "andreas@example.dk") + if err != nil { + t.Fatalf("read back: %v", err) + } + if got.Name != "Andreas" { + t.Errorf("stored name = %q, want %q", got.Name, "Andreas") + } +} + +func TestNormalizeUserNamesBackfill(t *testing.T) { + db := openTest(t) + ctx := context.Background() + + // An account as it looked before the app capitalised names: made properly, + // then set back to lower case behind the store's back to stand in for an old row. + u := &User{Email: "a@example.dk", Name: "Andreas", PasswordHash: "x"} + if err := db.CreateUser(ctx, u, "Inbox"); err != nil { + t.Fatalf("create user: %v", err) + } + if _, err := db.ExecContext(ctx, `UPDATE users SET name = 'andreas' WHERE id = ?`, u.ID); err != nil { + t.Fatalf("downgrade name: %v", err) + } + + fixed, err := db.NormalizeUserNames(ctx) + if err != nil { + t.Fatalf("normalize: %v", err) + } + if fixed != 1 { + t.Fatalf("fixed = %d, want 1", fixed) + } + got, _ := db.UserByID(ctx, u.ID) + if got.Name != "Andreas" { + t.Errorf("name after backfill = %q, want %q", got.Name, "Andreas") + } + + // Idempotent: nothing left to do on a second pass. + again, err := db.NormalizeUserNames(ctx) + if err != nil { + t.Fatalf("normalize again: %v", err) + } + if again != 0 { + t.Errorf("second pass fixed = %d, want 0", again) + } +} diff --git a/internal/store/users.go b/internal/store/users.go index bde261e..2d757fa 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -8,6 +8,7 @@ import ( "fmt" "strings" "time" + "unicode" "github.com/kristianwind/verdande/internal/auth" ) @@ -62,6 +63,26 @@ func NormalizeEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } +// NormalizeName trims a display name and capitalises the first letter of every +// word, leaving the rest of each word exactly as typed. So "andreas" becomes +// "Andreas" and "anne mette" becomes "Anne Mette", while a name that already +// carries a capital in the middle — "McDonald", an "iPhone" — keeps it: only the +// first letter of a word is ever raised, never lowered. +// +// A name is the person's to spell; the one thing the instance insists on is that +// it does not start in lower case, because a column of lower-case first names +// reads as a bug the person cannot fix from their own keyboard. Unicode-aware, so +// "æblegaard" and "óscar" are raised the same as an ASCII initial. +func NormalizeName(name string) string { + fields := strings.Fields(name) + for i, f := range fields { + r := []rune(f) + r[0] = unicode.ToUpper(r[0]) + fields[i] = string(r) + } + return strings.Join(fields, " ") +} + // CreateUser inserts a user and gives them an Inbox, in one transaction. // // The Inbox is created here rather than lazily on first use because every other @@ -70,6 +91,7 @@ func NormalizeEmail(email string) string { // of them end up racing to create the second one. func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error { u.Email = NormalizeEmail(u.Email) + u.Name = NormalizeName(u.Name) if u.ID == "" { u.ID = NewID() } @@ -473,6 +495,7 @@ func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionI // invites were sent to, and changing it is a re-verification flow rather than a // field. Neither is is_admin — an account cannot promote itself. func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale string) error { + name = NormalizeName(name) res, err := db.ExecContext(ctx, `UPDATE users SET name = ?, timezone = ?, locale = ?, updated_at = ? WHERE id = ?`, name, timezone, locale, time.Now().Unix(), userID) @@ -485,6 +508,56 @@ func (db *DB) UpdateProfile(ctx context.Context, userID, name, timezone, locale return nil } +// NormalizeUserNames capitalises the first letter of every existing user's name, +// for the accounts created before the app started doing it on the way in. +// +// Idempotent: a name already normalised is left untouched, so it costs one pass +// of names at each start and writes nothing once it has caught up. Like SealNotes +// it runs at boot rather than as a migration, because the rule lives in Go — +// Unicode-aware and per word — and SQLite's own UPPER cannot spell it. +// +// `updated_at` is not touched: fixing the spelling of a name the person chose is +// the instance tidying up after itself, not the person editing their profile. +func (db *DB) NormalizeUserNames(ctx context.Context) (int, error) { + rows, err := db.QueryContext(ctx, `SELECT id, name FROM users`) + if err != nil { + return 0, err + } + type pending struct{ id, name string } + var todo []pending + for rows.Next() { + var id, name string + if err := rows.Scan(&id, &name); err != nil { + rows.Close() + return 0, err + } + if n := NormalizeName(name); n != name { + todo = append(todo, pending{id, n}) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + if len(todo) == 0 { + return 0, nil + } + + err = db.Tx(ctx, func(tx *sql.Tx) error { + for _, p := range todo { + if _, err := tx.ExecContext(ctx, + `UPDATE users SET name = ? WHERE id = ?`, p.name, p.id); err != nil { + return err + } + } + return nil + }) + if err != nil { + return 0, err + } + return len(todo), nil +} + func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error { // Sealed, like a mailbox password and for the same reason: the copies, not the // server. A TOTP secret in a downloadable backup lets whoever holds the copy diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 8621c06..6a7abbb 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -925,6 +925,8 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) => const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const andreas = await context.newPage(); await andreas.goto(link); + // Typed in lower case on purpose: the instance capitalises a name on the way + // in, so from here on this person is "Andreas" everywhere they are shown. await andreas.getByLabel('Navn', { exact: true }).fill('andreas'); await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test'); await andreas.getByRole('button', { name: 'Opret konto' }).click(); @@ -943,7 +945,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) => await page.reload(); await projectAction(page, /Delt/); await expect(page.getByText('Ejer')).toBeVisible(); - await page.getByLabel('Rolle for andreas').selectOption('editor'); + await page.getByLabel('Rolle for Andreas').selectOption('editor'); await andreas.reload(); await expect(andreas.getByLabel('Ny opgave')).toBeVisible(); @@ -956,11 +958,11 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) => await page.getByLabel('Ny opgave').press('Enter'); await page.getByText('brænde kaffe', { exact: true }).click(); const drawer = page.getByRole('complementary', { name: 'Opgave' }); - await drawer.getByLabel('Ansvarlig').selectOption({ label: 'andreas' }); + await drawer.getByLabel('Ansvarlig').selectOption({ label: 'Andreas' }); await page.keyboard.press('Escape'); const row = page.locator('.row').filter({ hasText: 'brænde kaffe' }); - await expect(row.getByTitle('Uddelegeret til andreas')).toBeVisible(); + await expect(row.getByTitle('Uddelegeret til Andreas')).toBeVisible(); // And not on a task that is nobody's. await page.getByLabel('Ny opgave').fill('min egen'); await page.getByLabel('Ny opgave').press('Enter');