diff --git a/src/app.ts b/src/app.ts index 33b60d6..e95e1da 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import { Keycloak } from 'keycloak-connect'; import { adminRoleName } from './config/env'; import adminRouter from './routes/admin'; +import newsletterRouter from './routes/newsletter'; import publicRouter from './routes/public'; import savedFiltersRouter from './routes/savedFilters'; import statisticsRouter from './routes/statistics'; @@ -37,6 +38,7 @@ export default (keycloak: Keycloak): Express => { app.use('/user-sets', keycloak.protect(), userSetsRouter); app.use('/admin', keycloak.protect('realm:' + adminRoleName), adminRouter); app.use('/statistics', keycloak.protect('realm:' + adminRoleName), statisticsRouter); + app.use('/newsletter', keycloak.protect(), newsletterRouter); app.use(globalErrorLogger, globalErrorHandler); diff --git a/src/db/dal/newsletter.ts b/src/db/dal/newsletter.ts new file mode 100644 index 0000000..12469a0 --- /dev/null +++ b/src/db/dal/newsletter.ts @@ -0,0 +1,91 @@ +import { keycloakRealm } from '../../config/env'; +import { getNewsletterStatusFetcher, getNewsletterUpdater, SubscriptionStatus } from '../../utils/newsletter'; +import UserModel, { IUserOutput } from '../models/User'; +import { getUserById } from './user'; + +export const subscribeNewsletter = async (keycloak_id: string, email: string): Promise => { + const newsletterUpdater = getNewsletterUpdater(keycloakRealm); + + const user = await getUserById(keycloak_id, true); + + if (newsletterUpdater) { + const newsletterStatus = await newsletterUpdater({ + user, + action: SubscriptionStatus.SUBSCRIBED, + email, + }); + + return updateUserNewsletterInfo(keycloak_id, { + newsletter_subscription_status: newsletterStatus, + newsletter_email: newsletterStatus !== SubscriptionStatus.FAILED ? email : undefined, + }); + } + + return user; +}; + +export const unsubscribeNewsletter = async (keycloak_id: string): Promise => { + const newsletterUpdater = getNewsletterUpdater(keycloakRealm); + + const user = await getUserById(keycloak_id, true); + + if (newsletterUpdater) { + const newsletterStatus = await newsletterUpdater({ + user: user as UserModel, + action: SubscriptionStatus.UNSUBSCRIBED, + email: user.newsletter_email, + }); + + return updateUserNewsletterInfo(keycloak_id, { + newsletter_subscription_status: newsletterStatus, + newsletter_email: newsletterStatus !== SubscriptionStatus.FAILED ? null : undefined, + }); + } + + return user; +}; + +export const refreshNewsletterStatus = async (keycloak_id: string): Promise => { + const newsletterStatusFetcher = getNewsletterStatusFetcher(keycloakRealm); + + const user = await getUserById(keycloak_id, true); + const isSubscribed = user.newsletter_subscription_status === SubscriptionStatus.SUBSCRIBED; + + if (newsletterStatusFetcher && isSubscribed) { + const newsletterStatus = await newsletterStatusFetcher(user.newsletter_email); + + if (newsletterStatus !== user.newsletter_subscription_status) { + return updateUserNewsletterInfo(keycloak_id, { + newsletter_subscription_status: newsletterStatus, + newsletter_email: newsletterStatus === SubscriptionStatus.UNSUBSCRIBED ? null : undefined, + }); + } + } + + return user; +}; + +export const updateUserNewsletterInfo = async ( + keycloak_id: string, + payload: { + newsletter_subscription_status: SubscriptionStatus; + newsletter_email?: string | null; + }, +): Promise => { + const { newsletter_subscription_status, newsletter_email } = payload; + const updatedUser = await UserModel.update( + { + newsletter_subscription_status, + newsletter_email, + updated_date: new Date(), + }, + { + where: { + keycloak_id, + }, + returning: true, + }, + ); + + return updatedUser[1][0]; +}; diff --git a/src/db/dal/user.ts b/src/db/dal/user.ts index 4a08832..6127998 100644 --- a/src/db/dal/user.ts +++ b/src/db/dal/user.ts @@ -4,11 +4,10 @@ import { StatusCodes } from 'http-status-codes'; import { Op, Order } from 'sequelize'; import { uuid } from 'uuidv4'; -import { keycloakRealm, profileImageBucket } from '../../config/env'; +import { profileImageBucket } from '../../config/env'; import config from '../../config/project'; -import { getNewsletterHandler, NewsletterPayload, SubscriptionStatus } from '../../utils/newsletter'; import { UserValidator } from '../../utils/userValidator'; -import UserModel, { IUserInput, IUserOuput } from '../models/User'; +import UserModel, { IUserInput, IUserOutput } from '../models/User'; let S3Client; try { @@ -18,9 +17,19 @@ try { } const sanitizeInputPayload = (payload: IUserInput) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { id, keycloak_id, completed_registration, creation_date, email, era_commons_id, nih_ned_id, ...rest } = - payload; + const { + id, + keycloak_id, + completed_registration, + creation_date, + email, + era_commons_id, + nih_ned_id, + newsletter_email, + newsletter_subscription_status, + ...rest + } = payload; + return rest; }; @@ -139,7 +148,7 @@ export const getProfileImageUploadPresignedUrl = async (keycloak_id: string) => }; }; -export const getUserById = async (keycloak_id: string, isOwn: boolean): Promise => { +export const getUserById = async (keycloak_id: string, isOwn: boolean): Promise => { let attributesClause = {}; if (!isOwn) { attributesClause = { @@ -159,7 +168,7 @@ export const getUserById = async (keycloak_id: string, isOwn: boolean): Promise< throw createHttpError(StatusCodes.NOT_FOUND, `User with keycloak id ${keycloak_id} does not exist.`); } - return user; + return user.dataValues; }; export const isUserExists = async ( @@ -178,22 +187,24 @@ export const isUserExists = async ( }; }; -export const createUser = async (keycloak_id: string, payload: IUserInput): Promise => { +export const createUser = async (keycloak_id: string, payload: IUserInput): Promise => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { newsletter_email, newsletter_subscription_status, ...rest } = payload; + const newUser = await UserModel.create({ - ...payload, + ...rest, keycloak_id: keycloak_id, creation_date: new Date(), updated_date: new Date(), }); - return newUser; -}; -export const updateUser = async (keycloak_id: string, payload: IUserInput): Promise => { - const { newsletter_email, newsletter_subscription_status, ...rest } = payload; + return newUser.dataValues; +}; +export const updateUser = async (keycloak_id: string, payload: IUserInput): Promise => { const results = await UserModel.update( { - ...sanitizeInputPayload(rest), + ...sanitizeInputPayload(payload), updated_date: new Date(), }, { @@ -203,13 +214,8 @@ export const updateUser = async (keycloak_id: string, payload: IUserInput): Prom returning: true, }, ); - const updatedUser = results[1][0]; - return updateNewsletterStatus({ - user: updatedUser, - email: newsletter_email, - action: newsletter_subscription_status, - }); + return results[1][0].dataValues; }; export const deleteUser = async (keycloak_id: string): Promise => { @@ -226,6 +232,8 @@ export const deleteUser = async (keycloak_id: string): Promise => { linkedin: null, external_individual_fullname: null, external_individual_email: null, + newsletter_email: null, + newsletter_subscription_status: null, deleted: true, }, { @@ -240,7 +248,7 @@ export const completeRegistration = async ( keycloak_id: string, payload: IUserInput, validator: UserValidator, -): Promise => { +): Promise => { if (!validator(payload)) { throw createHttpError( StatusCodes.BAD_REQUEST, @@ -248,11 +256,9 @@ export const completeRegistration = async ( ); } - const { newsletter_email, newsletter_subscription_status, ...rest } = payload; - const results = await UserModel.update( { - ...sanitizeInputPayload(rest), + ...sanitizeInputPayload(payload), completed_registration: true, updated_date: new Date(), }, @@ -264,45 +270,7 @@ export const completeRegistration = async ( }, ); - const updatedUser = results[1][0]; - - return updateNewsletterStatus({ - user: updatedUser, - email: newsletter_email, - action: newsletter_subscription_status, - }); -}; - -export const updateNewsletterStatus = async (payload: NewsletterPayload): Promise => { - const newsletterHandler = getNewsletterHandler(keycloakRealm); - - const shouldUpdateStatus = - payload.action && payload.action !== payload.user.dataValues.newsletter_subscription_status; - - if (newsletterHandler && shouldUpdateStatus) { - const newsletterStatus = await newsletterHandler({ - ...payload, - email: payload.email || payload.user.dataValues.newsletter_email, - }); - - const updatedUser = await UserModel.update( - { - newsletter_subscription_status: newsletterStatus, - newsletter_email: newsletterStatus !== SubscriptionStatus.FAILED ? payload.email : undefined, - updated_date: new Date(), - }, - { - where: { - keycloak_id: payload.user.keycloak_id, - }, - returning: true, - }, - ); - - return updatedUser[1][0]; - } - - return payload.user; + return results[1][0].dataValues; }; export const resetAllConsents = async (): Promise => { diff --git a/src/db/models/User.ts b/src/db/models/User.ts index 5197497..9faaf42 100644 --- a/src/db/models/User.ts +++ b/src/db/models/User.ts @@ -38,8 +38,9 @@ interface IUserAttributes { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface IUserInput extends IUserAttributes {} + // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface IUserOuput extends IUserAttributes {} +export interface IUserOutput extends IUserAttributes {} class UserModel extends Model implements IUserAttributes { public id: number; diff --git a/src/external/smartsheet.test.ts b/src/external/smartsheet.test.ts index 00fe3f9..f5ea2d1 100644 --- a/src/external/smartsheet.test.ts +++ b/src/external/smartsheet.test.ts @@ -6,6 +6,7 @@ import { fetchSheet, findSubscription, formatRow, + getSubscriptionStatus, handleNewsletterUpdate, subscribeNewsletter, unsubscribeNewsletter, @@ -135,6 +136,63 @@ describe('smartsheet', () => { expect(fetch).toHaveBeenCalledTimes(1); }); }); + describe('getSubscriptionStatus', () => { + it('should return SUBSCRIBED if rowId is found', async () => { + const subscribeMockSheet = { + ...mockSheet, + rows: [ + { + id: 1, + cells: [ + { + columnId: 5, + value: 'test@example.com', + }, + ], + }, + ], + }; + const email = 'test@example.com'; + + fetch.mockResolvedValueOnce(new Response(JSON.stringify(subscribeMockSheet))); + + const result = await getSubscriptionStatus(email); + + expect(result).toBe(SubscriptionStatus.SUBSCRIBED); + }); + + it('should return UNSUBSCRIBED if no rowId is found', async () => { + const unsubscribeMockSheet = { + ...mockSheet, + rows: [ + { + id: 1, + cells: [ + { + columnId: 5, + value: 'test@example.com', + }, + ], + }, + ], + }; + const email = 'nonexistent@example.com'; + + fetch.mockResolvedValueOnce(new Response(JSON.stringify(unsubscribeMockSheet))); + + const result = await getSubscriptionStatus(email); + + expect(result).toBe(SubscriptionStatus.UNSUBSCRIBED); + }); + + it('should return FAILED if fetchSheet fails', async () => { + fetch.mockRejectedValueOnce(new Error('Network Error')); + const email = 'test@example.com'; + const result = await getSubscriptionStatus(email); + + expect(result).toBe(SubscriptionStatus.FAILED); + }); + }); describe('subscribeNewsletter', () => { it('should subscribe to newsletter successfully', async () => { diff --git a/src/external/smartsheet.ts b/src/external/smartsheet.ts index e97eb95..ebbf5e4 100644 --- a/src/external/smartsheet.ts +++ b/src/external/smartsheet.ts @@ -28,7 +28,7 @@ export const handleNewsletterUpdate = async (payload: NewsletterPayload): Promis if (!rowId && payload.action === SubscriptionStatus.SUBSCRIBED) { const formattedRow = formatRow(smartsheet.columns, { - ...payload.user.dataValues, + ...payload.user, newsletter_email: payload.email, }); @@ -44,6 +44,18 @@ export const handleNewsletterUpdate = async (payload: NewsletterPayload): Promis } }; +export const getSubscriptionStatus = async (email: string): Promise => { + try { + const smartsheet = await fetchSheet(); + const rowId = findSubscription(smartsheet.rows, email); + + return rowId ? SubscriptionStatus.SUBSCRIBED : SubscriptionStatus.UNSUBSCRIBED; + } catch (error) { + console.error(error); + return SubscriptionStatus.FAILED; + } +}; + export const subscribeNewsletter = async (row: FormattedRow): Promise => { try { await fetch(`https://api.smartsheet.com/2.0/sheets/${smartsheetId}/rows`, { diff --git a/src/external/smartsheetTypes.ts b/src/external/smartsheetTypes.ts index f8ddb0c..c3f9ada 100644 --- a/src/external/smartsheetTypes.ts +++ b/src/external/smartsheetTypes.ts @@ -1,4 +1,4 @@ -import { IUserOuput } from '../db/models/User'; +import { IUserOutput } from '../db/models/User'; // The type could be better but this is most likely sufficient for our use case // In case we need to update the types, it's Greatly inspired from: @@ -69,7 +69,7 @@ export type Row = { }; export type SubscribeNewsletterPayload = Pick< - IUserOuput, + IUserOutput, 'first_name' | 'last_name' | 'affiliation' | 'roles' | 'newsletter_email' >; diff --git a/src/routes/newsletter.ts b/src/routes/newsletter.ts new file mode 100644 index 0000000..b38bf4b --- /dev/null +++ b/src/routes/newsletter.ts @@ -0,0 +1,40 @@ +import { Router } from 'express'; +import { StatusCodes } from 'http-status-codes'; + +import { refreshNewsletterStatus, subscribeNewsletter, unsubscribeNewsletter } from '../db/dal/newsletter'; + +const newsletterRouter = Router(); + +newsletterRouter.put('/refresh', async (req, res, next) => { + try { + const keycloak_id = req['kauth']?.grant?.access_token?.content?.sub; + const result = await refreshNewsletterStatus(keycloak_id); + res.status(StatusCodes.OK).send(result); + } catch (e) { + next(e); + } +}); + +newsletterRouter.put('/subscribe', async (req, res, next) => { + try { + const keycloak_id = req['kauth']?.grant?.access_token?.content?.sub; + const result = await subscribeNewsletter(keycloak_id, req.body.newsletter_email); + + console.log(result); + res.status(StatusCodes.OK).send(result); + } catch (e) { + next(e); + } +}); + +newsletterRouter.put('/unsubscribe', async (req, res, next) => { + try { + const keycloak_id = req['kauth']?.grant?.access_token?.content?.sub; + const result = await unsubscribeNewsletter(keycloak_id); + res.status(StatusCodes.OK).send(result); + } catch (e) { + next(e); + } +}); + +export default newsletterRouter; diff --git a/src/utils/newsletter.ts b/src/utils/newsletter.ts index 15cd581..7a5a2d8 100644 --- a/src/utils/newsletter.ts +++ b/src/utils/newsletter.ts @@ -1,6 +1,6 @@ import Realm from '../config/realm'; -import UserModel from '../db/models/User'; -import { handleNewsletterUpdate } from '../external/smartsheet'; +import { IUserOutput } from '../db/models/User'; +import { getSubscriptionStatus, handleNewsletterUpdate } from '../external/smartsheet'; export enum SubscriptionStatus { SUBSCRIBED = 'subscribed', @@ -9,16 +9,20 @@ export enum SubscriptionStatus { } export type NewsletterPayload = { - user: UserModel; + user: IUserOutput; email: string; action: SubscriptionStatus; }; -export interface NewsletterHandler { +export interface NewsletterUpdater { (payload: NewsletterPayload): Promise; } -export const getNewsletterHandler = (realm: string): NewsletterHandler => { +export interface NewsletterStatusFetcher { + (email: string): Promise; +} + +export const getNewsletterUpdater = (realm: string): NewsletterUpdater => { switch (realm) { case Realm.INCLUDE: return handleNewsletterUpdate; @@ -26,3 +30,12 @@ export const getNewsletterHandler = (realm: string): NewsletterHandler => { return undefined; } }; + +export const getNewsletterStatusFetcher = (realm: string): NewsletterStatusFetcher => { + switch (realm) { + case Realm.INCLUDE: + return getSubscriptionStatus; + default: + return undefined; + } +};