Skip to content

Commit c982e30

Browse files
committed
feat: notifcations settings
1 parent d89eb44 commit c982e30

File tree

12 files changed

+583
-143
lines changed

12 files changed

+583
-143
lines changed

apps/backend/src/api/routes/users.controller.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions
2222
import { ApiTags } from '@nestjs/swagger';
2323
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
2424
import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto';
25+
import { EmailNotificationsDto } from '@gitroom/nestjs-libraries/dtos/users/email-notifications.dto';
2526
import { HttpForbiddenException } from '@gitroom/nestjs-libraries/services/exception.filter';
2627
import { RealIP } from 'nestjs-real-ip';
2728
import { UserAgent } from '@gitroom/nestjs-libraries/user/user.agent';
@@ -125,6 +126,19 @@ export class UsersController {
125126
return this._userService.changePersonal(user.id, body);
126127
}
127128

129+
@Get('/email-notifications')
130+
async getEmailNotifications(@GetUserFromRequest() user: User) {
131+
return this._userService.getEmailNotifications(user.id);
132+
}
133+
134+
@Post('/email-notifications')
135+
async updateEmailNotifications(
136+
@GetUserFromRequest() user: User,
137+
@Body() body: EmailNotificationsDto
138+
) {
139+
return this._userService.updateEmailNotifications(user.id, body);
140+
}
141+
128142
@Get('/subscription')
129143
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
130144
async getSubscription(@GetOrgFromRequest() organization: Organization) {
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
'use client';
2+
3+
import React, { useCallback, useEffect, useRef, useState } from 'react';
4+
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
5+
import useSWR from 'swr';
6+
import { Slider } from '@gitroom/react/form/slider';
7+
import { useToaster } from '@gitroom/react/toaster/toaster';
8+
import { useT } from '@gitroom/react/translation/get.transation.service.client';
9+
10+
interface EmailNotifications {
11+
sendSuccessEmails: boolean;
12+
sendFailureEmails: boolean;
13+
}
14+
15+
export const useEmailNotifications = () => {
16+
const fetch = useFetch();
17+
18+
const load = useCallback(async () => {
19+
return (await fetch('/user/email-notifications')).json();
20+
}, []);
21+
22+
return useSWR<EmailNotifications>('email-notifications', load, {
23+
revalidateOnFocus: false,
24+
revalidateOnReconnect: false,
25+
revalidateIfStale: false,
26+
revalidateOnMount: true,
27+
refreshWhenHidden: false,
28+
refreshWhenOffline: false,
29+
});
30+
};
31+
32+
const EmailNotificationsComponent = () => {
33+
const t = useT();
34+
const fetch = useFetch();
35+
const toaster = useToaster();
36+
const { data, isLoading } = useEmailNotifications();
37+
38+
const [localSettings, setLocalSettings] = useState<EmailNotifications>({
39+
sendSuccessEmails: true,
40+
sendFailureEmails: true,
41+
});
42+
43+
// Keep a ref to always have the latest state
44+
const settingsRef = useRef(localSettings);
45+
settingsRef.current = localSettings;
46+
47+
// Sync local state with fetched data
48+
useEffect(() => {
49+
if (data) {
50+
setLocalSettings(data);
51+
}
52+
}, [data]);
53+
54+
const updateSetting = useCallback(
55+
async (key: keyof EmailNotifications, value: boolean) => {
56+
// Use ref to get the latest state
57+
const currentSettings = settingsRef.current;
58+
const newData = {
59+
...currentSettings,
60+
[key]: value,
61+
};
62+
63+
// Update local state immediately
64+
setLocalSettings(newData);
65+
66+
await fetch('/user/email-notifications', {
67+
method: 'POST',
68+
body: JSON.stringify(newData),
69+
});
70+
71+
toaster.show(t('settings_updated', 'Settings updated'), 'success');
72+
},
73+
[]
74+
);
75+
76+
const handleSuccessEmailsChange = useCallback(
77+
(value: 'on' | 'off') => {
78+
updateSetting('sendSuccessEmails', value === 'on');
79+
},
80+
[updateSetting]
81+
);
82+
83+
const handleFailureEmailsChange = useCallback(
84+
(value: 'on' | 'off') => {
85+
updateSetting('sendFailureEmails', value === 'on');
86+
},
87+
[updateSetting]
88+
);
89+
90+
if (isLoading) {
91+
return (
92+
<div className="my-[16px] mt-[16px] bg-sixth border-fifth border rounded-[4px] p-[24px]">
93+
<div className="animate-pulse">
94+
{t('loading', 'Loading...')}
95+
</div>
96+
</div>
97+
);
98+
}
99+
100+
return (
101+
<div className="my-[16px] mt-[16px] bg-sixth border-fifth border rounded-[4px] p-[24px] flex flex-col gap-[24px]">
102+
<div className="mt-[4px]">
103+
{t('email_notifications', 'Email Notifications')}
104+
</div>
105+
<div className="flex items-center justify-between">
106+
<div className="flex flex-col">
107+
<div className="text-[14px]">
108+
{t('success_emails', 'Success Emails')}
109+
</div>
110+
<div className="text-[12px] text-customColor18">
111+
{t(
112+
'success_emails_description',
113+
'Receive email notifications when posts are published successfully'
114+
)}
115+
</div>
116+
</div>
117+
<Slider
118+
value={localSettings.sendSuccessEmails ? 'on' : 'off'}
119+
onChange={handleSuccessEmailsChange}
120+
fill={true}
121+
/>
122+
</div>
123+
<div className="flex items-center justify-between">
124+
<div className="flex flex-col">
125+
<div className="text-[14px]">
126+
{t('failure_emails', 'Failure Emails')}
127+
</div>
128+
<div className="text-[12px] text-customColor18">
129+
{t(
130+
'failure_emails_description',
131+
'Receive email notifications when posts fail to publish'
132+
)}
133+
</div>
134+
</div>
135+
<Slider
136+
value={localSettings.sendFailureEmails ? 'on' : 'off'}
137+
onChange={handleFailureEmailsChange}
138+
fill={true}
139+
/>
140+
</div>
141+
</div>
142+
);
143+
};
144+
145+
export default EmailNotificationsComponent;
146+

apps/frontend/src/components/settings/global.settings.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,22 @@
33
import React from 'react';
44
import { useT } from '@gitroom/react/translation/get.transation.service.client';
55
import dynamic from 'next/dynamic';
6+
import EmailNotificationsComponent from '@gitroom/frontend/components/settings/email-notifications.component';
67

78
const MetricComponent = dynamic(
89
() => import('@gitroom/frontend/components/settings/metric.component'),
910
{
1011
ssr: false,
1112
}
1213
);
14+
1315
export const GlobalSettings = () => {
1416
const t = useT();
1517
return (
1618
<div className="flex flex-col">
1719
<h3 className="text-[20px]">{t('global_settings', 'Global Settings')}</h3>
1820
<MetricComponent />
21+
<EmailNotificationsComponent />
1922
</div>
2023
);
2124
};

libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,9 @@ export class IntegrationService {
187187
orgId,
188188
`Could not refresh your ${integration.providerIdentifier} channel ${err}`,
189189
`Could not refresh your ${integration.providerIdentifier} channel ${err}. Please go back to the system and connect it again ${process.env.FRONTEND_URL}/launches`,
190-
true
190+
true,
191+
false,
192+
'info'
191193
);
192194
}
193195

