feat(properties): CRUD + service/trust/document editors (plan phase 4)

Utilities section becomes create/edit/archive-able, with its child data.

API:
- Property gains archivedAt (soft-delete); list/browser default to
  archivedAt=null with ?includeArchived opt-in.
- PropertiesService: header create/update/archive/restore (customer FK
  validated); PropertyService add/update/remove scoped to the property;
  TrustAccount upsert (1:1) + remove; ServiceDocument pointer delete.
- Controller write routes: create needs STAFF+ (property:create), archive
  MANAGER+ (property:delete), every service/trust/document route
  property:update. Mutations audited. DTOs added.
- Document *upload* deliberately deferred: it needs the object-storage
  client wired into the API (today only the migration writes to MinIO);
  removing an existing pointer row is supported and the UI says so.

Web:
- PropertyForm (header) with CustomerPicker; /servicios/nuevo (accepts
  ?customerId prefill) and /servicios/[id]/editar.
- Property detail: gated action bar (Editar/Archivar) + "Administrar
  propiedad" — services via the shared ChildCollection editor, an inline
  1:1 TrustEditor (create/update/clear), and document-row delete.
- "Nueva propiedad" buttons on the list and customer detail (prefilled).
  api.ts + types for all of it.

Verified against dev: property create (archivedAt null), service
add/update, VIEWER service-add 403, trust upsert (create then update the
same row), trust/service remove, cross-property child guard 404, archive
drops from the default list and includeArchived surfaces it. Both apps
compile clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 12:27:53 -07:00
co-authored by Claude Opus 4.8
parent 7a46c30d9b
commit 506f8ce684
12 changed files with 922 additions and 15 deletions
@@ -1,6 +1,14 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, ServiceKind } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { toDate } from "../common/coerce";
import {
CreatePropertyDto,
ServiceDto,
TrustDto,
UpdatePropertyDto,
UpdateServiceDto,
} from "./property.dto";
/**
* Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2`
@@ -37,6 +45,7 @@ export interface ListParams {
customerId?: string;
/** Window in days for the `expiring` trust bucket. */
days: number;
includeArchived?: boolean;
sort: PropertySort;
}
@@ -134,11 +143,14 @@ export class PropertiesService {
hasServices,
customerId,
days,
includeArchived,
sort,
} = params;
const and: Prisma.PropertyWhereInput[] = [this.trustWhere(trust, days)];
if (!includeArchived) and.push({ archivedAt: null });
// Sorting by trust due date is only meaningful for properties that have a
// trust; MySQL would otherwise float the ~966 trust-less rows (NULL first
// on ASC) above every real due date. Scoping is explicit in the UI label.
@@ -191,6 +203,7 @@ export class PropertiesService {
phone2: true,
phone3: true,
zone: true,
archivedAt: true,
customer: {
select: { id: true, name: true, city: true, state: true },
},
@@ -221,6 +234,7 @@ export class PropertiesService {
addressLine1: r.addressLine1,
addressLine2: r.addressLine2,
zone: r.zone,
archived: r.archivedAt != null,
phones: [r.phone1, r.phone2, r.phone3].filter(Boolean) as string[],
customerId: r.customer.id,
customerName: r.customer.name,
@@ -443,4 +457,102 @@ export class PropertiesService {
})),
};
}
// --- property header writes -----------------------------------------------
async create(dto: CreatePropertyDto) {
const customer = await this.prisma.customer.findUnique({
where: { id: dto.customerId },
select: { id: true },
});
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
return this.prisma.property.create({ data: { ...dto } });
}
async update(id: string, dto: UpdatePropertyDto) {
await this.ensureProperty(id);
return this.prisma.property.update({ where: { id }, data: { ...dto } });
}
async archive(id: string) {
await this.ensureProperty(id);
return this.prisma.property.update({ where: { id }, data: { archivedAt: new Date() } });
}
async restore(id: string) {
await this.ensureProperty(id);
return this.prisma.property.update({ where: { id }, data: { archivedAt: null } });
}
private async ensureProperty(id: string) {
const found = await this.prisma.property.findUnique({
where: { id },
select: { id: true },
});
if (!found) throw new NotFoundException(`Property ${id} not found`);
}
private async ensureService(propertyId: string, serviceId: string) {
await this.ensureProperty(propertyId);
const row = await this.prisma.propertyService.findFirst({
where: { id: serviceId, propertyId },
select: { id: true },
});
if (!row) throw new NotFoundException(`Service ${serviceId} not found on property ${propertyId}`);
}
// --- services -------------------------------------------------------------
async addService(propertyId: string, dto: ServiceDto) {
await this.ensureProperty(propertyId);
return this.prisma.propertyService.create({ data: { propertyId, ...dto } });
}
async updateService(propertyId: string, id: string, dto: UpdateServiceDto) {
await this.ensureService(propertyId, id);
return this.prisma.propertyService.update({ where: { id }, data: { ...dto } });
}
async removeService(propertyId: string, id: string) {
await this.ensureService(propertyId, id);
return this.prisma.propertyService.delete({ where: { id } });
}
// --- trust account (1:1 upsert) -------------------------------------------
async upsertTrust(propertyId: string, dto: TrustDto) {
await this.ensureProperty(propertyId);
const data = {
bankName: dto.bankName,
trustNumber: dto.trustNumber,
bankFee: dto.bankFee,
...(dto.dueDate1 !== undefined && { dueDate1: toDate(dto.dueDate1) }),
...(dto.dueDate2 !== undefined && { dueDate2: toDate(dto.dueDate2) }),
};
return this.prisma.trustAccount.upsert({
where: { propertyId },
create: { propertyId, ...data },
update: data,
});
}
async removeTrust(propertyId: string) {
await this.ensureProperty(propertyId);
const existing = await this.prisma.trustAccount.findUnique({
where: { propertyId },
select: { id: true },
});
if (!existing) throw new NotFoundException(`No trust account on property ${propertyId}`);
return this.prisma.trustAccount.delete({ where: { propertyId } });
}
// --- documents ------------------------------------------------------------
// Removing a pointer row only; uploading files needs the object-storage
// client wired into the API (today only the migration writes to MinIO).
async removeDocument(propertyId: string, id: string) {
await this.ensureProperty(propertyId);
const row = await this.prisma.serviceDocument.findFirst({
where: { id, propertyId },
select: { id: true },
});
if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`);
return this.prisma.serviceDocument.delete({ where: { id } });
}
}