diff --git a/.dev.vars.example b/.dev.vars.example new file mode 100644 index 0000000..d9da6aa --- /dev/null +++ b/.dev.vars.example @@ -0,0 +1,50 @@ +# Copy this file to .dev.vars (gitignored) and fill in real values for local +# development. Never commit .dev.vars or real credentials. +# +# See docs/google-calendar-setup.md for how to create these in Google Cloud. + +GOOGLE_CLIENT_SECRET= + +# Base64-encoded 32-byte key used to encrypt OAuth tokens at rest. +# Generate one with: openssl rand -base64 32 +TOKEN_ENCRYPTION_KEY= + +# Phase 9.2 notification delivery — Resend (email) and Twilio (SMS). Leaving +# any of these blank is safe: the dispatcher treats an unconfigured channel +# as a normal, retryable "provider_not_configured" outcome, never a crash. +# RESEND_FROM_ADDRESS is NOT a secret — set it in wrangler.toml's [vars] +# instead, see the comment there. +RESEND_API_KEY= +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_FROM_NUMBER= + +# Phase 10.1 — Google Maps Platform Geocoding API. Only used when +# wrangler.toml's GEOCODING_PROVIDER var is set to "google" (default +# "none" — leave that alone unless you actually want real geocoding). +# Leaving this blank while GEOCODING_PROVIDER=google is a safe, disclosed +# configuration failure (geocode attempts persist as "pending", never a +# crash) — see src/server/google-geocoding.ts. Restrict this key in Google +# Cloud Console to the Geocoding API only — it is server-side only (never +# referrer-restricted), so its API restriction is the sole thing limiting +# what it can do if it ever leaked. +GOOGLE_MAPS_API_KEY= + +# Phase 10.4 — Google Routes API (Compute Routes). Only used when +# wrangler.toml's ROUTING_PROVIDER var is set to "google" (default "none" +# — leave that alone unless you actually want real travel-time data). +# Leaving this blank while ROUTING_PROVIDER=google is a safe, disclosed +# configuration failure (every leg reports travel time unavailable, never +# a crash) — see src/server/google-routing.ts. Deliberately a SEPARATE key +# from GOOGLE_MAPS_API_KEY above — restrict this one in Google Cloud +# Console to the Routes API only, never reuse the geocoding key here. +GOOGLE_ROUTES_API_KEY= + +# Phase 13B — gates the local/mock online payment flow (Section 34: +# "Online Pay gracefully unavailable" when unset — every other financial +# workflow, including manual/on-site payments, works with this blank). +# Used to HMAC-sign/verify the mock payment provider's webhook events — +# generate one with: openssl rand -base64 32. Not a real payment +# processor credential (no production provider is configured by this +# phase) — see src/server/payment-provider.ts. +MOCK_PAYMENT_WEBHOOK_SECRET= diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..0b23d7d --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,109 @@ +name: Quality + +on: + pull_request: + push: + branches: + - main + - master + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: corepack pnpm install --frozen-lockfile + + - name: Typecheck + run: corepack pnpm run typecheck + + - name: Test + run: corepack pnpm run test + + - name: Lint + run: corepack pnpm run lint + + - name: Build + run: corepack pnpm run build + + verify-web: + runs-on: ubuntu-latest + timeout-minutes: 15 + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: open_fieldservice_ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/open_fieldservice_ci + AUTH_SECRET: ci-only-non-production-secret-at-least-32-chars + APP_URL: http://localhost:3000 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: corepack pnpm install --frozen-lockfile + + - name: Typecheck + working-directory: apps/web + run: corepack pnpm run typecheck + + - name: Lint + working-directory: apps/web + run: corepack pnpm run lint + + - name: Migrate database + working-directory: apps/web + run: corepack pnpm exec drizzle-kit migrate + + - name: Unit tests + working-directory: apps/web + run: corepack pnpm run test + + - name: PostgreSQL integration tests + working-directory: apps/web + run: corepack pnpm run test:integration + + - name: Build + working-directory: apps/web + run: corepack pnpm run build + + - name: Drizzle drift check + working-directory: apps/web + run: | + before=$(find src/db/migrations -maxdepth 1 -name '*.sql' | wc -l) + corepack pnpm exec drizzle-kit generate + after=$(find src/db/migrations -maxdepth 1 -name '*.sql' | wc -l) + if [ "$before" != "$after" ]; then + echo "Drizzle schema drift detected: schema.ts changed without a generated/committed migration" + exit 1 + fi diff --git a/.gitignore b/.gitignore index 933db4b..415d613 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,15 @@ node_modules/ +.next/ +*.tsbuildinfo +.env*.local +.postgres-test/ dist/ data.db data.db-shm data.db-wal -docs/ +.wrangler/ +coverage/ +.dev.vars +.dev-server.stdout.log +.dev-server.stderr.log +.serena/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..60ade1a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.19.0 diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..19e8964 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,11 @@ +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/open_fieldservice +AUTH_SECRET=replace-with-at-least-32-random-characters +APP_URL=http://localhost:3000 +SEED_ADMIN_PASSWORD=development-password-change-me +R2_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET=open-fieldservice-media +R2_ENDPOINT=https://replace-me.r2.cloudflarestorage.com +MAINTENANCE_SCHEDULER_SECRET= +PHONE_OPERATIONS_ENCRYPTION_KEY= diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md new file mode 100644 index 0000000..643577d --- /dev/null +++ b/apps/web/AGENTS.md @@ -0,0 +1,9 @@ + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/apps/web/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..97f6d2c --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,19 @@ +# Open Fieldservice Next.js application + +This is the side-by-side migration target. The legacy Preact/Worker application remains the source of truth until individual modules pass parity gates. + +## Local setup + +1. Copy `.env.example` to `.env.local` and use development-only values. +2. Create a PostgreSQL database. +3. From this directory, run `pnpm drizzle-kit generate` and `pnpm drizzle-kit migrate`. +4. Run `pnpm dev`. + +R2 variables are optional until storage features are exercised, but must be supplied as a complete group. + +## Verification + +- `pnpm typecheck`, `pnpm lint`, and `pnpm test` run the standard checks. +- `pnpm test:integration` runs the PostgreSQL suite against `DATABASE_URL`. +- On this Windows development host, `powershell -ExecutionPolicy Bypass -File scripts/run-postgres-integration.ps1` creates a disposable PostgreSQL 17 cluster on port 55432, recreates the test database, runs the integration suite, and stops the cluster. +- `pnpm db:seed` adds synthetic demonstration records and requires `SEED_ADMIN_PASSWORD`; it never reads legacy D1 data. diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..8582e84 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { "css": "src/app/globals.css", "baseColor": "slate", "cssVariables": true }, + "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, + "iconLibrary": "lucide" +} diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts new file mode 100644 index 0000000..db379f9 --- /dev/null +++ b/apps/web/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "drizzle-kit"; + +if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL is required to run Drizzle commands"); +} + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/db/schema/index.ts", + out: "./src/db/migrations", + dbCredentials: { url: process.env.DATABASE_URL }, + strict: true, + verbose: true, +}); diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..6407a84 --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,9 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTypeScript from "eslint-config-next/typescript"; + +export default defineConfig([ + ...nextVitals, + ...nextTypeScript, + globalIgnores([".next/**", "next-env.d.ts"]), +]); diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..ce4e94a --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..ac55783 --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,11 @@ +import type { NextConfig } from "next"; +import { fileURLToPath } from "node:url"; + +const nextConfig: NextConfig = { + reactStrictMode: true, + serverExternalPackages: ["@react-pdf/renderer", "bcryptjs", "sharp"], + experimental: { serverActions: { bodySizeLimit: "16mb" } }, + turbopack: { root: fileURLToPath(new URL("../..", import.meta.url)) }, +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..d583b40 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,56 @@ +{ + "name": "@open-fieldservice/web", + "version": "0.1.0", + "private": true, + "engines": { + "node": ">=24.0.0 <25.0.0", + "pnpm": "11.21.0" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "test": "vitest run", + "test:integration": "vitest run --config vitest.integration.config.ts", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:seed": "tsx --conditions=react-server src/db/seed.ts" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.0.0", + "@aws-sdk/s3-request-presigner": "^3.0.0", + "@base-ui/react": "^1.0.0", + "@react-pdf/renderer": "^4.0.0", + "bcryptjs": "^3.0.0", + "drizzle-orm": "^0.44.0", + "lucide-react": "^0.500.0", + "next": "^16.0.0", + "next-auth": "5.0.0-beta.30", + "next-themes": "^0.4.0", + "postgres": "^3.4.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "server-only": "^0.0.1", + "sharp": "^0.34.0", + "sonner": "^2.0.0", + "tw-animate-css": "^1.0.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "@types/bcryptjs": "^2.4.0", + "@types/node": "^24.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "drizzle-kit": "^0.31.0", + "eslint": "^9.0.0", + "eslint-config-next": "^16.0.0", + "fake-indexeddb": "^6.2.5", + "tailwindcss": "^4.0.0", + "tsx": "^4.20.0", + "typescript": "^5.7.0", + "vitest": "^4.0.0" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..999cd7b --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,7 @@ +const postcssConfig = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default postcssConfig; diff --git a/apps/web/scripts/run-postgres-integration.ps1 b/apps/web/scripts/run-postgres-integration.ps1 new file mode 100644 index 0000000..73755be --- /dev/null +++ b/apps/web/scripts/run-postgres-integration.ps1 @@ -0,0 +1,31 @@ +$ErrorActionPreference = "Stop" + +$postgresBin = "C:\Program Files\PostgreSQL\17\bin" +$cluster = Join-Path $PSScriptRoot "..\.postgres-test" +$data = Join-Path $cluster "data" +$log = Join-Path $cluster "postgres.log" +$port = 55432 + +if (-not (Test-Path (Join-Path $postgresBin "initdb.exe"))) { + throw "PostgreSQL 17 tools were not found. Set DATABASE_URL and run pnpm test:integration directly." +} + +New-Item -ItemType Directory -Force -Path $cluster | Out-Null +if (-not (Test-Path (Join-Path $data "PG_VERSION"))) { + & (Join-Path $postgresBin "initdb.exe") -D $data -U postgres --auth=trust --encoding=UTF8 --no-locale + if ($LASTEXITCODE -ne 0) { throw "initdb failed" } +} + +try { + & (Join-Path $postgresBin "pg_ctl.exe") -D $data -l $log -o "-p $port -h 127.0.0.1" -w start + if ($LASTEXITCODE -ne 0) { throw "PostgreSQL test cluster failed to start" } + & (Join-Path $postgresBin "dropdb.exe") -h 127.0.0.1 -p $port -U postgres --if-exists open_fieldservice_test + & (Join-Path $postgresBin "createdb.exe") -h 127.0.0.1 -p $port -U postgres open_fieldservice_test + $env:DATABASE_URL = "postgresql://postgres@127.0.0.1:$port/open_fieldservice_test" + $env:AUTH_SECRET = "integration-test-secret-at-least-32-characters" + pnpm.cmd test:integration + if ($LASTEXITCODE -ne 0) { throw "PostgreSQL integration tests failed" } +} +finally { + & (Join-Path $postgresBin "pg_ctl.exe") -D $data -m fast -w stop +} diff --git a/apps/web/src/app/(auth)/login/actions.ts b/apps/web/src/app/(auth)/login/actions.ts new file mode 100644 index 0000000..9a2cbe1 --- /dev/null +++ b/apps/web/src/app/(auth)/login/actions.ts @@ -0,0 +1,6 @@ +"use server"; +import { signIn } from "@/auth/auth"; + +export async function loginAction(formData: FormData): Promise { + await signIn("credentials", { email: formData.get("email"), password: formData.get("password"), organizationSlug: formData.get("organizationSlug"), redirectTo: "/customers" }); +} diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..5a7867e --- /dev/null +++ b/apps/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,19 @@ +import { loginAction } from "./actions"; + +export default function LoginPage() { + return ( +
+
+

