Adding Custom Quote Emails, 3‑D Luma Tours, and Drive‑Backed Media to the Broker Portal
TL;DR: I extended the broker‑portal API so agents can send quotes with a custom subject, message, CC the advisor, and persist the quote URL. I also wired up a Luma AI 3‑D tour generator, ElevenLabs TTS, and Google Drive upload pipeline. The result is a fully‑automated “quote‑and‑tour” flow that runs in production without manual steps.
The Problem
Our real‑estate broker portal (the portal‑broker micro‑service) was sending static email templates for quotes. Agents needed to:
- Personalize the email subject and body per prospect.
- CC the internal advisor automatically.
- Keep a history of the quote URL generated by Luma AI (our 3‑D tour provider).
Additionally, the virtual‑tour pipeline was broken: the endpoint that accepted the rendered MP4 from Luma returned a 500 error because the file was never saved, and we had no way to generate accompanying audio with ElevenLabs or store the assets in a shared Google Drive folder.
The symptom was a “Cotización rota” error in the UI and missing media on the admin side.
What I Tried First
My first attempt was to patch the UI only: I added a free‑text input for the subject and message, then concatenated those values into the existing sendQuoteEmail call.
// apps/web/src/app/portal-broker/page.tsx (initial hack)
const subject = customSubjectRef.current?.value ?? "Cotización";
await fetch("/api/broker-portal/quotes/send-email", {
method: "POST",
body: JSON.stringify({ subject, ...payload })
});
I also tried to upload the Luma MP4 directly from the browser to our S3 bucket, bypassing the API. That worked locally but failed on Render because outbound ports to S3 were blocked. The error logged was:
Error: connect ECONNREFUSED 52.216.XX.XX:443
Both approaches were dead ends: the backend never persisted the quote metadata, and the media upload violated our security model.
The Implementation
1. Database Migration – Store Quote URL
I added a nullable column luma_tour_url to the properties_sale table so we can keep a permanent reference.
// apps/api/src/db/db.ts (migration snippet)
alter table properties_sale add column if not exists luma_tour_url text;
The migration runs automatically on startup (migrate()), and the new field is exposed via the Quote DTO.
2. API – BrokerPortalController Enhancements
The controller now accepts subject, message, and ccAdvisor fields, validates them, and stores the Luma URL.
// apps/api/src/brokers/broker-portal.controller.ts
@Post('quotes/send-email')
async sendQuoteEmail(@Body() body: {
quoteId: string;
prospectEmail: string;
subject?: string;
message?: string;
ccAdvisor?: boolean;
}) {
const { quoteId, prospectEmail, subject, message, ccAdvisor } = body;
// fetch quote + property data
const quote = await this.quoteService.getQuote(quoteId);
// optional Luma URL persistence
if (quote.lumaTourUrl) {
await this.db.query(
`UPDATE properties_sale SET luma_tour_url = $1 WHERE id = $2`,
[quote.lumaTourUrl, quote.propertyId]
);
}
// build email payload
const emailPayload = {
to: prospectEmail,
subject: subject ?? `Cotización — ${quote.propertyTitle}`,
html: this.emailService.renderQuoteTemplate({
quote,
customMessage: message,
}),
cc: ccAdvisor ? env.ADVISOR_EMAIL : undefined,
attachments: [/* optional PDF */],
};
await this.emailService.sendMail(emailPayload);
return { success: true };
}
Key changes:
- Custom Subject/Message – If omitted, we fall back to the previous default.
-
CC Advisor – Controlled by a boolean flag; the advisor email lives in
env.ADVISOR_EMAIL. - Quote History – The Luma URL is saved the first time the email is sent.
3. Email Service – Template & Brevo Integration
We switched from the generic Brevo wrapper to a dedicated template that injects the custom message.
// apps/api/src/email/email.service.ts
export async function sendMail({
to,
subject,
html,
cc,
attachments,
}: {
to: string;
subject: string;
html: string;
cc?: string;
attachments?: Attachment[];
}) {
const payload = {
sender: { email: env.BREVO_SENDER },
to: [{ email: to }],
subject,
htmlContent: html,
cc: cc ? [{ email: cc }] : undefined,
attachment: attachments?.map(a => ({
name: a.filename,
content: a.content.toString('base64'),
type: a.contentType,
})),
};
await deliverBrev(payload);
}
The deliverBrev helper now logs the full request for debugging, which helped catch the earlier 500 errors caused by malformed attachment data.
4. Virtual‑Tour Pipeline
a. Luma AI 3‑D Tour Link
Agents can now paste a Luma link directly in the quote form. The controller validates the URL format and stores it.
// apps/api/src/brokers/broker-portal.controller.ts (excerpt)
if (body.lumaTourLink?.startsWith('https://luma.ai/')) {
await this.db.query(
`UPDATE properties_sale SET luma_tour_url = $1 WHERE id = $2`,
[body.lumaTourLink, quote.propertyId]
);
}
b. MP4 Render Endpoint
A new endpoint receives the rendered MP4 from Luma’s webhook.
// apps/api/src/virtual-tour/virtual-tour.controller.ts
@Post('render')
@UseInterceptors(FileInterceptor('file'))
async receiveRender(@UploadedFile() file: Express.Multer.File, @Body() body: { propertyId: string }) {
if (!file) throw new BadRequestException('No file uploaded');
const drivePath = await this.virtualTourService.uploadToDrive(file, body.propertyId);
return { drivePath };
}
c. Google Drive Helper
All Drive interactions live in google-drive.ts. We use a service account with domain‑wide delegation.
ts
// apps/api/src/booking/google-drive.ts
export async function uploadToDrive(file: Buffer, filename: string, folderId: string) {
const drive = google.drive({ version: 'v3', auth: serviceAccountAuth });
const res = await drive.files.create({
requestBody: {
name: filename,
parents: [folderId],
},
media:
---
*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.*
*Repo: `zaerohell/VS` · 2026-09-02*
\#playadev #buildinpublic
Top comments (0)