Skip to content

refactor(api): GraphQL context interceptor for getOrSyncByKeycloakId #122

Description

@rudingma

Summary

Move the userService.getOrSyncByKeycloakId(jwt) call out of individual resolvers and into a GraphQL context factory (or NestJS interceptor) that runs once per authenticated request. Resolvers then read the already-synced user from context.dbUser.

Why

Every authenticated resolver today repeats the pattern:

async myHouseholds(@CurrentUser() jwt: JwtPayload): Promise<Household[]> {
  const user = await this.userService.getOrSyncByKeycloakId(jwt);
  return this.householdService.listForUser(user.id);
}

Today: 2 resolvers × 1 call each = fine.

When the app grows, every future feature adds resolvers that repeat this line. The DB upsert (INSERT ... ON CONFLICT DO UPDATE) is idempotent but not free — every mutation+query for an authenticated user pays for it. Worse, the copy-paste pattern diffuses the auth-sync assumption across the resolver layer; if we ever need to add a side effect around the first-per-request sync (cache warming, audit logging, feature-flag evaluation), every resolver has to be touched.

The design thinking was done during Story A review rounds (Opus round-3, round-4): sync once per request in the GraphQL context, resolvers read context.dbUser directly. Scoping the work here so we don't lose the design.

Scope

  • Apollo GraphQL context factory (or a NestJS ExecutionContext interceptor at the GraphQL layer) that:
    • Reads JwtPayload from the request (same mechanism as @CurrentUser() today)
    • Calls userService.getOrSyncByKeycloakId(jwt) once per request
    • Attaches the resolved DB user to the context: { dbUser: User }
    • Handles the unauthenticated path cleanly (public resolvers like @Public() skip the sync)
  • Update existing resolvers (2 today: UserResolver.me, HouseholdResolver.myHouseholds, HouseholdResolver.createHousehold) to read from context.dbUser instead of calling the service
  • @CurrentDbUser() decorator as a typed accessor (parallel to existing @CurrentUser() for JWT access)

Trigger

Land this before the 3rd authenticated resolver is added. Once 3+ resolvers copy the pattern, refactoring gets more intrusive + the design decisions become noise to re-litigate.

Non-goals

  • Caching user data across requests (that's a different concern; the existing Apollo onConflictDoUpdate upsert is the correct per-request pattern).
  • Unifying JWT verification (already handled by JwtAuthGuard).
  • Changing the public-route convention (@Public() stays as-is).

Context

Flagged by Opus code-reviewer round-3 (confidence Minor→Important — "every future feature will repeat this lookup") and reinforced in round-4 when the two resolvers shipped identical boilerplate. Not ship-blocking for Story A; file here so the next feature PR doesn't add a 3rd copy before we refactor.

References

  • apps/hearthly-api/src/modules/user/resolvers/user.resolver.ts
  • apps/hearthly-api/src/modules/household/resolvers/household.resolver.ts
  • apps/hearthly-api/src/modules/user/user.service.ts (getOrSyncByKeycloakId)
  • apps/hearthly-api/src/modules/auth/decorators/current-user.decorator.ts (existing decorator to mirror)

Shipped from Story A (#113) review rounds.

Metadata

Metadata

Assignees

No one assigned

    Labels

    infraInfrastructure/DevOps

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions