DEV Community

Roberto Luna
Roberto Luna

Posted on

Adding Custom Quote Emails, 3‑D Luma Tours, and Drive‑Backed Media to the Broker Portal

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:

  1. Personalize the email subject and body per prospect.
  2. CC the internal advisor automatically.
  3. 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 })
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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]
  );
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Top comments (0)