Skip to content

Commit 32adafb

Browse files
committed
emails infra + welcome email + function: delete user
1 parent f46d709 commit 32adafb

31 files changed

+1940
-49
lines changed

netlify.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
[functions]
66
directory = "netlify/functions-src/functions"
7+
included_files = ["netlify/functions-src/functions/email/templates/**.html"]
78

89
[dev]
910
command = "yarn start"

netlify/functions-src/email/interfaces/email.interface.ts

Lines changed: 0 additions & 19 deletions
This file was deleted.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Config from '../config';
2+
3+
const auth0Domain = Config.auth0.backend.DOMAIN;
4+
5+
const getAdminAccessToken = async (): Promise<string> => {
6+
try {
7+
8+
const response = await fetch(`https://${auth0Domain}/oauth/token`, {
9+
method: 'POST',
10+
headers: {
11+
'Content-Type': 'application/json',
12+
},
13+
body: JSON.stringify({
14+
client_id: Config.auth0.backend.CLIENT_ID,
15+
client_secret: Config.auth0.backend.CLIENT_SECRET,
16+
audience: Config.auth0.backend.AUDIENCE,
17+
grant_type: 'client_credentials',
18+
}),
19+
});
20+
const data = await response.json();
21+
return data.access_token;
22+
} catch (error) {
23+
// eslint-disable-next-line no-console
24+
console.error('Error fetching admin access token:', error);
25+
throw new Error('Failed to fetch access token');
26+
}
27+
};
28+
29+
30+
export const deleteUser = async (userId: string): Promise<void> => {
31+
const accessToken = await getAdminAccessToken();
32+
if (!accessToken) {
33+
throw new Error('Failed to get access token');
34+
}
35+
36+
const response = await fetch(`https://${auth0Domain}/api/v2/users/${userId}`, {
37+
method: 'DELETE',
38+
headers: {
39+
Authorization: `Bearer ${accessToken}`,
40+
},
41+
});
42+
43+
if (!response.ok) {
44+
throw new Error(`Failed to delete user: ${response.statusText}`);
45+
}
46+
47+
return response.json();
48+
}

netlify/functions-src/functions/common/interfaces/user.interface.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export interface User {
1414
};
1515
channels?: any[];
1616
createdAt: Date;
17+
spokenLanguages?: string[];
18+
tags?: string[];
1719
}
1820

