diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index 3d974c1f8..3416bd4af 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -69,6 +69,40 @@ services: timeout: 20s retries: 3 + # MinIO bucket initialization and lifecycle rules (runs once) + minio-init: + image: minio/mc:latest + container_name: mana-infra-minio-init + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set myminio http://minio:9000 $${MINIO_ACCESS_KEY:-minioadmin} $${MINIO_SECRET_KEY:-minioadmin}; + mc mb --ignore-existing myminio/manacore-storage; + mc mb --ignore-existing myminio/picture-storage; + mc mb --ignore-existing myminio/chat-storage; + mc mb --ignore-existing myminio/manadeck-storage; + mc mb --ignore-existing myminio/presi-storage; + mc mb --ignore-existing myminio/calendar-storage; + mc mb --ignore-existing myminio/contacts-storage; + mc mb --ignore-existing myminio/storage-storage; + mc mb --ignore-existing myminio/inventory-storage; + mc mb --ignore-existing myminio/mukke-storage; + mc mb --ignore-existing myminio/planta-storage; + mc mb --ignore-existing myminio/projectdoc-storage; + mc mb --ignore-existing myminio/mail-storage; + mc anonymous set download myminio/manacore-storage; + mc anonymous set download myminio/picture-storage; + mc anonymous set download myminio/planta-storage; + mc anonymous set download myminio/inventory-storage; + mc ilm rule add --expire-days 90 myminio/chat-storage --prefix 'tmp/' 2>/dev/null || true; + mc ilm rule add --expire-days 30 myminio/calendar-storage --prefix 'tmp/' 2>/dev/null || true; + mc ilm rule add --expire-days 7 myminio/picture-storage --prefix 'tmp/' 2>/dev/null || true; + echo 'Buckets and lifecycle rules created successfully'; + exit 0; + " + # ============================================ # Tier 1: Core Auth Service (Port 3001) # ============================================ diff --git a/packages/shared-storage/src/client.spec.ts b/packages/shared-storage/src/client.spec.ts index c5e2601ac..52b71fe28 100644 --- a/packages/shared-storage/src/client.spec.ts +++ b/packages/shared-storage/src/client.spec.ts @@ -31,6 +31,9 @@ vi.mock('@aws-sdk/client-s3', () => ({ HeadObjectCommand: vi.fn(function (this: any, input: any) { Object.assign(this, input); }), + CopyObjectCommand: vi.fn(function (this: any, input: any) { + Object.assign(this, input); + }), CreateMultipartUploadCommand: vi.fn(function (this: any, input: any) { Object.assign(this, input); }), @@ -418,6 +421,88 @@ describe('StorageClient', () => { }); }); + describe('deleteByPrefix', () => { + it('lists and deletes all files with prefix', async () => { + mockSend + .mockResolvedValueOnce({ + Contents: [ + { Key: 'users/123/a.png', Size: 100, LastModified: new Date() }, + { Key: 'users/123/b.png', Size: 200, LastModified: new Date() }, + ], + IsTruncated: false, + }) + .mockResolvedValue({}); // deleteMany + + const count = await storage.deleteByPrefix('users/123/'); + + expect(count).toBe(2); + }); + + it('returns 0 when prefix has no files', async () => { + mockSend.mockResolvedValue({ Contents: undefined, IsTruncated: false }); + + const count = await storage.deleteByPrefix('users/nonexistent/'); + + expect(count).toBe(0); + expect(mockSend).toHaveBeenCalledTimes(1); // only list, no delete + }); + }); + + describe('copy', () => { + it('copies a file and returns new key', async () => { + mockSend.mockResolvedValue({ CopyObjectResult: { ETag: '"copied"' } }); + + const result = await storage.copy('old/file.png', 'new/file.png'); + + expect(result.key).toBe('new/file.png'); + expect(result.etag).toBe('"copied"'); + expect(result.url).toBe('http://localhost:9000/test-bucket/new/file.png'); + }); + + it('sends CopyObjectCommand with correct source', async () => { + mockSend.mockResolvedValue({ CopyObjectResult: {} }); + const { CopyObjectCommand } = await import('@aws-sdk/client-s3'); + + await storage.copy('src.png', 'dst.png'); + + expect(CopyObjectCommand).toHaveBeenCalledWith( + expect.objectContaining({ + Bucket: 'test-bucket', + CopySource: 'test-bucket/src.png', + Key: 'dst.png', + }) + ); + }); + }); + + describe('getMetadata', () => { + it('returns file metadata', async () => { + mockSend.mockResolvedValue({ + ContentType: 'image/png', + ContentLength: 4096, + LastModified: new Date('2024-06-15'), + ETag: '"meta-etag"', + Metadata: { author: 'test' }, + }); + + const meta = await storage.getMetadata('file.png'); + + expect(meta.contentType).toBe('image/png'); + expect(meta.size).toBe(4096); + expect(meta.etag).toBe('"meta-etag"'); + expect(meta.metadata).toEqual({ author: 'test' }); + }); + + it('handles missing optional fields', async () => { + mockSend.mockResolvedValue({}); + + const meta = await storage.getMetadata('file.png'); + + expect(meta.size).toBe(0); + expect(meta.contentType).toBeUndefined(); + }); + }); + describe('presigned multipart upload', () => { it('createMultipartUpload returns upload ID', async () => { mockSend.mockResolvedValue({ UploadId: 'mp-123' }); diff --git a/packages/shared-storage/src/client.ts b/packages/shared-storage/src/client.ts index 6ac86113e..53e655244 100644 --- a/packages/shared-storage/src/client.ts +++ b/packages/shared-storage/src/client.ts @@ -6,6 +6,7 @@ import { DeleteObjectsCommand, ListObjectsV2Command, HeadObjectCommand, + CopyObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, @@ -24,6 +25,7 @@ import type { MultipartUploadPart, UploadResult, FileInfo, + FileMetadata, } from './types'; /** @@ -370,6 +372,59 @@ export class StorageClient { return this.bucket.name; } + /** + * Delete all files matching a prefix. + * Useful for account deletion (e.g., deleteByPrefix('users/user-123/')) + */ + async deleteByPrefix(prefix: string): Promise { + const files = await this.list(prefix); + if (files.length === 0) return 0; + + const keys = files.map((f) => f.key); + await this.deleteMany(keys); + return keys.length; + } + + /** + * Copy a file within the same bucket. + * For move operations, call copy() then delete() the source. + */ + async copy(sourceKey: string, destKey: string): Promise { + const command = new CopyObjectCommand({ + Bucket: this.bucket.name, + CopySource: `${this.bucket.name}/${sourceKey}`, + Key: destKey, + }); + + const result = await this.client.send(command); + + return { + key: destKey, + url: this.getPublicUrl(destKey), + etag: result.CopyObjectResult?.ETag, + }; + } + + /** + * Get file metadata without downloading the file. + */ + async getMetadata(key: string): Promise { + const command = new HeadObjectCommand({ + Bucket: this.bucket.name, + Key: key, + }); + + const response = await this.client.send(command); + + return { + contentType: response.ContentType, + size: response.ContentLength ?? 0, + lastModified: response.LastModified, + etag: response.ETag, + metadata: response.Metadata, + }; + } + // ── Presigned Multipart Upload (browser direct-upload) ────────────── /** diff --git a/packages/shared-storage/src/index.ts b/packages/shared-storage/src/index.ts index a110e9c0a..6a222a3b7 100644 --- a/packages/shared-storage/src/index.ts +++ b/packages/shared-storage/src/index.ts @@ -62,4 +62,5 @@ export { type MultipartUploadPart, type UploadResult, type FileInfo, + type FileMetadata, } from './types'; diff --git a/packages/shared-storage/src/types.ts b/packages/shared-storage/src/types.ts index 9bc48a346..dd0bdfe1f 100644 --- a/packages/shared-storage/src/types.ts +++ b/packages/shared-storage/src/types.ts @@ -86,6 +86,22 @@ export interface FileInfo { etag?: string; } +/** + * Metadata for a stored file (from HeadObject) + */ +export interface FileMetadata { + /** Content type (MIME type) */ + contentType?: string; + /** File size in bytes */ + size: number; + /** Last modified date */ + lastModified?: Date; + /** ETag */ + etag?: string; + /** Custom metadata */ + metadata?: Record; +} + /** * Multipart upload initialization result */