Commit b87a2723 authored by ThinhNC's avatar ThinhNC

feat(uploads): add R2 avatar upload flow

parent 27f4372a
......@@ -19,6 +19,12 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
update và delete giao dịch cập nhật Wallet trong Prisma transaction mức Serializable.
- Hóa đơn Transaction được lưu cục bộ dưới `storage/receipts`, chỉ đọc qua API có auth;
hỗ trợ JPEG, PNG, WebP, PDF và giới hạn mặc định 5 MB.
- Upload file mới dùng mô hình browser tải trực tiếp lên Cloudflare R2 qua presigned PUT URL
do backend cấp. Hiện chỉ bật purpose `avatar` (JPEG/PNG/WebP, mặc định 5 MB), object key do
server sinh theo user và public URL chỉ được lưu vào profile sau khi PUT thành công. Khi mở rộng,
mỗi purpose phải có ownership prefix, MIME/size/TTL riêng; tài liệu riêng tư không dùng public URL.
- Avatar lưu điểm lấy nét theo phần trăm `avatarPositionX`/`avatarPositionY` (0-100, mặc định
50/50) để frontend hiển thị crop nhất quán; xóa avatar đặt URL về null và reset điểm lấy nét.
- Budget có `currency` riêng (mặc định `VND`), hỗ trợ phạm vi tổng (`OVERALL`) hoặc
danh mục chi (`CATEGORY`), chu kỳ `CUSTOM`, `WEEKLY`, `MONTHLY`, `YEARLY` và archive
để giữ lịch sử. Mức sử dụng, phần trăm cùng cảnh báo ngưỡng được tổng hợp trực tiếp
......@@ -56,6 +62,11 @@ File này chỉ lưu sự thật và quyết định dài hạn giúp các phiê
## Trạng thái đã biết
- Khi Prisma Client đã được generate với field mới nhưng database chưa chạy migration tương ứng,
mọi query lấy toàn bộ model có thể lỗi `P2022` (ví dụ `users.avatar_position_x` không tồn tại)
ngay cả ở luồng không trực tiếp dùng field đó như đăng nhập. Sau thay đổi schema phải áp dụng
migration trước khi khởi động lại server; dùng `prisma migrate status` để xác nhận và
`prisma migrate deploy` cho migration cộng thêm an toàn, tuyệt đối không reset database.
- Đã thiết lập khung kiểm thử tích hợp (integration tests) bằng Jest và Supertest, chạy kiểm thử qua lệnh pnpm test sử dụng cấu hình môi trường test cô lập (REDIS_ENABLED=false để chạy in-memory cache).
- Hệ thống linting đã được cấu hình qua eslint.config.mjs (flat config, bỏ qua các tệp test & configs liên quan) và chạy sạch sẽ khi gọi pnpm run lint.
- `env.config.ts` dùng port fallback `8888`, còn `.env.example` dùng `7777`; README
......
......@@ -36,6 +36,16 @@ ALLOWED_ORIGINS=http://localhost:2999,http://localhost:3000,http://localhost:517
RECEIPT_UPLOAD_DIR=storage/receipts
RECEIPT_MAX_FILE_SIZE_MB=5
# Cloudflare R2 (server-side only; never expose credentials to the browser)
R2_ACCOUNT_ID=your_cloudflare_account_id
R2_BUCKET_NAME=finwise-uploads
R2_ACCESS_KEY_ID=your_r2_access_key_id
R2_SECRET_ACCESS_KEY=your_r2_secret_access_key
# Public r2.dev URL or custom domain used to render uploaded avatars
R2_PUBLIC_BASE_URL=https://uploads.example.com
R2_PRESIGNED_URL_EXPIRES_IN_SECONDS=300
R2_AVATAR_MAX_FILE_SIZE_MB=5
NOTIFICATION_WORKER_ENABLED=true
NOTIFICATION_WORKER_INTERVAL_MS=60000
NOTIFICATION_FINANCIAL_SCAN_INTERVAL_MS=300000
......
......@@ -63,7 +63,8 @@ Tất cả các endpoint bên dưới có tiền tố (prefix) mặc định: `/
* `GET /auth/me` [Bearer] - Lấy thông tin tài khoản hiện tại.
* `POST /auth/refresh` [Public] - Dùng Refresh Token để làm mới cặp Access/Refresh Token.
* `POST /auth/logout` [Public] - Đăng xuất và thu hồi Refresh Token trong DB.
* `PUT /auth/profile` [Bearer] - Cập nhật thông tin cá nhân (họ tên, số điện thoại, avatar).
* `PUT /auth/profile` [Bearer] - Cập nhật thông tin cá nhân (họ tên, số điện thoại, avatar và vị trí crop 0-100%); gửi `avatarUrl: null` để xóa avatar khỏi hồ sơ.
* `POST /uploads/presign` [Bearer] - Tạo presigned PUT URL để browser tải avatar trực tiếp lên Cloudflare R2.
* `PUT /auth/password` [Bearer] - Đổi mật khẩu tài khoản.
* `POST /auth/forgot-password` [Public] - Gửi email khôi phục mật khẩu.
* `POST /auth/reset-password` [Public] - Đặt lại mật khẩu mới sử dụng token.
......
# Direct browser upload lên Cloudflare R2
## Phạm vi hiện tại
Luồng presigned PUT hiện chỉ phục vụ **avatar**. Frontend không gửi file qua backend:
1. Browser gọi `POST /api/v1/uploads/presign` bằng phiên đăng nhập hiện tại.
2. Backend kiểm tra purpose, MIME type và kích thước khai báo, tự sinh object key rồi ký URL ngắn hạn.
3. Browser `PUT` file trực tiếp tới S3 API domain của R2 với đúng header `Content-Type` mà API trả về.
4. Khi PUT thành công, frontend gửi `publicUrl` trong `PUT /api/v1/auth/profile`.
R2 credentials chỉ tồn tại ở backend. Presigned URL là bearer credential ngắn hạn, không log hoặc lưu URL này.
## Cấu hình backend
Các biến cần thiết được liệt kê trong `.env.example`:
- `R2_ACCOUNT_ID`, `R2_BUCKET_NAME`
- `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`
- `R2_PUBLIC_BASE_URL`: public `r2.dev` URL hoặc custom domain dùng để hiển thị avatar
- `R2_PRESIGNED_URL_EXPIRES_IN_SECONDS`: mặc định 300 giây
- `R2_AVATAR_MAX_FILE_SIZE_MB`: mặc định 5 MB
API token R2 chỉ cần quyền ghi đúng bucket được dùng cho upload.
## CORS bắt buộc trên R2 bucket
Thay các origin bằng origin thật của Mini App và môi trường local:
```json
[
{
"AllowedOrigins": [
"https://your-mini-app-origin.example",
"http://localhost:2999",
"http://localhost:3000"
],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
```
Nếu thiếu CORS, URL vẫn ký hợp lệ nhưng browser sẽ chặn request. Client phải gửi chính xác
`requiredHeaders` do endpoint presign trả về.
## Quy ước khi mở rộng upload sau này
- Thêm purpose cụ thể vào allowlist; không biến endpoint thành nơi nhận object key tùy ý từ client.
- Mỗi purpose phải có prefix theo ownership, MIME allowlist, giới hạn kích thước và TTL riêng.
- File công khai (như avatar) có thể dùng `publicUrl`; hóa đơn/tài liệu riêng tư phải giữ bucket private và đọc bằng API hoặc presigned GET có kiểm tra ownership.
- Chỉ lưu URL/key nghiệp vụ sau khi PUT thành công. Cần thêm cơ chế xác nhận object (HEAD) nếu use case có yêu cầu toàn vẹn cao.
- `fileSize` hiện là dữ liệu client khai báo, giúp chặn lỗi thông thường nhưng không phải quota cưỡng chế tuyệt đối của R2 presigned PUT. Với file lớn hoặc quota nghiêm ngặt, bổ sung bước xác nhận server-side và dọn object vi phạm.
- Có kế hoạch xóa object cũ và object mồ côi khi người dùng thay file nhưng không hoàn tất bước lưu nghiệp vụ.
......@@ -23,6 +23,8 @@
"test:cov": "jest --coverage --runInBand"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1107.0",
"@aws-sdk/s3-request-presigner": "^3.1107.0",
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
......
......@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@aws-sdk/client-s3':
specifier: ^3.1107.0
version: 3.1107.0
'@aws-sdk/s3-request-presigner':
specifier: ^3.1107.0
version: 3.1107.0
'@prisma/client':
specifier: ^5.22.0
version: 5.22.0(prisma@5.22.0)
......@@ -126,6 +132,82 @@ importers:
packages:
'@aws-sdk/checksums@3.1000.26':
resolution: {integrity: sha512-CGznePoL+1oWCSzqmkvlYMpWQEZohPR3LntjKXXfVH+oh6Kd8d+yHLkjpiAGwPIHAxFe4OAq3aKfRnv/wqWIyA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-s3@3.1107.0':
resolution: {integrity: sha512-95N3VATuqbyhEUO0YHQSsgKdkG872rZXm+O2ZkffsF8RJLRDQAsplVD8/OdWW3vX78vNDogK/zbfD1VG1OcSIA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.977.6':
resolution: {integrity: sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.67':
resolution: {integrity: sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.69':
resolution: {integrity: sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.12':
resolution: {integrity: sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.74':
resolution: {integrity: sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.78':
resolution: {integrity: sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.67':
resolution: {integrity: sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.11':
resolution: {integrity: sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.73':
resolution: {integrity: sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-s3@3.972.72':
resolution: {integrity: sha512-lSAoVPvQxX1d8TOM6waKDBQrvvZcm4w6pCldFAsRUffEaXq6lYY0pPyew3KlLu6Xqb74DXI42hGvSsbGBLljlw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.41':
resolution: {integrity: sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/s3-request-presigner@3.1107.0':
resolution: {integrity: sha512-/jlRXrrYLlVYmF33+ppCFMi8G+vYkt3LQZtwyVifDXBx66U1j1KqKstoGq6GXOQfb4I/gmojzACrrPU8eL37hw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.43':
resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1103.0':
resolution: {integrity: sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.2':
resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.37':
resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
......@@ -701,6 +783,30 @@ packages:
'@sinonjs/fake-timers@15.4.0':
resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==}
'@smithy/core@3.31.1':
resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.4.16':
resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.6.13':
resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.9.13':
resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.6.12':
resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.16.1':
resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==}
engines: {node: '>=18.0.0'}
'@tsconfig/node10@1.0.12':
resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==}
......@@ -1132,6 +1238,9 @@ packages:
resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
......@@ -2704,6 +2813,180 @@ packages:
snapshots:
'@aws-sdk/checksums@3.1000.26':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/client-s3@3.1107.0':
dependencies:
'@aws-sdk/checksums': 3.1000.26
'@aws-sdk/core': 3.977.6
'@aws-sdk/credential-provider-node': 3.972.78
'@aws-sdk/middleware-sdk-s3': 3.972.72
'@aws-sdk/signature-v4-multi-region': 3.996.43
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/fetch-http-handler': 5.6.13
'@smithy/node-http-handler': 4.9.13
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/core@3.977.6':
dependencies:
'@aws-sdk/types': 3.974.2
'@aws-sdk/xml-builder': 3.972.37
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.31.1
'@smithy/signature-v4': 5.6.12
'@smithy/types': 4.16.1
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.67':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.69':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/fetch-http-handler': 5.6.13
'@smithy/node-http-handler': 4.9.13
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.12':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/credential-provider-env': 3.972.67
'@aws-sdk/credential-provider-http': 3.972.69
'@aws-sdk/credential-provider-login': 3.972.74
'@aws-sdk/credential-provider-process': 3.972.67
'@aws-sdk/credential-provider-sso': 3.973.11
'@aws-sdk/credential-provider-web-identity': 3.972.73
'@aws-sdk/nested-clients': 3.997.41
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/credential-provider-imds': 4.4.16
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.74':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/nested-clients': 3.997.41
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.78':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.67
'@aws-sdk/credential-provider-http': 3.972.69
'@aws-sdk/credential-provider-ini': 3.973.12
'@aws-sdk/credential-provider-process': 3.972.67
'@aws-sdk/credential-provider-sso': 3.973.11
'@aws-sdk/credential-provider-web-identity': 3.972.73
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/credential-provider-imds': 4.4.16
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.67':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.11':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/nested-clients': 3.997.41
'@aws-sdk/token-providers': 3.1103.0
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.73':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/nested-clients': 3.997.41
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.72':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/signature-v4-multi-region': 3.996.43
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.41':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/signature-v4-multi-region': 3.996.43
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/fetch-http-handler': 5.6.13
'@smithy/node-http-handler': 4.9.13
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/s3-request-presigner@3.1107.0':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/signature-v4-multi-region': 3.996.43
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.43':
dependencies:
'@aws-sdk/types': 3.974.2
'@smithy/signature-v4': 5.6.12
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/token-providers@3.1103.0':
dependencies:
'@aws-sdk/core': 3.977.6
'@aws-sdk/nested-clients': 3.997.41
'@aws-sdk/types': 3.974.2
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/types@3.974.2':
dependencies:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.37':
dependencies:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {}
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
......@@ -3335,6 +3618,39 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
'@smithy/core@3.31.1':
dependencies:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/credential-provider-imds@4.4.16':
dependencies:
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/fetch-http-handler@5.6.13':
dependencies:
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/node-http-handler@4.9.13':
dependencies:
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/signature-v4@5.6.12':
dependencies:
'@smithy/core': 3.31.1
'@smithy/types': 4.16.1
tslib: 2.8.1
'@smithy/types@4.16.1':
dependencies:
tslib: 2.8.1
'@tsconfig/node10@1.0.12': {}
'@tsconfig/node12@1.0.11': {}
......@@ -3805,6 +4121,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
bowser@2.14.1: {}
brace-expansion@1.1.15:
dependencies:
balanced-match: 1.0.2
......@@ -5419,8 +5737,7 @@ snapshots:
strip-bom: 3.0.0
strip-json-comments: 2.0.1
tslib@2.8.1:
optional: true
tslib@2.8.1: {}
tsx@4.22.4:
dependencies:
......
-- AlterTable
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "avatar_url" TEXT,
ADD COLUMN "avatar_position_x" SMALLINT NOT NULL DEFAULT 50,
ADD COLUMN "avatar_position_y" SMALLINT NOT NULL DEFAULT 50;
-- AddConstraint
ALTER TABLE "users"
ADD CONSTRAINT "users_avatar_position_x_check" CHECK ("avatar_position_x" BETWEEN 0 AND 100),
ADD CONSTRAINT "users_avatar_position_y_check" CHECK ("avatar_position_y" BETWEEN 0 AND 100);
-- AlterTable
ALTER TABLE "users"
ADD COLUMN "avatar_zoom" SMALLINT NOT NULL DEFAULT 100;
-- AddConstraint
ALTER TABLE "users"
ADD CONSTRAINT "users_avatar_zoom_check" CHECK ("avatar_zoom" BETWEEN 100 AND 300);
-- AlterConstraint
ALTER TABLE "users"
DROP CONSTRAINT "users_avatar_zoom_check";
ALTER TABLE "users"
ADD CONSTRAINT "users_avatar_zoom_check" CHECK ("avatar_zoom" BETWEEN 1 AND 300);
-- AlterConstraint
ALTER TABLE "users"
DROP CONSTRAINT "users_avatar_zoom_check";
ALTER TABLE "users"
ADD CONSTRAINT "users_avatar_zoom_check" CHECK ("avatar_zoom" BETWEEN 100 AND 300);
-- DropConstraint
ALTER TABLE "users"
DROP CONSTRAINT "users_avatar_zoom_check";
-- AlterTable
ALTER TABLE "users"
DROP COLUMN "avatar_zoom";
......@@ -86,18 +86,20 @@ enum ReminderFrequency {
}
model User {
id String @id @default(uuid()) @db.Uuid
email String? @unique
password String?
fullName String? @map("full_name")
avatarUrl String? @map("avatar_url")
phoneNumber String? @unique @map("phone_number")
isActive Boolean @default(false) @map("is_active")
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") @db.Uuid
roleId String @map("role_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(uuid()) @db.Uuid
email String? @unique
password String?
fullName String? @map("full_name")
avatarUrl String? @map("avatar_url")
avatarPositionX Int @default(50) @map("avatar_position_x") @db.SmallInt
avatarPositionY Int @default(50) @map("avatar_position_y") @db.SmallInt
phoneNumber String? @unique @map("phone_number")
isActive Boolean @default(false) @map("is_active")
deletedAt DateTime? @map("deleted_at")
deletedBy String? @map("deleted_by") @db.Uuid
roleId String @map("role_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
role Role @relation(fields: [roleId], references: [id], onDelete: Restrict)
......
......@@ -27,6 +27,7 @@ export const ERROR_CODE = {
FILE_TYPE_UNSUPPORTED: 'FILE_TYPE_UNSUPPORTED',
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
RECEIPT_NOT_FOUND: 'RECEIPT_NOT_FOUND',
STORAGE_NOT_CONFIGURED: 'STORAGE_NOT_CONFIGURED',
AI_PROVIDER_NOT_CONFIGURED: 'AI_PROVIDER_NOT_CONFIGURED',
AI_PROVIDER_UNAVAILABLE: 'AI_PROVIDER_UNAVAILABLE',
AI_RESPONSE_INVALID: 'AI_RESPONSE_INVALID',
......
......@@ -41,6 +41,24 @@ export const envConfig = {
return this.maxFileSizeMb * 1024 * 1024;
},
},
r2: {
accountId: process.env.R2_ACCOUNT_ID || '',
bucketName: process.env.R2_BUCKET_NAME || '',
accessKeyId: process.env.R2_ACCESS_KEY_ID || '',
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY || '',
publicBaseUrl: (process.env.R2_PUBLIC_BASE_URL || '').replace(/\/+$/, ''),
presignedUrlExpiresInSeconds: (() => {
const value = parseInt(process.env.R2_PRESIGNED_URL_EXPIRES_IN_SECONDS || '300', 10);
return Number.isFinite(value) && value >= 60 && value <= 3600 ? value : 300;
})(),
avatarMaxFileSizeMb: (() => {
const value = parseInt(process.env.R2_AVATAR_MAX_FILE_SIZE_MB || '5', 10);
return Number.isFinite(value) && value > 0 && value <= 10 ? value : 5;
})(),
get avatarMaxFileSizeBytes() {
return this.avatarMaxFileSizeMb * 1024 * 1024;
},
},
notifications: {
workerEnabled: process.env.NOTIFICATION_WORKER_ENABLED !== 'false',
workerIntervalMs: (() => {
......
......@@ -66,6 +66,10 @@ export const swaggerSpec = {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
fullName: { type: 'string', nullable: true },
avatarUrl: { type: 'string', format: 'uri', nullable: true },
avatarPositionX: { type: 'integer', minimum: 0, maximum: 100, example: 50 },
avatarPositionY: { type: 'integer', minimum: 0, maximum: 100, example: 50 },
phoneNumber: { type: 'string', nullable: true, example: '0912345678' },
roleId: { type: 'string', format: 'uuid' },
role: { $ref: '#/components/schemas/Role' },
isActive: { type: 'boolean' },
......@@ -145,10 +149,42 @@ export const swaggerSpec = {
type: 'object',
properties: {
fullName: { type: 'string', example: 'Nguyen Van B' },
avatarUrl: { type: 'string', format: 'uri', example: 'https://example.com/avatar.jpg' },
avatarUrl: { type: 'string', format: 'uri', nullable: true, example: 'https://example.com/avatar.jpg' },
avatarPositionX: { type: 'integer', minimum: 0, maximum: 100, example: 50 },
avatarPositionY: { type: 'integer', minimum: 0, maximum: 100, example: 50 },
phoneNumber: { type: 'string', example: '0912345678' },
},
},
CreatePresignedUploadBody: {
type: 'object',
required: ['purpose', 'fileName', 'contentType', 'fileSize'],
properties: {
purpose: { type: 'string', enum: ['avatar'], example: 'avatar' },
fileName: { type: 'string', maxLength: 255, example: 'avatar.png' },
contentType: {
type: 'string',
enum: ['image/jpeg', 'image/png', 'image/webp'],
example: 'image/png',
},
fileSize: { type: 'integer', minimum: 1, example: 245760 },
},
},
PresignedUpload: {
type: 'object',
required: ['uploadUrl', 'publicUrl', 'objectKey', 'expiresIn', 'requiredHeaders'],
properties: {
uploadUrl: { type: 'string', format: 'uri' },
publicUrl: { type: 'string', format: 'uri' },
objectKey: { type: 'string', example: 'avatars/user-id/random-id.png' },
expiresIn: { type: 'integer', example: 300 },
requiredHeaders: {
type: 'object',
properties: {
'Content-Type': { type: 'string', example: 'image/png' },
},
},
},
},
UpdatePasswordBody: {
type: 'object',
required: ['newPassword'],
......@@ -1786,6 +1822,7 @@ export const swaggerSpec = {
tags: [
{ name: 'System', description: 'Health check' },
{ name: 'Auth', description: 'Authentication endpoints' },
{ name: 'Uploads', description: 'Authenticated direct-to-R2 uploads' },
{ name: 'Users', description: 'User account management (Admin only)' },
{ name: 'Wallets', description: 'Authenticated user wallet management' },
{
......@@ -2024,6 +2061,48 @@ export const swaggerSpec = {
},
},
},
'/uploads/presign': {
post: {
tags: ['Uploads'],
summary: 'Create a presigned PUT URL for a direct browser upload',
description: 'Currently restricted to authenticated avatar uploads (JPEG, PNG, or WebP).',
security: [{ BearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: { $ref: '#/components/schemas/CreatePresignedUploadBody' },
},
},
},
responses: {
200: {
description: 'Presigned PUT URL created',
content: {
'application/json': {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{
type: 'object',
properties: {
data: { $ref: '#/components/schemas/PresignedUpload' },
},
},
],
},
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
422: { $ref: '#/components/responses/Validation' },
503: {
description: 'Cloudflare R2 is not configured',
content: { 'application/json': { schema: { $ref: '#/components/schemas/ErrorResponse' } } },
},
},
},
},
'/auth/profile': {
put: {
tags: ['Auth'],
......@@ -2038,7 +2117,13 @@ export const swaggerSpec = {
schema: {
allOf: [
{ $ref: '#/components/schemas/SuccessResponse' },
{ type: 'object', properties: { message: { type: 'string', example: 'Profile updated successfully' } } },
{
type: 'object',
properties: {
message: { type: 'string', example: 'Profile updated successfully' },
data: { $ref: '#/components/schemas/User' },
},
},
],
},
},
......
......@@ -135,11 +135,12 @@ export class AuthController {
updateProfile = async (req: Request, res: Response, next: NextFunction) => {
try {
const body = req.body as UpdateProfileDto;
await this.service.updateProfile(req.user.id, body);
const result = await this.service.updateProfile(req.user.id, body);
res.json({
success: true,
message: 'Profile updated successfully',
data: result,
});
} catch (error) {
next(error);
......
......@@ -18,25 +18,31 @@ export interface MeDto {
id: string;
email: string | null;
fullName: string | null;
role: string;
avatarUrl: string | null;
avatarPositionX: number;
avatarPositionY: number;
phoneNumber: string | null;
roleId: string;
role: {
id: string;
name: string;
};
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface LoginResponseDto {
accessToken: string;
refreshToken: string;
user: {
id: string;
email: string | null;
fullName: string | null;
role: string;
};
user: MeDto;
}
export interface UpdateProfileDto {
fullName?: string;
avatarUrl?: string;
avatarUrl?: string | null;
avatarPositionX?: number;
avatarPositionY?: number;
phoneNumber?: string;
}
......
......@@ -140,7 +140,13 @@ export class AuthRepository {
});
}
async updateProfile(userId: string, data: { fullName?: string; avatarUrl?: string; phoneNumber?: string }) {
async updateProfile(userId: string, data: {
fullName?: string;
avatarUrl?: string | null;
avatarPositionX?: number;
avatarPositionY?: number;
phoneNumber?: string;
}) {
return prisma.user.update({
where: { id: userId },
data,
......
......@@ -94,7 +94,18 @@ export class AuthService {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role.name,
avatarUrl: user.avatarUrl,
avatarPositionX: user.avatarPositionX,
avatarPositionY: user.avatarPositionY,
phoneNumber: user.phoneNumber,
roleId: user.roleId,
role: {
id: user.role.id,
name: user.role.name,
},
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
};
}
......@@ -165,9 +176,18 @@ export class AuthService {
id: user.id,
email: user.email,
fullName: user.fullName,
role: user.role.name,
avatarUrl: user.avatarUrl,
avatarPositionX: user.avatarPositionX,
avatarPositionY: user.avatarPositionY,
phoneNumber: user.phoneNumber,
roleId: user.roleId,
role: {
id: user.role.id,
name: user.role.name,
},
isActive: user.isActive,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
};
}
......@@ -219,7 +239,7 @@ export class AuthService {
await this.repository.deleteVerificationToken(verificationToken.id);
}
async updateProfile(userId: string, data: UpdateProfileDto): Promise<void> {
async updateProfile(userId: string, data: UpdateProfileDto): Promise<MeDto> {
const user = await this.repository.findById(userId);
if (!user) {
throw new AppError('User not found', 404, ERROR_CODE.NOT_FOUND);
......@@ -232,7 +252,16 @@ export class AuthService {
}
}
await this.repository.updateProfile(userId, data);
const profileData = data.avatarUrl === null
? {
...data,
avatarPositionX: 50,
avatarPositionY: 50,
}
: data;
await this.repository.updateProfile(userId, profileData);
return this.getMe(userId);
}
async updatePassword(userId: string, data: UpdatePasswordDto): Promise<void> {
......
......@@ -37,7 +37,15 @@ export const verifyEmailSchema = z.object({
export const updateProfileSchema = z.object({
fullName: z.string().min(1, 'Full name cannot be empty').optional(),
avatarUrl: z.string().url('Invalid avatar URL format').optional().or(z.literal('')),
avatarUrl: z
.union([
z.string().url('Invalid avatar URL format'),
z.null(),
z.literal('').transform(() => null),
])
.optional(),
avatarPositionX: z.number().int().min(0).max(100).optional(),
avatarPositionY: z.number().int().min(0).max(100).optional(),
phoneNumber: z.string().regex(/^[0-9]{10,11}$/, 'Invalid phone number format (must be 10-11 digits)').optional(),
});
......
import { NextFunction, Request, Response } from 'express';
import { CreatePresignedUploadDto } from './upload.dto';
import { UploadService } from './upload.service';
export class UploadController {
private readonly service = new UploadService();
createPresignedUpload = async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await this.service.createPresignedUpload(
req.user.id,
req.body as CreatePresignedUploadDto,
);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
}
export const UPLOAD_PURPOSES = ['avatar'] as const;
export type UploadPurpose = (typeof UPLOAD_PURPOSES)[number];
export const AVATAR_CONTENT_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
] as const;
export type AvatarContentType = (typeof AVATAR_CONTENT_TYPES)[number];
export interface CreatePresignedUploadDto {
purpose: UploadPurpose;
fileName: string;
contentType: AvatarContentType;
fileSize: number;
}
export interface PresignedUploadDto {
uploadUrl: string;
publicUrl: string;
objectKey: string;
expiresIn: number;
requiredHeaders: {
'Content-Type': AvatarContentType;
};
}
import { Router } from 'express';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { UploadController } from './upload.controller';
import { createPresignedUploadSchema } from './upload.validation';
const router = Router();
const controller = new UploadController();
router.post(
'/presign',
authMiddleware,
validate(createPresignedUploadSchema),
controller.createPresignedUpload,
);
export default router;
import { randomUUID } from 'crypto';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { AppError } from '../../common/errors/app-error';
import { ERROR_CODE } from '../../common/errors/error-code';
import { envConfig } from '../../config/env.config';
import {
AvatarContentType,
CreatePresignedUploadDto,
PresignedUploadDto,
} from './upload.dto';
const EXTENSION_BY_CONTENT_TYPE: Record<AvatarContentType, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
};
export class UploadService {
async createPresignedUpload(
userId: string,
data: CreatePresignedUploadDto,
): Promise<PresignedUploadDto> {
const config = envConfig.r2;
if (
!config.accountId
|| !config.bucketName
|| !config.accessKeyId
|| !config.secretAccessKey
|| !config.publicBaseUrl
) {
throw new AppError(
'Cloudflare R2 storage is not configured',
503,
ERROR_CODE.STORAGE_NOT_CONFIGURED,
);
}
const extension = EXTENSION_BY_CONTENT_TYPE[data.contentType];
const objectKey = `avatars/${userId}/${randomUUID()}.${extension}`;
const client = new S3Client({
region: 'auto',
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
const command = new PutObjectCommand({
Bucket: config.bucketName,
Key: objectKey,
ContentType: data.contentType,
});
const uploadUrl = await getSignedUrl(client, command, {
expiresIn: config.presignedUrlExpiresInSeconds,
});
const publicPath = objectKey
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/');
return {
uploadUrl,
publicUrl: `${config.publicBaseUrl}/${publicPath}`,
objectKey,
expiresIn: config.presignedUrlExpiresInSeconds,
requiredHeaders: {
'Content-Type': data.contentType,
},
};
}
}
import { z } from 'zod';
import { envConfig } from '../../config/env.config';
import { AVATAR_CONTENT_TYPES, UPLOAD_PURPOSES } from './upload.dto';
export const createPresignedUploadSchema = z.object({
purpose: z.enum(UPLOAD_PURPOSES),
fileName: z.string().trim().min(1).max(255),
contentType: z.enum(AVATAR_CONTENT_TYPES),
fileSize: z
.number()
.int()
.positive()
.max(
envConfig.r2.avatarMaxFileSizeBytes,
`Avatar must not exceed ${envConfig.r2.avatarMaxFileSizeMb} MB`,
),
}).strict();
......@@ -10,6 +10,7 @@ import reportRoute from '../modules/reports/report.route';
import notificationRoute from '../modules/notifications/notification.route';
import reminderRoute from '../modules/reminders/reminder.route';
import aiAssistantRoute from '../modules/ai-assistant/ai-assistant.route';
import uploadRoute from '../modules/uploads/upload.route';
import { healthCheck } from './health.controller';
......@@ -28,5 +29,6 @@ router.use('/reports', reportRoute);
router.use('/notifications', notificationRoute);
router.use('/reminders', reminderRoute);
router.use('/ai-assistant', aiAssistantRoute);
router.use('/uploads', uploadRoute);
export default router;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment