feat: add user jit provisioning and preferences service

This commit is contained in:
Bastian Wagner
2026-08-17 14:43:13 +02:00
parent ec95a8e527
commit 7ca4bd9cf7
11 changed files with 382 additions and 7 deletions

View File

@@ -0,0 +1,4 @@
export * from './user.types';
export * from './users.service';
export * from './user-preferences.service';
export * from './users.module';

View File

@@ -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<Database>) {}
async findByUserId(userId: string): Promise<UserPreference | undefined> {
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<UserPreference> {
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);
}
}

View File

@@ -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,
}),
);
});
});
});

View File

@@ -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<UserPreference> {
const stored = await this.repository.findByUserId(userId);
return stored ?? { userId, ...DEFAULT_USER_PREFERENCE };
}
async upsert(
userId: string,
dto: UpdateUserPreferenceDto,
): Promise<UserPreference> {
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,
});
}
}

View File

@@ -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<UserPreference, 'userId'> = {
preferredPace: null,
preferredBudgetLevel: null,
maxWalkingDistanceKm: null,
preferredStartTime: null,
childFriendlyPreferred: false,
interests: [],
notes: null,
};

View File

@@ -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 {}

View File

@@ -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<Database>) {}
async upsertByExternalSubjectId(
externalSubjectId: string,
claims: UserClaims,
): Promise<User> {
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<User | undefined> {
const row = await this.db
.selectFrom('users')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? toUser(row) : undefined;
}
}

View File

@@ -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');
});
});

View File

@@ -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<User> {
return this.usersRepository.upsertByExternalSubjectId(
externalSubjectId,
claims,
);
}
findById(id: string): Promise<User | undefined> {
return this.usersRepository.findById(id);
}
}

View File

@@ -28,7 +28,7 @@
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"ioredis": "^6.0.0", "ioredis": "^6.0.0",
"kysely": "^0.29.5", "kysely": "0.28.17",
"node-pg-migrate": "^7.9.1", "node-pg-migrate": "^7.9.1",
"pg": "^8.23.0", "pg": "^8.23.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",

12
pnpm-lock.yaml generated
View File

@@ -27,8 +27,8 @@ importers:
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.0.0 version: 6.0.0
kysely: kysely:
specifier: ^0.29.5 specifier: 0.28.17
version: 0.29.5 version: 0.28.17
node-pg-migrate: node-pg-migrate:
specifier: ^7.9.1 specifier: ^7.9.1
version: 7.9.1(@types/pg@8.21.0)(pg@8.23.0) version: 7.9.1(@types/pg@8.21.0)(pg@8.23.0)
@@ -3531,9 +3531,9 @@ packages:
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
kysely@0.29.5: kysely@0.28.17:
resolution: {integrity: sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==} resolution: {integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==}
engines: {node: '>=22.0.0'} engines: {node: '>=20.0.0'}
leven@3.1.0: leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
@@ -8675,7 +8675,7 @@ snapshots:
dependencies: dependencies:
json-buffer: 3.0.1 json-buffer: 3.0.1
kysely@0.29.5: {} kysely@0.28.17: {}
leven@3.1.0: {} leven@3.1.0: {}