feat(storage): add deleteByPrefix, copy, getMetadata and prod lifecycle rules

- Add deleteByPrefix(prefix) for bulk user data deletion (account cleanup)
- Add copy(sourceKey, destKey) via CopyObjectCommand for file duplication
- Add getMetadata(key) via HeadObjectCommand for content-type/size/metadata
- Add FileMetadata type for structured metadata responses
- Add minio-init container to docker-compose.macmini.yml with bucket creation,
  public access policies, and lifecycle rules (matching dev compose)
- 96 tests passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 19:40:17 +01:00
parent b0e5a9c5ff
commit 152fa5fe08
5 changed files with 191 additions and 0 deletions

View file

@ -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)
# ============================================

View file

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

View file

@ -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<number> {
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<UploadResult> {
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<FileMetadata> {
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) ──────────────
/**

View file

@ -62,4 +62,5 @@ export {
type MultipartUploadPart,
type UploadResult,
type FileInfo,
type FileMetadata,
} from './types';

View file

@ -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<string, string>;
}
/**
* Multipart upload initialization result
*/