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