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>
241 lines
6.8 KiB
TypeScript
241 lines
6.8 KiB
TypeScript
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
Put,
|
|
Query,
|
|
Req,
|
|
Res,
|
|
StreamableFile,
|
|
UploadedFile,
|
|
UseGuards,
|
|
UseInterceptors,
|
|
} from "@nestjs/common";
|
|
import { FileInterceptor } from "@nestjs/platform-express";
|
|
import { ServiceKind } from "@jorgecuadros/database";
|
|
import { Request, Response } from "express";
|
|
import { downloadName, type UploadedFileLike } from "../storage/upload-file";
|
|
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",
|
|
"ELECTRIC",
|
|
"GAS",
|
|
"CABLE",
|
|
"PROPERTY_TAX",
|
|
"FEDERAL_ZONE",
|
|
"ALARM",
|
|
"OTHER",
|
|
];
|
|
|
|
const TRUST_FILTERS: TrustFilter[] = [
|
|
"with",
|
|
"without",
|
|
"active",
|
|
"expiring",
|
|
"expired",
|
|
"undated",
|
|
];
|
|
|
|
const SORTS: PropertySort[] = [
|
|
"customer",
|
|
"address",
|
|
"services_desc",
|
|
"trust_due_asc",
|
|
"trust_due_desc",
|
|
];
|
|
|
|
function parseDays(days?: string): number {
|
|
return Math.min(365, Math.max(1, Number(days) || 30));
|
|
}
|
|
|
|
@UseGuards(AuthenticatedGuard, AbilityGuard)
|
|
@Controller("properties")
|
|
export class PropertiesController {
|
|
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) {
|
|
return this.properties.stats(parseDays(days));
|
|
}
|
|
|
|
@Get("facets")
|
|
facets() {
|
|
return this.properties.facets();
|
|
}
|
|
|
|
@Get()
|
|
list(
|
|
@Query("query") query?: string,
|
|
@Query("page") page?: string,
|
|
@Query("pageSize") pageSize?: string,
|
|
@Query("serviceKind") serviceKind?: string,
|
|
@Query("municipality") municipality?: string,
|
|
@Query("bank") bank?: string,
|
|
@Query("trust") trust?: string,
|
|
@Query("hasServices") hasServices?: string,
|
|
@Query("customerId") customerId?: string,
|
|
@Query("days") days?: string,
|
|
@Query("includeArchived") includeArchived?: string,
|
|
@Query("sort") sort?: string,
|
|
) {
|
|
return this.properties.list({
|
|
query,
|
|
page: Math.max(1, Number(page) || 1),
|
|
pageSize: Math.min(100, Math.max(1, Number(pageSize) || 25)),
|
|
serviceKind: KINDS.includes(serviceKind as ServiceKind)
|
|
? (serviceKind as ServiceKind)
|
|
: undefined,
|
|
municipality: municipality || undefined,
|
|
bank: bank || undefined,
|
|
trust: TRUST_FILTERS.includes(trust as TrustFilter)
|
|
? (trust as TrustFilter)
|
|
: undefined,
|
|
hasServices:
|
|
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",
|
|
});
|
|
}
|
|
|
|
@Get(":id")
|
|
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 ------------------------------------------------------------
|
|
|
|
@Post(":id/documents")
|
|
@RequireAbility("property:update")
|
|
@UseInterceptors(
|
|
FileInterceptor("file", { limits: { fileSize: 50 * 1024 * 1024 } }),
|
|
)
|
|
addDocument(
|
|
@Param("id") id: string,
|
|
@UploadedFile() file: UploadedFileLike | undefined,
|
|
@Query("type") type: string | undefined,
|
|
) {
|
|
if (!file) throw new Error("No se recibió ningún archivo.");
|
|
return this.properties.addDocument(id, file, type);
|
|
}
|
|
|
|
@Get(":id/documents/:childId/download")
|
|
async downloadDocument(
|
|
@Param("id") id: string,
|
|
@Param("childId") childId: string,
|
|
@Res({ passthrough: true }) res: Response,
|
|
): Promise<StreamableFile> {
|
|
const { row, stream, contentType } = await this.properties.getDocument(id, childId);
|
|
res.set({
|
|
"Content-Type": contentType ?? "application/octet-stream",
|
|
"Content-Disposition": `attachment; filename="${downloadName(row.storageKey, row.documentType)}"`,
|
|
});
|
|
return new StreamableFile(stream);
|
|
}
|
|
|
|
@Delete(":id/documents/:childId")
|
|
@RequireAbility("property:update")
|
|
removeDocument(@Param("id") id: string, @Param("childId") childId: string) {
|
|
return this.properties.removeDocument(id, childId);
|
|
}
|
|
}
|