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:
-
No column to persist the Luma link – the
properties_saletable only storedid,price,address, etc. -
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);
What went wrong?
- The
metadatacolumn is ajsonbfield with a strict schema validator in the ORM. Adding an arbitrary key caused a validation error:
QueryFailedError: column "metadata" violates check constraint "metadata_check"
- Even when we forced the insert, the email template (
quote-email.hbs) used{{quote.lumaTour}}. Becausemetadata.lumaTourwasn’t mapped to a top‑level property, the template renderedundefined, causing theTypeErrorseen 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;
`);
}
}
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;
}
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.
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}`;
}
}
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);
}
}
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>
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
};
}
6. End‑to‑end test
I added an integration test (apps/api/test/quote.e2e-spec.ts) that:
- Creates a property.
- Calls the quote endpoint.
- Asserts that the response JSON contains
public_linkand 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
Top comments (0)