Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
56f5cc7
feat: migrate contact export to edge function (#2812)
malek10xdev Jun 19, 2026
931316b
fix: use upsert and private schema prefix in export-contacts RPC calls
malek10xdev Jun 26, 2026
1acf0cd
fix: use .schema('private').rpc() instead of dot-notation in contacts…
malek10xdev Jun 26, 2026
481bf84
fix(enrich): add missing user_id field to enriched contact data
malek10xdev Jun 26, 2026
a441cef
feat: add IMAP boxes endpoint to emails-fetcher microservice
malek10xdev Jun 25, 2026
591aa1e
feat: migrate frontend IMAP boxes to use edge function directly
malek10xdev Jun 25, 2026
eca4599
feat: update IMAP edge function with Hono and /boxes proxy endpoint
malek10xdev Jun 25, 2026
4a59e59
chore: remove deprecated backend IMAP boxes routes and code
malek10xdev Jun 25, 2026
e7eddc8
feat(config): register export-contacts edge function in Docker runtime
malek10xdev Jun 26, 2026
9eb303f
fix: add hono import map, EMAILS_FETCHER_URL env, header type fix
malek10xdev Jun 26, 2026
1de1c7d
refactor(enrich): port Engine interface from backend
malek10xdev Jun 26, 2026
4d86d23
refactor(enrich): port Enricher orchestrator from backend
malek10xdev Jun 26, 2026
c9dcdd7
refactor(enrich): port engine classes (EnrichLayer, TheDig, Voilanorb…
malek10xdev Jun 26, 2026
7e35076
feat(frontend): migrate enrichment to call edge function directly
malek10xdev Jun 26, 2026
f7315a0
feat(config): register imap edge function in Docker runtime
malek10xdev Jun 26, 2026
4c5a9ac
chore: remove deprecated backend enrichment routes and services
malek10xdev Jun 26, 2026
a32956b
merge: resolve conflicts with main — keep enrich edge function refact…
malek10xdev Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ import notFound from './middleware/notFound';
import initializeSentry from './middleware/sentry';
import initializeAuthRoutes from './routes/auth.routes';
import initializeContactsRoutes from './routes/contacts.routes';
import initializeEnrichmentRoutes from './routes/enrichment.routes';
import initializeImapRoutes from './routes/imap.routes';
import initializeMiningRoutes from './routes/mining.routes';
import initializeSmtpSendersRoutes from './routes/smtp-senders.routes';
import initializeStreamRouter from './routes/stream.routes';
Expand Down Expand Up @@ -65,7 +63,6 @@ export default function initializeApp(
);

app.use('/api/auth', initializeAuthRoutes());
app.use('/api/imap', initializeImapRoutes(authResolver, miningSourceService));
app.use('/api/imap', initializeStreamRouter(miningEngine, authResolver));
app.use(
'/api/imap',
Expand All @@ -81,7 +78,6 @@ export default function initializeApp(
'/api',
initializeContactsRoutes(contacts, authResolver, miningSourceService)
);
app.use('/api/enrich', initializeEnrichmentRoutes(authResolver));
app.use(
'/api',
initializeSmtpSendersRoutes(smtpSenders, authResolver, miningSources)
Expand Down
276 changes: 2 additions & 274 deletions backend/src/controllers/contacts.controller.ts
Original file line number Diff line number Diff line change
@@ -1,275 +1,3 @@
import { User } from '@supabase/supabase-js';
import { NextFunction, Request, Response } from 'express';
import { Contacts } from '../db/interfaces/Contacts';
import { Contact, ExportService } from '../db/types';
import Billing from '../utils/billing-plugin';
import { ExportOptions, ExportType } from '../services/export/types';
import ExportFactory from '../services/export';
import {
MiningSources,
OAuthMiningSourceCredentials
} from '../db/interfaces/MiningSources';

async function validateRequest(
req: Request,
res: Response,
miningSourceService: MiningSources
) {
const user = res.locals.user as User;
const partialExport = req.body.partialExport ?? false;
const updateEmptyFieldsOnly = req.body.updateEmptyFieldsOnly ?? false;
const exportType = (req.params.exportType ?? ExportType.CSV) as ExportType;

const localeFromHeader = req.headers['accept-language'];
const delimiterOption = req.query.delimiter?.toString();

const validExportType = Object.values(ExportType).includes(
exportType as ExportType
);

if (!validExportType) {
throw new Error(`Invalid export type: ${exportType}`);
}

let googleContactsOptions: ExportOptions['googleContactsOptions'] = {
userId: user.id,
accessToken: undefined,
refreshToken: undefined,
updateEmptyFieldsOnly
};

if (exportType === ExportType.GOOGLE_CONTACTS) {
const targetEmail = req.body.targetEmail || user.email;
const sources = await miningSourceService.getSourcesForUser(
user.id,
targetEmail as string
);
const oauthCredentials = sources?.find((e) => e.email === targetEmail)
?.credentials as OAuthMiningSourceCredentials;

googleContactsOptions = {
...googleContactsOptions,
accessToken: oauthCredentials?.accessToken,
refreshToken: oauthCredentials?.refreshToken
};
}

const {
ids,
exportAllContacts
}: { ids?: string[]; exportAllContacts: boolean } = req.body;

if (!exportAllContacts && (!Array.isArray(ids) || !ids.length)) {
return {
userId: user.id,
exportType,
contactsToExport: null,
partialExport,
exportOptions: {
locale: localeFromHeader,
delimiter: undefined,
googleContactsOptions
}
};
}

const contactsToExport = exportAllContacts ? undefined : ids;

return {
userId: user.id,
exportType,
contactsToExport,
partialExport,
exportOptions: {
locale: localeFromHeader,
delimiter: delimiterOption,
googleContactsOptions
}
};
}

async function respondWithContacts(
res: Response,
userId: string,
contacts: Contacts,
exportType: ExportType,
contactsToExport?: string[],
exportOption?: ExportOptions
) {
const selectedContacts = await contacts.getContacts(userId, contactsToExport);

if (!selectedContacts.length) {
return res.sendStatus(204); // 204 No Content
}

try {
const { content, contentType } = await ExportFactory.get(exportType).export(
selectedContacts,
exportOption
);

return res.header('Content-Type', contentType).status(200).send(content);
} catch (err) {
if ((err as Error).message === 'Invalid credentials.') {
return res.sendStatus(401);
}
throw err;
}
}

async function verifyCredits(
userId: string,
contacts: Contacts,
contactsToExport?: string[]
) {
const newContacts = await contacts.getNonExportedContacts(
userId,
contactsToExport
);
const previousExportedContacts = await contacts.getExportedContacts(
userId,
contactsToExport
);
const creditsInfo = await Billing!.validateCustomerCredits(
userId,
newContacts.length
);
const response = {
total: newContacts.length + previousExportedContacts.length,
available: Math.floor(creditsInfo.availableUnits),
availableAlready: previousExportedContacts.length
};
return {
newContacts,
previousExportedContacts,
creditsInfo,
response
};
}

async function registerAndDeductCredits(
userId: string,
exportType: ExportType,
availableUnits: number,
contacts: Contacts,
availableContacts: Contact[]
) {
if (availableContacts.length) {
await contacts.registerExportedContacts(
availableContacts.map(({ id }) => id),
exportType as unknown as ExportService,
userId
);
await Billing!.deductCustomerCredits(userId, availableUnits);
}
}

async function respondWithConfirmedContacts(
res: Response,
userId: string,
exportType: ExportType,
contacts: Contacts,
newContacts: Contact[],
previousExportedContacts: Contact[],
availableUnits: number,
statusCode: number,
exportOptions?: ExportOptions
) {
const availableContacts = newContacts.slice(0, availableUnits);
const selectedContacts = [...previousExportedContacts, ...availableContacts];

try {
const { content, contentType } = await ExportFactory.get(exportType).export(
selectedContacts,
exportOptions
);

await registerAndDeductCredits(
userId,
exportType,
availableUnits,
contacts,
selectedContacts
);

return res
.header('Content-Type', contentType)
.status(statusCode)
.send(content);
} catch (err) {
if ((err as Error).message === 'Invalid credentials.') {
return res.sendStatus(401);
}
throw err;
}
}

export default function initializeContactsController(
contacts: Contacts,
miningSources: MiningSources
) {
return {
async exportContactsCSV(req: Request, res: Response, next: NextFunction) {
const {
userId,
contactsToExport,
partialExport,
exportType,
exportOptions
} = await validateRequest(req, res, miningSources);
if (contactsToExport === null) {
return res.status(400).json({
message: 'Parameter "ids" must be a non-empty list of person ids'
});
}

let statusCode = 200;
try {
if (!Billing) {
// No need to Verify Credits, Export.
return await respondWithContacts(
res,
userId,
contacts,
exportType,
contactsToExport,
exportOptions
);
}

const { newContacts, previousExportedContacts, creditsInfo, response } =
await verifyCredits(userId, contacts, contactsToExport);

if (
creditsInfo.hasDeficientCredits &&
!previousExportedContacts.length
) {
return res.status(402).json(response);
}

if (creditsInfo.hasInsufficientCredits && newContacts.length) {
if (!partialExport) {
res.statusMessage = 'Confirm Partial Content';
return res.status(266).json(response); // 266 Confirm Partial Content
}
statusCode = 206; // 206 Partial Content
}

// Export confirmed contacts.
return await respondWithConfirmedContacts(
res,
userId,
exportType,
contacts,
newContacts,
previousExportedContacts,
creditsInfo.availableUnits,
statusCode,
exportOptions
);
} catch (error) {
return next(error);
}
}
};
export default function initializeContactsController() {
return {};
}
Loading
Loading