1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-28 17:55:15 +02:00

feat: ability to query users with pagination (#10130)

Made a few QoL improvements:
- Don't use default export for class
- Move users store to a feature package (didn't move the interface as it
might be referenced elsewhere)
- Add types for query builders (and ts-expect-error when needed)
This commit is contained in:
Gastón Fournier 2025-06-12 17:43:22 +02:00 committed by GitHub
parent 4e48d90ed8
commit 37a125f0b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 49 additions and 28 deletions

View File

@ -15,7 +15,7 @@ import ClientInstanceStore from './client-instance-store.js';
import ClientApplicationsStore from './client-applications-store.js'; import ClientApplicationsStore from './client-applications-store.js';
import ContextFieldStore from '../features/context/context-field-store.js'; import ContextFieldStore from '../features/context/context-field-store.js';
import SettingStore from './setting-store.js'; import SettingStore from './setting-store.js';
import UserStore from './user-store.js'; import { UserStore } from '../features/users/user-store.js';
import ProjectStore from '../features/project/project-store.js'; import ProjectStore from '../features/project/project-store.js';
import TagStore from './tag-store.js'; import TagStore from './tag-store.js';
import TagTypeStore from '../features/tag-type/tag-type-store.js'; import TagTypeStore from '../features/tag-type/tag-type-store.js';
@ -105,7 +105,7 @@ export const createStores = (
config.flagResolver, config.flagResolver,
), ),
settingStore: new SettingStore(db, getLogger), settingStore: new SettingStore(db, getLogger),
userStore: new UserStore(db, getLogger, config.flagResolver), userStore: new UserStore(db, getLogger),
accountStore: new AccountStore(db, getLogger), accountStore: new AccountStore(db, getLogger),
projectStore: new ProjectStore(db, eventBus, config), projectStore: new ProjectStore(db, eventBus, config),
tagStore: new TagStore(db, eventBus, getLogger), tagStore: new TagStore(db, eventBus, getLogger),

View File

@ -10,7 +10,7 @@ import {
import type { IUnleashConfig } from '../../types/index.js'; import type { IUnleashConfig } from '../../types/index.js';
import type { Db } from '../../db/db.js'; import type { Db } from '../../db/db.js';
import FeatureToggleStore from '../feature-toggle/feature-toggle-store.js'; import FeatureToggleStore from '../feature-toggle/feature-toggle-store.js';
import UserStore from '../../db/user-store.js'; import { UserStore } from '../users/user-store.js';
import ProjectStore from '../project/project-store.js'; import ProjectStore from '../project/project-store.js';
import EnvironmentStore from '../project-environments/environment-store.js'; import EnvironmentStore from '../project-environments/environment-store.js';
import StrategyStore from '../../db/strategy-store.js'; import StrategyStore from '../../db/strategy-store.js';
@ -57,7 +57,7 @@ export const createInstanceStatsService = (db: Db, config: IUnleashConfig) => {
getLogger, getLogger,
flagResolver, flagResolver,
); );
const userStore = new UserStore(db, getLogger, flagResolver); const userStore = new UserStore(db, getLogger);
const projectStore = new ProjectStore(db, eventBus, config); const projectStore = new ProjectStore(db, eventBus, config);
const environmentStore = new EnvironmentStore(db, eventBus, config); const environmentStore = new EnvironmentStore(db, eventBus, config);
const strategyStore = new StrategyStore(db, getLogger); const strategyStore = new StrategyStore(db, getLogger);

View File

