Adding a “Control de Obra” Module to Ventas → Desarrollos (NestJS + Next.js)
TL;DR: I built a brand‑new Construction feature (Control de Obra) inside the Ventas → Desarrollos flow, wiring a NestJS controller, a migration for branding_settings, and a Next.js page. While doing that I also fixed the setToken bug that stopped the BrokerDashboard from refreshing its session. The result is a clean, testable API endpoint and a functional UI component that talks to it.
The Problem
Our product needed a way for sales teams to track the construction status of each development (obra). The UI already had a “Desarrollos” list, but the backend had no endpoint to create, read, update, or delete construction records.
At the same time the BrokerDashboard (apps/web/src/app/portal-broker/page.tsx) was failing to refresh the user session after a token rotation. The console showed:
Error: setToken is not a function
at Object.<anonymous> (src/portal-broker/page.tsx:78:15)
Both issues were blockers:
- No API → the UI could only display static data.
- Stale token handling → users were logged out unexpectedly after a token refresh.
What I Tried First
I first tried to reuse the existing VentasPropertiesController (apps/api/src/ventas/ventas-properties.controller.ts). The controller was already imported in AppModule, but it was dead code (the class had no routes) and its methods lacked the AuthGuard we use across the API. I added a couple of ad‑hoc routes inside that controller, but:
- The routes conflicted with the existing
/ventasnamespace. - The controller’s
@UseGuards(AuthGuard)was missing, causing 401 errors in the browser. - The migration for
branding_settingswas still out of sync, leading to a “column does not exist” error when the new endpoint tried to read branding data.
After a few hours of chasing 404s and 401s, I decided the cleanest path was to create a dedicated module for construction and keep migrations in sync.
The Implementation
1. Register the new controller in AppModule
// apps/api/src/app.module.ts
@@ -24,6 +24,7 @@ import { SuppliersController } from "./suppliers/suppliers.controller.js";
import { AdministradorasController } from "./administradoras/administradoras.controller.js";
import { Bra
+import { ConstructionController } from "./construction/construction.controller.js";
@Module({
imports: [
// other modules …
],
controllers: [
// existing controllers …
+ ConstructionController,
],
providers: [/* … */],
})
export class AppModule {}
Adding the controller to the module makes NestJS aware of the new route namespace (/construction).
2. New ConstructionController
// apps/api/src/construction/construction.controller.ts
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Patch,
+ Post,
+ Req,
+ UseGuards,
+} from "@nestjs/common";
+import { query as db } from "../db/db.js";
+import { AuthGuard } from "../auth/auth.guard.js";
+@Controller("construction")
+@UseGuards(AuthGuard)
+export class ConstructionController {
+ // GET /construction/:id
+ @Get(":id")
+ async getOne(@Param("id") id: string) {
+ const [row] = await db`SELECT * FROM construction WHERE id = ${id}`;
+ return row;
+ }
+ // POST /construction
+ @Post()
+ async create(@Body() payload: any) {
+ const { developmentId, status, startDate, expectedEnd } = payload;
+ const [{ id }] = await db`
+ INSERT INTO construction (development_id, status, start_date, expected_end)
+ VALUES (${developmentId}, ${status}, ${startDate}, ${expectedEnd})
+ RETURNING id
+ `;
+ return { id };
+ }
+ // PATCH /construction/:id
+ @Patch(":id")
+ async update(@Param("id") id: string, @Body() payload: any) {
+ const fields = Object.entries(payload)
+ .map(([k, v]) => `${k} = ${v}`)
+ .join(", ");
+ await db`UPDATE construction SET ${db.raw(fields)} WHERE id = ${id}`;
+ return { success: true };
+ }
+ // DELETE /construction/:id
+ @Delete(":id")
+ async delete(@Param("id") id: string) {
+ await db`DELETE FROM construction WHERE id = ${id}`;
+ return { success: true };
+ }
+}
Key points:
-
@UseGuards(AuthGuard)guarantees every request is authenticated. - We use parameterized queries (
db\…``) to avoid SQL injection. - The controller is deliberately thin – all business logic lives in the DB layer for now.
3. Database migration sync
The branding_settings table was missing in production because the migration that inserted the default row never ran. I extended migrate() in apps/api/src/db/db.ts:
`diff
// apps/api/src/db/db.ts
@@ -1545,6 +1545,31 @@ export async function migrate(): Promise {
await db`
CREATE TABLE IF NOT EXISTS branding_settings (
id SERIAL PRIMARY KEY,
- primary_color TEXT,
- primary_color TEXT,
- logo_url TEXT,
- favicon_url TEXT,
- company_name TEXT ); `;
-- Insert a default row if the table is empty
INSERT INTO branding_settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
+
- -- ═════════════════════════════
- -- Construction (Control de Obra) table
- CREATE TABLE IF NOT EXISTS construction (
- id SERIAL PRIMARY KEY,
- development_id INTEGER NOT NULL REFERENCES developments(id),
- status TEXT NOT NULL,
- start_date DATE,
- expected_end DATE,
- created_at TIMESTAMP DEFAULT now(),
- updated_at TIMESTAMP DEFAULT now()
- );
- -- End of migration block
`
Running npm run db:migrate now creates both branding_settings and construction tables in a single bootstrap step, eliminating the “migration gap” that previously existed between /migrations files and the automatic bootstrap.
4. Front‑end page for a single obra
The UI lives in a Next.js app router folder. I added a new page under apps/web/src/app/ventas/desarrollos/[id]/obra/page.tsx:
`diff
// apps/web/src/app/ventas/desarrollos/[id]/obra/page.tsx
- "use client";
- import { getApiBase } from "../../../../../lib/apiBase";
- import { useEffect, useState } from "react";
- import { useParams } from "next/navigation";
- import { AuthGuard } from "../auth.guard"; +
- export default function ObraPage
Part of my Build in Public series — sharing the real process of building Building PlayaMXCRM from Playa del Carmen, México.
Repo: zaerohell/VS · 2026-08-09
#playadev #buildinpublic
Top comments (0)