diff --git a/.gitignore b/.gitignore index c7ca521..e43ce0c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ node_modules **/.env **/.env.* !**/.env.example +!**/.env.*.example # Testing coverage diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..6e9aaf9 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,3 @@ +pnpm exec lint-staged +node scripts/hooks/typecheck-staged.mjs +node scripts/hooks/check-staged-files.mjs diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..060a403 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,2 @@ +pnpm lint +pnpm typecheck diff --git a/AGENTS.md b/AGENTS.md index e27ccc3..6aec892 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,29 +30,30 @@ Read the implementation and package manifests as the source of truth. See `READM ## Repository Structure -| Path | Responsibility | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -| `apps/web/app` | Active Next.js App Router routes, layouts, and global styles | -| `apps/web/src` | Feature components, API clients, hooks, auth context, Zustand stores, and previews | -| `apps/server` | Public API, authentication, ownership checks, projects, generation entry points, credits, and service clients | -| `apps/orchestrator-service` | AI planning, generation, iteration, replication, prompts, and model routing | -| `apps/research-service` | Research corpus endpoints, retrieval, fusion, and reranking | -| `apps/scoring-service` | Website scoring agents and criteria | -| `apps/billing-service` | Razorpay checkout, payment methods, webhooks, and auto-reload endpoints | -| `apps/worker` | BullMQ processors for scans, SEO, scoring, domains, PDFs, billing jobs, and cache expiry | -| `apps/deploy-service` | Site builds, R2 uploads, KV publishing (`syncSiteToKvs`), rollback, GC, reaper, reconcile; see `RUNBOOK.md` | -| `apps/site-edge` | Cloudflare Worker serving generated sites from KV + R2; deployed with Wrangler, never from CI | -| `apps/media-service` | BullMQ worker for the media library: upload processing (sharp/ffmpeg), vision descriptions, media GC | -| `infra/terraform`, `infra/k8s` | Cloudflare sites-hosting Terraform module and GKE manifests (applied by a human, never by agents) | -| `infra/helm/useframe` | Helm chart for the backend workloads on GKE; agents run only offline checks (lint/template/unittest) | -| `packages/db` | Shared Prisma client, schema, migrations, seed data, and scan cache helpers | -| `packages/schemas` | Shared Zod contracts for projects, site specs, AI outputs, research, and credits | -| `packages/events` | Shared queue names and job payload contracts | -| `packages/site-builder` | Shared generated-site construction logic | -| `packages/evals` | Evaluation runner, scorers, and datasets | -| `packages/ui` | Shared UI primitives; also check the richer app-local UI components before adding components | -| `packages/validators`, `packages/types` | Shared validation and type package entry points; verify actual exports before using | -| `packages/typescript-config`, `packages/eslint-config` | Shared compiler and lint configuration | +| Path | Responsibility | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `apps/web/app` | Active Next.js App Router routes, layouts, and global styles | +| `apps/web/src` | Feature components, API clients, hooks, auth context, Zustand stores, and previews | +| `apps/server` | Public API, authentication, ownership checks, projects, generation entry points, credits, and service clients | +| `apps/orchestrator-service` | AI planning, generation, iteration, replication, prompts, and model routing | +| `apps/research-service` | Research corpus endpoints, retrieval, fusion, and reranking | +| `apps/scoring-service` | Website scoring agents and criteria | +| `apps/billing-service` | Razorpay checkout, payment methods, webhooks, and auto-reload endpoints | +| `apps/worker` | BullMQ processors for scans, SEO, scoring, domains, PDFs, billing jobs, and cache expiry | +| `apps/deploy-service` | Site builds, R2 uploads, KV publishing (`syncSiteToKvs`), rollback, GC, reaper, reconcile; see `RUNBOOK.md` | +| `apps/site-edge` | Cloudflare Worker serving generated sites from KV + R2; deployed with Wrangler, never from CI | +| `apps/media-service` | BullMQ worker for the media library: uploads (sharp/ffmpeg), descriptions, GC, and AI media jobs; see `docs/media-ai.md` | +| `infra/terraform`, `infra/k8s` | Cloudflare sites-hosting Terraform module and GKE manifests (applied by a human, never by agents) | +| `infra/helm/useframe` | Helm chart for the backend workloads on GKE; agents run only offline checks (lint/template/unittest) | +| `packages/db` | Shared Prisma client, schema, migrations, seed data, and scan cache helpers | +| `packages/schemas` | Shared Zod contracts for projects, site specs, AI outputs, research, and credits | +| `packages/events` | Shared queue names and job payload contracts | +| `packages/site-builder` | Shared generated-site construction logic | +| `packages/media-ai` | AI media: Site Blueprint, media job logic, prompts, providers (fake by default); test fakes at `@repo/media-ai/testing` | +| `packages/evals` | Evaluation runner, scorers, and datasets | +| `packages/ui` | Shared UI primitives; also check the richer app-local UI components before adding components | +| `packages/validators`, `packages/types` | Shared validation and type package entry points; verify actual exports before using | +| `packages/typescript-config`, `packages/eslint-config` | Shared compiler and lint configuration | The frontend currently has both `src/store` and `src/stores`. Follow the relevant feature's existing location. The active routing tree is `apps/web/app`; do not create a competing routing tree under `src/app`. @@ -140,7 +141,8 @@ Run these from the repository root unless otherwise noted: | `pnpm --filter @useframe/server dev` | Start the public API (default port 4000) | | `pnpm --filter @useframe/ dev` | Start a specific service using its actual package name | | `pnpm build` | Build packages/apps that define a build task | -| `pnpm lint` | Run defined lint tasks; report script/configuration failures | +| `pnpm lint` | Lint every package with the root ESLint config through Turbo | +| `pnpm typecheck` | Type-check every package through Turbo (cached) | | `pnpm check-types` | Run defined `check-types` tasks through Turbo | | `pnpm --filter @useframe/web typecheck` | Check frontend types separately | | `pnpm --filter @useframe/server typecheck` | Check public API types separately | @@ -151,8 +153,7 @@ Run these from the repository root unless otherwise noted: | `pnpm docker:build -- ` / `docker:smoke` | Build and smoke-test production images locally; see `docker/README.md` | | `helm unittest infra/helm/useframe` | Chart unit tests (lint/kubeconform: see the chart README) | -- Root `check-types` does not include web/server's differently named `typecheck` scripts. Run these explicitly when affected. -- The web lint script is still `next lint` despite the Next.js 16 dependency. Inspect the local ESLint setup and use `pnpm --filter @useframe/web exec eslint .` when appropriate; report configuration failures rather than treating them as passes. +- Use `pnpm typecheck` (every package); root `check-types` skips web, server and site-edge. Husky pre-commit/pre-push hooks run lint and type checks; never bypass them with `--no-verify` — fix the hook instead. - There is no root `test` script. Run relevant existing tests/evaluations where available and report coverage gaps instead of inventing a passing test command. - Shared packages such as db, schemas, events, and site-builder export `dist`; rebuild them after changes before restarting consumers (`@useframe/db` runs `prisma generate` then `tsc`). A running watch process does not prove shared output is current. - Backend services build with `tsc && tsc-alias` (the `@/` alias becomes relative paths) and run in production with plain `node dist/...`; never ship `tsx` in an image. Runtime workspace packages declare `files` (normally `dist`) because `pnpm deploy` only copies published files. diff --git a/CLAUDE.md b/CLAUDE.md index e27ccc3..6aec892 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,29 +30,30 @@ Read the implementation and package manifests as the source of truth. See `READM ## Repository Structure -| Path | Responsibility | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -| `apps/web/app` | Active Next.js App Router routes, layouts, and global styles | -| `apps/web/src` | Feature components, API clients, hooks, auth context, Zustand stores, and previews | -| `apps/server` | Public API, authentication, ownership checks, projects, generation entry points, credits, and service clients | -| `apps/orchestrator-service` | AI planning, generation, iteration, replication, prompts, and model routing | -| `apps/research-service` | Research corpus endpoints, retrieval, fusion, and reranking | -| `apps/scoring-service` | Website scoring agents and criteria | -| `apps/billing-service` | Razorpay checkout, payment methods, webhooks, and auto-reload endpoints | -| `apps/worker` | BullMQ processors for scans, SEO, scoring, domains, PDFs, billing jobs, and cache expiry | -| `apps/deploy-service` | Site builds, R2 uploads, KV publishing (`syncSiteToKvs`), rollback, GC, reaper, reconcile; see `RUNBOOK.md` | -| `apps/site-edge` | Cloudflare Worker serving generated sites from KV + R2; deployed with Wrangler, never from CI | -| `apps/media-service` | BullMQ worker for the media library: upload processing (sharp/ffmpeg), vision descriptions, media GC | -| `infra/terraform`, `infra/k8s` | Cloudflare sites-hosting Terraform module and GKE manifests (applied by a human, never by agents) | -| `infra/helm/useframe` | Helm chart for the backend workloads on GKE; agents run only offline checks (lint/template/unittest) | -| `packages/db` | Shared Prisma client, schema, migrations, seed data, and scan cache helpers | -| `packages/schemas` | Shared Zod contracts for projects, site specs, AI outputs, research, and credits | -| `packages/events` | Shared queue names and job payload contracts | -| `packages/site-builder` | Shared generated-site construction logic | -| `packages/evals` | Evaluation runner, scorers, and datasets | -| `packages/ui` | Shared UI primitives; also check the richer app-local UI components before adding components | -| `packages/validators`, `packages/types` | Shared validation and type package entry points; verify actual exports before using | -| `packages/typescript-config`, `packages/eslint-config` | Shared compiler and lint configuration | +| Path | Responsibility | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `apps/web/app` | Active Next.js App Router routes, layouts, and global styles | +| `apps/web/src` | Feature components, API clients, hooks, auth context, Zustand stores, and previews | +| `apps/server` | Public API, authentication, ownership checks, projects, generation entry points, credits, and service clients | +| `apps/orchestrator-service` | AI planning, generation, iteration, replication, prompts, and model routing | +| `apps/research-service` | Research corpus endpoints, retrieval, fusion, and reranking | +| `apps/scoring-service` | Website scoring agents and criteria | +| `apps/billing-service` | Razorpay checkout, payment methods, webhooks, and auto-reload endpoints | +| `apps/worker` | BullMQ processors for scans, SEO, scoring, domains, PDFs, billing jobs, and cache expiry | +| `apps/deploy-service` | Site builds, R2 uploads, KV publishing (`syncSiteToKvs`), rollback, GC, reaper, reconcile; see `RUNBOOK.md` | +| `apps/site-edge` | Cloudflare Worker serving generated sites from KV + R2; deployed with Wrangler, never from CI | +| `apps/media-service` | BullMQ worker for the media library: uploads (sharp/ffmpeg), descriptions, GC, and AI media jobs; see `docs/media-ai.md` | +| `infra/terraform`, `infra/k8s` | Cloudflare sites-hosting Terraform module and GKE manifests (applied by a human, never by agents) | +| `infra/helm/useframe` | Helm chart for the backend workloads on GKE; agents run only offline checks (lint/template/unittest) | +| `packages/db` | Shared Prisma client, schema, migrations, seed data, and scan cache helpers | +| `packages/schemas` | Shared Zod contracts for projects, site specs, AI outputs, research, and credits | +| `packages/events` | Shared queue names and job payload contracts | +| `packages/site-builder` | Shared generated-site construction logic | +| `packages/media-ai` | AI media: Site Blueprint, media job logic, prompts, providers (fake by default); test fakes at `@repo/media-ai/testing` | +| `packages/evals` | Evaluation runner, scorers, and datasets | +| `packages/ui` | Shared UI primitives; also check the richer app-local UI components before adding components | +| `packages/validators`, `packages/types` | Shared validation and type package entry points; verify actual exports before using | +| `packages/typescript-config`, `packages/eslint-config` | Shared compiler and lint configuration | The frontend currently has both `src/store` and `src/stores`. Follow the relevant feature's existing location. The active routing tree is `apps/web/app`; do not create a competing routing tree under `src/app`. @@ -140,7 +141,8 @@ Run these from the repository root unless otherwise noted: | `pnpm --filter @useframe/server dev` | Start the public API (default port 4000) | | `pnpm --filter @useframe/ dev` | Start a specific service using its actual package name | | `pnpm build` | Build packages/apps that define a build task | -| `pnpm lint` | Run defined lint tasks; report script/configuration failures | +| `pnpm lint` | Lint every package with the root ESLint config through Turbo | +| `pnpm typecheck` | Type-check every package through Turbo (cached) | | `pnpm check-types` | Run defined `check-types` tasks through Turbo | | `pnpm --filter @useframe/web typecheck` | Check frontend types separately | | `pnpm --filter @useframe/server typecheck` | Check public API types separately | @@ -151,8 +153,7 @@ Run these from the repository root unless otherwise noted: | `pnpm docker:build -- ` / `docker:smoke` | Build and smoke-test production images locally; see `docker/README.md` | | `helm unittest infra/helm/useframe` | Chart unit tests (lint/kubeconform: see the chart README) | -- Root `check-types` does not include web/server's differently named `typecheck` scripts. Run these explicitly when affected. -- The web lint script is still `next lint` despite the Next.js 16 dependency. Inspect the local ESLint setup and use `pnpm --filter @useframe/web exec eslint .` when appropriate; report configuration failures rather than treating them as passes. +- Use `pnpm typecheck` (every package); root `check-types` skips web, server and site-edge. Husky pre-commit/pre-push hooks run lint and type checks; never bypass them with `--no-verify` — fix the hook instead. - There is no root `test` script. Run relevant existing tests/evaluations where available and report coverage gaps instead of inventing a passing test command. - Shared packages such as db, schemas, events, and site-builder export `dist`; rebuild them after changes before restarting consumers (`@useframe/db` runs `prisma generate` then `tsc`). A running watch process does not prove shared output is current. - Backend services build with `tsc && tsc-alias` (the `@/` alias becomes relative paths) and run in production with plain `node dist/...`; never ship `tsx` in an image. Runtime workspace packages declare `files` (normally `dist`) because `pnpm deploy` only copies published files. diff --git a/GEMINI.md b/GEMINI.md index e27ccc3..6aec892 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -30,29 +30,30 @@ Read the implementation and package manifests as the source of truth. See `READM ## Repository Structure -| Path | Responsibility | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -| `apps/web/app` | Active Next.js App Router routes, layouts, and global styles | -| `apps/web/src` | Feature components, API clients, hooks, auth context, Zustand stores, and previews | -| `apps/server` | Public API, authentication, ownership checks, projects, generation entry points, credits, and service clients | -| `apps/orchestrator-service` | AI planning, generation, iteration, replication, prompts, and model routing | -| `apps/research-service` | Research corpus endpoints, retrieval, fusion, and reranking | -| `apps/scoring-service` | Website scoring agents and criteria | -| `apps/billing-service` | Razorpay checkout, payment methods, webhooks, and auto-reload endpoints | -| `apps/worker` | BullMQ processors for scans, SEO, scoring, domains, PDFs, billing jobs, and cache expiry | -| `apps/deploy-service` | Site builds, R2 uploads, KV publishing (`syncSiteToKvs`), rollback, GC, reaper, reconcile; see `RUNBOOK.md` | -| `apps/site-edge` | Cloudflare Worker serving generated sites from KV + R2; deployed with Wrangler, never from CI | -| `apps/media-service` | BullMQ worker for the media library: upload processing (sharp/ffmpeg), vision descriptions, media GC | -| `infra/terraform`, `infra/k8s` | Cloudflare sites-hosting Terraform module and GKE manifests (applied by a human, never by agents) | -| `infra/helm/useframe` | Helm chart for the backend workloads on GKE; agents run only offline checks (lint/template/unittest) | -| `packages/db` | Shared Prisma client, schema, migrations, seed data, and scan cache helpers | -| `packages/schemas` | Shared Zod contracts for projects, site specs, AI outputs, research, and credits | -| `packages/events` | Shared queue names and job payload contracts | -| `packages/site-builder` | Shared generated-site construction logic | -| `packages/evals` | Evaluation runner, scorers, and datasets | -| `packages/ui` | Shared UI primitives; also check the richer app-local UI components before adding components | -| `packages/validators`, `packages/types` | Shared validation and type package entry points; verify actual exports before using | -| `packages/typescript-config`, `packages/eslint-config` | Shared compiler and lint configuration | +| Path | Responsibility | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `apps/web/app` | Active Next.js App Router routes, layouts, and global styles | +| `apps/web/src` | Feature components, API clients, hooks, auth context, Zustand stores, and previews | +| `apps/server` | Public API, authentication, ownership checks, projects, generation entry points, credits, and service clients | +| `apps/orchestrator-service` | AI planning, generation, iteration, replication, prompts, and model routing | +| `apps/research-service` | Research corpus endpoints, retrieval, fusion, and reranking | +| `apps/scoring-service` | Website scoring agents and criteria | +| `apps/billing-service` | Razorpay checkout, payment methods, webhooks, and auto-reload endpoints | +| `apps/worker` | BullMQ processors for scans, SEO, scoring, domains, PDFs, billing jobs, and cache expiry | +| `apps/deploy-service` | Site builds, R2 uploads, KV publishing (`syncSiteToKvs`), rollback, GC, reaper, reconcile; see `RUNBOOK.md` | +| `apps/site-edge` | Cloudflare Worker serving generated sites from KV + R2; deployed with Wrangler, never from CI | +| `apps/media-service` | BullMQ worker for the media library: uploads (sharp/ffmpeg), descriptions, GC, and AI media jobs; see `docs/media-ai.md` | +| `infra/terraform`, `infra/k8s` | Cloudflare sites-hosting Terraform module and GKE manifests (applied by a human, never by agents) | +| `infra/helm/useframe` | Helm chart for the backend workloads on GKE; agents run only offline checks (lint/template/unittest) | +| `packages/db` | Shared Prisma client, schema, migrations, seed data, and scan cache helpers | +| `packages/schemas` | Shared Zod contracts for projects, site specs, AI outputs, research, and credits | +| `packages/events` | Shared queue names and job payload contracts | +| `packages/site-builder` | Shared generated-site construction logic | +| `packages/media-ai` | AI media: Site Blueprint, media job logic, prompts, providers (fake by default); test fakes at `@repo/media-ai/testing` | +| `packages/evals` | Evaluation runner, scorers, and datasets | +| `packages/ui` | Shared UI primitives; also check the richer app-local UI components before adding components | +| `packages/validators`, `packages/types` | Shared validation and type package entry points; verify actual exports before using | +| `packages/typescript-config`, `packages/eslint-config` | Shared compiler and lint configuration | The frontend currently has both `src/store` and `src/stores`. Follow the relevant feature's existing location. The active routing tree is `apps/web/app`; do not create a competing routing tree under `src/app`. @@ -140,7 +141,8 @@ Run these from the repository root unless otherwise noted: | `pnpm --filter @useframe/server dev` | Start the public API (default port 4000) | | `pnpm --filter @useframe/ dev` | Start a specific service using its actual package name | | `pnpm build` | Build packages/apps that define a build task | -| `pnpm lint` | Run defined lint tasks; report script/configuration failures | +| `pnpm lint` | Lint every package with the root ESLint config through Turbo | +| `pnpm typecheck` | Type-check every package through Turbo (cached) | | `pnpm check-types` | Run defined `check-types` tasks through Turbo | | `pnpm --filter @useframe/web typecheck` | Check frontend types separately | | `pnpm --filter @useframe/server typecheck` | Check public API types separately | @@ -151,8 +153,7 @@ Run these from the repository root unless otherwise noted: | `pnpm docker:build -- ` / `docker:smoke` | Build and smoke-test production images locally; see `docker/README.md` | | `helm unittest infra/helm/useframe` | Chart unit tests (lint/kubeconform: see the chart README) | -- Root `check-types` does not include web/server's differently named `typecheck` scripts. Run these explicitly when affected. -- The web lint script is still `next lint` despite the Next.js 16 dependency. Inspect the local ESLint setup and use `pnpm --filter @useframe/web exec eslint .` when appropriate; report configuration failures rather than treating them as passes. +- Use `pnpm typecheck` (every package); root `check-types` skips web, server and site-edge. Husky pre-commit/pre-push hooks run lint and type checks; never bypass them with `--no-verify` — fix the hook instead. - There is no root `test` script. Run relevant existing tests/evaluations where available and report coverage gaps instead of inventing a passing test command. - Shared packages such as db, schemas, events, and site-builder export `dist`; rebuild them after changes before restarting consumers (`@useframe/db` runs `prisma generate` then `tsc`). A running watch process does not prove shared output is current. - Backend services build with `tsc && tsc-alias` (the `@/` alias becomes relative paths) and run in production with plain `node dist/...`; never ship `tsx` in an image. Runtime workspace packages declare `files` (normally `dist`) because `pnpm deploy` only copies published files. diff --git a/README.md b/README.md index dad70bd..d4591e0 100644 --- a/README.md +++ b/README.md @@ -245,7 +245,8 @@ their own WebContainer compatibility work before enabling a replacement snapshot | ------------------------------------------------ | ------------------------------------------- | | `pnpm dev` | Run workspace development tasks | | `pnpm build` | Build apps and packages with a build script | -| `pnpm lint` | Run defined lint tasks | +| `pnpm lint` | Lint every package (ESLint, through Turbo) | +| `pnpm typecheck` | Type-check every package (through Turbo) | | `pnpm check-types` | Run defined `check-types` tasks | | `pnpm --filter @useframe/web typecheck` | Check frontend types | | `pnpm --filter @useframe/server typecheck` | Check API types | @@ -261,10 +262,21 @@ For a deliberate schema change in your development database: 3. Regenerate the Prisma client. 4. Commit the schema and migration together. +## Quality checks + +- `pnpm lint` runs ESLint in every package with the single root config (`eslint.config.mjs`, built from `packages/eslint-config`). +- `pnpm typecheck` runs `tsc --noEmit` in every package with its own `tsconfig.json`. Turbo caches both, so unchanged packages are skipped. + +Git hooks (Husky, installed by `pnpm install`): + +- **pre-commit:** ESLint with `--fix` on the staged `ts`/`tsx`/`js`/`mjs`/`cjs` files only (lint-staged), then a type check of only the packages changed since `HEAD` and the packages that depend on them, then a guard that blocks `.env*` files (other than `.env.example` and `.env.*.example`) and any new file over 5 MB. +- **pre-push:** the full `pnpm lint` and `pnpm typecheck`. + +Never use `--no-verify`. If a hook is wrong, fix the hook. + ## Current tooling caveats -- **Type checks:** root `pnpm check-types` does not run web/server's differently named `typecheck` scripts. Run those separately when affected. -- **Frontend lint:** its script still uses `next lint` despite the Next.js 16 dependency. The direct command is `pnpm --filter @useframe/web exec eslint .`; the existing configuration may still need attention. +- **Type checks:** use `pnpm typecheck`, which covers every package. The older `pnpm check-types` skips web, server and site-edge. - **Database scripts:** `db:push`, `db:studio`, and `db:generate` currently point to Drizzle. Application persistence uses Prisma, so use the explicit Prisma commands above. - **Tests:** there is no root `test` script. Vitest and Testing Library dependencies are present, and `packages/evals` provides evaluation checks; these do not imply complete automated coverage. - **Shared output:** schemas, events, and site-builder export compiled `dist` files. Rebuild them after changes when starting services individually. diff --git a/apps/billing-service/package.json b/apps/billing-service/package.json index bbdd6cd..9485ace 100644 --- a/apps/billing-service/package.json +++ b/apps/billing-service/package.json @@ -10,7 +10,10 @@ "dev": "tsx --watch src/index.ts", "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "start": "node dist/index.js", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run", + "lint": "eslint .", + "typecheck": "tsc --noEmit" }, "dependencies": { "@repo/events": "workspace:*", @@ -34,6 +37,7 @@ "@useframe/typescript-config": "workspace:*", "tsc-alias": "1.9.7", "tsx": "^4.22.1", - "typescript": "5.9.2" + "typescript": "5.9.2", + "vitest": "^2.1.9" } } diff --git a/apps/billing-service/src/lib/checkout.ts b/apps/billing-service/src/lib/checkout.ts new file mode 100644 index 0000000..a26c760 --- /dev/null +++ b/apps/billing-service/src/lib/checkout.ts @@ -0,0 +1,111 @@ +import type { prisma } from "@useframe/db" +import { + CUSTOM_MAX_AMOUNT_CENTS, + CUSTOM_MIN_AMOUNT_CENTS, + creditsForCustomAmount, + type CheckoutRequest, +} from "@repo/schemas" + +// Requires International Payments enabled on the Razorpay account. +export const CUSTOM_TOPUP_CURRENCY = "USD" + +export class CheckoutError extends Error { + constructor( + public readonly status: 400 | 422, + public readonly code: string, + message: string, + ) { + super(message) + this.name = "CheckoutError" + } +} + +export type CheckoutDb = Pick + +export type CreateProviderOrder = (input: { + amountMinor: number + currency: string + notes: Record +}) => Promise<{ id: string }> + +export type CheckoutResult = { + orderId: string + amountCents: number + currency: string + credits: number + planSlug: string | null +} + +type Sale = { + amountMinor: number + currency: string + credits: number + plan: { id: string; slug: string; name: string } | null +} + +async function planSale(db: CheckoutDb, slug: string): Promise { + const plan = await db.plan.findUnique({ + where: { slug }, + select: { id: true, slug: true, name: true, priceMinor: true, currency: true, credits: true, isActive: true }, + }) + if (!plan || !plan.isActive || plan.priceMinor <= 0 || plan.credits <= 0) { + throw new CheckoutError(400, "PLAN_UNAVAILABLE", "That plan isn't available.") + } + return { + amountMinor: plan.priceMinor, + currency: plan.currency.toUpperCase(), + credits: plan.credits, + plan: { id: plan.id, slug: plan.slug, name: plan.name }, + } +} + +function customSale(amountCents: number): Sale { + if (!Number.isInteger(amountCents) || amountCents < CUSTOM_MIN_AMOUNT_CENTS) { + throw new CheckoutError(400, "BELOW_MINIMUM", `Minimum top-up is $${CUSTOM_MIN_AMOUNT_CENTS / 100}.`) + } + if (amountCents > CUSTOM_MAX_AMOUNT_CENTS) { + throw new CheckoutError(400, "ABOVE_MAXIMUM", `Maximum top-up is $${CUSTOM_MAX_AMOUNT_CENTS / 100}.`) + } + return { amountMinor: amountCents, currency: CUSTOM_TOPUP_CURRENCY, credits: creditsForCustomAmount(amountCents), plan: null } +} + +// Prices every purchase on the server: a plan from its database row, a custom +// top-up from the custom rate. The payment row keeps a snapshot of the sale. +export async function createCheckout( + deps: { db: CheckoutDb; createOrder: CreateProviderOrder }, + userId: string, + input: CheckoutRequest, +): Promise { + let sale: Sale + if (input.planSlug !== undefined) sale = await planSale(deps.db, input.planSlug) + else if (input.amountCents !== undefined) sale = customSale(input.amountCents) + else throw new CheckoutError(400, "MISSING_PLAN", "Choose a plan or enter a top-up amount.") + + const order = await deps.createOrder({ + amountMinor: sale.amountMinor, + currency: sale.currency, + notes: { userId, credits: String(sale.credits), ...(sale.plan ? { planSlug: sale.plan.slug } : {}) }, + }) + + await deps.db.payment.create({ + data: { + userId, + purpose: "CREDIT_TOPUP", + amount: sale.amountMinor, + currency: sale.currency, + status: "PENDING", + provider: "razorpay", + providerOrderId: order.id, + creditsGranted: sale.credits, + ...(sale.plan ? { planId: sale.plan.id, planSlug: sale.plan.slug, planName: sale.plan.name } : {}), + }, + }) + + return { + orderId: order.id, + amountCents: sale.amountMinor, + currency: sale.currency, + credits: sale.credits, + planSlug: sale.plan?.slug ?? null, + } +} diff --git a/apps/billing-service/src/routes/autoReload.route.ts b/apps/billing-service/src/routes/autoReload.route.ts index 4fa1fad..669795f 100644 --- a/apps/billing-service/src/routes/autoReload.route.ts +++ b/apps/billing-service/src/routes/autoReload.route.ts @@ -1,13 +1,18 @@ import { Router, type Request, type Response } from "express" import { z } from "zod" import { prisma } from "@useframe/db" +import { CUSTOM_MAX_AMOUNT_CENTS } from "@repo/schemas" const router: Router = Router() const AutoReloadSchema = z.object({ enabled: z.boolean(), thresholdCents: z.number().int().positive(), - topUpToCents: z.number().int().min(500, "Top-up amount must be at least $5"), + topUpToCents: z + .number() + .int() + .min(500, "Top-up amount must be at least $5") + .max(CUSTOM_MAX_AMOUNT_CENTS, `Top-up amount can be at most $${CUSTOM_MAX_AMOUNT_CENTS / 100}`), }) router.get("/auto-reload", (req: Request, res: Response, next) => { diff --git a/apps/billing-service/src/routes/checkout.route.ts b/apps/billing-service/src/routes/checkout.route.ts index 60be384..1076081 100644 --- a/apps/billing-service/src/routes/checkout.route.ts +++ b/apps/billing-service/src/routes/checkout.route.ts @@ -1,29 +1,14 @@ import { Router, type Request, type Response } from "express" -import { z } from "zod" import { prisma } from "@useframe/db" +import { CheckoutRequestSchema } from "@repo/schemas" import { env } from "@/config/env.js" import { razorpay } from "@/lib/razorpay.js" import { getOrCreateRazorpayCustomer } from "@/lib/customer.js" -import { - CUSTOM_MIN_AMOUNT_CENTS, - creditsForCustomAmount, - findCreditPack, -} from "@repo/schemas" +import { CheckoutError, createCheckout } from "@/lib/checkout.js" const router: Router = Router() -// Must stay aligned with the dollar-priced packs in @repo/schemas. -// Requires International Payments enabled on the Razorpay account. -export const CHECKOUT_CURRENCY = "USD" - -// Either a fixed pack (packId is the pack's amountCents) or a custom amount. -// Pack purchases ignore any amountCents sent alongside — the pack's own -// price is authoritative, so a client can't buy a pack's credits for less. -const CreateCheckoutSchema = z.object({ - packId: z.number().int().optional(), - amountCents: z.number().int().optional(), -}) - +// The body names a plan (or a custom top-up amount); the price always comes from the server. router.post("/checkout/create-order", (req: Request, res: Response, next) => { const userId = req.headers["x-user-id"] as string | undefined const userEmail = req.headers["x-user-email"] as string | undefined @@ -32,7 +17,7 @@ router.post("/checkout/create-order", (req: Request, res: Response, next) => { return } - const parsed = CreateCheckoutSchema.safeParse(req.body) + const parsed = CheckoutRequestSchema.safeParse(req.body) if (!parsed.success) { res.status(422).json({ success: false, @@ -42,74 +27,27 @@ router.post("/checkout/create-order", (req: Request, res: Response, next) => { return } - let amountCents: number - let credits: number - - if (parsed.data.packId !== undefined) { - const pack = findCreditPack(parsed.data.packId) - if (!pack) { - res.status(400).json({ success: false, error: "INVALID_PACK" }) - return - } - amountCents = pack.amountCents - credits = pack.credits - } else { - if (parsed.data.amountCents === undefined) { - res.status(400).json({ - success: false, - error: "MISSING_AMOUNT", - message: "Provide either a packId or a custom amountCents.", - }) - return - } - if (parsed.data.amountCents < CUSTOM_MIN_AMOUNT_CENTS) { - res.status(400).json({ - success: false, - error: "BELOW_MINIMUM", - message: "Minimum top-up is $10.", - }) - return - } - amountCents = parsed.data.amountCents - credits = creditsForCustomAmount(amountCents) - } - - getOrCreateRazorpayCustomer(userId, userEmail) - .then((customerId) => - razorpay.orders.create({ - amount: amountCents, - currency: CHECKOUT_CURRENCY, - customer_id: customerId, - notes: { userId, credits: String(credits) }, - }) - ) - .then((order) => - prisma.payment - .create({ - data: { - userId, - purpose: "CREDIT_TOPUP", - amount: amountCents, - currency: CHECKOUT_CURRENCY, - status: "PENDING", - provider: "razorpay", - providerOrderId: order.id, - }, - }) - .then(() => { - res.status(201).json({ - success: true, - data: { - orderId: order.id, - keyId: env.RAZORPAY_KEY_ID, - amountCents, - currency: CHECKOUT_CURRENCY, - credits, - }, - }) - }) - ) - .catch(next) + createCheckout( + { + db: prisma, + createOrder: async ({ amountMinor, currency, notes }) => { + const customerId = await getOrCreateRazorpayCustomer(userId, userEmail) + return razorpay.orders.create({ amount: amountMinor, currency, customer_id: customerId, notes }) + }, + }, + userId, + parsed.data, + ) + .then((result) => { + res.status(201).json({ success: true, data: { ...result, keyId: env.RAZORPAY_KEY_ID } }) + }) + .catch((err: unknown) => { + if (err instanceof CheckoutError) { + res.status(err.status).json({ success: false, error: err.code, code: err.code, message: err.message }) + return + } + next(err) + }) }) export default router diff --git a/apps/billing-service/test/checkout.test.ts b/apps/billing-service/test/checkout.test.ts new file mode 100644 index 0000000..7bcd4d7 --- /dev/null +++ b/apps/billing-service/test/checkout.test.ts @@ -0,0 +1,103 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { CheckoutRequestSchema } from "@repo/schemas" +import { CheckoutError, createCheckout, type CheckoutDb, type CreateProviderOrder } from "@/lib/checkout.js" + +type Row = Record + +function fakeDb() { + const plans: Row[] = [] + const payments: Row[] = [] + const db = { + plan: { + findUnique: async ({ where }: { where: { slug: string } }) => plans.find((p) => p.slug === where.slug) ?? null, + update: async ({ where, data }: { where: { slug: string }; data: Row }) => + Object.assign(plans.find((p) => p.slug === where.slug)!, data), + }, + payment: { + create: async ({ data }: { data: Row }) => { + const row = { id: `pay_${payments.length + 1}`, ...data } + payments.push(row) + return row + }, + }, + } + return { db, plans, payments } +} + +let store: ReturnType +let orders: Parameters[0][] +const createOrder: CreateProviderOrder = async (input) => { + orders.push(input) + return { id: `order_${orders.length}` } +} +const deps = () => ({ db: store.db as unknown as CheckoutDb, createOrder }) +// The route parses the body with the shared schema; tests go through it too. +const body = (raw: unknown) => CheckoutRequestSchema.parse(raw) + +beforeEach(() => { + store = fakeDb() + orders = [] + store.plans.push( + { id: "plan_starter", slug: "starter", name: "Starter", priceMinor: 4900, currency: "USD", credits: 98, isActive: true }, + { id: "plan_old", slug: "legacy", name: "Legacy", priceMinor: 2900, currency: "USD", credits: 58, isActive: false }, + ) +}) + +describe("createCheckout", () => { + it("ignores any client-sent amount, price, currency or credits for a plan", async () => { + const result = await createCheckout( + deps(), + "user_1", + body({ planSlug: "starter", amountCents: 1, price: 1, currency: "INR", credits: 999999 }), + ) + expect(orders).toEqual([{ amountMinor: 4900, currency: "USD", notes: { userId: "user_1", credits: "98", planSlug: "starter" } }]) + expect(result).toMatchObject({ amountCents: 4900, currency: "USD", credits: 98, planSlug: "starter" }) + expect(store.payments[0]).toMatchObject({ + amount: 4900, + currency: "USD", + creditsGranted: 98, + planId: "plan_starter", + planSlug: "starter", + planName: "Starter", + providerOrderId: "order_1", + status: "PENDING", + }) + }) + + it("rejects an unknown or inactive plan with PLAN_UNAVAILABLE and creates nothing", async () => { + for (const planSlug of ["nope", "legacy"]) { + await expect(createCheckout(deps(), "user_1", body({ planSlug }))).rejects.toMatchObject({ status: 400, code: "PLAN_UNAVAILABLE" }) + } + expect(orders).toHaveLength(0) + expect(store.payments).toHaveLength(0) + }) + + it("rejects a malformed plan slug at the schema", () => { + expect(CheckoutRequestSchema.safeParse({ planSlug: "Starter; DROP" }).success).toBe(false) + }) + + it("uses the new price after a price change while old payments keep their snapshot", async () => { + await createCheckout(deps(), "user_1", body({ planSlug: "starter" })) + await store.db.plan.update({ where: { slug: "starter" }, data: { priceMinor: 5900, credits: 118, name: "Starter Plus" } }) + await createCheckout(deps(), "user_1", body({ planSlug: "starter" })) + + expect(orders.map((o) => o.amountMinor)).toEqual([4900, 5900]) + expect(store.payments[0]).toMatchObject({ amount: 4900, creditsGranted: 98, planName: "Starter" }) + expect(store.payments[1]).toMatchObject({ amount: 5900, creditsGranted: 118, planName: "Starter Plus" }) + }) + + it("prices a custom top-up on the server and enforces its bounds", async () => { + const result = await createCheckout(deps(), "user_1", body({ amountCents: 2500, credits: 5000 })) + expect(result).toMatchObject({ amountCents: 2500, credits: 12, planSlug: null }) + expect(store.payments[0]).toMatchObject({ amount: 2500, creditsGranted: 12 }) + expect(store.payments[0]!.planId).toBeUndefined() + + await expect(createCheckout(deps(), "user_1", body({ amountCents: 999 }))).rejects.toMatchObject({ code: "BELOW_MINIMUM" }) + await expect(createCheckout(deps(), "user_1", body({ amountCents: 100_001 }))).rejects.toMatchObject({ code: "ABOVE_MAXIMUM" }) + expect(CheckoutRequestSchema.safeParse({ amountCents: 25.5 }).success).toBe(false) + }) + + it("requires a plan or an amount", async () => { + await expect(createCheckout(deps(), "user_1", body({}))).rejects.toBeInstanceOf(CheckoutError) + }) +}) diff --git a/apps/billing-service/vitest.config.ts b/apps/billing-service/vitest.config.ts new file mode 100644 index 0000000..ada9be0 --- /dev/null +++ b/apps/billing-service/vitest.config.ts @@ -0,0 +1,6 @@ +import { fileURLToPath } from "node:url" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } }, +}) diff --git a/apps/deploy-service/package.json b/apps/deploy-service/package.json index 62051d9..132e8c1 100644 --- a/apps/deploy-service/package.json +++ b/apps/deploy-service/package.json @@ -22,7 +22,9 @@ "deploy:unsuspend": "tsx src/cli/suspend.ts unsuspend", "spike:domains": "tsx src/cli/spikeDomains.ts", "deploy:domains:doctor": "tsx src/cli/domainsDoctor.ts", - "deploy:domain:release": "tsx src/cli/domainRelease.ts" + "deploy:domain:release": "tsx src/cli/domainRelease.ts", + "lint": "eslint .", + "typecheck": "tsc --noEmit" }, "dependencies": { "@aws-sdk/client-s3": "3.1144.0", diff --git a/apps/media-service/package.json b/apps/media-service/package.json index 93b0e29..82baae2 100644 --- a/apps/media-service/package.json +++ b/apps/media-service/package.json @@ -12,7 +12,9 @@ "start": "node dist/worker.js", "check-types": "tsc --noEmit", "test": "vitest run", - "media:gc": "tsx src/cli/gc.ts" + "media:gc": "tsx src/cli/gc.ts", + "lint": "eslint .", + "typecheck": "tsc --noEmit" }, "dependencies": { "@aws-sdk/client-s3": "3.1144.0", diff --git a/apps/orchestrator-service/package.json b/apps/orchestrator-service/package.json index 6bda0cc..7f4b4b4 100644 --- a/apps/orchestrator-service/package.json +++ b/apps/orchestrator-service/package.json @@ -11,7 +11,9 @@ "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "start": "node dist/index.js", "check-types": "tsc --noEmit", - "test:chat": "vitest run" + "test:chat": "vitest run", + "lint": "eslint .", + "typecheck": "tsc --noEmit" }, "dependencies": { "@ai-sdk/anthropic": "^1.0.0", diff --git a/apps/orchestrator-service/src/agents/chat.test.ts b/apps/orchestrator-service/src/agents/chat.test.ts index 98188b9..f375d70 100644 --- a/apps/orchestrator-service/src/agents/chat.test.ts +++ b/apps/orchestrator-service/src/agents/chat.test.ts @@ -253,9 +253,7 @@ it("provider error chunks reject instead of waiting forever for object metadata" }); it("cancellation rejects even if the provider stream never settles", async () => { vi.mocked(streamObject).mockReturnValue({ - fullStream: (async function* () { - await new Promise(() => {}); - })(), + fullStream: { [Symbol.asyncIterator]: () => ({ next: () => new Promise(() => {}) }) }, } as ReturnType); const controller = new AbortController(); const result = streamChatAnswer({ diff --git a/apps/orchestrator-service/src/routes/extract.route.ts b/apps/orchestrator-service/src/routes/extract.route.ts index 4f27db0..088b818 100644 --- a/apps/orchestrator-service/src/routes/extract.route.ts +++ b/apps/orchestrator-service/src/routes/extract.route.ts @@ -124,7 +124,7 @@ router.post( router.post( "/extract/answer", - (req: Request, res: Response, next: NextFunction): void => { + (req: Request, res: Response, _next: NextFunction): void => { try { verifyToken(req) } catch (err) { diff --git a/apps/research-service/package.json b/apps/research-service/package.json index cdbb25a..42cba3e 100644 --- a/apps/research-service/package.json +++ b/apps/research-service/package.json @@ -11,7 +11,9 @@ "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "start": "node dist/index.js", "check-types": "tsc --noEmit", - "test": "vitest run" + "test": "vitest run", + "lint": "eslint .", + "typecheck": "tsc --noEmit" }, "dependencies": { "ioredis": "^5.4.1", diff --git a/apps/scoring-service/package.json b/apps/scoring-service/package.json index f4c60c4..352dfe1 100644 --- a/apps/scoring-service/package.json +++ b/apps/scoring-service/package.json @@ -10,7 +10,9 @@ "dev": "tsx --watch src/index.ts", "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "start": "node dist/index.js", - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "lint": "eslint .", + "typecheck": "tsc --noEmit" }, "dependencies": { "@ai-sdk/anthropic": "^1.0.0", diff --git a/apps/server/package.json b/apps/server/package.json index 7690fb4..5c7be1e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -10,7 +10,7 @@ "dev": "tsx --watch src/index.ts", "build": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json", "start": "node dist/index.js", - "lint": "eslint src --ext .ts", + "lint": "eslint .", "typecheck": "tsc --noEmit", "test": "vitest run" }, diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 39e76b8..22187d2 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,6 +1,6 @@ import researchCitationsRoutes from "./modules/plan/researchCitations.routes.js"; import validationRoutes from "./modules/validation/validation.routes.js"; -import express, { Request, Response, NextFunction } from "express"; +import express, { Request, Response } from "express"; import type { Express } from "express"; import cors from "cors"; import helmet from "helmet"; @@ -15,6 +15,7 @@ import chatRoutes from "@/modules/chat/chat.routes.js"; import scoreRoutes from "@/modules/score/score.routes.js"; import seoRoutes from "@/modules/seo/seo.routes.js"; import billingRoutes from "@/modules/billing/billing.routes.js"; +import plansRoutes from "@/modules/plans/plans.routes.js"; import creditsRoutes from "@/modules/credits/credits.routes.js"; import findingsRoutes from "@/modules/findings/findings.routes.js"; import generateRoutes from "@/modules/generate/generate.routes.js"; @@ -64,6 +65,7 @@ app.use("/api/projects", projectRoutes); app.use("/api/chat", chatRoutes); app.use("/api/score", scoreRoutes); app.use("/api/seo", seoRoutes); +app.use("/api/plans", plansRoutes); app.use("/api/billing", billingRoutes); app.use("/api/credits", creditsRoutes); app.use("/api/findings", findingsRoutes); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 45f794a..713153c 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -29,7 +29,7 @@ const start = async () => { } process.once("SIGTERM", shutdown) process.once("SIGINT", shutdown) - } catch (err) { + } catch { await prisma.$disconnect() process.exit(1) } diff --git a/apps/server/src/lib/billingService.ts b/apps/server/src/lib/billingService.ts index 670a19b..0e0142e 100644 --- a/apps/server/src/lib/billingService.ts +++ b/apps/server/src/lib/billingService.ts @@ -1,7 +1,14 @@ import { env } from "@/config/env.js" import { AppError } from "@/middleware/errorHandler.js" -export type CreateCheckoutResult = { clientSecret: string; credits: number } +export type CreateCheckoutResult = { + orderId: string + keyId: string + amountCents: number + currency: string + credits: number + planSlug: string | null +} export type CreateSetupIntentResult = { clientSecret: string } export type AutoReloadSettingResult = { enabled: boolean @@ -26,12 +33,13 @@ async function billingFetch( const body = (await res.json().catch(() => null)) as | { success: true; data: T } - | { success: false; message: string } + | { success: false; message: string; code?: string } | null if (!res.ok || !body?.success) { const message = body && "message" in body ? body.message : "Billing service request failed" - throw new AppError(message, res.status >= 400 && res.status < 500 ? res.status : 502) + const code = body && "code" in body ? body.code : undefined + throw new AppError(message, res.status >= 400 && res.status < 500 ? res.status : 502, code) } return body.data @@ -39,7 +47,7 @@ async function billingFetch( export function callCreateCheckout( user: { id: string; email: string }, - input: { packId?: number; amountCents?: number } + input: { planSlug: string } | { amountCents?: number } ): Promise { return billingFetch(user, "/checkout/create-order", { method: "POST", diff --git a/apps/server/src/middleware/errorHandler.ts b/apps/server/src/middleware/errorHandler.ts index 1a22074..95a3e8d 100644 --- a/apps/server/src/middleware/errorHandler.ts +++ b/apps/server/src/middleware/errorHandler.ts @@ -19,7 +19,7 @@ export const errorHandler = ( err: Error, req: Request, res: Response, - next: NextFunction + _next: NextFunction ): void => { if (err instanceof AppError) { // only log real failures, not expected 401s @@ -48,7 +48,7 @@ export const errorHandler = ( console.error(`[${new Date().toISOString()}] ${err.name}: ${err.message}`) - if ((err as any).code === "P2002") { + if ((err as { code?: unknown }).code === "P2002") { res.status(409).json({ success: false, message: "Already exists" }) return } diff --git a/apps/server/src/modules/auth/auth.controller.ts b/apps/server/src/modules/auth/auth.controller.ts index 2a1b0ab..4f6e309 100644 --- a/apps/server/src/modules/auth/auth.controller.ts +++ b/apps/server/src/modules/auth/auth.controller.ts @@ -25,7 +25,7 @@ export const AuthController = { res.redirect(url) }, - googleCallback: async (req: Request, res: Response, next: NextFunction): Promise => { + googleCallback: async (req: Request, res: Response, _next: NextFunction): Promise => { try { const code = req.query.code as string | undefined const state = req.query.state as string | undefined @@ -59,7 +59,7 @@ export const AuthController = { res.redirect(url) }, - githubCallback: async (req: Request, res: Response, next: NextFunction): Promise => { + githubCallback: async (req: Request, res: Response, _next: NextFunction): Promise => { try { const code = req.query.code as string | undefined const state = req.query.state as string | undefined diff --git a/apps/server/src/modules/billing/billing.controller.ts b/apps/server/src/modules/billing/billing.controller.ts index baee42d..ba768ab 100644 --- a/apps/server/src/modules/billing/billing.controller.ts +++ b/apps/server/src/modules/billing/billing.controller.ts @@ -10,8 +10,8 @@ export const BillingController = { next: NextFunction ): Promise => { try { - const { packId, amountCents } = req.body as CheckoutInput - const result = await BillingService.createCheckout(req.user!, { packId, amountCents }) + const { planSlug, amountCents } = req.body as CheckoutInput + const result = await BillingService.createCheckout(req.user!, planSlug !== undefined ? { planSlug } : { amountCents }) res.status(201).json({ success: true, data: result }) } catch (err) { next(err) diff --git a/apps/server/src/modules/billing/billing.schema.ts b/apps/server/src/modules/billing/billing.schema.ts index 4895b46..2e892ad 100644 --- a/apps/server/src/modules/billing/billing.schema.ts +++ b/apps/server/src/modules/billing/billing.schema.ts @@ -1,20 +1,19 @@ import { z } from "zod" +import { CUSTOM_MAX_AMOUNT_CENTS, CheckoutRequestSchema, type CheckoutRequest } from "@repo/schemas" -export const CheckoutSchema = z.object({ - // Either a fixed pack (packId = the pack's amountCents) or a custom - // amount. billing-service is the authority on both the pack table and the - // custom minimum, so this layer just forwards rather than duplicating the - // pricing rules. - packId: z.number().int().optional(), - amountCents: z.number().int().optional(), -}) +// A plan slug or a custom top-up amount; billing-service prices both from the server side. +export const CheckoutSchema = CheckoutRequestSchema -export type CheckoutInput = z.infer +export type CheckoutInput = CheckoutRequest export const AutoReloadSchema = z.object({ enabled: z.boolean(), thresholdCents: z.number().int().positive(), - topUpToCents: z.number().int().min(500, "Top-up amount must be at least $5"), + topUpToCents: z + .number() + .int() + .min(500, "Top-up amount must be at least $5") + .max(CUSTOM_MAX_AMOUNT_CENTS, `Top-up amount can be at most $${CUSTOM_MAX_AMOUNT_CENTS / 100}`), }) export type AutoReloadInput = z.infer diff --git a/apps/server/src/modules/billing/billing.service.ts b/apps/server/src/modules/billing/billing.service.ts index 0727826..6a1469c 100644 --- a/apps/server/src/modules/billing/billing.service.ts +++ b/apps/server/src/modules/billing/billing.service.ts @@ -4,10 +4,10 @@ import { callGetAutoReload, callPutAutoReload, } from "@/lib/billingService.js" -import type { AutoReloadInput } from "./billing.schema.js" +import type { AutoReloadInput, CheckoutInput } from "./billing.schema.js" export const BillingService = { - createCheckout(user: { id: string; email: string }, input: { packId?: number; amountCents?: number }) { + createCheckout(user: { id: string; email: string }, input: CheckoutInput) { return callCreateCheckout(user, input) }, diff --git a/apps/server/src/modules/messages/messages.service.ts b/apps/server/src/modules/messages/messages.service.ts index 903c1f3..2fa1872 100644 --- a/apps/server/src/modules/messages/messages.service.ts +++ b/apps/server/src/modules/messages/messages.service.ts @@ -50,7 +50,7 @@ export const MessagesService = { ) if (!hasCredits) throw new AppError("Insufficient credits", 402) - const userMessage = await prisma.message.create({ + await prisma.message.create({ data: { conversationId: conversation.id, role: "USER", diff --git a/apps/server/src/modules/plans/plans.controller.ts b/apps/server/src/modules/plans/plans.controller.ts new file mode 100644 index 0000000..69e575f --- /dev/null +++ b/apps/server/src/modules/plans/plans.controller.ts @@ -0,0 +1,14 @@ +import type { Request, Response, NextFunction } from "express" +import { PlansService } from "./plans.service.js" + +export const PlansController = { + list: async (_req: Request, res: Response, next: NextFunction): Promise => { + try { + const plans = await PlansService.listActive() + res.setHeader("Cache-Control", "public, max-age=60") + res.status(200).json({ success: true, data: plans }) + } catch (err) { + next(err) + } + }, +} diff --git a/apps/server/src/modules/plans/plans.routes.ts b/apps/server/src/modules/plans/plans.routes.ts new file mode 100644 index 0000000..446bcec --- /dev/null +++ b/apps/server/src/modules/plans/plans.routes.ts @@ -0,0 +1,9 @@ +import { Router } from "express" +import { PlansController } from "./plans.controller.js" + +// Public: the pricing page renders before sign-in. +const router: Router = Router() + +router.get("/", PlansController.list) + +export default router diff --git a/apps/server/src/modules/plans/plans.service.test.ts b/apps/server/src/modules/plans/plans.service.test.ts new file mode 100644 index 0000000..bfdd3d7 --- /dev/null +++ b/apps/server/src/modules/plans/plans.service.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import type { Request, Response } from "express" + +type PlanRow = { + slug: string + name: string + description: string | null + priceMinor: number + currency: string + credits: number + features: unknown + highlighted: boolean + isActive: boolean + rank: number +} + +const m = vi.hoisted(() => ({ plans: [] as PlanRow[] })) + +// Honours the where/orderBy/select the service sends, so a missing filter fails the test. +vi.mock("@useframe/db", () => ({ + prisma: { + plan: { + findMany: async ({ where, orderBy, select }: { where: Partial; orderBy: Record[]; select: Record }) => + m.plans + .filter((p) => Object.entries(where).every(([k, v]) => p[k as keyof PlanRow] === v)) + .sort((a, b) => { + for (const o of orderBy) { + const [k] = Object.keys(o) as (keyof PlanRow)[] + if (a[k!] !== b[k!]) return (a[k!] as number) - (b[k!] as number) + } + return 0 + }) + .map((p) => Object.fromEntries(Object.keys(select).map((k) => [k, p[k as keyof PlanRow]]))), + }, + }, +})) + +const { PlansService } = await import("./plans.service.js") +const { PlansController } = await import("./plans.controller.js") + +const plan = (overrides: Partial): PlanRow => ({ + slug: "starter", + name: "Starter", + description: "One real site", + priceMinor: 4900, + currency: "USD", + credits: 98, + features: ["A", "B"], + highlighted: false, + isActive: true, + rank: 1, + ...overrides, +}) + +beforeEach(() => { + m.plans = [ + plan({ slug: "agency", name: "Agency", priceMinor: 19900, rank: 3 }), + plan({ slug: "retired", name: "Retired", priceMinor: 100, rank: 0, isActive: false }), + plan({ slug: "starter", rank: 1 }), + plan({ slug: "studio", name: "Studio", priceMinor: 9900, rank: 2, highlighted: true, features: ["x", 3, null] }), + ] +}) + +describe("GET /api/plans", () => { + it("returns active plans only, in display order, without internal fields", async () => { + const plans = await PlansService.listActive() + expect(plans.map((p) => p.slug)).toEqual(["starter", "studio", "agency"]) + expect(plans.some((p) => p.slug === "retired")).toBe(false) + expect(Object.keys(plans[0]!).sort()).toEqual(["currency", "description", "features", "highlighted", "name", "priceMinor", "slug"]) + expect(plans[1]!.features).toEqual(["x"]) + }) + + it("sets a short public cache", async () => { + const headers: Record = {} + let body: unknown + const res = { + setHeader: (k: string, v: string) => void (headers[k] = v), + status: () => res, + json: (b: unknown) => void (body = b), + } as unknown as Response + await PlansController.list({} as Request, res, vi.fn()) + expect(headers["Cache-Control"]).toBe("public, max-age=60") + expect((body as { data: { slug: string }[] }).data.map((p) => p.slug)).not.toContain("retired") + }) +}) diff --git a/apps/server/src/modules/plans/plans.service.ts b/apps/server/src/modules/plans/plans.service.ts new file mode 100644 index 0000000..76a031e --- /dev/null +++ b/apps/server/src/modules/plans/plans.service.ts @@ -0,0 +1,15 @@ +import { prisma } from "@useframe/db" +import type { PublicPlan } from "@repo/schemas" + +const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []) + +export const PlansService = { + async listActive(): Promise { + const plans = await prisma.plan.findMany({ + where: { isActive: true }, + orderBy: [{ rank: "asc" }, { priceMinor: "asc" }], + select: { slug: true, name: true, description: true, priceMinor: true, currency: true, features: true, highlighted: true }, + }) + return plans.map((p) => ({ ...p, currency: p.currency.toUpperCase(), features: strings(p.features) })) + }, +} diff --git a/apps/server/src/modules/replicate/replicateMessages.service.ts b/apps/server/src/modules/replicate/replicateMessages.service.ts index 152ab99..02cccbc 100644 --- a/apps/server/src/modules/replicate/replicateMessages.service.ts +++ b/apps/server/src/modules/replicate/replicateMessages.service.ts @@ -6,7 +6,7 @@ export const ReplicateMessagesService = { async sendMessage( user: { id: string; email: string; plan: string }, slug: string, - input: ReplicateMessageInput + _input: ReplicateMessageInput ) { const replication = await prisma.replication.findFirst({ where: { slug, userId: user.id }, diff --git a/apps/site-edge/package.json b/apps/site-edge/package.json index 7be325f..8e1251c 100644 --- a/apps/site-edge/package.json +++ b/apps/site-edge/package.json @@ -8,7 +8,8 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "deploy": "node scripts/check-config.mjs && wrangler deploy", - "deploy:dry": "wrangler deploy --dry-run --outdir dist" + "deploy:dry": "wrangler deploy --dry-run --outdir dist", + "lint": "eslint ." }, "devDependencies": { "@cloudflare/vitest-pool-workers": "0.22.0", diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js deleted file mode 100644 index 47b0670..0000000 --- a/apps/web/eslint.config.js +++ /dev/null @@ -1,4 +0,0 @@ -import { nextJsConfig } from "@repo/eslint-config/next-js"; - -/** @type {import("eslint").Linter.Config[]} */ -export default nextJsConfig; diff --git a/apps/web/package.json b/apps/web/package.json index c01f6d2..b3d6a4b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,7 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "eslint .", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -70,4 +70,4 @@ "typescript": "^5.7.2", "vitest": "^2.1.8" } -} \ No newline at end of file +} diff --git a/apps/web/src/components/billing/BillingView.tsx b/apps/web/src/components/billing/BillingView.tsx index 019136f..ca6b3b6 100644 --- a/apps/web/src/components/billing/BillingView.tsx +++ b/apps/web/src/components/billing/BillingView.tsx @@ -8,54 +8,19 @@ import { billingApi } from "@/lib/api/services/billing.service"; import { serif } from "@/components/home/fonts"; import { CrossLineBackground } from "@/components/web-score/CrossLineBackground"; import { - CREDIT_PACKS, + CUSTOM_MAX_AMOUNT_CENTS, CUSTOM_MIN_AMOUNT_CENTS, creditsForCustomAmount, - findCreditPack, } from "@repo/schemas"; +import { formatPlanPrice, plansApi, PLANS_QUERY_KEY } from "@/lib/api/services/plans.service"; -// Fixed packs plus a custom-amount path. CREDIT_PACKS and the custom rate -// are imported from @repo/schemas — the same source billing-service's -// checkout validates against — so what's shown here is what checkout grants. -function formatRate(amountCents: number, credits: number): string { - return `$${(amountCents / 100 / credits).toFixed(2)}/credit`; -} - -type PackTier = { - name: string; - tagline: string; - amountCents: number; - credits: number; - rate: string; - gradient: string; - featured?: boolean; -}; - -const PACK_PRESENTATION: Record = { - 1900: { - name: "Starter", - tagline: "One real site, fully iterated", - gradient: "from-sky-400 via-blue-400 to-blue-600", - }, - 4900: { - name: "Builder", - tagline: "A couple of sites, room to explore", - gradient: "from-blue-500 via-blue-600 to-indigo-700", - }, - 9900: { - name: "Studio", - tagline: "Best rate, for shipping regularly", - gradient: "from-indigo-500 via-blue-700 to-slate-800", - featured: true, - }, -}; - -const PACK_TIERS: PackTier[] = CREDIT_PACKS.map((pack) => ({ - ...PACK_PRESENTATION[pack.amountCents]!, - amountCents: pack.amountCents, - credits: pack.credits, - rate: formatRate(pack.amountCents, pack.credits), -})); +// Plans and their prices come from GET /api/plans, and checkout prices them again on +// the server. Only the custom top-up preview uses the shared custom rate. +const PLAN_GRADIENTS = [ + "from-sky-400 via-blue-400 to-blue-600", + "from-blue-500 via-blue-600 to-indigo-700", + "from-indigo-500 via-blue-700 to-slate-800", +]; const THRESHOLD_OPTIONS_CENTS = [500, 1000, 1500, 2500]; const TOPUP_OPTIONS_CENTS = [1000, 1500, 2500, 5000]; @@ -76,7 +41,8 @@ export default function BillingView() { queryClient.invalidateQueries({ queryKey: ["credits-summary"] }); }, [queryClient]); - const [selectedAmount, setSelectedAmount] = useState(1900); + const plans = useQuery({ queryKey: PLANS_QUERY_KEY, queryFn: plansApi.list, staleTime: 60_000 }); + const [selectedSlug, setSelectedSlug] = useState(null); const [customAmount, setCustomAmount] = useState(""); const [checkoutCredits, setCheckoutCredits] = useState(0); const [checkoutSuccess, setCheckoutSuccess] = useState(false); @@ -102,28 +68,32 @@ export default function BillingView() { setAutoReloadSynced(true); }, [summary, autoReloadSynced]); - // A pack purchase is identified by packId; a typed custom amount always - // prices at the custom rate even if it happens to equal a pack's price, - // matching how checkout validates it server-side. const isCustom = customAmount.length > 0; - const amountCents = isCustom ? Math.round(Number(customAmount) * 100) : selectedAmount; - const selectedPack = isCustom ? undefined : findCreditPack(selectedAmount); - const liveCredits = - amountCents > 0 - ? (selectedPack?.credits ?? creditsForCustomAmount(amountCents)) - : 0; + const customCents = isCustom ? Math.round(Number(customAmount) * 100) : 0; + const selectedPlan = isCustom + ? undefined + : (plans.data?.find((p) => p.slug === selectedSlug) ?? plans.data?.find((p) => p.highlighted) ?? plans.data?.[0]); + const customCredits = customCents > 0 ? creditsForCustomAmount(customCents) : 0; + const canCheckout = isCustom + ? customCents >= CUSTOM_MIN_AMOUNT_CENTS && customCents <= CUSTOM_MAX_AMOUNT_CENTS + : !!selectedPlan; const handleStartCheckout = useCallback(async () => { - if (isCustom && amountCents < CUSTOM_MIN_AMOUNT_CENTS) { - setCheckoutError("Minimum top-up is $10"); + if (isCustom && customCents < CUSTOM_MIN_AMOUNT_CENTS) { + setCheckoutError(`Minimum top-up is ${formatDollars(CUSTOM_MIN_AMOUNT_CENTS)}`); + return; + } + if (isCustom && customCents > CUSTOM_MAX_AMOUNT_CENTS) { + setCheckoutError(`Maximum top-up is ${formatDollars(CUSTOM_MAX_AMOUNT_CENTS)}`); return; } + if (!isCustom && !selectedPlan) return; setStartingCheckout(true); setCheckoutError(null); setCheckoutSuccess(false); try { const order = await billingApi.createCheckout( - isCustom ? { amountCents } : { packId: selectedAmount }, + isCustom ? { amountCents: customCents } : { planSlug: selectedPlan!.slug }, ); setCheckoutCredits(order.credits); @@ -150,7 +120,7 @@ export default function BillingView() { } finally { setStartingCheckout(false); } - }, [amountCents, isCustom, selectedAmount, invalidateSummary]); + }, [customCents, isCustom, selectedPlan, invalidateSummary]); const handleStartAddCard = useCallback(async () => { setStartingAddCard(true); @@ -253,29 +223,54 @@ export default function BillingView() { {( <> -
- {PACK_TIERS.map((tier) => { - const isSelected = !customAmount && selectedAmount === tier.amountCents; + {plans.isError ? ( +
+

We couldn't load plans right now.

+ +
+ ) : ( +
+ {plans.isLoading && + [0, 1, 2].map((i) => ( +
+
+
+
+
+
+
+
+ ))} + {plans.data?.map((plan, index) => { + const isSelected = !customAmount && selectedPlan?.slug === plan.slug; return (
diff --git a/apps/web/src/components/board/BoardTopBar.tsx b/apps/web/src/components/board/BoardTopBar.tsx index 5814f6f..278f0db 100644 --- a/apps/web/src/components/board/BoardTopBar.tsx +++ b/apps/web/src/components/board/BoardTopBar.tsx @@ -2,7 +2,7 @@ import { memo } from "react"; import type { Tool } from "./types"; -import { MIN_ZOOM, MAX_ZOOM, TOOLS } from "./boardConstants"; +import { MIN_ZOOM, MAX_ZOOM } from "./boardConstants"; interface Props { zoom: number; @@ -62,12 +62,10 @@ function TextBtn({ onClick, children, active }: { } export const BoardTopBar = memo(function BoardTopBar({ - zoom, showGrid, shapeCount, selectedCount, - canUndo, canRedo, activeTool, + zoom, showGrid, shapeCount, + canUndo, canRedo, onZoom, onToggleGrid, onUndo, onRedo, onClear, }: Props) { - const toolLabel = TOOLS.find(t => t.id === activeTool)?.label ?? ""; - return (
observer.disconnect(); }, []); - const [fill, setFill] = useState("#FFFFFF"); - const [stroke, setStroke] = useState("#1A1915"); - const [strokeWidth, setStrokeWidth] = useState(2); - const [opacity, setOpacity] = useState(1); - const [fontSize, setFontSize] = useState(18); + const [fill] = useState("#FFFFFF"); + const [stroke] = useState("#1A1915"); + const [strokeWidth] = useState(2); + const [opacity] = useState(1); + const [fontSize] = useState(18); const [stickyColor, setStickyColor] = useState(STICKY_COLORS[0]); const drawing = useRef(false); diff --git a/apps/web/src/components/board/PropertiesPanel.tsx b/apps/web/src/components/board/PropertiesPanel.tsx index e5cbce0..7ef8733 100644 --- a/apps/web/src/components/board/PropertiesPanel.tsx +++ b/apps/web/src/components/board/PropertiesPanel.tsx @@ -15,7 +15,7 @@ interface Props { } export const PropertiesPanel = memo(function PropertiesPanel({ - selShape, shapes, selected, onApply, onDelete, onDuplicate, + selShape, onApply, onDelete, onDuplicate, }: Props) { const handleDuplicate = useCallback(() => { const clone: Shape = { ...selShape, id: uid(), x: selShape.x + 20, y: selShape.y + 20 }; diff --git a/apps/web/src/components/brief/BriefFieldInput.tsx b/apps/web/src/components/brief/BriefFieldInput.tsx index 445c5f9..5df625b 100644 --- a/apps/web/src/components/brief/BriefFieldInput.tsx +++ b/apps/web/src/components/brief/BriefFieldInput.tsx @@ -394,7 +394,7 @@ export function BriefFieldInput(props: FieldRenderProps) { update({ ...item, note: optional(v) })} disabled={disabled} />
)} @@ -494,7 +494,7 @@ export function BriefFieldInput(props: FieldRenderProps) {
update({ ...plan, interval: interval as typeof plan.interval })} disabled={disabled} /> - What's included (one per line, up to 8) + What's included (one per line, up to 8)