1921
export enum Role {

netlify/functions-src/functions/data/users.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { User } from '../common/interfaces/user.interface';
33
import { getCollection } from '../utils/db';
44
import { upsertEntity } from './utils';
55
import { DataError } from './errors';
6+
import type { EntityPayload } from './types';
67

78
const getUserWithoutChannels = async (id: string): Promise<User> => {
89
const user = await getCollection<User>('users').findOne({ _id: new ObjectId(id) });
@@ -98,7 +99,15 @@ export const getUserByAuthId = async (auth0Id: string) => {
9899
return user;
99100
}
100101

101-
export const upsertUser = async (user: User) => {
102+
export const upsertUser = async (user: EntityPayload<User>) => {
102103
const upsertedUser = await upsertEntity<User>('users', user);
103104
return upsertedUser;
104105
}
106+
107+
export const deleteUser = async (id: ObjectId) => {
108+
const result = await getCollection<User>('users').deleteOne({ _id: id });
109+
if (result.deletedCount === 0) {
110+
throw new DataError(404, 'User not found');
111+
}
112+
return result.acknowledged;
113+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { Handler } from '@netlify/functions';
2+
import { send } from './email/client';
3+
4+
export const handler: Handler = async (event) => {
5+
const { to, subject } = event.queryStringParameters || {};
6+
7+
if (!to || !subject) {
8+
return {
9+
statusCode: 400,
10+
body: JSON.stringify({ error: 'Missing required fields' }),
11+
};
12+
}
13+
14+
try {
15+
await send({ to, subject, name: 'welcome', data: { name: 'John Doe' } });
16+
17+
return {
18+
statusCode: 200,
19+
body: JSON.stringify({ message: 'Email sent successfully' }),
20+
};
21+
} catch (error) {
22+
// eslint-disable-next-line no-console
23+
console.error('Error sending email:', error);
24+
return {
25+
statusCode: 500,
26+
body: JSON.stringify({ error: 'Failed to send email' }),
27+
};
28+
}
29+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { promises } from 'fs';
2+
import path from 'path';
3+
import { compile } from 'ejs';
4+
import type { EmailParams } from './interfaces/email.interface';
5+
import { sendEmail } from './sendgrid';
6+
7+
export const send = async (params: EmailParams) => {
8+
const { to, subject, data = {}, name } = params;
9+
10+
const content = await injectData(name, data);
11+
try {
12+
return sendEmail({
13+
to: to as string,
14+
subject,
15+
html: content,
16+
});
17+
} catch (error) {
18+
// eslint-disable-next-line no-console
19+
console.error('Send email error', params, JSON.stringify(error, null, 2));
20+
throw new Error('Failed to send email');
21+
}
22+
}
23+
24+
const injectData = async (name: string, data: Record<string, string>) => {
25+
const template = await getTemplateContent(name);
26+
const layout = await getLayout();
27+
const content = compile(template)(data);
28+
return compile(layout)({ content });
29+
}
30+
31+
const getLayout = async () => {
32+
return getTemplateContent('layout');
33+
}
34+
35+
const getTemplateContent = (name: string) => {
36+
const templatesDir = path.resolve(__dirname, 'email/templates');
37+
const templatePath = `${templatesDir}/${name}.html`;
38+
return promises.readFile(templatePath, {
39+
encoding: 'utf8',
40+
}).catch((error) => {
41+
// eslint-disable-next-line no-console
42+
console.error('Error reading template file:', error);
43+
throw new Error(`Template file not found: ${templatePath}`);
44+
});
45+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import type { MailData } from '@sendgrid/helpers/classes/mail';
2+
import type { User } from '../../common/interfaces/user.interface';
3+
4+
interface WelcomePayload {
5+
name: 'welcome';
6+
data: {
7+
name: string;
8+
};
9+
}
10+
11+
interface MentorshipAccepted {
12+
name: 'mentorship-accepted';
13+
data: {
14+
menteeName: string;
15+
mentorName: string;
16+
contactURL: string;
17+
openRequests: number;
18+
};
19+
}
20+
21+
interface MentorshipCancelled {
22+
name: 'mentorship-cancelled';
23+
data: {
24+
mentorName: string;
25+
menteeName: string;
26+
reason: string;
27+
};
28+
}
29+
30+
interface MentorshipDeclined {
31+
name: 'mentorship-declined';
32+
data: {
33+
menteeName: string;
34+
mentorName: string;
35+
reason: string;
36+
bySystem: boolean;
37+
};
38+
}
39+
40+
interface MentorshipRequested {
41+
name: 'mentorship-requested';
42+
data: {
43+
menteeName: string;
44+
menteeEmail: string;
45+
mentorName: string;
46+
message: string;
47+
background: string;
48+
expectation: string;
49+
};
50+
}
51+
52+
interface MentorshipReminder {
53+
name: 'mentorship-reminder';
54+
data: {
55+
menteeName: string;
56+
mentorName: string;
57+
message: string;
58+
};
59+
}
60+
61+
interface MentorApplicationReceived {
62+
name: 'mentor-application-received';
63+
data: {
64+
name: string;
65+
};
66+
}
67+
68+
interface MentorApplicationDeclined {
69+
name: 'mentor-application-declined';
70+
data: {
71+
name: string;
72+
reason: string;
73+
};
74+
}
75+
76+
interface MentorApplicationApproved {
77+
name: 'mentor-application-approved';
78+
data: {
79+
name: string;
80+
};
81+
}
82+
83+
interface MentorNotActive {
84+
name: 'mentor-not-active';
85+
data: {
86+
mentorName: string;
87+
numOfMentorshipRequests: number;
88+
};
89+
}
90+
91+
interface MentorFreeze {
92+
name: 'mentor-freeze';
93+
data: {
94+
mentorName: string;
95+
};
96+
}
97+
98+
export type EmailParams = Required<Pick<MailData, 'to' | 'subject'>> &
99+
(
100+
| WelcomePayload
101+
| MentorshipAccepted
102+
| MentorshipCancelled
103+
| MentorshipDeclined
104+
| MentorshipRequested
105+
| MentorshipReminder
106+
| MentorApplicationReceived
107+
| MentorApplicationDeclined
108+
| MentorApplicationApproved
109+
| MentorNotActive
110+
| MentorFreeze
111+
);
112+
113+
/**
114+
* Data for SendGrid templates, when an admin rejects a mentor from the platform.
115+
*/
116+
export interface SendDataRejectParams {
117+
reason: string;
118+
}
119+
120+
/**
121+
* Data for SendGrid templates, when a user request a mentorship.
122+
*/
123+
export interface SendDataMentorshipParams {
124+
name: string;
125+
message: string;
126+
}
127+
128+
export interface SendDataMentorshipApprovalParams {
129+
menteeName: string;
130+
mentorName: string;
131+
contactURL: string;
132+
channels: User['channels'];
133+
}
134+
135+
export interface SendDataMentorshipRejectionParams {
136+
menteeName: string;
137+
mentorName: string;
138+
reason: string;
139+
}
140+
141+
export interface SendData {
142+
to: string;
143+
subject: string;
144+
html: string;
145+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import sgMail from '@sendgrid/mail';
2+
import type { SendData } from './interfaces/email.interface';
3+
import Config from '../config';
4+
5+
sgMail.setApiKey(Config.sendGrid.API_KEY);
6+
7+
export const sendEmail = async (payload: SendData) => {
8+
try {
9+
const msg = {
10+
to: payload.to,
11+
from: Config.email.FROM,
12+
subject: payload.subject,
13+
html: payload.html,
14+
};
15+
16+
return sgMail.send(msg);
17+
} catch (error) {
18+
console.error('Error sending email:', error);
19+
throw new Error('Failed to send email');
20+
}
21+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
### Run
2+
3+
```bash
4+
nodemon --config nodemon-emails.json
5+
```
6+
7+
### Links
8+
9+
|||
10+
|--- |--- |
11+
|Welcome|http://localhost:3003/welcome?data={%22name%22:%22Moshe%22}|
12+
|Mentorship accepted|http://localhost:3003/mentorship-accepted?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%20%22Brent%22,%22contactURL%22:%20%22https%22,%20%22openRequests%22:%208}|
13+
|Mentorship declined|http://localhost:3003/mentorship-declined?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%22Brent%22,%22reason%22:%22because%22}|
14+
|Mentorship declined (by system)|http://localhost:3003/mentorship-declined?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%22Brent%22,%22reason%22:%22because%22,%22bySystem%22:true}|
15+
|Mentorship cancelled|http://localhost:3003/mentorship-cancelled?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%20%22Brent%22,%22reason%22:%20%22I%27ve%20already%20found%20a%20mentor%22}|
16+
|Mentorship requested|http://localhost:3003/mentorship-requested?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%22Brent%22,%22message%22:%22because%22,%22background%22:%22here%20is%20my%20background%22,%22expectation%22:%22I'm%20expecting%20for%20the%20best!%22}|
17+
|Mentorship reminder|http://localhost:3003/mentorship-reminder?data={%22menteeName%22:%22Moshe%22,%22mentorName%22:%22Brent%22,%22message%22:%22because%22}|
18+
|Mentor application received|http://localhost:3003/mentor-application-received?data={%22name%22:%22Brent%22}|
19+
|Mentorship application denied|http://localhost:3003/mentor-application-declined?data={%22name%22:%22Moshe%22,%22reason%22:%22your%20avatar%20is%20not%20you%22}|
20+
|Mentorship application approved|http://localhost:3003/mentor-application-approved?data={%22name%22:%22Moshe%22}|

0 commit comments

Comments
 (0)