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

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);
}
}