Skip to content

Commit a778c78

Browse files
committed
feat: missed of call notification endpoint
1 parent 831f8a2 commit a778c78

File tree

12 files changed

+332
-6
lines changed

12 files changed

+332
-6
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<!DOCTYPE html>
2+
<html lang="{{languageId}}">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>{{t 'subject'}}</title>
7+
<style>
8+
body {
9+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
10+
line-height: 1.6;
11+
color: #333;
12+
max-width: 600px;
13+
margin: 0 auto;
14+
padding: 20px;
15+
background-color: #f5f7f9;
16+
}
17+
.container {
18+
background-color: #ffffff;
19+
border-radius: 12px;
20+
padding: 35px;
21+
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
22+
}
23+
.header {
24+
text-align: center;
25+
margin-bottom: 35px;
26+
}
27+
h2 {
28+
color: #459da1;
29+
font-size: 26px;
30+
margin: 0;
31+
font-weight: 600;
32+
}
33+
.content {
34+
font-size: 16px;
35+
color: #4a5568;
36+
}
37+
.task-box {
38+
background-color: #f8f9fa;
39+
border-left: 4px solid #459da1;
40+
padding: 20px;
41+
margin: 25px 0;
42+
border-radius: 0 8px 8px 0;
43+
}
44+
.cta-container {
45+
text-align: center;
46+
margin: 35px 0;
47+
}
48+
.button {
49+
display: inline-block;
50+
background-color: #459da1;
51+
color: white;
52+
padding: 14px 28px;
53+
text-decoration: none;
54+
border-radius: 6px;
55+
font-weight: 600;
56+
transition: all 0.3s ease;
57+
}
58+
.button:hover {
59+
background-color: #3a8387;
60+
transform: translateY(-1px);
61+
box-shadow: 0 4px 12px rgba(69,157,161,0.2);
62+
}
63+
.secondary-button {
64+
display: inline-block;
65+
background-color: #f0f9fa;
66+
color: #459da1;
67+
padding: 12px 24px;
68+
text-decoration: none;
69+
border-radius: 6px;
70+
font-weight: 600;
71+
transition: all 0.3s ease;
72+
border: 2px solid #459da1;
73+
margin-top: 15px;
74+
}
75+
.secondary-button:hover {
76+
background-color: #e0f2f3;
77+
transform: translateY(-1px);
78+
}
79+
.emphasis {
80+
color: #459da1;
81+
font-size: 14px;
82+
font-weight: 600;
83+
padding: 15px;
84+
background-color: #f0f9fa;
85+
border-radius: 8px;
86+
margin: 25px 0;
87+
}
88+
.footer {
89+
font-size: 14px;
90+
color: #718096;
91+
text-align: center;
92+
margin-top: 35px;
93+
font-style: italic;
94+
}
95+
.verification-container {
96+
text-align: center;
97+
margin: 20px 0;
98+
}
99+
</style>
100+
</head>
101+
<body>
102+
<div class="container">
103+
<div class="header">
104+
<h2>{{t 'header'}}</h2>
105+
</div>
106+
<div class="content">
107+
<p>{{t 'greeting' firstName=firstName}}</p>
108+
<p>{{t 'intro1' date=date time=time}}</p>
109+
<p>{{t 'intro2'}}</p>
110+
111+
<div class="task-box">
112+
<p>{{t 'taskDescription' oppositeGender=oppositeGender}}</p>
113+
<p>{{t 'callExplanation'}}</p>
114+
</div>
115+
116+
<div class="verification-container">
117+
<p>{{t 'verificationNote'}}</p>
118+
<a href="https://www.offlinery.io/verification-call/verification-call_{{languageId}}.pdf" class="secondary-button">
119+
{{t 'verificationLink'}}
120+
</a>
121+
</div>
122+
123+
<div class="cta-container">
124+
<a href="https://calendly.com/wavect/safety-call-{{languageId}}?name={{firstName}}&email={{email}}" class="button">
125+
{{t 'bookCallButton'}}
126+
</a>
127+
</div>
128+
129+
<div class="emphasis">
130+
<p>{{t 'reminder'}}</p>
131+
</div>
132+
</div>
133+
<div class="footer">
134+
<p>{{t 'footer'}}</p>
135+
</div>
136+
</div>
137+
</body>
138+
</html>

backend/src/DTOs/abstract/base-notification.adto.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export enum ENotificationType {
99
SAFETYCALL_REMINDER = "safetycall_reminder",
1010
ACCOUNT_DENIED = "account_denied",
1111
NEW_MESSAGE = "new_message",
12+
SAFETY_CALL_MISSED = "safety_call_missed",
1213
}
1314

