From 3f8d48fa8436b06aed797494dea0fa58598170b6 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Mon, 4 Nov 2024 18:01:19 +0100 Subject: [PATCH 1/3] feat: user subscriptions - store --- .../user-subscriptions-read-model-type.ts | 9 +++ .../user-subscriptions-read-model.ts | 65 +++++++++++++++ .../user-subscriptions-service.ts | 80 +++++++++++++++++++ .../user-unsubscribe-store-type.ts | 10 +++ .../user-unsubscribe-store.ts | 54 +++++++++++++ src/lib/types/stores.ts | 4 + 6 files changed, 222 insertions(+) create mode 100644 src/lib/features/user-subscriptions/user-subscriptions-read-model-type.ts create mode 100644 src/lib/features/user-subscriptions/user-subscriptions-read-model.ts create mode 100644 src/lib/features/user-subscriptions/user-subscriptions-service.ts create mode 100644 src/lib/features/user-subscriptions/user-unsubscribe-store-type.ts create mode 100644 src/lib/features/user-subscriptions/user-unsubscribe-store.ts diff --git a/src/lib/features/user-subscriptions/user-subscriptions-read-model-type.ts b/src/lib/features/user-subscriptions/user-subscriptions-read-model-type.ts new file mode 100644 index 000000000000..ae8c8feace6f --- /dev/null +++ b/src/lib/features/user-subscriptions/user-subscriptions-read-model-type.ts @@ -0,0 +1,9 @@ +export type Subscriber = { + name: string; + email: string; +}; + +export interface IUserSubscriptionsReadModel { + getSubscribedUsers(subscription: string): Promise; + getUserSubscriptions(userId: number): Promise; +} diff --git a/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts b/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts new file mode 100644 index 000000000000..4ab7fb1d20f5 --- /dev/null +++ b/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts @@ -0,0 +1,65 @@ +import type { Db } from '../../db/db'; +import metricsHelper from '../../util/metrics-helper'; +import type EventEmitter from 'events'; +import type { + IUserSubscriptionsReadModel, + Subscriber, +} from './user-subscriptions-read-model-type'; + +const USERS_TABLE = 'users'; +const USER_COLUMNS = [ + 'id', + 'name', + 'username', + 'email', + 'image_url', + 'is_service', +]; +const UNSUBSCRIPTION_TABLE = 'user_unsubscription'; + +const DB_TIME = 'db_time'; + +const mapRowToSubscriber = (row) => + ({ + name: row.name || row.username || '', + email: row.email, + }) as Subscriber; + +export class UserSubscriptionsReadModel implements IUserSubscriptionsReadModel { + private db: Db; + + private timer: Function; + + constructor(db: Db, eventBus: EventEmitter) { + this.db = db; + this.timer = (action: string) => + metricsHelper.wrapTimer(eventBus, DB_TIME, { + store: 'user_subscriptions', + action, + }); + } + + async getSubscribedUsers(subscription: string) { + const timer = this.timer('getSubscribedUsers'); + const unsubscribedUserIdsQuery = this.db(UNSUBSCRIPTION_TABLE) + .select('user_id') + .where('subscription', subscription); + + const users = await this.db(USERS_TABLE) + .select(USER_COLUMNS) + .whereNotIn('id', unsubscribedUserIdsQuery) + .andWhere('is_service', false); + + timer(); + return users.filter((row) => row.email).map(mapRowToSubscriber); + } + + async getUserSubscriptions(userId: number) { + const timer = this.timer('getUserSubscriptions'); + const subscriptions = await this.db(UNSUBSCRIPTION_TABLE) + .select('subscription') + .where('user_id', userId); + timer(); + return subscriptions.map((row) => row.subscription); + } +} diff --git a/src/lib/features/user-subscriptions/user-subscriptions-service.ts b/src/lib/features/user-subscriptions/user-subscriptions-service.ts new file mode 100644 index 000000000000..df16eb18ce9e --- /dev/null +++ b/src/lib/features/user-subscriptions/user-subscriptions-service.ts @@ -0,0 +1,80 @@ +import type { IUnleashConfig, IUnleashStores } from '../../types'; +import type { Logger } from '../../logger'; +import type { IAuditUser } from '../../types/user'; +import type { + IUserUnsubscribeStore, + UnsubscribeEntry, +} from './user-unsubscribe-store-type'; +import type EventService from '../events/event-service'; +import type { IUserSubscriptionsReadModel } from './user-subscriptions-read-model-type'; + +export default class UserSubscriptionService { + private userUnsubscribeStore: IUserUnsubscribeStore; + + private userSubscriptionsReadModel: IUserSubscriptionsReadModel; + + private eventService: EventService; + + private logger: Logger; + + constructor( + { + userUnsubscribeStore, + userSubscriptionsReadModel, + }: Pick< + IUnleashStores, + 'userUnsubscribeStore' | 'userSubscriptionsReadModel' + >, + { getLogger }: Pick, + eventService: EventService, + ) { + this.userUnsubscribeStore = userUnsubscribeStore; + this.userSubscriptionsReadModel = userSubscriptionsReadModel; + this.eventService = eventService; + this.logger = getLogger('services/user-subscription-service.ts'); + } + + async subscribe( + userId: number, + subscription: string, + auditUser: IAuditUser, + ): Promise { + const entry: UnsubscribeEntry = { + userId, + subscription, + }; + + await this.userUnsubscribeStore.delete(entry); + // TODO: log an event + // await this.eventService.storeEvent( + // new UserSubscriptionEvent({ + // data: { ...entry, action: 'subscribed' }, + // auditUser, + // }), + // ); + } + + async unsubscribe( + userId: number, + subscription: string, + auditUser: IAuditUser, + ): Promise { + const entry: UnsubscribeEntry = { + userId, + subscription, + }; + + await this.userUnsubscribeStore.insert(entry); + // TODO: log an event + // await this.eventService.storeEvent( + // new UserSubscriptionEvent({ + // data: { ...entry, action: 'unsubscribed' }, + // auditUser, + // }), + // ); + } + + async getSubscribed(subscription: string) { + return this.userSubscriptionsReadModel.getSubscribedUsers(subscription); + } +} diff --git a/src/lib/features/user-subscriptions/user-unsubscribe-store-type.ts b/src/lib/features/user-subscriptions/user-unsubscribe-store-type.ts new file mode 100644 index 000000000000..9b9e4b254fcf --- /dev/null +++ b/src/lib/features/user-subscriptions/user-unsubscribe-store-type.ts @@ -0,0 +1,10 @@ +export type UnsubscribeEntry = { + userId: number; + subscription: string; + createdAt?: Date; +}; + +export interface IUserUnsubscribeStore { + insert(item: UnsubscribeEntry): Promise>; + delete(item: UnsubscribeEntry): Promise; +} diff --git a/src/lib/features/user-subscriptions/user-unsubscribe-store.ts b/src/lib/features/user-subscriptions/user-unsubscribe-store.ts new file mode 100644 index 000000000000..dcb593b7cb90 --- /dev/null +++ b/src/lib/features/user-subscriptions/user-unsubscribe-store.ts @@ -0,0 +1,54 @@ +import type { Logger, LogProvider } from '../../logger'; +import type { Db } from '../../db/db'; +import type { + UnsubscribeEntry, + IUserUnsubscribeStore, +} from './user-unsubscribe-store-type'; + +const COLUMNS = ['user_id', 'subscription', 'created_at']; +export const TABLE = 'user_unsubscription'; + +interface IUserUnsubscribeTable { + user_id: number; + subscription: string; + created_at?: Date; +} + +const rowToField = (row: IUserUnsubscribeTable): UnsubscribeEntry => ({ + userId: row.user_id, + subscription: row.subscription, + createdAt: row.created_at, +}); + +export default class UserUnsubscribeStore implements IUserUnsubscribeStore { + private db: Db; + + private logger: Logger; + + constructor(db: Db, getLogger: LogProvider) { + this.db = db; + this.logger = getLogger('user-unsubscribe-store.ts'); + } + + async insert({ userId, subscription }) { + const unsubscribeEntry = await this.db + .table(TABLE) + .insert({ user_id: userId, subscription: subscription }) + .onConflict(['user_id', 'subscription']) + .ignore() + .returning(COLUMNS); + + return rowToField(unsubscribeEntry[0] as IUserUnsubscribeTable); + } + + async delete({ userId, subscription }): Promise { + await this.db + .table(TABLE) + .where({ user_id: userId, subscription: subscription }) + .del(); + } + + destroy(): void {} +} + +module.exports = UserUnsubscribeStore; diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 634bfedca46d..0379a0f78017 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -51,6 +51,8 @@ import { IFeatureCollaboratorsReadModel } from '../features/feature-toggle/types import type { IProjectReadModel } from '../features/project/project-read-model-type'; import { IOnboardingReadModel } from '../features/onboarding/onboarding-read-model-type'; import { IOnboardingStore } from '../features/onboarding/onboarding-store-type'; +import type { IUserUnsubscribeStore } from '../features/user-subscriptions/user-unsubscribe-store-type'; +import type { IUserSubscriptionsReadModel } from '../features/user-subscriptions/user-subscriptions-read-model-type'; export interface IUnleashStores { accessStore: IAccessStore; @@ -106,6 +108,8 @@ export interface IUnleashStores { projectReadModel: IProjectReadModel; onboardingReadModel: IOnboardingReadModel; onboardingStore: IOnboardingStore; + userUnsubscribeStore: IUserUnsubscribeStore; + userSubscriptionsReadModel: IUserSubscriptionsReadModel; } export { From df10f37d50986f34f9ce08535b614c7e61650348 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Tue, 5 Nov 2024 09:55:19 +0100 Subject: [PATCH 2/3] update read model --- .../user-subscriptions-read-model-type.ts | 2 ++ .../user-subscriptions-read-model.ts | 33 +++++++------------ .../user-subscriptions-service.ts | 16 +-------- 3 files changed, 15 insertions(+), 36 deletions(-) diff --git a/src/lib/features/user-subscriptions/user-subscriptions-read-model-type.ts b/src/lib/features/user-subscriptions/user-subscriptions-read-model-type.ts index ae8c8feace6f..d61087905cb7 100644 --- a/src/lib/features/user-subscriptions/user-subscriptions-read-model-type.ts +++ b/src/lib/features/user-subscriptions/user-subscriptions-read-model-type.ts @@ -7,3 +7,5 @@ export interface IUserSubscriptionsReadModel { getSubscribedUsers(subscription: string): Promise; getUserSubscriptions(userId: number): Promise; } + +export const SUBSCRIPTION_TYPES = ['productivity-report'] as const; diff --git a/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts b/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts index 4ab7fb1d20f5..746457ca83a4 100644 --- a/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts +++ b/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts @@ -1,9 +1,9 @@ import type { Db } from '../../db/db'; -import metricsHelper from '../../util/metrics-helper'; import type EventEmitter from 'events'; -import type { - IUserSubscriptionsReadModel, - Subscriber, +import { + SUBSCRIPTION_TYPES, + type IUserSubscriptionsReadModel, + type Subscriber, } from './user-subscriptions-read-model-type'; const USERS_TABLE = 'users'; @@ -17,8 +17,6 @@ const USER_COLUMNS = [ ]; const UNSUBSCRIPTION_TABLE = 'user_unsubscription'; -const DB_TIME = 'db_time'; - const mapRowToSubscriber = (row) => ({ name: row.name || row.username || '', @@ -28,19 +26,11 @@ const mapRowToSubscriber = (row) => export class UserSubscriptionsReadModel implements IUserSubscriptionsReadModel { private db: Db; - private timer: Function; - constructor(db: Db, eventBus: EventEmitter) { this.db = db; - this.timer = (action: string) => - metricsHelper.wrapTimer(eventBus, DB_TIME, { - store: 'user_subscriptions', - action, - }); } async getSubscribedUsers(subscription: string) { - const timer = this.timer('getSubscribedUsers'); const unsubscribedUserIdsQuery = this.db(UNSUBSCRIPTION_TABLE) .select('user_id') .where('subscription', subscription); @@ -48,18 +38,19 @@ export class UserSubscriptionsReadModel implements IUserSubscriptionsReadModel { const users = await this.db(USERS_TABLE) .select(USER_COLUMNS) .whereNotIn('id', unsubscribedUserIdsQuery) - .andWhere('is_service', false); + .andWhere('is_service', false) + .andWhereNot('email', null); - timer(); - return users.filter((row) => row.email).map(mapRowToSubscriber); + return users.map(mapRowToSubscriber); } async getUserSubscriptions(userId: number) { - const timer = this.timer('getUserSubscriptions'); - const subscriptions = await this.db(UNSUBSCRIPTION_TABLE) + const unsubscriptions = await this.db(UNSUBSCRIPTION_TABLE) .select('subscription') .where('user_id', userId); - timer(); - return subscriptions.map((row) => row.subscription); + + return SUBSCRIPTION_TYPES.filter( + (subscription) => !unsubscriptions.includes(subscription), + ); } } diff --git a/src/lib/features/user-subscriptions/user-subscriptions-service.ts b/src/lib/features/user-subscriptions/user-subscriptions-service.ts index df16eb18ce9e..3e197da2984e 100644 --- a/src/lib/features/user-subscriptions/user-subscriptions-service.ts +++ b/src/lib/features/user-subscriptions/user-subscriptions-service.ts @@ -6,30 +6,20 @@ import type { UnsubscribeEntry, } from './user-unsubscribe-store-type'; import type EventService from '../events/event-service'; -import type { IUserSubscriptionsReadModel } from './user-subscriptions-read-model-type'; export default class UserSubscriptionService { private userUnsubscribeStore: IUserUnsubscribeStore; - private userSubscriptionsReadModel: IUserSubscriptionsReadModel; - private eventService: EventService; private logger: Logger; constructor( - { - userUnsubscribeStore, - userSubscriptionsReadModel, - }: Pick< - IUnleashStores, - 'userUnsubscribeStore' | 'userSubscriptionsReadModel' - >, + { userUnsubscribeStore }: Pick, { getLogger }: Pick, eventService: EventService, ) { this.userUnsubscribeStore = userUnsubscribeStore; - this.userSubscriptionsReadModel = userSubscriptionsReadModel; this.eventService = eventService; this.logger = getLogger('services/user-subscription-service.ts'); } @@ -73,8 +63,4 @@ export default class UserSubscriptionService { // }), // ); } - - async getSubscribed(subscription: string) { - return this.userSubscriptionsReadModel.getSubscribedUsers(subscription); - } } From d21b8916cdf4ef3d25eb1d97d510cd588f393f40 Mon Sep 17 00:00:00 2001 From: kwasniew Date: Tue, 5 Nov 2024 10:09:26 +0100 Subject: [PATCH 3/3] fix: type checker --- src/lib/db/index.ts | 7 +++++++ .../fake-user-subscriptions-read-model.ts | 13 +++++++++++++ .../fake-user-unsubscribe-store.ts | 9 +++++++++ .../user-unsubscribe-store-type.ts | 2 +- .../user-subscriptions/user-unsubscribe-store.ts | 8 ++------ src/test/fixtures/store.ts | 4 ++++ 6 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 src/lib/features/user-subscriptions/fake-user-subscriptions-read-model.ts create mode 100644 src/lib/features/user-subscriptions/fake-user-unsubscribe-store.ts diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index ef17d0de6ac8..f52d7b62c500 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -54,6 +54,8 @@ import { FeatureCollaboratorsReadModel } from '../features/feature-toggle/featur import { createProjectReadModel } from '../features/project/createProjectReadModel'; import { OnboardingStore } from '../features/onboarding/onboarding-store'; import { createOnboardingReadModel } from '../features/onboarding/createOnboardingReadModel'; +import { UserUnsubscribeStore } from '../features/user-subscriptions/user-unsubscribe-store'; +import { UserSubscriptionsReadModel } from '../features/user-subscriptions/user-subscriptions-read-model'; export const createStores = ( config: IUnleashConfig, @@ -187,6 +189,11 @@ export const createStores = ( eventBus, config.flagResolver, ), + userUnsubscribeStore: new UserUnsubscribeStore(db, getLogger), + userSubscriptionsReadModel: new UserSubscriptionsReadModel( + db, + eventBus, + ), }; }; diff --git a/src/lib/features/user-subscriptions/fake-user-subscriptions-read-model.ts b/src/lib/features/user-subscriptions/fake-user-subscriptions-read-model.ts new file mode 100644 index 000000000000..1f04a8574765 --- /dev/null +++ b/src/lib/features/user-subscriptions/fake-user-subscriptions-read-model.ts @@ -0,0 +1,13 @@ +import type { IUserSubscriptionsReadModel } from './user-subscriptions-read-model-type'; + +export class FakeUserSubscriptionsReadModel + implements IUserSubscriptionsReadModel +{ + async getSubscribedUsers(subscription: string) { + return []; + } + + async getUserSubscriptions() { + return ['productivity-report']; + } +} diff --git a/src/lib/features/user-subscriptions/fake-user-unsubscribe-store.ts b/src/lib/features/user-subscriptions/fake-user-unsubscribe-store.ts new file mode 100644 index 000000000000..66d51696a723 --- /dev/null +++ b/src/lib/features/user-subscriptions/fake-user-unsubscribe-store.ts @@ -0,0 +1,9 @@ +import type { IUserUnsubscribeStore } from './user-unsubscribe-store-type'; + +export class FakeUserUnsubscribeStore implements IUserUnsubscribeStore { + async insert() {} + + async delete(): Promise {} + + destroy(): void {} +} diff --git a/src/lib/features/user-subscriptions/user-unsubscribe-store-type.ts b/src/lib/features/user-subscriptions/user-unsubscribe-store-type.ts index 9b9e4b254fcf..78cf8956324a 100644 --- a/src/lib/features/user-subscriptions/user-unsubscribe-store-type.ts +++ b/src/lib/features/user-subscriptions/user-unsubscribe-store-type.ts @@ -5,6 +5,6 @@ export type UnsubscribeEntry = { }; export interface IUserUnsubscribeStore { - insert(item: UnsubscribeEntry): Promise>; + insert(item: UnsubscribeEntry): Promise; delete(item: UnsubscribeEntry): Promise; } diff --git a/src/lib/features/user-subscriptions/user-unsubscribe-store.ts b/src/lib/features/user-subscriptions/user-unsubscribe-store.ts index dcb593b7cb90..6562c06ba3b2 100644 --- a/src/lib/features/user-subscriptions/user-unsubscribe-store.ts +++ b/src/lib/features/user-subscriptions/user-unsubscribe-store.ts @@ -20,7 +20,7 @@ const rowToField = (row: IUserUnsubscribeTable): UnsubscribeEntry => ({ createdAt: row.created_at, }); -export default class UserUnsubscribeStore implements IUserUnsubscribeStore { +export class UserUnsubscribeStore implements IUserUnsubscribeStore { private db: Db; private logger: Logger; @@ -31,14 +31,12 @@ export default class UserUnsubscribeStore implements IUserUnsubscribeStore { } async insert({ userId, subscription }) { - const unsubscribeEntry = await this.db + await this.db .table(TABLE) .insert({ user_id: userId, subscription: subscription }) .onConflict(['user_id', 'subscription']) .ignore() .returning(COLUMNS); - - return rowToField(unsubscribeEntry[0] as IUserUnsubscribeTable); } async delete({ userId, subscription }): Promise { @@ -50,5 +48,3 @@ export default class UserUnsubscribeStore implements IUserUnsubscribeStore { destroy(): void {} } - -module.exports = UserUnsubscribeStore; diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 56fd2747fa2f..93762be5d657 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -54,6 +54,8 @@ import { FakeFeatureCollaboratorsReadModel } from '../../lib/features/feature-to import { createFakeProjectReadModel } from '../../lib/features/project/createProjectReadModel'; import { FakeOnboardingStore } from '../../lib/features/onboarding/fake-onboarding-store'; import { createFakeOnboardingReadModel } from '../../lib/features/onboarding/createOnboardingReadModel'; +import { FakeUserUnsubscribeStore } from '../../lib/features/user-subscriptions/fake-user-unsubscribe-store'; +import { FakeUserSubscriptionsReadModel } from '../../lib/features/user-subscriptions/fake-user-subscriptions-read-model'; const db = { select: () => ({ @@ -117,6 +119,8 @@ const createStores: () => IUnleashStores = () => { featureCollaboratorsReadModel: new FakeFeatureCollaboratorsReadModel(), projectReadModel: createFakeProjectReadModel(), onboardingStore: new FakeOnboardingStore(), + userUnsubscribeStore: new FakeUserUnsubscribeStore(), + userSubscriptionsReadModel: new FakeUserSubscriptionsReadModel(), }; };