diff --git a/docs/changelog.md b/docs/changelog.md index 4c728c2..e82e471 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,22 @@ All notable changes to Kal will be documented in this file. --- +## [1.1.0] - 2026-04-07 + +### Added + +- **Subscription billing** — upgrade to Tier 1 (RM45/mo) or Tier 2 (RM75/mo) for higher rate limits and priority support +- **Public pricing page** (`/pricing`) — view all plans and pricing without signing in; sign in to subscribe directly +- Manage your subscription, update payment methods, and view invoices from the billing portal +- Pricing link in the landing page navbar +- Upgrade and billing management buttons in dashboard settings + +### Fixed + +- Fixed an issue where accounts with no email could fail to be created + +--- + ## [1.0.2] - 2026-04-07 ### Added diff --git a/docs/github-actions.md b/docs/github-actions.md deleted file mode 100644 index 6c1e28e..0000000 --- a/docs/github-actions.md +++ /dev/null @@ -1,184 +0,0 @@ -# GitHub Actions CI/CD - -This project uses GitHub Actions for continuous integration and deployment. - ---- - -## Workflow Overview - -The CI workflow is located at `.github/workflows/ci.yml` and runs automatically on: - -- **Push** to `main` or `dev` branches -- **Pull requests** targeting `main` or `dev` branches - ---- - -## Pipeline Stages - -### 1. Lint & Type Check - -The first stage validates code quality: - -```yaml -steps: - - pnpm install --frozen-lockfile - - pnpm lint - - pnpm typecheck -``` - -**What it checks:** - -- ESLint rules compliance -- TypeScript type errors -- Import ordering and consistency - -### 2. Build - -After linting passes, the build stage compiles all packages: - -```yaml -steps: - - pnpm install --frozen-lockfile - - pnpm build -``` - -This ensures: - -- All TypeScript compiles successfully -- Next.js builds without errors -- No missing dependencies - -### 3. Docker Build Validation (Main Branch Only) - -On pushes to `main`, the workflow also validates Docker builds: - -```yaml -- Build Backend Docker Image - - context: ./packages/kal-backend -``` - ---- - -## Deployment - -Deployment is handled automatically by [Coolify](https://coolify.io/) when changes are merged to `main`: - -| Branch | Action | -| ------ | ----------------------------------- | -| `dev` | CI runs (lint, typecheck, build) | -| `main` | CI runs + Auto-deploy to production | - -### Deployment URL - -🌐 **Production:** [https://kalori-api.my](https://kalori-api.my) - ---- - -## Workflow Configuration - -### Environment Variables - -The workflow uses these environment variables: - -| Variable | Value | Description | -| -------------- | ----- | -------------------- | -| `NODE_VERSION` | 18 | Node.js version | -| `PNPM_VERSION` | 8 | pnpm package manager | - -### Concurrency - -The workflow uses concurrency groups to prevent duplicate runs: - -```yaml -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true -``` - -This means: - -- Only one CI run per branch at a time -- New pushes cancel in-progress runs - ---- - -## Running CI Locally - -Before pushing, you can run the same checks locally: - -```bash -# Install dependencies -pnpm install - -# Lint check -pnpm lint - -# Auto-fix lint issues -pnpm lint:fix - -# Type check -pnpm typecheck - -# Build all packages -pnpm build -``` - ---- - -## Troubleshooting - -### Common CI Failures - -| Error | Solution | -| -------------------- | -------------------------------------------------------------- | -| `pnpm install` fails | Run `pnpm install --frozen-lockfile` locally to check lockfile | -| Lint errors | Run `pnpm lint:fix` to auto-fix | -| Type errors | Check TypeScript errors with `pnpm typecheck` | -| Build fails | Check for missing environment variables | - -### Viewing Logs - -1. Go to the repository on GitHub -2. Click on **Actions** tab -3. Select the failed workflow run -4. Click on the failed job to view logs - ---- - -## Adding New Workflows - -To add new workflows: - -1. Create a new file in `.github/workflows/` -2. Use YAML format with the workflow definition -3. Push to trigger the workflow - -### Example: Adding a Test Workflow - -```yaml -# .github/workflows/test.yml -name: Test - -on: - pull_request: - branches: [dev] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 - with: - version: 8 - - run: pnpm install - - run: pnpm test -``` - ---- - -## Related Documentation - -- [Contributing Guidelines](./contributing.md) -- [Linting Guide](./linting.md) -- [VPS Setup](./vps-setup-selfhosted.md) diff --git a/docs/migration.md b/docs/migration.md deleted file mode 100644 index 3362ed0..0000000 --- a/docs/migration.md +++ /dev/null @@ -1,515 +0,0 @@ -# Deployment Architecture Migration - -> **Goal:** Move frontend deployments to Vercel, keep only backend on VPS with pre-built Docker images from GitHub Actions. - -**Current State:** - -- All services (backend, frontend, frontend-chat, admin) built and run on VPS via Docker Compose -- Coolify orchestrates deployments by pulling repo and building on VPS - -**Target State:** - -- `kal-frontend` → Vercel -- `kal-admin` → Vercel -- `kal-frontend-chat` → **DELETED** -- `kal-backend` → VPS (Docker image built in CI, pushed to GHCR, pulled on VPS) - ---- - -## Prerequisites - -Before starting, gather these values: - -| Value | Description | How to get | -| ---------------------------- | ----------------------------------------- | --------------------------------------------- | -| GitHub org/user | Your GitHub username or organization name | Check `github.com/` | -| `VERCEL_TOKEN` | Vercel API token | Vercel Dashboard → Settings → Tokens → Create | -| `VERCEL_ORG_ID` | Vercel team/org ID | Run `vercel whoami` or check dashboard URL | -| `VERCEL_PROJECT_ID_FRONTEND` | Vercel project ID for kal-frontend | Create project first, then get from dashboard | -| `VERCEL_PROJECT_ID_ADMIN` | Vercel project ID for kal-admin | Create project first, then get from dashboard | - ---- - -## Phase 1 — Root `.dockerignore` - -**File:** `/.dockerignore` (create at monorepo root) - -All Dockerfiles use `context: ..` (monorepo root). Without a root `.dockerignore`, Docker sends the entire repo including all `node_modules`, `.next`, and `dist` folders to the build daemon. - -**Create file with contents:** - -``` -.git -.github -**/.env* -!**/.env.example -**/node_modules -**/.next -**/dist -**/baml_client -**/*.log -**/.DS_Store -packages/kal-frontend-chat -``` - -**Verification:** - -```bash -ls -la .dockerignore -``` - ---- - -## Phase 2 — Backend: CI builds and pushes to GHCR - -**File:** `.github/workflows/ci.yml` - -Replace the existing `docker-build` job (validate-only, `push: false`) with `docker-publish` that actually pushes to GitHub Container Registry. - -**Replace the `docker-build` job with:** - -```yaml -docker-publish: - name: Build & Push Backend Image - runs-on: ubuntu-latest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: build - permissions: - contents: read - packages: write - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - file: ./packages/kal-backend/Dockerfile - push: true - tags: | - ghcr.io/${{ github.repository }}/kal-backend:latest - ghcr.io/${{ github.repository }}/kal-backend:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max -``` - -**No new secrets needed** — `GITHUB_TOKEN` is automatically provided by GitHub Actions. - -**Verification:** - -- Push to `main` branch -- Check Actions tab for successful `docker-publish` job -- Verify image at `ghcr.io//kal-monorepo/kal-backend` - ---- - -## Phase 3 — Backend: docker-compose uses pre-built GHCR image - -**File:** `docker/docker-compose.yml` - -1. Remove `frontend`, `frontend-chat`, `admin` service blocks -2. Replace `backend.build:` with `image:` -3. Remove `profiles: ["apps"]` from backend -4. Remove `CHAT_FRONTEND_URL` from backend environment - -**New backend service block:** - -```yaml -backend: - image: ghcr.io//kal-monorepo/kal-backend:latest - container_name: kal-backend - ports: - - "${BACKEND_PORT:-4000}:3000" - environment: - - NODE_ENV=production - - MONGODB_URI=mongodb://${MONGODB_USER}:${MONGODB_PASSWORD}@mongodb:27017/${MONGODB_DATABASE}?authSource=admin - - REDIS_URL=redis://redis:6379 - - LOGTO_ENDPOINT=${LOGTO_ENDPOINT} - - LOGTO_APP_ID=${LOGTO_APP_ID} - - LOGTO_APP_SECRET=${LOGTO_APP_SECRET} - - SESSION_SECRET=${SESSION_SECRET} - - GLM_API_BASE_URL=${GLM_API_BASE_URL} - - GLM_API_KEY=${GLM_API_KEY} - - INTERNAL_API_KEY=${INTERNAL_API_KEY} - - FRONTEND_URL=${FRONTEND_URL} - depends_on: - - mongodb - - logto - - redis - networks: - - kal-network -``` - -**Remove these service blocks entirely:** - -- `frontend` -- `frontend-chat` -- `admin` - -**VPS update workflow (manual, after merge to main):** - -```bash -docker compose -f docker/docker-compose.yml pull backend -docker compose -f docker/docker-compose.yml up -d backend -``` - -**Verification:** - -```bash -docker compose -f docker/docker-compose.yml config -docker compose -f docker/docker-compose.yml pull backend -``` - ---- - -## Phase 4 — Remove `kal-frontend-chat` - -Full cleanup of the chat frontend package. - -**Steps:** - -1. Delete `packages/kal-frontend-chat/` directory -2. No changes needed to `pnpm-workspace.yaml` (uses `packages/*` glob) -3. No changes needed to `turbo.json` (no package-specific overrides) - -**Commands:** - -```bash -rm -rf packages/kal-frontend-chat -``` - -**Verification:** - -```bash -ls packages/ # Should NOT show kal-frontend-chat -pnpm install # Should succeed without errors -``` - ---- - -## Phase 5 — Prepare `kal-frontend` for Vercel - -**File:** `packages/kal-frontend/vercel.json` (create) - -This configures Vercel to build from monorepo root with correct dependency order. - -**Create file with contents:** - -```json -{ - "framework": "nextjs", - "installCommand": "cd ../.. && pnpm install --frozen-lockfile", - "buildCommand": "cd ../.. && pnpm turbo build --filter=kal-frontend", - "outputDirectory": ".next" -} -``` - -**Note:** `kal-frontend/next.config.mjs` requires NO changes — it's already using default Next.js output mode (compatible with Vercel). - -**Verification:** - -```bash -cat packages/kal-frontend/vercel.json -``` - ---- - -## Phase 6 — Prepare `kal-admin` for Vercel - -**File:** `packages/kal-admin/next.config.ts` (modify) - -Remove `output: "standalone"` and `outputFileTracingRoot` — these are for Docker self-hosted deployments and conflict with Vercel's build system. - -**Change FROM:** - -```typescript -import type { NextConfig } from "next"; -import path from "path"; - -const nextConfig: NextConfig = { - reactCompiler: true, - output: "standalone", - outputFileTracingRoot: path.join(__dirname, "../../"), -}; - -export default nextConfig; -``` - -**Change TO:** - -```typescript -import type { NextConfig } from "next"; - -const nextConfig: NextConfig = { - reactCompiler: true, -}; - -export default nextConfig; -``` - -**File:** `packages/kal-admin/vercel.json` (create) - -**Create file with contents:** - -```json -{ - "framework": "nextjs", - "installCommand": "cd ../.. && pnpm install --frozen-lockfile", - "buildCommand": "cd ../.. && pnpm turbo build --filter=kal-admin", - "outputDirectory": ".next" -} -``` - -**Verification:** - -```bash -cat packages/kal-admin/next.config.ts -cat packages/kal-admin/vercel.json -``` - ---- - -## Phase 7 — Vercel Auto-Deploy (no CI needed) - -Vercel deploys are handled automatically by connecting the repo directly in the Vercel dashboard. -No CI changes are needed — no Vercel secrets in GitHub required. - -**How it works:** - -- Push to `dev` → Vercel builds a **preview deployment** -- Push/merge to `main` → Vercel builds a **production deployment** -- Vercel reads `vercel.json` in each package automatically and runs the correct build command - -**CI pipeline (final state):** - -``` -lint-and-typecheck - │ - build - │ - docker-publish - (GHCR — backend only) -``` - -Vercel watches the GitHub repo independently and deploys `kal-frontend` and `kal-admin` on its own. - ---- - -## Phase 8 — Vercel Project Setup (Manual — Dashboard Only) - -### 8.1 Create Project for `kal-frontend` - -1. Go to [vercel.com](https://vercel.com) → Add New → Project -2. Import `Kal-Monorepo` from GitHub -3. Set **Root Directory** to `packages/kal-frontend` -4. Framework Preset: Next.js (auto-detected) -5. Leave Build Command and Output Directory as default — `vercel.json` overrides them -6. Click Deploy - -### 8.2 Create Project for `kal-admin` - -1. Go to [vercel.com](https://vercel.com) → Add New → Project -2. Import the **same** `Kal-Monorepo` repo -3. Set **Root Directory** to `packages/kal-admin` -4. Framework Preset: Next.js (auto-detected) -5. Click Deploy - -### 8.3 Add Environment Variables - -In each Vercel project: Project → Settings → Environment Variables - -**kal-frontend:** - -| Variable | Description | -| ---------------------------- | -------------------------------------------------- | -| `NEXT_PUBLIC_LOGTO_ENDPOINT` | Logto endpoint URL | -| `NEXT_PUBLIC_LOGTO_APP_ID` | Logto app ID | -| `NEXT_PUBLIC_APP_URL` | Frontend production URL | -| `NEXT_PUBLIC_API_URL` | Backend API URL (e.g. `https://kalori-api.my/api`) | -| `LOGTO_APP_SECRET` | Logto app secret (server-side only) | -| `SESSION_SECRET` | Session secret | - -**kal-admin:** - -| Variable | Description | -| --------------------- | -------------------------------------------------- | -| `NEXT_PUBLIC_API_URL` | Backend API URL (e.g. `https://kalori-api.my/api`) | -| `ADMIN_SECRET` | Admin auth secret | -| `ADMIN_USERNAME` | Admin username | -| `ADMIN_PASSWORD` | Admin password | - -### 8.4 Branch Behaviour - -Vercel will automatically: - -- Deploy `main` → **Production** -- Deploy `dev` and any other branch → **Preview** - -No GitHub secrets needed. No additional CI configuration needed. - ---- - -## Phase 9 — VPS: Coolify + GHCR (Manual — VPS Dashboard) - -This replaces the old Coolify Git-based backend (which built the image on the VPS) with a new -Docker image resource that pulls directly from GHCR. - -> **Do this after merging to `main`** so the image already exists in GHCR before Coolify tries to pull it. - -### 9.1 Merge to Main (Trigger First Image Push) - -1. Merge `chore/deployment-migration` PR → `dev` -2. Merge `dev` → `main` -3. Wait for `Backend / Build & Push Docker Image` GitHub Actions job to succeed -4. Verify the image exists at: - `https://github.com/Zen0space/Kal-Monorepo/pkgs/container/kal-monorepo%2Fkal-backend` - -### 9.2 Create New Coolify Resource (Docker Image) - -In Coolify dashboard → your Project → **Add New Resource**: - -1. Select **Docker Image** (not Git Repository) -2. Image: `ghcr.io/zen0space/kal-monorepo/kal-backend:latest` -3. Name: `kal-backend` -4. No registry credentials needed — image is public - -### 9.3 Configure Environment Variables - -In the new resource settings, add all environment variables: - -| Variable | Value | -| ------------------ | ------------------------------------------------------------------------ | -| `NODE_ENV` | `production` | -| `MONGODB_URI` | `mongodb://:@:27017/?authSource=admin` | -| `REDIS_URL` | `redis://:6379` | -| `LOGTO_ENDPOINT` | Your Logto URL | -| `LOGTO_APP_ID` | Your Logto app ID | -| `LOGTO_APP_SECRET` | Your Logto app secret | -| `SESSION_SECRET` | Your session secret | -| `GLM_API_BASE_URL` | `https://api.z.ai/api/paas/v4` | -| `GLM_API_KEY` | Your GLM API key | -| `INTERNAL_API_KEY` | Your internal API key | -| `FRONTEND_URL` | Your Vercel frontend URL | - -> **Note:** Use the Coolify container hostnames (not `localhost`) for `MONGODB_URI` and `REDIS_URL` -> since all services run on the same Docker network in Coolify. - -### 9.4 Configure Port - -- Container port: `3000` -- Exposed port: `4000` (or whatever your current backend port is) - -### 9.5 Deploy - -Click **Deploy** — Coolify will pull `ghcr.io/zen0space/kal-monorepo/kal-backend:latest` and start the container. - -### 9.6 Verify - -```bash -# SSH into VPS and check the container is running -docker ps | grep kal-backend - -# Check logs for startup errors -docker logs --tail 50 - -# Quick API health check -curl http://localhost:4000 -``` - -### 9.7 Remove Old Resources - -Once the new backend is confirmed working: - -1. In Coolify → delete the old Git-based `kal-backend` resource -2. Delete old `kal-frontend`, `kal-frontend-chat`, `kal-admin` resources (moving to Vercel) -3. Clean up dangling images on VPS: - -```bash -docker image prune -f -``` - -### 9.8 Redeployment Workflow (Going Forward) - -When you want to deploy a new backend version: - -1. Merge to `main` -2. Wait for `Backend / Build & Push Docker Image` to succeed in GitHub Actions -3. Go to Coolify dashboard → `kal-backend` resource → click **Redeploy** -4. Coolify pulls the new `:latest` image and restarts the container - ---- - -## Final Architecture - -``` - GitHub push to dev/main - │ - ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - │ │ - ā–¼ ā–¼ - GitHub Actions Vercel (auto) - ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - │ lint & │ │ kal-frontend │ - │ typecheck │ │ (preview/prod) │ - ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - │ ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - build │ kal-admin │ - │ │ (preview/prod) │ - docker-publish ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - (kal-backend → GHCR) │ - │ │ API calls - ā–¼ │ - VPS ā—„ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ - ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” - │ backend │ ◄── docker pull from GHCR - │ mongodb │ - │ logto │ - │ redis │ - │ postgres │ - ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ -``` - ---- - -## Rollback Plan - -If anything goes wrong: - -| Phase | Rollback | -| ----- | ------------------------------------------------------------------------- | -| 1 | Delete `.dockerignore` | -| 2 | Revert CI workflow, restore `docker-build` job | -| 3 | Revert `docker-compose.yml`, restore `build:` blocks | -| 4 | Cannot rollback (files deleted) — restore from git | -| 5-6 | Delete `vercel.json` files, revert `kal-admin/next.config.ts` | -| 7-8 | Delete Vercel projects | -| 9 | In Coolify, delete new Docker Image resource, keep old Git-based resource | - ---- - -## Checklist - -- [x] Phase 1: Create root `.dockerignore` -- [x] Phase 2: Update CI to push backend image to GHCR -- [x] Phase 3: Update docker-compose to use GHCR image -- [x] Phase 4: Delete `kal-frontend-chat` package -- [x] Phase 5: Create `kal-frontend/vercel.json` -- [x] Phase 6: Update `kal-admin/next.config.ts`, create `kal-admin/vercel.json` -- [x] Phase 7: Vercel auto-deploy (no CI jobs needed) -- [ ] Phase 8: Create Vercel projects and add env vars in dashboard -- [ ] Phase 9: Create new Coolify Docker Image resource pointing to GHCR -- [ ] Verify: Merge to main, check `Backend / Build & Push Docker Image` passes -- [ ] Verify: New backend container running on VPS via Coolify -- [ ] Verify: Old Coolify Git-based resources removed (backend, frontend-chat, frontend, admin) -- [ ] Verify: Frontend and admin deploy to Vercel diff --git a/packages/kal-backend/.env.example b/packages/kal-backend/.env.example index 33b49cd..97efa32 100644 --- a/packages/kal-backend/.env.example +++ b/packages/kal-backend/.env.example @@ -7,11 +7,13 @@ MONGODB_DATABASE=kalori_db # Server BACKEND_PORT=4000 +PUBLIC_URL=http://localhost:4000 # Logto Auth (for protected routes) -LOGTO_ENDPOINT=http://localhost:3301 +LOGTO_ENDPOINT=http://localhost:3001 LOGTO_APP_ID=your-app-id LOGTO_APP_SECRET=your-app-secret +BACKEND_BASE_URL=http://localhost:4000 # =================== # GLM 4.6 (Zhipu AI) - BAML Chat API @@ -22,4 +24,11 @@ GLM_API_KEY=your-glm-api-key INTERNAL_API_KEY=kal_660e99df8cb78a60d8cc90dcb1228b1a812283d1393f0e49918ca3ca82363820 REDIS_URL=redis://localhost:6379 -ADMIN_SECRET=replace_with_secure_random_string \ No newline at end of file +ADMIN_SECRET=replace_with_secure_random_string + +# Stripe +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +STRIPE_TIER1_PRICE_ID=price_... +STRIPE_TIER2_PRICE_ID=price_... +FRONTEND_URL=http://localhost:3000 \ No newline at end of file diff --git a/packages/kal-backend/package.json b/packages/kal-backend/package.json index a831616..fa89024 100644 --- a/packages/kal-backend/package.json +++ b/packages/kal-backend/package.json @@ -42,6 +42,7 @@ "mongodb": "^6.12.0", "swagger-ui-express": "^5.0.1", "ws": "^8.18.3", + "stripe": "^17.7.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/packages/kal-backend/src/index.ts b/packages/kal-backend/src/index.ts index 190d287..e49396f 100644 --- a/packages/kal-backend/src/index.ts +++ b/packages/kal-backend/src/index.ts @@ -28,11 +28,47 @@ import { } from "./middleware/timeout.js"; import { apiRouter } from "./routers/api.js"; import { chatStreamRouter } from "./routes/chat-stream.js"; +import { stripeWebhookRouter } from "./routes/stripe-webhook.js"; import { appRouter } from "./routers/index.js"; const PORT = process.env.BACKEND_PORT || 3000; +const isProduction = process.env.NODE_ENV === "production"; + +/** + * Validate that critical environment variables are set in production. + * Prevents silent fallback to localhost URLs that would break the app. + */ +function validateProductionEnv() { + if (!isProduction) return; + + const required: Record = { + FRONTEND_URL: process.env.FRONTEND_URL, + LOGTO_ENDPOINT: process.env.LOGTO_ENDPOINT, + BACKEND_BASE_URL: process.env.BACKEND_BASE_URL, + LOGTO_APP_ID: process.env.LOGTO_APP_ID, + LOGTO_APP_SECRET: process.env.LOGTO_APP_SECRET, + SESSION_SECRET: process.env.SESSION_SECRET, + STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, + STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, + }; + + const missing = Object.entries(required) + .filter(([, value]) => !value) + .map(([key]) => key); + + if (missing.length > 0) { + console.error( + `\nāŒ FATAL: Missing required environment variables in production:\n` + + missing.map((k) => ` - ${k}`).join("\n") + + `\n\nSet these in your .env or deployment environment.\n` + ); + process.exit(1); + } +} async function main() { + // Validate production environment before anything else + validateProductionEnv(); // Connect to MongoDB await connectDB(); console.log("āœ… Connected to MongoDB"); @@ -78,9 +114,14 @@ async function main() { // Private CORS: Strict whitelist for your own apps (tRPC, auth, sessions) const privateCorsOptions = { origin: [ - "http://localhost:3000", - "http://localhost:3003", // Chat frontend - "http://localhost:3005", // Admin frontend + // Only include localhost origins in development + ...(isProduction + ? [] + : [ + "http://localhost:3000", + "http://localhost:3003", // Chat frontend + "http://localhost:3005", // Admin frontend + ]), process.env.NEXT_PUBLIC_APP_URL, process.env.FRONTEND_URL, process.env.CHAT_FRONTEND_URL, @@ -96,6 +137,10 @@ async function main() { credentials: false, // No cookies for public API }; + // Stripe webhook route — MUST be before express.json() because + // Stripe needs the raw request body for signature verification + app.use("/stripe/webhook", stripeWebhookRouter); + // Core middleware app.use(express.json()); app.use(cookieParser()); diff --git a/packages/kal-backend/src/lib/context.ts b/packages/kal-backend/src/lib/context.ts index 1a529de..d34d277 100644 --- a/packages/kal-backend/src/lib/context.ts +++ b/packages/kal-backend/src/lib/context.ts @@ -32,7 +32,8 @@ async function syncUserFromLogto( { logtoId: claims.sub }, { $set: { - email: claims.email || null, + // Only set email when we actually have one (avoids null duplicate key issues) + ...(claims.email ? { email: claims.email } : {}), // Only update name if we have a value (don't overwrite with empty) ...(displayName ? { name: displayName } : {}), updatedAt: now, diff --git a/packages/kal-backend/src/lib/stripe.ts b/packages/kal-backend/src/lib/stripe.ts new file mode 100644 index 0000000..5b6a3b7 --- /dev/null +++ b/packages/kal-backend/src/lib/stripe.ts @@ -0,0 +1,29 @@ +import Stripe from "stripe"; + +if (!process.env.STRIPE_SECRET_KEY) { + console.warn( + "āš ļø STRIPE_SECRET_KEY is not set. Stripe functionality will not work." + ); +} + +export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || ""); + +/** + * Maps a Stripe Price ID to a UserTier. + * Returns "free" if the price ID doesn't match any known tier. + */ +export function priceIdToTier(priceId: string): "free" | "tier_1" | "tier_2" { + if (priceId === process.env.STRIPE_TIER1_PRICE_ID) return "tier_1"; + if (priceId === process.env.STRIPE_TIER2_PRICE_ID) return "tier_2"; + return "free"; +} + +/** + * Maps a UserTier to a Stripe Price ID. + * Returns null for the free tier (no Stripe price). + */ +export function tierToPriceId(tier: "tier_1" | "tier_2"): string | null { + if (tier === "tier_1") return process.env.STRIPE_TIER1_PRICE_ID || null; + if (tier === "tier_2") return process.env.STRIPE_TIER2_PRICE_ID || null; + return null; +} diff --git a/packages/kal-backend/src/routers/index.ts b/packages/kal-backend/src/routers/index.ts index e45751f..3ef4d64 100644 --- a/packages/kal-backend/src/routers/index.ts +++ b/packages/kal-backend/src/routers/index.ts @@ -8,6 +8,7 @@ import { foodRouter } from "./food.js"; import { halalRouter } from "./halal.js"; import { platformSettingsRouter } from "./platform-settings.js"; import { requestLogsRouter } from "./request-logs.js"; +import { subscriptionRouter } from "./subscription.js"; import { userRouter } from "./user.js"; export const appRouter = router({ @@ -20,6 +21,7 @@ export const appRouter = router({ platformSettings: platformSettingsRouter, requestLogs: requestLogsRouter, adminLogs: adminLogsRouter, + subscription: subscriptionRouter, }); // Export type for client diff --git a/packages/kal-backend/src/routers/subscription.ts b/packages/kal-backend/src/routers/subscription.ts new file mode 100644 index 0000000..ae2837a --- /dev/null +++ b/packages/kal-backend/src/routers/subscription.ts @@ -0,0 +1,205 @@ +import { TRPCError } from "@trpc/server"; +import { ObjectId } from "mongodb"; +import { z } from "zod"; + +import type { User } from "kal-shared"; + +import { stripe, tierToPriceId } from "../lib/stripe.js"; +import { protectedProcedure, router } from "../lib/trpc.js"; + +export const subscriptionRouter = router({ + /** + * Create a Stripe Checkout session for subscribing to a tier. + * Returns the Checkout URL for the frontend to redirect to. + */ + createCheckoutSession: protectedProcedure + .input( + z.object({ + tier: z.enum(["tier_1", "tier_2"]), + }) + ) + .mutation(async ({ ctx, input }) => { + const { db, userId } = ctx; + + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + + const user = await db + .collection("users") + .findOne({ _id: new ObjectId(userId) as any }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + // Don't allow subscribing if already on the requested tier + if (user.tier === input.tier) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `You are already on ${input.tier}`, + }); + } + + // If user already has a subscription, redirect to portal for plan changes + if (user.stripeSubscriptionId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "You already have an active subscription. Use the billing portal to change your plan.", + }); + } + + const priceId = tierToPriceId(input.tier); + + if (!priceId) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Price ID not configured for this tier", + }); + } + + // Create or reuse a Stripe customer + let stripeCustomerId = user.stripeCustomerId; + + if (!stripeCustomerId) { + const customer = await stripe.customers.create({ + email: user.email || undefined, + name: user.name || undefined, + metadata: { + userId: String(user._id), + logtoId: user.logtoId, + platform: "kal", + }, + }); + stripeCustomerId = customer.id; + + // Save customer ID to user record + await db + .collection("users") + .updateOne( + { _id: new ObjectId(userId) as any }, + { $set: { stripeCustomerId: customer.id, updatedAt: new Date() } } + ); + } + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + + const session = await stripe.checkout.sessions.create({ + customer: stripeCustomerId, + client_reference_id: String(user._id), + mode: "subscription", + payment_method_types: ["card"], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + success_url: `${frontendUrl}/dashboard/settings?subscription=success`, + cancel_url: `${frontendUrl}/dashboard/settings?subscription=cancelled`, + metadata: { + userId: String(user._id), + tier: input.tier, + platform: "kal", + }, + }); + + if (!session.url) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create checkout session", + }); + } + + return { url: session.url }; + }), + + /** + * Create a Stripe Customer Portal session for managing billing. + * Returns the portal URL for the frontend to redirect to. + */ + createPortalSession: protectedProcedure.mutation(async ({ ctx }) => { + const { db, userId } = ctx; + + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + + const user = await db + .collection("users") + .findOne({ _id: new ObjectId(userId) as any }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + if (!user.stripeCustomerId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "No billing account found. You need an active subscription first.", + }); + } + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000"; + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: user.stripeCustomerId, + return_url: `${frontendUrl}/dashboard/settings`, + }); + + return { url: portalSession.url }; + }), + + /** + * Get the current user's subscription status. + */ + getSubscriptionStatus: protectedProcedure.query(async ({ ctx }) => { + const { db, userId } = ctx; + + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You must be logged in", + }); + } + + const user = await db + .collection("users") + .findOne({ _id: new ObjectId(userId) as any }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "User not found", + }); + } + + const isActive = + user.tier !== "free" && + !!user.stripeSubscriptionId && + !!user.stripeCurrentPeriodEnd && + new Date(user.stripeCurrentPeriodEnd) > new Date(); + + return { + tier: user.tier, + stripeCustomerId: user.stripeCustomerId || null, + stripeSubscriptionId: user.stripeSubscriptionId || null, + stripeCurrentPeriodEnd: user.stripeCurrentPeriodEnd || null, + isActive, + }; + }), +}); diff --git a/packages/kal-backend/src/routes/stripe-webhook.ts b/packages/kal-backend/src/routes/stripe-webhook.ts new file mode 100644 index 0000000..684462d --- /dev/null +++ b/packages/kal-backend/src/routes/stripe-webhook.ts @@ -0,0 +1,275 @@ +import express, { Router } from "express"; +import type { Collection } from "mongodb"; +import { ObjectId } from "mongodb"; +import type Stripe from "stripe"; + +import { getDB } from "../lib/db.js"; +import { stripe, priceIdToTier } from "../lib/stripe.js"; + +import type { User } from "kal-shared"; + +const router: Router = Router(); + +/** + * Stripe Webhook Handler + * + * IMPORTANT: This route must be mounted BEFORE express.json() middleware + * because Stripe requires the raw body for signature verification. + */ +router.post( + "/", + express.raw({ type: "application/json" }), + async (req, res) => { + const sig = req.headers["stripe-signature"]; + + if (!sig) { + console.error("[Stripe Webhook] Missing stripe-signature header"); + return res.status(400).json({ error: "Missing stripe-signature header" }); + } + + if (!process.env.STRIPE_WEBHOOK_SECRET) { + console.error("[Stripe Webhook] STRIPE_WEBHOOK_SECRET is not configured"); + return res.status(500).json({ error: "Webhook secret not configured" }); + } + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent( + req.body, + sig, + process.env.STRIPE_WEBHOOK_SECRET + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error( + `[Stripe Webhook] Signature verification failed: ${message}` + ); + return res + .status(400) + .json({ error: `Webhook signature verification failed` }); + } + + const db = getDB(); + const users = db.collection("users"); + + try { + switch (event.type) { + case "checkout.session.completed": { + await handleCheckoutCompleted( + event.data.object as Stripe.Checkout.Session, + users + ); + break; + } + case "customer.subscription.updated": { + await handleSubscriptionUpdated( + event.data.object as Stripe.Subscription, + users + ); + break; + } + case "customer.subscription.deleted": { + await handleSubscriptionDeleted( + event.data.object as Stripe.Subscription, + users + ); + break; + } + case "invoice.payment_failed": { + await handlePaymentFailed(event.data.object as Stripe.Invoice); + break; + } + default: { + console.log(`[Stripe Webhook] Unhandled event type: ${event.type}`); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error( + `[Stripe Webhook] Error handling ${event.type}: ${message}` + ); + // Return 200 anyway to prevent Stripe from retrying + // (we log the error and can investigate manually) + } + + // Always return 200 to acknowledge receipt + res.status(200).json({ received: true }); + } +); + +/** + * Handle checkout.session.completed + * Triggered when a customer successfully completes the Stripe Checkout flow. + */ +async function handleCheckoutCompleted( + session: Stripe.Checkout.Session, + users: Collection +) { + const userId = session.client_reference_id; + + if (!userId) { + console.error( + "[Stripe Webhook] checkout.session.completed: No client_reference_id" + ); + return; + } + + const subscriptionId = + typeof session.subscription === "string" + ? session.subscription + : session.subscription?.id; + + const customerId = + typeof session.customer === "string" + ? session.customer + : session.customer?.id; + + if (!subscriptionId || !customerId) { + console.error( + "[Stripe Webhook] checkout.session.completed: Missing subscription or customer ID" + ); + return; + } + + // Retrieve the subscription to get the price ID and period end + const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const priceId = subscription.items.data[0]?.price?.id; + + if (!priceId) { + console.error( + "[Stripe Webhook] checkout.session.completed: No price ID found in subscription" + ); + return; + } + + const tier = priceIdToTier(priceId); + + await users.updateOne( + { _id: new ObjectId(userId) as any }, + { + $set: { + tier, + stripeCustomerId: customerId, + stripeSubscriptionId: subscriptionId, + stripeCurrentPeriodEnd: new Date( + subscription.current_period_end * 1000 + ), + updatedAt: new Date(), + }, + } + ); + + console.log( + `[Stripe Webhook] checkout.session.completed: User ${userId} upgraded to ${tier}` + ); +} + +/** + * Handle customer.subscription.updated + * Triggered when a subscription is changed (plan upgrade/downgrade, renewal, etc.) + */ +async function handleSubscriptionUpdated( + subscription: Stripe.Subscription, + users: Collection +) { + const customerId = + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer?.id; + + if (!customerId) { + console.error("[Stripe Webhook] subscription.updated: No customer ID"); + return; + } + + const priceId = subscription.items.data[0]?.price?.id; + if (!priceId) { + console.error("[Stripe Webhook] subscription.updated: No price ID found"); + return; + } + + const tier = priceIdToTier(priceId); + + // If the subscription is set to cancel at period end, keep the current tier + // but update the period end so we know when to downgrade + const updateFields: Record = { + stripeSubscriptionId: subscription.id, + stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), + updatedAt: new Date(), + }; + + // Only update tier if subscription is active (not pending cancellation) + if (subscription.status === "active" && !subscription.cancel_at_period_end) { + updateFields.tier = tier; + } + + await users.updateOne( + { stripeCustomerId: customerId }, + { $set: updateFields } + ); + + console.log( + `[Stripe Webhook] subscription.updated: Customer ${customerId} → tier ${tier} (status: ${subscription.status}, cancel_at_period_end: ${subscription.cancel_at_period_end})` + ); +} + +/** + * Handle customer.subscription.deleted + * Triggered when a subscription is fully cancelled (end of billing period or immediate). + */ +async function handleSubscriptionDeleted( + subscription: Stripe.Subscription, + users: Collection +) { + const customerId = + typeof subscription.customer === "string" + ? subscription.customer + : subscription.customer?.id; + + if (!customerId) { + console.error("[Stripe Webhook] subscription.deleted: No customer ID"); + return; + } + + await users.updateOne( + { stripeCustomerId: customerId }, + { + $set: { + tier: "free", + stripeSubscriptionId: null, + stripeCurrentPeriodEnd: null, + updatedAt: new Date(), + }, + } + ); + + console.log( + `[Stripe Webhook] subscription.deleted: Customer ${customerId} downgraded to free` + ); +} + +/** + * Handle invoice.payment_failed + * Triggered when a subscription payment fails. + */ +async function handlePaymentFailed(invoice: Stripe.Invoice) { + const customerId = + typeof invoice.customer === "string" + ? invoice.customer + : invoice.customer?.id; + + if (!customerId) { + console.error("[Stripe Webhook] invoice.payment_failed: No customer ID"); + return; + } + + // Log the payment failure — we don't downgrade immediately because + // Stripe will retry the payment. The subscription.deleted event + // will fire if all retries fail and the subscription is cancelled. + console.warn( + `[Stripe Webhook] invoice.payment_failed: Customer ${customerId} — payment failed for invoice ${invoice.id}` + ); +} + +export { router as stripeWebhookRouter }; diff --git a/packages/kal-db/migrations/20260407000001_add_stripe_fields.js b/packages/kal-db/migrations/20260407000001_add_stripe_fields.js new file mode 100644 index 0000000..8deb3cf --- /dev/null +++ b/packages/kal-db/migrations/20260407000001_add_stripe_fields.js @@ -0,0 +1,45 @@ +/** + * Migration: Add Stripe subscription fields to users collection + * + * Adds stripeCustomerId, stripeSubscriptionId, and stripeCurrentPeriodEnd + * to support Stripe subscription billing. + */ + +export const up = async (db, client) => { + // Add Stripe fields to all existing users (default to null) + await db.collection("users").updateMany( + { stripeCustomerId: { $exists: false } }, + { + $set: { + stripeCustomerId: null, + stripeSubscriptionId: null, + stripeCurrentPeriodEnd: null, + }, + } + ); + + // Create index on stripeCustomerId for webhook lookups + await db + .collection("users") + .createIndex({ stripeCustomerId: 1 }, { sparse: true, unique: true }); +}; + +export const down = async (db, client) => { + // Drop the Stripe customer ID index + await db + .collection("users") + .dropIndex("stripeCustomerId_1") + .catch(() => {}); + + // Remove Stripe fields from all users + await db.collection("users").updateMany( + {}, + { + $unset: { + stripeCustomerId: "", + stripeSubscriptionId: "", + stripeCurrentPeriodEnd: "", + }, + } + ); +}; diff --git a/packages/kal-db/migrations/20260407000002_fix_email_index_sparse.js b/packages/kal-db/migrations/20260407000002_fix_email_index_sparse.js new file mode 100644 index 0000000..2025455 --- /dev/null +++ b/packages/kal-db/migrations/20260407000002_fix_email_index_sparse.js @@ -0,0 +1,35 @@ +/** + * Migration: Fix email index to allow multiple null emails + * + * The original email_1 index was { unique: true } which prevents + * multiple users from having email: null (e.g. social/phone login users). + * This changes it to { unique: true, sparse: true } so null values are + * excluded from the uniqueness constraint. + */ + +export const up = async (db, client) => { + // Drop the old non-sparse unique index + await db + .collection("users") + .dropIndex("email_1") + .catch(() => {}); + + // Recreate as sparse unique — allows multiple null emails + await db + .collection("users") + .createIndex({ email: 1 }, { unique: true, sparse: true }); + + console.log("āœ… Recreated email index as sparse unique"); +}; + +export const down = async (db, client) => { + // Revert to original non-sparse unique index + await db + .collection("users") + .dropIndex("email_1") + .catch(() => {}); + + await db.collection("users").createIndex({ email: 1 }, { unique: true }); + + console.log("āœ… Reverted email index to non-sparse unique"); +}; diff --git a/packages/kal-db/migrations/20260407000003_fix_email_index_partial.js b/packages/kal-db/migrations/20260407000003_fix_email_index_partial.js new file mode 100644 index 0000000..267774b --- /dev/null +++ b/packages/kal-db/migrations/20260407000003_fix_email_index_partial.js @@ -0,0 +1,46 @@ +/** + * Migration: Fix email index using partialFilterExpression + * + * The previous sparse:true fix doesn't actually work because MongoDB sparse + * indexes still include documents where the field is explicitly set to null. + * Sparse only skips documents where the field is completely absent. + * + * This replaces the index with a partialFilterExpression that only indexes + * documents where email is a string — completely excluding null/missing values + * from the uniqueness constraint. + */ + +export const up = async (db, client) => { + // Drop the current sparse unique index + await db + .collection("users") + .dropIndex("email_1") + .catch(() => {}); + + // Recreate with partialFilterExpression — only enforce uniqueness on actual string emails + await db.collection("users").createIndex( + { email: 1 }, + { + unique: true, + partialFilterExpression: { email: { $type: "string" } }, + } + ); + + console.log( + "āœ… Recreated email index with partialFilterExpression (string only)" + ); +}; + +export const down = async (db, client) => { + // Revert to the sparse unique index from the previous migration + await db + .collection("users") + .dropIndex("email_1") + .catch(() => {}); + + await db + .collection("users") + .createIndex({ email: 1 }, { unique: true, sparse: true }); + + console.log("āœ… Reverted email index to sparse unique"); +}; diff --git a/packages/kal-frontend/.env.example b/packages/kal-frontend/.env.example index 725bc81..8f7d18d 100644 --- a/packages/kal-frontend/.env.example +++ b/packages/kal-frontend/.env.example @@ -1,6 +1,13 @@ # API NEXT_PUBLIC_API_URL=http://localhost:4000 +# Site Configuration (used for SEO metadata, robots.txt, sitemap, legal pages, docs) +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +NEXT_PUBLIC_SITE_DOMAIN=localhost:3000 +NEXT_PUBLIC_API_DOMAIN=localhost:4000 +NEXT_PUBLIC_SUPPORT_EMAIL=support@localhost +NEXT_PUBLIC_PRIVACY_EMAIL=privacy@localhost + # Logto Auth NEXT_PUBLIC_LOGTO_ENDPOINT=http://localhost:3001 NEXT_PUBLIC_LOGTO_APP_ID=8hr723agloaw142cbag0g @@ -9,3 +16,6 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 SESSION_SECRET=AyQ4erWsxYn6HTmSmkEz7Ih3j1qUg3TG NEXT_PUBLIC_DEMO_API_KEY=kal_660e99df8cb78a60d8cc90dcb1228b1a812283d1393f0e49918ca3ca82363820 + +# Stripe +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... diff --git a/packages/kal-frontend/package.json b/packages/kal-frontend/package.json index 1432baf..1e386db 100644 --- a/packages/kal-frontend/package.json +++ b/packages/kal-frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@logto/next": "^4.2.7", + "@stripe/stripe-js": "^5.5.0", "@tanstack/react-query": "^5.62.11", "@trpc/client": "^11.0.0-rc.682", "@trpc/react-query": "^11.0.0-rc.682", diff --git a/packages/kal-frontend/src/app/api-docs/client.tsx b/packages/kal-frontend/src/app/api-docs/client.tsx index 5951d24..1f251dd 100644 --- a/packages/kal-frontend/src/app/api-docs/client.tsx +++ b/packages/kal-frontend/src/app/api-docs/client.tsx @@ -6,6 +6,7 @@ import { Check, FileText, Heart, Lock, Menu, Star, X } from "react-feather"; import { Button } from "@/components/ui/Button"; import { Container } from "@/components/ui/Container"; +import { API_URL_DISPLAY } from "@/lib/site-config"; import { trpc } from "@/lib/trpc"; // Get the API base URL from environment @@ -563,7 +564,7 @@ export default function APIDocsClient({

