diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index ef17d0de6a..f52d7b62c5 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 0000000000..1f04a85747 --- /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 0000000000..66d51696a7 --- /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-subscriptions-read-model-type.ts b/src/lib/features/user-subscriptions/user-subscriptions-read-model-type.ts new file mode 100644 index 0000000000..d61087905c --- /dev/null +++ b/src/lib/features/user-subscriptions/user-subscriptions-read-model-type.ts @@ -0,0 +1,11 @@ +export type Subscriber = { + name: string; + email: string; +}; + +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 new file mode 100644 index 0000000000..746457ca83 --- /dev/null +++ b/src/lib/features/user-subscriptions/user-subscriptions-read-model.ts @@ -0,0 +1,56 @@ +import type { Db } from '../../db/db'; +import type EventEmitter from 'events'; +import { + SUBSCRIPTION_TYPES, + type IUserSubscriptionsReadModel, + type 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 mapRowToSubscriber = (row) => + ({ + name: row.name || row.username || '', + email: row.email, + }) as Subscriber; + +export class UserSubscriptionsReadModel implements IUserSubscriptionsReadModel { + private db: Db; + + constructor(db: Db, eventBus: EventEmitter) { + this.db = db; + } + + async getSubscribedUsers(subscription: string) { + 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) + .andWhereNot('email', null); + + return users.map(mapRowToSubscriber); + } + + async getUserSubscriptions(userId: number) { + const unsubscriptions = await this.db(UNSUBSCRIPTION_TABLE) + .select('subscription') + .where('user_id', userId); + + 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 new file mode 100644 index 0000000000..3e197da298 --- /dev/null +++ b/src/lib/features/user-subscriptions/user-subscriptions-service.ts @@ -0,0 +1,66 @@ +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'; + +export default class UserSubscriptionService { + private userUnsubscribeStore: IUserUnsubscribeStore; + + private eventService: EventService; + + private logger: Logger; + + constructor( + { userUnsubscribeStore }: Pick, + { getLogger }: Pick, + eventService: EventService, + ) { + this.userUnsubscribeStore = userUnsubscribeStore; + 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, + // }), + // ); + } +} 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 0000000000..78cf895632 --- /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 0000000000..6562c06ba3 --- /dev/null +++ b/src/lib/features/user-subscriptions/user-unsubscribe-store.ts @@ -0,0 +1,50 @@ +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 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 }) { + await this.db + .table(TABLE) + .insert({ user_id: userId, subscription: subscription }) + .onConflict(['user_id', 'subscription']) + .ignore() + .returning(COLUMNS); + } + + async delete({ userId, subscription }): Promise { + await this.db + .table(TABLE) + .where({ user_id: userId, subscription: subscription }) + .del(); + } + + destroy(): void {} +} diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 634bfedca4..0379a0f780 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 { diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 56fd2747fa..93762be5d6 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(), }; };