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:
@@ -1,11 +1,34 @@
|
||||
import { Controller, Get, Param, Query, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ServiceKind } from "@jorgecuadros/database";
|
||||
import { Request } from "express";
|
||||
import { AuthenticatedGuard } from "../auth/authenticated.guard";
|
||||
import { AbilityGuard } from "../auth/ability.guard";
|
||||
import { RequireAbility } from "../auth/require-ability.decorator";
|
||||
import { AuditService } from "../common/audit.service";
|
||||
import {
|
||||
PropertiesService,
|
||||
type PropertySort,
|
||||
type TrustFilter,
|
||||
} from "./properties.service";
|
||||
import {
|
||||
CreatePropertyDto,
|
||||
ServiceDto,
|
||||
TrustDto,
|
||||
UpdatePropertyDto,
|
||||
UpdateServiceDto,
|
||||
} from "./property.dto";
|
||||
|
||||
const KINDS: ServiceKind[] = [
|
||||
"WATER",
|
||||
@@ -35,15 +58,21 @@ const SORTS: PropertySort[] = [
|
||||
"trust_due_desc",
|
||||
];
|
||||
|
||||
/** Clamped trust-renewal window; 30 days matches the policies module. */
|
||||
function parseDays(days?: string): number {
|
||||
return Math.min(365, Math.max(1, Number(days) || 30));
|
||||
}
|
||||
|
||||
@UseGuards(AuthenticatedGuard)
|
||||
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
||||
@Controller("properties")
|
||||
export class PropertiesController {
|
||||
constructor(private readonly properties: PropertiesService) {}
|
||||
constructor(
|
||||
private readonly properties: PropertiesService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
private actingId(req: Request): string {
|
||||
return (req.user as { id: string }).id;
|
||||
}
|
||||
|
||||
@Get("stats")
|
||||
stats(@Query("days") days?: string) {
|
||||
@@ -67,6 +96,7 @@ export class PropertiesController {
|
||||
@Query("hasServices") hasServices?: string,
|
||||
@Query("customerId") customerId?: string,
|
||||
@Query("days") days?: string,
|
||||
@Query("includeArchived") includeArchived?: string,
|
||||
@Query("sort") sort?: string,
|
||||
) {
|
||||
return this.properties.list({
|
||||
@@ -85,6 +115,7 @@ export class PropertiesController {
|
||||
hasServices === "true" ? true : hasServices === "false" ? false : undefined,
|
||||
customerId: customerId || undefined,
|
||||
days: parseDays(days),
|
||||
includeArchived: includeArchived === "true",
|
||||
sort: SORTS.includes(sort as PropertySort)
|
||||
? (sort as PropertySort)
|
||||
: "customer",
|
||||
@@ -95,4 +126,81 @@ export class PropertiesController {
|
||||
detail(@Param("id") id: string, @Query("days") days?: string) {
|
||||
return this.properties.detail(id, parseDays(days));
|
||||
}
|
||||
|
||||
// --- header writes --------------------------------------------------------
|
||||
|
||||
@Post()
|
||||
@RequireAbility("property:create")
|
||||
async create(@Body() dto: CreatePropertyDto, @Req() req: Request) {
|
||||
const p = await this.properties.create(dto);
|
||||
void this.audit.log(this.actingId(req), "property.create", { propertyId: p.id });
|
||||
return p;
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@RequireAbility("property:update")
|
||||
async update(@Param("id") id: string, @Body() dto: UpdatePropertyDto, @Req() req: Request) {
|
||||
const p = await this.properties.update(id, dto);
|
||||
void this.audit.log(this.actingId(req), "property.update", { propertyId: id });
|
||||
return p;
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@RequireAbility("property:delete")
|
||||
async archive(@Param("id") id: string, @Req() req: Request) {
|
||||
const p = await this.properties.archive(id);
|
||||
void this.audit.log(this.actingId(req), "property.archive", { propertyId: id });
|
||||
return p;
|
||||
}
|
||||
|
||||
@Post(":id/restore")
|
||||
@RequireAbility("property:delete")
|
||||
async restore(@Param("id") id: string, @Req() req: Request) {
|
||||
const p = await this.properties.restore(id);
|
||||
void this.audit.log(this.actingId(req), "property.restore", { propertyId: id });
|
||||
return p;
|
||||
}
|
||||
|
||||
// --- services (property:update) -------------------------------------------
|
||||
|
||||
@Post(":id/services")
|
||||
@RequireAbility("property:update")
|
||||
addService(@Param("id") id: string, @Body() dto: ServiceDto) {
|
||||
return this.properties.addService(id, dto);
|
||||
}
|
||||
@Patch(":id/services/:childId")
|
||||
@RequireAbility("property:update")
|
||||
updateService(
|
||||
@Param("id") id: string,
|
||||
@Param("childId") childId: string,
|
||||
@Body() dto: UpdateServiceDto,
|
||||
) {
|
||||
return this.properties.updateService(id, childId, dto);
|
||||
}
|
||||
@Delete(":id/services/:childId")
|
||||
@RequireAbility("property:update")
|
||||
removeService(@Param("id") id: string, @Param("childId") childId: string) {
|
||||
return this.properties.removeService(id, childId);
|
||||
}
|
||||
|
||||
// --- trust account (1:1) --------------------------------------------------
|
||||
|
||||
@Put(":id/trust")
|
||||
@RequireAbility("property:update")
|
||||
upsertTrust(@Param("id") id: string, @Body() dto: TrustDto) {
|
||||
return this.properties.upsertTrust(id, dto);
|
||||
}
|
||||
@Delete(":id/trust")
|
||||
@RequireAbility("property:update")
|
||||
removeTrust(@Param("id") id: string) {
|
||||
return this.properties.removeTrust(id);
|
||||
}
|
||||
|
||||
// --- documents (remove pointer only) --------------------------------------
|
||||
|
||||
@Delete(":id/documents/:childId")
|
||||
@RequireAbility("property:update")
|
||||
removeDocument(@Param("id") id: string, @Param("childId") childId: string) {
|
||||
return this.properties.removeDocument(id, childId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from "class-validator";
|
||||
import { ServiceKind } from "@jorgecuadros/database";
|
||||
|
||||
export class CreatePropertyDto {
|
||||
@IsString() @MinLength(1) customerId!: string;
|
||||
@IsOptional() @IsString() policyId?: string;
|
||||
@IsOptional() @IsString() addressLine1?: string;
|
||||
@IsOptional() @IsString() addressLine2?: string;
|
||||
@IsOptional() @IsString() phone1?: string;
|
||||
@IsOptional() @IsString() phone2?: string;
|
||||
@IsOptional() @IsString() phone3?: string;
|
||||
@IsOptional() @IsString() zone?: string;
|
||||
}
|
||||
|
||||
export class UpdatePropertyDto {
|
||||
@IsOptional() @IsString() policyId?: string;
|
||||
@IsOptional() @IsString() addressLine1?: string;
|
||||
@IsOptional() @IsString() addressLine2?: string;
|
||||
@IsOptional() @IsString() phone1?: string;
|
||||
@IsOptional() @IsString() phone2?: string;
|
||||
@IsOptional() @IsString() phone3?: string;
|
||||
@IsOptional() @IsString() zone?: string;
|
||||
}
|
||||
|
||||
export class ServiceDto {
|
||||
@IsEnum(ServiceKind) kind!: ServiceKind;
|
||||
@IsOptional() @IsString() accountNumber?: string;
|
||||
@IsOptional() @IsString() meterNumber?: string;
|
||||
@IsOptional() @IsString() route?: string;
|
||||
@IsOptional() @IsString() dueDay?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
export class UpdateServiceDto {
|
||||
@IsOptional() @IsEnum(ServiceKind) kind?: ServiceKind;
|
||||
@IsOptional() @IsString() accountNumber?: string;
|
||||
@IsOptional() @IsString() meterNumber?: string;
|
||||
@IsOptional() @IsString() route?: string;
|
||||
@IsOptional() @IsString() dueDay?: string;
|
||||
@IsOptional() @IsBoolean() active?: boolean;
|
||||
@IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
/** Trust is 1:1 with a property — this both creates and updates it (upsert). */
|
||||
export class TrustDto {
|
||||
@IsOptional() @IsString() bankName?: string;
|
||||
@IsOptional() @IsString() trustNumber?: string;
|
||||
@IsOptional() @IsNumber() bankFee?: number;
|
||||
@IsOptional() @IsString() dueDate1?: string;
|
||||
@IsOptional() @IsString() dueDate2?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user