From 7ca4bd9cf7b0c1d29bb7aca354a75d3020c97e6e Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Mon, 17 Aug 2026 14:43:13 +0200 Subject: [PATCH] feat: add user jit provisioning and preferences service --- backend/libs/users/src/index.ts | 4 ++ .../users/src/user-preferences.repository.ts | 70 +++++++++++++++++++ .../src/user-preferences.service.spec.ts | 60 ++++++++++++++++ .../users/src/user-preferences.service.ts | 46 ++++++++++++ backend/libs/users/src/user.types.ts | 44 ++++++++++++ backend/libs/users/src/users.module.ts | 18 +++++ backend/libs/users/src/users.repository.ts | 61 ++++++++++++++++ backend/libs/users/src/users.service.spec.ts | 50 +++++++++++++ backend/libs/users/src/users.service.ts | 22 ++++++ backend/package.json | 2 +- pnpm-lock.yaml | 12 ++-- 11 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 backend/libs/users/src/index.ts create mode 100644 backend/libs/users/src/user-preferences.repository.ts create mode 100644 backend/libs/users/src/user-preferences.service.spec.ts create mode 100644 backend/libs/users/src/user-preferences.service.ts create mode 100644 backend/libs/users/src/user.types.ts create mode 100644 backend/libs/users/src/users.module.ts create mode 100644 backend/libs/users/src/users.repository.ts create mode 100644 backend/libs/users/src/users.service.spec.ts create mode 100644 backend/libs/users/src/users.service.ts diff --git a/backend/libs/users/src/index.ts b/backend/libs/users/src/index.ts new file mode 100644 index 0000000..ad36a27 --- /dev/null +++ b/backend/libs/users/src/index.ts @@ -0,0 +1,4 @@ +export * from './user.types'; +export * from './users.service'; +export * from './user-preferences.service'; +export * from './users.module'; diff --git a/backend/libs/users/src/user-preferences.repository.ts b/backend/libs/users/src/user-preferences.repository.ts new file mode 100644 index 0000000..2cada62 --- /dev/null +++ b/backend/libs/users/src/user-preferences.repository.ts @@ -0,0 +1,70 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { Kysely } from 'kysely'; +import { KYSELY_DB } from '../../database/src'; +import type { Database } from '../../database/src'; +import type { UpdateUserPreferenceDto, UserPreference } from './user.types'; + +function toUserPreference(row: { + user_id: string; + preferred_pace: string | null; + preferred_budget_level: string | null; + max_walking_distance_km: string | number | null; + preferred_start_time: string | null; + child_friendly_preferred: boolean; + interests: string[]; + notes: string | null; +}): UserPreference { + return { + userId: row.user_id, + preferredPace: row.preferred_pace, + preferredBudgetLevel: row.preferred_budget_level, + maxWalkingDistanceKm: + row.max_walking_distance_km === null + ? null + : Number(row.max_walking_distance_km), + preferredStartTime: row.preferred_start_time, + childFriendlyPreferred: row.child_friendly_preferred, + interests: row.interests, + notes: row.notes, + }; +} + +@Injectable() +export class UserPreferencesRepository { + constructor(@Inject(KYSELY_DB) private readonly db: Kysely) {} + + async findByUserId(userId: string): Promise { + const row = await this.db + .selectFrom('user_preferences') + .selectAll() + .where('user_id', '=', userId) + .executeTakeFirst(); + return row ? toUserPreference(row) : undefined; + } + + async upsert( + userId: string, + dto: UpdateUserPreferenceDto, + ): Promise { + const values = { + preferred_pace: dto.preferredPace, + preferred_budget_level: dto.preferredBudgetLevel, + max_walking_distance_km: dto.maxWalkingDistanceKm ?? null, + preferred_start_time: dto.preferredStartTime, + child_friendly_preferred: dto.childFriendlyPreferred, + interests: dto.interests, + notes: dto.notes, + }; + + const row = await this.db + .insertInto('user_preferences') + .values({ user_id: userId, ...values }) + .onConflict((oc) => + oc.column('user_id').doUpdateSet({ ...values, updated_at: new Date() }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + + return toUserPreference(row); + } +} diff --git a/backend/libs/users/src/user-preferences.service.spec.ts b/backend/libs/users/src/user-preferences.service.spec.ts new file mode 100644 index 0000000..d934912 --- /dev/null +++ b/backend/libs/users/src/user-preferences.service.spec.ts @@ -0,0 +1,60 @@ +import { UserPreferencesService } from './user-preferences.service'; +import { DEFAULT_USER_PREFERENCE } from './user.types'; +import type { UpdateUserPreferenceDto } from './user.types'; + +describe('UserPreferencesService', () => { + describe('getOrDefault', () => { + it('returns the stored preference when one exists', async () => { + const stored = { + userId: 'u1', + ...DEFAULT_USER_PREFERENCE, + preferredPace: 'relaxed', + }; + const repo = { findByUserId: jest.fn().mockResolvedValue(stored) }; + const service = new UserPreferencesService(repo as never); + + await expect(service.getOrDefault('u1')).resolves.toEqual(stored); + }); + + it('returns an in-memory default without persisting a row when none exists', async () => { + const repo = { + findByUserId: jest.fn().mockResolvedValue(undefined), + upsert: jest.fn(), + }; + const service = new UserPreferencesService(repo as never); + + const result = await service.getOrDefault('u1'); + + expect(result).toEqual({ userId: 'u1', ...DEFAULT_USER_PREFERENCE }); + expect(repo.upsert).not.toHaveBeenCalled(); + }); + }); + + describe('upsert', () => { + it('merges the partial dto onto the current effective preference before persisting', async () => { + const repo = { + findByUserId: jest.fn().mockResolvedValue({ + userId: 'u1', + ...DEFAULT_USER_PREFERENCE, + preferredPace: 'relaxed', + }), + upsert: jest + .fn() + .mockImplementation((userId: string, dto: UpdateUserPreferenceDto) => + Promise.resolve({ userId, ...DEFAULT_USER_PREFERENCE, ...dto }), + ), + }; + const service = new UserPreferencesService(repo as never); + + await service.upsert('u1', { childFriendlyPreferred: true }); + + expect(repo.upsert).toHaveBeenCalledWith( + 'u1', + expect.objectContaining({ + preferredPace: 'relaxed', + childFriendlyPreferred: true, + }), + ); + }); + }); +}); diff --git a/backend/libs/users/src/user-preferences.service.ts b/backend/libs/users/src/user-preferences.service.ts new file mode 100644 index 0000000..1d6eea3 --- /dev/null +++ b/backend/libs/users/src/user-preferences.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { UserPreferencesRepository } from './user-preferences.repository'; +import { DEFAULT_USER_PREFERENCE } from './user.types'; +import type { UpdateUserPreferenceDto, UserPreference } from './user.types'; + +@Injectable() +export class UserPreferencesService { + constructor(private readonly repository: UserPreferencesRepository) {} + + async getOrDefault(userId: string): Promise { + const stored = await this.repository.findByUserId(userId); + return stored ?? { userId, ...DEFAULT_USER_PREFERENCE }; + } + + async upsert( + userId: string, + dto: UpdateUserPreferenceDto, + ): Promise { + const current = await this.getOrDefault(userId); + return this.repository.upsert(userId, { + preferredPace: + dto.preferredPace !== undefined + ? dto.preferredPace + : current.preferredPace, + preferredBudgetLevel: + dto.preferredBudgetLevel !== undefined + ? dto.preferredBudgetLevel + : current.preferredBudgetLevel, + maxWalkingDistanceKm: + dto.maxWalkingDistanceKm !== undefined + ? dto.maxWalkingDistanceKm + : current.maxWalkingDistanceKm, + preferredStartTime: + dto.preferredStartTime !== undefined + ? dto.preferredStartTime + : current.preferredStartTime, + childFriendlyPreferred: + dto.childFriendlyPreferred !== undefined + ? dto.childFriendlyPreferred + : current.childFriendlyPreferred, + interests: + dto.interests !== undefined ? dto.interests : current.interests, + notes: dto.notes !== undefined ? dto.notes : current.notes, + }); + } +} diff --git a/backend/libs/users/src/user.types.ts b/backend/libs/users/src/user.types.ts new file mode 100644 index 0000000..1c092a3 --- /dev/null +++ b/backend/libs/users/src/user.types.ts @@ -0,0 +1,44 @@ +export interface User { + id: string; + externalSubjectId: string; + displayName: string; + email: string; + createdAt: Date; + updatedAt: Date; +} + +export interface UserClaims { + email: string; + displayName: string; +} + +export interface UserPreference { + userId: string; + preferredPace: string | null; + preferredBudgetLevel: string | null; + maxWalkingDistanceKm: number | null; + preferredStartTime: string | null; + childFriendlyPreferred: boolean; + interests: string[]; + notes: string | null; +} + +export interface UpdateUserPreferenceDto { + preferredPace?: string | null; + preferredBudgetLevel?: string | null; + maxWalkingDistanceKm?: number | null; + preferredStartTime?: string | null; + childFriendlyPreferred?: boolean; + interests?: string[]; + notes?: string | null; +} + +export const DEFAULT_USER_PREFERENCE: Omit = { + preferredPace: null, + preferredBudgetLevel: null, + maxWalkingDistanceKm: null, + preferredStartTime: null, + childFriendlyPreferred: false, + interests: [], + notes: null, +}; diff --git a/backend/libs/users/src/users.module.ts b/backend/libs/users/src/users.module.ts new file mode 100644 index 0000000..b86fc3a --- /dev/null +++ b/backend/libs/users/src/users.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { DatabaseModule } from '../../database/src'; +import { UsersRepository } from './users.repository'; +import { UsersService } from './users.service'; +import { UserPreferencesRepository } from './user-preferences.repository'; +import { UserPreferencesService } from './user-preferences.service'; + +@Module({ + imports: [DatabaseModule], + providers: [ + UsersRepository, + UsersService, + UserPreferencesRepository, + UserPreferencesService, + ], + exports: [UsersService, UserPreferencesService], +}) +export class UsersLibModule {} diff --git a/backend/libs/users/src/users.repository.ts b/backend/libs/users/src/users.repository.ts new file mode 100644 index 0000000..d0b2796 --- /dev/null +++ b/backend/libs/users/src/users.repository.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { Kysely } from 'kysely'; +import { KYSELY_DB } from '../../database/src'; +import type { Database } from '../../database/src'; +import type { User, UserClaims } from './user.types'; + +function toUser(row: { + id: string; + external_subject_id: string; + display_name: string; + email: string; + created_at: Date; + updated_at: Date; +}): User { + return { + id: row.id, + externalSubjectId: row.external_subject_id, + displayName: row.display_name, + email: row.email, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +@Injectable() +export class UsersRepository { + constructor(@Inject(KYSELY_DB) private readonly db: Kysely) {} + + async upsertByExternalSubjectId( + externalSubjectId: string, + claims: UserClaims, + ): Promise { + const row = await this.db + .insertInto('users') + .values({ + external_subject_id: externalSubjectId, + display_name: claims.displayName, + email: claims.email, + }) + .onConflict((oc) => + oc.column('external_subject_id').doUpdateSet({ + display_name: claims.displayName, + email: claims.email, + updated_at: new Date(), + }), + ) + .returningAll() + .executeTakeFirstOrThrow(); + + return toUser(row); + } + + async findById(id: string): Promise { + const row = await this.db + .selectFrom('users') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row ? toUser(row) : undefined; + } +} diff --git a/backend/libs/users/src/users.service.spec.ts b/backend/libs/users/src/users.service.spec.ts new file mode 100644 index 0000000..cb43d74 --- /dev/null +++ b/backend/libs/users/src/users.service.spec.ts @@ -0,0 +1,50 @@ +import { UsersService } from './users.service'; + +describe('UsersService.findOrCreateByExternalSubjectId', () => { + it('creates a new local user on first login', async () => { + const repo = { + upsertByExternalSubjectId: jest.fn().mockResolvedValue({ + id: 'u1', + externalSubjectId: 'sub-1', + displayName: 'Alex', + email: 'a@example.com', + createdAt: new Date(), + updatedAt: new Date(), + }), + }; + const service = new UsersService(repo as never); + + const user = await service.findOrCreateByExternalSubjectId('sub-1', { + email: 'a@example.com', + displayName: 'Alex', + }); + + expect(repo.upsertByExternalSubjectId).toHaveBeenCalledWith('sub-1', { + email: 'a@example.com', + displayName: 'Alex', + }); + expect(user.id).toBe('u1'); + }); + + it('refreshes displayName/email on subsequent logins without changing the id', async () => { + const repo = { + upsertByExternalSubjectId: jest.fn().mockResolvedValue({ + id: 'u1', + externalSubjectId: 'sub-1', + displayName: 'Alex Renamed', + email: 'a@example.com', + createdAt: new Date(), + updatedAt: new Date(), + }), + }; + const service = new UsersService(repo as never); + + const user = await service.findOrCreateByExternalSubjectId('sub-1', { + email: 'a@example.com', + displayName: 'Alex Renamed', + }); + + expect(user.id).toBe('u1'); + expect(user.displayName).toBe('Alex Renamed'); + }); +}); diff --git a/backend/libs/users/src/users.service.ts b/backend/libs/users/src/users.service.ts new file mode 100644 index 0000000..5f95add --- /dev/null +++ b/backend/libs/users/src/users.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@nestjs/common'; +import { UsersRepository } from './users.repository'; +import type { User, UserClaims } from './user.types'; + +@Injectable() +export class UsersService { + constructor(private readonly usersRepository: UsersRepository) {} + + findOrCreateByExternalSubjectId( + externalSubjectId: string, + claims: UserClaims, + ): Promise { + return this.usersRepository.upsertByExternalSubjectId( + externalSubjectId, + claims, + ); + } + + findById(id: string): Promise { + return this.usersRepository.findById(id); + } +} diff --git a/backend/package.json b/backend/package.json index d077224..97bc251 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,7 +28,7 @@ "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "ioredis": "^6.0.0", - "kysely": "^0.29.5", + "kysely": "0.28.17", "node-pg-migrate": "^7.9.1", "pg": "^8.23.0", "reflect-metadata": "^0.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4c9ba7..a155cb8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^6.0.0 version: 6.0.0 kysely: - specifier: ^0.29.5 - version: 0.29.5 + specifier: 0.28.17 + version: 0.28.17 node-pg-migrate: specifier: ^7.9.1 version: 7.9.1(@types/pg@8.21.0)(pg@8.23.0) @@ -3531,9 +3531,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kysely@0.29.5: - resolution: {integrity: sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==} - engines: {node: '>=22.0.0'} + kysely@0.28.17: + resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==} + engines: {node: '>=20.0.0'} leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} @@ -8675,7 +8675,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - kysely@0.29.5: {} + kysely@0.28.17: {} leven@3.1.0: {}