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:
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.
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),
),
}));
}
}
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>
);
}
Key points
-
fetchJsonis a thin wrapper aroundfetchthat 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";
}
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"
}
}
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.tsapps/api/src/ventas/asistente-crm.controller.tsapps/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,
);
}
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)