feat: StorageService S3/MinIO, POST /users, invoice upload, portal dashboard §08 statuses + notifications

This commit is contained in:
Lizandro Guarnizo
2026-06-01 20:55:30 -05:00
parent 98ab5a309c
commit a047a8b032
19 changed files with 1166 additions and 134 deletions
+97 -14
View File
@@ -3,38 +3,121 @@ import { ConfigService } from "@nestjs/config";
import * as fs from "fs";
import * as path from "path";
import { randomUUID } from "crypto";
import { S3Client, DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { GetObjectCommand } from "@aws-sdk/client-s3";
/**
* StorageService — dual mode:
* • If S3_ENDPOINT is set → MinIO / S3-compatible storage
* • Otherwise → local disk under ./uploads/ (development)
*/
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private readonly uploadDir: string;
private readonly baseUrl: string;
private readonly s3?: S3Client;
private readonly bucket?: string;
private readonly s3Public?: string;
constructor(private config: ConfigService) {
this.uploadDir = path.join(process.cwd(), "uploads");
this.baseUrl = this.config.get("API_URL", "http://localhost:3001");
// ensure uploads dir exists
if (!fs.existsSync(this.uploadDir)) {
fs.mkdirSync(this.uploadDir, { recursive: true });
this.baseUrl = this.config.get("API_URL", "http://localhost:3001");
const endpoint = this.config.get<string>("S3_ENDPOINT");
const accessKey = this.config.get<string>("S3_ACCESS_KEY");
const secretKey = this.config.get<string>("S3_SECRET_KEY");
const region = this.config.get<string>("S3_REGION", "us-east-1");
const forcePath = this.config.get<string>("S3_FORCE_PATH_STYLE", "false") === "true";
this.bucket = this.config.get<string>("S3_BUCKET", "moraworld");
this.s3Public = this.config.get<string>("S3_PUBLIC_URL") ?? endpoint;
if (endpoint && accessKey && secretKey) {
this.s3 = new S3Client({
endpoint,
region,
forcePathStyle: forcePath,
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
});
this.logger.log(`[STORAGE] S3/MinIO mode — endpoint: ${endpoint}, bucket: ${this.bucket}`);
} else {
// Local disk fallback
if (!fs.existsSync(this.uploadDir)) {
fs.mkdirSync(this.uploadDir, { recursive: true });
}
this.logger.log("[STORAGE] Local disk mode (no S3_ENDPOINT configured)");
}
}
async saveFile(subdir: string, originalName: string, buffer: Buffer): Promise<string> {
const ext = path.extname(originalName);
get isS3Mode(): boolean { return !!this.s3; }
/**
* Store a file. Returns its public URL.
* @param subdir e.g. "packages/pkg-123" or "invoices"
* @param originalName e.g. "photo.jpg"
* @param buffer file contents
* @param mimeType e.g. "image/jpeg"
*/
async saveFile(
subdir: string,
originalName: string,
buffer: Buffer,
mimeType = "application/octet-stream",
): Promise<string> {
const ext = path.extname(originalName);
const filename = `${randomUUID()}${ext}`;
const key = `${subdir}/${filename}`;
if (this.s3) {
await this.s3.send(new PutObjectCommand({
Bucket: this.bucket!,
Key: key,
Body: buffer,
ContentType: mimeType,
// ACL not used — presigned URLs or public endpoint handle access
}));
// Return public path (either bucket/key or via configured public URL)
return `${this.s3Public}/${this.bucket}/${key}`;
}
// Local disk
const dir = path.join(this.uploadDir, subdir);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const filepath = path.join(dir, filename);
fs.writeFileSync(filepath, buffer);
// Return URL relative to API base
return `${this.baseUrl}/uploads/${subdir}/${filename}`;
fs.writeFileSync(path.join(dir, filename), buffer);
return `${this.baseUrl}/uploads/${key}`;
}
async deleteFile(url: string): Promise<void> {
/**
* Generate a presigned GET URL for an S3 key (valid 1 hour).
* Falls through to a direct URL if not in S3 mode.
*/
async presign(keyOrUrl: string, expiresIn = 3600): Promise<string> {
if (!this.s3) return keyOrUrl;
// If a full URL was passed, extract the key
const key = keyOrUrl.includes(`/${this.bucket}/`)
? keyOrUrl.split(`/${this.bucket}/`)[1]
: keyOrUrl;
return getSignedUrl(
this.s3,
new GetObjectCommand({ Bucket: this.bucket!, Key: key }),
{ expiresIn },
);
}
/** Delete a file by its URL or S3 key */
async deleteFile(urlOrKey: string): Promise<void> {
try {
const relative = url.replace(/^https?:\/\/[^/]+\/uploads\//, "");
const filepath = path.join(this.uploadDir, relative);
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
if (this.s3) {
const key = urlOrKey.includes(`/${this.bucket}/`)
? urlOrKey.split(`/${this.bucket}/`)[1]
: urlOrKey;
await this.s3.send(new DeleteObjectCommand({ Bucket: this.bucket!, Key: key }));
} else {
const relative = urlOrKey.replace(/^https?:\/\/[^/]+\/uploads\//, "");
const filepath = path.join(this.uploadDir, relative);
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
}
} catch (e: unknown) {
this.logger.warn(`deleteFile failed: ${(e as Error).message}`);
}