Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

improve the recape email design #10195

Merged
merged 27 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
41d6811
improve the recape email design
Dec 10, 2024
8bf1357
added the backend logic for unsubscribe
Dec 11, 2024
03a5dc6
added the uuid so every unsubscriber linke is unique
Dec 11, 2024
ceb5657
fixed unit test error
Dec 12, 2024
da52885
Merge branch 'master' of https://github.com/hicommonwealth/commonweal…
Dec 12, 2024
ed83661
fixed the pr Review
Dec 12, 2024
7c0b075
Merge branch 'master' of https://github.com/hicommonwealth/commonweal…
Dec 16, 2024
59b7a76
Merge branch 'master' of https://github.com/hicommonwealth/commonweal…
Dec 17, 2024
7a48159
fixed the review
Dec 18, 2024
5d04e4f
Merge branch 'master' of https://github.com/hicommonwealth/commonweal…
Dec 18, 2024
002de1e
fix test
Dec 18, 2024
a84023d
Merge branch 'master' of https://github.com/hicommonwealth/commonweal…
Dec 19, 2024
6c0690a
Merge branch 'master' of https://github.com/hicommonwealth/commonweal…
Dec 26, 2024
0c678ea
fixed types issues
Dec 26, 2024
4a4d81c
Merge branch 'master' of https://github.com/hicommonwealth/commonweal…
Dec 30, 2024
ad74020
Merge branch 'master' of https://github.com/hicommonwealth/commonweal…
Jan 9, 2025
4d5b919
pull master
Jan 9, 2025
b8054bc
fixed type
KaleemNeslit Jan 9, 2025
417356e
Merge branch 'master' of https://github.com/hicommonwealth/commonweal…
Jan 9, 2025
85d73bc
Merge branch 'kaleemNeslit.10139.recap_email' of https://github.com/h…
Jan 9, 2025
93bb4bf
merge the master
Jan 10, 2025
5d806ea
Merge branch 'master' of https://github.com/hicommonwealth/commonweal…
Jan 10, 2025
5c62f88
resolve tim review
Jan 10, 2025
efb5f2d
fixed the typeScript annotation error
Jan 13, 2025
0be333b
improve type annotation
Jan 13, 2025
205af92
resolve type
Jan 13, 2025
3a0a655
added the null for type casting
Jan 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion libs/core/src/integration/email.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const GetRecapEmailData = {
input: z.object({
user_id: z.string(),
}),

KaleemNeslit marked this conversation as resolved.
Show resolved Hide resolved
output: z.object({
discussion: z.array(
z.union([
Expand All @@ -91,9 +92,9 @@ export const GetRecapEmailData = {
),
num_notifications: z.number(),
notifications_link: z.string(),
unsubscribe_link: z.string(),
}),
};

export const EnrichedThread = Thread.extend({
name: z
.string()
Expand Down
6 changes: 3 additions & 3 deletions libs/model/src/emails/GetRecapEmailData.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ import {
} from '@hicommonwealth/core';
import { QueryTypes } from 'sequelize';
import z from 'zod';
import { config, models } from '..';
import { config, generateUnsubscribeLink, models } from '..';

const log = logger(import.meta);

type AdditionalMetaData<Key extends keyof typeof EnrichedNotificationNames> = {
event_name: (typeof EnrichedNotificationNames)[Key];
inserted_at: string;
Expand Down Expand Up @@ -53,7 +52,6 @@ async function getMessages(userId: string): Promise<{
const sevenDaysAgo = new Date(new Date().getTime() - 604_800_000);
let oldestFetched = new Date();
let cursor: string | undefined;

const provider = notificationsProvider();
while (
oldestFetched > sevenDaysAgo &&
Expand Down Expand Up @@ -268,6 +266,7 @@ export function GetRecapEmailDataQuery(): Query<typeof GetRecapEmailData> {
const enrichedDiscussion = await enrichDiscussionNotifications(
notifications.discussion,
);
const unSubscribeLink = await generateUnsubscribeLink(payload.user_id);
return {
discussion: enrichedDiscussion,
...enrichedGovernanceAndProtocol,
Expand All @@ -276,6 +275,7 @@ export function GetRecapEmailDataQuery(): Query<typeof GetRecapEmailData> {
enrichedGovernanceAndProtocol.governance.length +
enrichedGovernanceAndProtocol.protocol.length,
notifications_link: config.SERVER_URL,
unsubscribe_link: unSubscribeLink,
};
},
};
Expand Down
2 changes: 2 additions & 0 deletions libs/model/src/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export default (sequelize: Sequelize.Sequelize): UserModelStatic =>
selected_community_id: { type: Sequelize.STRING, allowNull: true },
profile: { type: Sequelize.JSONB, allowNull: false },
xp_points: { type: Sequelize.INTEGER, defaultValue: 0, allowNull: true },
unsubscribe_uuid: { type: Sequelize.STRING, allowNull: true },
KaleemNeslit marked this conversation as resolved.
Show resolved Hide resolved
referral_eth_earnings: {
type: Sequelize.FLOAT,
allowNull: false,
Expand All @@ -95,6 +96,7 @@ export default (sequelize: Sequelize.Sequelize): UserModelStatic =>
'isAdmin',
'created_at',
'updated_at',
'unsubscribe_uuid',
],
},
},
Expand Down
23 changes: 23 additions & 0 deletions libs/model/src/subscription/UnSubscribeEmail.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { type Command } from '@hicommonwealth/core';
KaleemNeslit marked this conversation as resolved.
Show resolved Hide resolved
import * as schemas from '@hicommonwealth/schemas';
import { models } from '../database';
import { mustExist } from '../middleware/guards';
import { handleSubscriptionPreferencesUpdate } from '../utils/handleSubscriptionPreferencesUpdate';
export function UnsubscribeEmail(): Command<typeof schemas.UnsubscribeEmail> {
return {
...schemas.UnsubscribeEmail,
auth: [],
secure: false,
body: async ({ payload }) => {
const user = await models.User.findOne({
where: { unsubscribe_uuid: payload.user_uuid },
});
mustExist('User', user);

return await handleSubscriptionPreferencesUpdate({
userIdentifier: user.id as number,
KaleemNeslit marked this conversation as resolved.
Show resolved Hide resolved
payload,
});
},
};
}
Original file line number Diff line number Diff line change
@@ -1,26 +1,6 @@
import { type Command } from '@hicommonwealth/core';
import { emitEvent } from '@hicommonwealth/model';
import * as schemas from '@hicommonwealth/schemas';
import { SubscriptionPreference } from '@hicommonwealth/schemas';
import { z } from 'zod';
import { models } from '../database';

function getDifferences(
fullObject: Record<string, unknown>,
subsetObject: Record<string, unknown>,
): Partial<z.infer<typeof SubscriptionPreference>> {
const differences: Record<string, unknown> = {};
for (const key in subsetObject) {
if (
key !== 'id' &&
key in subsetObject &&
subsetObject[key] !== fullObject[key]
) {
differences[key] = subsetObject[key];
}
}
return differences;
}
import { handleSubscriptionPreferencesUpdate } from '../utils/handleSubscriptionPreferencesUpdate';

export function UpdateSubscriptionPreferences(): Command<
typeof schemas.UpdateSubscriptionPreferences
Expand All @@ -30,52 +10,10 @@ export function UpdateSubscriptionPreferences(): Command<
auth: [],
secure: true,
body: async ({ payload, actor }) => {
const existingPreferences: z.infer<typeof SubscriptionPreference> | null =
await models.SubscriptionPreference.findOne({
where: {
user_id: actor.user.id,
},
raw: true,
});

if (!existingPreferences) {
throw new Error('Existing preferences not found');
}

const preferenceUpdates = getDifferences(existingPreferences, payload);
if (!Object.keys(preferenceUpdates).length) {
return existingPreferences;
}

let result;
await models.sequelize.transaction(async (transaction) => {
result = await models.SubscriptionPreference.update(payload, {
where: {
user_id: actor.user.id,
},
returning: true,
transaction,
});

if (result[1].length !== 1)
throw new Error('Failed to update subscription preferences');

await emitEvent(
models.Outbox,
[
{
event_name: schemas.EventNames.SubscriptionPreferencesUpdated,
event_payload: {
user_id: existingPreferences.user_id,
...preferenceUpdates,
},
},
],
transaction,
);
return await handleSubscriptionPreferencesUpdate({
userIdentifier: Number(actor.user.id),
payload,
});

return result![1][0].get({ plain: true });
},
};
}
1 change: 1 addition & 0 deletions libs/model/src/subscription/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export * from './GetCommunityAlerts.query';
export * from './GetSubscriptionPreferences.query';
export * from './GetThreadSubscriptions.query';
export * from './RegisterClientRegistrationToken.command';
export * from './UnSubscribeEmail.command';
export * from './UnregisterClientRegistrationToken.command';
export * from './UpdateSubscriptionPreferences.command';
16 changes: 16 additions & 0 deletions libs/model/src/utils/generateUnsubscribeLink.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { v4 as uuidv4 } from 'uuid';
import { config, models } from '..';

/**
* Generates an unsubscribe link for a user and updates the database.
* @param userId - The user's ID
* @returns {Promise<string>} - The unsubscribe link
*/
export async function generateUnsubscribeLink(userId: string): Promise<string> {
const unsubscribeUuid = uuidv4();
await models.User.update(
{ unsubscribe_uuid: unsubscribeUuid },
{ where: { id: userId } },
);
return `${config.SERVER_URL}/unsubscribe/${unsubscribeUuid}`;
}
74 changes: 74 additions & 0 deletions libs/model/src/utils/handleSubscriptionPreferencesUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { InvalidState } from '@hicommonwealth/core';
import * as schemas from '@hicommonwealth/schemas';
import { SubscriptionPreference } from '@hicommonwealth/schemas';
import { z } from 'zod';
import { models } from '../database';
import { mustExist } from '../middleware/guards';
import { emitEvent } from './utils';

function getDifferences(
KaleemNeslit marked this conversation as resolved.
Show resolved Hide resolved
fullObject: Record<string, unknown>,
subsetObject: Record<string, unknown>,
): Partial<z.infer<typeof SubscriptionPreference>> {
const differences: Record<string, unknown> = {};
for (const key in subsetObject) {
if (
key !== 'id' &&
key in subsetObject &&
subsetObject[key] !== fullObject[key]
) {
differences[key] = subsetObject[key];
}
}
return differences;
}

export async function handleSubscriptionPreferencesUpdate({
userIdentifier,
payload,
}: {
userIdentifier: number;
payload: Partial<z.infer<typeof SubscriptionPreference>>;
}) {
const existingPreferences: z.infer<typeof SubscriptionPreference> | null =
await models.SubscriptionPreference.findOne({
where: {
user_id: userIdentifier,
},
raw: true,
});

mustExist('existingPreferences', existingPreferences);

const preferenceUpdates = getDifferences(existingPreferences, payload);
if (!Object.keys(preferenceUpdates).length) {
return existingPreferences;
}
let result;
await models.sequelize.transaction(async (transaction) => {
result = await models.SubscriptionPreference.update(payload, {
where: { user_id: userIdentifier },
returning: true,
transaction,
});
if (result[1].length !== 1) {
throw new InvalidState('Failed to update subscription preferences');
}

await emitEvent(
models.Outbox,
[
{
event_name: schemas.EventNames.SubscriptionPreferencesUpdated,
event_payload: {
user_id: existingPreferences.user_id,
...preferenceUpdates,
},
},
],
transaction,
);
});

return result![1][0].get({ plain: true });
}
1 change: 1 addition & 0 deletions libs/model/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * from './decodeContent';
export * from './defaultAvatar';
export * from './denormalizedCountUtils';
export * from './farcasterUtils';
export * from './generateUnsubscribeLink';
export * from './getDefaultContestImage';
export * from './getDelta';
export * from './makeGetBalancesOptions';
Expand Down
8 changes: 8 additions & 0 deletions libs/schemas/src/commands/subscription.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,11 @@ export const UnregisterClientRegistrationToken = {
}),
output: z.object({}),
};

export const UnsubscribeEmail = {
input: z.object({
user_uuid: z.string().uuid(),
email_notifications_enabled: z.boolean(),
}),
output: SubscriptionPreference,
};
2 changes: 2 additions & 0 deletions libs/schemas/src/entities/user.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export const User = z.object({

profile: UserProfile,
xp_points: PG_INT.default(0).nullish(),
referral_link: z.string().nullish(),
unsubscribe_uuid: z.string().uuid().nullish(),
referral_eth_earnings: z.number().optional(),

ProfileTags: z.array(ProfileTags).optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ const ProfilePageRedirect = lazy(() => import('views/pages/profile_redirect'));
const CommunityNotFoundPage = lazy(
() => import('views/pages/CommunityNotFoundPage'),
);

const UnSubscribePage = lazy(() => import('views/pages/UnSubscribePage'));
const RewardsPage = lazy(() => import('views/pages/RewardsPage'));

const CommonDomainRoutes = ({ launchpadEnabled }: RouteFeatureFlags) => [
Expand Down Expand Up @@ -153,6 +155,11 @@ const CommonDomainRoutes = ({ launchpadEnabled }: RouteFeatureFlags) => [
path="/createCommunity"
element={withLayout(CreateCommunityPage, { type: 'common' })}
/>,
<Route
key="/unSubscribe/:userId"
path="/unSubscribe/:userId"
KaleemNeslit marked this conversation as resolved.
Show resolved Hide resolved
KaleemNeslit marked this conversation as resolved.
Show resolved Hide resolved
element={withLayout(UnSubscribePage, { type: 'common' })}
/>,
...(launchpadEnabled
? [
<Route
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const NewSnapshotProposalPage = lazy(
const NewProfilePage = lazy(() => import('views/pages/new_profile'));
const EditNewProfilePage = lazy(() => import('views/pages/edit_new_profile'));
const ProfilePageRedirect = lazy(() => import('views/pages/profile_redirect'));
const UnSubscribePage = lazy(() => import('views/pages/UnSubscribePage'));

const RewardsPage = lazy(() => import('views/pages/RewardsPage'));

Expand All @@ -119,6 +120,11 @@ const CustomDomainRoutes = ({ launchpadEnabled }: RouteFeatureFlags) => {
path="/createCommunity"
element={withLayout(CreateCommunityPage, { type: 'common' })}
/>,
<Route
key="/unSubscribe/:userId"
path="/unSubscribe/:userId"
element={withLayout(UnSubscribePage, { type: 'common' })}
/>,
...(launchpadEnabled
? [
<Route
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { trpc } from 'utils/trpcClient';

export function useUnSubscribeEmailMutation() {
return trpc.subscriptions.unSubscribeEmail.useMutation();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@import '../../../styles/shared.scss';

.UnSubscribeModal {
.CWModalBody {
padding-bottom: 24px !important;
}
.CWModalFooter .primary {
background-color: $rorange-400 !important;
}
}
Loading
Loading