Files
jorgecuadros-platform/apps/api/src/properties/properties.service.ts
T
rmancinasandClaude Opus 4.8 afe2411c86
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m37s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m8s
feat(storage): wire MinIO/S3 document upload & download into the API + web
The schema has carried `storageKey` pointers and the migration has written
blobs to MinIO since day one, but the API had no S3 client — documents could
only be deleted, never uploaded or retrieved. This adds the missing wiring.

API
- StorageModule/StorageService (@aws-sdk/client-s3, path-style for MinIO):
  put/getStream/delete, best-effort bucket ensure on boot, gracefully disabled
  when S3 env is absent (ServiceUnavailable on use).
- Reads S3_ENDPOINT/S3_BUCKET + S3_ACCESS_KEY/S3_SECRET_KEY, falling back to
  MINIO_ROOT_USER/MINIO_ROOT_PASSWORD so one credential set drives both the
  migration and the API.
- Property service documents: POST :id/documents (multipart), GET
  :id/documents/:childId/download (streamed), delete now also drops the blob.
- Policy documents: same upload/download/delete (previously had none).
- Keys stay under the service/<id>/… and policy/<id>/… prefixes the migration
  established.

Web
- api.ts: shared uploadFile() helper (uploadIngest refactored onto it),
  upload/download/remove helpers for property & policy documents.
- Servicios, polizas, clientes detail pages: real Descargar links and an
  upload control (gated by policy:update / property:update) replacing the
  "storage pending" notes.

Infra
- docker-compose: minio service (9000/9001, healthcheck, named volume) + S3
  env wired into the api service.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 19:19:57 -07:00

594 lines
19 KiB
TypeScript

