Skip to content

fix: normalize email to lowercase on register and login - #322

Merged
EmmanuelOchaje merged 4 commits into
StellarTickets:mainfrom
drakeo338:claude/fix-47-normalize-email
Sep 30, 2026
Merged

EmmanuelOchaje merged 4 commits into
StellarTickets:mainfrom
drakeo338:claude/fix-47-normalize-email

Conversation

@drakeo338

Copy link
Copy Markdown
Contributor

Fixes #47

Right now @IsEmail() validates an address but leaves its casing and
surrounding whitespace alone. The auth service passes dto.email straight into
both the dedupe findUnique and the create, and the User.email @unique
constraint in Postgres is case-sensitive. So Ada@Example.com and
ada@example.com are treated as two different accounts: the same person can
register twice, and someone who signed up with one casing can be locked out when
they log in with another. Leading/trailing whitespace has the same effect and,
worse, ada@example.com fails @IsEmail() outright with a 400.

What this does: it normalizes the email input (trim, then lowercase) before
validation runs, on register, login, and the GET /users/lookup query. I did
this with a class-transformer @Transform on the email field of each DTO.
ValidationPipe is already configured with transform: true, so the service
layer and the unique constraint now always see one canonical form. This matches
the DTO style already in the repo, which uses class-transformer (@Type in
create-event.dto.ts).

I included the users lookup query on purpose. The issue title says "register and
login," but LookupQuery in users.controller.ts had the identical defect:
@IsEmail() with no normalization, feeding usersService.lookupByEmail(...), so
GET /users/lookup?email=User@x.com would not resolve an account stored as
user@x.com. It is the same one logical change, so leaving it out would have
shipped half a fix. I moved that inline class into users/dto/lookup-query.dto.ts
so it lives next to the other DTOs and can be unit-tested the same way.

The transform keeps a typeof value === 'string' guard and returns the value
untouched otherwise, so a missing or non-string email still falls through to
@IsEmail() and is rejected exactly as before, rather than throwing on
undefined.toLowerCase(). There is a test that locks that behavior in.

Tests: I added assertions to the existing register.dto.spec.ts,
login.dto.spec.ts, and a new lookup-query.dto.spec.ts that feed a mixed-case,
whitespace-padded address and assert the normalized result, plus the
non-string passthrough case. Each normalization test fails if the transform is
removed, so they actually pin the behavior.

On the migration the issue mentions: I deliberately did not add a data backfill.
This repo is pre-release (0.0.1, a single init migration, no meaningful
production rows), and a blind UPDATE users SET email = lower(email) can collide
on the unique constraint if two case-variant rows already exist. Resolving that
needs a dedupe/merge decision that is really the maintainer's call, so it does
not belong in a good-first-issue. The consequence worth naming in the same
breath: once login normalizes input, any pre-existing mixed-case row would not be
able to authenticate until such a backfill runs. That is fine here given there
are effectively no real rows, but I want it stated rather than discovered. Happy
to send the backfill (with the dedupe handling) as a follow-up if you want it.

@isemail() validates an address but leaves its casing and surrounding
whitespace untouched, so `Ada@Example.com` and `ada@example.com` are
treated as different emails. The auth service passes dto.email straight
into both the dedupe findUnique and the create, and the User.email
@unique constraint in Postgres is case-sensitive. The same person can
register twice under different casing, and someone who registered as
`Ada@Example.com` is locked out when they log in as `ada@example.com`.
Leading/trailing whitespace has the same effect, and a padded address
fails @isemail() outright with a 400.

Add a class-transformer @Transform to the email field that trims and
lowercases the value before validation. ValidationPipe runs with
transform: true, so the service and the unique constraint now always see
one canonical address. This matches the DTO style already in the repo,
which uses class-transformer (@type in create-event.dto.ts).

Apply the same normalization to the users lookup query, which had the
identical defect: GET /users/lookup?email=User@x.com would not resolve an
account stored as user@x.com. Move the inline LookupQuery class into
users/dto/lookup-query.dto.ts so it sits with the other DTOs and can be
unit-tested the same way. The transform keeps a typeof guard so a
non-string email still falls through to @isemail() instead of throwing.
@netlify

netlify Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for stellarticketsbackend ready!

Name Link
🔨 Latest commit 8996d8d
🔍 Latest deploy log https://app.netlify.com/projects/stellarticketsbackend/deploys/6aba5f47be44940008687963
😎 Deploy Preview https://deploy-preview-322--stellarticketsbackend.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

…lize-email

# Conflicts:
#	src/users/users.controller.ts
…lize-email

# Conflicts:
#	src/auth/dto/register.dto.ts
email had no initializer and no definite assignment assertion, so
`tsc --noEmit` fails under strict mode (TS2564). login.dto.ts and
register.dto.ts already use the `!` form; match it here.
@drakeo338

Copy link
Copy Markdown
Contributor Author

Checking in on this one: the branch still merges cleanly and the deploy preview passes. Happy to change anything if needed.

@EmmanuelOchaje
EmmanuelOchaje merged commit 641c64e into StellarTickets:main Sep 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Normalize email to lowercase on register and login

2 participants