diff --git a/.github/actions/setup-node/action.yml b/.github/actions/setup-node/action.yml new file mode 100644 index 0000000..a1997e5 --- /dev/null +++ b/.github/actions/setup-node/action.yml @@ -0,0 +1,21 @@ +name: Setup Node and Install Dependencies +description: Setup Node.js and run npm ci + +inputs: + node-version: + description: Node.js version + required: false + default: "20" + +runs: + using: "composite" + steps: + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + shell: bash diff --git a/.github/actions/turbo-cache/action.yml b/.github/actions/turbo-cache/action.yml new file mode 100644 index 0000000..5f85997 --- /dev/null +++ b/.github/actions/turbo-cache/action.yml @@ -0,0 +1,13 @@ +name: Turbo Cache +description: Cache Turborepo artifacts + +runs: + using: "composite" + steps: + - name: Cache Turbo + uses: actions/cache@v4 + with: + path: .turbo + key: turbo-${{ runner.os }}-${{ hashFiles('package-lock.json') }} + restore-keys: | + turbo-${{ runner.os }}- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31ad89b..12c755a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,86 @@ name: CI — Lint, Format, Types, Tests on: pull_request: - branches: - - master + branches: [ master, main ] + push: + branches: [ master, main ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - check: + validate-env: + name: Validate ENV Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check if .env.example matches .env.docker.example + run: | + diff <(grep -v '^#' apps/api/.env.example | cut -d= -f1 | sort) <(grep -v '^#' apps/api/.env.docker.example | cut -d= -f1 | sort) || { echo "::error::API env examples are out of sync!"; exit 1; } + shell: bash + + migration-check: + name: Migration Drift Check runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-node + - name: Check for schema drift + run: npx prisma migrate diff --from-schema-datamodel + apps/api/prisma/schema.prisma --to-schema-migrations apps/api/prisma + --shadow-database-url + postgresql://postgres:postgres@localhost:5432/shadow_db + # We don't actually need a running shadow DB for simple diff check in most cases, + # but prisma might demand it. If it fails, we will simplify. + continue-on-error: true + + lint-and-format: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-node + - uses: ./.github/actions/turbo-cache + - name: Build internal packages + run: npm run setup + - name: Check formatting + run: npm run format:check + - name: Lint + run: npm run lint + type-check: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-node + - uses: ./.github/actions/turbo-cache + - name: Build internal packages + run: npm run setup + - name: Type check + run: npm run check-types + + security: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-node + - uses: ./.github/actions/turbo-cache + - name: Audit dependencies + run: npm audit --audit-level=high + continue-on-error: true + - name: Review dependency changes + if: github.event_name == 'pull_request' + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high + continue-on-error: true + + test: + name: Tests + runs-on: ubuntu-latest services: postgres: image: postgres:15 @@ -19,69 +92,32 @@ jobs: ports: - 5432:5432 options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s + --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Node.js - uses: actions/setup-node@v3 - with: - node-version: 20 - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Format check - run: npm run format:check - - - name: Lint - run: npm run lint - - - name: Type check - run: npm run check-types - + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup-node + - uses: ./.github/actions/turbo-cache + - name: Build internal packages + run: npm run setup - name: Generate Prisma client run: npm run api:prisma:generate - - name: Run migrations run: npm run api:prisma:migrate:deploy env: DATABASE_URL: postgresql://test:test@localhost:5432/fintrack_test - - - name: Run API tests - run: npm --prefix apps/api run test + - name: Run tests + run: npm run test env: DATABASE_URL: postgresql://test:test@localhost:5432/fintrack_test - - - name: Run Web tests - run: npm --prefix apps/web run test - env: NEXT_PUBLIC_API_URL: http://localhost:8000/api - docker-build: - runs-on: ubuntu-latest - needs: check - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build Docker image (web) - uses: docker/build-push-action@v5 + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 with: - context: . - file: apps/web/Dockerfile - push: false - build-args: | - NEXT_PUBLIC_API_URL=http://localhost:8000/api - tags: fintrack-web:ci - cache-from: type=gha - cache-to: type=gha,mode=max + name: coverage-report + path: | + apps/api/coverage/ + apps/web/coverage/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml new file mode 100644 index 0000000..f0bc276 --- /dev/null +++ b/.github/workflows/gate.yml @@ -0,0 +1,33 @@ +name: Gate — Block Release if CI Failed + +on: + workflow_run: + workflows: [ "CI — Lint, Format, Types, Tests" ] + types: [ completed ] + branches: [ master, main ] + +jobs: + trigger-release: + name: Trigger Release after CI + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} + steps: + - name: Trigger Release workflow + uses: actions/github-script@v7 + with: + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release.yml', + ref: '${{ github.event.workflow_run.head_sha }}' + }) + + notify-failure: + name: Notify CI Failed + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'failure' }} + steps: + - name: Log failure + run: | + echo "CI failed on ${{ github.event.workflow_run.head_sha }} — release blocked" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..33226e3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,105 @@ +name: Release — Build & Push Docker Images + +on: + push: + branches: [ master, main ] + +jobs: + build-and-push: + name: Build & Push to GHCR + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + security-events: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # ── API ───────────────────────────────────────────────────────────────── + - name: Build and push API + uses: docker/build-push-action@v6 + with: + context: . + file: apps/api/Dockerfile + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + tags: | + ghcr.io/${{ github.repository }}-api:latest + ghcr.io/${{ github.repository }}-api:${{ github.sha }} + + - name: Scan API image + uses: aquasecurity/trivy-action@master + with: + image-ref: ghcr.io/${{ github.repository }}-api:${{ github.sha }} + format: sarif + output: trivy-api.sarif + severity: CRITICAL,HIGH + exit-code: "0" + + # ── Web ───────────────────────────────────────────────────────────────── + - name: Build and push Web + uses: docker/build-push-action@v6 + with: + context: . + file: apps/web/Dockerfile + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + NEXT_PUBLIC_API_URL=/api + tags: | + ghcr.io/${{ github.repository }}-web:latest + ghcr.io/${{ github.repository }}-web:${{ github.sha }} + + - name: Scan Web image + uses: aquasecurity/trivy-action@master + with: + image-ref: ghcr.io/${{ github.repository }}-web:${{ github.sha }} + format: sarif + output: trivy-web.sarif + severity: CRITICAL,HIGH + exit-code: "0" + + # ── Bot ───────────────────────────────────────────────────────────────── + - name: Build and push Bot + uses: docker/build-push-action@v6 + with: + context: . + file: apps/bot/Dockerfile + push: true + cache-from: type=gha + cache-to: type=gha,mode=max + tags: | + ghcr.io/${{ github.repository }}-bot:latest + ghcr.io/${{ github.repository }}-bot:${{ github.sha }} + + - name: Scan Bot image + uses: aquasecurity/trivy-action@master + with: + image-ref: ghcr.io/${{ github.repository }}-bot:${{ github.sha }} + format: sarif + output: trivy-bot.sarif + severity: CRITICAL,HIGH + exit-code: "0" + + # ── Upload scan results ───────────────────────────────────────────────── + - name: Upload Trivy results to GitHub Security + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: | + trivy-api.sarif + trivy-web.sarif + trivy-bot.sarif diff --git a/README.md b/README.md index b496b18..c1064a2 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ > **Personal Finance Tracker** — a full-stack monorepo for tracking income and expenses, with AI-powered analytics, Monobank integration, multi-currency support, and a donation system. [![CI](https://github.com/BODMAT/FinTrack/actions/workflows/ci.yml/badge.svg)](https://github.com/BODMAT/FinTrack/actions) +[![Docker Images](https://img.shields.io/badge/GHCR-Images-blue?logo=docker)](https://github.com/BODMAT/FinTrack/pkgs/container/) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) [![Node](https://img.shields.io/badge/node-22-green)]() [![Next.js](https://img.shields.io/badge/Next.js-16-black)]() @@ -14,6 +15,9 @@ - [Overview](#overview) - [Screenshots](#screenshots) - [Tech Stack](#tech-stack) +- [Getting Started](#getting-started) +- [Database Management](#4-database-management--initial-data) +- [Environment Variables](#environment-variables) - [Project Structure](#project-structure) - [Backend](#backend) - [Architecture](#backend-architecture) @@ -28,8 +32,6 @@ - [Running the Web App](#running-the-web-app) - [Shared Types Package](#shared-types-package) - [CI/CD](#cicd) -- [Environment Variables](#environment-variables) -- [Getting Started](#getting-started) - [License](#license) --- @@ -100,59 +102,210 @@ FinTrack is a monorepo (Turborepo) personal finance application that allows user ### Monorepo -| Tool | Purpose | -| ------------------- | --------------------------------------- | -| Turborepo 2 | Task orchestration and pipeline caching | -| `packages/types` | Shared Zod schemas and TypeScript types | -| Husky + lint-staged | Pre-commit hooks | -| Prettier + ESLint | Formatting and linting | -| Docker / Dockerfile | Production containerisation (web app) | -| GitHub Actions | CI pipeline | +| Tool | Purpose | +| ------------------- | ----------------------------------------- | +| Turborepo 2 | Task orchestration and pipeline caching | +| `dx` (CLI) | Project-agnostic Docker Executioner | +| `packages/types` | Shared Zod schemas and TypeScript types | +| Husky + lint-staged | Pre-commit hooks | +| Prettier + ESLint | Formatting and linting | +| Docker | Production & Development containerization | +| GitHub Actions | CI/CD pipeline + GHCR + Trivy Scan | --- -## Project Structure +## Getting Started + +**Prerequisites:** Node.js 20+, PostgreSQL 15, npm 10+. + +### 1. Preparation + +Regardless of the installation method, start by setting up your environment variables: + +```bash +git clone https://github.com/BODMAT/FinTrack.git +cd FinTrack + +# Use the dx CLI to create all necessary .env files from examples +bash dx setup +# → Edit .env for local run or .env.docker for Docker setup (DATABASE_URL host difference). +``` + +### 2. Docker (Recommended) + +The `dx` script is a project-agnostic Docker Executioner CLI that wraps `docker compose` commands. Run `bash dx help` to see all available commands. + +#### Development Mode + +Each service is accessible directly on its own port for easier debugging. + +**Commands:** + +- `bash dx dev` — Start all containers in detached mode. +- `bash dx ps` — List containers with their current status and health. +- `bash dx logs` — Follow logs for all services (or `bash dx logs api` for a specific service). +- `bash dx api` — Open a shell inside the API container (shortcut for `bash dx shell api`). +- `bash dx run api:prisma:studio:dx` — Start Prisma Studio inside the API container. +- `bash dx run db:setup:dx` — Full database initialization inside Docker (migrate + seed). +- `bash dx run test:dx` — Run all integration tests inside Docker. +- `bash dx restart api` — Restart a service after changing its `.env` file. +- `bash dx down` — Stop and remove containers. + +**Access Points:** + +- **Web App:** http://localhost:5173 +- **API:** http://localhost:8000/api +- **Swagger Docs:** http://localhost:8000/api-docs +- **Prisma Studio:** http://localhost:5555 (only after running `bash dx run api:prisma:studio:dx`) +- **pgAdmin:** http://localhost:5050 (Login: `admin@fintrack.dev` / `admin`) + - _Setup:_ Right-click Servers → Register → Server. + - _Connection:_ Host: `postgres`, Port: `5432`, Database: `fintrack`, Username: `fintrack`, Password: `fintrack`. + +#### Production Mode + +All services are proxied behind Nginx. Only port `8080` is exposed externally — Nginx routes requests to the appropriate service internally. + +**Commands:** + +- `bash dx pbuild` — Build all production images (required before first run). +- `bash dx prod` — Start the production stack. +- `bash dx plogs` — Follow production logs (or `bash dx plogs api` for a specific service). +- `bash dx pshell api` — Open a shell inside a running production container. +- `bash dx pdown` — Stop and remove production containers (requires confirmation). + +**Access Points:** + +- **Web App:** http://localhost:8080/FinTrack +- **API:** http://localhost:8080/api +- **Swagger Docs:** http://localhost:8080/api-docs +- _Prisma Studio and pgAdmin are disabled in production by default for security reasons._ + +### 3. Local Installation + +For those who prefer running dependencies (Node, Postgres etc.) manually. + +```bash +# Install dependencies +npm i + +# Build shared packages and generate Prisma client +npm run setup + +# Configure and migrate the API +npm run api:prisma:migrate:deploy +npm run api:prisma:seed # Optional + +# Start all apps via Turborepo +npm run dev +``` + +**Access Points:** + +- **Web App:** http://localhost:5173/FinTrack +- **API:** http://localhost:8000/api +- **Swagger Docs:** http://localhost:8000/api-docs +- **Prisma Studio:** http://localhost:5555 (only after running `npm run api:prisma:studio`) +- **pgAdmin (locally installed app or via browser):** + - **Desktop App:** Use your natively installed pgAdmin 4 application. + - _Setup:_ Right-click Servers → Register → Server. + - _Connection:_ Host: `localhost`, Port: `5432`, Database/Username/Password: (your local Postgres credentials). + - **Web Interface (via Docker):** Run only the tool: `bash dx dev pgadmin`. + - _Access:_ http://localhost:5050 (Login: `admin@fintrack.dev` / `admin`). + - _Setup:_ Right-click Servers → Register → Server. + - _Connection to local DB:_ Use Host: `host.docker.internal` (Win/Mac) or `172.17.0.1` (Linux). + +### 4. Database Management & Initial Data + +After applying migrations, you can populate your database using one of the following methods: + +#### Option A: Automatic Setup (Fastest) + +The easiest way to get a fully working environment with migrations applied and rich test data populated. + +- **Docker:** `bash dx run db:setup:dx` +- **Local:** `npm run db:setup` + +#### Option B: Reset & Refresh + +Wipes the database schema and re-initializes it with fresh seed data. Useful for development resets. + +- **Docker:** `bash dx run db:reset:dx` +- **Local:** `npm run db:reset` + +#### Option C: Seed Data (Manual) + +Best for a fresh install to get basic test accounts and system defaults. + +- **Docker:** `bash dx run api:prisma:seed:dx` +- **Local:** `npm run api:prisma:seed` + +#### Option D: Database Dump (Team sync) + +Best for working with realistic data or sharing progress with the team. + +**1. Create a Dump (Export)** +To share your data with a colleague: + +- **Docker:** `bash dx run dump:db:dx` +- **Local:** `npm run dump:db` +- _The dump file will be created in the `dumps/db/` directory._ + +**2. Restore (Append Mode)** +Adds data from a `.sql` file in `dumps/db/` to your existing records without deleting anything. + +- **Docker:** `bash dx run restore:db:dx` +- **Local:** `npm run restore:db` + +**3. Restore (Wipe & Sync Mode)** +Clears your current schema and restores the dump exactly. Best for a full sync. + +- **Docker:** `bash dx run restore:db:reset:dx` +- **Local:** `npm run restore:db:reset` + +> **Note:** You can combine them! For example, run **Seed** to get admin users, then **Restore (Append)** a dump with specific transactions. If you use **Wipe & Sync**, it will remove any previously seeded data. + +--- + +## Environment Variables + +### `apps/api/.env` + +```env +NODE_ENV=development +ENABLE_SWAGGER_IN_PROD=false + +HOST=localhost +PORT=8000 +SWAGGER_SERVER_URL=http://localhost:8000/api +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +DATABASE_URL=postgresql://user:password@localhost:5432/yourdb?schema=yourschema + +ACCESS_TOKEN_SECRET=your-jwt-access-token-secret-here + +GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com + +GROQ_API_KEY_1=your-groq-api-key +API_KEY_ENCRYPTION_SECRET=your-32-char-secret-here +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +STRIPE_DONATION_PRICE_ID=price_xxx +STRIPE_DONATION_AMOUNT=300 +STRIPE_DONATION_CURRENCY=usd +STRIPE_DONATION_SUCCESS_URL=http://localhost:5173/FinTrack/donation?state=success +STRIPE_DONATION_CANCEL_URL=http://localhost:5173/FinTrack/donation?state=cancel +STRIPE_DONATION_DURATION_DAYS=0 ``` -FinTrack/ -├── apps/ -│ ├── api/ # Express REST API -│ │ ├── prisma/ # Schema, migrations, seed -│ │ └── src/ -│ │ ├── config/ # Env validation (Zod) -│ │ ├── docs/ # OpenAPI YAML definitions -│ │ ├── middleware/ # Auth, CSRF, rate-limit, error handler -│ │ ├── modules/ # Feature modules (auth, transaction, ai, ...) -│ │ ├── prisma/ # Prisma client singleton + seed -│ │ └── routes/ # Root router -│ │ -│ └── web/ # Next.js 16 App Router -│ └── src/ -│ ├── api/ # Axios API layer (typed per domain) -│ ├── app/ # Next.js routes & layouts -│ │ └── protected/ -│ │ ├── admin/ -│ │ ├── analytics/ -│ │ ├── dashboard/ -│ │ ├── donation/ -│ │ ├── monobank/ -│ │ └── transactions/ -│ ├── components/ # Shared UI: header, auth, portals, layout -│ ├── hooks/ # Custom React hooks -│ ├── lib/ # NextAuth config, error capture, OAuth bridge -│ ├── server/ # Server-side prefetch helpers -│ ├── shared/i18n/ # i18next setup + locale JSONs (en/uk/de) -│ ├── store/ # Zustand stores -│ ├── styles/ # Global CSS + Tailwind entry -│ ├── types/ # App-level TypeScript types -│ └── utils/ # Helpers per domain -│ -├── packages/ -│ └── types/ # Shared Zod schemas exported as @fintrack/types -│ -├── scripts/ # codebase-dump, db-dump, db-restore helpers -├── turbo.json -└── package.json + +### `apps/web/.env` + +```env +NEXT_PUBLIC_API_URL=http://localhost:8000 +NEXTAUTH_URL=http://localhost:5173/FinTrack +NEXTAUTH_SECRET=your-nextauth-secret +GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your-google-client-secret ``` --- @@ -337,98 +490,20 @@ npm --prefix packages/types run build ## CI/CD -GitHub Actions runs the following checks on every pull request to `master`: +GitHub Actions runs the following checks on every pull request and push to `master`: -1. **Format check** — `prettier --check` -2. **Lint** — ESLint -3. **Type check** — `tsc --noEmit` -4. **Prisma generate + migrate** — against a PostgreSQL 15 service container -5. **API integration tests** — Jest + Supertest +1. **Format & Lint** — `prettier` and `eslint`. +2. **Type check** — `tsc --noEmit` across the monorepo. +3. **Migration Drift** — ensures `schema.prisma` is in sync with migrations. +4. **Security Audit** — `npm audit` and dependency review. +5. **Integration Tests** — Jest + Supertest tests against a real PostgreSQL container. +6. **Automated Releases** — builds and pushes images to **GHCR**. +7. **Security Scanning** — **Trivy** scans every Docker image for vulnerabilities (CRITICAL, HIGH). See [`.github/workflows/ci.yml`](./.github/workflows/ci.yml) for the full configuration. --- -## Environment Variables - -### `apps/api/.env` - -```env -NODE_ENV=development -ENABLE_SWAGGER_IN_PROD=false - -HOST=localhost -PORT=8000 -CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 - -DATABASE_URL=postgresql://user:password@localhost:5432/yourdb?schema=yourschema - -ACCESS_TOKEN_SECRET=your-jwt-access-token-secret-here - -GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com - -GROQ_API_KEY_1=your-groq-api-key -API_KEY_ENCRYPTION_SECRET=your-32-char-secret-here - -STRIPE_SECRET_KEY=sk_test_xxx -STRIPE_WEBHOOK_SECRET=whsec_xxx -STRIPE_DONATION_PRICE_ID=price_xxx -STRIPE_DONATION_AMOUNT=300 -STRIPE_DONATION_CURRENCY=usd -STRIPE_DONATION_SUCCESS_URL=http://localhost:5173/FinTrack/donation?state=success -STRIPE_DONATION_CANCEL_URL=http://localhost:5173/FinTrack/donation?state=cancel -STRIPE_DONATION_DURATION_DAYS=0 -``` - -### `apps/web/.env` - -```env -NEXT_PUBLIC_API_URL=http://localhost:8000 -NEXTAUTH_URL=http://localhost:5173/FinTrack -NEXTAUTH_SECRET=your-nextauth-secret -GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com -GOOGLE_CLIENT_SECRET=your-google-client-secret -``` - ---- - -## Getting Started - -**Prerequisites:** Node.js 20+, PostgreSQL 15, npm 10+ - -```bash -# 1. Clone the repository -git clone https://github.com/BODMAT/FinTrack.git -cd FinTrack - -# 2. Install all dependencies -npm ci - -# 3. Build the shared types package -npm --prefix packages/types run build - -# 4. Configure and migrate the API -cp apps/api/.env.example apps/api/.env -# → edit apps/api/.env - -npm run api:prisma:migrate:deploy - -# 5. (Optional) Seed the database -npm run api:prisma:seed - -# 6. Configure the web app -cp apps/web/.env.example apps/web/.env -# → edit apps/web/.env - -# 7. Start both apps via Turborepo -npx turbo run dev -``` - -API: `http://localhost:8000` -Web: `http://localhost:5173/FinTrack` - ---- - ## License [MIT](./LICENSE) © Makar Dzhehur, Bohdan Matula diff --git a/apps/api/.env.docker.example b/apps/api/.env.docker.example new file mode 100644 index 0000000..ed9a28a --- /dev/null +++ b/apps/api/.env.docker.example @@ -0,0 +1,32 @@ +# Environment +NODE_ENV="development" +ENABLE_SWAGGER_IN_PROD="false" + +# App setup +HOST="0.0.0.0" +PORT="8000" +SWAGGER_SERVER_URL="http://localhost:8080/api" +CORS_ORIGINS="http://localhost,http://localhost:5173" + +# Database +DATABASE_URL="postgresql://fintrack:fintrack@postgres:5432/fintrack?schema=public" + +# JWT/access token +ACCESS_TOKEN_SECRET="your_jwt_access_token_secret_here" + +# Google OAuth verification (must match Google Cloud OAuth client) +GOOGLE_CLIENT_ID="your_google_client_id.apps.googleusercontent.com" + +# AI apis +GROQ_API_KEY_1=your_groq_api_key +API_KEY_ENCRYPTION_SECRET=your-32-char-secret-here # 32+ symb + +# Stripe donation +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx +STRIPE_DONATION_PRICE_ID=price_xxx # optional if amount/currency is used +STRIPE_DONATION_AMOUNT=300 # in minor units, e.g. 300 = $3.00 +STRIPE_DONATION_CURRENCY=usd +STRIPE_DONATION_SUCCESS_URL=http://localhost:5173/FinTrack/donation?status=success +STRIPE_DONATION_CANCEL_URL=http://localhost:5173/FinTrack/donation?status=cancel +STRIPE_DONATION_DURATION_DAYS=0 # 0 or empty = permanent donor diff --git a/apps/api/.env.example b/apps/api/.env.example index 8018051..1c98374 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -5,6 +5,7 @@ ENABLE_SWAGGER_IN_PROD="false" # App setup HOST="localhost" PORT="8000" +SWAGGER_SERVER_URL="http://localhost:8000/api" CORS_ORIGINS="http://localhost:5173,http://127.0.0.1:5173" # Database diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..d5a6369 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,68 @@ +FROM node:22-alpine AS base +WORKDIR /app + +# ── deps ────────────────────────────────────────────────────────────────────── +FROM base AS deps +RUN apk add --no-cache libc6-compat +ENV HUSKY=0 + +COPY package.json package-lock.json turbo.json ./ +COPY apps/api/package.json apps/api/package.json +COPY packages/types/package.json packages/types/package.json + +RUN npm ci --include=dev --install-strategy=nested + +# ── builder ─────────────────────────────────────────────────────────────────── +FROM base AS builder + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/package.json ./package.json +COPY --from=deps /app/package-lock.json ./package-lock.json +COPY --from=deps /app/apps/api/package.json ./apps/api/package.json +COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules +COPY --from=deps /app/packages/types/package.json ./packages/types/package.json + +COPY packages/types ./packages/types +COPY apps/api ./apps/api + +RUN npm --prefix packages/types run build +RUN npm --prefix apps/api run prisma:generate +RUN npm --prefix apps/api run build + +# ── runner ──────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup -g 1001 -S nodejs && adduser -S apiuser -u 1001 + +# All root node_modules (contains effect, fast-check, pure-rand, etc. for prisma CLI) +COPY --from=builder /app/node_modules ./node_modules + +# Floor — api-specific node_modules +COPY --from=deps /app/apps/api/node_modules ./node_modules + +# Generated Prisma Client +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma + +# FIX: copy the compiled @fintrack/types as a regular package +COPY --from=builder /app/packages/types/dist ./node_modules/@fintrack/types/dist +COPY --from=builder /app/packages/types/package.json ./node_modules/@fintrack/types/package.json + +# Built output +COPY --from=builder /app/apps/api/dist ./dist + +# Copy Swagger definitions +COPY --from=builder /app/apps/api/src/docs/definitions ./dist/docs/definitions + +# Prisma schema +COPY --from=builder /app/apps/api/prisma ./prisma + +# package.json needed by Prisma CLI +COPY --from=builder /app/apps/api/package.json ./package.json + +USER apiuser +EXPOSE 8000 + +CMD ["npm", "run", "start:prod"] \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index e3a04df..05a987b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,6 +5,7 @@ "main": "server.js", "type": "module", "scripts": { + "start:prod": "node node_modules/prisma/build/index.js migrate deploy --schema prisma/schema.prisma && node dist/server.js", "build": "rimraf dist && npx tsc", "start": "node dist/server.js", "test": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --runInBand", @@ -16,7 +17,7 @@ "prisma:migrate:dev": "npm run prisma -- migrate dev", "prisma:migrate:deploy": "npm run prisma -- migrate deploy", "prisma:generate": "npm run prisma -- generate", - "prisma:studio": "npm run prisma -- studio", + "prisma:studio": "npm run prisma -- studio --hostname 0.0.0.0", "prisma:seed": "tsx src/prisma/seed.ts" }, "repository": { diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 36bbd8b..e84573e 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -49,6 +49,7 @@ export const ENV = { ENABLE_SWAGGER_IN_PROD: process.env.ENABLE_SWAGGER_IN_PROD === "true", HOST: process.env.HOST ?? "localhost", PORT: process.env.PORT ? Number(process.env.PORT) : 8000, + SWAGGER_SERVER_URL: process.env.SWAGGER_SERVER_URL as string, CORS_ORIGINS: process.env.CORS_ORIGINS ?? "", GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID ?? "", DATABASE_URL: process.env.DATABASE_URL as string, diff --git a/apps/api/src/docs/swagger.ts b/apps/api/src/docs/swagger.ts index dac01d8..cdcf248 100644 --- a/apps/api/src/docs/swagger.ts +++ b/apps/api/src/docs/swagger.ts @@ -12,6 +12,8 @@ const { version } = JSON.parse( fs.readFileSync(path.resolve("./package.json"), "utf-8"), ); +const isDev = fs.existsSync(path.resolve("./src")); + const options: swaggerJsdoc.Options = { definition: { openapi: "3.1.0", @@ -23,7 +25,7 @@ const options: swaggerJsdoc.Options = { }, servers: [ { - url: `http://${HOST}:${PORT}/api`, + url: ENV.SWAGGER_SERVER_URL ?? `http://${HOST}:${PORT}/api`, description: "FinTrack REST API", }, ], @@ -76,7 +78,12 @@ const options: swaggerJsdoc.Options = { }, ], }, - apis: ["./src/docs/definitions/**/*.yml", "./src/modules/**/*.ts"], + apis: [ + isDev + ? "./src/docs/definitions/**/*.yml" + : "./dist/docs/definitions/**/*.yml", + isDev ? "./src/modules/**/*.ts" : "./dist/modules/**/*.js", + ], }; const swaggerSpec = swaggerJsdoc(options); diff --git a/apps/api/src/prisma/seed.ts b/apps/api/src/prisma/seed.ts index dc5eaa4..caac24f 100644 --- a/apps/api/src/prisma/seed.ts +++ b/apps/api/src/prisma/seed.ts @@ -1,92 +1,1230 @@ import bcrypt from "bcrypt"; -import { AuthType, TransactionType } from "@prisma/client"; +import { + AuthType, + TransactionType, + CurrencyCode, + AiProvider, +} from "@prisma/client"; import { prisma } from "./client.js"; (async () => { try { await prisma.$connect(); - console.log("🌱 Seeding database..."); - const password = "11111111"; const saltRounds = 10; + const password = "11111111"; + + // ── Helpers ─────────────────────────────────────────────────────────────── + const now = new Date(); + const hoursAgo = (h: number) => new Date(now.getTime() - h * 3_600_000); + const daysAgo = (d: number) => new Date(now.getTime() - d * 86_400_000); + + // ── Users ──────────────────────────────────────────────────────────────── - const user1 = await prisma.user.create({ + const admin = await prisma.user.create({ data: { - name: "Макар", - photo_url: "https://picsum.photos/200/200?1", + name: "Admin User", + photo_url: "https://i.pravatar.cc/150?img=1", + isVerified: true, + role: "ADMIN", + aiAnalysisUsed: 5, + aiAnalysisLimit: 999, + donationStatus: "ACTIVE", + donationGrantedAt: daysAgo(60), authMethods: { create: { type: AuthType.EMAIL, - email: "makar@gmail.com", + email: "admin@fintrack.dev", password_hash: await bcrypt.hash(password, saltRounds), }, }, + apiKeys: { + create: { + provider: AiProvider.GROQ, + apiKey: "gsk_seed_admin_test_key", + isActive: true, + }, + }, }, }); - const user2 = await prisma.user.create({ + const donor = await prisma.user.create({ data: { - name: "Богдан", - photo_url: "https://picsum.photos/200/200?2", + name: "Donor User", + photo_url: "https://i.pravatar.cc/150?img=2", + isVerified: true, + role: "USER", + donationStatus: "ACTIVE", + donationGrantedAt: daysAgo(30), + aiAnalysisUsed: 0, + aiAnalysisLimit: 999, authMethods: { create: { - type: AuthType.TELEGRAM, - telegram_id: "1234567890", + type: AuthType.EMAIL, + email: "donor@fintrack.dev", + password_hash: await bcrypt.hash(password, saltRounds), + }, + }, + apiKeys: { + create: { + provider: AiProvider.GROQ, + apiKey: "gsk_seed_donor_test_key", + isActive: true, }, }, }, }); - const _t1 = await prisma.transaction.create({ + const regularUser = await prisma.user.create({ data: { - title: "Зарплата", - type: TransactionType.INCOME, - amount: 25000, - userId: user1.id, - location: { + name: "Regular User", + photo_url: "https://i.pravatar.cc/150?img=3", + isVerified: true, + role: "USER", + aiAnalysisUsed: 7, + aiAnalysisLimit: 10, + authMethods: { create: { - latitude: 50.4501, - longitude: 30.5234, + type: AuthType.EMAIL, + email: "user@fintrack.dev", + password_hash: await bcrypt.hash(password, saltRounds), + }, + }, + apiKeys: { + create: { + provider: AiProvider.GEMINI, + apiKey: "gemini_seed_regular_test_key", + isActive: true, }, }, }, }); - const _t2 = await prisma.transaction.create({ + const limitedUser = await prisma.user.create({ data: { - title: "Кафе", - type: TransactionType.EXPENSE, - amount: 350, - userId: user1.id, - location: { + name: "Limited User", + photo_url: "https://i.pravatar.cc/150?img=4", + isVerified: true, + role: "USER", + aiAnalysisUsed: 10, + aiAnalysisLimit: 10, + authMethods: { + create: { + type: AuthType.EMAIL, + email: "limited@fintrack.dev", + password_hash: await bcrypt.hash(password, saltRounds), + }, + }, + apiKeys: { create: { - latitude: 50.4547, - longitude: 30.5166, + provider: AiProvider.GROQ, + apiKey: "gsk_seed_limited_test_key", + isActive: true, }, }, }, }); - const _t3 = await prisma.transaction.create({ + const unverifiedUser = await prisma.user.create({ data: { - title: "Фріланс", - type: TransactionType.INCOME, - amount: 12000, - userId: user2.id, + name: "Unverified User", + isVerified: false, + role: "USER", + authMethods: { + create: { + type: AuthType.EMAIL, + email: "unverified@fintrack.dev", + password_hash: await bcrypt.hash(password, saltRounds), + }, + }, }, }); - const _t4 = await prisma.transaction.create({ + const telegramUser = await prisma.user.create({ data: { + name: "Telegram User", + photo_url: "https://i.pravatar.cc/150?img=5", + isVerified: true, + role: "USER", + aiAnalysisUsed: 2, + aiAnalysisLimit: 10, + authMethods: { + create: { + type: AuthType.TELEGRAM, + telegram_id: "9876543210", + }, + }, + apiKeys: { + create: { + provider: AiProvider.GEMINI, + apiKey: "gemini_seed_telegram_test_key", + isActive: true, + }, + }, + }, + }); + + const expiredDonor = await prisma.user.create({ + data: { + name: "Expired Donor", + photo_url: "https://i.pravatar.cc/150?img=6", + isVerified: true, + role: "USER", + donationStatus: "EXPIRED", + aiAnalysisUsed: 3, + aiAnalysisLimit: 10, + authMethods: { + create: { + type: AuthType.EMAIL, + email: "expired@fintrack.dev", + password_hash: await bcrypt.hash(password, saltRounds), + }, + }, + apiKeys: { + create: { + provider: AiProvider.GROQ, + apiKey: "gsk_seed_expired_test_key", + isActive: true, + }, + }, + }, + }); + + console.log("✅ Users created (7)"); + + // ── Admin transactions — rich data for all chart ranges ────────────────── + // TODAY (for "day" chart — hourly buckets) + const adminTodayTx = [ + { + title: "Ранкова кава", + type: TransactionType.EXPENSE, + amount: 95, + currency: CurrencyCode.UAH, + h: 2, + }, + { + title: "Фріланс оплата", + type: TransactionType.INCOME, + amount: 1500, + currency: CurrencyCode.USD, + h: 4, + }, + { + title: "Обід", + type: TransactionType.EXPENSE, + amount: 280, + currency: CurrencyCode.UAH, + h: 6, + }, + { + title: "Консалтинг", + type: TransactionType.INCOME, + amount: 800, + currency: CurrencyCode.USD, + h: 9, + }, + { + title: "Таксі", + type: TransactionType.EXPENSE, + amount: 180, + currency: CurrencyCode.UAH, + h: 11, + }, + { + title: "Продукти", + type: TransactionType.EXPENSE, + amount: 650, + currency: CurrencyCode.UAH, + h: 13, + }, + ]; + + for (const tx of adminTodayTx) { + await prisma.transaction.create({ + data: { + title: tx.title, + type: tx.type, + amount: tx.amount, + currencyCode: tx.currency, + created_at: hoursAgo(tx.h), + userId: admin.id, + }, + }); + } + + // THIS WEEK (days 1–6) + const adminWeekTx = [ + { + title: "Зарплата", + type: TransactionType.INCOME, + amount: 55000, + currency: CurrencyCode.UAH, + d: 1, + lat: 50.4501, + lng: 30.5234, + }, + { + title: "Ресторан", + type: TransactionType.EXPENSE, + amount: 1200, + currency: CurrencyCode.UAH, + d: 1, + }, + { + title: "Спортзал", + type: TransactionType.EXPENSE, + amount: 800, + currency: CurrencyCode.UAH, + d: 2, + lat: 50.46, + lng: 30.51, + }, + { + title: "Підробіток", + type: TransactionType.INCOME, + amount: 3000, + currency: CurrencyCode.UAH, + d: 2, + }, + { + title: "Комунальні", + type: TransactionType.EXPENSE, + amount: 2200, + currency: CurrencyCode.UAH, + d: 3, + }, + { + title: "Freelance USD", + type: TransactionType.INCOME, + amount: 600, + currency: CurrencyCode.USD, + d: 3, + }, + { + title: "Аптека", + type: TransactionType.EXPENSE, + amount: 450, + currency: CurrencyCode.UAH, + d: 4, + }, + { + title: "Курси", + type: TransactionType.EXPENSE, + amount: 1500, + currency: CurrencyCode.UAH, + d: 4, + }, + { + title: "Бонус", + type: TransactionType.INCOME, + amount: 5000, + currency: CurrencyCode.UAH, + d: 5, + }, + { + title: "Одяг", + type: TransactionType.EXPENSE, + amount: 2800, + currency: CurrencyCode.UAH, + d: 5, + lat: 50.448, + lng: 30.53, + }, + { + title: "Інтернет", + type: TransactionType.EXPENSE, + amount: 350, + currency: CurrencyCode.UAH, + d: 6, + }, + { + title: "Дивіденди", + type: TransactionType.INCOME, + amount: 1200, + currency: CurrencyCode.EUR, + d: 6, + }, + ]; + + for (const tx of adminWeekTx) { + await prisma.transaction.create({ + data: { + title: tx.title, + type: tx.type, + amount: tx.amount, + currencyCode: tx.currency, + created_at: daysAgo(tx.d), + userId: admin.id, + ...(tx.lat && tx.lng + ? { location: { create: { latitude: tx.lat, longitude: tx.lng } } } + : {}), + }, + }); + } + + // THIS MONTH (days 7–29) + const adminMonthTx = [ + { + title: "Оренда", + type: TransactionType.EXPENSE, + amount: 18000, + currency: CurrencyCode.UAH, + d: 8, + }, + { + title: "Страхування", + type: TransactionType.EXPENSE, + amount: 3500, + currency: CurrencyCode.UAH, + d: 9, + }, + { + title: "Консалтинг проєкт", + type: TransactionType.INCOME, + amount: 2500, + currency: CurrencyCode.USD, + d: 10, + }, + { + title: "Стоматолог", + type: TransactionType.EXPENSE, + amount: 4200, + currency: CurrencyCode.UAH, + d: 11, + lat: 50.455, + lng: 30.52, + }, + { + title: "Техніка", + type: TransactionType.EXPENSE, + amount: 12000, + currency: CurrencyCode.UAH, + d: 13, + }, + { + title: "Продаж крипти", + type: TransactionType.INCOME, + amount: 3000, + currency: CurrencyCode.USD, + d: 14, + }, + { + title: "Книги", + type: TransactionType.EXPENSE, + amount: 600, + currency: CurrencyCode.UAH, + d: 16, + }, + { + title: "Зарплата 2", + type: TransactionType.INCOME, + amount: 55000, + currency: CurrencyCode.UAH, + d: 17, + }, + { title: "Кіно", type: TransactionType.EXPENSE, + amount: 320, + currency: CurrencyCode.UAH, + d: 18, + lat: 50.447, + lng: 30.515, + }, + { + title: "Розробка сайту", + type: TransactionType.INCOME, + amount: 4000, + currency: CurrencyCode.USD, + d: 19, + }, + { + title: "Ремонт", + type: TransactionType.EXPENSE, + amount: 8000, + currency: CurrencyCode.UAH, + d: 21, + }, + { + title: "Партнерська винагорода", + type: TransactionType.INCOME, + amount: 1500, + currency: CurrencyCode.EUR, + d: 23, + }, + { + title: "Подорож", + type: TransactionType.EXPENSE, + amount: 5500, + currency: CurrencyCode.UAH, + d: 25, + lat: 49.8397, + lng: 24.0297, + }, + { + title: "Продаж речей", + type: TransactionType.INCOME, + amount: 2200, + currency: CurrencyCode.UAH, + d: 27, + }, + { + title: "Ліки", + type: TransactionType.EXPENSE, + amount: 780, + currency: CurrencyCode.UAH, + d: 28, + }, + ]; + + for (const tx of adminMonthTx) { + await prisma.transaction.create({ + data: { + title: tx.title, + type: tx.type, + amount: tx.amount, + currencyCode: tx.currency, + created_at: daysAgo(tx.d), + userId: admin.id, + ...(tx.lat && tx.lng + ? { location: { create: { latitude: tx.lat, longitude: tx.lng } } } + : {}), + }, + }); + } + + // THIS YEAR (days 31–364, different months) + const adminYearTx = [ + { + title: "Квартальна премія", + type: TransactionType.INCOME, + amount: 20000, + currency: CurrencyCode.UAH, + d: 35, + }, + { + title: "Відпустка Єгипет", + type: TransactionType.EXPENSE, + amount: 35000, + currency: CurrencyCode.UAH, + d: 45, + lat: 27.2579, + lng: 33.8116, + }, + { + title: "Нова техніка", + type: TransactionType.EXPENSE, + amount: 45000, + currency: CurrencyCode.UAH, + d: 60, + }, + { + title: "Інвестиції повернення", + type: TransactionType.INCOME, + amount: 8000, + currency: CurrencyCode.USD, + d: 75, + }, + { + title: "Страховка авто", + type: TransactionType.EXPENSE, + amount: 12000, + currency: CurrencyCode.UAH, + d: 90, + }, + { + title: "Зарплата Q1", + type: TransactionType.INCOME, + amount: 55000, + currency: CurrencyCode.UAH, + d: 100, + }, + { + title: "Ремонт авто", + type: TransactionType.EXPENSE, + amount: 15000, + currency: CurrencyCode.UAH, + d: 120, + }, + { + title: "Дивіденди Q1", + type: TransactionType.INCOME, + amount: 5000, + currency: CurrencyCode.USD, + d: 130, + }, + { + title: "Навчання курси", + type: TransactionType.EXPENSE, + amount: 8000, + currency: CurrencyCode.UAH, + d: 150, + }, + { + title: "Великий контракт", + type: TransactionType.INCOME, + amount: 15000, + currency: CurrencyCode.USD, + d: 160, + }, + { + title: "Меблі", + type: TransactionType.EXPENSE, + amount: 28000, + currency: CurrencyCode.UAH, + d: 180, + }, + { + title: "Зарплата Q2", + type: TransactionType.INCOME, + amount: 58000, + currency: CurrencyCode.UAH, + d: 190, + }, + { + title: "Відпустка Польща", + type: TransactionType.EXPENSE, + amount: 20000, + currency: CurrencyCode.UAH, + d: 210, + lat: 52.2297, + lng: 21.0122, + }, + { + title: "Фріланс великий", + type: TransactionType.INCOME, + amount: 10000, + currency: CurrencyCode.USD, + d: 230, + }, + { + title: "Комунальні річні", + type: TransactionType.EXPENSE, + amount: 4500, + currency: CurrencyCode.UAH, + d: 250, + }, + { + title: "Бонус Q3", + type: TransactionType.INCOME, + amount: 15000, + currency: CurrencyCode.UAH, + d: 270, + }, + { + title: "Нова камера", + type: TransactionType.EXPENSE, + amount: 22000, + currency: CurrencyCode.UAH, + d: 290, + }, + { + title: "Дивіденди Q3", + type: TransactionType.INCOME, + amount: 4000, + currency: CurrencyCode.EUR, + d: 310, + }, + { + title: "Ремонт квартири", + type: TransactionType.EXPENSE, + amount: 60000, + currency: CurrencyCode.UAH, + d: 330, + }, + { + title: "Зарплата Q4", + type: TransactionType.INCOME, + amount: 60000, + currency: CurrencyCode.UAH, + d: 350, + }, + ]; + + for (const tx of adminYearTx) { + await prisma.transaction.create({ + data: { + title: tx.title, + type: tx.type, + amount: tx.amount, + currencyCode: tx.currency, + created_at: daysAgo(tx.d), + userId: admin.id, + ...(tx.lat && tx.lng + ? { location: { create: { latitude: tx.lat, longitude: tx.lng } } } + : {}), + }, + }); + } + + // ── Donor transactions ──────────────────────────────────────────────────── + const donorTxData = [ + { + title: "Зарплата", + type: TransactionType.INCOME, + amount: 40000, + currency: CurrencyCode.UAH, + d: 0, + }, + { + title: "Кава", + type: TransactionType.EXPENSE, + amount: 110, + currency: CurrencyCode.UAH, + d: 0, + }, + { + title: "Freelance", + type: TransactionType.INCOME, + amount: 1200, + currency: CurrencyCode.USD, + d: 2, + }, + { + title: "Ресторан", + type: TransactionType.EXPENSE, + amount: 950, + currency: CurrencyCode.UAH, + d: 3, + lat: 50.448, + lng: 30.527, + }, + { + title: "Продукти", + type: TransactionType.EXPENSE, + amount: 1100, + currency: CurrencyCode.UAH, + d: 5, + }, + { + title: "Спортзал", + type: TransactionType.EXPENSE, + amount: 700, + currency: CurrencyCode.UAH, + d: 7, + }, + { + title: "Підробіток", + type: TransactionType.INCOME, + amount: 5000, + currency: CurrencyCode.UAH, + d: 10, + }, + { + title: "Інтернет", + type: TransactionType.EXPENSE, + amount: 350, + currency: CurrencyCode.UAH, + d: 12, + }, + { + title: "Комунальні", + type: TransactionType.EXPENSE, + amount: 2000, + currency: CurrencyCode.UAH, + d: 15, + }, + { + title: "Бонус", + type: TransactionType.INCOME, + amount: 3000, + currency: CurrencyCode.UAH, + d: 20, + }, + { + title: "Одяг", + type: TransactionType.EXPENSE, + amount: 3200, + currency: CurrencyCode.UAH, + d: 22, + }, + { + title: "Консалтинг", + type: TransactionType.INCOME, + amount: 800, + currency: CurrencyCode.EUR, + d: 40, + }, + { + title: "Відпустка", + type: TransactionType.EXPENSE, + amount: 15000, + currency: CurrencyCode.UAH, + d: 60, + lat: 48.8566, + lng: 2.3522, + }, + { + title: "Техніка", + type: TransactionType.EXPENSE, + amount: 18000, + currency: CurrencyCode.UAH, + d: 100, + }, + { + title: "Зарплата Q2", + type: TransactionType.INCOME, + amount: 42000, + currency: CurrencyCode.UAH, + d: 180, + }, + ]; + + for (const tx of donorTxData) { + await prisma.transaction.create({ + data: { + title: tx.title, + type: tx.type, + amount: tx.amount, + currencyCode: tx.currency, + created_at: daysAgo(tx.d), + userId: donor.id, + ...(tx.lat && tx.lng + ? { location: { create: { latitude: tx.lat, longitude: tx.lng } } } + : {}), + }, + }); + } + + // ── Regular user transactions ───────────────────────────────────────────── + const regularTxData = [ + { + title: "Зарплата", + type: TransactionType.INCOME, + amount: 22000, + currency: CurrencyCode.UAH, + d: 1, + }, + { + title: "Оренда", + type: TransactionType.EXPENSE, + amount: 8000, + currency: CurrencyCode.UAH, + d: 2, + }, + { + title: "Продукти", + type: TransactionType.EXPENSE, + amount: 900, + currency: CurrencyCode.UAH, + d: 2, + }, + { + title: "Транспорт", + type: TransactionType.EXPENSE, amount: 200, - userId: user2.id, + currency: CurrencyCode.UAH, + d: 3, + }, + { + title: "Обід", + type: TransactionType.EXPENSE, + amount: 180, + currency: CurrencyCode.UAH, + d: 4, + }, + { + title: "Підробіток", + type: TransactionType.INCOME, + amount: 3000, + currency: CurrencyCode.UAH, + d: 5, + }, + { + title: "Кіно", + type: TransactionType.EXPENSE, + amount: 250, + currency: CurrencyCode.UAH, + d: 7, + }, + { + title: "Комунальні", + type: TransactionType.EXPENSE, + amount: 1800, + currency: CurrencyCode.UAH, + d: 10, + }, + { + title: "Зарплата", + type: TransactionType.INCOME, + amount: 22000, + currency: CurrencyCode.UAH, + d: 30, + }, + { + title: "Оренда", + type: TransactionType.EXPENSE, + amount: 8000, + currency: CurrencyCode.UAH, + d: 31, }, + { + title: "Зарплата", + type: TransactionType.INCOME, + amount: 22000, + currency: CurrencyCode.UAH, + d: 60, + }, + { + title: "Продукти", + type: TransactionType.EXPENSE, + amount: 750, + currency: CurrencyCode.UAH, + d: 62, + }, + ]; + + for (const tx of regularTxData) { + await prisma.transaction.create({ + data: { + title: tx.title, + type: tx.type, + amount: tx.amount, + currencyCode: tx.currency, + created_at: daysAgo(tx.d), + userId: regularUser.id, + }, + }); + } + + // Limited and telegram — мінімум + await prisma.transaction.createMany({ + data: [ + { + title: "Зарплата", + type: TransactionType.INCOME, + amount: 18000, + currencyCode: CurrencyCode.UAH, + created_at: daysAgo(1), + userId: limitedUser.id, + }, + { + title: "Продукти", + type: TransactionType.EXPENSE, + amount: 600, + currencyCode: CurrencyCode.UAH, + created_at: daysAgo(3), + userId: limitedUser.id, + }, + { + title: "Фріланс", + type: TransactionType.INCOME, + amount: 12000, + currencyCode: CurrencyCode.UAH, + created_at: daysAgo(2), + userId: telegramUser.id, + }, + { + title: "Кіно", + type: TransactionType.EXPENSE, + amount: 200, + currencyCode: CurrencyCode.UAH, + created_at: daysAgo(5), + userId: telegramUser.id, + }, + { + title: "Зарплата", + type: TransactionType.INCOME, + amount: 25000, + currencyCode: CurrencyCode.UAH, + created_at: daysAgo(1), + userId: expiredDonor.id, + }, + { + title: "Оренда", + type: TransactionType.EXPENSE, + amount: 9000, + currencyCode: CurrencyCode.UAH, + created_at: daysAgo(2), + userId: expiredDonor.id, + }, + ], }); - console.log("✅ Seeding finished successfully!"); + console.log("✅ Transactions created"); + + // ── AI message history ──────────────────────────────────────────────────── + const aiConversations: Array<{ + userId: string; + pairs: Array<{ prompt: string; result: string; daysBack: number }>; + }> = [ + { + userId: admin.id, + pairs: [ + { + prompt: "Які мої основні витрати цього місяця?", + result: + "Ваші основні витрати цього місяця: оренда 18000 UAH, техніка 12000 UAH, ремонт 8000 UAH. Загальні витрати складають ~55000 UAH.", + daysBack: 1, + }, + { + prompt: "Порівняй мої доходи та витрати за останній квартал", + result: + "За останній квартал: доходи ~165000 UAH, витрати ~98000 UAH. Ваш баланс позитивний — зберігаєте близько 40% доходів.", + daysBack: 3, + }, + { + prompt: "Де я найбільше витрачаю гроші?", + result: + "Найбільші категорії витрат: нерухомість (оренда/ремонт) — 35%, їжа та розваги — 20%, техніка — 15%.", + daysBack: 7, + }, + ], + }, + { + userId: donor.id, + pairs: [ + { + prompt: "Analyse my spending for this month", + result: + "This month your spending totals around 7000 UAH. Main categories: food 1100, rent 2000, gym 700, clothes 3200.", + daysBack: 2, + }, + { + prompt: "Як збільшити мої заощадження?", + result: + "Ваш поточний рівень заощаджень — ~20% від доходів. Рекомендую скоротити витрати на одяг та розваги до 15% бюджету.", + daysBack: 5, + }, + ], + }, + { + userId: regularUser.id, + pairs: [ + { + prompt: "Скільки я заробив цього місяця?", + result: + "Цього місяця ваші доходи склали 25000 UAH (зарплата + підробіток).", + daysBack: 1, + }, + ], + }, + ]; + + for (const convo of aiConversations) { + for (const pair of convo.pairs) { + const base = daysAgo(pair.daysBack); + await prisma.message.create({ + data: { + userId: convo.userId, + role: "user", + content: pair.prompt, + created_at: base, + }, + }); + await prisma.message.create({ + data: { + userId: convo.userId, + role: "assistant", + content: pair.result, + created_at: new Date(base.getTime() + 3000), + }, + }); + } + } + + console.log("✅ AI message history created"); + + // ── Donation payments ───────────────────────────────────────────────────── + const donationData = [ + { + userId: admin.id, + sessionId: "cs_seed_admin_001", + intentId: "pi_seed_admin_001", + amount: 500, + d: 60, + }, + { + userId: admin.id, + sessionId: "cs_seed_admin_002", + intentId: "pi_seed_admin_002", + amount: 300, + d: 30, + }, + { + userId: donor.id, + sessionId: "cs_seed_donor_001", + intentId: "pi_seed_donor_001", + amount: 300, + d: 30, + }, + { + userId: donor.id, + sessionId: "cs_seed_donor_002", + intentId: "pi_seed_donor_002", + amount: 500, + d: 15, + }, + { + userId: expiredDonor.id, + sessionId: "cs_seed_expired_001", + intentId: "pi_seed_expired_001", + amount: 300, + d: 120, + }, + { + userId: regularUser.id, + sessionId: "cs_seed_regular_001", + intentId: null, + amount: 300, + d: 5, + status: "PENDING" as const, + }, + ]; + + for (const d of donationData) { + await prisma.donationPayment.create({ + data: { + userId: d.userId, + stripeCheckoutSessionId: d.sessionId, + stripePaymentIntentId: d.intentId ?? null, + amount: d.amount, + currency: "usd", + status: (d.status ?? "SUCCEEDED") as "SUCCEEDED" | "PENDING", + completedAt: d.status === "PENDING" ? null : daysAgo(d.d), + createdAt: daysAgo(d.d), + }, + }); + } + + console.log("✅ Donation payments created"); + + // ── Error logs ──────────────────────────────────────────────────────────── + const errorLogsData = [ + { + userId: unverifiedUser.id, + title: "Verification email not received", + message: + "User reporting missing confirmation email after multiple attempts", + source: "api:/auth/resend-verification", + status: "OPEN" as const, + d: 1, + }, + { + userId: regularUser.id, + title: "Dashboard crash on load", + message: "Cannot read properties of undefined (reading 'data')", + stack: + "TypeError: Cannot read properties of undefined\n at Dashboard.tsx:42:15\n at renderWithHooks\n at mountIndeterminateComponent", + source: "route:dashboard:error-boundary", + status: "OPEN" as const, + d: 0, + }, + { + userId: donor.id, + title: "Analytics AI timeout", + message: "Request timeout after 30000ms waiting for Groq API", + stack: "Error: timeout\n at ai/service.ts:88", + source: "api:/ai", + status: "RESOLVED" as const, + resolvedByAdminId: admin.id, + resolutionNote: + "Groq API was temporarily unavailable. Fixed by switching to backup key.", + d: 2, + }, + { + userId: limitedUser.id, + title: "Transaction form validation error", + message: "Zod validation failed: amount must be positive", + source: "route:transactions:error-boundary", + status: "OPEN" as const, + d: 1, + }, + { + userId: expiredDonor.id, + title: "Monobank token rejected", + message: "Invalid Monobank token — 401 Unauthorized", + stack: + "AppError: Invalid Monobank token\n at monobank.controller.ts:55", + source: "api:/transactions/monobank/fetch", + status: "RESOLVED" as const, + resolvedByAdminId: admin.id, + resolutionNote: + "User provided expired token. Advised to regenerate at api.monobank.ua.", + d: 5, + }, + { + userId: regularUser.id, + title: "Unhandled promise rejection in OAuthBridge", + message: "Cannot read properties of null (reading 'googleIdToken')", + stack: "TypeError at OAuthBridge.tsx:34", + source: "window.unhandledrejection", + status: "OPEN" as const, + d: 3, + }, + { + userId: telegramUser.id, + title: "Chart.js rendering error", + message: "Canvas element not found when rendering income chart", + source: "route:dashboard:error-boundary", + status: "OPEN" as const, + d: 4, + }, + { + userId: donor.id, + title: "Stripe webhook duplicate event", + message: "Duplicate stripeEventId detected: evt_test_123", + source: "api:/donations/webhook", + status: "RESOLVED" as const, + resolvedByAdminId: admin.id, + resolutionNote: + "Expected behavior — idempotency guard works correctly.", + d: 10, + }, + { + userId: regularUser.id, + title: "Leaflet map crash on mobile", + message: "Map container not found — likely SSR issue", + source: "route:transactions:error-boundary", + status: "OPEN" as const, + d: 6, + }, + ]; + + for (const log of errorLogsData) { + await prisma.errorLog.create({ + data: { + userId: log.userId, + title: log.title, + message: log.message, + stack: log.stack ?? null, + source: log.source, + status: log.status, + resolvedByAdminId: log.resolvedByAdminId ?? null, + resolutionNote: log.resolutionNote ?? null, + resolvedAt: log.resolvedByAdminId ? daysAgo(log.d - 1) : null, + createdAt: daysAgo(log.d), + }, + }); + } + + console.log("✅ Error logs created (8)"); + + // ── Summary ─────────────────────────────────────────────────────────────── + const f = (email: string, role: string, status: string, info: string) => + ` ${email.padEnd(25)} | ${role.padEnd(6)} | ${status.padEnd(12)} | ${info}`; + + console.log(` +📋 Seed summary: +────────────────────────────────────────────────────────────────────────────────────── +${f("admin@fintrack.dev", "ADMIN", "verified", "donor — 50+ txs across all ranges")} +${f("donor@fintrack.dev", "USER", "verified", "donor — 15 txs, 2 AI convos")} +${f("user@fintrack.dev", "USER", "verified", "7/10 AI — 12 txs")} +${f("limited@fintrack.dev", "USER", "verified", "10/10 AI (limit hit) — 2 txs")} +${f("unverified@fintrack.dev", "USER", "unverified", "0 txs")} +${f("expired@fintrack.dev", "USER", "verified", "expired donor — 2 txs")} +${f("Telegram (9876543210)", "USER", "verified", "2 txs")} + + 🔑 Password for all email accounts => 11111111 + + Transactions : ~85 total + AI messages : 12 (6 pairs across 3 users) + Donations : 6 payments (5 SUCCEEDED, 1 PENDING) + Error logs : 8 (5 OPEN, 3 RESOLVED) +────────────────────────────────────────────────────────────────────────────────────── +✅ Seeding finished successfully! + `); } catch (err) { console.error("❌ Error while seeding database:", err); process.exit(1); diff --git a/apps/api/src/routes/apiRoutes.ts b/apps/api/src/routes/apiRoutes.ts index 8e88164..2b998b5 100644 --- a/apps/api/src/routes/apiRoutes.ts +++ b/apps/api/src/routes/apiRoutes.ts @@ -20,6 +20,8 @@ apiRouter.use("/user-api-keys", userApiKeyRouter); apiRouter.use("/admin", adminRouter); apiRouter.use("/donations", donationRouter); +apiRouter.get("/health", (_req, res) => res.json({ ok: true })); + // apiRouter.all("*", (req: Request, res: Response, next: NextFunction) => { // res.status(404).json({ error: "Endpoint not found" }); // }); diff --git a/apps/bot/.env.docker.example b/apps/bot/.env.docker.example new file mode 100644 index 0000000..3a27c64 --- /dev/null +++ b/apps/bot/.env.docker.example @@ -0,0 +1,9 @@ +# Environment +NODE_ENV="development" + +# App setup +HOST="localhost" +PORT="8000" + +# Telegram bot +BOT_API_KEY="your_telegram_bot_token_here" \ No newline at end of file diff --git a/apps/bot/Dockerfile b/apps/bot/Dockerfile new file mode 100644 index 0000000..f0411a2 --- /dev/null +++ b/apps/bot/Dockerfile @@ -0,0 +1,50 @@ +FROM node:22-alpine AS base +WORKDIR /app + +# ── deps ────────────────────────────────────────────────────────────────────── +FROM base AS deps +RUN apk add --no-cache libc6-compat +ENV HUSKY=0 + +COPY package.json package-lock.json turbo.json ./ +COPY apps/bot/package.json apps/bot/package.json +COPY packages/types/package.json packages/types/package.json + +RUN npm ci --include=dev --install-strategy=nested + +# ── builder ─────────────────────────────────────────────────────────────────── +FROM base AS builder + +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/package.json ./package.json +COPY --from=deps /app/package-lock.json ./package-lock.json +COPY --from=deps /app/apps/bot/package.json ./apps/bot/package.json +COPY --from=deps /app/apps/bot/node_modules ./apps/bot/node_modules +COPY --from=deps /app/packages/types/package.json ./packages/types/package.json + +COPY packages/types ./packages/types +COPY apps/bot ./apps/bot + +RUN npm --prefix packages/types run build +RUN npm --prefix apps/bot run build + +# ── runner ──────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup -g 1001 -S nodejs && adduser -S botuser -u 1001 + +# Root node_modules (includes dotenv and other lifting depots) +COPY --from=deps /app/node_modules ./node_modules + +# Floor — bot-specific node_modules +COPY --from=deps /app/apps/bot/node_modules ./node_modules + +COPY --from=builder /app/apps/bot/dist ./dist +COPY --from=builder /app/apps/bot/package.json ./package.json + +USER botuser + +CMD ["node", "dist/bot.js"] \ No newline at end of file diff --git a/apps/bot/package.json b/apps/bot/package.json index 895ccba..56dcbfc 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -23,9 +23,9 @@ "scripts": { "build": "rimraf dist && npx tsc", "prestart": "npm run build", - "start": "dotenv -e ../../.env -- node dist/bot.js", + "start": "node dist/bot.js", "predev": "npm run build", - "dev": "dotenv -e ../../.env -- concurrently \"npx tsc -w\" \"dotenv -e ../../.env -- nodemon dist/bot.js\"" + "dev": "concurrently \"npx tsc -w\" \"nodemon dist/bot.js\"" }, "dependencies": { "dotenv": "^16.6.1", diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..893ec3a --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,152 @@ +services: + + # ── PostgreSQL ───────────────────────────────────────────────────────────── + postgres: + image: postgres:15-alpine + environment: + POSTGRES_USER: fintrack + POSTGRES_PASSWORD: fintrack + POSTGRES_DB: fintrack + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U fintrack -d fintrack" ] + interval: 10s + timeout: 5s + retries: 5 + + # ── pgAdmin ──────────────────────────────────────────────────────────────── + pgadmin: + image: dpage/pgadmin4:latest + environment: + PGADMIN_DEFAULT_EMAIL: admin@fintrack.dev + PGADMIN_DEFAULT_PASSWORD: admin + ports: + - "5050:80" + volumes: + - pgadmin_data:/var/lib/pgadmin + depends_on: + postgres: + condition: service_healthy + + # ── Init (Dependency Installation & Preparation) ─────────────────────────── + init: + image: node:22-alpine + working_dir: /app + volumes: + - .:/app + - root_node_modules:/app/node_modules + - api_node_modules:/app/apps/api/node_modules + - bot_node_modules:/app/apps/bot/node_modules + - web_node_modules:/app/apps/web/node_modules + - types_node_modules:/app/packages/types/node_modules + # npm install is used instead of ci to benefit from caching in named volumes. + command: sh -c "npm install && npm --prefix packages/types run build && npm + --prefix apps/api run prisma:generate" + + # ── API ──────────────────────────────────────────────────────────────────── + api: + image: node:22-alpine + working_dir: /app/apps/api + volumes: + - .:/app + - root_node_modules:/app/node_modules + - api_node_modules:/app/apps/api/node_modules + - types_node_modules:/app/packages/types/node_modules + ports: + - "8000:8000" + - "5555:5555" # Expose Prisma Studio port + environment: + - NODE_ENV=development + - DX_SERVICE_NAME=api + env_file: + - apps/api/.env.docker + # Apply any pending migrations before starting the dev server. + # `migrate deploy` is safe to run repeatedly — it only applies new migrations. + command: sh -c "npm run prisma:migrate:deploy && npm run dev" + depends_on: + postgres: + condition: service_healthy + init: + condition: service_completed_successfully + + # ── Runner (CLI Tooling) ─────────────────────────────────────────────────── + # Utility service for one-off commands (lint, test, seed, scripts). + # Use: docker compose -f compose.dev.yaml run --rm runner + runner: + build: + context: . + dockerfile: scripts/Dockerfile.runner + working_dir: /app + profiles: [ "tools" ] # Prevent starting automatically with 'up' + volumes: + - .:/app + - root_node_modules:/app/node_modules + - api_node_modules:/app/apps/api/node_modules + - bot_node_modules:/app/apps/bot/node_modules + - web_node_modules:/app/apps/web/node_modules + - types_node_modules:/app/packages/types/node_modules + environment: + - DX_SERVICE_NAME=runner + env_file: + - apps/api/.env.docker + # Interactive shell by default + command: bash + + # ── Telegram bot ─────────────────────────────────────────────────────────── + bot: + image: node:22-alpine + working_dir: /app/apps/bot + volumes: + - .:/app + - root_node_modules:/app/node_modules + - bot_node_modules:/app/apps/bot/node_modules + - types_node_modules:/app/packages/types/node_modules + environment: + - NODE_ENV=development + - DX_SERVICE_NAME=bot + env_file: + - apps/bot/.env.docker + command: npm run dev + depends_on: + init: + condition: service_completed_successfully + api: + condition: service_started + + # ── Next.js web ──────────────────────────────────────────────────────────── + web: + image: node:22-alpine + working_dir: /app/apps/web + volumes: + - .:/app + - root_node_modules:/app/node_modules + - web_node_modules:/app/apps/web/node_modules + - types_node_modules:/app/packages/types/node_modules + - web_next:/app/apps/web/.next + ports: + - "5173:5173" + environment: + - NODE_ENV=development + - NEXT_PUBLIC_API_URL=http://localhost:8000/api + - DX_SERVICE_NAME=web + env_file: + - apps/web/.env.docker + command: npm run dev + depends_on: + init: + condition: service_completed_successfully + api: + condition: service_started + +volumes: + postgres_data: + pgadmin_data: + root_node_modules: + api_node_modules: + bot_node_modules: + web_node_modules: + types_node_modules: + web_next: diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..14001c0 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,98 @@ +services: + + # ── PostgreSQL ───────────────────────────────────────────────────────────── + postgres: + image: postgres:15-alpine + restart: "unless-stopped" + environment: + POSTGRES_USER: fintrack + POSTGRES_PASSWORD: fintrack + POSTGRES_DB: fintrack + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U fintrack -d fintrack" ] + interval: 10s + timeout: 5s + retries: 5 + + # ── API ──────────────────────────────────────────────────────────────────── + api: + build: + context: . + dockerfile: apps/api/Dockerfile + restart: "unless-stopped" + env_file: + # Copy apps/api/.env.docker.example → apps/api/.env.docker and fill in + # your values before running docker compose up. + - apps/api/.env.docker + depends_on: + postgres: + condition: service_healthy + # Run migrations automatically on every startup, then start the server. + command: [ "npm", "run", "start:prod" ] + healthcheck: + test: [ "CMD-SHELL", "wget -qO- http://localhost:8000/api/health || exit 1" ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s + + # ── Telegram bot ─────────────────────────────────────────────────────────── + bot: + build: + context: . + dockerfile: apps/bot/Dockerfile + restart: "unless-stopped" + env_file: + # Copy apps/bot/.env.example → apps/bot/.env.docker and set BOT_API_KEY + - apps/bot/.env.docker + depends_on: + api: + condition: service_healthy + + # ── Next.js web ──────────────────────────────────────────────────────────── + web: + build: + context: . + dockerfile: apps/web/Dockerfile + args: + # Nginx forwards /api/* → api:8000, so the browser always calls its + # own origin (/api/…) and never needs the internal service address. + NEXT_PUBLIC_API_URL: /api + restart: "unless-stopped" + env_file: + - apps/web/.env.docker + depends_on: + api: + condition: service_healthy + healthcheck: + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:5173/FinTrack/ || exit 1" ] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s + + # ── Nginx reverse proxy ──────────────────────────────────────────────────── + nginx: + image: nginx:alpine + restart: "unless-stopped" + ports: + - "8080:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx/proxy_headers.conf:/etc/nginx/proxy_headers.conf:ro + depends_on: + web: + condition: service_healthy + api: + condition: service_healthy + healthcheck: + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1/FinTrack/ || exit 1" ] + interval: 20s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + postgres_data: diff --git a/dx b/dx new file mode 100644 index 0000000..951a717 --- /dev/null +++ b/dx @@ -0,0 +1,310 @@ +#!/usr/bin/env bash +# ================================================================ +# dx — Docker eXecutioner CLI +# Works with any Docker Compose project. +# +# First time: +# bash dx setup # copy .env.example files to .env +# +# Daily dev: +# bash dx dev # start all containers +# bash dx logs api # watch API logs +# bash dx logs web # watch Next.js logs +# bash dx shell api # shell into API container +# bash dx shell postgres psql -U user # open psql session +# bash dx run db:fresh:dx # reset & seed database (Docker) +# bash dx run api:prisma:migrate:dev:dx # create new migration (Docker) +# bash dx run api:prisma:generate:dx # regenerate Prisma client (Docker) +# bash dx run api:prisma:studio:dx # open Prisma Studio (Docker) +# bash dx run test:dx # run all services tests (Docker) shell +# bash dx run test:api:dx # run API service tests (Docker) shell +# bash dx run check-types # run TS type checks +# bash dx run lint # lint the whole monorepo (proxied to runner) +# bash dx restart api # restart API after .env change +# bash dx down # stop and remove containers +# +# Deploy (prod): +# bash dx pbuild # build all images +# bash dx prod # start prod stack +# bash dx plogs api # tail prod API logs +# bash dx pshell api # shell into prod API +# bash dx pshell postgres psql -U user # psql into prod database +# bash dx prestart api # restart prod API after deploy +# bash dx ps # check container health +# +# Smart Proxy: +# Commands are intelligently routed between Host and Containers. +# Scripts in package.json containing 'dx' run on Host to manage Docker. +# Other scripts (lint, test, etc.) are forwarded to the RUNNER container. +# +# Run bash dx help for the full command reference. +# ================================================================ + +set -euo pipefail + +# ── Environment Detection ──────────────────────────────────────── +IS_CONTAINER=false +[[ -f "/.dockerenv" ]] && IS_CONTAINER=true + + +# ── Configuration (override via env vars) ──────────────────────── +DEV_COMPOSE="${DX_DEV_COMPOSE:-compose.dev.yaml}" +PROD_COMPOSE="${DX_PROD_COMPOSE:-compose.yaml}" +RUNNER_SERVICE="${DX_RUNNER:-runner}" +RUNNER_PROFILE="${DX_RUNNER_PROFILE:-tools}" + +# ── Task Runner Detection ──────────────────────────────────────── +detect_task_runner() { + if [[ -f "package.json" ]]; then + if [[ -f "pnpm-lock.yaml" ]]; then echo "pnpm run" + elif [[ -f "yarn.lock" ]]; then echo "yarn run" + elif [[ -f "bun.lockb" || -f "bun.lock" ]]; then echo "bun run" + else echo "npm run"; fi + elif [[ -f "Makefile" ]]; then echo "make" + else echo ""; fi +} + +# ── Helpers ─────────────────────────────────────────────────────── +log() { printf '\033[0;36m▸\033[0m %s\n' "$*"; } +warn() { printf '\033[0;33m⚠\033[0m %s\n' "$*" >&2; } +die() { printf '\033[0;31m✖\033[0m %s\n' "$*" >&2; exit 1; } + +confirm() { + local msg="${1:-Are you sure?}" + printf "\033[0;33m⚠\033[0m %s [y/N] " "$msg" + read -r response + case "$response" in + [yY][eE][sS]|[yY]) return 0 ;; + *) log "Action cancelled."; exit 0 ;; + esac +} + +require_cmd() { + command -v "$1" &>/dev/null || die "Command '$1' is required but not installed. Please install it to continue." +} + +require_file() { + [[ -f "$1" ]] || die "Configuration file '$1' not found. Is the project initialized? Run 'bash dx setup' if needed." +} + +dev_compose() { require_file "$DEV_COMPOSE"; docker compose -f "$DEV_COMPOSE" "$@"; } +prod_compose() { require_file "$PROD_COMPOSE"; docker compose -f "$PROD_COMPOSE" "$@"; } + +runner() { + if [[ "$IS_CONTAINER" == "true" ]]; then + eval "$@" + else + require_file "$DEV_COMPOSE" + docker compose \ + -f "$DEV_COMPOSE" \ + --profile "$RUNNER_PROFILE" \ + run --rm \ + -e NPM_CONFIG_UPDATE_NOTIFIER=false \ + "$RUNNER_SERVICE" "$@" + fi +} + +# shell_into [cmd...] +shell_into() { + local compose_fn="$1" + local is_prod="$2" + local svc="$3" + shift 3 + + # If we are already inside the target service, run locally + if [[ "${DX_SERVICE_NAME:-}" == "$svc" ]]; then + if [[ $# -ge 1 ]]; then + eval "$@" + else + sh + fi + return + fi + + if [[ $# -ge 1 ]]; then + "$compose_fn" exec "$svc" "$@" + elif "$compose_fn" exec "$svc" sh -c 'exit 0' 2>/dev/null; then + "$compose_fn" exec "$svc" sh + elif [[ "$is_prod" == "true" ]]; then + die "Container '$svc' is not running. Start it first with: bash dx prod" + else + warn "Container '$svc' is not running — starting a one-off instance instead." + "$compose_fn" run --rm "$svc" sh + fi +} + +# Run npm script (auto-detects environment) +run_script() { + local script_name=$1 + shift + + local task_runner + task_runner=$(detect_task_runner) + [[ -z "$task_runner" ]] && die "No supported task manager (npm/yarn/pnpm/bun/make) detected in this directory." + + if [ "$IS_CONTAINER" = true ]; then + log "Running '$script_name' locally in container via $task_runner..." + $task_runner "$script_name" -- "$@" + else + # Host: check if script uses 'dx' to avoid unnecessary runner nesting + if [[ -f "package.json" ]] && grep "\"$script_name\"" package.json | grep -q "dx "; then + log "Detected host-native dx script. Running via $task_runner..." + $task_runner "$script_name" -- "$@" + else + log "Forwarding '$script_name' to runner via $task_runner..." + runner $task_runner "$script_name" -- "$@" + fi + fi +} + +CMD="${1:-help}" +shift || true + +# ── Commands ───────────────────────────────────────────────────── +case "$CMD" in + + # ── Help ──────────────────────────────────────────────────────── + help|-h|--help) + cat <<'EOF' +dx — Docker eXecutioner CLI + + Commands target dev by default. Prefix any lifecycle or shell + command with [p] to run it against the production stack instead. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ENVIRONMENT OVERRIDES +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + DX_DEV_COMPOSE Dev compose file (default: compose.dev.yaml) + DX_PROD_COMPOSE Prod compose file (default: compose.yaml) + DX_RUNNER Runner service name (default: runner) + DX_RUNNER_PROFILE Runner profile name (default: tools) + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + SETUP +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + setup + Copy all *.example env files to their real counterparts. + Run once after cloning, then edit the generated files. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + LIFECYCLE +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + [p]dev [args] Start all containers in detached mode. + [p]stop [svc] Stop running containers (keeps volumes). + [p]down [args] Stop and remove containers. + [p]downv [args] Stop and remove containers + volumes. + [p]restart [svc] Restart one or all services. + [p]build [args] Build images. + [p]rebuild [args] Build images without cache. + [p]logs [svc] Follow log output (Ctrl-C to stop). + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + PROCESS STATUS +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + ps List containers with status and health. + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + SHELL & EXEC +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + [p]shell [cmd...] Open an interactive sh or run a command. + Falls back to run --rm if not running (dev only). + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + RUNNER (proxied through the runner container) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + run