Commit e5fef1a7 authored by ThinhNC's avatar ThinhNC

feat: initial commit for finwise-miniapp-be

parents
# Bộ nhớ và quy tắc làm việc của dự án
Thư mục này tách phần hướng dẫn chi tiết khỏi `AGENTS.md` để dễ đọc và chỉnh sửa.
`AGENTS.md` ở root vẫn là điểm vào được Codex tự động phát hiện.
```text
.
├── AGENTS.md # Hướng dẫn chính, Codex tự đọc
└── .agents/
├── README.md # Sơ đồ và cách bảo trì
├── project.md # Bản đồ dự án
├── memory.md # Sự thật/quyết định dài hạn
├── local.md.example # Mẫu ghi chú riêng theo máy
├── rules/
│ ├── architecture.md # Ranh giới các layer/module
│ ├── tech-defaults.md # Quy ước TypeScript, Express, Prisma
│ └── workflow.md # Quy trình sửa và kiểm tra
└── checklists/
├── new-module.md # Checklist thêm module API
└── review.md # Checklist review thay đổi
```
## Cách chỉnh sửa
- Quy tắc áp dụng cho mọi công việc: đặt trong `AGENTS.md`.
- Mô tả cấu trúc thực tế: cập nhật `project.md`.
- Quyết định đã thống nhất và còn hiệu lực: cập nhật `memory.md`.
- Quy tắc chuyên sâu: cập nhật file phù hợp trong `rules/`.
- Ghi chú cá nhân hoặc lệnh riêng theo máy: copy `local.md.example` thành
`local.md`. File này đã được ignore.
Giữ tài liệu ngắn và có thể hành động. Không lưu secret, token, mật khẩu, dữ liệu
khách hàng hoặc nội dung của `.env` trong thư mục này.
# Checklist thêm module API
- [ ] Xác định endpoint, actor, quyền truy cập và response/error contract.
- [ ] Tạo DTO/type cho input/output.
- [ ] Tạo Zod schema cho body/query/params.
- [ ] Tạo repository chứa Prisma query và lọc field nhạy cảm.
- [ ] Tạo service chứa business rule và transaction boundary.
- [ ] Tạo controller chỉ xử lý HTTP.
- [ ] Tạo route với đúng thứ tự auth, role, validation, controller.
- [ ] Mount route ở `src/routes/index.ts`.
- [ ] Dùng `AppError` + `ERROR_CODE` cho expected failure.
- [ ] Kiểm tra ownership, soft delete, pagination và concurrency nếu liên quan.
- [ ] Cập nhật Swagger/README khi public API thay đổi.
- [ ] Chạy build và các kiểm tra Prisma phù hợp.
- [ ] Thêm test khi dự án có test framework; ghi rõ nếu hiện chưa thể test.
# Checklist review thay đổi
## Correctness
- [ ] Happy path và failure path đúng contract.
- [ ] Dữ liệu đã validate trước khi dùng.
- [ ] Null/optional, pagination, sorting và timezone được xử lý rõ.
- [ ] Nhiều write phụ thuộc nhau nằm trong transaction.
## Security
- [ ] Endpoint có auth/role/ownership phù hợp.
- [ ] Không lộ password, token, secret hoặc field riêng tư.
- [ ] Không tin identifier/role do client tự khai báo.
- [ ] CORS, cookie, JWT và rate limit không bị nới lỏng ngoài chủ ý.
## Database
- [ ] Query tôn trọng soft delete.
- [ ] Migration không phá dữ liệu ngoài chủ ý và có index/constraint phù hợp.
- [ ] Không sửa migration lịch sử đã được dùng.
- [ ] Tiền tệ không bị chuyển sang floating point thiếu chính xác.
## Maintainability
- [ ] Đúng ranh giới route/validation/controller/service/repository.
- [ ] Không có refactor hoặc dependency ngoài phạm vi.
- [ ] Documentation và `.agents/memory.md` phản ánh quyết định bền vững mới.
- [ ] Verification được báo chính xác, không đánh đồng build với test.
# Local Agent Notes
Copy file này thành `.agents/local.md` để thêm quy ước riêng cho máy của bạn.
`local.md` không được commit và được đọc sau các quy tắc chung.
Ví dụ các nội dung phù hợp:
- Lệnh khởi động database local.
- Tên container hoặc port riêng trên máy.
- Công cụ kiểm tra bổ sung đã cài local.
- Cách trình bày phản hồi cá nhân.
Không đặt secret, token, mật khẩu hoặc nội dung `.env` ở đây.
This diff is collapsed.
# Bản đồ dự án FinWise Backend
## Mục đích
Backend API cho FinWise, một Zalo Mini App quản lý thu chi và báo cáo tài chính.
Các API đang được mount dưới `/api/v1`; Swagger UI ở `/api/docs`.
## Công nghệ
- Node.js + Express 4 + TypeScript (CommonJS, strict mode)
- Prisma 5 + PostgreSQL
- Zod cho validation
- JWT cho authentication, role middleware cho authorization
- pnpm là package manager chuẩn
## Cấu trúc runtime
```text
src/
├── server.ts # Nạp env và mở HTTP server
├── app.ts # Khởi tạo Express và middleware
├── routes/index.ts # Mount route cấp /api/v1
├── modules/
│ ├── auth/ # Đăng ký, đăng nhập, token, profile, session
│ └── users/ # Quản trị user
├── middlewares/ # Auth, role, validation, rate limit, error
├── config/ # Env, DB, JWT, mail, Swagger
├── database/ # Prisma client
└── common/
├── constants/
├── errors/
├── helpers/
├── services/
└── types/
prisma/
├── schema.prisma # Database schema
├── migrations/ # Lịch sử migration
└── seed.ts # Seed data
scripts/prisma-run.js # Wrapper chạy Prisma với env của dự án
```
## Luồng request chuẩn
```text
Express router
-> auth/role middleware (khi cần)
-> Zod validation
-> controller
-> service
-> repository
-> Prisma/PostgreSQL
-> response hoặc error middleware
```
## Phạm vi hiện tại
- Route hoạt động: health, auth, users, wallets, categories, transactions, transfers, budgets,
saving goals, financial reports và AI Financial Assistant.
- Wallet, Category, Transaction, Transfer và Budget có module API theo ownership trong
`src/modules/`.
- Financial Reports tổng hợp dữ liệu hiện có theo khoảng thời gian và currency,
không lưu snapshot báo cáo riêng trong database.
- AI Financial Assistant cung cấp phân loại giao dịch, OCR hóa đơn, hỏi đáp tài chính,
phân tích xu hướng/bất thường và khuyến nghị. Module chỉ đọc dữ liệu thuộc người dùng,
không lưu hội thoại hoặc kết quả AI và truy cập mô hình qua provider interface.
- Notification cung cấp inbox, trạng thái đã đọc, cấu hình kênh và outbox giao nhận;
Reminder hỗ trợ lịch một lần hoặc lặp lại và được xử lý bởi worker nền.
- Email verification, password reset, cảnh báo thiết bị và quản lý session nằm
trong module auth.
## File sinh tự động hoặc không được sửa trực tiếp
- `dist/`
- `node_modules/`
- Prisma Client được generate
- Migration cũ đã được dùng ở môi trường chia sẻ
# Quy tắc kiến trúc
## Ranh giới layer
### Route
- Khai báo HTTP method/path và thứ tự middleware.
- Áp dụng auth, role và validation trước controller.
- Không chứa business logic hoặc Prisma query.
### Validation
- Dùng Zod cho body/query/params có dữ liệu cần kiểm tra.
- Normalize/coerce dữ liệu tại schema khi phù hợp.
- DTO phải phản ánh dữ liệu sau validation, tránh cast che lỗi kiểu.
### Controller
- Đọc request đã validate, gọi service và tạo HTTP response.
- Chuyển lỗi cho error middleware; không lặp lại mapping lỗi ở từng controller.
- Không query Prisma, hash password hoặc thực thi business rule.
### Service
- Chứa use case, business rule, authorization theo dữ liệu và điều phối nhiều
repository/service.
- Dùng `AppError` + `ERROR_CODE` cho lỗi dự kiến.
- Dùng transaction khi nhiều database write phải thành công hoặc thất bại cùng
nhau.
### Repository
- Là nơi duy nhất trong module thực hiện Prisma query.
- Không tạo HTTP response hoặc phụ thuộc Express.
- Mặc định loại record soft-deleted khi nghiệp vụ yêu cầu dữ liệu đang hoạt động.
- Chỉ trả các field cần thiết; không làm rò `password`, token hay dữ liệu nhạy cảm.
## Thêm module mới
- Theo naming hiện tại:
`<name>.route.ts`, `<name>.validation.ts`, `<name>.controller.ts`,
`<name>.service.ts`, `<name>.repository.ts`, `<name>.dto.ts`.
- Mount route ở `src/routes/index.ts`.
- Dùng checklist `.agents/checklists/new-module.md`.
## Database
- Thay đổi schema phải xem xét migration, index, unique constraint, quan hệ và
`onDelete`.
- Không sửa migration cũ đã chia sẻ; tạo migration mới.
- Không chạy `db:migrate:reset` nếu người dùng chưa yêu cầu rõ ràng.
- Với tiền tệ, giữ Prisma `Decimal`; không âm thầm chuyển sang JavaScript float.
# Quy ước kỹ thuật mặc định
## TypeScript
- Giữ `strict` và tránh thêm `any`; nếu thư viện buộc phải dùng, cô lập và giải
thích ở phạm vi nhỏ nhất.
- Ưu tiên type/interface rõ ràng ở biên module.
- Không dùng type assertion để bỏ qua validation hoặc nullability nếu có thể kiểm
tra đúng tại runtime.
- Theo style hiện tại: single quote, semicolon, trailing comma ở multiline.
## Express và API
- Response thành công giữ shape nhất quán: `{ success: true, ... }`.
- Error đi qua `errorMiddleware` và có `message`, `code`.
- Route mới phải xác định rõ public hay cần `authMiddleware`/`requireRole`.
- Không tin dữ liệu từ `req.body`, `req.query`, `req.params` trước validation.
- Không ghi access token, refresh token, password hay cookie nhạy cảm vào log.
## Auth và security
- Hash password bằng primitive hiện có; không lưu hoặc trả password plaintext.
- Kiểm tra ownership/role ở server, không dựa vào client.
- Secret chỉ lấy từ environment/config; không hard-code secret mới.
- Với thay đổi token/cookie/CORS/rate limit, kiểm tra cả luồng login, refresh,
logout và failure path.
## Prisma
- Query nằm ở repository.
- Dùng `$transaction` cho các write phụ thuộc nhau.
- Cân nhắc pagination và index cho endpoint dạng danh sách.
- Select/omit field nhạy cảm một cách chủ động.
- Chạy validate/generate phù hợp khi sửa `schema.prisma`.
## Dependency và generated output
- Không thêm production dependency nếu thư viện hiện có hoặc Node.js built-in đã
đáp ứng rõ ràng.
- Không sửa `dist/`, `node_modules/` hoặc generated Prisma Client.
- Khi thêm env var, cập nhật `.env.example` bằng placeholder an toàn và cập nhật
config validation/access tương ứng; không sao chép giá trị từ `.env`.
# Quy trình làm việc
## 1. Trước khi sửa
- Chạy `git status --short` và giữ nguyên thay đổi không liên quan của người dùng.
- Đọc entry point, module liên quan, Prisma schema và config cần thiết.
- Tóm tắt phạm vi cùng giả định quan trọng trước thay đổi lớn.
## 2. Khi triển khai
- Tạo thay đổi nhỏ, tập trung và theo kiến trúc hiện tại.
- Không refactor ngoài phạm vi chỉ để làm đẹp.
- Khi hành vi API đổi, cập nhật route/validation/DTO/Swagger hoặc README có liên
quan nếu chúng đang mô tả hành vi đó.
- Khi schema đổi, thêm migration mới và kiểm tra tác động dữ liệu.
## 3. Verification
Chọn kiểm tra tương ứng với thay đổi:
```bash
pnpm build
pnpm exec prisma validate
pnpm run prisma:generate
```
- Luôn ưu tiên `pnpm build` cho thay đổi TypeScript.
- Chạy `prisma validate` khi sửa schema, seed hoặc database config.
- Chạy generate khi Prisma schema làm thay đổi client types.
- Chỉ chạy `pnpm lint` khi ESLint config đã tồn tại và hoạt động.
- Hiện chưa có test suite; không tuyên bố “tests pass” nếu chỉ build thành công.
- Không chạy migration reset hoặc seed lên database không xác định.
## 4. Trước khi bàn giao
- Xem lại `git diff --check`, `git diff``git status --short`.
- Kiểm tra không có `.env`, secret, generated output hoặc file ngoài phạm vi bị
đưa vào diff.
- Báo ngắn gọn: kết quả, file chính, verification đã chạy và phần chưa kiểm tra.
- Cập nhật `.agents/memory.md` nếu có quyết định dài hạn mới.
node_modules
dist
.env
.env.local
.DS_Store
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
coverage
.vscode
.idea
*.log
NODE_ENV=development
PORT=7777
DATABASE_URL="postgresql://postgres:[PASSWORD]@localhost:5432/datafinwise?schema=public"
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=[PASSWORD]
DB_NAME=datafinwise
JWT_ACCESS_SECRET=change_me_access_secret
JWT_REFRESH_SECRET=change_me_refresh_secret
JWT_ACCESS_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d
REDIS_URL=redis://red-d2xxxxxxxxxxxxxxxxxx:6379
REDIS_HOST=localhost
REDIS_PORT=7379
REDIS_PASSWORD=
REDIS_ENABLED=true
RATE_LIMIT_MAX_REQUESTS=1000
RATE_LIMIT_WINDOW_MS=900000
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_SECURE=false
MAIL_USER=your_gmail_user@gmail.com
MAIL_PASS=your_gmail_app_password
MAIL_FROM="FinWise <noreply@gmail.com>"
APP_URL=http://localhost:7777
TRUST_PROXY=false
ALLOWED_ORIGINS=http://localhost:2999,http://localhost:3000,http://localhost:5173
# Legacy local receipt directory (read-only compatibility for files uploaded before R2)
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
RECURRING_TRANSACTION_BATCH_LIMIT=100
AI_PROVIDER=gemini
GEMINI_API_KEYS=replace_with_key_1,replace_with_key_2
GEMINI_MODEL=gemini-3.6-flash
GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta
AI_REQUEST_TIMEOUT_MS=30000
AI_MAX_OUTPUT_TOKENS=2048
AI_MAX_CONTEXT_TRANSACTIONS=200
AI_RATE_LIMIT_MAX_REQUESTS=20
AI_RATE_LIMIT_WINDOW_MS=900000
# Zalo Mini App credentials (from developers.zalo.me)
ZALO_APP_ID=your_zalo_app_id
ZALO_APP_SECRET=your_zalo_app_secret_key
name: FinWise CI Pipeline
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
build-and-test:
name: Build, Lint & Validate
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 9.15.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Validate Prisma Schema
run: pnpm exec prisma validate
- name: Run Lint Rules
run: pnpm run lint
- name: Build Code Compilation
run: pnpm run build
node_modules/
dist/
.env
*.log
storage/exports/*
!storage/exports/.gitkeep
storage/receipts/*
!storage/receipts/.gitkeep
.DS_Store
.agents/local.md
.wrangler/
.dev.vars
shamefully-hoist=true
# FinWise Backend Agent Guide
Tài liệu này là điểm vào chính cho mọi phiên làm việc với Codex trong repository.
Mục tiêu là giữ cách phân tích, triển khai và kiểm tra thay đổi nhất quán.
## Context bắt buộc
Trước khi sửa code:
1. Đọc `.agents/project.md`.
2. Đọc các quy tắc trong `.agents/rules/` có liên quan; với thay đổi code backend,
tối thiểu đọc cả ba file:
- `.agents/rules/architecture.md`
- `.agents/rules/tech-defaults.md`
- `.agents/rules/workflow.md`
3. Đọc `.agents/memory.md` để biết trạng thái và quyết định dài hạn hiện tại.
4. Nếu có `.agents/local.md`, đọc file đó sau cùng. Đây là ghi chú riêng của máy
và không được commit.
Code và cấu hình đang chạy là nguồn sự thật cao nhất. Nếu tài liệu khác với code,
hãy nêu sự khác biệt, làm theo yêu cầu hiện tại và cập nhật tài liệu khi thay đổi
đã được xác nhận.
## Nguyên tắc cốt lõi
- Giữ thay đổi đúng phạm vi yêu cầu; không tiện tay refactor phần không liên quan.
- Tôn trọng kiến trúc module hiện tại:
`route -> validation -> controller -> service -> repository`.
- Validate dữ liệu ở biên HTTP bằng Zod.
- Controller chỉ xử lý HTTP; business rule thuộc service; Prisma query thuộc
repository.
- Dùng `AppError``ERROR_CODE` cho lỗi nghiệp vụ có chủ đích.
- Không đọc, ghi log, commit hoặc đưa vào phản hồi giá trị bí mật từ `.env`.
- Không sửa trực tiếp `dist/`, `node_modules/` hoặc migration đã được áp dụng.
- Không chạy reset database, xóa dữ liệu hay tạo migration phá hủy nếu chưa có
yêu cầu và xác nhận rõ ràng.
- Dùng `pnpm` theo `packageManager` trong `package.json`.
## Prisma schema, migration và generated client
- Khi code sử dụng model, field, enum, relation, index hoặc default mới trong
`schema.prisma`, luôn kiểm tra cả `prisma/migrations/` và generated Prisma
Client. Không được kết luận “schema đã có nên không cần migration”.
- Mọi thay đổi database chưa có trong migration history phải có migration mới;
không sửa migration cũ đã được áp dụng.
- Sau thay đổi Prisma, bắt buộc chạy theo thứ tự phù hợp:
`pnpm exec prisma validate`, `pnpm run prisma:generate`, rồi `pnpm build`.
Xác nhận generated types thực sự chứa field mới; không chỉ dựa vào một lần
build thành công vì TypeScript hoặc IDE có thể đang resolve client khác/stale.
- Nếu database local đã được đồng bộ bằng `db push` nhưng migration history còn
thiếu, tạo migration cho môi trường mới mà không reset hoặc tự ý apply lại lên
database hiện tại. Báo rõ nhu cầu baseline/`migrate resolve` nếu có.
- Nếu migration diff kéo theo thao tác phá hủy hoặc thay đổi ngoài phạm vi, không
đưa chúng vào âm thầm. Chỉ tạo migration an toàn đúng phạm vi hoặc dừng để xin
xác nhận, đồng thời ghi rõ migration debt còn lại.
## Hoàn tất công việc
- Kiểm tra diff để không ghi đè thay đổi có sẵn của người dùng.
- Chạy kiểm tra phù hợp theo `.agents/rules/workflow.md`.
- Nêu rõ file đã đổi, kiểm tra đã chạy và hạn chế còn lại.
- Khi một quyết định kiến trúc hoặc trạng thái dự án bền vững thay đổi, cập nhật
`.agents/memory.md`; không biến file này thành nhật ký từng phiên.
## Code review
- Ưu tiên lỗi correctness, security, phân quyền, validation, rò rỉ dữ liệu nhạy
cảm, tính nhất quán transaction và migration.
- Mỗi nhận xét phải chỉ ra file/vị trí, tác động và hướng sửa an toàn.
- Không báo lỗi chỉ mang tính format nếu formatter có thể xử lý tự động.
# --- BUILD STAGE ---
FROM node:20-alpine AS builder
WORKDIR /usr/src/app
# Prisma requires OpenSSL for its query engine.
RUN apk add --no-cache openssl
# Enable corepack to use pnpm defined in package.json
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Copy package descriptors first to leverage Docker layer caching
COPY package.json pnpm-lock.yaml ./
COPY prisma/schema.prisma ./prisma/
COPY scripts/prisma-run.js ./scripts/prisma-run.js
# Install all dependencies (including devDependencies)
RUN pnpm install --frozen-lockfile
# Generate Prisma Client
RUN pnpm run prisma:generate
# Copy source code and config
COPY tsconfig.json ./
COPY src/ ./src/
# Compile TypeScript code to JavaScript (outputs to dist/)
RUN pnpm run build
# --- RUNTIME STAGE ---
FROM node:20-alpine AS runner
WORKDIR /usr/src/app
# Prisma requires OpenSSL for its query engine.
RUN apk add --no-cache openssl
# Enable corepack to use pnpm
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
# Set runtime environment
ENV NODE_ENV=production
ENV PORT=8888
# Create storage directory for local receipts
RUN mkdir -p storage/receipts && chown -R node:node storage
# Copy package descriptors
COPY package.json pnpm-lock.yaml ./
COPY prisma/ ./prisma/
# Install only production dependencies
RUN pnpm install --prod --frozen-lockfile
# Copy compiled files from builder stage
COPY --from=builder /usr/src/app/dist ./dist
# Use non-root node user for security hardening
USER node
# Expose port
EXPOSE 8888
# Execute migrations deploy and start application
CMD ["pnpm", "start"]
This diff is collapsed.
services:
postgres:
image: postgres:16-alpine
container_name: finwise_postgres
environment:
POSTGRES_USER: "${DB_USER:-postgres}"
POSTGRES_PASSWORD: "${DB_PASSWORD:-postgres}"
POSTGRES_DB: "${DB_NAME:-datafinwise}"
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-datafinwise}"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: finwise_redis
ports:
- "${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
container_name: finwise_app
restart: always
ports:
- "${PORT:-8888}:${PORT:-8888}"
env_file:
- .env
environment:
NODE_ENV: "${NODE_ENV:-production}"
DB_HOST: postgres
DB_PORT: 5432
DB_USER: "${DB_USER:-postgres}"
DB_PASSWORD: "${DB_PASSWORD:-postgres}"
DB_NAME: "${DB_NAME:-datafinwise}"
DATABASE_URL: "postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@postgres:5432/${DB_NAME:-datafinwise}?schema=public"
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_ENABLED: "true"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
postgres_data:
This diff is collapsed.
This diff is collapsed.
# Hướng dẫn Cài đặt và Triển khai Hệ thống FinWise Backend
Tài liệu này cung cấp hướng dẫn cài đặt từ môi trường phát triển (Development) cục bộ cho đến môi trường vận hành thực tế (Production) có container hoá.
---
## 1. Yêu cầu Hệ thống tối thiểu
* **Node.js**: Phiên bản 18.x trở lên.
* **Package Manager**: `pnpm` phiên bản 9.x trở lên.
* **Database**: PostgreSQL 15.x trở lên.
* **Cache & Session**: Redis 7.x trở lên.
* **Docker & Docker Compose**: Nếu triển khai bằng Container.
---
## 2. Hướng dẫn Triển khai cục bộ (Local Development)
### Bước 2.1: Tải mã nguồn và Cài đặt thư viện phụ thuộc
Sử dụng `pnpm` để cài đặt dependencies theo chuẩn cấu hình `package.json`:
```bash
pnpm install
```
### Bước 2.2: Cấu hình biến môi trường
1. Sao chép file cấu hình mẫu:
```bash
cp .env.example .env
```
2. Mở file `.env` và cập nhật thông số kết nối Database, Redis và JWT:
```env
NODE_ENV=development
PORT=7777
# Kết nối PostgreSQL
DATABASE_URL="postgresql://postgres:password@localhost:5432/datafinwise?schema=public"
# Cấu hình Token bảo mật
JWT_ACCESS_SECRET="your_strong_access_secret_key"
JWT_REFRESH_SECRET="your_strong_refresh_secret_key"
JWT_ACCESS_EXPIRES_IN=1d
JWT_REFRESH_EXPIRES_IN=7d
# Kết nối Cache Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_ENABLED=true
# Dịch vụ AI (Trợ lý Tài chính)
AI_PROVIDER=gemini
GEMINI_API_KEYS="key1,key2" # Danh sách khóa xoay vòng ngăn lỗi quota limit
```
### Bước 2.3: Chuẩn bị Cơ sở dữ liệu (Prisma setup)
Chạy tuần tự các lệnh sau để kiểm tra cấu trúc schema, sinh kiểu (client types) và cập nhật cơ sở dữ liệu:
```bash
# Validate cấu trúc Prisma schema
pnpm exec prisma validate
# Sinh mã Prisma Client tương thích
pnpm run prisma:generate
# Triển khai các file migration và cập nhật cấu trúc database
pnpm run db:migrate:deploy
# Nạp dữ liệu mẫu demo phong phú (Ví, giao dịch, budget, saving goals mẫu)
pnpm run db:seed
```
### Bước 2.4: Khởi động Server phát triển
```bash
pnpm dev
```
Hệ thống sẽ chạy tại `http://localhost:7777`.
---
## 3. Triển khai Production sử dụng Docker (Khuyến nghị)
FinWise cung cấp file cấu hình Docker tối ưu bảo mật chạy dưới quyền **non-root user** để ngăn chặn leo thang đặc quyền bảo mật.
### Bước 3.1: Build Container Image
Dockerfile multi-stage giúp giảm tối đa dung lượng image và loại bỏ source code TypeScript thừa ở runtime:
```bash
docker build -t finwise-backend:latest .
```
### Bước 3.2: Chạy toàn bộ Stack dịch vụ bằng Docker Compose
File `docker-compose.yml` định nghĩa đầy đủ 3 services chính: `app` (Node.js API), `postgres` (Database), `redis` (Cache).
Đặc biệt, hệ thống sử dụng **Healthchecks** tích hợp để đảm bảo các dịch vụ hạ tầng sẵn sàng trước khi nạp ứng dụng.
Để khởi động toàn bộ hệ thống ở chế độ nền (detached mode):
```bash
docker compose up -d
```
Để theo dõi log hoạt động:
```bash
docker compose logs -f
```
Để dừng hệ thống và bảo lưu dữ liệu (Named volumes):
```bash
docker compose down
```
---
## 4. Triển khai bằng PM2 (Môi trường Linux VPS thông thường)
Nếu không sử dụng Docker trên máy chủ, sử dụng công cụ quản lý tiến trình **PM2** để chạy ngầm và tự động khởi động lại ứng dụng khi gặp sự cố crash.
### Bước 4.1: Cài đặt PM2 toàn cục
```bash
npm install -g pm2
```
### Bước 4.2: Build mã nguồn TypeScript thành Javascript
```bash
pnpm build
```
### Bước 4.3: Khởi động ứng dụng bằng PM2
Tạo file cấu hình `ecosystem.config.js` ở thư mục gốc:
```javascript
module.exports = {
apps: [
{
name: 'finwise-backend',
script: 'dist/server.js',
instances: 'max', // Chạy chế độ Cluster tận dụng tối đa số nhân CPU
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
},
},
],
};
```
Khởi động ứng dụng:
```bash
pm2 start ecosystem.config.js
```
Kiểm tra trạng thái các instances:
```bash
pm2 status
```
# Direct browser upload lên Cloudflare R2
## Phạm vi hiện tại
Luồng presigned PUT hiện phục vụ **avatar**. Frontend không gửi file avatar 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.
Hóa đơn giao dịch cũng được lưu trong R2 dưới prefix riêng tư `receipts/<userId>/`, nhưng binary
đi qua endpoint giao dịch có xác thực để backend kiểm tra chữ ký file và ownership. Hóa đơn không
dùng public URL; `GET /transactions/:id/receipt` đọc object sau khi kiểm tra giao dịch thuộc người dùng.
Các key local cũ vẫn được đọc tương thích từ `RECEIPT_UPLOAD_DIR`.
## 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ụ.
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: [
'dist/**/*',
'node_modules/**/*',
'.wrangler/**/*',
'scripts/**/*',
'prisma/**/*',
'eslint.config.mjs',
'jest.config.ts',
'tests/**/*',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
languageOptions: {
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-namespace': 'off',
'no-console': 'off',
'no-undef': 'off', // TypeScript compiler already checks undefined variables
},
}
);
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: ['**/*.spec.ts', '**/*.test.ts'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
verbose: true,
forceExit: true,
clearMocks: true,
resetMocks: true,
restoreMocks: true,
testTimeout: 30000,
};
export default config;
{
"name": "finwise-miniapp-be",
"version": "1.0.0",
"description": "Backend API service for FinWise - Sổ tay Chi tiêu & Báo cáo Tài chính (Zalo Mini App)",
"main": "dist/server.js",
"packageManager": "pnpm@9.15.0",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"prisma:generate": "node scripts/prisma-run.js generate",
"prisma:studio": "node scripts/prisma-run.js studio",
"db:migrate": "node scripts/prisma-run.js migrate dev",
"db:migrate:init": "node scripts/prisma-run.js migrate dev --name init",
"db:migrate:deploy": "node scripts/prisma-run.js migrate deploy",
"db:migrate:reset": "node scripts/prisma-run.js migrate reset",
"db:migrate:status": "node scripts/prisma-run.js migrate status",
"db:seed": "node scripts/prisma-run.js db seed -- --tsx prisma/seed.ts",
"lint": "eslint .",
"format": "prettier --write .",
"test": "jest --runInBand",
"test:watch": "jest --watch --runInBand",
"test:cov": "jest --coverage --runInBand",
"wrangler:dev": "wrangler dev",
"wrangler:dev:remote": "wrangler dev --remote",
"wrangler:deploy": "wrangler deploy"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1107.0",
"@aws-sdk/s3-request-presigner": "^3.1107.0",
"@prisma/adapter-pg": "^5.22.0",
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"bullmq": "^6.2.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"helmet": "^8.0.0",
"ioredis": "^6.0.0",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"multer": "^2.2.0",
"nodemailer": "^9.0.3",
"pg": "^8.23.0",
"swagger-ui-express": "^5.0.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^5.20260820.1",
"@eslint/js": "^10.0.1",
"@types/bcryptjs": "^2.4.6",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/morgan": "^1.9.9",
"@types/multer": "^2.2.0",
"@types/node": "^22.10.2",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.23.1",
"@types/supertest": "^7.2.1",
"@types/swagger-ui-express": "^4.1.8",
"eslint": "^9.17.0",
"jest": "^30.4.2",
"prettier": "^3.4.2",
"prisma": "^5.22.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.12",
"ts-node": "^10.9.2",
"ts-node-dev": "^2.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.66.0",
"wrangler": "^4.124.0"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}
This diff is collapsed.
-- CreateEnum
CREATE TYPE "TransactionType" AS ENUM ('INCOME', 'EXPENSE');
-- CreateTable
CREATE TABLE "users" (
"id" UUID NOT NULL,
"email" TEXT NOT NULL,
"password" TEXT NOT NULL,
"full_name" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"role_id" UUID NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wallets" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"balance" DOUBLE PRECISION NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "wallets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "categories" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"type" "TransactionType" NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "transactions" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"wallet_id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"type" "TransactionType" NOT NULL,
"description" TEXT,
"date" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "transactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "budgets" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"start_date" TIMESTAMP(3) NOT NULL,
"end_date" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "budgets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "refresh_tokens" (
"id" UUID NOT NULL,
"token" TEXT NOT NULL,
"user_id" UUID NOT NULL,
"user_agent" TEXT,
"ip_address" TEXT,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
-- CreateIndex
CREATE UNIQUE INDEX "wallets_user_id_name_key" ON "wallets"("user_id", "name");
-- CreateIndex
CREATE UNIQUE INDEX "categories_user_id_name_type_key" ON "categories"("user_id", "name", "type");
-- CreateIndex
CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token");
-- CreateIndex
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wallets" ADD CONSTRAINT "wallets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_wallet_id_fkey" FOREIGN KEY ("wallet_id") REFERENCES "wallets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "budgets" ADD CONSTRAINT "budgets_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "budgets" ADD CONSTRAINT "budgets_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- DropForeignKey
ALTER TABLE "transactions" DROP CONSTRAINT "transactions_wallet_id_fkey";
-- AlterTable
ALTER TABLE "wallets"
ADD COLUMN "color" TEXT,
ADD COLUMN "currency" VARCHAR(3) NOT NULL DEFAULT 'VND',
ADD COLUMN "description" TEXT,
ADD COLUMN "icon" TEXT,
ADD COLUMN "is_archived" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "is_default" BOOLEAN NOT NULL DEFAULT false,
ALTER COLUMN "balance" SET DEFAULT 0,
ALTER COLUMN "balance" SET DATA TYPE DECIMAL(18, 2);
-- CreateIndex
CREATE INDEX "wallets_user_id_idx" ON "wallets"("user_id");
-- AddForeignKey
ALTER TABLE "transactions"
ADD CONSTRAINT "transactions_wallet_id_fkey"
FOREIGN KEY ("wallet_id") REFERENCES "wallets"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- Category records with a NULL user_id are shared system defaults.
ALTER TABLE "categories"
ALTER COLUMN "user_id" DROP NOT NULL,
ADD COLUMN "parent_id" UUID,
ADD COLUMN "icon" TEXT,
ADD COLUMN "color" TEXT,
ADD COLUMN "is_system" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "is_archived" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "categories"
ADD CONSTRAINT "categories_parent_id_fkey"
FOREIGN KEY ("parent_id") REFERENCES "categories"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "categories"
ADD CONSTRAINT "categories_system_owner_check"
CHECK (
("is_system" = true AND "user_id" IS NULL)
OR ("is_system" = false AND "user_id" IS NOT NULL)
);
CREATE UNIQUE INDEX "categories_system_name_type_ci_key"
ON "categories"(LOWER("name"), "type")
WHERE "is_system" = true;
CREATE UNIQUE INDEX "categories_user_name_type_ci_key"
ON "categories"("user_id", LOWER("name"), "type")
WHERE "is_system" = false;
CREATE INDEX "categories_user_id_is_archived_idx"
ON "categories"("user_id", "is_archived");
CREATE INDEX "categories_type_is_archived_idx"
ON "categories"("type", "is_archived");
CREATE INDEX "categories_parent_id_idx"
ON "categories"("parent_id");
DROP INDEX IF EXISTS "categories_user_id_idx";
-- Store transaction money with exact decimal precision and add receipt metadata.
ALTER TABLE "transactions"
ALTER COLUMN "amount" SET DATA TYPE DECIMAL(18, 2)
USING "amount"::DECIMAL(18, 2),
ADD COLUMN "receipt_url" TEXT,
ADD COLUMN "location" TEXT;
CREATE INDEX "transactions_user_id_idx"
ON "transactions"("user_id");
CREATE INDEX "transactions_wallet_id_idx"
ON "transactions"("wallet_id");
CREATE INDEX "transactions_category_id_idx"
ON "transactions"("category_id");
CREATE INDEX "transactions_date_idx"
ON "transactions"("date");
CREATE INDEX "transactions_user_id_date_idx"
ON "transactions"("user_id", "date");
CREATE INDEX "transactions_user_id_type_idx"
ON "transactions"("user_id", "type");
-- Add explicit budget scopes, cycle metadata, alert configuration, and archival.
CREATE TYPE "BudgetType" AS ENUM ('OVERALL', 'CATEGORY');
CREATE TYPE "BudgetPeriod" AS ENUM ('CUSTOM', 'WEEKLY', 'MONTHLY', 'YEARLY');
ALTER TABLE "budgets"
ALTER COLUMN "amount" SET DATA TYPE DECIMAL(18, 2)
USING "amount"::DECIMAL(18, 2),
ALTER COLUMN "category_id" DROP NOT NULL,
ADD COLUMN "type" "BudgetType" NOT NULL DEFAULT 'CATEGORY',
ADD COLUMN "period" "BudgetPeriod" NOT NULL DEFAULT 'CUSTOM',
ADD COLUMN "alert_threshold" DECIMAL(5, 2) NOT NULL DEFAULT 80,
ADD COLUMN "is_archived" BOOLEAN NOT NULL DEFAULT false;
-- Keep historical budgets when a category is referenced.
ALTER TABLE "budgets"
DROP CONSTRAINT "budgets_category_id_fkey",
ADD CONSTRAINT "budgets_category_id_fkey"
FOREIGN KEY ("category_id") REFERENCES "categories"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "budgets"
ADD CONSTRAINT "budgets_amount_positive_check"
CHECK ("amount" > 0),
ADD CONSTRAINT "budgets_date_range_check"
CHECK ("end_date" > "start_date"),
ADD CONSTRAINT "budgets_alert_threshold_check"
CHECK ("alert_threshold" > 0 AND "alert_threshold" <= 100),
ADD CONSTRAINT "budgets_scope_category_check"
CHECK (
("type" = 'OVERALL' AND "category_id" IS NULL)
OR ("type" = 'CATEGORY' AND "category_id" IS NOT NULL)
);
CREATE INDEX "budgets_category_id_idx"
ON "budgets"("category_id");
CREATE INDEX "budgets_user_id_is_archived_idx"
ON "budgets"("user_id", "is_archived");
CREATE INDEX "budgets_user_id_start_date_end_date_idx"
ON "budgets"("user_id", "start_date", "end_date");
CREATE INDEX "budgets_user_id_type_period_idx"
ON "budgets"("user_id", "type", "period");
-- Add saving goals and their contribution history.
CREATE TYPE "SavingGoalStatus" AS ENUM ('ACTIVE', 'PAUSED', 'COMPLETED');
CREATE TABLE "saving_goals" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"target_amount" DECIMAL(18, 2) NOT NULL,
"currency" VARCHAR(3) NOT NULL DEFAULT 'VND',
"target_date" TIMESTAMP(3) NOT NULL,
"description" TEXT,
"icon" TEXT,
"color" TEXT,
"status" "SavingGoalStatus" NOT NULL DEFAULT 'ACTIVE',
"completed_at" TIMESTAMP(3),
"is_archived" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "saving_goals_pkey" PRIMARY KEY ("id"),
CONSTRAINT "saving_goals_target_amount_positive_check"
CHECK ("target_amount" > 0),
CONSTRAINT "saving_goals_currency_format_check"
CHECK ("currency" ~ '^[A-Z]{3}$'),
CONSTRAINT "saving_goals_completion_check"
CHECK (
("status" = 'COMPLETED' AND "completed_at" IS NOT NULL)
OR ("status" <> 'COMPLETED' AND "completed_at" IS NULL)
)
);
CREATE TABLE "saving_contributions" (
"id" UUID NOT NULL,
"saving_goal_id" UUID NOT NULL,
"amount" DECIMAL(18, 2) NOT NULL,
"contributed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"note" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "saving_contributions_pkey" PRIMARY KEY ("id"),
CONSTRAINT "saving_contributions_amount_positive_check"
CHECK ("amount" > 0)
);
CREATE INDEX "saving_goals_user_id_is_archived_idx"
ON "saving_goals"("user_id", "is_archived");
CREATE INDEX "saving_goals_user_id_status_idx"
ON "saving_goals"("user_id", "status");
CREATE INDEX "saving_goals_user_id_target_date_idx"
ON "saving_goals"("user_id", "target_date");
CREATE INDEX "saving_contributions_saving_goal_id_contributed_at_idx"
ON "saving_contributions"("saving_goal_id", "contributed_at");
ALTER TABLE "saving_goals"
ADD CONSTRAINT "saving_goals_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "saving_contributions"
ADD CONSTRAINT "saving_contributions_saving_goal_id_fkey"
FOREIGN KEY ("saving_goal_id") REFERENCES "saving_goals"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- Keep budget utilization currency-safe. Existing budgets use the project's
-- historical default currency and can be updated explicitly after deployment.
ALTER TABLE "budgets"
ADD COLUMN "currency" VARCHAR(3) NOT NULL DEFAULT 'VND';
CREATE INDEX "budgets_user_id_currency_start_date_end_date_idx"
ON "budgets"("user_id", "currency", "start_date", "end_date");
-- CreateEnum
CREATE TYPE "NotificationType" AS ENUM ('BUDGET_NEAR_LIMIT', 'BUDGET_EXCEEDED', 'SAVING_GOAL_NEAR_TARGET', 'SAVING_GOAL_ACHIEVED', 'SAVING_GOAL_DUE_SOON', 'RECURRING_PAYMENT_DUE', 'UNUSUAL_TRANSACTION', 'USER_REMINDER', 'SYSTEM');
-- CreateEnum
CREATE TYPE "NotificationPriority" AS ENUM ('LOW', 'NORMAL', 'HIGH', 'CRITICAL');
-- CreateEnum
CREATE TYPE "NotificationChannel" AS ENUM ('IN_APP', 'EMAIL', 'ZALO', 'PUSH');
-- CreateEnum
CREATE TYPE "NotificationDeliveryStatus" AS ENUM ('PENDING', 'PROCESSING', 'SENT', 'FAILED', 'SKIPPED');
-- CreateEnum
CREATE TYPE "NotificationSourceType" AS ENUM ('BUDGET', 'SAVING_GOAL', 'TRANSACTION', 'REMINDER', 'SYSTEM');
-- CreateEnum
CREATE TYPE "ReminderType" AS ENUM ('GENERAL', 'RECURRING_PAYMENT');
-- CreateEnum
CREATE TYPE "ReminderFrequency" AS ENUM ('ONCE', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY');
-- CreateTable
CREATE TABLE "notifications" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"type" "NotificationType" NOT NULL,
"priority" "NotificationPriority" NOT NULL DEFAULT 'NORMAL',
"title" VARCHAR(160) NOT NULL,
"message" TEXT NOT NULL,
"channels" "NotificationChannel"[] NOT NULL DEFAULT ARRAY['IN_APP']::"NotificationChannel"[],
"data" JSONB,
"action_url" VARCHAR(500),
"source_type" "NotificationSourceType",
"source_id" UUID,
"dedup_key" VARCHAR(255) NOT NULL,
"read_at" TIMESTAMP(3),
"expires_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notifications_channels_not_empty_check"
CHECK (cardinality("channels") > 0)
);
-- CreateTable
CREATE TABLE "notification_deliveries" (
"id" UUID NOT NULL,
"notification_id" UUID NOT NULL,
"channel" "NotificationChannel" NOT NULL,
"status" "NotificationDeliveryStatus" NOT NULL DEFAULT 'PENDING',
"attempt_count" INTEGER NOT NULL DEFAULT 0,
"next_attempt_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sent_at" TIMESTAMP(3),
"failure_reason" VARCHAR(500),
"provider_message_id" VARCHAR(255),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_deliveries_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notification_deliveries_external_channel_check"
CHECK ("channel" <> 'IN_APP')
);
-- CreateTable
CREATE TABLE "notification_settings" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"channels" "NotificationChannel"[] NOT NULL DEFAULT ARRAY['IN_APP']::"NotificationChannel"[],
"budget_alerts_enabled" BOOLEAN NOT NULL DEFAULT true,
"saving_goal_alerts_enabled" BOOLEAN NOT NULL DEFAULT true,
"reminder_alerts_enabled" BOOLEAN NOT NULL DEFAULT true,
"unusual_txn_alerts_enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_settings_pkey" PRIMARY KEY ("id"),
CONSTRAINT "notification_settings_channels_not_empty_check"
CHECK (cardinality("channels") > 0)
);
-- CreateTable
CREATE TABLE "reminders" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"type" "ReminderType" NOT NULL DEFAULT 'GENERAL',
"title" VARCHAR(160) NOT NULL,
"message" TEXT,
"remind_at" TIMESTAMP(3) NOT NULL,
"frequency" "ReminderFrequency" NOT NULL DEFAULT 'ONCE',
"repeat_interval" INTEGER NOT NULL DEFAULT 1,
"end_at" TIMESTAMP(3),
"next_trigger_at" TIMESTAMP(3),
"last_triggered_at" TIMESTAMP(3),
"action_url" VARCHAR(500),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "reminders_pkey" PRIMARY KEY ("id"),
CONSTRAINT "reminders_repeat_interval_positive_check"
CHECK ("repeat_interval" > 0),
CONSTRAINT "reminders_end_at_check"
CHECK ("end_at" IS NULL OR "end_at" > "remind_at"),
CONSTRAINT "reminders_once_end_at_check"
CHECK ("frequency" <> 'ONCE' OR "end_at" IS NULL),
CONSTRAINT "reminders_active_trigger_check"
CHECK (NOT "is_active" OR "next_trigger_at" IS NOT NULL)
);
-- CreateIndex
CREATE UNIQUE INDEX "notifications_user_id_dedup_key_key" ON "notifications"("user_id", "dedup_key");
CREATE INDEX "notifications_user_id_created_at_idx" ON "notifications"("user_id", "created_at");
CREATE INDEX "notifications_user_id_read_at_created_at_idx" ON "notifications"("user_id", "read_at", "created_at");
CREATE INDEX "notifications_source_type_source_id_idx" ON "notifications"("source_type", "source_id");
CREATE UNIQUE INDEX "notification_deliveries_notification_id_channel_key" ON "notification_deliveries"("notification_id", "channel");
CREATE INDEX "notification_deliveries_status_next_attempt_at_idx" ON "notification_deliveries"("status", "next_attempt_at");
CREATE UNIQUE INDEX "notification_settings_user_id_key" ON "notification_settings"("user_id");
CREATE INDEX "reminders_user_id_is_active_next_trigger_at_idx" ON "reminders"("user_id", "is_active", "next_trigger_at");
CREATE INDEX "reminders_next_trigger_at_is_active_idx" ON "reminders"("next_trigger_at", "is_active");
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "notification_deliveries" ADD CONSTRAINT "notification_deliveries_notification_id_fkey" FOREIGN KEY ("notification_id") REFERENCES "notifications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "notification_settings" ADD CONSTRAINT "notification_settings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "reminders" ADD CONSTRAINT "reminders_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- 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";
-- Add wallet-to-wallet transfers with exact monetary values and ownership-scoped history.
CREATE TABLE "transfers" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"source_wallet_id" UUID NOT NULL,
"destination_wallet_id" UUID NOT NULL,
"amount" DECIMAL(18, 2) NOT NULL,
"note" TEXT,
"transferred_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "transfers_pkey" PRIMARY KEY ("id"),
CONSTRAINT "transfers_amount_positive_check" CHECK ("amount" > 0),
CONSTRAINT "transfers_wallets_different_check"
CHECK ("source_wallet_id" <> "destination_wallet_id")
);
CREATE INDEX "transfers_user_id_idx"
ON "transfers"("user_id");
CREATE INDEX "transfers_source_wallet_id_idx"
ON "transfers"("source_wallet_id");
CREATE INDEX "transfers_destination_wallet_id_idx"
ON "transfers"("destination_wallet_id");
CREATE INDEX "transfers_transferred_at_idx"
ON "transfers"("transferred_at");
CREATE INDEX "transfers_user_id_transferred_at_idx"
ON "transfers"("user_id", "transferred_at");
ALTER TABLE "transfers"
ADD CONSTRAINT "transfers_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "transfers"
ADD CONSTRAINT "transfers_source_wallet_id_fkey"
FOREIGN KEY ("source_wallet_id") REFERENCES "wallets"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "transfers"
ADD CONSTRAINT "transfers_destination_wallet_id_fkey"
FOREIGN KEY ("destination_wallet_id") REFERENCES "wallets"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
-- Business calendar fields are stored as PostgreSQL DATE.
-- Existing transaction/goal timestamps are interpreted as UTC instants and
-- converted to their Asia/Ho_Chi_Minh calendar date.
ALTER TABLE "transactions"
ALTER COLUMN "date" TYPE DATE
USING (("date" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Ho_Chi_Minh')::date;
ALTER TABLE "budgets"
ALTER COLUMN "start_date" TYPE DATE
USING (("start_date" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Ho_Chi_Minh')::date,
ALTER COLUMN "end_date" TYPE DATE
USING (
CASE
WHEN "period" = 'CUSTOM' THEN
((("end_date" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Ho_Chi_Minh')::date)
ELSE
((("end_date" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Ho_Chi_Minh')::date - 1)
END
);
ALTER TABLE "saving_goals"
ALTER COLUMN "target_date" TYPE DATE
USING (("target_date" AT TIME ZONE 'UTC') AT TIME ZONE 'Asia/Ho_Chi_Minh')::date;
-- TIMESTAMP WITHOUT TIME ZONE values were historically written by Prisma as
-- UTC wall-clock values. Attach UTC explicitly while converting to timestamptz.
ALTER TABLE "users"
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "roles"
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "wallets"
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "categories"
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "transactions"
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "transfers"
ALTER COLUMN "transferred_at" TYPE TIMESTAMPTZ(3) USING "transferred_at" AT TIME ZONE 'UTC',
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "budgets"
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "saving_goals"
ALTER COLUMN "completed_at" TYPE TIMESTAMPTZ(3) USING "completed_at" AT TIME ZONE 'UTC',
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "saving_contributions"
ALTER COLUMN "contributed_at" TYPE TIMESTAMPTZ(3) USING "contributed_at" AT TIME ZONE 'UTC',
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "refresh_tokens"
ALTER COLUMN "expires_at" TYPE TIMESTAMPTZ(3) USING "expires_at" AT TIME ZONE 'UTC',
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC';
ALTER TABLE "notifications"
ALTER COLUMN "read_at" TYPE TIMESTAMPTZ(3) USING "read_at" AT TIME ZONE 'UTC',
ALTER COLUMN "expires_at" TYPE TIMESTAMPTZ(3) USING "expires_at" AT TIME ZONE 'UTC',
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "notification_deliveries"
ALTER COLUMN "next_attempt_at" TYPE TIMESTAMPTZ(3) USING "next_attempt_at" AT TIME ZONE 'UTC',
ALTER COLUMN "sent_at" TYPE TIMESTAMPTZ(3) USING "sent_at" AT TIME ZONE 'UTC',
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "notification_settings"
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
ALTER TABLE "reminders"
ALTER COLUMN "remind_at" TYPE TIMESTAMPTZ(3) USING "remind_at" AT TIME ZONE 'UTC',
ALTER COLUMN "end_at" TYPE TIMESTAMPTZ(3) USING "end_at" AT TIME ZONE 'UTC',
ALTER COLUMN "next_trigger_at" TYPE TIMESTAMPTZ(3) USING "next_trigger_at" AT TIME ZONE 'UTC',
ALTER COLUMN "last_triggered_at" TYPE TIMESTAMPTZ(3) USING "last_triggered_at" AT TIME ZONE 'UTC',
ALTER COLUMN "created_at" TYPE TIMESTAMPTZ(3) USING "created_at" AT TIME ZONE 'UTC',
ALTER COLUMN "updated_at" TYPE TIMESTAMPTZ(3) USING "updated_at" AT TIME ZONE 'UTC';
-- These auth/device tables predate the checked-in migration history in some
-- environments. Convert them only where they already exist; their baseline
-- migration remains a separate schema-reconciliation concern.
DO $$
DECLARE table_name text;
BEGIN
FOREACH table_name IN ARRAY ARRAY['user_socials', 'verification_tokens', 'password_reset_tokens', 'user_devices']
LOOP
IF to_regclass('public.' || table_name) IS NOT NULL THEN
EXECUTE format(
'ALTER TABLE %I ALTER COLUMN created_at TYPE TIMESTAMPTZ(3) USING created_at AT TIME ZONE ''UTC''',
table_name
);
END IF;
END LOOP;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = 'users' AND column_name = 'deleted_at'
) THEN
ALTER TABLE "users"
ALTER COLUMN "deleted_at" TYPE TIMESTAMPTZ(3) USING "deleted_at" AT TIME ZONE 'UTC';
END IF;
IF to_regclass('public.verification_tokens') IS NOT NULL THEN
ALTER TABLE "verification_tokens"
ALTER COLUMN "expires_at" TYPE TIMESTAMPTZ(3) USING "expires_at" AT TIME ZONE 'UTC';
END IF;
IF to_regclass('public.password_reset_tokens') IS NOT NULL THEN
ALTER TABLE "password_reset_tokens"
ALTER COLUMN "expires_at" TYPE TIMESTAMPTZ(3) USING "expires_at" AT TIME ZONE 'UTC';
END IF;
IF to_regclass('public.user_devices') IS NOT NULL THEN
ALTER TABLE "user_devices"
ALTER COLUMN "last_login_at" TYPE TIMESTAMPTZ(3) USING "last_login_at" AT TIME ZONE 'UTC';
END IF;
END $$;
-- CreateIndex
CREATE INDEX "wallets_user_id_is_archived_idx" ON "wallets"("user_id", "is_archived");
-- CreateIndex
CREATE INDEX "notification_deliveries_status_updated_at_idx" ON "notification_deliveries"("status", "updated_at");
-- CreateEnum
CREATE TYPE "RecurringTransactionFrequency" AS ENUM ('DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY');
-- CreateEnum
CREATE TYPE "RecurringTransactionMissedRunPolicy" AS ENUM ('SKIP', 'CATCH_UP');
-- CreateEnum
CREATE TYPE "RecurringTransactionOccurrenceStatus" AS ENUM ('POSTED', 'FAILED');
-- CreateTable
CREATE TABLE "recurring_transaction_schedules" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"wallet_id" UUID NOT NULL,
"category_id" UUID NOT NULL,
"amount" DECIMAL(18,2) NOT NULL,
"type" "TransactionType" NOT NULL,
"description" TEXT,
"location" TEXT,
"frequency" "RecurringTransactionFrequency" NOT NULL,
"repeat_interval" INTEGER NOT NULL DEFAULT 1,
"anchor_date" DATE NOT NULL,
"end_date" DATE,
"next_run_at" DATE,
"missed_run_policy" "RecurringTransactionMissedRunPolicy" NOT NULL DEFAULT 'SKIP',
"last_run_at" TIMESTAMPTZ(3),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"deleted_at" TIMESTAMPTZ(3),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "recurring_transaction_schedules_pkey" PRIMARY KEY ("id"),
CONSTRAINT "recurring_transaction_schedules_amount_positive_check" CHECK ("amount" > 0),
CONSTRAINT "recurring_transaction_schedules_repeat_interval_positive_check" CHECK ("repeat_interval" > 0),
CONSTRAINT "recurring_transaction_schedules_end_date_check" CHECK ("end_date" IS NULL OR "end_date" >= "anchor_date"),
CONSTRAINT "recurring_transaction_schedules_active_next_run_check" CHECK (NOT "is_active" OR ("deleted_at" IS NULL AND "next_run_at" IS NOT NULL))
);
-- CreateTable
CREATE TABLE "recurring_transaction_occurrences" (
"id" UUID NOT NULL,
"schedule_id" UUID NOT NULL,
"scheduled_for" DATE NOT NULL,
"status" "RecurringTransactionOccurrenceStatus" NOT NULL DEFAULT 'POSTED',
"transaction_id" UUID,
"failure_code" VARCHAR(100),
"failure_message" VARCHAR(500),
"attempt_count" INTEGER NOT NULL DEFAULT 1,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "recurring_transaction_occurrences_pkey" PRIMARY KEY ("id"),
CONSTRAINT "recurring_transaction_occurrences_attempt_count_positive_check" CHECK ("attempt_count" > 0),
CONSTRAINT "recurring_transaction_occurrences_status_payload_check" CHECK (
("status" = 'POSTED' AND "failure_code" IS NULL)
OR ("status" = 'FAILED' AND "transaction_id" IS NULL AND "failure_code" IS NOT NULL)
)
);
-- CreateIndex
CREATE INDEX "recurring_transaction_schedules_user_id_deleted_at_is_active_idx" ON "recurring_transaction_schedules"("user_id", "deleted_at", "is_active");
CREATE INDEX "recurring_transaction_schedules_is_active_next_run_at_idx" ON "recurring_transaction_schedules"("is_active", "next_run_at");
CREATE INDEX "recurring_transaction_schedules_wallet_id_idx" ON "recurring_transaction_schedules"("wallet_id");
CREATE INDEX "recurring_transaction_schedules_category_id_idx" ON "recurring_transaction_schedules"("category_id");
CREATE UNIQUE INDEX "recurring_transaction_occurrences_transaction_id_key" ON "recurring_transaction_occurrences"("transaction_id");
CREATE UNIQUE INDEX "recurring_transaction_occurrences_schedule_id_scheduled_for_key" ON "recurring_transaction_occurrences"("schedule_id", "scheduled_for");
CREATE INDEX "recurring_transaction_occurrences_schedule_id_created_at_idx" ON "recurring_transaction_occurrences"("schedule_id", "created_at");
CREATE INDEX "recurring_transaction_occurrences_status_updated_at_idx" ON "recurring_transaction_occurrences"("status", "updated_at");
-- AddForeignKey
ALTER TABLE "recurring_transaction_schedules" ADD CONSTRAINT "recurring_transaction_schedules_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "recurring_transaction_schedules" ADD CONSTRAINT "recurring_transaction_schedules_wallet_id_fkey" FOREIGN KEY ("wallet_id") REFERENCES "wallets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "recurring_transaction_schedules" ADD CONSTRAINT "recurring_transaction_schedules_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "recurring_transaction_occurrences" ADD CONSTRAINT "recurring_transaction_occurrences_schedule_id_fkey" FOREIGN KEY ("schedule_id") REFERENCES "recurring_transaction_schedules"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "recurring_transaction_occurrences" ADD CONSTRAINT "recurring_transaction_occurrences_transaction_id_fkey" FOREIGN KEY ("transaction_id") REFERENCES "transactions"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "roles" ADD COLUMN "description" TEXT;
ALTER TABLE "roles" ADD COLUMN "is_system" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "permissions" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"resource" VARCHAR(100) NOT NULL,
"action" VARCHAR(100) NOT NULL,
"is_system" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"id" UUID NOT NULL,
"role_id" UUID NOT NULL,
"permission_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" UUID NOT NULL,
"actor_id" UUID,
"action" VARCHAR(100) NOT NULL,
"target_type" VARCHAR(100) NOT NULL,
"target_id" VARCHAR(255),
"previous_state" JSONB,
"new_state" JSONB,
"ip_address" VARCHAR(100),
"user_agent" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "permissions_name_key" ON "permissions"("name");
CREATE INDEX "permissions_resource_idx" ON "permissions"("resource");
-- CreateIndex
CREATE UNIQUE INDEX "role_permissions_role_id_permission_id_key" ON "role_permissions"("role_id", "permission_id");
CREATE INDEX "role_permissions_role_id_idx" ON "role_permissions"("role_id");
CREATE INDEX "role_permissions_permission_id_idx" ON "role_permissions"("permission_id");
-- CreateIndex
CREATE INDEX "audit_logs_actor_id_idx" ON "audit_logs"("actor_id");
CREATE INDEX "audit_logs_action_idx" ON "audit_logs"("action");
CREATE INDEX "audit_logs_target_type_target_id_idx" ON "audit_logs"("target_type", "target_id");
CREATE INDEX "audit_logs_created_at_idx" ON "audit_logs"("created_at");
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permission_id_fkey" FOREIGN KEY ("permission_id") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
require('dotenv').config();
const { spawn } = require('child_process');
if (!process.env.DATABASE_URL) {
const password = encodeURIComponent(process.env.DB_PASSWORD || '');
const user = encodeURIComponent(process.env.DB_USER || 'postgres');
process.env.DATABASE_URL = `postgresql://${user}:${password}@${process.env.DB_HOST || 'localhost'}:${process.env.DB_PORT || '5432'}/${process.env.DB_NAME || 'datacrawler'}?schema=public`;
}
const args = process.argv.slice(2);
const cmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const child = spawn(cmd, ['prisma', ...args], {
stdio: 'inherit',
env: process.env,
shell: true,
});
child.on('exit', (code) => process.exit(code ?? 1));
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import swaggerUi from 'swagger-ui-express';
import cookieParser from 'cookie-parser';
import { errorMiddleware, notFoundMiddleware } from './middlewares/error.middleware';
import routes from './routes';
import { swaggerSpec, swaggerOptions } from './config/swagger.config';
import { rateLimitMiddleware } from './middlewares/rate-limit.middleware';
import { maintenanceModeMiddleware } from './middlewares/maintenance-mode.middleware';
import { envConfig } from './config/env.config';
const app = express();
app.set('trust proxy', envConfig.trustProxy);
app.use(helmet({ contentSecurityPolicy: false }));
const corsOptions: cors.CorsOptions = {
origin: (origin, callback) => {
const allowed = envConfig.cors.allowedOrigins;
// Allow non-browser requests without origin header (e.g., mobile apps, cURL, server-to-server)
if (!origin) {
callback(null, true);
return;
}
// Disallow wildcard with credentials in production
if (allowed.includes('*')) {
if (envConfig.nodeEnv === 'production') {
callback(new Error('CORS wildcard origin not allowed with credentials in production'), false);
return;
}
callback(null, true);
return;
}
if (allowed.includes(origin)) {
callback(null, true);
} else {
callback(null, false);
}
},
credentials: true,
};
app.use(cors(corsOptions));
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, swaggerOptions));
app.use('/api/v1', rateLimitMiddleware, maintenanceModeMiddleware, routes);
app.use(notFoundMiddleware);
app.use(errorMiddleware);
export default app;
import { envConfig } from '../../config/env.config';
import { AIProvider, AIProviderError } from './ai-provider';
import { GeminiProvider } from './gemini.provider';
let provider: AIProvider | undefined;
export function getAIProvider(): AIProvider {
if (provider) {
return provider;
}
if (envConfig.ai.provider !== 'gemini') {
throw new AIProviderError(
'NOT_CONFIGURED',
`Unsupported AI provider: ${envConfig.ai.provider}`,
);
}
provider = new GeminiProvider({
apiKeys: envConfig.ai.geminiApiKeys,
model: envConfig.ai.geminiModel,
baseUrl: envConfig.ai.geminiBaseUrl,
timeoutMs: envConfig.ai.requestTimeoutMs,
defaultMaxOutputTokens: envConfig.ai.maxOutputTokens,
});
return provider;
}
export interface AIInlineData {
mimeType: string;
data: string;
}
export type AIContentPart =
| { text: string }
| { inlineData: AIInlineData };
export interface AIGenerateRequest {
systemInstruction: string;
parts: AIContentPart[];
responseJsonSchema: Record<string, unknown>;
temperature?: number;
maxOutputTokens?: number;
}
export interface AIUsage {
promptTokens: number | null;
completionTokens: number | null;
totalTokens: number | null;
}
export interface AIGenerateResponse {
data: unknown;
provider: string;
model: string;
usage: AIUsage;
}
export type AIProviderErrorReason =
| 'NOT_CONFIGURED'
| 'UNAVAILABLE'
| 'INVALID_RESPONSE';
export class AIProviderError extends Error {
constructor(public readonly reason: AIProviderErrorReason, message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export interface AIProvider {
readonly name: string;
readonly model: string;
generateStructured(request: AIGenerateRequest): Promise<AIGenerateResponse>;
}
This diff is collapsed.
export * from './permission.constant';
export * from './system-role.constant';
This diff is collapsed.
export const SYSTEM_ROLES = {
ADMIN: 'ADMIN',
USER: 'USER',
MANAGER: 'MANAGER',
SUPER_ADMIN: 'SUPER_ADMIN',
} as const;
export type SystemRole = (typeof SYSTEM_ROLES)[keyof typeof SYSTEM_ROLES];
This diff is collapsed.
import { ErrorCode } from './error-code';
export class AppError extends Error {
public readonly statusCode: number;
public readonly code?: ErrorCode;
public readonly isOperational: boolean;
constructor(message: string, statusCode: number = 500, code?: ErrorCode) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
Object.setPrototypeOf(this, new.target.prototype);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import path from 'path';
import fs from 'fs';
export function ensureDirExists(dirPath: string): void {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
export function buildExportFilePath(exportDir: string, fileName: string): string {
return path.join(exportDir, fileName);
}
export function getFileSizeBytes(filePath: string): number {
try {
const stat = fs.statSync(filePath);
return stat.size;
} catch {
return 0;
}
}
export function deleteFile(filePath: string): void {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch {
// ignore
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
import { envConfig } from './env.config';
export const jwtConfig = envConfig.jwt;
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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