Base URL

- https://api.kalori-api.my + {API_URL_DISPLAY}
diff --git a/packages/kal-frontend/src/app/dashboard/changelog/client.tsx b/packages/kal-frontend/src/app/dashboard/changelog/client.tsx index 1430b5c..23705aa 100644 --- a/packages/kal-frontend/src/app/dashboard/changelog/client.tsx +++ b/packages/kal-frontend/src/app/dashboard/changelog/client.tsx @@ -147,9 +147,9 @@ function VersionCard({ ); } -/** Renders inline `code` segments with accent styling */ +/** Renders inline `code` and **bold** segments with styling */ function HighlightCode({ text }: { text: string }) { - const parts = text.split(/(`[^`]+`)/g); + const parts = text.split(/(`[^`]+`|\*\*[^*]+\*\*)/g); return ( <> {parts.map((part, i) => @@ -160,6 +160,10 @@ function HighlightCode({ text }: { text: string }) { > {part.slice(1, -1)} + ) : part.startsWith("**") && part.endsWith("**") ? ( + + {part.slice(2, -2)} + ) : ( {part} ) diff --git a/packages/kal-frontend/src/app/dashboard/docs/client.tsx b/packages/kal-frontend/src/app/dashboard/docs/client.tsx index e22558a..1bb424c 100644 --- a/packages/kal-frontend/src/app/dashboard/docs/client.tsx +++ b/packages/kal-frontend/src/app/dashboard/docs/client.tsx @@ -20,6 +20,7 @@ import { } from "react-feather"; import { AuthUpdater, useAuth } from "@/lib/auth-context"; +import { API_URL_DISPLAY } from "@/lib/site-config"; import { trpc } from "@/lib/trpc"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -572,7 +573,7 @@ async function getFoodCached(query) { const hit = cache.get(key); if (hit && Date.now() - hit.ts < 5 * 60 * 1000) return hit.data; - const res = await fetch(\`https://api.kalori-api.my/api/v1/foods/search?q=\${encodeURIComponent(query)}\`, + const res = await fetch(\`${API_URL_DISPLAY}/api/v1/foods/search?q=\${encodeURIComponent(query)}\`, { headers: { 'X-API-Key': process.env.KAL_API_KEY } }); const data = await res.json(); cache.set(key, { data, ts: Date.now() }); @@ -616,7 +617,7 @@ export async function GET(req: Request) { const { searchParams } = new URL(req.url); const q = searchParams.get('q') ?? ''; const res = await fetch( - \`https://api.kalori-api.my/api/v1/foods/search?q=\${encodeURIComponent(q)}\`, + \`${API_URL_DISPLAY}/api/v1/foods/search?q=\${encodeURIComponent(q)}\`, { headers: { 'X-API-Key': process.env.KAL_API_KEY! } } ); return Response.json(await res.json()); @@ -663,7 +664,7 @@ data.data.forEach(food => console.log(food.name));`, } // Usage -for await (const page of paginate('https://api.kalori-api.my/api/v1/foods', key)) { +for await (const page of paginate('${API_URL_DISPLAY}/api/v1/foods', key)) { console.log(\`Got \${page.length} foods\`); }`, lang: "javascript", @@ -727,7 +728,7 @@ function ApiKeysTab({ header. Query-string based auth is not supported.

- + ); } -function SettingsContentWrapper({ - expectedLogtoId, +function SettingsContentWrapper({ + expectedLogtoId, nameProp, - emailProp -}: { - expectedLogtoId?: string; + emailProp, +}: { + expectedLogtoId?: string; nameProp?: string | null; emailProp?: string | null; }) { const { logtoId } = useAuth(); - + if (expectedLogtoId && logtoId !== expectedLogtoId) { return (
@@ -47,23 +65,92 @@ function SettingsContentWrapper({ return ; } -function SettingsContent({ nameProp, emailProp }: { nameProp?: string | null; emailProp?: string | null }) { +function SettingsContent({ + nameProp, + emailProp, +}: { + nameProp?: string | null; + emailProp?: string | null; +}) { const { isMobile } = useBreakpoint(); const { data: userInfo, isLoading } = trpc.apiKeys.getMe.useQuery(); const { data: stats } = trpc.apiKeys.getUsageStats.useQuery(); + const { data: subscriptionStatus } = + trpc.subscription.getSubscriptionStatus.useQuery(); + const createPortal = trpc.subscription.createPortalSession.useMutation({ + onSuccess: (data) => { + window.location.href = data.url; + }, + }); + + const searchParams = useSearchParams(); + const subscriptionResult = searchParams.get("subscription"); + + // Clean up URL params after showing success/cancelled message + useEffect(() => { + if (subscriptionResult) { + const timer = setTimeout(() => { + const url = new URL(window.location.href); + url.searchParams.delete("subscription"); + window.history.replaceState({}, "", url.toString()); + }, 5000); + return () => clearTimeout(timer); + } + }, [subscriptionResult]); const displayName = userInfo?.name || nameProp || "Developer"; const displayEmail = userInfo?.email || emailProp || ""; const tier = stats?.tier || "free"; const limits = RATE_LIMITS[tier]; + const hasStripeCustomer = !!subscriptionStatus?.stripeCustomerId; + + const handleManageBilling = () => { + createPortal.mutate(); + }; return (
-

Account Settings

-

Manage your account information

+

+ Account Settings +

+

+ Manage your account information +

+ {/* Subscription Success/Cancelled Banner */} + {subscriptionResult === "success" && ( +
+
+ +
+
+

+ Subscription activated! +

+

+ Your plan has been upgraded. It may take a moment to reflect. +

+
+
+ )} + {subscriptionResult === "cancelled" && ( +
+
+ +
+
+

+ Checkout cancelled +

+

+ No charges were made. You can try again anytime. +

+
+
+ )} + {/* Profile Section */}

@@ -71,7 +158,9 @@ function SettingsContent({ nameProp, emailProp }: { nameProp?: string | null; em

- +

{isLoading ? ( @@ -84,7 +173,9 @@ function SettingsContent({ nameProp, emailProp }: { nameProp?: string | null; em -

{displayEmail}

+

+ {displayEmail} +

@@ -97,40 +188,99 @@ function SettingsContent({ nameProp, emailProp }: { nameProp?: string | null; em
-

Current Plan

+

+ Current Plan +

- {tier === "free" ? "Free" : tier === "tier_1" ? "Tier 1" : "Tier 2"} + {tier === "free" + ? "Free" + : tier === "tier_1" + ? "Tier 1" + : "Tier 2"}

- + {tier.toUpperCase().replace("_", " ")}
-

Daily Limit

-

{limits.dailyLimit.toLocaleString()}

+

+ Daily Limit +

+

+ {limits.dailyLimit.toLocaleString()} +

-

Monthly Limit

-

{limits.monthlyLimit.toLocaleString()}

+

+ Monthly Limit +

+

+ {limits.monthlyLimit.toLocaleString()} +

-

Per Minute

-

{limits.minuteLimit}/min

+

+ Per Minute +

+

+ {limits.minuteLimit}/min +

+ + {/* Subscription Actions */} +
+ {tier === "free" ? ( + + + Upgrade Plan + + ) : ( + + + Change Plan + + )} + {hasStripeCustomer && ( + + )} +
+ {createPortal.error && ( +

+ {createPortal.error.message} +

+ )}
{/* Danger Zone */}
-

Danger Zone

+

+ Danger Zone +

- Account deletion is not available through this interface. Please contact support if you need to delete your account. + Account deletion is not available through this interface. Please + contact support if you need to delete your account.

- + )} +
+
+ + + ); +} + +function PricingContent({ + isAuthenticated, + authReady, + onSignIn, +}: { + isAuthenticated: boolean; + authReady: boolean; + onSignIn?: () => Promise; +}) { + const router = useRouter(); + + // Only fetch subscription status if authenticated + const { data: subscriptionStatus, isLoading } = + trpc.subscription.getSubscriptionStatus.useQuery(undefined, { + enabled: isAuthenticated && authReady, + }); + + const createCheckout = trpc.subscription.createCheckoutSession.useMutation({ + onSuccess: (data) => { + window.location.href = data.url; + }, + }); + const createPortal = trpc.subscription.createPortalSession.useMutation({ + onSuccess: (data) => { + window.location.href = data.url; + }, + }); + + const currentTier = isAuthenticated + ? subscriptionStatus?.tier || "free" + : null; + + const handleSubscribe = (tier: "tier_1" | "tier_2") => { + if (!isAuthenticated) { + onSignIn?.(); + return; + } + createCheckout.mutate({ tier }); + }; + + const handleManageBilling = () => { + createPortal.mutate(); + }; + + const tiers: { + key: UserTier; + features: string[]; + highlighted?: boolean; + }[] = [ + { + key: "free", + features: [ + `${RATE_LIMITS.free.minuteLimit} requests/minute`, + `${RATE_LIMITS.free.dailyLimit.toLocaleString()} requests/day`, + `${RATE_LIMITS.free.monthlyLimit.toLocaleString()} requests/month`, + "Full food database access", + "AI-powered nutrition chat", + "Community support", + ], + }, + { + key: "tier_1", + highlighted: true, + features: [ + `${RATE_LIMITS.tier_1.minuteLimit} requests/minute`, + `${RATE_LIMITS.tier_1.dailyLimit.toLocaleString()} requests/day`, + `${RATE_LIMITS.tier_1.monthlyLimit.toLocaleString()} requests/month`, + "Full food database access", + "AI-powered nutrition chat", + "Priority support", + "Higher burst limits", + ], + }, + { + key: "tier_2", + features: [ + `${RATE_LIMITS.tier_2.minuteLimit} requests/minute`, + `${RATE_LIMITS.tier_2.dailyLimit.toLocaleString()} requests/day`, + `${RATE_LIMITS.tier_2.monthlyLimit.toLocaleString()} requests/month`, + "Full food database access", + "AI-powered nutrition chat", + "Priority support", + "Maximum burst limits", + "Dedicated support channel", + ], + }, + ]; + + return ( +
+ {/* Back to home */} + {isAuthenticated && ( + + )} + + {/* Header */} +
+

+ Simple, Transparent Pricing +

+

+ Scale your application with higher rate limits and priority support. + All plans include full access to the Malaysian food nutrition + database. +

+
+ + {/* Pricing Cards */} +
+ {tiers.map((tier) => { + const pricing = TIER_PRICING[tier.key]; + const isCurrent = currentTier === tier.key; + const isHigherTier = + currentTier !== null && + ((tier.key === "tier_1" && currentTier === "free") || + (tier.key === "tier_2" && + (currentTier === "free" || currentTier === "tier_1"))); + const isPaid = tier.key !== "free"; + + return ( +
+ {/* Popular badge */} + {tier.highlighted && ( +
+ + POPULAR + +
+ )} + + {/* Tier name */} +

+ {pricing.label} +

+

+ {pricing.description} +

+ + {/* Price */} +
+ {pricing.price === 0 ? ( +
+ + Free + +
+ ) : ( +
+ RM + + {pricing.price} + + /month +
+ )} +
+ + {/* Features */} +
    + {tier.features.map((feature, i) => ( +
  • + + + {feature} + +
  • + ))} +
+ + {/* CTA Button */} + {!isAuthenticated ? ( + // Visitor — not signed in + + ) : !authReady || isLoading ? ( +
+ ) : isCurrent ? ( +
+ + Current Plan + + {isPaid && subscriptionStatus?.stripeCustomerId && ( + + )} +
+ ) : isHigherTier ? ( + + ) : ( + // Lower tier than current — show manage billing + + )} +
+ ); + })} +
+ + {/* Footer note */} +

+ All prices are in Malaysian Ringgit (MYR). Subscriptions are billed + monthly and can be cancelled anytime. +

+ + {/* Error display */} + {(createCheckout.error || createPortal.error) && ( +
+

+ {createCheckout.error?.message || createPortal.error?.message} +

+
+ )} +
+ ); +} + +function VisitorButton({ + tier, + highlighted, + onSignIn, +}: { + tier: UserTier; + highlighted?: boolean; + onSignIn?: () => Promise; +}) { + if (tier === "free") { + return ( + + ); + } + + return ( + + ); +} diff --git a/packages/kal-frontend/src/app/pricing/page.tsx b/packages/kal-frontend/src/app/pricing/page.tsx new file mode 100644 index 0000000..3d553ba --- /dev/null +++ b/packages/kal-frontend/src/app/pricing/page.tsx @@ -0,0 +1,32 @@ +import { signIn } from "@logto/next/server-actions"; +import { getLogtoContext } from "@logto/next/server-actions"; + +import PricingClient from "./client"; + +import { getLogtoConfig } from "@/lib/logto"; + +export const metadata = { + title: "Pricing - Kalori API", + description: "Choose the right plan for your application", +}; + +export default async function PricingPage() { + const config = getLogtoConfig(); + const { isAuthenticated, claims } = await getLogtoContext(config); + + const onSignIn = async () => { + "use server"; + const cfg = getLogtoConfig(); + await signIn(cfg); + }; + + return ( + + ); +} diff --git a/packages/kal-frontend/src/app/privacy/page.tsx b/packages/kal-frontend/src/app/privacy/page.tsx index dcec104..9867b4e 100644 --- a/packages/kal-frontend/src/app/privacy/page.tsx +++ b/packages/kal-frontend/src/app/privacy/page.tsx @@ -2,6 +2,12 @@ import type { Metadata } from "next"; import Link from "next/link"; import { Container } from "@/components/ui/Container"; +import { + SITE_URL, + SITE_DOMAIN, + SUPPORT_EMAIL, + PRIVACY_EMAIL, +} from "@/lib/site-config"; export const metadata: Metadata = { title: "Privacy Policy", @@ -89,11 +95,8 @@ export default function PrivacyPolicyPage() { personal data. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our website at{" "} - - kalori-api.my + + {SITE_DOMAIN} {" "} and our API services (collectively, the "Service"). @@ -120,8 +123,8 @@ export default function PrivacyPolicyPage() { Data Controller

Kalori API

-

Email: privacy@kalori-api.my

-

Website: https://kalori-api.my

+

Email: {PRIVACY_EMAIL}

+

Website: {SITE_URL}

@@ -160,7 +163,9 @@ export default function PrivacyPolicyPage() {

3.2 Information Collected Automatically

-

When you access our Service, we automatically collect:

+

+ When you access our Service, we automatically collect: +

  • Usage Data: API call logs, endpoints @@ -346,7 +351,9 @@ export default function PrivacyPolicyPage() { Adequate safeguards are in place (e.g., Standard Contractual Clauses for EU transfers)
  • -
  • Compliance with PDPA cross-border transfer requirements
  • +
  • + Compliance with PDPA cross-border transfer requirements +
  • Data protection standards equivalent to those in Malaysia
  • @@ -450,10 +457,10 @@ export default function PrivacyPolicyPage() {

    To exercise these rights, contact us at{" "} - privacy@kalori-api.my + {PRIVACY_EMAIL} . We will respond within 21 days (PDPA) or 30 days (GDPR).

    @@ -530,7 +537,9 @@ export default function PrivacyPolicyPage() { 11. Third-Party Services
    -

    Our Service integrates with the following third parties:

    +

    + Our Service integrates with the following third parties: +

    • Logto: Authentication service @@ -569,10 +578,10 @@ export default function PrivacyPolicyPage() { If you believe we have collected data from a child, please contact us immediately at{" "} - privacy@kalori-api.my + {PRIVACY_EMAIL} , and we will take steps to delete such information.

      @@ -649,9 +658,9 @@ export default function PrivacyPolicyPage() {

      Kalori API - Privacy Team

      -

      Email: privacy@kalori-api.my

      -

      General Inquiries: support@kalori-api.my

      -

      Website: https://kalori-api.my

      +

      Email: {PRIVACY_EMAIL}

      +

      General Inquiries: {SUPPORT_EMAIL}

      +

      Website: {SITE_URL}

    @@ -686,7 +695,10 @@ export default function PrivacyPolicyPage() { Privacy Policy - + Terms of Service diff --git a/packages/kal-frontend/src/app/robots.ts b/packages/kal-frontend/src/app/robots.ts index 5619c79..a7023b6 100644 --- a/packages/kal-frontend/src/app/robots.ts +++ b/packages/kal-frontend/src/app/robots.ts @@ -7,7 +7,7 @@ import type { MetadataRoute } from "next"; * @see https://nextjs.org/docs/app/api-reference/file-conventions/metadata/robots */ export default function robots(): MetadataRoute.Robots { - const baseUrl = "https://kalori-api.my"; + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; return { rules: [ diff --git a/packages/kal-frontend/src/app/sitemap.ts b/packages/kal-frontend/src/app/sitemap.ts index 901f02f..34fb647 100644 --- a/packages/kal-frontend/src/app/sitemap.ts +++ b/packages/kal-frontend/src/app/sitemap.ts @@ -7,7 +7,7 @@ import type { MetadataRoute } from "next"; * @see https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap */ export default function sitemap(): MetadataRoute.Sitemap { - const baseUrl = "https://kalori-api.my"; + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; const currentDate = new Date(); // Main pages with their priorities and change frequencies diff --git a/packages/kal-frontend/src/app/terms/page.tsx b/packages/kal-frontend/src/app/terms/page.tsx index 6626275..4fe4e68 100644 --- a/packages/kal-frontend/src/app/terms/page.tsx +++ b/packages/kal-frontend/src/app/terms/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { Container } from "@/components/ui/Container"; +import { SITE_URL, SITE_DOMAIN, SUPPORT_EMAIL } from "@/lib/site-config"; export const metadata: Metadata = { title: "Terms of Service", @@ -72,11 +73,8 @@ export default function TermsOfServicePage() { "Platform", "we", "us", or "our"). These Terms of Service ("Terms") govern your access to and use of our website at{" "} - - kalori-api.my + + {SITE_DOMAIN} {" "} and our Application Programming Interface (API) services.

    @@ -198,7 +196,9 @@ export default function TermsOfServicePage() {
    • Keep your API keys confidential and secure
    • -
    • Do not share API keys publicly or in client-side code
    • +
    • + Do not share API keys publicly or in client-side code +
    • Regenerate keys immediately if you suspect unauthorized access @@ -252,8 +252,9 @@ export default function TermsOfServicePage() { or entity
    • - Violate any third-party platform terms (including X/Twitter - Developer Agreement) when integrating with our Service + Violate any third-party platform terms (including + X/Twitter Developer Agreement) when integrating with our + Service
    @@ -291,9 +292,7 @@ export default function TermsOfServicePage() { third-party services, including:

      -
    • - Authentication providers (Google, Apple, X/Twitter) -
    • +
    • Authentication providers (Google, Apple, X/Twitter)
    • Analytics services
    • Payment processors
    @@ -372,7 +371,9 @@ export default function TermsOfServicePage() {
  • Your use of the Service
  • Your violation of these Terms
  • Your violation of any third-party rights
  • -
  • Any applications or content you create using our API
  • +
  • + Any applications or content you create using our API +
@@ -473,8 +474,8 @@ export default function TermsOfServicePage() {

Kalori API

-

Email: support@kalori-api.my

-

Website: https://kalori-api.my

+

Email: {SUPPORT_EMAIL}

+

Website: {SITE_URL}

@@ -504,7 +505,10 @@ export default function TermsOfServicePage() {
- + Privacy Policy diff --git a/packages/kal-frontend/src/components/landing/Navbar.tsx b/packages/kal-frontend/src/components/landing/Navbar.tsx index b14dd05..7293706 100644 --- a/packages/kal-frontend/src/components/landing/Navbar.tsx +++ b/packages/kal-frontend/src/components/landing/Navbar.tsx @@ -10,6 +10,7 @@ import { Container } from "@/components/ui/Container"; const navLinks = [ { label: "Features", href: "#features" }, { label: "How It Works", href: "#how-it-works" }, + { label: "Pricing", href: "/pricing" }, { label: "FAQ", href: "#faq" }, { label: "API", href: "/api-docs" }, ]; @@ -33,15 +34,25 @@ export function Navbar({ onSignIn }: NavbarProps) { {/* Desktop Navigation */}
- {navLinks.map((link) => ( - - {link.label} - - ))} + {navLinks.map((link) => + link.href.startsWith("/") ? ( + + {link.label} + + ) : ( + + {link.label} + + ) + )}
{/* CTA Button */} @@ -67,16 +78,27 @@ export function Navbar({ onSignIn }: NavbarProps) { {/* Mobile Menu */} {mobileMenuOpen && (
- {navLinks.map((link) => ( - setMobileMenuOpen(false)} - className="block py-2 text-content-secondary hover:text-content-primary transition-colors" - > - {link.label} - - ))} + {navLinks.map((link) => + link.href.startsWith("/") ? ( + setMobileMenuOpen(false)} + className="block py-2 text-content-secondary hover:text-content-primary transition-colors" + > + {link.label} + + ) : ( + setMobileMenuOpen(false)} + className="block py-2 text-content-secondary hover:text-content-primary transition-colors" + > + {link.label} + + ) + )}