Skip to content

Commit

Permalink
feat: SJIP-725 move newsletter to standalone routes (#71)
Browse files Browse the repository at this point in the history
* refactor: SJIP-725 export to standalone routes
  • Loading branch information
aperron-ferlab authored Mar 20, 2024
1 parent be96844 commit 8f3b9b5
Show file tree
Hide file tree
Showing 9 changed files with 258 additions and 73 deletions.
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Expand Down
91 changes: 91 additions & 0 deletions src/db/dal/newsletter.ts
Original file line number Diff line number Diff line change
@@ -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<IUserOutput> => {
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<IUserOutput> => {
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<IUserOutput> => {
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<IUserOutput> => {
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];
};
96 changes: 32 additions & 64 deletions src/db/dal/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
};

Expand Down Expand Up @@ -139,7 +148,7 @@ export const getProfileImageUploadPresignedUrl = async (keycloak_id: string) =>
};
};

export const getUserById = async (keycloak_id: string, isOwn: boolean): Promise<IUserOuput> => {
export const getUserById = async (keycloak_id: string, isOwn: boolean): Promise<IUserOutput> => {
let attributesClause = {};
if (!isOwn) {
attributesClause = {
Expand All @@ -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 (
Expand All @@ -178,22 +187,24 @@ export const isUserExists = async (
};
};

export const createUser = async (keycloak_id: string, payload: IUserInput): Promise<IUserOuput> => {
export const createUser = async (keycloak_id: string, payload: IUserInput): Promise<IUserOutput> => {
// 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<IUserOuput> => {
const { newsletter_email, newsletter_subscription_status, ...rest } = payload;
return newUser.dataValues;
};

export const updateUser = async (keycloak_id: string, payload: IUserInput): Promise<IUserOutput> => {
const results = await UserModel.update(
{
...sanitizeInputPayload(rest),
...sanitizeInputPayload(payload),
updated_date: new Date(),
},
{
Expand All @@ -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<void> => {
Expand All @@ -226,6 +232,8 @@ export const deleteUser = async (keycloak_id: string): Promise<void> => {
linkedin: null,
external_individual_fullname: null,
external_individual_email: null,
newsletter_email: null,
newsletter_subscription_status: null,
deleted: true,
},
{
Expand All @@ -240,19 +248,17 @@ export const completeRegistration = async (
keycloak_id: string,
payload: IUserInput,
validator: UserValidator,
): Promise<IUserOuput> => {
): Promise<IUserOutput> => {
if (!validator(payload)) {
throw createHttpError(
StatusCodes.BAD_REQUEST,
'Some required fields are missing to complete user registration',
);
}

const { newsletter_email, newsletter_subscription_status, ...rest } = payload;

const results = await UserModel.update(
{
...sanitizeInputPayload(rest),
...sanitizeInputPayload(payload),
completed_registration: true,
updated_date: new Date(),
},
Expand All @@ -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<IUserOuput> => {
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<number> => {
Expand Down
3 changes: 2 additions & 1 deletion src/db/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IUserAttributes, IUserInput> implements IUserAttributes {
public id: number;
Expand Down
58 changes: 58 additions & 0 deletions src/external/smartsheet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
fetchSheet,
findSubscription,
formatRow,
getSubscriptionStatus,
handleNewsletterUpdate,
subscribeNewsletter,
unsubscribeNewsletter,
Expand Down Expand Up @@ -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: '[email protected]',
},
],
},
],
};
const email = '[email protected]';

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: '[email protected]',
},
],
},
],
};
const email = '[email protected]';

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 = '[email protected]';
const result = await getSubscriptionStatus(email);

expect(result).toBe(SubscriptionStatus.FAILED);
});
});

describe('subscribeNewsletter', () => {
it('should subscribe to newsletter successfully', async () => {
Expand Down
14 changes: 13 additions & 1 deletion src/external/smartsheet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand All @@ -44,6 +44,18 @@ export const handleNewsletterUpdate = async (payload: NewsletterPayload): Promis
}
};

export const getSubscriptionStatus = async (email: string): Promise<SubscriptionStatus> => {
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<SubscriptionStatus> => {
try {
await fetch(`https://api.smartsheet.com/2.0/sheets/${smartsheetId}/rows`, {
Expand Down
Loading

0 comments on commit 8f3b9b5

Please sign in to comment.