Skip to content

Commit 56f5cc7

Browse files
committed
feat: migrate contact export to edge function (#2812)
1 parent 5f80917 commit 56f5cc7

25 files changed

Lines changed: 1588 additions & 1208 deletions

File tree

Lines changed: 2 additions & 274 deletions
Original file line numberDiff line numberDiff line change
@@ -1,275 +1,3 @@
1-
import { User } from '@supabase/supabase-js';
2-
import { NextFunction, Request, Response } from 'express';
3-
import { Contacts } from '../db/interfaces/Contacts';
4-
import { Contact, ExportService } from '../db/types';
5-
import Billing from '../utils/billing-plugin';
6-
import { ExportOptions, ExportType } from '../services/export/types';
7-
import ExportFactory from '../services/export';
8-
import {
9-
MiningSources,
10-
OAuthMiningSourceCredentials
11-
} from '../db/interfaces/MiningSources';
12-
13-
async function validateRequest(
14-
req: Request,
15-
res: Response,
16-
miningSourceService: MiningSources
17-
) {
18-
const user = res.locals.user as User;
19-
const partialExport = req.body.partialExport ?? false;
20-
const updateEmptyFieldsOnly = req.body.updateEmptyFieldsOnly ?? false;
21-
const exportType = (req.params.exportType ?? ExportType.CSV) as ExportType;
22-
23-
const localeFromHeader = req.headers['accept-language'];
24-
const delimiterOption = req.query.delimiter?.toString();
25-
26-
const validExportType = Object.values(ExportType).includes(
27-
exportType as ExportType
28-
);
29-
30-
if (!validExportType) {
31-
throw new Error(`Invalid export type: ${exportType}`);
32-
}
33-
34-
let googleContactsOptions: ExportOptions['googleContactsOptions'] = {
35-
userId: user.id,
36-
accessToken: undefined,
37-
refreshToken: undefined,
38-
updateEmptyFieldsOnly
39-
};
40-
41-
if (exportType === ExportType.GOOGLE_CONTACTS) {
42-
const targetEmail = req.body.targetEmail || user.email;
43-
const sources = await miningSourceService.getSourcesForUser(
44-
user.id,
45-
targetEmail as string
46-
);
47-
const oauthCredentials = sources?.find((e) => e.email === targetEmail)
48-
?.credentials as OAuthMiningSourceCredentials;
49-
50-
googleContactsOptions = {
51-
...googleContactsOptions,
52-
accessToken: oauthCredentials?.accessToken,
53-
refreshToken: oauthCredentials?.refreshToken
54-
};
55-
}
56-
57-
const {
58-
ids,
59-
exportAllContacts
60-
}: { ids?: string[]; exportAllContacts: boolean } = req.body;
61-
62-
if (!exportAllContacts && (!Array.isArray(ids) || !ids.length)) {
63-
return {
64-
userId: user.id,
65-
exportType,
66-
contactsToExport: null,
67-
partialExport,
68-
exportOptions: {
69-
locale: localeFromHeader,
70-
delimiter: undefined,
71-
googleContactsOptions
72-
}
73-
};
74-
}
75-
76-
const contactsToExport = exportAllContacts ? undefined : ids;
77-
78-
return {
79-
userId: user.id,
80-
exportType,
81-
contactsToExport,
82-
partialExport,
83-
exportOptions: {
84-
locale: localeFromHeader,
85-
delimiter: delimiterOption,
86-
googleContactsOptions
87-
}
88-
};
89-
}
90-
91-
async function respondWithContacts(
92-
res: Response,
93-
userId: string,
94-
contacts: Contacts,
95-
exportType: ExportType,
96-
contactsToExport?: string[],
97-
exportOption?: ExportOptions
98-
) {
99-
const selectedContacts = await contacts.getContacts(userId, contactsToExport);
100-
101-
if (!selectedContacts.length) {
102-
return res.sendStatus(204); // 204 No Content
103-
}
104-
105-
try {
106-
const { content, contentType } = await ExportFactory.get(exportType).export(
107-
selectedContacts,
108-
exportOption
109-
);
110-
111-
return res.header('Content-Type', contentType).status(200).send(content);
112-
} catch (err) {
113-
if ((err as Error).message === 'Invalid credentials.') {
114-
return res.sendStatus(401);
115-
}
116-
throw err;
117-
}
118-
}
119-
120-
async function verifyCredits(
121-
userId: string,
122-
contacts: Contacts,
123-
contactsToExport?: string[]
124-
) {
125-
const newContacts = await contacts.getNonExportedContacts(
126-
userId,
127-
contactsToExport
128-
);
129-
const previousExportedContacts = await contacts.getExportedContacts(
130-
userId,
131-
contactsToExport
132-
);
133-
const creditsInfo = await Billing!.validateCustomerCredits(
134-
userId,
135-
newContacts.length
136-
);
137-
const response = {
138-
total: newContacts.length + previousExportedContacts.length,
139-
available: Math.floor(creditsInfo.availableUnits),
140-
availableAlready: previousExportedContacts.length
141-
};
142-
return {
143-
newContacts,
144-
previousExportedContacts,
145-
creditsInfo,
146-
response
147-
};
148-
}
149-
150-
async function registerAndDeductCredits(
151-
userId: string,
152-
exportType: ExportType,
153-
availableUnits: number,
154-
contacts: Contacts,
155-
availableContacts: Contact[]
156-
) {
157-
if (availableContacts.length) {
158-
await contacts.registerExportedContacts(
159-
availableContacts.map(({ id }) => id),
160-
exportType as unknown as ExportService,
161-
userId
162-
);
163-
await Billing!.deductCustomerCredits(userId, availableUnits);
164-
}
165-
}
166-
167-
async function respondWithConfirmedContacts(
168-
res: Response,
169-
userId: string,
170-
exportType: ExportType,
171-
contacts: Contacts,
172-
newContacts: Contact[],
173-
previousExportedContacts: Contact[],
174-
availableUnits: number,
175-
statusCode: number,
176-
exportOptions?: ExportOptions
177-
) {
178-
const availableContacts = newContacts.slice(0, availableUnits);
179-
const selectedContacts = [...previousExportedContacts, ...availableContacts];
180-
181-
try {
182-
const { content, contentType } = await ExportFactory.get(exportType).export(
183-
selectedContacts,
184-
exportOptions
185-
);
186-
187-
await registerAndDeductCredits(
188-
userId,
189-
exportType,
190-
availableUnits,
191-
contacts,
192-
selectedContacts
193-
);
194-
195-
return res
196-
.header('Content-Type', contentType)
197-
.status(statusCode)
198-
.send(content);
199-
} catch (err) {
200-
if ((err as Error).message === 'Invalid credentials.') {
201-
return res.sendStatus(401);
202-
}
203-
throw err;
204-
}
205-
}
206-
207-
export default function initializeContactsController(
208-
contacts: Contacts,
209-
miningSources: MiningSources
210-
) {
211-
return {
212-
async exportContactsCSV(req: Request, res: Response, next: NextFunction) {
213-
const {
214-
userId,
215-
contactsToExport,
216-
partialExport,
217-
exportType,
218-
exportOptions
219-
} = await validateRequest(req, res, miningSources);
220-
if (contactsToExport === null) {
221-
return res.status(400).json({
222-
message: 'Parameter "ids" must be a non-empty list of person ids'
223-
});
224-
}
225-
226-
let statusCode = 200;
227-
try {
228-
if (!Billing) {
229-
// No need to Verify Credits, Export.
230-
return await respondWithContacts(
231-
res,
232-
userId,
233-
contacts,
234-
exportType,
235-
contactsToExport,
236-
exportOptions
237-
);
238-
}
239-
240-
const { newContacts, previousExportedContacts, creditsInfo, response } =
241-
await verifyCredits(userId, contacts, contactsToExport);
242-
243-
if (
244-
creditsInfo.hasDeficientCredits &&
245-
!previousExportedContacts.length
246-
) {
247-
return res.status(402).json(response);
248-
}
249-
250-
if (creditsInfo.hasInsufficientCredits && newContacts.length) {
251-
if (!partialExport) {
252-
res.statusMessage = 'Confirm Partial Content';
253-
return res.status(266).json(response); // 266 Confirm Partial Content
254-
}
255-
statusCode = 206; // 206 Partial Content
256-
}
257-
258-
// Export confirmed contacts.
259-
return await respondWithConfirmedContacts(
260-
res,
261-
userId,
262-
exportType,
263-
contacts,
264-
newContacts,
265-
previousExportedContacts,
266-
creditsInfo.availableUnits,
267-
statusCode,
268-
exportOptions
269-
);
270-
} catch (error) {
271-
return next(error);
272-
}
273-
}
274-
};
1+
export default function initializeContactsController() {
2+
return {};
2753
}

backend/src/routes/contacts.routes.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,24 @@
11
import { Router } from 'express';
22
import rateLimit from 'express-rate-limit';
3-
import initializeContactsController from '../controllers/contacts.controller';
43
import initializeContactsVerificationController from '../controllers/contacts-verification.controller';
54
import { Contacts } from '../db/interfaces/Contacts';
65
import initializeAuthMiddleware from '../middleware/auth';
76
import AuthResolver from '../services/auth/AuthResolver';
8-
import { MiningSources } from '../db/interfaces/MiningSources';
97

108
export default function initializeContactsRoutes(
119
contacts: Contacts,
1210
authResolver: AuthResolver,
13-
miningSources: MiningSources
11+
miningSources: unknown
1412
) {
1513
const router = Router();
1614
const contactsRouteLimiter = rateLimit({
1715
windowMs: 15 * 60 * 1000,
1816
max: 100
1917
});
2018

21-
const { exportContactsCSV } = initializeContactsController(
22-
contacts,
23-
miningSources
24-
);
25-
2619
const { verifyEmailStatus } =
2720
initializeContactsVerificationController(contacts);
2821

29-
router.post(
30-
'/contacts/export/:exportType',
31-
initializeAuthMiddleware(authResolver),
32-
contactsRouteLimiter,
33-
exportContactsCSV
34-
);
35-
3622
router.post(
3723
'/contacts/verify',
3824
initializeAuthMiddleware(authResolver),

0 commit comments

Comments
 (0)