Files
jorgecuadros-platform/apps/api/src/policies/policies.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

580 lines
19 KiB
TypeScript

import { Injectable, NotFoundException } from "@nestjs/common";
import { randomUUID } from "node:crypto";
import { Prisma } from "@jorgecuadros/database";
import { PrismaService } from "../prisma/prisma.service";
import { StorageService } from "../storage/storage.service";
import { extForUpload, type UploadedFileLike } from "../storage/upload-file";
import { toDate } from "../common/coerce";
import { CreatePolicyDto, UpdatePolicyDto } from "./policy.dto";
import {
BeneficiaryDto,
ClaimDto,
DriverDto,
InstallmentDto,
UpdateBeneficiaryDto,
UpdateClaimDto,
UpdateDriverDto,
UpdateInstallmentDto,
VehicleDto,
} from "./children.dto";
import {
AdjusterDto,
PolicyTypeDto,
ProviderDto,
UpdateAdjusterDto,
UpdatePolicyTypeDto,
UpdateProviderDto,
} from "./lookup.dto";
/**
* Vigencia buckets, derived from `policyTo` against today. `undated` is a real
* bucket rather than an error case: 528 of the migrated policies carry no end
* date at all (the legacy Access tables left it blank), so they can neither be
* called current nor expired.
*/
export type PolicyStatus = "active" | "expiring" | "expired" | "undated";
export type PolicySort =
| "expiry_desc"
| "expiry_asc"
| "customer"
| "number"
| "premium_desc";
export interface ListParams {
query?: string;
page: number;
pageSize: number;
status?: PolicyStatus;
/** Window in days for the `expiring` bucket. */
days: number;
typeId?: string;
providerId?: string;
liquidated?: boolean;
includeArchived?: boolean;
sort: PolicySort;
}
/** Midnight today, UTC — policy 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 statusOf(policyTo: Date | null, from: Date, soon: Date): PolicyStatus {
if (!policyTo) return "undated";
if (policyTo < from) return "expired";
return policyTo <= soon ? "expiring" : "active";
}
function daysUntil(policyTo: Date | null, from: Date): number | null {
if (!policyTo) return null;
return Math.round((policyTo.getTime() - from.getTime()) / 86400000);
}
@Injectable()
export class PoliciesService {
constructor(
private readonly prisma: PrismaService,
private readonly storage: StorageService,
) {}
private statusWhere(
status: PolicyStatus | undefined,
days: number,
): Prisma.PolicyWhereInput {
const from = today();
switch (status) {
case "active":
return { policyTo: { gte: from } };
case "expiring":
return { policyTo: { gte: from, lte: addDays(from, days) } };
case "expired":
return { policyTo: { lt: from } };
case "undated":
return { policyTo: null };
default:
return {};
}
}
private orderBy(sort: PolicySort): Prisma.PolicyOrderByWithRelationInput[] {
switch (sort) {
case "expiry_asc":
return [{ policyTo: "asc" }];
case "customer":
return [{ customer: { name: "asc" } }, { policyTo: "desc" }];
case "number":
return [{ policyNumber: "asc" }];
case "premium_desc":
// Sorts on netPremium, not total: `total` is 0 or null on all but 2 of
// the 2378 migrated policies, so ordering by it is meaningless.
return [{ netPremium: "desc" }];
default:
// MySQL sorts NULLs last on DESC, which puts the 528 undated policies
// at the end instead of the top — the behaviour we want by default.
return [{ policyTo: "desc" }];
}
}
/** Policy list with search, vigencia/type/provider filters, paginated. */
async list(params: ListParams) {
const { query, page, pageSize, status, days, typeId, providerId, liquidated,
includeArchived, sort } = params;
const where: Prisma.PolicyWhereInput = { ...this.statusWhere(status, days) };
if (!includeArchived) where.archivedAt = null;
if (query && query.trim()) {
const q = query.trim();
where.OR = [
{ policyNumber: { contains: q } },
{ customer: { name: { contains: q } } },
{ agentName: { contains: q } },
{ vehicles: { some: { licensePlate: { contains: q } } } },
{ insuredDrivers: { some: { fullName: { contains: q } } } },
{ legacyId: { contains: q } },
];
}
if (typeId) where.policyTypeId = typeId;
if (providerId) where.insuranceProviderId = providerId;
if (liquidated !== undefined) where.liquidated = liquidated;
const [total, rows] = await this.prisma.$transaction([
this.prisma.policy.count({ where }),
this.prisma.policy.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: this.orderBy(sort),
select: {
id: true,
policyNumber: true,
agentName: true,
policyFrom: true,
policyTo: true,
netPremium: true,
total: true,
currency: true,
liquidated: true,
archivedAt: true,
customer: { select: { id: true, name: true, city: true } },
policyType: { select: { id: true, name: true } },
insuranceProvider: { select: { id: true, name: true } },
_count: { select: { vehicles: true, installments: true, documents: true } },
},
}),
]);
const from = today();
const soon = addDays(from, days);
const items = rows.map((r) => ({
id: r.id,
policyNumber: r.policyNumber,
agentName: r.agentName,
policyFrom: r.policyFrom,
policyTo: r.policyTo,
netPremium: r.netPremium,
total: r.total,
currency: r.currency,
liquidated: r.liquidated,
archived: r.archivedAt != null,
customerId: r.customer.id,
customerName: r.customer.name,
customerCity: r.customer.city,
policyType: r.policyType,
insuranceProvider: r.insuranceProvider,
status: statusOf(r.policyTo, from, soon),
daysToExpiry: daysUntil(r.policyTo, from),
vehicleCount: r._count.vehicles,
installmentCount: r._count.installments,
documentCount: r._count.documents,
}));
return { items, total, page, pageSize, pageCount: Math.ceil(total / pageSize) };
}
/** Top-line counts for the policies page header. */
async stats(days: number) {
const from = today();
const soon = addDays(from, days);
const [total, active, expiring, expired, undated, liquidated] =
await this.prisma.$transaction([
this.prisma.policy.count(),
this.prisma.policy.count({ where: { policyTo: { gte: from } } }),
this.prisma.policy.count({
where: { policyTo: { gte: from, lte: soon } },
}),
this.prisma.policy.count({ where: { policyTo: { lt: from } } }),
this.prisma.policy.count({ where: { policyTo: null } }),
this.prisma.policy.count({ where: { liquidated: true } }),
]);
// Premium in force, per currency — the two currencies can't be summed.
const inForce = await this.prisma.policy.groupBy({
by: ["currency"],
where: { policyTo: { gte: from } },
_sum: { total: true, netPremium: true },
_count: { _all: true },
});
return {
total,
active,
expiring,
expired,
undated,
liquidated,
pending: total - liquidated,
days,
premiumInForce: inForce.map((r) => ({
currency: r.currency,
total: r._sum.total,
netPremium: r._sum.netPremium,
count: r._count._all,
})),
};
}
/** Filter dropdown options, with counts so empty choices are visible. */
async facets() {
const [types, providers] = await this.prisma.$transaction([
this.prisma.policyType.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, _count: { select: { policies: true } } },
}),
this.prisma.insuranceProvider.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, _count: { select: { policies: true } } },
}),
]);
return {
types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })),
providers: providers.map((p) => ({
id: p.id,
name: p.name,
count: p._count.policies,
})),
};
}
/** Full policy view, including the owning customer. */
async detail(id: string, days: number) {
const policy = await this.prisma.policy.findUnique({
where: { id },
include: {
customer: {
select: {
id: true,
name: true,
nameSource: true,
city: true,
state: true,
phone: true,
mobile: true,
email: true,
},
},
policyType: true,
insuranceProvider: true,
installments: { orderBy: { sequence: "asc" } },
vehicles: true,
insuredDrivers: true,
beneficiaries: true,
claims: { include: { adjuster: true } },
documents: true,
properties: {
select: { id: true, addressLine1: true, addressLine2: true, zone: true },
},
},
});
if (!policy) {
throw new NotFoundException(`Policy ${id} not found`);
}
const from = today();
return {
...policy,
status: statusOf(policy.policyTo, from, addDays(from, days)),
daysToExpiry: daysUntil(policy.policyTo, from),
};
}
// --- policy header writes -------------------------------------------------
private headerData(dto: CreatePolicyDto | UpdatePolicyDto) {
const { policyDate, policyFrom, policyTo, liquidationDate, ...rest } =
dto as CreatePolicyDto;
return {
...rest,
...(policyDate !== undefined && { policyDate: toDate(policyDate) }),
...(policyFrom !== undefined && { policyFrom: toDate(policyFrom) }),
...(policyTo !== undefined && { policyTo: toDate(policyTo) }),
...(liquidationDate !== undefined && { liquidationDate: toDate(liquidationDate) }),
};
}
async create(dto: CreatePolicyDto) {
// Validate the customer FK up front for a clean 404 instead of a raw
// Prisma constraint error.
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.policy.create({
data: {
...this.headerData(dto),
policyNumber: dto.policyNumber,
customerId: dto.customerId,
},
});
}
async update(id: string, dto: UpdatePolicyDto) {
await this.ensurePolicy(id);
return this.prisma.policy.update({ where: { id }, data: this.headerData(dto) });
}
async archive(id: string) {
await this.ensurePolicy(id);
return this.prisma.policy.update({ where: { id }, data: { archivedAt: new Date() } });
}
async restore(id: string) {
await this.ensurePolicy(id);
return this.prisma.policy.update({ where: { id }, data: { archivedAt: null } });
}
private async ensurePolicy(id: string) {
const found = await this.prisma.policy.findUnique({
where: { id },
select: { id: true },
});
if (!found) throw new NotFoundException(`Policy ${id} not found`);
}
// --- child rows -----------------------------------------------------------
// Each child is created under a policy and edited/removed by its own id,
// scoped to that policy so one policy's id can't touch another's rows.
private async ensureChild(
model: "policyPaymentInstallment" | "vehicle" | "insuredDriver" | "policyBeneficiary" | "claim",
policyId: string,
childId: string,
) {
await this.ensurePolicy(policyId);
// @ts-expect-error dynamic delegate access is safe for these known models
const row = await this.prisma[model].findFirst({
where: { id: childId, policyId },
select: { id: true },
});
if (!row) throw new NotFoundException(`Child ${childId} not found on policy ${policyId}`);
}
async addInstallment(policyId: string, dto: InstallmentDto) {
await this.ensurePolicy(policyId);
return this.prisma.policyPaymentInstallment.create({
data: {
policyId,
sequence: dto.sequence,
amount: dto.amount,
currency: dto.currency,
dueDate: toDate(dto.dueDate) ?? undefined,
paidDate: toDate(dto.paidDate) ?? undefined,
checkNumber: dto.checkNumber,
isCash: dto.isCash,
},
});
}
async updateInstallment(policyId: string, id: string, dto: UpdateInstallmentDto) {
await this.ensureChild("policyPaymentInstallment", policyId, id);
return this.prisma.policyPaymentInstallment.update({
where: { id },
data: {
sequence: dto.sequence,
amount: dto.amount,
currency: dto.currency,
...(dto.dueDate !== undefined && { dueDate: toDate(dto.dueDate) }),
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
checkNumber: dto.checkNumber,
isCash: dto.isCash,
},
});
}
async removeInstallment(policyId: string, id: string) {
await this.ensureChild("policyPaymentInstallment", policyId, id);
return this.prisma.policyPaymentInstallment.delete({ where: { id } });
}
async addVehicle(policyId: string, dto: VehicleDto) {
await this.ensurePolicy(policyId);
return this.prisma.vehicle.create({ data: { policyId, ...dto } });
}
async updateVehicle(policyId: string, id: string, dto: VehicleDto) {
await this.ensureChild("vehicle", policyId, id);
return this.prisma.vehicle.update({ where: { id }, data: { ...dto } });
}
async removeVehicle(policyId: string, id: string) {
await this.ensureChild("vehicle", policyId, id);
return this.prisma.vehicle.delete({ where: { id } });
}
async addDriver(policyId: string, dto: DriverDto) {
await this.ensurePolicy(policyId);
return this.prisma.insuredDriver.create({
data: { policyId, ...dto, birthDate: toDate(dto.birthDate) ?? undefined },
});
}
async updateDriver(policyId: string, id: string, dto: UpdateDriverDto) {
await this.ensureChild("insuredDriver", policyId, id);
return this.prisma.insuredDriver.update({
where: { id },
data: { ...dto, ...(dto.birthDate !== undefined && { birthDate: toDate(dto.birthDate) }) },
});
}
async removeDriver(policyId: string, id: string) {
await this.ensureChild("insuredDriver", policyId, id);
return this.prisma.insuredDriver.delete({ where: { id } });
}
async addBeneficiary(policyId: string, dto: BeneficiaryDto) {
await this.ensurePolicy(policyId);
return this.prisma.policyBeneficiary.create({ data: { policyId, ...dto } });
}
async updateBeneficiary(policyId: string, id: string, dto: UpdateBeneficiaryDto) {
await this.ensureChild("policyBeneficiary", policyId, id);
return this.prisma.policyBeneficiary.update({ where: { id }, data: { ...dto } });
}
async removeBeneficiary(policyId: string, id: string) {
await this.ensureChild("policyBeneficiary", policyId, id);
return this.prisma.policyBeneficiary.delete({ where: { id } });
}
async addClaim(policyId: string, dto: ClaimDto) {
await this.ensurePolicy(policyId);
return this.prisma.claim.create({ data: { policyId, ...this.claimData(dto) } });
}
async updateClaim(policyId: string, id: string, dto: UpdateClaimDto) {
await this.ensureChild("claim", policyId, id);
return this.prisma.claim.update({ where: { id }, data: this.claimData(dto) });
}
async removeClaim(policyId: string, id: string) {
await this.ensureChild("claim", policyId, id);
return this.prisma.claim.delete({ where: { id } });
}
private claimData(dto: ClaimDto) {
const { incidentDate, reportedDate, settlementDate, ...rest } = dto;
return {
...rest,
...(incidentDate !== undefined && { incidentDate: toDate(incidentDate) }),
...(reportedDate !== undefined && { reportedDate: toDate(reportedDate) }),
...(settlementDate !== undefined && { settlementDate: toDate(settlementDate) }),
};
}
// --- documents ------------------------------------------------------------
// Blob in object storage under `policy/<policyId>/…`; row is the pointer.
async addDocument(
policyId: string,
file: UploadedFileLike,
documentType?: string,
) {
await this.ensurePolicy(policyId);
const key = `policy/${policyId}/${randomUUID()}${extForUpload(file)}`;
await this.storage.put(key, file.buffer, file.mimetype);
return this.prisma.policyDocument.create({
data: {
policyId,
documentType: documentType?.trim() || "DOCUMENT",
storageKey: key,
},
});
}
async getDocument(policyId: string, id: string) {
const row = await this.prisma.policyDocument.findFirst({
where: { id, policyId },
});
if (!row) throw new NotFoundException(`Document ${id} not found on policy ${policyId}`);
const blob = await this.storage.getStream(row.storageKey);
return { row, ...blob };
}
async removeDocument(policyId: string, id: string) {
await this.ensurePolicy(policyId);
const row = await this.prisma.policyDocument.findFirst({
where: { id, policyId },
select: { id: true, storageKey: true },
});
if (!row) throw new NotFoundException(`Document ${id} not found on policy ${policyId}`);
const deleted = await this.prisma.policyDocument.delete({ where: { id } });
await this.storage.delete(row.storageKey);
return deleted;
}
// --- lookups (providers / policy types / adjusters) -----------------------
listLookups() {
return this.prisma.$transaction([
this.prisma.insuranceProvider.findMany({
orderBy: { name: "asc" },
select: { id: true, name: true, _count: { select: { policies: true } } },
}),
this.prisma.policyType.findMany({
orderBy: { name: "asc" },
select: {
id: true,
name: true,
shortDescription: true,
_count: { select: { policies: true } },
},
}),
this.prisma.adjuster.findMany({ orderBy: { name: "asc" } }),
]).then(([providers, types, adjusters]) => ({ providers, types, adjusters }));
}
createProvider(dto: ProviderDto) {
return this.prisma.insuranceProvider.create({ data: dto });
}
updateProvider(id: string, dto: UpdateProviderDto) {
return this.prisma.insuranceProvider.update({ where: { id }, data: dto });
}
removeProvider(id: string) {
return this.prisma.insuranceProvider.delete({ where: { id } });
}
createPolicyType(dto: PolicyTypeDto) {
return this.prisma.policyType.create({ data: dto });
}
updatePolicyType(id: string, dto: UpdatePolicyTypeDto) {
return this.prisma.policyType.update({ where: { id }, data: dto });
}
removePolicyType(id: string) {
return this.prisma.policyType.delete({ where: { id } });
}
createAdjuster(dto: AdjusterDto) {
return this.prisma.adjuster.create({ data: dto });
}
updateAdjuster(id: string, dto: UpdateAdjusterDto) {
return this.prisma.adjuster.update({ where: { id }, data: dto });
}
removeAdjuster(id: string) {
return this.prisma.adjuster.delete({ where: { id } });
}
}