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>
594 lines
19 KiB
TypeScript
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;
|
|
}
|
|
}
|