diff --git a/.env.example b/.env.example index ac567ee..3e6bbf6 100644 --- a/.env.example +++ b/.env.example @@ -41,8 +41,10 @@ STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxx # =========================================== # API # =========================================== -# Backend API URL (Next.js web app) -API_BASE_URL=https://your-api.com +# Backend API URL (Dart Frog on Railway in production) +# Local: http://localhost:8080 +# Production: https://.up.railway.app +API_BASE_URL=http://localhost:8080 # =========================================== # FIREBASE (Analytics, Crashlytics, Push) diff --git a/.github/workflows/backend-deploy.yml b/.github/workflows/backend-deploy.yml new file mode 100644 index 0000000..a486cc9 --- /dev/null +++ b/.github/workflows/backend-deploy.yml @@ -0,0 +1,62 @@ +name: Deploy Backend to Railway + +on: + push: + branches: [prod] + paths: + - 'backend/**' + - '.github/workflows/backend-deploy.yml' + +jobs: + # ── Test backend before deploying ────────────────────────── + test-backend: + name: Test Backend + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Dart SDK + uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Install dependencies + working-directory: backend + run: dart pub get + + - name: Analyze backend code + working-directory: backend + run: dart analyze --fatal-infos + + - name: Run backend tests + working-directory: backend + run: dart test + + # ── Deploy to Railway on push to prod ────────────────────── + deploy-backend: + name: Deploy to Railway + needs: test-backend + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Railway CLI + run: npm install -g @railway/cli + + - name: Deploy to Railway + working-directory: backend + run: railway up --service familiarise-mobile-api --detach + env: + RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} + + - name: Verify deployment health + run: | + echo "Waiting for deployment to stabilize..." + sleep 30 + curl --fail --retry 5 --retry-delay 10 \ + "${{ secrets.PRODUCTION_API_BASE_URL }}/api/health" || \ + echo "::warning::Health check failed - check Railway dashboard" diff --git a/.github/workflows/flutter-ci.yml b/.github/workflows/flutter-ci.yml index b8b5137..76d772a 100644 --- a/.github/workflows/flutter-ci.yml +++ b/.github/workflows/flutter-ci.yml @@ -2,11 +2,22 @@ name: Flutter CI/CD on: push: - branches: [main, develop] + branches: [prod, dev] + tags: ['v*.*.*'] pull_request: - branches: [main, develop] + branches: [prod, dev] release: types: [published] + workflow_dispatch: + inputs: + deploy_type: + description: 'Deployment type' + required: false + default: 'patch' + type: choice + options: + - patch + - release env: FLUTTER_VERSION: '3.24.3' @@ -32,7 +43,7 @@ jobs: run: flutter pub get - name: Generate code - run: flutter pub run build_runner build --delete-conflicting-outputs + run: dart run build_runner build --delete-conflicting-outputs - name: Analyze code run: flutter analyze --fatal-infos @@ -85,7 +96,7 @@ jobs: run: flutter pub get - name: Generate code - run: flutter pub run build_runner build --delete-conflicting-outputs + run: dart run build_runner build --delete-conflicting-outputs - name: Decode Keystore if: github.event_name == 'release' @@ -159,7 +170,7 @@ jobs: run: flutter pub get - name: Generate code - run: flutter pub run build_runner build --delete-conflicting-outputs + run: dart run build_runner build --delete-conflicting-outputs - name: Install CocoaPods run: | @@ -270,3 +281,107 @@ jobs: --file build/*.ipa \ --apiKey $APP_STORE_CONNECT_KEY_ID \ --apiIssuer $APP_STORE_CONNECT_ISSUER_ID + + # ============================================ + # Test Dart Frog Backend (runs on every push and PR) + # ============================================ + test-backend: + name: Test Backend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Dart SDK + uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Install backend dependencies + working-directory: backend + run: dart pub get + + - name: Analyze backend code + working-directory: backend + run: dart analyze --fatal-infos + + - name: Run backend tests + working-directory: backend + run: dart test + + # ============================================ + # Shorebird Release (alongside store release, on tags) + # ============================================ + shorebird-release: + name: Shorebird Release + needs: [deploy-android, deploy-ios] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Generate code + run: dart run build_runner build --delete-conflicting-outputs + + - name: Install Shorebird CLI + uses: shorebirdtech/setup-shorebird@v1 + + - name: Create Shorebird release (Android) + run: shorebird release android --flutter-version ${{ env.FLUTTER_VERSION }} + env: + SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }} + + - name: Create Shorebird release (iOS) + run: shorebird release ios --flutter-version ${{ env.FLUTTER_VERSION }} + env: + SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }} + + # ============================================ + # Shorebird Patch (hotfix — no store review needed) + # Triggers: push to hotfix/* OR manual workflow_dispatch + # ============================================ + shorebird-patch: + name: Shorebird Hotfix Patch + runs-on: ubuntu-latest + if: | + github.event_name == 'workflow_dispatch' || + startsWith(github.ref, 'refs/heads/hotfix/') + + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Generate code + run: dart run build_runner build --delete-conflicting-outputs + + - name: Install Shorebird CLI + uses: shorebirdtech/setup-shorebird@v1 + + - name: Apply patch (Android) + run: shorebird patch android + env: + SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }} + + - name: Apply patch (iOS) + run: shorebird patch ios + env: + SHOREBIRD_TOKEN: ${{ secrets.SHOREBIRD_TOKEN }} diff --git a/.gitignore b/.gitignore index 504eb36..b5ec949 100644 --- a/.gitignore +++ b/.gitignore @@ -83,7 +83,7 @@ key.properties # iOS build files /ios/Pods/ -/ios/Podfile.lock +# /ios/Podfile.lock # Tracked — locks CocoaPods versions for reproducible builds /ios/.symlinks/ /ios/Flutter/Flutter.framework /ios/Flutter/Flutter.podspec diff --git a/.mcp.json.example b/.mcp.json.example index 97466a2..e6404c7 100644 --- a/.mcp.json.example +++ b/.mcp.json.example @@ -17,6 +17,10 @@ "chrome-devtools": { "command": "npx", "args": ["-y", "chrome-devtools-mcp@latest"] + }, + "railway": { + "command": "npx", + "args": ["-y", "@railway/mcp-server"] } } } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b02498d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# Familiarise Mobile — System Architecture + +## Infrastructure + +### Backend hosting — Railway + +The Dart Frog API (`backend/`) is deployed on Railway. + +- Auto-deploys on every push to `prod` that touches `backend/**` +- Health check endpoint: `GET /api/health` +- Environment variables are set in the Railway dashboard (not in code) +- Uses Supabase connection pooler (port 6543) for `DATABASE_URL` at runtime +- Uses direct connection (port 5432) for `DIRECT_URL` (migrations only) +- The backend uses `DotEnv(includePlatformEnvironment: true)` so it works both locally (with `.env` file) and in Docker/Railway (where env vars are injected by the platform) + +**Local dev:** `cd backend && dart_frog dev` → `http://localhost:8080` + +### OTA updates — Shorebird + +Shorebird enables Dart-only patches to be delivered to users without App Store/Play Store review. + +- A Shorebird **release** is created automatically alongside every tagged store release +- A Shorebird **patch** is applied automatically when pushing to any `hotfix/*` branch +- Patches can also be triggered manually via GitHub Actions → workflow_dispatch +- Patches apply silently on the user's next app launch (no update prompt) + +**Limitation:** Shorebird patches Dart code only. Changes to native Android/iOS code, new Flutter plugins with native bindings, or new assets require a full store release. + +### Update decision tree + +``` +Need to fix something? +│ +├── Backend logic / API / DB query +│ └── Push to prod → auto-deploys to Railway → users see it instantly +│ +├── Flutter Dart code (UI, state, business logic, API calls) +│ ├── Minor hotfix → push to hotfix/* branch → shorebird patch +│ └── Larger change with tests → PR → merge to dev → tag release +│ +└── Native code (new plugin, permission, asset, icon) + └── Full release: tag → GitHub Actions builds + deploys to stores +``` + +### Branch strategy + +- `dev` — development branch (default) +- `prod` — production branch (Railway deploys from here) +- `feature/*` — feature branches (PR to dev) +- `hotfix/*` — hotfix branches (triggers Shorebird patch) + +### Environment configuration + +- **Flutter app:** Uses `envied` package to read from `.env` at build time. CI generates `.env` from GitHub Secrets. Do NOT use `--dart-define` — it's incompatible with the envied approach. +- **Backend:** Uses `dotenv` package with `includePlatformEnvironment: true`. Works with both `.env` file (local) and platform env vars (Railway/Docker). + +### CI/CD workflows + +| Workflow | File | Triggers | +|----------|------|----------| +| Flutter CI/CD | `flutter-ci.yml` | Push to prod/dev, PRs, releases, tags, manual | +| Backend Deploy | `backend-deploy.yml` | Push to prod (backend/** changes) | + +### Known gotchas + +- `backend/lib/generated/` has a `Platform` enum (from Prisma schema) that conflicts with `dart:io.Platform`. Use `import 'dart:io' as io show Platform;` or explicit `show` clauses. +- `flutter analyze` runs on both frontend AND backend (the whole workspace). +- Use `scripts/regenerate-build.sh --prisma` when generated models are stale. diff --git a/README.md b/README.md index e745abc..478be04 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ A Flutter-based mobile application for the Familiarise consultation and mentorsh | **Platforms** | iOS 14+, Android API 24+ | | **Flutter Version** | 3.24.x (Dart 3.5.x) | | **Architecture** | Clean Architecture with Feature-First Structure | -| **Backend** | Dart Frog with Prisma ORM | +| **Backend** | Dart Frog on Railway (Prisma ORM) | +| **OTA Updates** | Shorebird (Dart-only patches, no store review) | ## Features @@ -99,33 +100,21 @@ dart run build_runner build --delete-conflicting-outputs ### 5. Backend Setup -The project includes a Dart Frog backend in the `backend/` directory: +The project includes a Dart Frog backend in the `backend/` directory. + +**Local development:** ```bash -# Navigate to backend cd backend - -# Install dependencies dart pub get - -# Set up Prisma -npm install -g prisma - -# Copy and configure backend environment -cp .env.example .env -# Edit .env with your database URL and JWT secret - -# Generate Prisma client -dart run orm generate - -# Apply database migrations (if using Prisma migrations) -# npx prisma migrate deploy - -# Start the development server -dart_frog dev +dart run orm generate # Generate Prisma client +cp .env.example .env # Copy and fill in values +dart_frog dev # Starts at http://localhost:8080 ``` -The backend will run at `http://localhost:8080`. +**Production:** The backend is hosted on Railway and auto-deploys when you push to `prod`. No manual deploy steps needed. + +**Environment variables:** Copy `backend/.env.example` to `backend/.env` and fill in values. For production values, see the Railway dashboard. ### 6. Run the Application @@ -348,15 +337,32 @@ open coverage/html/index.html This project uses GitHub Actions for continuous integration and deployment. -**Workflow:** `.github/workflows/flutter-ci.yml` +**Workflows:** `.github/workflows/flutter-ci.yml`, `.github/workflows/backend-deploy.yml` -| Stage | Triggers | Actions | -|-------|----------|---------| +| Job | Triggers | Actions | +|-----|----------|---------| | Analyze & Test | Push, PR | Lint, analyze, unit tests, coverage upload | -| Build Android | Push, PR, Release | Debug APK, Release AAB (on release) | -| Build iOS | Push, PR, Release | Release build, IPA (on release) | +| Test Backend | Push, PR | Dart analyze, backend unit tests | +| Build Android | Push, PR, Release | Debug APK / Release AAB | +| Build iOS | Push, PR, Release | Debug build / Release IPA | | Deploy Android | Release | Upload to Play Store (internal track) | -| Deploy iOS | Release | Upload to App Store Connect | +| Deploy iOS | Release | Upload to App Store Connect (TestFlight) | +| Deploy Backend | Push to `prod` | Auto-deploy Dart Frog API to Railway | +| Shorebird Release | Release tag | Register release with Shorebird | +| Shorebird Patch | Push to `hotfix/*` / manual | OTA patch — no store review required | + +### Shipping a hotfix (no App Store wait) + +For Dart-only bug fixes (UI, business logic, API calls): + +```bash +git checkout -b hotfix/fix-booking-crash +# Make your Dart code changes... +git push origin hotfix/fix-booking-crash +``` + +The patch is delivered silently to all users on their next app launch. +For native code changes (new plugins, permissions), a full store release is required. ## Documentation diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..84c08df --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +.env +.env.* +!.env.example +.dart_tool/ +.packages +build/ +.git/ +.vscode/ +.serena/ +*.md +test/ diff --git a/backend/.env.example b/backend/.env.example index e7bd30e..48d5227 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -36,5 +36,27 @@ STRIPE_SECRET_KEY=sk_test_xxxxx # Webhook signing secret from Stripe Dashboard -> Webhooks STRIPE_WEBHOOK_SECRET=whsec_xxxxx +# Stream (Video & Chat SDK) +STREAM_API_KEY=your-stream-api-key +STREAM_API_SECRET=your-stream-api-secret + +# Supabase (used by storage and service-role operations) +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key + +# Upstash Redis (slot locking — optional) +UPSTASH_REDIS_REST_URL=https://your-redis.upstash.io +UPSTASH_REDIS_REST_TOKEN=your-upstash-token + +# Resend (Email — optional, needed for password reset & verification) +RESEND_API_KEY=re_xxxxx + +# App +APP_BASE_URL=https://familiarise.com + # Server PORT=8080 + +# Production only — set these in Railway dashboard +# DART_ENV=production +# ALLOWED_ORIGINS=https://familiarise.com diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..97c2ee5 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,82 @@ +# Familiarise Mobile API — Production Dockerfile +# +# IMPORTANT BUILD NOTES (learned from failed deployments): +# +# 1. BASE IMAGE: Must use Flutter image, NOT dart:stable. +# prisma_flutter_connector depends on the Flutter SDK for dependency +# resolution. Using dart:stable causes "flutter from sdk doesn't exist" +# during `dart pub get`. +# +# 2. PRISMA CODEGEN: lib/generated/ is gitignored (PR #108), so Railway +# never receives the Prisma-generated types. The Dockerfile must regenerate +# them via `dart run prisma_flutter_connector:generate`. +# +# 3. BUILD_RUNNER: After Prisma generation, freezed/json_serializable must +# also run. Without this, the AOT compile fails with "Not a constant +# expression" and undefined type errors in repositories. +# +# 4. PRISMA SCHEMA: backend/prisma/schema.prisma was originally a symlink +# to ../../familiarise_web/prisma/schema.prisma. Docker cannot follow +# symlinks that point outside the build context. The symlink was replaced +# with a real file copy. If the web schema changes, re-copy it: +# cp ~/Desktop/familiarise_web/prisma/schema.prisma backend/prisma/schema.prisma +# +# 5. RUNTIME IMAGE: Cannot use `FROM scratch` with the Flutter build image. +# The official dart:stable image provides /runtime/ (libc + friends) for +# scratch-based final stages, but the Flutter image does not. AOT-compiled +# Dart binaries need libc, so we use debian:bookworm-slim instead. +# +# 6. LOCAL TESTING: Always test locally before deploying to Railway: +# docker build -t familiarise-mobile-api . +# docker run -p 8080:8080 --env-file .env familiarise-mobile-api +# curl http://localhost:8080/api/health +# +# Build steps mirror: scripts/regenerate-build.sh --backend +# ───────────────────────────────────────────────────────────── + +# ── Stage 1: Build ────────────────────────────────────────── +FROM ghcr.io/cirruslabs/flutter:stable AS build + +WORKDIR /app + +# Install dart_frog_cli for the build step +RUN dart pub global activate dart_frog_cli +ENV PATH="/root/.pub-cache/bin:${PATH}" + +# Copy and resolve dependencies first (Docker layer caching) +COPY pubspec.* ./ +RUN flutter pub get + +# Copy full source +COPY . . +RUN flutter pub get --offline + +# 1. Generate Prisma client (lib/generated/ is gitignored, must regenerate) +RUN dart run prisma_flutter_connector:generate \ + --schema prisma/schema.prisma \ + --output lib/generated \ + --server + +# 2. Run build_runner for freezed/json codegen on generated models +RUN dart run build_runner build --delete-conflicting-outputs + +# 3. Generate Dart Frog build output (creates build/bin/server.dart) +RUN dart_frog build + +# 4. AOT compile the generated server to a native binary +RUN dart compile exe build/bin/server.dart -o build/bin/server + +# ── Stage 2: Runtime ──────────────────────────────────────── +# Use debian:bookworm-slim (NOT scratch) — the Flutter build image does not +# provide /runtime/ like dart:stable does. AOT binaries need libc + friends. +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=build /app/build/bin/server /app/bin/server + +EXPOSE 8080 + +CMD ["/app/bin/server"] diff --git a/backend/README.md b/backend/README.md index 1d290e2..5bec6db 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,99 +1,102 @@ # Familiarise Backend -[![style: dart frog lint][dart_frog_lint_badge]][dart_frog_lint_link] -[![License: MIT][license_badge]][license_link] [![Powered by Dart Frog](https://img.shields.io/endpoint?url=https://tinyurl.com/dartfrog-badge)](https://dart-frog.dev) -Dart Frog backend API for the Familiarise mobile application. +Dart Frog REST API for the Familiarise consultation marketplace. -## Prerequisites +## Overview -- Dart SDK 3.5.x+ -- Dart Frog CLI (`dart pub global activate dart_frog_cli`) -- PostgreSQL database (or Supabase) +| Aspect | Details | +|--------|---------| +| **Framework** | Dart Frog 1.2.x | +| **ORM** | Prisma (via prisma_flutter_connector) | +| **Database** | Supabase PostgreSQL | +| **Hosting** | Railway (auto-deploy from `prod` branch) | +| **Port** | 8080 (local) / set by Railway in production | -## Setup +## Local development ```bash # Install dependencies dart pub get -# Copy environment file +# Generate Prisma client +dart run orm generate + +# Copy and fill environment variables cp .env.example .env -# Edit .env with your database credentials and JWT secret -# Generate Prisma client (if using Prisma) -dart run orm generate +# Start dev server (hot reload) +dart_frog dev +# -> http://localhost:8080 ``` -## Running the Server +## Environment variables -### Development Mode (with hot reload) +| Variable | Description | Required | +|---|---|---| +| `DATABASE_URL` | Supabase pooler URL (port 6543) | Yes | +| `DIRECT_URL` | Supabase direct URL (port 5432) | Yes | +| `JWT_SECRET` | Secret for JWT signing | Yes | +| `STREAM_API_KEY` | Stream SDK API key | Yes | +| `STREAM_API_SECRET` | Stream SDK API secret | Yes | +| `SUPABASE_URL` | Supabase project URL | Yes | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service role key | Yes | +| `RAZORPAY_KEY_ID` | Razorpay API key | For payments | +| `RAZORPAY_KEY_SECRET` | Razorpay secret | For payments | +| `RAZORPAY_WEBHOOK_SECRET` | Razorpay webhook signing secret | For webhooks | +| `STRIPE_SECRET_KEY` | Stripe secret key | For payments | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret | For webhooks | +| `SENTRY_DSN` | Sentry error tracking DSN | Recommended | +| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google auth | +| `RESEND_API_KEY` | Resend email API key | For emails | +| `APP_BASE_URL` | App base URL (default: familiarise.com) | Optional | +| `UPSTASH_REDIS_REST_URL` | Upstash Redis URL | For slot locking | +| `UPSTASH_REDIS_REST_TOKEN` | Upstash Redis token | For slot locking | +| `DART_ENV` | Set to `production` in prod | Production only | +| `ALLOWED_ORIGINS` | CORS allowed origins | Production only | +| `PORT` | Server port (Railway sets this automatically) | Auto | + +## API routes + +| Method | Route | Description | +|---|---|---| +| GET | `/api/health` | Health check (used by Railway) | +| POST | `/api/auth/sign-in` | Sign in | +| POST | `/api/auth/sign-up` | Register | +| DELETE | `/api/auth/sign-out` | Sign out | +| GET | `/api/auth/session` | Get current session | +| GET | `/api/consultants` | List consultants | +| GET | `/api/consultants/:id` | Get consultant by ID | +| GET | `/api/consultants/:id/availability` | Get available slots | +| GET | `/api/appointments` | List user appointments | +| POST | `/api/checkout/create-order` | Create payment order | +| POST | `/api/checkout/verify` | Verify payment | +| GET | `/api/stream/token` | Get Stream chat token | +| GET | `/api/stream/video-token` | Get Stream video token | + +## Production deployment + +Production deploys are automated via GitHub Actions. Pushing to `prod` triggers `.github/workflows/backend-deploy.yml`, which runs `railway up`. + +To deploy manually (requires Railway CLI and token): ```bash -~/.pub-cache/bin/dart_frog dev +npm install -g @railway/cli +railway login +cd backend +railway up --service familiarise-mobile-api ``` -### Production Mode +## Docker ```bash -# Build the server -~/.pub-cache/bin/dart_frog build - -# Run on port 8080 -PORT=8080 dart build/bin/server.dart -``` +# Build +docker build -t familiarise-mobile-api . -## Server Management - -| Action | Command | -|--------|---------| -| Start (dev) | `~/.pub-cache/bin/dart_frog dev` | -| Build | `~/.pub-cache/bin/dart_frog build` | -| Start (prod) | `PORT=8080 dart build/bin/server.dart` | -| Stop | `lsof -ti:8080 \| xargs kill -9` | -| Restart | `lsof -ti:8080 \| xargs kill -9 2>/dev/null; PORT=8080 dart build/bin/server.dart` | - -## API Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/api/auth/*` | Various | Authentication endpoints | -| `/api/consultants` | GET | List consultants | -| `/api/consultants/:id` | GET | Get consultant details | -| `/api/slots/availability` | GET | Get consultant availability | -| `/api/appointments` | GET/POST | User appointments | -| `/api/checkout/*` | Various | Payment processing | - -## Project Structure +# Run locally with env file +docker run -p 8080:8080 --env-file .env familiarise-mobile-api +# Test health check +curl http://localhost:8080/api/health ``` -backend/ -├── lib/ -│ ├── database/ # Database configuration and repositories -│ │ ├── prisma_client.dart -│ │ └── repositories/ # Data access layer -│ ├── middleware/ # Request middleware -│ └── services/ # Business logic services -├── routes/ -│ └── api/ # API route handlers -├── build/ # Generated production build -└── prisma/ # Prisma schema and migrations -``` - -## Environment Variables - -| Variable | Description | Required | -|----------|-------------|----------| -| `DATABASE_URL` | PostgreSQL connection string | Yes | -| `DIRECT_URL` | Direct database URL (for Prisma) | Yes | -| `JWT_SECRET` | Secret for JWT token signing | Yes | -| `PORT` | Server port (default: 8080) | No | -| `RAZORPAY_KEY_ID` | Razorpay API key | For payments | -| `RAZORPAY_KEY_SECRET` | Razorpay secret | For payments | -| `SENTRY_DSN` | Sentry error tracking DSN | No | - -[dart_frog_lint_badge]: https://img.shields.io/badge/style-dart_frog_lint-1DF9D2.svg -[dart_frog_lint_link]: https://pub.dev/packages/dart_frog_lint -[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg -[license_link]: https://opensource.org/licenses/MIT \ No newline at end of file diff --git a/backend/main.dart b/backend/main.dart index d1632b1..21324b7 100644 --- a/backend/main.dart +++ b/backend/main.dart @@ -1,4 +1,4 @@ -import 'dart:io'; +import 'dart:io' show File, HttpServer, InternetAddress; import 'package:backend/database/database_client.dart'; import 'package:backend/services/auth/auth_service.dart'; @@ -14,8 +14,14 @@ import 'package:dotenv/dotenv.dart'; /// Server entry point /// Initializes database, services, and starts the HTTP server Future run(Handler handler, InternetAddress ip, int port) async { - // Load environment variables - final env = DotEnv()..load(['.env']); + // Load environment variables. + // includePlatformEnvironment: true merges Platform.environment into the map, + // so env vars injected by Docker/Railway are accessible via env['KEY']. + // The .env file (if present) overrides platform env vars. + final env = DotEnv(includePlatformEnvironment: true); + if (File('.env').existsSync()) { + env.load(['.env']); + } // Initialize Sentry for error tracking (optional) await SentryLogger.init(env['SENTRY_DSN']); @@ -23,14 +29,19 @@ Future run(Handler handler, InternetAddress ip, int port) async { // Use DIRECT_URL for direct PostgreSQL connection (no PgBouncer) // PgBouncer in transaction mode doesn't support prepared statements // which the prisma_flutter_connector uses internally - final databaseUrl = env['DIRECT_URL'] ?? env['DATABASE_URL']; + final databaseUrl = + env['DIRECT_URL'] ?? env['DATABASE_URL']; if (databaseUrl == null) { - throw Exception('DIRECT_URL or DATABASE_URL must be set in .env'); + throw Exception( + 'DIRECT_URL or DATABASE_URL must be set in .env or environment', + ); } final jwtSecret = env['JWT_SECRET']; if (jwtSecret == null) { - throw Exception('JWT_SECRET must be set in .env'); + throw Exception( + 'JWT_SECRET must be set in .env or environment', + ); } // GitHub OAuth credentials (optional - only needed if using GitHub auth) @@ -63,7 +74,8 @@ Future run(Handler handler, InternetAddress ip, int port) async { // Email service (optional — only needed for password reset + email verification) final resendApiKey = env['RESEND_API_KEY']; - final appBaseUrl = env['APP_BASE_URL'] ?? 'https://familiarise.com'; + final appBaseUrl = + env['APP_BASE_URL'] ?? 'https://familiarise.com'; EmailService? emailService; if (resendApiKey != null && resendApiKey.isNotEmpty) { emailService = EmailService(apiKey: resendApiKey); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma deleted file mode 120000 index a72677e..0000000 --- a/backend/prisma/schema.prisma +++ /dev/null @@ -1 +0,0 @@ -../../../familiarise_web/prisma/schema.prisma \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..6797ed8 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,2404 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + // Database URLs are now configured in prisma.config.ts (Prisma 7) +} + +////////////////////////////////////////// USER MODEL ////////////////////////////////////////// + +model User { + id String @id @default(cuid()) + name String + email String @unique + emailVerified Boolean @default(false) + image String? + phone String? @unique + address String? + onlineStatus Boolean @default(false) + timezone String? + onboardingCompleted Boolean? @default(false) + role UserRole? @default(CONSULTEE) + + // New fields for enhanced user profile + dateOfBirth DateTime? + gender Gender? + city String? + country String? + linkedinUrl String? + bio String? @db.VarChar(160) // Short tagline, max 160 chars + profileDisplayImage String? // Square profile image for Explore Experts page + + // Consent timestamps + termsAcceptedAt DateTime? + privacyAcceptedAt DateTime? + + // Professional background (consolidated from profile level) + workExperiences WorkExperience[] + certifications Certification[] + education Education[] + + cookiePreferences CookiePreference? + notificationPreferences NotificationPreference? + Payment Payment[] + + // Relations + consultantProfile ConsultantProfile? + consultantProfileId String? @unique + consulteeProfile ConsulteeProfile? + consulteeProfileId String? @unique + staffProfile StaffProfile? + staffProfileId String? @unique + adminProfile AdminProfile? + adminProfileId String? @unique + + slotsOfAppointment SlotOfAppointment[] @relation("SlotOfAppointmentToUser") + Waitlist Waitlist[] + + // Feedback and Support + feedbacks Feedback[] + supportTickets SupportTicket[] + supportResponses SupportResponse[] + + accounts Account[] // BetterAuth Accounts + sessions Session[] // BetterAuth Sessions + members Member[] // Organization memberships + invitationsSent Invitation[] @relation("InvitationsSent") + + // Staff Dashboard Relations + reportsSubmitted ModerationReport[] @relation("ReportsSubmitted") + reportsReceived ModerationReport[] @relation("ReportsReceived") + moderationActions ModerationAction[] + + // Referral system + referralCode ReferralCode? + referral Referral? @relation("ReferredUser") + referralCredits ReferralCredit[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([consultantProfileId]) + @@index([consulteeProfileId]) + @@index([adminProfileId]) + @@index([role]) + @@index([staffProfileId]) + @@map("users") +} + +model Feedback { + id String @id @default(uuid()) + title String + description String @db.Text + rating Int? @db.SmallInt + category String? + status FeedbackStatus @default(PENDING) + + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} + +model SupportTicket { + id String @id @default(uuid()) + title String + description String @db.Text + priority SupportPriority @default(MEDIUM) + status SupportTicketStatus @default(OPEN) + category String? + issueType SupportIssueType? + + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String + + responses SupportResponse[] + attachments SupportTicketAttachment[] + + // Entity links for Swiggy-style context (link ticket to booking/payment) + consultationId String? + subscriptionId String? + paymentId String? + refundId String? // If refund was initiated from this ticket + + // Staff assignment + assignedToId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([status]) + @@index([assignedToId]) + @@index([consultationId]) + @@index([paymentId]) + @@index([createdAt]) + @@index([assignedToId, status]) +} + +model SupportResponse { + id String @id @default(uuid()) + message String @db.Text + isInternal Boolean @default(false) // Internal notes not visible to user + + supportTicket SupportTicket @relation(fields: [supportTicketId], references: [id], onUpdate: Cascade, onDelete: Cascade) + supportTicketId String + + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([supportTicketId]) +} + +model SupportTicketAttachment { + id String @id @default(uuid()) + fileName String + originalName String + fileSize Int + mimeType String + fileUrl String + storagePath String + + ticket SupportTicket @relation(fields: [ticketId], references: [id], onUpdate: Cascade, onDelete: Cascade) + ticketId String + + uploadedAt DateTime @default(now()) + + @@index([ticketId]) +} + +enum FeedbackStatus { + PENDING + ACKNOWLEDGED + IN_PROGRESS + RESOLVED + CLOSED +} + +enum SupportTicketStatus { + OPEN + IN_PROGRESS + ON_HOLD + RESOLVED + CLOSED +} + +enum SupportPriority { + LOW + MEDIUM + HIGH + URGENT +} + +// Cancellation reasons for Consultations and Subscriptions +enum CancellationReason { + // User-initiated + SCHEDULE_CONFLICT + FOUND_ALTERNATIVE + FINANCIAL_REASONS + PERSONAL_EMERGENCY + NO_LONGER_NEEDED + + // Consultant-initiated + CONSULTANT_UNAVAILABLE + CONSULTANT_EMERGENCY + + // System-initiated + PAYMENT_FAILED + EXPIRED + + // Issue-related + CONSULTANT_ISSUE + TECHNICAL_ISSUE + + // Other + OTHER +} + +// Issue types for support tickets (Swiggy-style categorization) +enum SupportIssueType { + // Session Issues + CONSULTANT_NO_SHOW + CONSULTANT_LATE + SESSION_ENDED_EARLY + SESSION_QUALITY_POOR + COMMUNICATION_ISSUE + TECHNICAL_ISSUES + WRONG_CONSULTANT + + // Access & Scheduling + ACCESS_ISSUE + TIMEZONE_CONFUSION + RESCHEDULING_HELP + + // Payment Issues + PAYMENT_FAILED + CHARGED_TWICE + REFUND_REQUEST + BILLING_QUESTION + + // Documents + DOCUMENT_ISSUE + + // Cancellation + WANT_TO_CANCEL + CANCELLATION_ISSUE + + // General + ACCOUNT_ISSUE + GENERAL_INQUIRY + OTHER +} + +model CookiePreference { + id String @id @default(cuid()) + + // For authenticated users + user User? @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String? @unique + + // For anonymous users (browser session identifier) + sessionId String? @unique + + // Essential: Core functionality - auth, security, CSRF (cannot be disabled) + essential Boolean @default(true) + + // Analytics: Usage tracking - GA4, Hotjar, Mixpanel, PostHog + analytics Boolean @default(false) + + // Marketing: Advertising - Facebook Pixel, Google Ads, LinkedIn, TikTok + marketing Boolean @default(false) + + // Functional: Third-party features - chat widgets (Intercom), video embeds + functional Boolean @default(false) + + // Consent timestamps for GDPR compliance + consentGivenAt DateTime @default(now()) + consentUpdatedAt DateTime @updatedAt + + @@map("cookie_preferences") +} + +model NotificationPreference { + id String @id @default(cuid()) + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String @unique + allNotifications Boolean @default(true) + + // Channel preferences + inAppEnabled Boolean @default(true) + emailEnabled Boolean @default(true) + pushEnabled Boolean @default(false) + + // Legacy category preferences (kept for backward compatibility) + mentions Boolean @default(false) + directMessages Boolean @default(false) + updates Boolean @default(false) + + // Category preferences + appointmentReminders Boolean @default(true) + paymentNotifications Boolean @default(true) + supportUpdates Boolean @default(true) + feedbackAlerts Boolean @default(true) + trialNotifications Boolean @default(true) + subscriptionAlerts Boolean @default(true) + marketingEmails Boolean @default(false) + + // Quiet hours + quietHoursEnabled Boolean @default(false) + quietHoursStart String? // "22:00" format in user timezone + quietHoursEnd String? // "08:00" format in user timezone + quietHoursTimezone String? // e.g. "Asia/Kolkata" + + @@map("notification_preferences") +} + +model Account { + id String @id @default(cuid()) + userId String + accountId String + providerId String + accessToken String? @db.Text + refreshToken String? @db.Text + accessTokenExpiresAt DateTime? + refreshTokenExpiresAt DateTime? + scope String? + idToken String? @db.Text + password String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([providerId, accountId]) + @@index([userId]) + @@map("accounts") +} + +model Session { + id String @id @default(cuid()) + token String @unique + userId String + expiresAt DateTime + ipAddress String? + userAgent String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@map("sessions") +} + +model Verification { + id String @id @default(cuid()) + identifier String + value String + expiresAt DateTime + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("verifications") +} + +model Organization { + id String @id @default(cuid()) + name String + slug String @unique + logo String? + metadata Json? + + members Member[] + invitations Invitation[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("organizations") +} + +model Member { + id String @id @default(cuid()) + organizationId String + userId String + role String @default("member") + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@unique([organizationId, userId]) + @@map("members") +} + +model Invitation { + id String @id @default(cuid()) + organizationId String + email String + role String @default("member") + status String @default("pending") + expiresAt DateTime + inviterId String + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + inviter User @relation("InvitationsSent", fields: [inviterId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@map("invitations") +} + +//////////////////////////////////////////////////// USER PROFILES and SLOTTING MECHANISM //////////////////////////////////////////////////// + +model ConsultantProfile { + id String @id @default(uuid()) + description String? @db.Text + experience Float? + rating Float @default(0) + + // New fields for enhanced consultant profile + headline String? @db.VarChar(120) // Professional headline, max 120 chars + websiteUrl String? + twitterUrl String? + githubUrl String? + videoIntroUrl String? + languages String[] @default([]) + toolsAndTechnologies String[] @default([]) + mentoringStyle String? @db.Text + sessionTypes SessionType[] @default([]) + profileCompletionPercentage Int @default(0) + isVerified Boolean @default(false) + verificationStatus ConsultantVerificationStatus @default(PENDING_VERIFICATION) + totalMenteesHelped Int @default(0) + + domain Domain @relation(fields: [domainId], references: [id]) + domainId String + subDomains SubDomain[] @relation("ConsultantProfileToSubDomain") + tags Tag[] @relation("ConsultantProfileToTag") + + reviews ConsultantReview[] + scheduleType ScheduleType + + slotsOfAvailabilityWeekly SlotOfAvailabilityWeekly[] + slotsOfAvailabilityCustom SlotOfAvailabilityCustom[] + + consultationPlans ConsultationPlan[] + subscriptionPlans SubscriptionPlan[] + webinarPlans WebinarPlan[] + classPlans ClassPlan[] + + // Collaborator relations + webinarCollaborations WebinarCollaborator[] @relation("WebinarCollaborator") + classCollaborations ClassCollaborator[] @relation("ClassCollaborator") + invitedWebinarCollabs WebinarCollaborator[] @relation("WebinarCollaboratorInvitedBy") + invitedClassCollabs ClassCollaborator[] @relation("ClassCollaboratorInvitedBy") + + // Trial sessions and activity logs + trialSessions TrialSession[] + activityLogs ActivityLog[] + + // Note: Professional background (workExperiences, certifications, education) + // has been consolidated to User level for DRY principle + + // Achievements / portfolio items + achievements Achievement[] + + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String @unique + + // Payout relations + earnings ConsultantEarnings[] + payouts Payout[] + payoutAccounts PayoutAccount[] + + // Tax & compliance + taxInfo ConsultantTaxInfo? + tdsRecords TDSRecord[] + + // Profile verification + verificationRequests ConsultantProfileVerification[] + + // Cached balances (in smallest currency unit, e.g., paise) + totalRevenue Int @default(0) + pendingRevenue Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([domainId]) + @@index([isVerified]) + @@index([verificationStatus]) +} + +model Domain { + id String @id @default(uuid()) + name String @unique + subDomains SubDomain[] + tags Tag[] + consultantProfiles ConsultantProfile[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([name]) +} + +model SubDomain { + id String @id @default(uuid()) + name String + domain Domain @relation(fields: [domainId], references: [id]) + domainId String + consultantProfiles ConsultantProfile[] @relation("ConsultantProfileToSubDomain") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([name, domainId]) + @@index([domainId]) +} + +model Tag { + id String @id @default(uuid()) + name String + domain Domain @relation(fields: [domainId], references: [id]) + domainId String + consultantProfiles ConsultantProfile[] @relation("ConsultantProfileToTag") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([name, domainId]) + @@index([domainId]) +} + +model ConsultantReview { + id String @id @default(uuid()) + rating Int @default(0) @db.SmallInt + reviewDescription String? @db.Text + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + consultantProfileId String + + consulteeProfile ConsulteeProfile @relation(fields: [consulteeProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + consulteeProfileId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model ConsulteeProfile { + id String @id @default(uuid()) + aboutMe String? @db.Text + preferredLanguage String? + goals String? @db.Text + + careerStage CareerStage? + skillsToDevelop String[] @default([]) + budgetPreference BudgetPreference? + + // Education and work experience are at User level (WorkExperience, Education models) + + consultationRequests Consultation[] + subscriptionRequests Subscription[] + consultantReviews ConsultantReview[] + trialSessions TrialSession[] + + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([careerStage]) +} + +model StaffProfile { + id String @id @default(uuid()) + department String? + position String? + + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([department]) +} + +model AdminProfile { + id String @id @default(uuid()) + adminLevel AdminLevel + notes String? @db.Text + + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([adminLevel]) +} + +//////////////////////////////////////////////////// PROFESSIONAL BACKGROUND MODELS //////////////////////////////////////////////////// + +model WorkExperience { + id String @id @default(uuid()) + company String + companyDomain String? // e.g., "google.com" — used by Logo.dev to fetch company logo sticker + title String + location String? + startDate DateTime + endDate DateTime? + isCurrent Boolean @default(false) + description String? @db.Text + + // Consolidated to User level (was ConsultantProfile) + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([isCurrent]) +} + +model Certification { + id String @id @default(uuid()) + name String + issuingOrganization String + issueDate DateTime + expiryDate DateTime? + credentialId String? + credentialUrl String? + + // Consolidated to User level (was ConsultantProfile) + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} + +model Education { + id String @id @default(uuid()) + institution String + institutionDomain String? + degree String + fieldOfStudy String? + startYear Int? + endYear Int? + grade String? + activities String? + description String? @db.Text + + // Consolidated to User level (was ConsultantProfile/ConsulteeProfile) + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} + +//////////////////////////////////////////////////// ACHIEVEMENTS / PORTFOLIO //////////////////////////////////////////////////// + +enum AchievementType { + AWARD + PUBLICATION + PROJECT + TALK + OPEN_SOURCE + OTHER +} + +model Achievement { + id String @id @default(uuid()) + title String + description String? @db.Text + url String? + imageUrl String? + achievementType AchievementType @default(OTHER) + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + consultantProfileId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([consultantProfileId]) +} + +//////////////////////////////////////////////////// AVAILABILITY SLOTS //////////////////////////////////////////////////// + +model SlotOfAvailabilityWeekly { + id String @id @default(uuid()) + startDay DayOfWeek + startTimeUtc Int @db.SmallInt // Minutes since midnight UTC (0-1439) + endDay DayOfWeek + endTimeUtc Int @db.SmallInt // Minutes since midnight UTC (0-1439) + utcOffsetMinutes Int @default(0) @db.SmallInt // UTC offset in minutes at slot creation (e.g. 330 for IST, -300 for EST) + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + consultantProfileId String + + createdAt DateTime @default(now()) @db.Timestamptz() + updatedAt DateTime @updatedAt @db.Timestamptz() + + @@index([consultantProfileId]) +} + +model SlotOfAvailabilityCustom { + id String @id @default(uuid()) + startsAt DateTime @db.Timestamptz() + endsAt DateTime @db.Timestamptz() + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + consultantProfileId String + + createdAt DateTime @default(now()) @db.Timestamptz() + updatedAt DateTime @updatedAt @db.Timestamptz() + + @@index([consultantProfileId]) +} + +//////////////////////////////////////////////////// PRICING PLANS //////////////////////////////////////////////////// + +// 1-1 Consultation +model ConsultationPlan { + id String @id @default(cuid()) + title String + description String? @db.Text + durationInHours Float @default(1) + price Int // in paise (smallest currency unit, e.g. 50000 = ₹500) + priceCurrency String @default("INR") + language String @default("English") + level String @default("Beginner") + prerequisites String? @default("None") + materialProvided String? @default("None") + learningOutcomes String[] @default([]) + topics Topic[] @relation("TopicToConsultationPlan") + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + consultantProfileId String + + consultations Consultation[] + materials PlanMaterial[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Consultation { + id String @id @default(uuid()) + consultationPlan ConsultationPlan @relation(fields: [consultationPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) + consultationPlanId String + + requestStatus RequestStatus @default(PENDING) + requestedBy ConsulteeProfile @relation(fields: [requestedById], references: [id], onUpdate: Cascade, onDelete: Cascade) + requestedById String + requestedAt DateTime @default(now()) @db.Timestamptz() + requestNotes String? + pendingPaymentUrl String? // Payment link while awaiting payment (cleared after payment) + bookingSource BookingSource @default(REQUEST_SUBMITTED) + feedbackFromConsultee String? + feedbackFromConsultant String? + rating Float? + + // Cancellation tracking + cancellationReason CancellationReason? + cancellationNotes String? @db.Text + cancelledAt DateTime? @db.Timestamptz() + cancelledBy String? // userId who cancelled + + appointment Appointment? + + createdAt DateTime @default(now()) @db.Timestamptz() + updatedAt DateTime @updatedAt @db.Timestamptz() + + @@index([requestedById]) + @@index([consultationPlanId]) + @@index([requestStatus]) + @@index([requestedById, requestStatus]) + @@index([requestedAt]) + @@index([cancelledAt]) +} + +model SubscriptionPlan { + id String @id @default(cuid()) + title String + description String? @db.Text + durationInMonths Int @default(1) + price Int // in paise (smallest currency unit, e.g. 50000 = ₹500) + priceCurrency String @default("INR") + callsPerWeek Int @default(1) + sessionDurationInHours Float @default(1.0) // Duration of each session in hours + totalSessions Int @default(4) // callsPerWeek × durationInMonths × 4 + totalHours Float @default(4.0) // totalSessions × sessionDurationInHours + emailSupport PlanEmailSupport @default(GENERAL) + language String @default("English") + level String @default("Beginner") + prerequisites String? @default("None") + materialProvided String? @default("None") + learningOutcomes String[] @default([]) + topics Topic[] @relation("TopicToSubscriptionPlan") + + // Free trial fields + freeTrialEnabled Boolean @default(false) + freeTrialDurationMinutes Int @default(30) // 30 or 60 minutes + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + consultantProfileId String + + subscriptions Subscription[] + subscriptionContents SubscriptionContent[] + trialSessions TrialSession[] + materials PlanMaterial[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Subscription { + id String @id @default(cuid()) + schedulingPeriodStartsAt DateTime @db.Timestamptz() + schedulingPeriodEndsAt DateTime @db.Timestamptz() + schedulingTimezone String @default("Asia/Kolkata") + + requestStatus RequestStatus @default(PENDING) + requestedBy ConsulteeProfile @relation(fields: [requestedById], references: [id], onUpdate: Cascade, onDelete: Cascade) + requestedById String + requestedAt DateTime @default(now()) @db.Timestamptz() + requestNotes String? + pendingPaymentUrl String? // Payment link while awaiting payment (cleared after payment) + bookingSource BookingSource @default(REQUEST_SUBMITTED) + feedbackFromConsultee String? + feedbackFromConsultant String? + rating Float? + + // Cancellation tracking + cancellationReason CancellationReason? + cancellationNotes String? @db.Text + cancelledAt DateTime? @db.Timestamptz() + cancelledBy String? // userId who cancelled + + subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) + subscriptionPlanId String + + appointments Appointment[] + convertedFromTrial TrialSession? + + createdAt DateTime @default(now()) @db.Timestamptz() + updatedAt DateTime @updatedAt @db.Timestamptz() + + @@index([requestedById]) + @@index([subscriptionPlanId]) + @@index([requestStatus]) + @@index([requestedById, requestStatus]) + @@index([requestedAt]) + @@index([cancelledAt]) +} + +enum RequestStatus { + PENDING + APPROVED + APPROVED_PENDING_PAYMENT // Approved by consultant but awaiting payment + SCHEDULED + COMPLETED + REJECTED + CANCELLED + EXPIRED +} + +// Free trial session tracking for subscriptions +model TrialSession { + id String @id @default(cuid()) + status TrialSessionStatus @default(PENDING) + notes String? @db.Text // Consultee's questions/goals + + // One trial per consultant per consultee + consulteeProfile ConsulteeProfile @relation(fields: [consulteeProfileId], references: [id]) + consulteeProfileId String + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id]) + consultantProfileId String + + subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id]) + subscriptionPlanId String + + // Session scheduling + appointment Appointment? @relation(fields: [appointmentId], references: [id]) + appointmentId String? @unique + + // Outcome tracking + convertedToSubscription Subscription? @relation(fields: [convertedToSubscriptionId], references: [id]) + convertedToSubscriptionId String? @unique + + requestedAt DateTime @default(now()) + completedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([consulteeProfileId, consultantProfileId]) // One trial per consultant + @@index([consultantProfileId]) + @@index([subscriptionPlanId]) +} + +enum TrialSessionStatus { + PENDING // Requested, awaiting consultant action + SCHEDULED // Time slot confirmed + COMPLETED // Trial session completed + CONVERTED // Consultee subscribed after trial + CANCELLED // Cancelled by consultee + REJECTED // Declined by consultant +} + +// Activity tracking for consultant dashboard +model ActivityLog { + id String @id @default(cuid()) + activityType ActivityType + description String + metadata Json? // Additional context (e.g., plan name, amount) + + // Actor (who performed the action) + actorId String + actorName String + actorImage String? + + // Target consultant + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id]) + consultantProfileId String + + // Optional references + consultationId String? + subscriptionId String? + webinarId String? + classId String? + trialSessionId String? + + createdAt DateTime @default(now()) + + @@index([consultantProfileId, createdAt(sort: Desc)]) +} + +enum ActivityType { + CONSULTATION_BOOKED + CONSULTATION_COMPLETED + CONSULTATION_CANCELLED + SUBSCRIPTION_REQUESTED + SUBSCRIPTION_APPROVED + SUBSCRIPTION_CANCELLED + WEBINAR_REGISTERED + CLASS_ENROLLED + TRIAL_REQUESTED + TRIAL_SCHEDULED + TRIAL_COMPLETED + TRIAL_CONVERTED + REVIEW_SUBMITTED + MESSAGE_RECEIVED +} + +enum BookingSource { + DIRECT_CHECKOUT + REQUEST_SUBMITTED +} + +// Many-Many Webinar +model WebinarPlan { + id String @id @default(cuid()) + title String + topics Topic[] @relation("TopicToWebinarPlan") + description String? @db.Text + price Int // in paise (smallest currency unit, e.g. 50000 = ₹500) + priceCurrency String @default("INR") + certificateProvided Boolean @default(false) + recordingEnabled Boolean @default(false) // Allow consultant to record sessions + recordingStoragePolicy RecordingStoragePolicy @default(STREAM_ONLY) + durationInHours Float @default(1) // Duration in hours + maxParticipants Int @default(100) + language String? @default("English") + level String? @default("Beginner") + prerequisites String? @default("None") + materialProvided String? @default("None") + learningOutcomes String[] @default([]) + imageUrl String? + + consultantProfile ConsultantProfile? @relation(fields: [consultantProfileId], references: [id]) + consultantProfileId String? + webinars Webinar[] + materials PlanMaterial[] + collaborators WebinarCollaborator[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([consultantProfileId]) +} + +model Webinar { + id String @id @default(cuid()) + status WebinarStatus @default(SCHEDULED) + feedbackSummary String? + waitlist Waitlist[] + + webinarPlan WebinarPlan @relation(fields: [webinarPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) + webinarPlanId String + + appointment Appointment? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([webinarPlanId]) + @@index([status]) +} + +enum WebinarStatus { + SCHEDULED + IN_PROGRESS + COMPLETED + CANCELLED +} + +// Many-Many Class +model ClassPlan { + id String @id @default(cuid()) + title String + description String @db.Text + topics Topic[] @relation("TopicToClassPlan") + classContents ClassContent[] + price Int // in paise (smallest currency unit, e.g. 50000 = ₹500) + priceCurrency String @default("INR") + certificateProvided Boolean @default(false) + recordingEnabled Boolean @default(false) // Allow consultant to record sessions + recordingStoragePolicy RecordingStoragePolicy @default(STREAM_ONLY) + durationInMonths Int @default(1) // Duration in months + meetingsPerWeek Int @default(1) + sessionDurationInHours Float @default(1.0) // Duration of each session in hours + totalSessions Int @default(4) // meetingsPerWeek × durationInMonths × 4 + totalHours Float @default(4.0) // totalSessions × sessionDurationInHours + emailSupport PlanEmailSupport @default(GENERAL) + maxParticipants Int @default(1) + language String? @default("English") + level String? @default("Beginner") + prerequisites String? @default("None") + materialProvided String? @default("None") + learningOutcomes String[] @default([]) + imageUrl String? + + consultantProfile ConsultantProfile? @relation(fields: [consultantProfileId], references: [id]) + consultantProfileId String? + classes Class[] + materials PlanMaterial[] + collaborators ClassCollaborator[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([consultantProfileId]) +} + +model Class { + id String @id @default(cuid()) + schedulingPeriodStartsAt DateTime? @db.Timestamptz() + schedulingPeriodEndsAt DateTime? @db.Timestamptz() + schedulingTimezone String @default("Asia/Kolkata") + status ClassStatus @default(SCHEDULED) + waitlist Waitlist[] + recordingUrls String[] + feedbackSummary String? @db.Text + + classPlan ClassPlan @relation(fields: [classPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) + classPlanId String + + appointments Appointment[] + + createdAt DateTime @default(now()) @db.Timestamptz() + updatedAt DateTime @updatedAt @db.Timestamptz() + + @@index([classPlanId]) + @@index([status]) +} + +model ClassContent { + id String @id @default(cuid()) + title String + description String @db.Text + contentType String? + contentUrl String? + order Int + hoursAllotted Float + + classPlan ClassPlan @relation(fields: [classPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) + classPlanId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([classPlanId]) + @@index([classPlanId, order]) +} + +// Session-by-session curriculum for subscriptions (similar to ClassContent) +model SubscriptionContent { + id String @id @default(cuid()) + title String + description String @db.Text + contentType String? // e.g., "Video Call", "Review Session", "Q&A" + contentUrl String? // Optional link to resources + order Int // Session sequence (1, 2, 3...) + hoursAllotted Float @default(1.0) + + subscriptionPlan SubscriptionPlan @relation(fields: [subscriptionPlanId], references: [id], onDelete: Cascade) + subscriptionPlanId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([subscriptionPlanId]) + @@index([subscriptionPlanId, order]) +} + +enum ClassStatus { + SCHEDULED + IN_PROGRESS + COMPLETED + CANCELLED +} + +model Topic { + id String @id @default(cuid()) + name String @unique + + webinarPlans WebinarPlan[] @relation("TopicToWebinarPlan") + classPlans ClassPlan[] @relation("TopicToClassPlan") + consultationPlans ConsultationPlan[] @relation("TopicToConsultationPlan") + subscriptionPlans SubscriptionPlan[] @relation("TopicToSubscriptionPlan") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Newsletter { + id String @id @default(uuid()) + email String @unique + + unsubscribed Boolean @default(false) + unsubscribedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +// Waitlist status for tracking user position and notification state +enum WaitlistStatus { + WAITING // In queue, waiting for spot + NOTIFIED // Spot available, awaiting user response + BOOKED // Successfully booked the spot + EXPIRED // Notification window expired (48h) + CANCELLED // User left waitlist voluntarily + SKIPPED // User declined spot, moved to back of queue +} + +model Waitlist { + id String @id @default(uuid()) + joinedAt DateTime @default(now()) + + // Queue management + position Int? // Calculated queue position (null until calculated) + status WaitlistStatus @default(WAITING) + priority Int @default(0) // Higher value = higher priority (for VIP/premium users) + + // Notification tracking + notifiedAt DateTime? // When user was notified of available spot + expiresAt DateTime? // 48 hours after notifiedAt + reminderSentAt DateTime? // When 12-hour reminder was sent + bookedAt DateTime? // When user successfully booked + respondedAt DateTime? // When user responded to notification + + // User preferences (for future enhancements) + preferences Json? // e.g., { preferredDates: [], maxPrice: 500 } + + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String + + webinar Webinar? @relation(fields: [webinarId], references: [id], onUpdate: Cascade, onDelete: Cascade) + webinarId String? + + class Class? @relation(fields: [classId], references: [id], onUpdate: Cascade, onDelete: Cascade) + classId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, webinarId]) + @@unique([userId, classId]) + @@index([userId]) + @@index([webinarId]) + @@index([classId]) + @@index([status]) + @@index([status, webinarId]) + @@index([status, classId]) + @@index([expiresAt]) + @@index([priority, joinedAt]) +} + +////////////////////////////////////////////// APPOINTMENT //////////////////////////////////////////////////// + +// Generic Appointment Model +model Appointment { + id String @id @default(uuid()) + appointmentType AppointmentsType + slotsOfAppointment SlotOfAppointment[] + + consultation Consultation? @relation(fields: [consultationId], references: [id], onUpdate: Cascade, onDelete: Cascade) + consultationId String? @unique + + subscription Subscription? @relation(fields: [subscriptionId], references: [id], onUpdate: Cascade, onDelete: Cascade) + subscriptionId String? + + webinar Webinar? @relation(fields: [webinarId], references: [id], onUpdate: Cascade, onDelete: Cascade) + webinarId String? @unique + + class Class? @relation(fields: [classId], references: [id], onUpdate: Cascade, onDelete: Cascade) + classId String? + + trialSession TrialSession? + + payment Payment[] + documents AppointmentDocument[] + + createdAt DateTime @default(now()) @db.Timestamptz() + updatedAt DateTime @updatedAt @db.Timestamptz() + + @@index([subscriptionId]) + @@index([classId]) + @@index([consultationId]) + @@index([webinarId]) + @@index([appointmentType]) +} + +enum AppointmentsType { + CONSULTATION + SUBSCRIPTION + WEBINAR + CLASS + TRIAL +} + +// Document Review Models +model AppointmentDocument { + id String @id @default(uuid()) + fileName String + originalName String + fileSize Int + mimeType String + fileUrl String + storagePath String + description String? // User description of what this document is (resume, ITR, legal document, etc.) + + // Review fields + reviewStatus DocumentReviewStatus @default(PENDING) + reviewNotes String? + reviewedAt DateTime? + reviewedBy String? // Consultant user ID + + // Upload role - who uploaded this document + uploadedByRole DocumentUploadRole @default(CONSULTEE) + + // Response document linking - for consultant responses to consultee submissions + responseToDocumentId String? + responseToDocument AppointmentDocument? @relation("DocumentResponse", fields: [responseToDocumentId], references: [id], onUpdate: Cascade, onDelete: SetNull) + responseDocuments AppointmentDocument[] @relation("DocumentResponse") + + // Relations + appointment Appointment @relation(fields: [appointmentId], references: [id], onUpdate: Cascade, onDelete: Cascade) + appointmentId String + + // Metadata + uploadedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([appointmentId]) + @@index([reviewStatus]) + @@index([uploadedByRole]) + @@index([responseToDocumentId]) +} + +enum DocumentReviewStatus { + PENDING + IN_REVIEW + APPROVED + REJECTED + NEEDS_REVISION +} + +enum DocumentUploadRole { + CONSULTEE + CONSULTANT +} + +// Plan Materials - Consultant-uploaded materials at plan level (shared across all instances) +model PlanMaterial { + id String @id @default(uuid()) + fileName String + originalName String + fileSize Int + mimeType String + fileUrl String + storagePath String + description String? @db.Text + order Int @default(0) // For ordering materials in display + + // Optional foreign keys - exactly one should be set + consultationPlanId String? + subscriptionPlanId String? + webinarPlanId String? + classPlanId String? + + // Relations + consultationPlan ConsultationPlan? @relation(fields: [consultationPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) + subscriptionPlan SubscriptionPlan? @relation(fields: [subscriptionPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) + webinarPlan WebinarPlan? @relation(fields: [webinarPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) + classPlan ClassPlan? @relation(fields: [classPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + uploadedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([consultationPlanId]) + @@index([subscriptionPlanId]) + @@index([webinarPlanId]) + @@index([classPlanId]) + @@index([order]) +} + +model SlotOfAppointment { + id String @id @default(uuid()) + + user User[] @relation("SlotOfAppointmentToUser") + + startsAt DateTime @db.Timestamptz() + endsAt DateTime @db.Timestamptz() + isTentative Boolean @default(false) + + completionStatus SlotCompletionStatus @default(SCHEDULED) + completedAt DateTime? @db.Timestamptz() + + appointment Appointment @relation(fields: [appointmentId], references: [id], onUpdate: Cascade, onDelete: Cascade) + appointmentId String + + meetingSession MeetingSession? + + createdAt DateTime @default(now()) @db.Timestamptz() + updatedAt DateTime @updatedAt @db.Timestamptz() + + @@index([appointmentId]) + @@index([isTentative, appointmentId]) + // OPT-3: Additional indexes for time range queries in validateSlotAvailability + @@index([startsAt, endsAt]) + @@index([isTentative, startsAt, endsAt]) + @@index([createdAt]) + @@index([completionStatus, endsAt]) +} + +enum SlotCompletionStatus { + SCHEDULED // Default — future, not yet happened + COMPLETED // Session held (MeetingSession.endedAt OR manual mark) + UNVERIFIED // Past + no MeetingSession record (may be offline session) + CANCELLED // Explicitly cancelled before it occurred + RESCHEDULED // Replaced by a later slot (via in-progress reallocation) +} + +////////////////////////////////////////////// MEETINGS FOR STREAM //////////////////////////////////////////////////// + +model MeetingSession { + id String @id @default(cuid()) + streamCallId String @unique + platform Platform @default(STREAM) + passcode String? + hostKeys String[] + + // Recording state + isRecording Boolean @default(false) + recordingStartedAt DateTime? + recordingStartedBy String? // userId who started recording + + // Session lifecycle + endedAt DateTime? // When session actually ended + endedReason String? // "call_ended", "session_timeout", "error" + + recordings Recording[] + + slotOfAppointment SlotOfAppointment @relation(fields: [slotOfAppointmentId], references: [id], onUpdate: Cascade, onDelete: Cascade) + slotOfAppointmentId String @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([isRecording]) +} + +// Recording storage types +enum RecordingStorageType { + STREAM_S3 // Default: expires in 2 weeks + SUPABASE // Permanent storage +} + +// Recording storage policy for plans (determines where recordings are stored) +enum RecordingStoragePolicy { + STREAM_ONLY // 2-week temporary storage (free tier) + SUPABASE_PERMANENT // Permanent storage (premium tier) +} + +// Recording status lifecycle +enum RecordingStatus { + RECORDING // Currently being recorded + PROCESSING // Stream is processing the recording + READY // Available on Stream S3 + TRANSFERRING // Being transferred to Supabase + AVAILABLE // Available on Supabase (permanent) + FAILED // Recording failed + EXPIRED // Stream S3 URL expired +} + +// Model for storing recording details with dual storage support +model Recording { + id String @id @default(cuid()) + title String + recordingUrl String + durationInMinutes Int + recordedAt DateTime + + // Stream-specific identifiers + streamRecordingId String? @unique + streamCallId String? + + // Storage configuration + storageType RecordingStorageType @default(STREAM_S3) + status RecordingStatus @default(READY) + + // Supabase permanent storage + supabaseUrl String? + supabasePath String? + + // Media metadata + thumbnailUrl String? + fileSize BigInt? // File size in bytes + codec String? // e.g., "h264", "vp9" + resolution String? // e.g., "1920x1080", "1280x720" + + // Preview clip (for explore pages) + previewClipUrl String? + previewClipDuration Int? // Duration in seconds + + // Expiration tracking + streamUrlExpiresAt DateTime? // When Stream S3 URL expires + transferredAt DateTime? // When transferred to Supabase + + meetingSession MeetingSession @relation(fields: [meetingSessionId], references: [id], onUpdate: Cascade, onDelete: Cascade) + meetingSessionId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([meetingSessionId]) + @@index([streamRecordingId]) + @@index([status]) + @@index([storageType]) + @@index([streamUrlExpiresAt]) +} + +enum Platform { + ZOOM + GOOGLE_MEET + MICROSOFT_TEAMS + STREAM + CUSTOM +} + +////////////////////////////////////////////// PAYMENT STUFF //////////////////////////////////////////////////// + +model Payment { + id String @id @default(uuid()) + amount Int // in paise — final amount charged to gateway (after discounts + tax - credits) + originalAmount Int // in paise — original plan price before discounts/credits/tax (for earnings) + taxAmount Int @default(0) // in paise — GST amount charged + currency String + description String? + receiptUrl String? + paymentMethod String + paymentIntent String @unique + paymentGateway PaymentGateway + paymentStatus PaymentStatus + expiresAt DateTime? // For tracking payment intent expiration + isMockPayment Boolean @default(false) // For development: mock payments skip gateway calls + + // International payment tracking + buyerCountry String? // ISO 3166-1 alpha-2 code detected at checkout + isInternational Boolean @default(false) // Denormalized for query efficiency + displayCurrencyAtCheckout String? @db.VarChar(3) // Currency code shown to the buyer at checkout + exchangeRateAtCheckout Float? // Snapshot of INR→displayCurrencyAtCheckout for audit + + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + userId String + appointment Appointment? @relation(fields: [appointmentId], references: [id], onUpdate: Cascade, onDelete: Cascade) + appointmentId String? + discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onUpdate: Cascade, onDelete: SetNull) + discountCodeId String? + + refunds Refund[] // Refunds associated with this payment + disputes Dispute[] // Disputes associated with this payment + + // Payout relations + earnings ConsultantEarnings[] + creditUsages ReferralCreditUsage[] + invoice Invoice? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, appointmentId]) // Allow multiple users to pay for same appointment (webinars/classes) + @@index([expiresAt, paymentStatus]) + @@index([isMockPayment]) + @@index([paymentStatus]) + @@index([createdAt]) + @@index([userId]) + @@index([appointmentId]) + @@index([paymentStatus, createdAt]) +} + +enum PaymentGateway { + STRIPE + RAZORPAY + LEMON_SQUEEZY + XFLOW + CARD +} + +enum PaymentStatus { + PENDING + SUCCEEDED + FAILED + EXPIRED +} + +model Refund { + id String @id @default(uuid()) + amount Int // Amount refunded in smallest currency unit (e.g., cents for USD) + currency String + reason String? // Reason for refund + status RefundStatus + refundId String @unique // Gateway-specific refund ID (Stripe/Razorpay) + paymentGateway PaymentGateway + metadata Json? // Additional gateway-specific metadata + exchangeRateAtRefund Float? // INR→displayCurrency rate snapshot at refund time + displayCurrency String? @db.VarChar(3) // Currency the buyer originally saw + + payment Payment @relation(fields: [paymentId], references: [id], onUpdate: Cascade, onDelete: Cascade) + paymentId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([paymentId]) + @@index([status]) + @@index([refundId]) + @@index([paymentId, status]) +} + +enum RefundStatus { + PENDING // Refund initiated but not yet processed + SUCCEEDED // Refund completed successfully + FAILED // Refund failed + CANCELLED // Refund cancelled +} + +model Dispute { + id String @id @default(uuid()) + amount Int // Disputed amount in smallest currency unit + currency String + reason String // Dispute reason from gateway + status DisputeStatus + disputeId String @unique // Gateway-specific dispute ID + paymentGateway PaymentGateway + evidence Json? // Evidence submitted to gateway + dueBy DateTime? // Deadline to respond to dispute + isChargeRefundable Boolean @default(true) + + payment Payment @relation(fields: [paymentId], references: [id], onUpdate: Cascade, onDelete: Cascade) + paymentId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([paymentId]) + @@index([status]) + @@index([disputeId]) + @@index([dueBy]) + @@index([paymentId, status]) +} + +enum DisputeStatus { + WARNING_NEEDS_RESPONSE // Early fraud warning, needs response + WARNING_UNDER_REVIEW // Early fraud warning under review + WARNING_CLOSED // Early fraud warning closed + NEEDS_RESPONSE // Dispute filed, needs evidence + UNDER_REVIEW // Evidence submitted, under review + CHARGE_REFUNDED // Charge was refunded + WON // Dispute won + LOST // Dispute lost +} + +////////////////////////////////////////////// PAYOUT SYSTEM //////////////////////////////////////////////////// + +enum EarningStatus { + PENDING // In hold period + HELD // Extended hold (dispute) + READY // Ready for payout + PAID // Successfully paid + REFUNDED // Refunded to consultee +} + +enum PayoutStatus { + PENDING // Awaiting approval + APPROVED // Admin approved + PROCESSING // Being processed + COMPLETED // Successfully sent + FAILED // Provider rejected + CANCELLED // Manually cancelled +} + +enum PayoutMethod { + BANK_TRANSFER + UPI + STRIPE_TRANSFER +} + +enum PayoutAccountType { + BANK_ACCOUNT + UPI + STRIPE_CONNECT +} + +model ConsultantEarnings { + id String @id @default(cuid()) + consultantProfileId String + paymentId String + payoutId String? + + // Revenue breakdown + grossAmount Int // Total sale price + platformFee Int // Platform cut (20%) + consultantShare Int // Consultant cut (80%) + + // Collaborator support + role EarningRole @default(OWNER) + sharePercentage Float @default(100) + + // Status tracking + status EarningStatus @default(PENDING) + holdUntil DateTime // Release after hold period + paidAt DateTime? + + // Currency (always INR for MVP, extensible for multi-currency) + currency String @default("INR") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + payment Payment @relation(fields: [paymentId], references: [id], onUpdate: Cascade, onDelete: Cascade) + payout Payout? @relation(fields: [payoutId], references: [id]) + + @@unique([paymentId, consultantProfileId, role]) + @@index([consultantProfileId, status]) + @@index([status, holdUntil]) + @@index([payoutId]) + @@index([paymentId]) +} + +model Payout { + id String @id @default(cuid()) + consultantProfileId String + provider PaymentGateway + providerPayoutId String? @unique + amount Int + currency String @default("INR") + status PayoutStatus @default(PENDING) + method PayoutMethod + batchId String? + + // TDS (Tax Deducted at Source) — Section 194J + tdsDeducted Int @default(0) // TDS amount deducted from this payout, in paise + netAmount Int? // Amount after TDS deduction (amount - tdsDeducted), sent to gateway + tdsRateApplied Float? // TDS rate reserved for this payout, used when creating the final audit record + tdsFinancialYear String? // FY when TDS was calculated (e.g. "2026-27"). Persisted to avoid FY-boundary drift. + + // Processing + failureReason String? + retryCount Int @default(0) + processedAt DateTime? + approvedAt DateTime? + approvedBy String? + + // Idempotency (required by Razorpay from March 2025) + idempotencyKey String? @unique + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + earnings ConsultantEarnings[] + tdsRecords TDSRecord[] + + @@index([consultantProfileId, status]) + @@index([batchId]) + @@index([status]) + @@index([createdAt]) +} + +model PayoutAccount { + id String @id @default(cuid()) + consultantProfileId String + provider PaymentGateway + accountType PayoutAccountType @default(BANK_ACCOUNT) + + // Bank details (store only masked, full via gateway) + accountHolderName String? + bankName String? + accountNumberLast4 String? // Only last 4 digits + ifscCode String? + + // UPI + upiId String? + + // Gateway IDs + stripeAccountId String? @unique + stripeAccountStatus String? + razorpayContactId String? + razorpayFundAccId String? @unique + + // Verification + isVerified Boolean @default(false) + isDefault Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + @@index([consultantProfileId]) + @@index([isDefault]) +} + +////////////////////////////////////////// TAX & COMPLIANCE ////////////////////////////////////////// + +model ConsultantTaxInfo { + id String @id @default(cuid()) + consultantProfileId String @unique + panEncrypted Bytes? // AES-256-GCM encrypted PAN. Format: [12B IV][ciphertext][16B auth tag] + panLast4 String? @db.VarChar(4) // Cleartext last 4 chars for masked display + panVerified Boolean @default(false) + gstin String? // GSTIN for registered consultants + gstinVerified Boolean @default(false) + country String @default("IN") // ISO 3166-1 alpha-2 + isIndianResident Boolean @default(true) + lutNumber String? // Letter of Undertaking for export zero-rating + lutValidUntil DateTime? + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([consultantProfileId]) +} + +model TDSRecord { + id String @id @default(cuid()) + consultantProfileId String + financialYear String // "2026-27" format (Apr-Mar) + quarter Int // 1=Apr-Jun, 2=Jul-Sep, 3=Oct-Dec, 4=Jan-Mar + + // Amounts in paise + cumulativeAmountCredited Int // FY cumulative of amounts credited/paid to consultant (sum of completed payouts) + tdsDeducted Int // TDS amount deducted this record + tdsRate Float // Rate at time of deduction (10 or 20) + + // Link to the payout that triggered this deduction + payoutId String? + earningsId String? + + // Reversal flag (for refund-triggered TDS reversals — negative tdsDeducted) + isReversal Boolean @default(false) + + // Filing status + reportedInForm26Q Boolean @default(false) + form26QFilingDate DateTime? + + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + payout Payout? @relation(fields: [payoutId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([consultantProfileId, financialYear]) + @@index([financialYear, quarter]) + @@index([reportedInForm26Q]) +} + +model Invoice { + id String @id @default(cuid()) + paymentId String? @unique + invoiceNumber String @unique // INV-YYYYMM-XXXXX + amount Int + currency String @default("INR") + status PaymentStatus @default(PENDING) + items Json // Line items with HSN codes + pdfUrl String? + dueDate DateTime? + paidAt DateTime? + + // Tax breakdown + taxAmount Int? // in paise — GST amount + taxRate Float? // 18% for services + hsnCode String? // SAC code (999293 for consulting) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + payment Payment? @relation(fields: [paymentId], references: [id]) + + @@index([invoiceNumber]) + @@index([status]) + @@index([paymentId]) +} + +model WebhookEvent { + id String @id @default(cuid()) + provider String // razorpay, stripe, etc. + eventId String @unique + eventType String + payload Json + signature String? + processed Boolean @default(false) + processedAt DateTime? + error String? + receivedAt DateTime @default(now()) + + @@index([provider]) + @@index([processed]) + @@index([eventType]) + @@index([receivedAt]) +} + +model DiscountCode { + id String @id @default(uuid()) + code String @unique + description String + discountType DiscountType + discountValue Int // For PERCENTAGE: whole number (10 = 10%). For FIXED_AMOUNT: in paise + currency String @default("INR") // Currency for FIXED_AMOUNT discounts + isActive Boolean @default(true) + expiresAt DateTime? + maxUses Int? + currentUses Int @default(0) + maxDiscount Int? // in paise (cap for FIXED_AMOUNT discounts) + + Payment Payment[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([isActive]) + @@index([expiresAt]) + @@index([isActive, expiresAt]) +} + +enum DiscountType { + PERCENTAGE + FIXED_AMOUNT +} + +////////////////////////////////////////// REFERRAL SYSTEM ////////////////////////////////////////// + +model ReferralCode { + id String @id @default(cuid()) + userId String @unique + code String @unique + customCode String? @unique + referrerReward Int? // Reward for referrer in smallest currency unit (paise) + refereeReward Int? // Reward for new user in smallest currency unit (paise) + totalReferrals Int @default(0) + successfulReferrals Int @default(0) + totalEarned Int @default(0) + maxReferrals Int @default(50) // Maximum referrals allowed per code + isActive Boolean @default(true) + + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + referrals Referral[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([code]) + @@index([customCode]) +} + +model Referral { + id String @id @default(cuid()) + referralCodeId String + referredUserId String @unique + status ReferralStatus @default(SIGNED_UP) + referrerRewardAmount Int? + refereeRewardAmount Int? + referrerRewardPaidAt DateTime? + refereeRewardPaidAt DateTime? + signedUpAt DateTime @default(now()) + qualifiedAt DateTime? + qualifyingAction String? + + referralCode ReferralCode @relation(fields: [referralCodeId], references: [id]) + referredUser User @relation("ReferredUser", fields: [referredUserId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([referralCodeId]) + @@index([status]) +} + +model ReferralCredit { + id String @id @default(cuid()) + userId String + amount Int // Credit amount in smallest currency unit (paise) + currency String @default("INR") + source CreditSource + referralId String? + usedAmount Int @default(0) + remainingAmount Int // amount - usedAmount + expiresAt DateTime? + usedAt DateTime? + + user User @relation(fields: [userId], references: [id], onUpdate: Cascade, onDelete: Cascade) + usages ReferralCreditUsage[] + + createdAt DateTime @default(now()) + + // Prevents duplicate credit rows for the same referral event. + // Note: PostgreSQL does NOT enforce this constraint when referralId IS NULL + // (NULL != NULL in unique indexes), so PROMOTION/MANUAL/COMPENSATION sources + // rely on the Serializable transaction in processQualifyingAction() for deduplication. + @@unique([userId, referralId, source]) + @@index([userId]) + @@index([expiresAt]) + @@index([userId, expiresAt]) +} + +model ReferralCreditUsage { + id String @id @default(cuid()) + creditId String + paymentId String + amount Int // Current remaining usage (decremented on partial refund restores) + originalAmount Int // Original amount at creation (never changes, used for ratio calculations) + restoredAmount Int @default(0) // Cumulative amount restored across partial refunds (drift-free tracking) + createdAt DateTime @default(now()) + + credit ReferralCredit @relation(fields: [creditId], references: [id], onUpdate: Cascade, onDelete: Cascade) + payment Payment @relation(fields: [paymentId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + @@unique([creditId, paymentId]) + @@index([paymentId]) +} + +enum ReferralStatus { + SIGNED_UP + QUALIFIED + REWARDED + EXPIRED + FRAUDULENT +} + +enum CreditSource { + REFERRAL_BONUS + REFEREE_BONUS + PROMOTION + COMPENSATION + MANUAL +} + +//////////////////////////////////////////////////// COLLABORATOR SYSTEM //////////////////////////////////////////////////// + +model WebinarCollaborator { + id String @id @default(cuid()) + consultantProfileId String + webinarPlanId String + role WebinarCollaboratorRole @default(CO_HOST) + permissions Json? + revenueSharePercentage Float + status CollaboratorStatus @default(PENDING) + invitedById String + respondedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + consultantProfile ConsultantProfile @relation("WebinarCollaborator", fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + webinarPlan WebinarPlan @relation(fields: [webinarPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) + invitedBy ConsultantProfile @relation("WebinarCollaboratorInvitedBy", fields: [invitedById], references: [id], onUpdate: Cascade, onDelete: Cascade) + + @@unique([consultantProfileId, webinarPlanId]) + @@index([webinarPlanId]) + @@index([status]) +} + +model ClassCollaborator { + id String @id @default(cuid()) + consultantProfileId String + classPlanId String + role ClassCollaboratorRole @default(CO_INSTRUCTOR) + permissions Json? + revenueSharePercentage Float + status CollaboratorStatus @default(PENDING) + invitedById String + respondedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + consultantProfile ConsultantProfile @relation("ClassCollaborator", fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + classPlan ClassPlan @relation(fields: [classPlanId], references: [id], onUpdate: Cascade, onDelete: Cascade) + invitedBy ConsultantProfile @relation("ClassCollaboratorInvitedBy", fields: [invitedById], references: [id], onUpdate: Cascade, onDelete: Cascade) + + @@unique([consultantProfileId, classPlanId]) + @@index([classPlanId]) + @@index([status]) +} + +enum CollaboratorStatus { + PENDING + ACCEPTED + DECLINED + REMOVED +} + +enum WebinarCollaboratorRole { + CO_HOST + MODERATOR + GUEST_SPEAKER + TECHNICAL_SUPPORT +} + +enum ClassCollaboratorRole { + CO_INSTRUCTOR + TEACHING_ASSISTANT + GUEST_LECTURER + CONTENT_CREATOR +} + +enum EarningRole { + OWNER + COLLABORATOR +} + +//////////////////////////////////////////////////// ENUMS //////////////////////////////////////////////////// + +enum PlanDuration { + ONE_MONTH + THREE_MONTHS + SIX_MONTHS + TWELVE_MONTHS +} + +enum PlanEmailSupport { + GENERAL + PRIORITY + DEDICATED +} + +enum UserRole { + CONSULTANT + CONSULTEE + ADMIN + STAFF +} + +enum DayOfWeek { + MONDAY + TUESDAY + WEDNESDAY + THURSDAY + FRIDAY + SATURDAY + SUNDAY +} + +enum ScheduleType { + WEEKLY + CUSTOM +} + +//////////////////////////////////////////////////// NEW ENUMS FOR ENHANCED USER PROFILES //////////////////////////////////////////////////// + +enum Gender { + MALE + FEMALE + NON_BINARY + PREFER_NOT_TO_SAY +} + +enum CareerStage { + SCHOOL_STUDENT // No UI yet + STUDENT + EARLY_CAREER // 0-3 years experience + MID_CAREER // 3-10 years experience + SENIOR // 10+ years experience + EXECUTIVE // C-level or equivalent +} + +enum AdminLevel { + SUPER_ADMIN // Full system access + ADMIN // High-level management + MODERATOR // Day-to-day operations +} + +enum BudgetPreference { + BUDGET // Looking for affordable options + MODERATE // Mid-range pricing + PREMIUM // Willing to pay for premium + FLEXIBLE // No specific preference +} + +enum SessionType { + ONE_ON_ONE // 1:1 video/audio sessions + GROUP // Group sessions + ASYNC_REVIEW // Asynchronous document/code review +} + +//////////////////////////////////////////////////// STAFF DASHBOARD MODELS //////////////////////////////////////////////////// + +// Content Moderation Enums +enum ModerationReportType { + REVIEW + PROFILE + MESSAGE + DOCUMENT + OTHER +} + +enum ModerationReportStatus { + PENDING + UNDER_REVIEW + DISMISSED + ACTION_TAKEN + ESCALATED +} + +enum ModerationActionType { + WARNING_ISSUED + CONTENT_REMOVED + USER_SUSPENDED + USER_BANNED + PROFILE_UNVERIFIED + NO_ACTION +} + +enum ProfileVerificationStatus { + PENDING + APPROVED + REJECTED + NEEDS_INFO + SUPERSEDED +} + +enum SystemJobStatus { + RUNNING + COMPLETED + FAILED + CANCELLED +} + +// Content Moderation Models +model ModerationReport { + id String @id @default(uuid()) + type ModerationReportType + status ModerationReportStatus @default(PENDING) + reason String + description String? @db.Text + reportCount Int @default(1) // Aggregated count for duplicates + + // Content reference - one of these will be set based on type + contentText String? @db.Text // For review text, message content, etc. + contentUrl String? // For profile URLs, document URLs, etc. + + // Reporter + reportedById String + reportedBy User @relation("ReportsSubmitted", fields: [reportedById], references: [id], onUpdate: Cascade, onDelete: Cascade) + + // Target user (who is being reported) + targetUserId String + targetUser User @relation("ReportsReceived", fields: [targetUserId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + // Optional entity references + reviewId String? // If reporting a ConsultantReview + + // Staff assignment + assignedToId String? + + // Resolution + resolvedAt DateTime? + resolvedBy String? // Staff user ID + + actions ModerationAction[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status]) + @@index([type]) + @@index([reportedById]) + @@index([targetUserId]) + @@index([assignedToId]) + @@index([createdAt]) +} + +model ModerationAction { + id String @id @default(uuid()) + actionType ModerationActionType + notes String? @db.Text + + report ModerationReport @relation(fields: [reportId], references: [id], onUpdate: Cascade, onDelete: Cascade) + reportId String + + // Staff who took the action + takenById String + takenBy User @relation(fields: [takenById], references: [id], onUpdate: Cascade, onDelete: Cascade) + + createdAt DateTime @default(now()) + + @@index([reportId]) + @@index([takenById]) + @@index([actionType]) +} + +// Profile Verification Models +model ConsultantProfileVerification { + id String @id @default(uuid()) + status ProfileVerificationStatus @default(PENDING) + + consultantProfileId String + consultantProfile ConsultantProfile @relation(fields: [consultantProfileId], references: [id], onUpdate: Cascade, onDelete: Cascade) + + // Verification details + submittedAt DateTime @default(now()) + notes String? @db.Text // Applicant notes when submitting + + // Staff review + reviewedAt DateTime? + reviewedById String? + reviewNotes String? @db.Text // Internal staff notes (not shown to consultant) + + // Feedback to consultant (shown when rejected/needs revision) + rejectionReason String? @db.Text // Brief reason shown to consultant (e.g., "Documents unclear") + feedbackDetails String? @db.Text // Detailed feedback (e.g., "12th certificate appears to be forged") + + documents ProfileVerificationDocument[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([consultantProfileId]) + @@index([status]) + @@index([reviewedById]) + @@index([submittedAt]) +} + +model ProfileVerificationDocument { + id String @id @default(uuid()) + fileName String + originalName String + fileSize Int + mimeType String + fileUrl String + storagePath String + description String? // Type of document (ID, degree, certificate, etc.) + + // Document-specific review (for granular feedback) + isValid Boolean? // null = not reviewed, true = valid, false = invalid + staffFeedback String? // Feedback on this specific document (e.g., "Certificate appears forged") + + verification ConsultantProfileVerification @relation(fields: [verificationId], references: [id], onUpdate: Cascade, onDelete: Cascade) + verificationId String + + uploadedAt DateTime @default(now()) + + @@index([verificationId]) +} + +// System Jobs Models +model SystemJobExecution { + id String @id @default(uuid()) + jobId String // Maps to job configuration ID + jobName String + status SystemJobStatus + startedAt DateTime @default(now()) + endedAt DateTime? + + // Execution details + triggeredBy String? // User ID if manual, null if cron + result Json? // JSON result of the job + errorLog String? @db.Text + + // Performance metrics + itemsProcessed Int? + errorCount Int? + durationMs Int? + + createdAt DateTime @default(now()) + + @@index([jobId]) + @@index([status]) + @@index([startedAt]) + @@index([triggeredBy]) +} + +//////////////////////////////////////////////////// ANNOUNCEMENT SYSTEM //////////////////////////////////////////////////// + +model Announcement { + id String @id @default(cuid()) + title String + content String @db.Text + isActive Boolean @default(true) + startDate DateTime? + endDate DateTime? + backgroundColor String? @default("#000000") + textColor String? @default("#FFFFFF") + linkUrl String? + linkText String? + createdBy String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([isActive]) + @@index([startDate, endDate]) + @@map("announcements") +} + +//////////////////////////////////////////////////// CONSULTANT VERIFICATION //////////////////////////////////////////////////// + +enum ConsultantVerificationStatus { + PENDING_VERIFICATION // Onboarding complete, awaiting review + UNDER_REVIEW // Staff reviewing + VERIFIED // Approved + REJECTED // Needs resubmission +} + +//////////////////////////////////////////////////// MAINTENANCE MODE //////////////////////////////////////////////////// + +enum MaintenancePhase { + OFF + DEGRADED + OFFLINE +} + +model MaintenanceWindow { + id String @id @default(cuid()) + phase MaintenancePhase @default(OFF) + reason String? + scheduledAt DateTime? + startedAt DateTime? + endedAt DateTime? + estimatedEnd DateTime? + startedBy String? + endedBy String? + bypassSecret String? + metadata Json? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("maintenance_windows") +} diff --git a/backend/railway.toml b/backend/railway.toml new file mode 100644 index 0000000..2edc46b --- /dev/null +++ b/backend/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "dockerfile" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/api/health" +healthcheckTimeout = 30 +restartPolicyType = "on_failure" +restartPolicyMaxRetries = 3 diff --git a/backend/routes/api/health.dart b/backend/routes/api/health.dart new file mode 100644 index 0000000..5c0d13f --- /dev/null +++ b/backend/routes/api/health.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:dart_frog/dart_frog.dart'; + +/// GET /api/health +/// Railway uses this endpoint to verify the server is running. +Response onRequest(RequestContext context) { + if (context.request.method != HttpMethod.get) { + return Response(statusCode: HttpStatus.methodNotAllowed); + } + + return Response.json( + body: { + 'status': 'ok', + 'timestamp': DateTime.now().toIso8601String(), + 'service': 'familiarise-mobile-api', + }, + ); +} diff --git a/docs/README.md b/docs/README.md index 3925a5e..46d0a2f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,5 +42,7 @@ Phase-by-phase implementation guides with code examples. These are ordered by bu | Document | Description | |----------|-------------| -| [Deployment](./deployment/deployment.md) | Build, release, and deployment process | +| [Deployment Strategy](./deployment/01-deployment-strategy.md) | Build, release, and deployment process | +| [Migration & CI/CD Plan](./deployment/02-migration-and-cicd-plan.md) | Railway migration and Shorebird OTA integration plan | +| [Migration Checklist](./deployment/03-migration-checklist.md) | Verification checklist for all migration phases | | [Troubleshooting](./troubleshooting/README.md) | Platform-specific bugs and fixes | diff --git a/docs/deployment/deployment.md b/docs/deployment/01-deployment-strategy.md similarity index 100% rename from docs/deployment/deployment.md rename to docs/deployment/01-deployment-strategy.md diff --git a/docs/deployment/MIGRATION_AND_CICD_PLAN.md b/docs/deployment/02-migration-and-cicd-plan.md similarity index 98% rename from docs/deployment/MIGRATION_AND_CICD_PLAN.md rename to docs/deployment/02-migration-and-cicd-plan.md index 49f1359..591c21e 100644 --- a/docs/deployment/MIGRATION_AND_CICD_PLAN.md +++ b/docs/deployment/02-migration-and-cicd-plan.md @@ -246,7 +246,7 @@ Response onRequest(RequestContext context) { body: { 'status': 'ok', 'timestamp': DateTime.now().toIso8601String(), - 'service': 'familiarise-api', + 'service': 'familiarise-mobile-api', }, ); } @@ -316,7 +316,7 @@ jobs: - name: Deploy to Railway working-directory: backend - run: railway up --service familiarise-api --detach + run: railway up --service familiarise-mobile-api --detach env: RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} @@ -324,7 +324,7 @@ jobs: if: success() run: | echo "✅ Backend deployed to Railway successfully" - echo "Service: familiarise-api" + echo "Service: familiarise-mobile-api" echo "Commit: ${{ github.sha }}" - name: Notify deployment failure @@ -343,7 +343,7 @@ jobs: ### 5.6 Environment variables to set on Railway -After creating the Railway project, set these environment variables in the Railway dashboard under the `familiarise-api` service → Variables: +After creating the Railway project, set these environment variables in the Railway dashboard under the `familiarise-mobile-api` service → Variables: ``` DATABASE_URL = postgresql://[user]:[password]@[host]:6543/[db]?pgbouncer=true @@ -369,10 +369,10 @@ Railway does not maintain persistent TCP connections the way a traditional VPS d ### 5.7 Update webhook URLs after Railway deployment -After Railway assigns a URL (e.g. `https://familiarise-api.up.railway.app`), update webhooks in: +After Railway assigns a URL (e.g. `https://familiarise-mobile-api.up.railway.app`), update webhooks in: -- **Stripe dashboard** → Developers → Webhooks → update endpoint to `https://familiarise-api.up.railway.app/api/webhooks/stripe` -- **Razorpay dashboard** → Settings → Webhooks → update endpoint to `https://familiarise-api.up.railway.app/api/webhooks/razorpay` +- **Stripe dashboard** → Developers → Webhooks → update endpoint to `https://familiarise-mobile-api.up.railway.app/api/webhooks/stripe` +- **Razorpay dashboard** → Settings → Webhooks → update endpoint to `https://familiarise-mobile-api.up.railway.app/api/webhooks/razorpay` ### 5.8 Update API_BASE_URL in Flutter app @@ -382,7 +382,7 @@ Update the `API_BASE_URL` placeholder: ```env # Backend API — update to Railway URL after deployment -API_BASE_URL=https://familiarise-api.up.railway.app +API_BASE_URL=https://familiarise-mobile-api.up.railway.app ``` Also update `lib/core/network/dio_client.dart` or wherever `API_BASE_URL` is consumed to ensure it falls back to `http://localhost:8080` for local dev only: @@ -844,7 +844,7 @@ jobs: - name: Deploy to Railway working-directory: backend - run: railway up --service familiarise-api --detach + run: railway up --service familiarise-mobile-api --detach env: RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }} @@ -853,7 +853,7 @@ jobs: echo "Waiting for deployment to stabilize..." sleep 30 curl --fail --retry 5 --retry-delay 10 \ - https://familiarise-api.up.railway.app/api/health || \ + https://familiarise-mobile-api.up.railway.app/api/health || \ echo "Health check failed — check Railway dashboard" # ════════════════════════════════════════════════════════════ @@ -1029,7 +1029,7 @@ Add a new top-level section titled `## Infrastructure` (insert after the existin ### Backend hosting — Railway The Dart Frog API (`backend/`) is deployed on Railway at: -`https://familiarise-api.up.railway.app` +`https://familiarise-mobile-api.up.railway.app` - Auto-deploys on every push to `main` that touches `backend/**` - Health check endpoint: `GET /api/health` @@ -1153,7 +1153,7 @@ To deploy manually (requires Railway CLI and token): npm install -g @railway/cli railway login cd backend -railway up --service familiarise-api +railway up --service familiarise-mobile-api ``` ``` @@ -1208,7 +1208,7 @@ The agent should add this checklist to a new file `docs/migration-checklist.md`: - [ ] All environment variables set in Railway dashboard (see Section 9) - [ ] `DATABASE_URL` uses Supabase pooler URL (port 6543) - [ ] Backend deployed and accessible at Railway URL -- [ ] Health check passes: `curl https://familiarise-api.up.railway.app/api/health` +- [ ] Health check passes: `curl https://familiarise-mobile-api.up.railway.app/api/health` - [ ] Auth endpoint tested: `POST /api/auth/sign-in` - [ ] `RAILWAY_TOKEN` secret added to GitHub - [ ] `.github/workflows/backend-deploy.yml` created diff --git a/docs/deployment/03-migration-checklist.md b/docs/deployment/03-migration-checklist.md new file mode 100644 index 0000000..764ac2b --- /dev/null +++ b/docs/deployment/03-migration-checklist.md @@ -0,0 +1,64 @@ +# Migration & Infrastructure Verification Checklist + +## Phase 1 — Railway Migration (complete before April 3, 2026) + +- [x] `backend/Dockerfile` created and tested locally (`docker build -t test-api .`) +- [x] `backend/railway.toml` created +- [x] `backend/routes/api/health.dart` created and returns `{"status": "ok"}` +- [x] `backend/main.dart` uses `DotEnv(includePlatformEnvironment: true)` for Docker compat +- [x] Dart Frog server binds to `0.0.0.0` / `anyIPv6` (not `localhost`) +- [x] Railway project created at railway.com (familiarise-mobile-api) +- [ ] GitHub repo connected to Railway project +- [x] All environment variables set in Railway dashboard (21 vars via MCP) +- [x] `DATABASE_URL` uses Supabase pooler URL (port 6543) +- [x] `DIRECT_URL` uses Supabase direct URL (port 5432) +- [x] Backend deployed and accessible at Railway URL +- [x] Health check passes: `curl https://familiarise-mobile-api-production.up.railway.app/api/health` +- [x] Auth endpoint tested: `POST /api/auth/email/sign-in` returns proper error response +- [ ] `RAILWAY_TOKEN` secret added to GitHub +- [ ] `PRODUCTION_API_BASE_URL` secret added to GitHub +- [x] `.github/workflows/backend-deploy.yml` created +- [ ] `prod` branch created from `dev` +- [ ] Push to `prod` triggers auto-deploy to Railway +- [ ] Stripe webhook URL updated in Stripe dashboard +- [ ] Razorpay webhook URL updated in Razorpay dashboard +- [x] `API_BASE_URL` in `.env.example` updated + +## Phase 2 — Shorebird OTA + +- [x] `shorebird_code_push` added to `dependencies` in pubspec.yaml +- [x] `shorebird init` run locally (app_id: f9b217a0-1007-48a5-bd41-d381568e23f1) +- [x] `shorebird.yaml` committed to repo with real app_id +- [x] Shorebird update check added to `lib/main.dart` +- [ ] `SHOREBIRD_TOKEN` secret added to GitHub +- [x] Shorebird release job added to `flutter-ci.yml` +- [x] Shorebird patch job added to `flutter-ci.yml` +- [ ] Test patch: push to `hotfix/test-patch` → verify patch job runs + +## Phase 3 — GitHub Actions + +- [x] `flutter-ci.yml` updated with `workflow_dispatch` trigger +- [x] Branch references updated: `main`/`develop` → `prod`/`dev` +- [x] Deprecated `flutter pub run` → `dart run` fixed +- [x] Backend test job added to `flutter-ci.yml` +- [x] Shorebird release + patch jobs added +- [ ] All GitHub Secrets created (see full list in migration plan) +- [ ] Full CI run passes on a test PR + +## Phase 4 — Documentation + +- [x] `CLAUDE.md` created with infrastructure section +- [x] `README.md` — CI/CD section updated +- [x] `README.md` — Overview table updated (backend → Railway) +- [x] `backend/README.md` — updated with Railway info and complete env vars +- [x] `docs/deployment/03-migration-checklist.md` — this file committed + +## Post-migration validation + +- [ ] Create a test PR → analyze and test-backend jobs pass +- [ ] Push to `prod` → backend auto-deploys, health check passes +- [ ] Create a tag → shorebird-release job runs +- [ ] Push to `hotfix/test` → shorebird-patch job runs +- [ ] Flutter app on a device points to Railway URL and can sign in +- [ ] Booking flow end-to-end works against Railway backend +- [ ] Payment webhook received by Railway (check Railway logs) diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..d71074f --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,572 @@ +PODS: + - app_links (6.4.1): + - Flutter + - AppAuth (1.7.6): + - AppAuth/Core (= 1.7.6) + - AppAuth/ExternalUserAgent (= 1.7.6) + - AppAuth/Core (1.7.6) + - AppAuth/ExternalUserAgent (1.7.6): + - AppAuth/Core + - AppCheckCore (11.2.0): + - GoogleUtilities/Environment (~> 8.0) + - GoogleUtilities/UserDefaults (~> 8.0) + - PromisesObjC (~> 2.4) + - audio_session (0.0.1): + - Flutter + - battery_plus (1.0.0): + - Flutter + - connectivity_plus (0.0.1): + - Flutter + - device_info_plus (0.0.1): + - Flutter + - DKImagePickerController/Core (4.3.9): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.9) + - DKImagePickerController/PhotoGallery (4.3.9): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.9) + - DKPhotoGallery (0.0.19): + - DKPhotoGallery/Core (= 0.0.19) + - DKPhotoGallery/Model (= 0.0.19) + - DKPhotoGallery/Preview (= 0.0.19) + - DKPhotoGallery/Resource (= 0.0.19) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.19): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.19): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.19): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - file_selector_ios (0.0.1): + - Flutter + - Firebase/Analytics (11.15.0): + - Firebase/Core + - Firebase/Core (11.15.0): + - Firebase/CoreOnly + - FirebaseAnalytics (~> 11.15.0) + - Firebase/CoreOnly (11.15.0): + - FirebaseCore (~> 11.15.0) + - Firebase/Messaging (11.15.0): + - Firebase/CoreOnly + - FirebaseMessaging (~> 11.15.0) + - firebase_analytics (11.6.0): + - Firebase/Analytics (= 11.15.0) + - firebase_core + - Flutter + - firebase_core (3.15.2): + - Firebase/CoreOnly (= 11.15.0) + - Flutter + - firebase_messaging (15.2.10): + - Firebase/Messaging (= 11.15.0) + - firebase_core + - Flutter + - FirebaseAnalytics (11.15.0): + - FirebaseAnalytics/Default (= 11.15.0) + - FirebaseCore (~> 11.15.0) + - FirebaseInstallations (~> 11.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseAnalytics/Default (11.15.0): + - FirebaseCore (~> 11.15.0) + - FirebaseInstallations (~> 11.0) + - GoogleAppMeasurement/Default (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseCore (11.15.0): + - FirebaseCoreInternal (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreInternal (11.15.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseInstallations (11.15.0): + - FirebaseCore (~> 11.15.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) + - FirebaseMessaging (11.15.0): + - FirebaseCore (~> 11.15.0) + - FirebaseInstallations (~> 11.0) + - GoogleDataTransport (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - Flutter (1.0.0) + - flutter_local_notifications (0.0.1): + - Flutter + - flutter_secure_storage (6.0.0): + - Flutter + - flutter_web_auth_2 (3.0.0): + - Flutter + - gal (1.0.0): + - Flutter + - FlutterMacOS + - get_thumbnail_video (0.0.1): + - Flutter + - libwebp + - google_sign_in_ios (0.0.1): + - AppAuth (>= 1.7.4) + - Flutter + - FlutterMacOS + - GoogleSignIn (~> 8.0) + - GTMSessionFetcher (>= 3.4.0) + - GoogleAdsOnDeviceConversion (2.1.0): + - GoogleUtilities/Logger (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Core (11.15.0): + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Default (11.15.0): + - GoogleAdsOnDeviceConversion (= 2.1.0) + - GoogleAppMeasurement/Core (= 11.15.0) + - GoogleAppMeasurement/IdentitySupport (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/IdentitySupport (11.15.0): + - GoogleAppMeasurement/Core (= 11.15.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - GoogleSignIn (8.0.0): + - AppAuth (< 2.0, >= 1.7.3) + - AppCheckCore (~> 11.0) + - GTMAppAuth (< 5.0, >= 4.1.1) + - GTMSessionFetcher/Core (~> 3.3) + - GoogleUtilities/AppDelegateSwizzler (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (8.1.0): + - GoogleUtilities/Privacy + - GoogleUtilities/Logger (8.1.0): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/Network (8.1.0): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (8.1.0)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (8.1.0): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GTMAppAuth (4.1.1): + - AppAuth/Core (~> 1.7) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher (3.5.0): + - GTMSessionFetcher/Full (= 3.5.0) + - GTMSessionFetcher/Core (3.5.0) + - GTMSessionFetcher/Full (3.5.0): + - GTMSessionFetcher/Core + - image_picker_ios (0.0.1): + - Flutter + - integration_test (0.0.1): + - Flutter + - just_audio (0.0.1): + - Flutter + - FlutterMacOS + - libwebp (1.5.0): + - libwebp/demux (= 1.5.0) + - libwebp/mux (= 1.5.0) + - libwebp/sharpyuv (= 1.5.0) + - libwebp/webp (= 1.5.0) + - libwebp/demux (1.5.0): + - libwebp/webp + - libwebp/mux (1.5.0): + - libwebp/demux + - libwebp/sharpyuv (1.5.0) + - libwebp/webp (1.5.0): + - libwebp/sharpyuv + - media_kit_video (0.0.1): + - Flutter + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter + - photo_manager (3.8.3): + - Flutter + - FlutterMacOS + - PromisesObjC (2.4.0) + - razorpay-core-pod (1.0.3) + - razorpay-pod (1.5.2): + - razorpay-core-pod (= 1.0.3) + - razorpay_flutter (1.1.10): + - Flutter + - razorpay-pod + - record_ios (1.1.0): + - Flutter + - SDWebImage (5.21.5): + - SDWebImage/Core (= 5.21.5) + - SDWebImage/Core (5.21.5) + - Sentry/HybridSDK (8.56.2) + - sentry_flutter (9.14.0): + - Flutter + - FlutterMacOS + - Sentry/HybridSDK (= 8.56.2) + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sign_in_with_apple (0.0.1): + - Flutter + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - stream_video_flutter (0.9.6): + - Flutter + - stream_webrtc_flutter + - stream_webrtc_flutter (2.2.6): + - Flutter + - Stripe (24.7.0): + - StripeApplePay (= 24.7.0) + - StripeCore (= 24.7.0) + - StripePayments (= 24.7.0) + - StripePaymentsUI (= 24.7.0) + - StripeUICore (= 24.7.0) + - stripe_ios (0.0.1): + - Flutter + - Stripe (~> 24.7.0) + - stripe_ios/stripe_ios (= 0.0.1) + - stripe_ios/stripe_objc (= 0.0.1) + - StripeApplePay (~> 24.7.0) + - StripeFinancialConnections (~> 24.7.0) + - StripePayments (~> 24.7.0) + - StripePaymentSheet (~> 24.7.0) + - StripePaymentsUI (~> 24.7.0) + - stripe_ios/stripe_ios (0.0.1): + - Flutter + - Stripe (~> 24.7.0) + - stripe_ios/stripe_objc + - StripeApplePay (~> 24.7.0) + - StripeFinancialConnections (~> 24.7.0) + - StripePayments (~> 24.7.0) + - StripePaymentSheet (~> 24.7.0) + - StripePaymentsUI (~> 24.7.0) + - stripe_ios/stripe_objc (0.0.1): + - Flutter + - Stripe (~> 24.7.0) + - StripeApplePay (~> 24.7.0) + - StripeFinancialConnections (~> 24.7.0) + - StripePayments (~> 24.7.0) + - StripePaymentSheet (~> 24.7.0) + - StripePaymentsUI (~> 24.7.0) + - StripeApplePay (24.7.0): + - StripeCore (= 24.7.0) + - StripeCore (24.7.0) + - StripeFinancialConnections (24.7.0): + - StripeCore (= 24.7.0) + - StripeUICore (= 24.7.0) + - StripePayments (24.7.0): + - StripeCore (= 24.7.0) + - StripePayments/Stripe3DS2 (= 24.7.0) + - StripePayments/Stripe3DS2 (24.7.0): + - StripeCore (= 24.7.0) + - StripePaymentSheet (24.7.0): + - StripeApplePay (= 24.7.0) + - StripeCore (= 24.7.0) + - StripePayments (= 24.7.0) + - StripePaymentsUI (= 24.7.0) + - StripePaymentsUI (24.7.0): + - StripeCore (= 24.7.0) + - StripePayments (= 24.7.0) + - StripeUICore (= 24.7.0) + - StripeUICore (24.7.0): + - StripeCore (= 24.7.0) + - SwiftyGif (5.4.5) + - thermal (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS + - wakelock_plus (0.0.1): + - Flutter + +DEPENDENCIES: + - app_links (from `.symlinks/plugins/app_links/ios`) + - audio_session (from `.symlinks/plugins/audio_session/ios`) + - battery_plus (from `.symlinks/plugins/battery_plus/ios`) + - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) + - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) + - Flutter (from `Flutter`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) + - gal (from `.symlinks/plugins/gal/darwin`) + - get_thumbnail_video (from `.symlinks/plugins/get_thumbnail_video/ios`) + - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) + - just_audio (from `.symlinks/plugins/just_audio/darwin`) + - media_kit_video (from `.symlinks/plugins/media_kit_video/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - photo_manager (from `.symlinks/plugins/photo_manager/darwin`) + - razorpay_flutter (from `.symlinks/plugins/razorpay_flutter/ios`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) + - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - stream_video_flutter (from `.symlinks/plugins/stream_video_flutter/ios`) + - stream_webrtc_flutter (from `.symlinks/plugins/stream_webrtc_flutter/ios`) + - stripe_ios (from `.symlinks/plugins/stripe_ios/ios`) + - thermal (from `.symlinks/plugins/thermal/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) + - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) + +SPEC REPOS: + trunk: + - AppAuth + - AppCheckCore + - DKImagePickerController + - DKPhotoGallery + - Firebase + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - FirebaseMessaging + - GoogleAdsOnDeviceConversion + - GoogleAppMeasurement + - GoogleDataTransport + - GoogleSignIn + - GoogleUtilities + - GTMAppAuth + - GTMSessionFetcher + - libwebp + - nanopb + - PromisesObjC + - razorpay-core-pod + - razorpay-pod + - SDWebImage + - Sentry + - Stripe + - StripeApplePay + - StripeCore + - StripeFinancialConnections + - StripePayments + - StripePaymentSheet + - StripePaymentsUI + - StripeUICore + - SwiftyGif + +EXTERNAL SOURCES: + app_links: + :path: ".symlinks/plugins/app_links/ios" + audio_session: + :path: ".symlinks/plugins/audio_session/ios" + battery_plus: + :path: ".symlinks/plugins/battery_plus/ios" + connectivity_plus: + :path: ".symlinks/plugins/connectivity_plus/ios" + device_info_plus: + :path: ".symlinks/plugins/device_info_plus/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + file_selector_ios: + :path: ".symlinks/plugins/file_selector_ios/ios" + firebase_analytics: + :path: ".symlinks/plugins/firebase_analytics/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + firebase_messaging: + :path: ".symlinks/plugins/firebase_messaging/ios" + Flutter: + :path: Flutter + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" + flutter_web_auth_2: + :path: ".symlinks/plugins/flutter_web_auth_2/ios" + gal: + :path: ".symlinks/plugins/gal/darwin" + get_thumbnail_video: + :path: ".symlinks/plugins/get_thumbnail_video/ios" + google_sign_in_ios: + :path: ".symlinks/plugins/google_sign_in_ios/darwin" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" + just_audio: + :path: ".symlinks/plugins/just_audio/darwin" + media_kit_video: + :path: ".symlinks/plugins/media_kit_video/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + photo_manager: + :path: ".symlinks/plugins/photo_manager/darwin" + razorpay_flutter: + :path: ".symlinks/plugins/razorpay_flutter/ios" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" + sentry_flutter: + :path: ".symlinks/plugins/sentry_flutter/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sign_in_with_apple: + :path: ".symlinks/plugins/sign_in_with_apple/ios" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + stream_video_flutter: + :path: ".symlinks/plugins/stream_video_flutter/ios" + stream_webrtc_flutter: + :path: ".symlinks/plugins/stream_webrtc_flutter/ios" + stripe_ios: + :path: ".symlinks/plugins/stripe_ios/ios" + thermal: + :path: ".symlinks/plugins/thermal/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" + wakelock_plus: + :path: ".symlinks/plugins/wakelock_plus/ios" + +SPEC CHECKSUMS: + app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a + AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 + AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 + battery_plus: b42253f6d2dde71712f8c36fef456d99121c5977 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe + DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c + DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be + file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a + Firebase: d99ac19b909cd2c548339c2241ecd0d1599ab02e + firebase_analytics: 0e25ca1d4001ccedd40b4e5b74c0ec34e18f6425 + firebase_core: 995454a784ff288be5689b796deb9e9fa3601818 + firebase_messaging: f4a41dd102ac18b840eba3f39d67e77922d3f707 + FirebaseAnalytics: 6433dfd311ba78084fc93bdfc145e8cb75740eae + FirebaseCore: efb3893e5b94f32b86e331e3bd6dadf18b66568e + FirebaseCoreInternal: 9afa45b1159304c963da48addb78275ef701c6b4 + FirebaseInstallations: 317270fec08a5d418fdbc8429282238cab3ac843 + FirebaseMessaging: 3b26e2cee503815e01c3701236b020aa9b576f09 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 + gal: baecd024ebfd13c441269ca7404792a7152fde89 + get_thumbnail_video: 1a754d46b860dffefcc57b7290a43089cd5d7e58 + google_sign_in_ios: b48bb9af78576358a168361173155596c845f0b9 + GoogleAdsOnDeviceConversion: 2be6297a4f048459e0ae17fad9bfd2844e10cf64 + GoogleAppMeasurement: 700dce7541804bec33db590a5c496b663fbe2539 + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 + GoogleSignIn: ce8c89bb9b37fb624b92e7514cc67335d1e277e4 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de + GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed + libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 + media_kit_video: f3b0d035d89def15cfbbcf7dc2ae278f201e2f83 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + photo_manager: fe4cbb0808b96f8be4af7ce6ae18dcd9c9b983c6 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + razorpay-core-pod: ef0309dbf8e3e5a1330f6cad89dcf9226d8ef758 + razorpay-pod: a9cb6e158aee8754053b147008eff6b4897789c8 + razorpay_flutter: 0e98e4fcaae27ad50e011d85f66d85e0a008754a + record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374 + SDWebImage: e9c98383c7572d713c1a0d7dd2783b10599b9838 + Sentry: b53951377b78e21a734f5dc8318e333dbfc682d7 + sentry_flutter: 841fa2fe08dc72eb95e2320b76e3f751f3400cf5 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + stream_video_flutter: 38b63abecb3c63d3370ba238497e95d12797ccd7 + stream_webrtc_flutter: f5ca5e2fc552ea9cbfc86e99732a532a8fddf2ef + Stripe: 8a03a78bfa16b197f9fac51e42670ac563b34388 + stripe_ios: 95bdf6ba58efd184fe1dfed194cd8692299d66ca + StripeApplePay: 3c1b43d9b5130f6b714863bf8c9482c24168ab27 + StripeCore: 4955c2af14446db04818ad043d19d8f97b73c5fa + StripeFinancialConnections: 8cf97b04c2f354879a2a5473126efac38f11f406 + StripePayments: 91820845bece6117809bcfdcaef39c84c2b4cae5 + StripePaymentSheet: 1810187cbdbc73410b8fb86cecafaaa41c1481fc + StripePaymentsUI: 326376e23caa369d1f58041bdb858c89c2b17ed4 + StripeUICore: 17a4f3adb81ae05ab885e1b353022a430176eab1 + SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 + thermal: d4c48be750d1ddbab36b0e2dcb2471531bc8df41 + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a + wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 + +PODFILE CHECKSUM: 96c6e4a736bff72bd2fac02ca1702064acac0d3f + +COCOAPODS: 1.16.2 diff --git a/lib/main.dart b/lib/main.dart index 9be0dfd..1c5455a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:shorebird_code_push/shorebird_code_push.dart'; import 'app/app.dart'; import 'core/config/env_config.dart'; @@ -35,6 +36,15 @@ Future main() async { appRunner: () async { WidgetsFlutterBinding.ensureInitialized(); + // Check for Shorebird OTA patches (downloads in background, + // applies silently on next app launch) + final shorebirdCodePush = ShorebirdCodePush(); + final isUpdateAvailable = + await shorebirdCodePush.isNewPatchAvailableForDownload(); + if (isUpdateAvailable) { + await shorebirdCodePush.downloadUpdateIfAvailable(); + } + // Initialize device detection for API URL selection (emulator vs physical device) await EnvConfig.initializeDeviceDetection(); diff --git a/pubspec.lock b/pubspec.lock index 8861708..e54243b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1948,6 +1948,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + shorebird_code_push: + dependency: "direct main" + description: + name: shorebird_code_push + sha256: "82203f39a66c78548da944dbe4079c2aa2a60fa5bc1105ed707b144c94f04349" + url: "https://pub.dev" + source: hosted + version: "2.0.5" sign_in_with_apple: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 4840c1b..6dad7b7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,9 @@ dependencies: # Environment envied: ^1.3.4 + # OTA Updates (Shorebird — Dart-only patches without store review) + shorebird_code_push: ^2.0.5 + # Firebase (Analytics, Push) firebase_core: ^3.6.0 firebase_analytics: ^11.3.3 @@ -109,6 +112,7 @@ flutter: - assets/images/ - assets/icons/ - assets/fonts/ + - shorebird.yaml # fonts: # - family: Inter diff --git a/shorebird.yaml b/shorebird.yaml new file mode 100644 index 0000000..9c24624 --- /dev/null +++ b/shorebird.yaml @@ -0,0 +1,14 @@ +# This file is used to configure the Shorebird updater used by your app. +# Learn more at https://docs.shorebird.dev +# This file does not contain any sensitive information and should be checked into version control. + +# Your app_id is the unique identifier assigned to your app. +# It is used to identify your app when requesting patches from Shorebird's servers. +# It is not a secret and can be shared publicly. +app_id: f9b217a0-1007-48a5-bd41-d381568e23f1 + +# auto_update controls if Shorebird should automatically update in the background on launch. +# If auto_update: false, you will need to use package:shorebird_code_push to trigger updates. +# https://pub.dev/packages/shorebird_code_push +# Uncomment the following line to disable automatic updates. +# auto_update: false