feat: add user jit provisioning and preferences service
This commit is contained in:
4
backend/libs/users/src/index.ts
Normal file
4
backend/libs/users/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './user.types';
|
||||||
|
export * from './users.service';
|
||||||
|
export * from './user-preferences.service';
|
||||||
|
export * from './users.module';
|
||||||
70
backend/libs/users/src/user-preferences.repository.ts
Normal file
70
backend/libs/users/src/user-preferences.repository.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/libs/users/src/user-preferences.service.spec.ts
Normal file
60
backend/libs/users/src/user-preferences.service.spec.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
46
backend/libs/users/src/user-preferences.service.ts
Normal file
46
backend/libs/users/src/user-preferences.service.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
44
backend/libs/users/src/user.types.ts
Normal file
44
backend/libs/users/src/user.types.ts
Normal 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,
|
||||||
|
};
|
||||||
18
backend/libs/users/src/users.module.ts
Normal file
18
backend/libs/users/src/users.module.ts
Normal 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 {}
|
||||||
61
backend/libs/users/src/users.repository.ts
Normal file
61
backend/libs/users/src/users.repository.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
backend/libs/users/src/users.service.spec.ts
Normal file
50
backend/libs/users/src/users.service.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
22
backend/libs/users/src/users.service.ts
Normal file
22
backend/libs/users/src/users.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
12
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user