Files
route-commerce/src/lib/storage.ts
T
tyler c20538ef9f Add MinIO storage + replace Supabase Storage with S3 SDK
- New: src/lib/storage.ts — S3-compatible client, uploadFile/deleteFile/listFiles/publicUrl helpers, bucket constants
- New: docker-compose adds minio + minio_init services
- Replaced Supabase fetch PUTs in:
  - src/actions/brand-settings.ts (3 uploaders)
  - src/actions/products/upload-image.ts
  - src/actions/communications/import-contacts.ts
  - src/app/api/water-photo-upload/route.ts
- Replaced hardcoded Supabase URLs in:
  - src/components/storefront/TuxedoVideoHero.tsx
  - src/components/time-tracking/TimeTrackingFieldClient.tsx
  - src/app/tuxedo/about/page.tsx
  - src/lib/email-service.ts (4 occurrences)
- Added @aws-sdk/client-s3 + @aws-sdk/s3-request-presigner
- .env.example adds MinIO + storage vars
- Migration cleanup: deleted BUNDLE_018_042, 4 XXX drafts, 087/145/099-contact storage migrations
- Migration patches: 006 STATIC→STABLE, 135 param reordering
- Preflight: added pgcrypto extension, removed storage stub
- Verified: MinIO upload/list/delete round-trip works against local instance
2026-06-05 15:17:21 +00:00

89 lines
3.2 KiB
TypeScript

import { S3Client, PutObjectCommand, DeleteObjectCommand, ListObjectsV2Command } from "@aws-sdk/client-s3";
import { randomUUID } from "crypto";
// ── Buckets ────────────────────────────────────────────────────────
export const BUCKETS = {
BRAND_LOGOS: "brand-logos",
PRODUCT_IMAGES: "product-images",
CONTACTS_IMPORTS: "contacts-imports",
VIDEOS: "videos",
WATER_PHOTOS: "water-photos",
} as const;
export type BucketName = (typeof BUCKETS)[keyof typeof BUCKETS];
// ── S3 client (MinIO is S3-compatible) ─────────────────────────────
const region = process.env.STORAGE_REGION || "us-east-1";
const endpoint = process.env.STORAGE_ENDPOINT || "http://127.0.0.1:9000";
const publicBaseUrl = process.env.NEXT_PUBLIC_STORAGE_BASE_URL || endpoint;
export const s3 = new S3Client({
region,
endpoint,
forcePathStyle: true, // MinIO requires path-style
credentials: {
accessKeyId: process.env.STORAGE_ACCESS_KEY || "",
secretAccessKey: process.env.STORAGE_SECRET_KEY || "",
},
});
const prefix = process.env.STORAGE_BUCKET_PREFIX || "";
// ── Helpers ────────────────────────────────────────────────────────
export function publicUrl(bucket: BucketName | string, key: string): string {
const fullKey = prefix ? `${prefix}/${key}` : key;
return `${publicBaseUrl}/${bucket}/${fullKey}`;
}
export type UploadInput = {
bucket: BucketName | string;
key: string;
body: Buffer | Uint8Array | string;
contentType?: string;
};
export async function uploadFile(input: UploadInput): Promise<{ url: string }> {
const fullKey = prefix ? `${prefix}/${input.key}` : input.key;
await s3.send(
new PutObjectCommand({
Bucket: input.bucket,
Key: fullKey,
Body: input.body,
ContentType: input.contentType,
})
);
return { url: publicUrl(input.bucket, input.key) };
}
export async function deleteFile(bucket: BucketName | string, key: string): Promise<void> {
const fullKey = prefix ? `${prefix}/${key}` : key;
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: fullKey }));
}
export async function listFiles(
bucket: BucketName | string,
prefix_?: string
): Promise<{ key: string; size: number; lastModified: Date }[]> {
const fullPrefix = prefix ? `${prefix}/${prefix_ || ""}` : prefix_ || undefined;
const res = await s3.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: fullPrefix,
})
);
return (res.Contents || []).map((obj) => ({
key: obj.Key || "",
size: obj.Size || 0,
lastModified: obj.LastModified || new Date(0),
}));
}
// ── Key builders (single source of truth) ──────────────────────────
export const storageKeys = {
brandLogo: (brandId: string, name: string) => `${brandId}/${name}`,
productImage: (productId: string, ext: string) =>
`products/${productId}/${randomUUID()}.${ext}`,
contactsImport: (brandId: string, name: string) =>
`${brandId}/${Date.now()}-${name}`,
};