From 3ed342459a56a431af80df8e56145ede9447f66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Wed, 24 Sep 2025 15:23:46 +0200 Subject: [PATCH] feat: add users updated read model --- src/lib/db/index.ts | 2 + src/lib/features/users/user-store.ts | 5 +- .../features/users/user-updates-read-model.ts | 45 ++++++++++++++++++ src/lib/types/stores.ts | 2 + .../20250923130348-add-users-updated-at.js | 46 +++++++++++++++++++ src/test/fixtures/store.ts | 2 + 6 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 src/lib/features/users/user-updates-read-model.ts create mode 100644 src/migrations/20250923130348-add-users-updated-at.js diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 01f5e47502..292c94a51e 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -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), diff --git a/src/lib/features/users/user-store.ts b/src/lib/features/users/user-store.ts index 5e093d7f62..4f29fed104 100644 --- a/src/lib/features/users/user-store.ts +++ b/src/lib/features/users/user-store.ts @@ -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', @@ -43,7 +44,7 @@ const mapUserToColumns = (user: ICreateUser) => ({ image_url: user.imageUrl, }); -const rowToUser = (row) => { +export const rowToUser = (row) => { if (!row) { throw new NotFoundError('No user found'); } diff --git a/src/lib/features/users/user-updates-read-model.ts b/src/lib/features/users/user-updates-read-model.ts new file mode 100644 index 0000000000..c8533a1a9a --- /dev/null +++ b/src/lib/features/users/user-updates-read-model.ts @@ -0,0 +1,45 @@ +import type { Logger, LogProvider } from '../../logger.js'; + +import type { Db } from '../../db/db.js'; +import { rowToUser, USER_COLUMNS_PUBLIC, USERS_TABLE } from './user-store.js'; +import type { User } from '../../internals.js'; + +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 { + 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 { + const result = await this.db(USERS_TABLE) + .where({ + // also consider deleted users (different than activeUsers query) + is_system: false, + is_service: false, + updated_at: { $gt: date }, + }) + .orderBy('updated_at', 'asc') + .select(USER_COLUMNS_PUBLIC) + .limit(limit); + return result.map(rowToUser); + } +} diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index bfbdec6552..7984c7e2a6 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -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; diff --git a/src/migrations/20250923130348-add-users-updated-at.js b/src/migrations/20250923130348-add-users-updated-at.js new file mode 100644 index 0000000000..ec9c8b0a69 --- /dev/null +++ b/src/migrations/20250923130348-add-users-updated-at.js @@ -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, + ); +}; diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index e4406e85ca..8775a84b76 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -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(),