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/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",
|
||||
|
||||
Reference in New Issue
Block a user