libraries/nestjs-libraries/src/database/prisma/notifications/notification.service.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/cl
66
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
77
import dayjs from 'dayjs';
88

9+
export type NotificationType = 'success' | 'fail' | 'info';
10+
911
@Injectable()
1012
export class NotificationService {
1113
constructor(
@@ -41,7 +43,8 @@ export class NotificationService {
4143
subject: string,
4244
message: string,
4345
sendEmail = false,
44-
digest = false
46+
digest = false,
47+
type: NotificationType = 'success'
4548
) {
4649
const date = new Date().toISOString();
4750
await this._notificationRepository.createNotification(orgId, message);
@@ -52,6 +55,12 @@ export class NotificationService {
5255
if (digest) {
5356
await ioRedis.watch('digest_' + orgId);
5457
const value = await ioRedis.get('digest_' + orgId);
58+
59+
// Track notification types in the digest
60+
const typesKey = 'digest_types_' + orgId;
61+
await ioRedis.sadd(typesKey, type);
62+
await ioRedis.expire(typesKey, 120); // Slightly longer than digest window
63+
5564
if (value) {
5665
return;
5766
}
@@ -77,12 +86,66 @@ export class NotificationService {
7786
return;
7887
}
7988

80-
await this.sendEmailsToOrg(orgId, subject, message);
89+
await this.sendEmailsToOrg(orgId, subject, message, type);
90+
}
91+
92+
async sendEmailsToOrg(
93+
orgId: string,
94+
subject: string,
95+
message: string,
96+
type?: NotificationType
97+
) {
98+
const userOrg = await this._organizationRepository.getAllUsersOrgs(orgId);
99+
for (const user of userOrg?.users || []) {
100+
// 'info' type is always sent regardless of preferences
101+
if (type !== 'info') {
102+
// Filter users based on their email preferences
103+
if (type === 'success' && !user.user.sendSuccessEmails) {
104+
continue;
105+
}
106+
if (type === 'fail' && !user.user.sendFailureEmails) {
107+
continue;
108+
}
109+
}
110+
await this.sendEmail(user.user.email, subject, message);
111+
}
112+
}
113+
114+
async getDigestTypes(orgId: string): Promise<NotificationType[]> {
115+
const typesKey = 'digest_types_' + orgId;
116+
const types = await ioRedis.smembers(typesKey);
117+
// Clean up the types key after reading
118+
await ioRedis.del(typesKey);
119+
return types as NotificationType[];
81120
}
82121

83-
async sendEmailsToOrg(orgId: string, subject: string, message: string) {
122+
async sendDigestEmailsToOrg(
123+
orgId: string,
124+
subject: string,
125+
message: string,
126+
types: NotificationType[]
127+
) {
84128
const userOrg = await this._organizationRepository.getAllUsersOrgs(orgId);
129+
const hasInfo = types.includes('info');
130+
const hasSuccess = types.includes('success');
131+
const hasFail = types.includes('fail');
132+
85133
for (const user of userOrg?.users || []) {
134+
// 'info' type is always sent regardless of preferences
135+
if (hasInfo) {
136+
await this.sendEmail(user.user.email, subject, message);
137+
continue;
138+
}
139+
140+
// For digest, check if user wants any of the notification types in the digest
141+
const wantsSuccess = hasSuccess && user.user.sendSuccessEmails;
142+
const wantsFail = hasFail && user.user.sendFailureEmails;
143+
144+
// Only send if user wants at least one type of notification in the digest
145+
if (!wantsSuccess && !wantsFail) {
146+
continue;
147+
}
148+
86149
await this.sendEmail(user.user.email, subject, message);
87150
}
88151
}

libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,8 @@ export class OrganizationRepository {
293293
select: {
294294
email: true,
295295
id: true,
296+
sendSuccessEmails: true,
297+
sendFailureEmails: true,
296298
},
297299
},
298300
},

libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,9 @@ export class PostsService {
304304
firstPost.organizationId,
305305
`We couldn't post to ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`,
306306
`We couldn't post to ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name} because you need to reconnect it. Please enable it and try again.`,
307-
true
307+
true,
308+
false,
309+
'info'
308310
);
309311
return;
310312
}
@@ -314,7 +316,9 @@ export class PostsService {
314316
firstPost.organizationId,
315317
`We couldn't post to ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`,
316318
`We couldn't post to ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name} because it's disabled. Please enable it and try again.`,
317-
true
319+
true,
320+
false,
321+
'info'
318322
);
319323
return;
320324
}
@@ -343,7 +347,9 @@ export class PostsService {
343347
firstPost.organizationId,
344348
`Error posting on ${firstPost.integration?.providerIdentifier} for ${firstPost?.integration?.name}`,
345349
`An error occurred while posting on ${firstPost.integration?.providerIdentifier}`,
346-
true
350+
true,
351+
false,
352+
'fail'
347353
);
348354

349355
return;
@@ -362,7 +368,9 @@ export class PostsService {
362368
`An error occurred while posting on ${
363369
firstPost.integration?.providerIdentifier
364370
}${err?.message ? `: ${err?.message}` : ``}`,
365-
true
371+
true,
372+
false,
373+
'fail'
366374
);
367375

368376
console.error(
@@ -959,15 +967,20 @@ export class PostsService {
959967
return;
960968
}
961969

970+
// Get the types of notifications in this digest
971+
const types = await this._notificationService.getDigestTypes(orgId);
972+
962973
const message = getNotificationsForOrgSince
963974
.map((p) => p.content)
964975
.join('<br />');
965-
await this._notificationService.sendEmailsToOrg(
976+
977+
await this._notificationService.sendDigestEmailsToOrg(
966978
orgId,
967979
getNotificationsForOrgSince.length === 1
968980
? subject
969981
: '[Postiz] Your latest notifications',
970-
message
982+
message,
983+
types.length > 0 ? types : ['success'] // Default to success if no types tracked
971984
);
972985
}
973986
}

0 commit comments

Comments
 (0)