mirror of
https://github.com/Unleash/unleash.git
synced 2025-09-24 17:51:14 +02:00
feat: add users updated read model (#10693)
## About the changes This adds a user read model using the updated_at new field in the users table.
This commit is contained in:
parent
413847374e
commit
e0cdf6addd
@ -68,6 +68,7 @@ import { UniqueConnectionReadModel } from '../features/unique-connection/unique-
|
||||
import { FeatureLinkStore } from '../features/feature-links/feature-link-store.js';
|
||||
import { UnknownFlagsStore } from '../features/metrics/unknown-flags/unknown-flags-store.js';
|
||||
import { FeatureLinksReadModel } from '../features/feature-links/feature-links-read-model.js';
|
||||
import { UserUpdatesReadModel } from '../features/users/user-updates-read-model.js';
|
||||
|
||||
export const createStores = (
|
||||
config: IUnleashConfig,
|
||||
@ -106,6 +107,7 @@ export const createStores = (
|
||||
),
|
||||
settingStore: new SettingStore(db, getLogger),
|
||||
userStore: new UserStore(db, getLogger),
|
||||
userUpdatesReadModel: new UserUpdatesReadModel(db, getLogger),
|
||||
accountStore: new AccountStore(db, getLogger),
|
||||
projectStore: new ProjectStore(db, eventBus, config),
|
||||
tagStore: new TagStore(db, eventBus, getLogger),
|
||||
|
@ -12,9 +12,10 @@ import type { Db } from '../../db/db.js';
|
||||
import type { Knex } from 'knex';
|
||||
|
||||
const TABLE = 'users';
|
||||
export const USERS_TABLE = TABLE;
|
||||
const PASSWORD_HASH_TABLE = 'used_passwords';
|
||||
|
||||
const USER_COLUMNS_PUBLIC = [
|
||||
export const USER_COLUMNS_PUBLIC = [
|
||||
'id',
|
||||
'name',
|
||||
'username',
|
||||
|
74
src/lib/features/users/user-updates-read-model.ts
Normal file
74
src/lib/features/users/user-updates-read-model.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import type { Logger, LogProvider } from '../../logger.js';
|
||||
|
||||
import type { Db } from '../../db/db.js';
|
||||
import { USER_COLUMNS_PUBLIC, USERS_TABLE } from './user-store.js';
|
||||
import type { Row } from '../../db/crud/row-type.js';
|
||||
|
||||
type UpdatedUser = {
|
||||
id: number;
|
||||
name?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
imageUrl?: string;
|
||||
seenAt?: Date;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date | null;
|
||||
deletedAt?: Date | null;
|
||||
};
|
||||
const toResponse = (row: Row<UpdatedUser>): UpdatedUser => {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
imageUrl: row.image_url,
|
||||
seenAt: row.seen_at,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
deletedAt: row.deleted_at,
|
||||
};
|
||||
};
|
||||
export class UserUpdatesReadModel {
|
||||
private db: Db;
|
||||
|
||||
private logger: Logger;
|
||||
|
||||
constructor(db: Db, getLogger: LogProvider) {
|
||||
this.db = db;
|
||||
this.logger = getLogger('user-updates-read-model.ts');
|
||||
}
|
||||
|
||||
async getLastUpdatedAt(): Promise<Date | null> {
|
||||
const result = await this.db(USERS_TABLE)
|
||||
.where({
|
||||
// also consider deleted users (different than activeUsers query)
|
||||
is_system: false,
|
||||
is_service: false,
|
||||
})
|
||||
.max('updated_at as last_updated_at')
|
||||
.first();
|
||||
return result ? result.last_updated_at : null;
|
||||
}
|
||||
|
||||
async getUsersUpdatedAfter(
|
||||
date: Date,
|
||||
limit: number = 100,
|
||||
): Promise<UpdatedUser[]> {
|
||||
const result = await this.db(USERS_TABLE)
|
||||
.where({
|
||||
// also consider deleted users (different than activeUsers query)
|
||||
is_system: false,
|
||||
is_service: false,
|
||||
})
|
||||
.where('updated_at', '>', date)
|
||||
.orderBy('updated_at', 'asc')
|
||||
.select([
|
||||
...USER_COLUMNS_PUBLIC,
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'deleted_at',
|
||||
])
|
||||
.limit(limit);
|
||||
return result.map(toResponse);
|
||||
}
|
||||
}
|
@ -62,6 +62,7 @@ import { ReleasePlanMilestoneStrategyStore } from '../features/release-plans/rel
|
||||
import type { IFeatureLinkStore } from '../features/feature-links/feature-link-store-type.js';
|
||||
import type { IUnknownFlagsStore } from '../features/metrics/unknown-flags/unknown-flags-store.js';
|
||||
import type { IFeatureLinksReadModel } from '../features/feature-links/feature-links-read-model-type.js';
|
||||
import type { UserUpdatesReadModel } from '../features/users/user-updates-read-model.js';
|
||||
|
||||
export interface IUnleashStores {
|
||||
accessStore: IAccessStore;
|
||||
@ -90,6 +91,7 @@ export interface IUnleashStores {
|
||||
tagTypeStore: ITagTypeStore;
|
||||
userFeedbackStore: IUserFeedbackStore;
|
||||
userStore: IUserStore;
|
||||
userUpdatesReadModel: UserUpdatesReadModel;
|
||||
userSplashStore: IUserSplashStore;
|
||||
roleStore: IRoleStore;
|
||||
segmentStore: ISegmentStore;
|
||||
|
46
src/migrations/20250923130348-add-users-updated-at.js
Normal file
46
src/migrations/20250923130348-add-users-updated-at.js
Normal file
@ -0,0 +1,46 @@
|
||||
|
||||
exports.up = (db, callback) => {
|
||||
db.runSql(
|
||||
`
|
||||
ALTER TABLE users ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
|
||||
|
||||
-- Backfill existing rows using max(created_at, deleted_at)
|
||||
UPDATE users
|
||||
SET updated_at = COALESCE(
|
||||
GREATEST(created_at, deleted_at),
|
||||
created_at,
|
||||
now()
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_users_updated_at()
|
||||
RETURNS trigger AS $unleash_user_updated_at_fn$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$unleash_user_updated_at_fn$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_set_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_users_updated_at();
|
||||
|
||||
-- create an index only for non-system and non-service users
|
||||
CREATE INDEX idx_users_only_updated_at_desc ON users (updated_at DESC)
|
||||
WHERE is_system = false AND is_service = false;
|
||||
`,
|
||||
callback,
|
||||
);
|
||||
};
|
||||
|
||||
exports.down = (db, callback) => {
|
||||
db.runSql(
|
||||
`
|
||||
DROP INDEX IF EXISTS idx_users_only_updated_at_desc;
|
||||
DROP TRIGGER IF EXISTS trg_set_users_updated_at ON users;
|
||||
DROP FUNCTION IF EXISTS set_users_updated_at();
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS updated_at;
|
||||
`,
|
||||
callback,
|
||||
);
|
||||
};
|
110
src/test/e2e/users/user-updates-read-model.e2e.test.ts
Normal file
110
src/test/e2e/users/user-updates-read-model.e2e.test.ts
Normal file
@ -0,0 +1,110 @@
|
||||
import dbInit, { type ITestDb } from '../helpers/database-init.js';
|
||||
import type { IUnleashStores } from '../../../lib/types/index.js';
|
||||
import { beforeAll, test, expect } from 'vitest';
|
||||
let stores: IUnleashStores;
|
||||
let db: ITestDb;
|
||||
|
||||
beforeAll(async () => {
|
||||
db = await dbInit();
|
||||
stores = db.stores;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await stores.userStore.deleteAll();
|
||||
});
|
||||
|
||||
test('should have no users', async () => {
|
||||
const readModel = stores.userUpdatesReadModel;
|
||||
const lastUpdatedAt = await readModel.getLastUpdatedAt();
|
||||
expect(lastUpdatedAt).toBeNull();
|
||||
|
||||
const users = await readModel.getUsersUpdatedAfter(new Date(0));
|
||||
expect(users).toEqual([]);
|
||||
});
|
||||
|
||||
test('Adding a user should return that user', async () => {
|
||||
const readModel = stores.userUpdatesReadModel;
|
||||
const userStore = stores.userStore;
|
||||
const beforeInsert = new Date(Date.now() - 1000);
|
||||
await userStore.upsert({ email: 'test@example.com' });
|
||||
|
||||
const lastUpdatedAt = await readModel.getLastUpdatedAt();
|
||||
expect(lastUpdatedAt).toBeDefined();
|
||||
expect(lastUpdatedAt).toBeInstanceOf(Date);
|
||||
// check that it's recent
|
||||
expect(lastUpdatedAt!.getTime()).toBeGreaterThanOrEqual(
|
||||
beforeInsert.getTime(),
|
||||
);
|
||||
|
||||
const users = await readModel.getUsersUpdatedAfter(beforeInsert);
|
||||
expect(users).toHaveLength(1);
|
||||
expect(users[0].email).toBe('test@example.com');
|
||||
expect(users[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(users[0].updatedAt).toBeInstanceOf(Date);
|
||||
expect(users[0].deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('Modifying a user should return that user', async () => {
|
||||
const readModel = stores.userUpdatesReadModel;
|
||||
const userStore = stores.userStore;
|
||||
const inserted = await userStore.upsert({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
const afterInsert = new Date();
|
||||
const lastUpdatedAt = await readModel.getLastUpdatedAt();
|
||||
expect(lastUpdatedAt).toBeDefined();
|
||||
expect(lastUpdatedAt).toBeInstanceOf(Date);
|
||||
|
||||
const users = await readModel.getUsersUpdatedAfter(afterInsert);
|
||||
expect(users).toHaveLength(0);
|
||||
|
||||
await userStore.update(inserted.id, { name: 'New Name' });
|
||||
|
||||
const lastUpdatedAt2 = await readModel.getLastUpdatedAt();
|
||||
expect(lastUpdatedAt2).toBeDefined();
|
||||
expect(lastUpdatedAt2).toBeInstanceOf(Date);
|
||||
expect(lastUpdatedAt2!.getTime()).toBeGreaterThanOrEqual(
|
||||
lastUpdatedAt!.getTime(),
|
||||
);
|
||||
|
||||
const users2 = await readModel.getUsersUpdatedAfter(afterInsert);
|
||||
expect(users2).toHaveLength(1);
|
||||
expect(users2[0].email).toBe('test@example.com');
|
||||
expect(users2[0].name).toBe('New Name');
|
||||
expect(users2[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(users2[0].updatedAt).toBeInstanceOf(Date);
|
||||
expect(users2[0].deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('Deleting a user should return that user', async () => {
|
||||
const readModel = stores.userUpdatesReadModel;
|
||||
const userStore = stores.userStore;
|
||||
const inserted = await userStore.upsert({
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
const afterInsert = new Date();
|
||||
const lastUpdatedAt = await readModel.getLastUpdatedAt();
|
||||
expect(lastUpdatedAt).toBeDefined();
|
||||
expect(lastUpdatedAt).toBeInstanceOf(Date);
|
||||
|
||||
const users = await readModel.getUsersUpdatedAfter(afterInsert);
|
||||
expect(users).toHaveLength(0);
|
||||
|
||||
await userStore.delete(inserted.id);
|
||||
|
||||
const lastUpdatedAt2 = await readModel.getLastUpdatedAt();
|
||||
expect(lastUpdatedAt2).toBeDefined();
|
||||
expect(lastUpdatedAt2).toBeInstanceOf(Date);
|
||||
expect(lastUpdatedAt2!.getTime()).toBeGreaterThanOrEqual(
|
||||
lastUpdatedAt!.getTime(),
|
||||
);
|
||||
|
||||
const users2 = await readModel.getUsersUpdatedAfter(afterInsert);
|
||||
expect(users2).toHaveLength(1);
|
||||
expect(users2[0].email).toBeNull(); // currently we nullify the email but this might change in the future
|
||||
expect(users2[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(users2[0].updatedAt).toBeInstanceOf(Date);
|
||||
expect(users2[0].deletedAt).toBeInstanceOf(Date);
|
||||
});
|
2
src/test/fixtures/store.ts
vendored
2
src/test/fixtures/store.ts
vendored
@ -65,6 +65,7 @@ import { UniqueConnectionReadModel } from '../../lib/features/unique-connection/
|
||||
import FakeFeatureLinkStore from '../../lib/features/feature-links/fake-feature-link-store.js';
|
||||
import { FakeFeatureLinksReadModel } from '../../lib/features/feature-links/fake-feature-links-read-model.js';
|
||||
import { FakeUnknownFlagsStore } from '../../lib/features/metrics/unknown-flags/fake-unknown-flags-store.js';
|
||||
import type { UserUpdatesReadModel } from '../../lib/features/users/user-updates-read-model.js';
|
||||
|
||||
const db = {
|
||||
select: () => ({
|
||||
@ -92,6 +93,7 @@ const createStores: () => IUnleashStores = () => {
|
||||
addonStore: new FakeAddonStore(),
|
||||
projectStore: new FakeProjectStore(),
|
||||
userStore: new FakeUserStore(),
|
||||
userUpdatesReadModel: {} as UserUpdatesReadModel,
|
||||
accessStore: new FakeAccessStore(),
|
||||
accountStore: new FakeAccountStore(),
|
||||
userFeedbackStore: new FakeUserFeedbackStore(),
|
||||
|
Loading…
Reference in New Issue
Block a user