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..ce9d061615 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', 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..7191b60e35 --- /dev/null +++ b/src/lib/features/users/user-updates-read-model.ts @@ -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 => { + 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 { + 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, + }) + .where('updated_at', '>', date) + .orderBy('updated_at', 'asc') + .select([ + ...USER_COLUMNS_PUBLIC, + 'created_at', + 'updated_at', + 'deleted_at', + ]) + .limit(limit); + return result.map(toResponse); + } +} 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/e2e/stores/user-store.e2e.test.ts b/src/test/e2e/users/user-store.e2e.test.ts similarity index 100% rename from src/test/e2e/stores/user-store.e2e.test.ts rename to src/test/e2e/users/user-store.e2e.test.ts diff --git a/src/test/e2e/users/user-updates-read-model.e2e.test.ts b/src/test/e2e/users/user-updates-read-model.e2e.test.ts new file mode 100644 index 0000000000..9c8c8c77fd --- /dev/null +++ b/src/test/e2e/users/user-updates-read-model.e2e.test.ts @@ -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); +}); 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(),