import { Injectable, NotFoundException } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { Prisma, ServiceKind } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { StorageService } from "../storage/storage.service";
import { extForUpload } from "../storage/upload-file";
import { toDate } from "../common/coerce";
import {
CreatePropertyDto,
ServiceDto,
TrustDto,
UpdatePropertyDto,
UpdateServiceDto,
} from "./property.dto";
/**
* Trust (fideicomiso) renewal buckets, derived from `trustAccount.dueDate2`
* against today. The migration loaded DATMEX's `vence1`/`vence2` pair as
* `dueDate1`/`dueDate2`; on 531 of the 541 dated trusts `dueDate2` is exactly
* one year after `dueDate1`, so `dueDate2` is the *next* annual due date — the
* one staff chase — and `dueDate1` is the period it renewed from.
*
* `undated` is a real bucket, not an error: 12 trusts carry no dates at all.
*/
export type TrustStatus = "active" | "expiring" | "expired" | "undated";
/** `with`/`without` filter on the whole property set; the rest are trust buckets. */
export type TrustFilter = "with" | "without" | TrustStatus;
export type PropertySort =
| "customer"
| "address"
| "services_desc"
| "trust_due_asc"
| "trust_due_desc";
export interface ListParams {
query?: string;
page: number;
pageSize: number;
serviceKind?: ServiceKind;
/** Municipality from the predial service's notes — see `facets()`. */
municipality?: string;
bank?: string;
trust?: TrustFilter;
/** false = properties with no service rows at all (240 of 1519). */
hasServices?: boolean;
customerId?: string;
/** Window in days for the `expiring` trust bucket. */
days: number;
includeArchived?: boolean;
sort: PropertySort;
}
/** Municipality lives in the predial service's `notes` (939/939 populated,
* exactly three values). FEDERAL_ZONE's notes hold the same idea but also
* carry non-municipality values like "SUSPENDIDO", so predial is the source. */
const MUNICIPALITY_KIND: ServiceKind = "PROPERTY_TAX";
/** Midnight today, UTC — trust dates are stored date-only at 00:00 UTC. */
function today(): Date {
const now = new Date();
return new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()),
);
}
function addDays(d: Date, days: number): Date {
return new Date(d.getTime() + days * 86400000);
}
function trustStatusOf(
dueDate: Date | null | undefined,
from: Date,
soon: Date,
): TrustStatus {
if (!dueDate) return "undated";
if (dueDate < from) return "expired";
return dueDate <= soon ? "expiring" : "active";
}
function daysUntil(dueDate: Date | null | undefined, from: Date): number | null {
if (!dueDate) return null;
return Math.round((dueDate.getTime() - from.getTime()) / 86400000);
}
@Injectable()
export class PropertiesService {
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
private trustWhere(
trust: TrustFilter | undefined,
days: number,
): Prisma.PropertyWhereInput {
const from = today();
switch (trust) {
case "with":
return { trustAccount: { isNot: null } };
case "without":
return { trustAccount: { is: null } };
case "active":
return { trustAccount: { dueDate2: { gte: from } } };
case "expiring":
return {
trustAccount: { dueDate2: { gte: from, lte: addDays(from, days) } },
};
case "expired":
return { trustAccount: { dueDate2: { lt: from } } };
case "undated":
return { trustAccount: { is: { dueDate2: null } } };
default:
return {};
}
}
private orderBy(sort: PropertySort): Prisma.PropertyOrderByWithRelationInput[] {
switch (sort) {
case "address":
return [{ addressLine1: "asc" }, { addressLine2: "asc" }];
case "services_desc":
return [{ services: { _count: "desc" } }, { customer: { name: "asc" } }];
case "trust_due_asc":
return [{ trustAccount: { dueDate2: "asc" } }];
case "trust_due_desc":
return [{ trustAccount: { dueDate2: "desc" } }];
default:
// Nameless customers last, same rule the customer list uses.
return [
{ customer: { nameMissing: "asc" } },
{ customer: { name: "asc" } },
{ addressLine1: "asc" },
];
}
}
/** Property list with search, service/trust/municipality filters, paginated. */
async list(params: ListParams) {
const {
query,
page,
pageSize,
serviceKind,
municipality,
bank,
trust,
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.
if (sort === "trust_due_asc" || sort === "trust_due_desc") {
and.push({ trustAccount: { isNot: null } });
}
if (query && query.trim()) {
const q = query.trim();
and.push({
OR: [
{ addressLine1: { contains: q } },
{ addressLine2: { contains: q } },
{ phone1: { contains: q } },
{ phone2: { contains: q } },
{ phone3: { contains: q } },
{ zone: { contains: q } },
{ legacyId: { contains: q } },
{ customer: { name: { contains: q } } },
{ services: { some: { accountNumber: { contains: q } } } },
{ services: { some: { meterNumber: { contains: q } } } },
{ trustAccount: { trustNumber: { contains: q } } },
],
});
}
if (serviceKind) and.push({ services: { some: { kind: serviceKind } } });
if (municipality)
and.push({
services: { some: { kind: MUNICIPALITY_KIND, notes: municipality } },
});
if (bank) and.push({ trustAccount: { bankName: bank } });
if (hasServices !== undefined)
and.push(hasServices ? { services: { some: {} } } : { services: { none: {} } });
if (customerId) and.push({ customerId });
const where: Prisma.PropertyWhereInput = { AND: and };
const [total, rows] = await this.prisma.$transaction([
this.prisma.property.count({ where }),
this.prisma.property.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: this.orderBy(sort),
select: {
id: true,
addressLine1: true,
addressLine2: true,
phone1: true,
phone2: true,
phone3: true,
zone: true,
archivedAt: true,
customer: {
select: { id: true, name: true, city: true, state: true },
},
services: {
select: { id: true, kind: true, active: true, notes: true },
},
trustAccount: {
select: {
bankName: true,
trustNumber: true,
bankFee: true,
dueDate1: true,
dueDate2: true,
},
},
_count: { select: { services: true, documents: true } },
},
}),
]);
const from = today();
const soon = addDays(from, days);
const items = rows.map((r) => {
const predial = r.services.find((s) => s.kind === MUNICIPALITY_KIND);
return {
id: r.id,
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,
customerCity: r.customer.city,
customerState: r.customer.state,
municipality: predial?.notes ?? null,
services: r.services.map((s) => ({
id: s.id,
kind: s.kind,
active: s.active,
})),
serviceCount: r._count.services,
activeServiceCount: r.services.filter((s) => s.active).length,
documentCount: r._count.documents,
trust: r.trustAccount
? {
bankName: r.trustAccount.bankName,
trustNumber: r.trustAccount.trustNumber,
bankFee: r.trustAccount.bankFee,
dueDate1: r.trustAccount.dueDate1,
dueDate2: r.trustAccount.dueDate2,
status: trustStatusOf(r.trustAccount.dueDate2, from, soon),
daysToDue: daysUntil(r.trustAccount.dueDate2, from),
}
: null,
};
});
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
}
/** Top-line counts for the utilities page header. */
async stats(days: number) {
const from = today();
const soon = addDays(from, days);
const [
properties,
owners,
services,
withoutServices,
trusts,
trustExpiring,
trustExpired,
documents,
] = await this.prisma.$transaction([
this.prisma.property.count(),
this.prisma.customer.count({ where: { properties: { some: {} } } }),
this.prisma.propertyService.count(),
this.prisma.property.count({ where: { services: { none: {} } } }),
this.prisma.property.count({ where: { trustAccount: { isNot: null } } }),
this.prisma.property.count({
where: { trustAccount: { dueDate2: { gte: from, lte: soon } } },
}),
this.prisma.property.count({
where: { trustAccount: { dueDate2: { lt: from } } },
}),
this.prisma.serviceDocument.count(),
]);
// Service mix, per kind — the operational headline for this line of
// business (how many bills of each type the office pays every month).
const byKind = await this.prisma.propertyService.groupBy({
by: ["kind"],
_count: { _all: true },
orderBy: { _count: { kind: "desc" } },
});
const activeByKind = await this.prisma.propertyService.groupBy({
by: ["kind"],
where: { active: true },
_count: { _all: true },
});
const activeMap = new Map(activeByKind.map((r) => [r.kind, r._count._all]));
return {
properties,
owners,
services,
withoutServices,
trusts,
trustExpiring,
trustExpired,
documents,
days,
byKind: byKind.map((r) => ({
kind: r.kind,
count: r._count._all,
active: activeMap.get(r.kind) ?? 0,
})),
};
}
/** Filter dropdown options, with counts so empty choices are visible. */
async facets() {
// Kept as separate awaits rather than one $transaction: Prisma's groupBy
// result type is lost when the calls are widened into a promise array.
const kinds = await this.prisma.propertyService.groupBy({
by: ["kind"],
_count: { _all: true },
orderBy: { _count: { kind: "desc" } },
});
const municipalities = await this.prisma.propertyService.groupBy({
by: ["notes"],
where: { kind: MUNICIPALITY_KIND, notes: { not: null } },
_count: { _all: true },
orderBy: { _count: { notes: "desc" } },
});
const banks = await this.prisma.trustAccount.groupBy({
by: ["bankName"],
where: { bankName: { not: null } },
_count: { _all: true },
orderBy: { _count: { bankName: "desc" } },
});
return {
kinds: kinds.map((k) => ({ kind: k.kind, count: k._count._all })),
municipalities: municipalities.map((m) => ({
name: m.notes as string,
count: m._count._all,
})),
banks: banks.map((b) => ({
name: b.bankName as string,
count: b._count._all,
})),
};
}
/** Full property view: services, trust, documents, owner and siblings. */
async detail(id: string, days: number) {
const property = await this.prisma.property.findUnique({
where: { id },
include: {
customer: {
select: {
id: true,
name: true,
nameSource: true,
addressLine1: true,
city: true,
state: true,
phone: true,
mobile: true,
email: true,
_count: { select: { properties: true, policies: true } },
},
},
services: { orderBy: { kind: "asc" } },
trustAccount: true,
documents: true,
policy: {
select: {
id: true,
policyNumber: true,
policyTo: true,
policyType: { select: { name: true } },
},
},
},
});
if (!property) {
throw new NotFoundException(`Property ${id} not found`);
}
// Other properties of the same owner, so staff can hop between them
// without going back through the customer file.
const siblings = await this.prisma.property.findMany({
where: { customerId: property.customerId, id: { not: id } },
orderBy: [{ addressLine1: "asc" }],
select: {
id: true,
addressLine1: true,
addressLine2: true,
zone: true,
_count: { select: { services: true } },
},
});
// Utility-domain ledger for the OWNER, not for this property: the legacy
// data ties payments to the customer, never to a specific property, so
// these are shown as the customer's service movements.
const transactions = await this.prisma.transaction.findMany({
where: { customerId: property.customerId, domain: "UTILITY" },
orderBy: { transactionDate: "desc" },
take: 12,
include: { type: true },
});
const ledger = await this.prisma.transaction.groupBy({
by: ["currency"],
where: { customerId: property.customerId, domain: "UTILITY", voidedAt: null },
_sum: { amount: true },
_count: { _all: true },
});
const from = today();
const predial = property.services.find((s) => s.kind === MUNICIPALITY_KIND);
return {
...property,
municipality: predial?.notes ?? null,
trustStatus: trustStatusOf(
property.trustAccount?.dueDate2,
from,
addDays(from, days),
),
daysToTrustDue: daysUntil(property.trustAccount?.dueDate2, from),
siblings: siblings.map((s) => ({
id: s.id,
addressLine1: s.addressLine1,
addressLine2: s.addressLine2,
zone: s.zone,
serviceCount: s._count.services,
})),
customerTransactions: transactions,
customerLedger: ledger.map((l) => ({
currency: l.currency,
total: l._sum.amount,
count: l._count._all,
})),
};
}
// --- 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 ------------------------------------------------------------
// The blob lives in object storage (MinIO); the row is just the pointer. Keys
// stay under the `service/<propertyId>/…` prefix the migration established.
async addDocument(
propertyId: string,
file: { buffer: Buffer; originalname?: string; mimetype?: string },
documentType?: string,
) {
await this.ensureProperty(propertyId);
const ext = extForUpload(file);
const key = `service/${propertyId}/${randomUUID()}${ext}`;
await this.storage.put(key, file.buffer, file.mimetype);
return this.prisma.serviceDocument.create({
data: {
propertyId,
documentType: documentType?.trim() || "DOCUMENT",
storageKey: key,
},
});
}
async getDocument(propertyId: string, id: string) {
const row = await this.prisma.serviceDocument.findFirst({
where: { id, propertyId },
});
if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`);
const blob = await this.storage.getStream(row.storageKey);
return { row, ...blob };
}
async removeDocument(propertyId: string, id: string) {
await this.ensureProperty(propertyId);
const row = await this.prisma.serviceDocument.findFirst({
where: { id, propertyId },
select: { id: true, storageKey: true },
});
if (!row) throw new NotFoundException(`Document ${id} not found on property ${propertyId}`);
const deleted = await this.prisma.serviceDocument.delete({ where: { id } });
await this.storage.delete(row.storageKey);
return deleted;
}
}