DEV Community

Roberto Luna
Roberto Luna

Posted on

Adding a `public_link` column and wiring Luma 3‑D tours into broker quote emails (Node/TypeScript)

Adding a public_link column and wiring Luma 3‑D tours into broker quote emails (Node/TypeScript)

TL;DR: I extended the properties_sale table with a public_link column, generated signed URLs for Luma tours, and updated the email service to embed those links. The change lets brokers receive a single email that contains both the price quote and a clickable 3‑D tour, without breaking the existing API.


The Problem

Our broker portal sends a quote email that contains plain‑text property details. A client asked for a 3‑D Luma tour to be attached to the same email. The existing codebase only stored static images and had no place to keep the Luma URL, so we had two issues:

  1. No column to persist the Luma link – the properties_sale table only stored id, price, address, etc.
  2. Email template only rendered fields from QuoteDto – adding a new field required changes across the API, service layer, and the Handlebars template.

When we tried to hack the URL into the notes column, the email rendering broke (TypeError: undefined is not an object (evaluating 'quote.lumaTour')). We needed a proper schema change and a clean way to generate the link.


What I Tried First

My first attempt was to keep the Luma URL in a JSON column called metadata (already present for other optional data). I added a helper that fetched the tour from Luma’s API and stored it as metadata.lumaTour. The email service then accessed quote.metadata.lumaTour.

// apps/api/src/services/quote.service.ts (first attempt)
const tourUrl = await lumaClient.generateTour(propertyId);
quote.metadata = { ...quote.metadata, lumaTour: tourUrl };
await this.quoteRepo.save(quote);
Enter fullscreen mode Exit fullscreen mode

What went wrong?

  • The metadata column is a jsonb field with a strict schema validator in the ORM. Adding an arbitrary key caused a validation error:
QueryFailedError: column "metadata" violates check constraint "metadata_check"
Enter fullscreen mode Exit fullscreen mode
  • Even when we forced the insert, the email template (quote-email.hbs) used {{quote.lumaTour}}. Because metadata.lumaTour wasn’t mapped to a top‑level property, the template rendered undefined, causing the TypeError seen in production logs.

Conclusion: a quick hack in a JSON blob was not sustainable. I needed a first‑class column.


The Implementation

1. Database migration

I added a nullable public_link column to properties_sale. The migration lives in apps/api/src/db/migrations/20260902_add_public_link.ts.

// apps/api/src/db/migrations/20260902_add_public_link.ts
import { MigrationInterface, QueryRunner } from "typeorm";

export class AddPublicLinkToPropertiesSale1693627200000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      ALTER TABLE properties_sale
      ADD COLUMN public_link VARCHAR(512) NULL;
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      ALTER TABLE properties_sale
      DROP COLUMN public_link;
    `);
  }
}
Enter fullscreen mode Exit fullscreen mode

Running npm run migration:run added the column without downtime because it’s nullable.

2. Entity update

// apps/api/src/db/db.ts (excerpt)
import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";

@Entity({ name: "properties_sale" })
export class PropertySale {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  address: string;

  @Column("decimal")
  price: number;

  // NEW
  @Column({ type: "varchar", length: 512, nullable: true })
  public_link?: string;
}
Enter fullscreen mode Exit fullscreen mode

The diff in content/2026/09/02/VS/changelog.md explicitly notes this addition:

+ **`apps/api/src/db/db.ts`** – se añadió la columna `public_link` a la tabla `properties_sale` para guardar el enlace generado por Luma.
Enter fullscreen mode Exit fullscreen mode

3. Luma client wrapper

I created a thin wrapper around Luma’s REST API to generate signed URLs that expire after 24 h.

// apps/api/src/services/luma-client.ts
import axios from "axios";
import { sign } from "jsonwebtoken";

export class LumaClient {
  private baseUrl = process.env.LUMA_API_URL!;
  private apiKey = process.env.LUMA_API_KEY!;

  async generateTour(propertyId: number): Promise<string> {
    const { data } = await axios.get(`${this.baseUrl}/tours/${propertyId}`, {
      headers: { "x-api-key": this.apiKey },
    });

    // Luma returns a raw tour ID; we sign it for temporary public access
    const token = sign({ tourId: data.id }, process.env.JWT_SECRET!, {
      expiresIn: "24h",
    });

    return `${process.env.LUMA_PUBLIC_URL}/${data.id}?token=${token}`;
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Service layer – persisting the link

When a quote is created or updated, we now generate the tour URL and store it in public_link.

// apps/api/src/services/quote.service.ts
import { LumaClient } from "./luma-client";
import { PropertySale } from "../db/db";

export class QuoteService {
  private luma = new LumaClient();

  async attachLumaTour(propertyId: number): Promise<void> {
    const property = await this.propertyRepo.findOneOrFail({ where: { id: propertyId } });

    // Guard against duplicate work
    if (property.public_link) {
      console.log(`Luma tour already attached for property ${propertyId}`);
      return;
    }

    const tourUrl = await this.luma.generateTour(propertyId);
    property.public_link = tourUrl;
    await this.propertyRepo.save(property);
  }
}
Enter fullscreen mode Exit fullscreen mode

I added a call to attachLumaTour in the quote creation flow (QuoteController.createQuote) right after the property record is persisted.

5. Email template update

The Handlebars template (apps/email/templates/quote-email.hbs) now includes a conditional block:

{{!-- apps/email/templates/quote-email.hbs --}}
<p>Dear {{broker.name}},</p>

<p>Here is the quote for {{property.address}}:</p>
<ul>
  <li>Price: ${{property.price}}</li>
  {{#if property.public_link}}
    <li>
      <a href="{{property.public_link}}">View 3‑D Luma Tour</a>
    </li>
  {{/if}}
</ul>

<p>Best,</p>
<p>VibeCoding Team</p>
Enter fullscreen mode Exit fullscreen mode

Because public_link lives on the PropertySale entity, the DTO sent to the email service now carries it:

// apps/email/src/dto/quote-email.dto.ts
export interface QuoteEmailDto {
  broker: { name: string; email: string };
  property: {
    address: string;
    price: number;
    public_link?: string; // <-- new field
  };
}
Enter fullscreen mode Exit fullscreen mode

6. End‑to‑end test

I added an integration test (apps/api/test/quote.e2e-spec.ts) that:

  1. Creates a property.
  2. Calls the quote endpoint.
  3. Asserts that the response JSON contains public_link and that the email HTML contains the <a href="..."> element.

ts
it("should embed Luma tour link in quote email", async () => {
  const property = await propertyRepo.save({ address: "123 Beach Rd", price: 350000 });
  await request(app.getHttpServer())
    .post("/quotes")
    .send({ propertyId: property.id, brokerId: broker.id })
    .expect

---

*Part of my [Build in Public](https://dev.to/zaerohell) series — sharing the real process of building SaaS projects from Playa del Carmen, México.*

*Repo: `zaerohell/content-automation` · 2026-09-03*

\#playadev #buildinpublic
Enter fullscreen mode Exit fullscreen mode

Top comments (0)