OPEN FIELDSERVICE

+

Sign in

+

Sign in to an organization workspace.

+
+ + + + +
+
+
+ ); +} diff --git a/apps/web/src/app/(dashboard)/contracts/[id]/page.tsx b/apps/web/src/app/(dashboard)/contracts/[id]/page.tsx new file mode 100644 index 0000000..fc92612 --- /dev/null +++ b/apps/web/src/app/(dashboard)/contracts/[id]/page.tsx @@ -0,0 +1,8 @@ +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { currentActor } from "@/auth/current-actor"; +import { ApplicationError } from "@/lib/errors"; +import { ContractService } from "@/modules/contracts"; +import { addContractSignerAction, reviseContractAction, revokeSigningRequestAction, sendContractAction, voidContractAction } from "../actions"; +export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ token?: string }> }) { const actor = await currentActor(); if (!actor) redirect("/login"); const { id } = await params, { token } = await searchParams; let view; try { view = await new ContractService().get(actor, id); } catch (error) { if (error instanceof ApplicationError) notFound(); throw error; } const commercial = JSON.parse(view.version.commercialSnapshot) as { quoteIdentifier: string; optionName: string; totalCents: number }; return

{view.contract.identifier}

{view.version.title}

{view.contract.status} · Version {view.version.versionNumber} · {commercial.quoteIdentifier} / {commercial.optionName} · ${(commercial.totalCents / 100).toFixed(2)}

{token ? : null}
{view.version.body || "No additional terms."}

Signers

{view.signers.map((signer) =>

{signer.name} · {signer.email} · {signer.role}

)}{view.contract.status === "draft" ?
: null}

Signing requests

{view.requests.map((request) =>
{request.status} · expires {request.expiresAt.toLocaleDateString()}{["pending", "viewed"].includes(request.status) ?
: null}
)}{view.contract.status === "draft" && view.signers.length ?
: null}{["sent", "partially_signed", "declined", "expired", "cancelled"].includes(view.contract.status) ?
: null}{["signed", "voided"].includes(view.contract.status) && view.version.signedDocumentHash ? Download retained signed PDF : null}
{["sent", "partially_signed", "signed", "declined", "expired"].includes(view.contract.status) ?
: null}
; } + diff --git a/apps/web/src/app/(dashboard)/contracts/actions.ts b/apps/web/src/app/(dashboard)/contracts/actions.ts new file mode 100644 index 0000000..88230d8 --- /dev/null +++ b/apps/web/src/app/(dashboard)/contracts/actions.ts @@ -0,0 +1,12 @@ +"use server"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { currentActor } from "@/auth/current-actor"; +import { ContractService } from "@/modules/contracts"; +export async function createContractAction(data: FormData) { const actor = await currentActor(); const contract = await new ContractService().create(actor!, { quoteId: String(data.get("quoteId") ?? ""), title: String(data.get("title") ?? "Service Agreement"), body: String(data.get("body") ?? "") }); revalidatePath("/contracts"); redirect(`/contracts/${contract.id}`); } +export async function addContractSignerAction(id: string, data: FormData) { const actor = await currentActor(); await new ContractService().addSigner(actor!, id, { name: String(data.get("name") ?? ""), email: String(data.get("email") ?? ""), role: String(data.get("role") ?? "customer") }); revalidatePath(`/contracts/${id}`); } +export async function sendContractAction(id: string) { const actor = await currentActor(); const result = await new ContractService().send(actor!, id, {}); revalidatePath(`/contracts/${id}`); redirect(`/contracts/${id}?token=${encodeURIComponent(result.links[0]?.token ?? "")}`); } +export async function reviseContractAction(id: string) { const actor = await currentActor(); await new ContractService().createRevision(actor!, id); revalidatePath(`/contracts/${id}`); } +export async function revokeSigningRequestAction(id: string, requestId: string) { const actor = await currentActor(); await new ContractService().revokeSigningRequest(actor!, id, requestId); revalidatePath(`/contracts/${id}`); } +export async function voidContractAction(id: string, data: FormData) { const actor = await currentActor(); await new ContractService().void(actor!, id, { reason: String(data.get("reason") ?? "") }); revalidatePath(`/contracts/${id}`); } + diff --git a/apps/web/src/app/(dashboard)/contracts/page.tsx b/apps/web/src/app/(dashboard)/contracts/page.tsx new file mode 100644 index 0000000..4a6d2dd --- /dev/null +++ b/apps/web/src/app/(dashboard)/contracts/page.tsx @@ -0,0 +1,8 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { currentActor } from "@/auth/current-actor"; +import { ContractService } from "@/modules/contracts"; +import { EstimateService } from "@/modules/estimates/estimate.service"; +import { createContractAction } from "./actions"; +export default async function Page() { const actor = await currentActor(); if (!actor) redirect("/login"); const [contracts, accepted] = await Promise.all([new ContractService().list(actor, { limit: 50 }), new EstimateService().list(actor, { status: "accepted", page: 1, pageSize: 100 })]); return

Commercial agreements

Contracts

{contracts.map(({ contract, title, customerName }) => {contract.identifier}{title}{customerName} · {contract.status})}

Create from accepted Estimate