1415
export abstract class BaseNotificationADTO implements Record<string, unknown> {

backend/src/DTOs/enums/app-screens.enum.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export enum EAppScreens {
99
SAFETYCALL_REMINDER = "Main_FindPeople",
1010
ACCOUNT_DENIED = "Main_FindPeople",
1111
NEW_MESSAGE = "Main_Encounters_onTab",
12+
SAFETY_CALL_MISSED = "Welcome", // @dev do not send to pendingVerification or FindPeople since user might have switched to BeApproached or not (let app decide)
1213
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { ApiProperty } from "@nestjs/swagger";
2+
import { IsEmail, IsNotEmpty } from "class-validator";
3+
4+
export class MissedEqCallDTO {
5+
@ApiProperty()
6+
@IsEmail()
7+
@IsNotEmpty()
8+
email: string;
9+
10+
@ApiProperty({
11+
type: "string",
12+
format: "date-time",
13+
nullable: false,
14+
required: true,
15+
})
16+
plannedDateTime: Date;
17+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {
2+
BaseNotificationADTO,
3+
ENotificationType,
4+
} from "@/DTOs/abstract/base-notification.adto";
5+
import { EAppScreens } from "@/DTOs/enums/app-screens.enum";
6+
import { ApiProperty } from "@nestjs/swagger";
7+
8+
export class NotificationSafetyCallMissedDTO extends BaseNotificationADTO {
9+
@ApiProperty({ enum: ENotificationType })
10+
type: ENotificationType = ENotificationType.SAFETY_CALL_MISSED;
11+
12+
@ApiProperty({ enum: EAppScreens })
13+
screen: EAppScreens.SAFETY_CALL_MISSED;
14+
}

backend/src/entities/pending-user/pending-user.controller.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { OnlyValidRegistrationSession } from "@/auth/auth-registration-session";
22
import { OnlyAdmin, Public } from "@/auth/auth.guard";
3+
import { MissedEqCallDTO } from "@/DTOs/missed-eq-call.dto";
34
import {
45
RegistrationForVerificationRequestDTO,
56
RegistrationForVerificationResponseDTO,
@@ -34,7 +35,7 @@ import { PendingUserService } from "./pending-user.service";
3435
export class PendingUserController {
3536
private readonly logger = new Logger(PendingUserController.name);
3637

37-
constructor(private readonly registrationService: PendingUserService) {}
38+
constructor(private readonly pendingUserService: PendingUserService) {}
3839

3940
@Post()
4041
@Public()
@@ -63,7 +64,7 @@ export class PendingUserController {
6364
@Body() emailDto: RegistrationForVerificationRequestDTO,
6465
): Promise<RegistrationForVerificationResponseDTO> {
6566
this.logger.debug(`User registers his email: ${emailDto.email}`);
66-
return await this.registrationService.registerPendingUser(emailDto);
67+
return await this.pendingUserService.registerPendingUser(emailDto);
6768
}
6869

6970
@Put("verify-email")
@@ -80,7 +81,7 @@ export class PendingUserController {
8081
}),
8182
)
8283
async verifyEmail(@Body() verifyEmailDto: VerifyEmailDTO): Promise<void> {
83-
return await this.registrationService.verifyEmail(
84+
return await this.pendingUserService.verifyEmail(
8485
verifyEmailDto.email,
8586
verifyEmailDto.verificationCode,
8687
);
@@ -103,7 +104,7 @@ export class PendingUserController {
103104
@Body()
104105
setAcceptedSpecialDataGenderLookingForAtDTO: SetAcceptedSpecialDataGenderLookingForDTO,
105106
) {
106-
return await this.registrationService.setAcceptedSpecialDataGenderLookingForAt(
107+
return await this.pendingUserService.setAcceptedSpecialDataGenderLookingForAt(
107108
setAcceptedSpecialDataGenderLookingForAtDTO.email,
108109
setAcceptedSpecialDataGenderLookingForAtDTO.dateTimeAccepted,
109110
);
@@ -125,9 +126,33 @@ export class PendingUserController {
125126
async changeVerificationStatus(
126127
@Body() updateStatus: UpdateUserVerificationstatusDTO,
127128
): Promise<void> {
128-
return await this.registrationService.changeVerificationStatus(
129+
return await this.pendingUserService.changeVerificationStatus(
129130
updateStatus.email,
130131
updateStatus.newVerificationStatus,
131132
);
132133
}
134+
135+
@Put("admin/missed-eq-call")
136+
@OnlyAdmin()
137+
@ApiExcludeEndpoint()
138+
@ApiOperation({
139+
summary: "Send reminder to user that they missed the EQ/Safety call.",
140+
})
141+
@ApiBody({
142+
type: MissedEqCallDTO,
143+
})
144+
@UsePipes(
145+
new ValidationPipe({
146+
transform: true,
147+
transformOptions: { enableImplicitConversion: true },
148+
}),
149+
)
150+
async sendMissedEQCallReminder(
151+
@Body() missedEQCall: MissedEqCallDTO,
152+
): Promise<void> {
153+
return await this.pendingUserService.sendMissedEQCallReminder(
154+
missedEQCall.email,
155+
missedEQCall.plannedDateTime,
156+
);
157+
}
133158
}

backend/src/entities/pending-user/pending-user.service.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
ELanguage,
1717
EVerificationStatus,
1818
} from "@/types/user.types";
19+
import { formatMultiLanguageDateTimeStringsCET } from "@/utils/date.utils";
1920
import {
2021
EMAIL_CODE_EXPIRATION_IN_MS,
2122
generate6DigitEmailCode,
@@ -258,6 +259,60 @@ export class PendingUserService {
258259
);
259260
}
260261

262+
public async sendMissedEQCallReminder(
263+
email: string,
264+
plannedDateTime: Date,
265+
) {
266+
const user = await this.userRepo.findOneBy({ email });
267+
if (!user) {
268+
throw new NotFoundException(
269+
`User with email ${email} does not exist!`,
270+
);
271+
}
272+
if (!user.isActive) {
273+
throw new BadRequestException("Account is inactive!");
274+
}
275+
const lang = user.preferredLanguage ?? ELanguage.en;
276+
const { date, time } = formatMultiLanguageDateTimeStringsCET(
277+
plannedDateTime,
278+
lang,
279+
);
280+
281+
await this.sendEQCallMissedMail(user, date, time, lang);
282+
if (user.pushToken) {
283+
/// @dev Also send push notification
284+
await this.notificationService.sendPushNotifications([
285+
{
286+
sound: "default" as const,
287+
title: this.i18n.translate(
288+
`main.notification.${ENotificationType.SAFETY_CALL_MISSED}.title`,
289+
{
290+
lang,
291+
},
292+
),
293+
body: this.i18n.translate(
294+
`main.notification.${ENotificationType.SAFETY_CALL_MISSED}.body`,
295+
{
296+
lang,
297+
args: {
298+
date,
299+
time,
300+
},
301+
},
302+
),
303+
to: user.pushToken,
304+
data: {
305+
screen: EAppScreens.SAFETY_CALL_MISSED,
306+
type: ENotificationType.SAFETY_CALL_MISSED,
307+
},
308+
},
309+
]);
310+
}
311+
this.logger.debug(
312+
`Missed EQ Call reminder sent to ${email}. Missed call on ${plannedDateTime}`,
313+
);
314+
}
315+
261316
private async sendAccountVerificationStatusMail(
262317
user: User,
263318
status: EVerificationStatus,
@@ -291,6 +346,41 @@ export class PendingUserService {
291346
});
292347
}
293348

349+
private async sendEQCallMissedMail(
350+
user: User,
351+
date: string,
352+
time: string,
353+
lang: ELanguage,
354+
) {
355+
this.logger.debug(
356+
`Sending new email to ${user.email} as safety/eq call missed in ${lang}.`,
357+
);
358+
await this.mailService.sendMail({
359+
to: user.email,
360+
subject: await this.i18n.translate(
361+
`main.email.safety-call-missed.subject`,
362+
{ lang },
363+
),
364+
template: `safety-call-missed`,
365+
context: {
366+
firstName: user.firstName,
367+
oppositeGender: await this.i18n.translate(
368+
`main.general.gender.${user.genderDesire[0]}_pl`,
369+
{ lang },
370+
),
371+
date,
372+
time,
373+
email: user.email,
374+
languageId: lang,
375+
t: (key: string, params?: Record<string, any>) =>
376+
this.i18n.translate(
377+
`main.email.safety-call-missed.${key}`,
378+
{ lang, args: { ...(params?.hash ?? params) } },
379+
),
380+
},
381+
});
382+
}
383+
294384
private async sendVerificationCodeMail(
295385
to: string,
296386
verificationCode: string,

backend/src/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { NotificationAccountApprovedDTO } from "@/DTOs/notifications/notificatio
44
import { NotificationGhostReminderDTO } from "@/DTOs/notifications/notification-ghostreminder.dto";
55
import { NotificationNewEventDTO } from "@/DTOs/notifications/notification-new-event.dto";
66
import { NotificationNewMessageDTO } from "@/DTOs/notifications/notification-new-message.dto";
7+
import { NotificationSafetyCallMissedDTO } from "@/DTOs/notifications/notification-safetycall-missed.dto";
78
import { DefaultApiUserSeeder } from "@/seeder/default-admin-api-user.seeder";
89
import { DefaultUserSeeder } from "@/seeder/default-user.seeder";
910
import { RandomUsersSeeder } from "@/seeder/random-users-seeder.service";
@@ -88,6 +89,7 @@ const setupSwagger = (app: INestApplication) => {
8889
deepScanRoutes: true,
8990
extraModels: [
9091
NotificationNavigateUserDTO,
92+
NotificationSafetyCallMissedDTO,
9193
NotificationAccountApprovedDTO,
9294
NotificationGhostReminderDTO,
9395
NotificationNewEventDTO,

0 commit comments

Comments
 (0)