Scanning a stack of bills and keying them in are the same daily job, ending in the same ledger path, so OCR intake becomes a mode of the capture screen instead of a second menu entry: - components/Captura.tsx holds the mode switch; the manual check form moves verbatim to components/ManualCheckCapture.tsx and the OCR intake to components/StatementIntake.tsx. - /estado-cuenta/lote opens on manual, /recibos on automatic — both render Captura, so batch-review links and old bookmarks still land right. - Nav drops "Recibos (OCR)"; "Captura" covers both, with a NavLink.aliases field so /recibos still highlights it. Also fixes the "El almacenamiento de documentos no está configurado" failure staff hit on upload. Uploading with no object storage configured used to succeed, then die on the first put minutes later, leaving a FAILED batch whose only explanation was that string. createBatch now refuses up front, GET /statements/status reports storageAvailable alongside ocrAvailable, and the intake tab explains the situation instead of offering an upload that cannot work. S3_* documented in .env.example (deploy stacks already set it). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
4.0 KiB
TypeScript
133 lines
4.0 KiB
TypeScript
import {
|
|
Injectable,
|
|
Logger,
|
|
OnModuleInit,
|
|
ServiceUnavailableException,
|
|
} from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import {
|
|
CreateBucketCommand,
|
|
DeleteObjectCommand,
|
|
GetObjectCommand,
|
|
HeadBucketCommand,
|
|
PutObjectCommand,
|
|
S3Client,
|
|
} from "@aws-sdk/client-s3";
|
|
import type { Readable } from "node:stream";
|
|
|
|
/**
|
|
* S3 / MinIO object storage for document blobs. MySQL keeps only the pointer
|
|
* (`storageKey`) + metadata; the bytes live here. Same bucket the migration's
|
|
* `blob_extract.py` writes to, so keys stay under the `service/…` and
|
|
* `policy/…` prefixes it established.
|
|
*
|
|
* Env (see deploy/.env.dev): S3_ENDPOINT, S3_BUCKET, and creds — S3_ACCESS_KEY
|
|
* / S3_SECRET_KEY, falling back to MINIO_ROOT_USER / MINIO_ROOT_PASSWORD so a
|
|
* single MinIO credential set drives both the migration and the API.
|
|
*/
|
|
@Injectable()
|
|
export class StorageService implements OnModuleInit {
|
|
private readonly logger = new Logger(StorageService.name);
|
|
private readonly client: S3Client | null;
|
|
readonly bucket: string;
|
|
|
|
constructor(config: ConfigService) {
|
|
const endpoint = config.get<string>("S3_ENDPOINT");
|
|
this.bucket = config.get<string>("S3_BUCKET") ?? "jorgecuadros-documents";
|
|
const accessKeyId =
|
|
config.get<string>("S3_ACCESS_KEY") ?? config.get<string>("MINIO_ROOT_USER");
|
|
const secretAccessKey =
|
|
config.get<string>("S3_SECRET_KEY") ?? config.get<string>("MINIO_ROOT_PASSWORD");
|
|
|
|
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
|
this.logger.warn(
|
|
"Object storage not configured (missing S3_ENDPOINT / credentials); " +
|
|
"document upload & download are disabled.",
|
|
);
|
|
this.client = null;
|
|
return;
|
|
}
|
|
|
|
this.client = new S3Client({
|
|
endpoint,
|
|
region: config.get<string>("S3_REGION") ?? "us-east-1",
|
|
credentials: { accessKeyId, secretAccessKey },
|
|
forcePathStyle: true, // MinIO needs path-style addressing
|
|
});
|
|
}
|
|
|
|
/** Best-effort bucket check on boot; never blocks API startup. */
|
|
async onModuleInit() {
|
|
if (!this.client) return;
|
|
try {
|
|
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
|
} catch {
|
|
try {
|
|
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }));
|
|
this.logger.log(`Created bucket "${this.bucket}".`);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`Could not verify/create bucket "${this.bucket}": ${(err as Error).message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether the deployment has object storage at all. Callers use this to
|
|
* refuse work up front instead of failing halfway through — a recibo batch
|
|
* that dies on its first `put` leaves a FAILED batch and no explanation the
|
|
* office can act on.
|
|
*/
|
|
get available(): boolean {
|
|
return this.client !== null;
|
|
}
|
|
|
|
private require(): S3Client {
|
|
if (!this.client) {
|
|
throw new ServiceUnavailableException(
|
|
"El almacenamiento de documentos no está configurado.",
|
|
);
|
|
}
|
|
return this.client;
|
|
}
|
|
|
|
async put(key: string, body: Buffer, contentType?: string): Promise<void> {
|
|
await this.require().send(
|
|
new PutObjectCommand({
|
|
Bucket: this.bucket,
|
|
Key: key,
|
|
Body: body,
|
|
ContentType: contentType,
|
|
}),
|
|
);
|
|
}
|
|
|
|
async getStream(key: string): Promise<{
|
|
stream: Readable;
|
|
contentType?: string;
|
|
contentLength?: number;
|
|
}> {
|
|
const out = await this.require().send(
|
|
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
|
|
);
|
|
return {
|
|
stream: out.Body as Readable,
|
|
contentType: out.ContentType,
|
|
contentLength: out.ContentLength,
|
|
};
|
|
}
|
|
|
|
/** Best-effort blob delete; a missing object is not an error. */
|
|
async delete(key: string): Promise<void> {
|
|
if (!this.client) return;
|
|
try {
|
|
await this.client.send(
|
|
new DeleteObjectCommand({ Bucket: this.bucket, Key: key }),
|
|
);
|
|
} catch (err) {
|
|
this.logger.warn(`Failed to delete blob "${key}": ${(err as Error).message}`);
|
|
}
|
|
}
|
|
}
|