DEV Community

Roberto Luna
Roberto Luna

Posted on

Automatic Permit Expiration Alerts & AI Error Reporting in VS (NestJS + Next.js)

Automatic Permit Expiration Alerts & AI Error Reporting in VS (NestJS + Next.js)

TL;DR: I added a new priority‑actions endpoint that flags construction permits expiring in ≤30 days, wired it to the Next.js UI, hardened the CI hooks (husky + pre‑push) and wrapped AI‑driven services with Sentry. The changes tighten the product’s operational safety and give us real‑time visibility into AI failures.


The Problem

Our ventas module needed two things that were breaking the user experience:

  1. No visibility on permits that were about to expire. Contractors kept receiving “surprise” emails when a permit lapsed, causing delays and manual follow‑ups. The symptom was a missing UI element on the “Control de Obra” page and no API to surface the data.

  2. Uncaught AI errors. The legal‑assistant, CRM‑assistant, and virtual‑tour services called Groq’s LLM without any error telemetry. When the provider throttled or returned malformed JSON, the API crashed and we had no trace in Sentry.

Compounding the issue, our CI pipeline started failing on Vercel because the prepare script for husky threw an error, and the pre‑push hook referenced lint scripts that no longer existed.


What I Tried First

1️⃣ Exposing permits via a static JSON file

I quickly dropped a permits.json in the repo and made the controller read it. It worked locally but broke on Vercel (no write access) and didn’t reflect real‑time DB changes.

2️⃣ Adding a global try/catch in the AI controllers

I wrapped the whole controller body in a generic catch (e) { console.error(e); }. The errors were logged to the console, but Sentry never saw them, and the response still returned a 500 without context.

3️⃣ Removing husky entirely

To get the build passing I commented out the prepare script. That silenced the Vercel error, but we lost Git‑hook enforcement, which is critical for code quality.

All three approaches solved the immediate symptom but introduced new problems (stale data, missing observability, loss of linting). I needed a proper, maintainable solution.


The Implementation

1️⃣ New Priority‑Actions endpoint (NestJS)

File: apps/api/src/priority-actions/priority-actions.controller.ts

// apps/api/src/priority-actions/priority-actions.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { db } from '../db/db.js';

@Controller('priority-actions')
export class PriorityActionsController {
  @Get('permits')
  async getExpiringPermits(@Query('days') days = 30) {
    // 1️⃣ Fetch permits whose expiration ≤ days from now
    const result = await db<any>`
      SELECT id, contractor_id, expiration_date
      FROM permits
      WHERE expiration_date <= DATE_ADD(CURRENT_DATE, INTERVAL ${days} DAY)
        AND status = 'active'
    `;

    // 2️⃣ Map to UI‑friendly shape
    return result.map(p => ({
      id: p.id,
      contractorId: p.contractor_id,
      expiresIn: Math.ceil(
        (new Date(p.expiration_date).getTime() -
         Date.now()) / (1000 * 60 * 60 * 24),
      ),
    }));
  }
}
Enter fullscreen mode Exit fullscreen mode

Why this shape? The UI only needs the ID, contractor reference, and days‑to‑expire. By computing expiresIn on the server we keep the client simple and avoid timezone bugs.

2️⃣ Consuming the endpoint in Next.js

File: apps/web/src/app/ventas/desarrollos/[id]/obra/page.tsx

// apps/web/src/app/ventas/desarrollos/[id]/obra/page.tsx
import { useEffect, useState } from 'react';
import { fetchJson } from '@/lib/fetchJson';

export default function ControlObraPage({ params }) {
  const devId = params.id;
  const [project, setProject] = useState(null);
  const [expiringPermits, setExpiringPermits] = useState([]);

  // Existing fetch for project data
  async function loadProject() {
    const data = await fetchJson(`/ventas/desarrollos/${devId}/obra`);
    setProject(data);
  }

  // New fetch for permits
  async function loadExpiringPermits() {
    const data = await fetchJson(
      `/priority-actions/permits?days=30`,
    );
    setExpiringPermits(data);
  }

  useEffect(() => {
    loadProject();
    loadExpiringPermits();
  }, [devId]);

  return (
    <section>
      {/* Existing UI */}
      {project && <ProjectDetails data={project} />}

      {/* New alert UI */}
      {expiringPermits.length > 0 && (
        <div className="alert alert-warning mt-4">
          <h4>Permisos próximos a vencer</h4>
          <ul>
            {expiringPermits.map(p => (
              <li key={p.id}>
                Permit #{p.id} – {p.expiresIn} día(s) restante(s)
              </li>
            ))}
          </ul>
        </div>
      )}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key points

  • fetchJson is a thin wrapper around fetch that throws on non‑2xx responses, keeping error handling consistent.
  • The component now loads both the project and the permit alerts in parallel, reducing perceived latency.

3️⃣ Fixing the role badge color bug

File: apps/web/src/app/_components/CrmShellCondos.tsx

// apps/web/src/app/_components/CrmShellCondos.tsx
// Before (hard‑coded green)
const BADGE_COLOR = "green";

// After – use role‑based mapping, fallback to neutral
const ROLE_COLOR_MAP = {
  admin: "red",
  manager: "blue",
  contractor: "orange",
};

function getBadgeColor(role: string) {
  return ROLE_COLOR_MAP[role] ?? "gray";
}
Enter fullscreen mode Exit fullscreen mode

Only one line changed, but now the badge respects the role rather than forcing a green background.

4️⃣ Making husky robust for Vercel builds

File: package.json

{
  "scripts": {
-   "prepare": "husky",
+   "prepare": "husky || true",
    "version": "changeset version",
    "release": "changeset release"
  }
}
Enter fullscreen mode Exit fullscreen mode

The || true ensures the prepare step never fails the build if husky cannot install (e.g., missing Git hooks on Vercel). The hook still runs locally.

5️⃣ Sentry integration for AI services

Files modified:

  • apps/api/src/asistente-legal/asistente-legal.controller.ts
  • apps/api/src/ventas/asistente-crm.controller.ts
  • apps/api/src/virtual-tour/virtual-tour.service.ts

Common snippet added to each:

import * as Sentry from '@sentry/node';

...

try {
  const response = await callGroq(prompt, key);
  return response;
} catch (err) {
  // Capture the raw error and contextual data
  Sentry.captureException(err, {
    tags: { service: 'asistente-legal' },
    extra: { prompt, userId: req.user.id },
  });
  // Re‑throw a generic HTTP error so the client gets a clean message
  throw new HttpException(
    'AI service unavailable, try again later.',
    HttpStatus.SERVICE_UNAVAILABLE,
  );
}
Enter fullscreen mode Exit fullscreen mode

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-10-01

#playadev #buildinpublic

Top comments (0)