Skip to content

Commit 8abc3ed

Browse files
rfontanarosagino-m
andauthored
Send email when new passlist entry is added (#2087)
Co-authored-by: Gino Miceli <[email protected]>
1 parent 926dd87 commit 8abc3ed

13 files changed

+2092
-750
lines changed

functions/package.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,16 @@
3232
"cors": "2.8.5",
3333
"csv-parser": "2.3.3",
3434
"firebase-admin": "12.1.0",
35-
"firebase-functions": "^5.0.1",
35+
"firebase-functions": "^5.1.1",
3636
"google-auth-library": "6.1.3",
3737
"googleapis": "64.0.0",
3838
"http-status-codes": "1.4.0",
3939
"immutable": "^4.3.6",
4040
"jsonstream-ts": "1.3.6",
4141
"module-alias": "^2.2.2",
42+
"nodemailer": "^6.9.16",
4243
"requests": "0.3.0",
44+
"sanitize-html": "^2.13.1",
4345
"ts-node": "^10.9.1"
4446
},
4547
"engines": {
@@ -53,6 +55,8 @@
5355
"@types/geojson": "^7946.0.14",
5456
"@types/jasmine": "^4.3.5",
5557
"@types/jsonstream": "0.8.30",
58+
"@types/nodemailer": "^6.4.16",
59+
"@types/sanitize-html": "^2.13.0",
5660
"@types/terraformer__wkt": "2.0.0",
5761
"firebase-functions-test": "^3.3.0",
5862
"firebase-tools": "13.6.0",

functions/src/common/context.ts

+15-4
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@
1515
*/
1616

1717
import {Datastore} from './datastore';
18+
import {MailService} from './mail-service';
1819
import {initializeApp, getApp} from 'firebase-admin/app';
1920
import {getFirestore} from 'firebase-admin/firestore';
2021

2122
let datastore: Datastore | undefined;
23+
let mailService: MailService | undefined;
2224

2325
export function initializeFirebaseApp() {
2426
try {
@@ -29,13 +31,22 @@ export function initializeFirebaseApp() {
2931
}
3032

3133
export function getDatastore(): Datastore {
32-
if (!datastore) {
33-
initializeFirebaseApp();
34-
datastore = new Datastore(getFirestore());
35-
}
34+
if (datastore) return datastore;
35+
initializeFirebaseApp();
36+
datastore = new Datastore(getFirestore());
3637
return datastore;
3738
}
3839

40+
export async function getMailService(): Promise<MailService | undefined> {
41+
if (mailService) return mailService;
42+
const mailServerConfig = await MailService.getMailServerConfig(
43+
getDatastore()
44+
);
45+
if (!mailServerConfig) return;
46+
mailService = new MailService(mailServerConfig);
47+
return mailService;
48+
}
49+
3950
export function resetDatastore() {
4051
datastore = undefined;
4152
}

functions/src/common/datastore.ts

+24
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,21 @@ type pseudoGeoJsonGeometry = {
3939
*/
4040
export const config = () => 'config';
4141

42+
/**
43+
* Returns the path of passlist entry doc with the specified id.
44+
*/
45+
export const passlistEntry = (entryId: string) => `passlist/${entryId}`;
46+
4247
/**
4348
* Returns the path of integrations doc.
4449
*/
4550
export const integrations = () => config() + '/integrations';
4651

52+
/**
53+
* Returns the path of mail doc.
54+
*/
55+
export const mail = () => config() + '/mail';
56+
4757
/**
4858
* Returns path to survey colection. This is a function for consistency with other path functions.
4959
*/
@@ -83,6 +93,12 @@ export const submissions = (surveyId: string) =>
8393
export const submission = (surveyId: string, submissionId: string) =>
8494
submissions(surveyId) + '/' + submissionId;
8595

96+
/**
97+
* Returns the path of template doc with the specified id.
98+
*/
99+
export const mailTemplate = (templateId: string) =>
100+
`${mail()}/templates/${templateId}`;
101+
86102
export class Datastore {
87103
private db_: firestore.Firestore;
88104

@@ -126,6 +142,10 @@ export class Datastore {
126142
return this.db_.collection(integrations() + '/propertyGenerators').get();
127143
}
128144

145+
fetchMailConfig() {
146+
return this.fetchDoc_(mail());
147+
}
148+
129149
fetchSurvey(surveyId: string) {
130150
return this.db_.doc(survey(surveyId)).get();
131151
}
@@ -145,6 +165,10 @@ export class Datastore {
145165
.get();
146166
}
147167

168+
fetchMailTemplate(templateId: string) {
169+
return this.fetchDoc_(mailTemplate(templateId));
170+
}
171+
148172
fetchSheetsConfig(surveyId: string) {
149173
return this.fetchDoc_(`${survey(surveyId)}/sheets/config`);
150174
}

functions/src/common/mail-service.ts

+96
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Copyright 2024 The Ground Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import * as nodemailer from 'nodemailer';
18+
import sanitizeHtml from 'sanitize-html';
19+
import {Datastore} from './datastore';
20+
21+
type MailConfig = {
22+
server?: MailServerConfig;
23+
};
24+
25+
type MailServerConfig = {
26+
id: string;
27+
host: string;
28+
port: number;
29+
username: string;
30+
password: string;
31+
sender?: string;
32+
};
33+
34+
export interface MailServiceEmail {
35+
to: string;
36+
subject: string;
37+
html: string;
38+
}
39+
40+
/**
41+
* Service for sending emails.
42+
*/
43+
export class MailService {
44+
private transporter_: nodemailer.Transporter;
45+
private sender_: string;
46+
47+
constructor(mailServerConfig: MailServerConfig) {
48+
const {host, port, username, password, sender} = mailServerConfig;
49+
50+
this.sender_ = sender || username;
51+
52+
this.transporter_ = nodemailer.createTransport({
53+
host,
54+
port,
55+
auth: {user: username, pass: password},
56+
sender: this.sender_,
57+
});
58+
}
59+
60+
/**
61+
* Sends an email.
62+
*
63+
* @param email - Email object containing recipient, subject, and body.
64+
*/
65+
async sendMail(email: MailServiceEmail): Promise<void> {
66+
const {html} = email;
67+
68+
const safeHtml = sanitizeHtml(html, {
69+
allowedTags: ['br', 'a'],
70+
allowedAttributes: {
71+
a: ['href'],
72+
},
73+
});
74+
75+
try {
76+
await this.transporter_.sendMail({
77+
from: this.sender_,
78+
...email,
79+
html: safeHtml,
80+
});
81+
} catch (err) {
82+
console.error(err);
83+
}
84+
}
85+
86+
/**
87+
* Retrieves the mail server configuration from the database.
88+
*/
89+
static async getMailServerConfig(
90+
db: Datastore
91+
): Promise<MailServerConfig | undefined> {
92+
const mailConfig = (await db.fetchMailConfig()) as MailConfig;
93+
if (!mailConfig?.server) console.debug('Unable to find mail configuration');
94+
return mailConfig?.server;
95+
}
96+
}

functions/src/common/utils.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Copyright 2024 The Ground Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export function stringFormat(s: string, ...args: any[]): string {
18+
return s.replace(/\{(\d+)\}/g, (_, index) => args[index] || `{${index}}`);
19+
}

functions/src/index.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,20 @@ import {exportCsvHandler} from './export-csv';
2424
import {exportGeojsonHandler} from './export-geojson';
2525
import {onCall} from 'firebase-functions/v2/https';
2626
import {onCreateLoiHandler} from './on-create-loi';
27+
import {onCreatePasslistEntryHandler} from './on-create-passlist-entry';
2728
import {onWriteJobHandler} from './on-write-job';
2829
import {onWriteLoiHandler} from './on-write-loi';
2930
import {onWriteSubmissionHandler} from './on-write-submission';
3031
import {onWriteSurveyHandler} from './on-write-survey';
31-
import {job, loi, submission, survey} from './common/datastore';
32+
import {job, loi, passlistEntry, submission, survey} from './common/datastore';
3233
import {initializeFirebaseApp} from './common/context';
3334

3435
// Ensure Firebase is initialized.
3536
initializeFirebaseApp();
3637

38+
/** Template for passlist entry write triggers capturing passlist entry id. */
39+
const passlistEntryPathTemplate = passlistEntry('{entryId}');
40+
3741
/** Template for job write triggers capturing survey and job id. */
3842
const jobPathTemplate = job('{surveyId}', '{jobId}');
3943

@@ -50,6 +54,10 @@ export const profile = {
5054
refresh: onCall(request => handleProfileRefresh(request)),
5155
};
5256

57+
export const onCreatePasslistEntry = functions.firestore
58+
.document(passlistEntryPathTemplate)
59+
.onCreate(onCreatePasslistEntryHandler);
60+
5361
export const importGeoJson = onHttpsRequestAsync(importGeoJsonCallback);
5462

5563
export const exportCsv = onHttpsRequest(exportCsvHandler);

functions/src/on-create-loi.ts

+7
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ type PropertyGenerator = {
3434
url: string;
3535
};
3636

37+
/**
38+
* Handles the creation of a Location of Interest (LOI) document in Firestore.
39+
* This function is triggered by a Cloud Function on Firestore document creation.
40+
*
41+
* @param snapshot The QueryDocumentSnapshot object containing the created LOI data.
42+
* @param context The EventContext object provided by the Cloud Functions framework.
43+
*/
3744
export async function onCreateLoiHandler(
3845
snapshot: QueryDocumentSnapshot,
3946
context: EventContext
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Copyright 2025 The Ground Authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
createMockFirestore,
19+
newDocumentSnapshot,
20+
newEventContext,
21+
stubAdminApi,
22+
} from '@ground/lib/dist/testing/firestore';
23+
import {resetDatastore} from './common/context';
24+
import {Firestore} from 'firebase-admin/firestore';
25+
import * as functions from './index';
26+
import * as context from './common/context';
27+
import {MailService} from './common/mail-service';
28+
29+
const test = require('firebase-functions-test')();
30+
31+
describe('onCreatePasslistEntry()', () => {
32+
let mockFirestore: Firestore;
33+
let getMailServiceMock: any;
34+
let mailServiceMock: any;
35+
36+
const serverConfig = {
37+
port: 5555,
38+
};
39+
const mail = {
40+
html: 'html',
41+
subject: 'subject',
42+
43+
};
44+
45+
beforeEach(() => {
46+
mockFirestore = createMockFirestore();
47+
stubAdminApi(mockFirestore);
48+
49+
mailServiceMock = jasmine.createSpyObj('MailService', [
50+
'sendMail',
51+
]) as jasmine.SpyObj<MailService>;
52+
53+
getMailServiceMock = spyOn(context, 'getMailService').and.returnValue(
54+
mailServiceMock
55+
);
56+
});
57+
58+
afterEach(() => {
59+
resetDatastore();
60+
});
61+
62+
afterAll(() => {
63+
test.cleanup();
64+
});
65+
66+
it('passlist notification email template exists', async () => {
67+
await mockFirestore.collection('passlists').add({});
68+
await test.wrap(functions.onCreatePasslistEntry)(newDocumentSnapshot({}));
69+
expect(getMailServiceMock).not.toHaveBeenCalled();
70+
});
71+
72+
it('mail server config exists', async () => {
73+
const docRef = mockFirestore.doc('config/mail');
74+
docRef.set({server: serverConfig});
75+
const docSnapshot = await docRef.get();
76+
const data = docSnapshot.data();
77+
expect(data).toEqual({server: serverConfig});
78+
expect(docSnapshot.exists).toBe(true);
79+
expect(docSnapshot.id).toBe('mail');
80+
});
81+
82+
it('sends email notification', async () => {
83+
mockFirestore.doc('config/mail').set({server: serverConfig});
84+
mockFirestore.doc('config/mail/templates/passlisted').set(mail);
85+
mockFirestore.doc(`passlists/${mail.to}`).set({});
86+
await test.wrap(functions.onCreatePasslistEntry)(
87+
newDocumentSnapshot({}),
88+
newEventContext({entryId: mail.to})
89+
);
90+
expect(getMailServiceMock).toHaveBeenCalled();
91+
expect(mailServiceMock.sendMail).toHaveBeenCalled();
92+
expect(mailServiceMock.sendMail).toHaveBeenCalledWith(mail);
93+
});
94+
});

0 commit comments

Comments
 (0)