diff --git a/libs/core/src/integration/email.schemas.ts b/libs/core/src/integration/email.schemas.ts index 61eaac08d69..a6058e19549 100644 --- a/libs/core/src/integration/email.schemas.ts +++ b/libs/core/src/integration/email.schemas.ts @@ -75,6 +75,7 @@ export const GetRecapEmailData = { input: z.object({ user_id: z.string(), }), + output: z.object({ discussion: z.array( z.union([ @@ -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() diff --git a/libs/model/src/emails/GetRecapEmailData.query.ts b/libs/model/src/emails/GetRecapEmailData.query.ts index 00d4bcbb547..f7f7a319723 100644 --- a/libs/model/src/emails/GetRecapEmailData.query.ts +++ b/libs/model/src/emails/GetRecapEmailData.query.ts @@ -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 = { event_name: (typeof EnrichedNotificationNames)[Key]; inserted_at: string; @@ -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 && @@ -268,6 +266,7 @@ export function GetRecapEmailDataQuery(): Query { const enrichedDiscussion = await enrichDiscussionNotifications( notifications.discussion, ); + const unSubscribeLink = await generateUnsubscribeLink(payload.user_id); return { discussion: enrichedDiscussion, ...enrichedGovernanceAndProtocol, @@ -276,6 +275,7 @@ export function GetRecapEmailDataQuery(): Query { enrichedGovernanceAndProtocol.governance.length + enrichedGovernanceAndProtocol.protocol.length, notifications_link: config.SERVER_URL, + unsubscribe_link: unSubscribeLink, }; }, }; diff --git a/libs/model/src/models/user.ts b/libs/model/src/models/user.ts index 687d5cd0127..207eb1641de 100644 --- a/libs/model/src/models/user.ts +++ b/libs/model/src/models/user.ts @@ -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 }, referral_count: { type: Sequelize.INTEGER, defaultValue: 0, @@ -100,6 +101,7 @@ export default (sequelize: Sequelize.Sequelize): UserModelStatic => 'isAdmin', 'created_at', 'updated_at', + 'unsubscribe_uuid', ], }, }, diff --git a/libs/model/src/subscription/UnSubscribeEmail.command.ts b/libs/model/src/subscription/UnSubscribeEmail.command.ts new file mode 100644 index 00000000000..7eeb32e92c3 --- /dev/null +++ b/libs/model/src/subscription/UnSubscribeEmail.command.ts @@ -0,0 +1,23 @@ +import { type Command } from '@hicommonwealth/core'; +import * as schemas from '@hicommonwealth/schemas'; +import { models } from '../database'; +import { mustExist } from '../middleware/guards'; +import { handleSubscriptionPreferencesUpdate } from '../utils/handleSubscriptionPreferencesUpdate'; +export function UnsubscribeEmail(): Command { + 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!, + payload, + }); + }, + }; +} diff --git a/libs/model/src/subscription/UpdateSubscriptionPreferences.command.ts b/libs/model/src/subscription/UpdateSubscriptionPreferences.command.ts index 563cfbbe751..6e98692e995 100644 --- a/libs/model/src/subscription/UpdateSubscriptionPreferences.command.ts +++ b/libs/model/src/subscription/UpdateSubscriptionPreferences.command.ts @@ -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, - subsetObject: Record, -): Partial> { - const differences: Record = {}; - 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 @@ -30,52 +10,10 @@ export function UpdateSubscriptionPreferences(): Command< auth: [], secure: true, body: async ({ payload, actor }) => { - const existingPreferences: z.infer | 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 }); }, }; } diff --git a/libs/model/src/subscription/index.ts b/libs/model/src/subscription/index.ts index f76a54234e1..f2387b10824 100644 --- a/libs/model/src/subscription/index.ts +++ b/libs/model/src/subscription/index.ts @@ -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'; diff --git a/libs/model/src/utils/generateUnsubscribeLink.ts b/libs/model/src/utils/generateUnsubscribeLink.ts new file mode 100644 index 00000000000..1d0bcf3ba9a --- /dev/null +++ b/libs/model/src/utils/generateUnsubscribeLink.ts @@ -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} - The unsubscribe link + */ +export async function generateUnsubscribeLink(userId: string): Promise { + const unsubscribeUuid = uuidv4(); + await models.User.update( + { unsubscribe_uuid: unsubscribeUuid }, + { where: { id: userId } }, + ); + return `${config.SERVER_URL}/unsubscribe/${unsubscribeUuid}`; +} diff --git a/libs/model/src/utils/handleSubscriptionPreferencesUpdate.ts b/libs/model/src/utils/handleSubscriptionPreferencesUpdate.ts new file mode 100644 index 00000000000..5f737bbcaec --- /dev/null +++ b/libs/model/src/utils/handleSubscriptionPreferencesUpdate.ts @@ -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( + fullObject: Record, + subsetObject: Record, +): Partial> { + const differences: Record = {}; + 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>; +}) { + const existingPreferences: z.infer | 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 }); +} diff --git a/libs/model/src/utils/index.ts b/libs/model/src/utils/index.ts index 12861f43c7c..cffd44f86a0 100644 --- a/libs/model/src/utils/index.ts +++ b/libs/model/src/utils/index.ts @@ -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'; diff --git a/libs/model/test/user/user-lifecycle.spec.ts b/libs/model/test/user/user-lifecycle.spec.ts index 389149d0dba..b8ca896a193 100644 --- a/libs/model/test/user/user-lifecycle.spec.ts +++ b/libs/model/test/user/user-lifecycle.spec.ts @@ -4,10 +4,10 @@ import { QuestParticipationPeriod, } from '@hicommonwealth/schemas'; import Chance from 'chance'; +import { CreateComment, CreateCommentReaction } from 'model/src/comment'; import { JoinCommunity } from 'model/src/community'; import moment from 'moment'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { CreateComment, CreateCommentReaction } from '../../src/comment'; import { models } from '../../src/database'; import { CreateQuest, UpdateQuest } from '../../src/quest'; import { CreateThread } from '../../src/thread'; diff --git a/libs/schemas/src/commands/subscription.schemas.ts b/libs/schemas/src/commands/subscription.schemas.ts index 03574517f31..547361da4cf 100644 --- a/libs/schemas/src/commands/subscription.schemas.ts +++ b/libs/schemas/src/commands/subscription.schemas.ts @@ -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, +}; diff --git a/libs/schemas/src/entities/user.schemas.ts b/libs/schemas/src/entities/user.schemas.ts index 7331c5e7160..a03d35f79bf 100644 --- a/libs/schemas/src/entities/user.schemas.ts +++ b/libs/schemas/src/entities/user.schemas.ts @@ -53,6 +53,7 @@ export const User = z.object({ profile: UserProfile, xp_points: PG_INT.default(0).nullish(), + unsubscribe_uuid: z.string().uuid().nullish(), referral_count: PG_INT.default(0) .nullish() .describe('Number of referrals that have earned ETH'), @@ -83,7 +84,7 @@ export const Address = z.object({ is_banned: z.boolean().default(false), hex: z.string().max(64).nullish(), - User: User.optional(), + User: User.optional().nullish(), created_at: z.coerce.date().optional(), updated_at: z.coerce.date().optional(), diff --git a/libs/schemas/src/queries/thread.schemas.ts b/libs/schemas/src/queries/thread.schemas.ts index f49d74c4f6b..f1dc59588ba 100644 --- a/libs/schemas/src/queries/thread.schemas.ts +++ b/libs/schemas/src/queries/thread.schemas.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { ZodType, z } from 'zod'; import { Address, Comment, @@ -73,7 +73,9 @@ export const UserView = z.object({ created_at: z.date().or(z.string()).nullish(), updated_at: z.date().or(z.string()).nullish(), ProfileTags: z.array(ProfileTagsView).optional(), + unsubscribe_uuid: z.string().uuid().nullish().optional(), }); +type UserView = z.infer; export const AddressView = Address.extend({ id: PG_INT, @@ -82,7 +84,7 @@ export const AddressView = Address.extend({ last_active: z.date().or(z.string()).nullish(), created_at: z.date().or(z.string()).nullish(), updated_at: z.date().or(z.string()).nullish(), - User: UserView.optional(), + User: UserView.optional().nullish() as ZodType, }); export const ReactionView = z.object({ diff --git a/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx b/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx index 0b73d25a9d7..1f87148aa75 100644 --- a/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx +++ b/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx @@ -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) => [ @@ -153,6 +155,11 @@ const CommonDomainRoutes = ({ launchpadEnabled }: RouteFeatureFlags) => [ path="/createCommunity" element={withLayout(CreateCommunityPage, { type: 'common' })} />, + , ...(launchpadEnabled ? [ 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')); @@ -119,6 +120,11 @@ const CustomDomainRoutes = ({ launchpadEnabled }: RouteFeatureFlags) => { path="/createCommunity" element={withLayout(CreateCommunityPage, { type: 'common' })} />, + , ...(launchpadEnabled ? [ void; + onUnsubscribe: () => void; + label: string; + description: string; + showDismissCheckbox?: boolean; +} + +const UnSubscribeModal = ({ + onModalClose, + onUnsubscribe, + label, + description, + showDismissCheckbox, +}: DismissModalProps) => { + const [isChecked, setIsChecked] = useState(false); + + return ( +
+ + + + {description} + + {showDismissCheckbox && ( + setIsChecked((prevChecked) => !prevChecked)} + /> + )} + + + + + +
+ ); +}; + +export default UnSubscribeModal; diff --git a/packages/commonwealth/client/scripts/views/pages/UnSubscribePage/UnSubscribePage.tsx b/packages/commonwealth/client/scripts/views/pages/UnSubscribePage/UnSubscribePage.tsx new file mode 100644 index 00000000000..1c0c0d1d31e --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/UnSubscribePage/UnSubscribePage.tsx @@ -0,0 +1,62 @@ +import { splitAndDecodeURL } from '@hicommonwealth/shared'; +import { useCommonNavigate } from 'client/scripts/navigation/helpers'; +import React, { useEffect, useState } from 'react'; +import { useUnSubscribeEmailMutation } from 'state/api/trpc/subscription/useUnSubscribeEmailMutation'; +import { CWModal } from '../../components/component_kit/new_designs/CWModal'; +import CWPageLayout from '../../components/component_kit/new_designs/CWPageLayout'; +import UnSubscribeModal from '../../modals/UnSubscribeModal/UnSubscribeModal'; + +const UnSubscribePage = () => { + const { mutateAsync: unSubscribeEmail } = useUnSubscribeEmailMutation(); + const [isModalOpen, setModalOpen] = useState(false); + + const navigate = useCommonNavigate(); + const handleModalClose = () => { + setModalOpen(false); + navigate('/dashboard'); + }; + const id = splitAndDecodeURL(window.location.pathname); + + const handleUnsubscribe = async () => { + if (id) { + await unSubscribeEmail({ + user_uuid: id, + email_notifications_enabled: false, + }).catch(console.error); + navigate('/dashboard'); + setModalOpen(false); + } + }; + const onUnsubscribe = () => { + handleUnsubscribe().catch((error) => { + console.error('Error during unsubscription:', error); + }); + }; + + useEffect(() => { + if (id) { + setModalOpen(true); + } + }, [id]); + + return ( + + + } + onClose={handleModalClose} + open={isModalOpen} + /> + + ); +}; + +export default UnSubscribePage; diff --git a/packages/commonwealth/client/scripts/views/pages/UnSubscribePage/index.ts b/packages/commonwealth/client/scripts/views/pages/UnSubscribePage/index.ts new file mode 100644 index 00000000000..eff9d6c5f4d --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/UnSubscribePage/index.ts @@ -0,0 +1,3 @@ +import UnSubscribePage from './UnSubscribePage'; + +export default UnSubscribePage; diff --git a/packages/commonwealth/server/api/subscription.ts b/packages/commonwealth/server/api/subscription.ts index c412d94be4e..2774b70d6e8 100644 --- a/packages/commonwealth/server/api/subscription.ts +++ b/packages/commonwealth/server/api/subscription.ts @@ -54,4 +54,8 @@ export const trpcRouter = trpc.router({ Subscription.UnregisterClientRegistrationToken, trpc.Tag.Subscription, ), + unSubscribeEmail: trpc.command( + Subscription.UnsubscribeEmail, + trpc.Tag.Subscription, + ), }); diff --git a/packages/commonwealth/server/migrations/20241211135155-add-unsubscribe-uuid-to-user.js b/packages/commonwealth/server/migrations/20241211135155-add-unsubscribe-uuid-to-user.js new file mode 100644 index 00000000000..a68f44132c9 --- /dev/null +++ b/packages/commonwealth/server/migrations/20241211135155-add-unsubscribe-uuid-to-user.js @@ -0,0 +1,13 @@ +'use strict'; +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('Users', 'unsubscribe_uuid', { + type: Sequelize.STRING, + allowNull: true, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('Users', 'unsubscribe_uuid'); + }, +};