@ -3,7 +3,7 @@ import type { Db } from '../../db/db.js';
import { OnboardingService } from './onboarding-service.js'; import { OnboardingService } from './onboarding-service.js';
import { OnboardingStore } from './onboarding-store.js'; import { OnboardingStore } from './onboarding-store.js';
import { ProjectReadModel } from '../project/project-read-model.js'; import { ProjectReadModel } from '../project/project-read-model.js';
import UserStore from '../../db/user-store.js'; import { UserStore } from '../users/user-store.js';
import FakeUserStore from '../../../test/fixtures/fake-user-store.js'; import FakeUserStore from '../../../test/fixtures/fake-user-store.js';
import { FakeProjectReadModel } from '../project/fake-project-read-model.js'; import { FakeProjectReadModel } from '../project/fake-project-read-model.js';
import { FakeOnboardingStore } from './fake-onboarding-store.js'; import { FakeOnboardingStore } from './fake-onboarding-store.js';
@ -18,7 +18,7 @@ export const createOnboardingService =
eventBus, eventBus,
flagResolver, flagResolver,
); );
const userStore = new UserStore(db, getLogger, flagResolver); const userStore = new UserStore(db, getLogger);
const onboardingService = new OnboardingService( const onboardingService = new OnboardingService(
{ {
onboardingStore, onboardingStore,

View File

@ -1,17 +1,15 @@
/* eslint camelcase: "off" */ import type { Logger, LogProvider } from '../../logger.js';
import User from '../../types/user.js';
import type { Logger, LogProvider } from '../logger.js'; import NotFoundError from '../../error/notfound-error.js';
import User from '../types/user.js';
import NotFoundError from '../error/notfound-error.js';
import type { import type {
ICreateUser, ICreateUser,
IUserLookup, IUserLookup,
IUserStore, IUserStore,
IUserUpdateFields, IUserUpdateFields,
} from '../types/stores/user-store.js'; } from '../../types/stores/user-store.js';
import type { Db } from './db.js'; import type { Db } from '../../db/db.js';
import type { IFlagResolver } from '../types/index.js'; import type { Knex } from 'knex';
const TABLE = 'users'; const TABLE = 'users';
const PASSWORD_HASH_TABLE = 'used_passwords'; const PASSWORD_HASH_TABLE = 'used_passwords';
@ -63,17 +61,14 @@ const rowToUser = (row) => {
}); });
}; };
class UserStore implements IUserStore { export class UserStore implements IUserStore {
private db: Db; private db: Db;
private logger: Logger; private logger: Logger;
private flagResolver: IFlagResolver; constructor(db: Db, getLogger: LogProvider) {
constructor(db: Db, getLogger: LogProvider, flagResolver: IFlagResolver) {
this.db = db; this.db = db;
this.logger = getLogger('user-store.ts'); this.logger = getLogger('user-store.ts');
this.flagResolver = flagResolver;
} }
async getPasswordsPreviouslyUsed(userId: number): Promise<string[]> { async getPasswordsPreviouslyUsed(userId: number): Promise<string[]> {
@ -130,7 +125,7 @@ class UserStore implements IUserStore {
return this.insert(user); return this.insert(user);
} }
buildSelectUser(q: IUserLookup): any { buildSelectUser(q: IUserLookup): Knex.QueryBuilder<User> {
const query = this.activeAll(); const query = this.activeAll();
if (q.id) { if (q.id) {
return query.where('id', q.id); return query.where('id', q.id);
@ -144,13 +139,13 @@ class UserStore implements IUserStore {
throw new Error('Can only find users with id, username or email.'); throw new Error('Can only find users with id, username or email.');
} }
activeAll(): any { activeAll(): Knex.QueryBuilder<User> {
return this.db(TABLE).where({ return this.db(TABLE).where({
deleted_at: null, deleted_at: null,
}); });
} }
activeUsers(): any { activeUsers(): Knex.QueryBuilder<User> {
return this.db(TABLE).where({ return this.db(TABLE).where({
deleted_at: null, deleted_at: null,
is_service: false, is_service: false,
@ -164,9 +159,25 @@ class UserStore implements IUserStore {
return item ? item.id : undefined; return item ? item.id : undefined;
} }
async getAll(): Promise<User[]> { async getAll(params?: {
const users = await this.activeUsers().select(USER_COLUMNS); limit: number;
return users.map(rowToUser); offset: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}): Promise<User[]> {
const usersQuery = this.activeUsers().select(USER_COLUMNS);
if (params) {
if (params.sortBy) {
usersQuery.orderBy(params.sortBy, params.sortOrder);
}
if (params.limit) {
usersQuery.limit(params.limit);
}
if (params.offset) {
usersQuery.offset(params.offset);
}
}
return (await usersQuery).map(rowToUser);
} }
async search(query: string): Promise<User[]> { async search(query: string): Promise<User[]> {
@ -191,11 +202,13 @@ class UserStore implements IUserStore {
} }
async delete(id: number): Promise<void> { async delete(id: number): Promise<void> {
return this.activeUsers() await this.activeUsers()
.where({ id }) .where({ id })
.update({ .update({
deleted_at: new Date(), deleted_at: new Date(),
// @ts-expect-error email is non-nullable in User type
email: null, email: null,
// @ts-expect-error username is non-nullable in User type
username: null, username: null,
scim_id: null, scim_id: null,
scim_external_id: null, scim_external_id: null,
@ -221,6 +234,7 @@ class UserStore implements IUserStore {
disallowNPreviousPasswords: number, disallowNPreviousPasswords: number,
): Promise<void> { ): Promise<void> {
await this.activeUsers().where('id', userId).update({ await this.activeUsers().where('id', userId).update({
// @ts-expect-error password_hash does not exist in User type
password_hash: passwordHash, password_hash: passwordHash,
}); });
// We apparently set this to null, but you should be allowed to have null, so need to allow this // We apparently set this to null, but you should be allowed to have null, so need to allow this
@ -237,12 +251,13 @@ class UserStore implements IUserStore {
} }
async incLoginAttempts(user: User): Promise<void> { async incLoginAttempts(user: User): Promise<void> {
return this.buildSelectUser(user).increment('login_attempts', 1); await this.buildSelectUser(user).increment('login_attempts', 1);
} }
async successfullyLogin(user: User): Promise<number> { async successfullyLogin(user: User): Promise<number> {
const currentDate = new Date(); const currentDate = new Date();
const updateQuery = this.buildSelectUser(user).update({ const updateQuery = this.buildSelectUser(user).update({
// @ts-expect-error login_attempts does not exist in User type
login_attempts: 0, login_attempts: 0,
seen_at: currentDate, seen_at: currentDate,
}); });
@ -262,6 +277,7 @@ class UserStore implements IUserStore {
firstLoginOrder = countEarlierUsers; firstLoginOrder = countEarlierUsers;
await updateQuery.update({ await updateQuery.update({
// @ts-expect-error first_seen_at does not exist in User type
first_seen_at: currentDate, first_seen_at: currentDate,
}); });
} }
@ -333,4 +349,3 @@ class UserStore implements IUserStore {
return firstInstanceUser ? firstInstanceUser.created_at : null; return firstInstanceUser ? firstInstanceUser.created_at : null;
} }
} }
export default UserStore;

View File

@ -25,6 +25,12 @@ export interface IUserStore extends Store<IUser, number> {
upsert(user: ICreateUser): Promise<IUser>; upsert(user: ICreateUser): Promise<IUser>;
hasUser(idQuery: IUserLookup): Promise<number | undefined>; hasUser(idQuery: IUserLookup): Promise<number | undefined>;
search(query: string): Promise<IUser[]>; search(query: string): Promise<IUser[]>;
getAll(params?: {
limit: number;
offset: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}): Promise<IUser[]>;
getAllWithId(userIdList: number[]): Promise<IUser[]>; getAllWithId(userIdList: number[]): Promise<IUser[]>;
getByQuery(idQuery: IUserLookup): Promise<IUser>; getByQuery(idQuery: IUserLookup): Promise<IUser>;
getPasswordHash(userId: number): Promise<string>; getPasswordHash(userId: number): Promise<string>;