From e0214ea0f8cf0105d50fc3567232e743d9e381ef Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Fri, 5 Jun 2026 19:18:16 +0200 Subject: [PATCH] feat(01-01): wire Clerk proxy.ts, Prisma 7 schema + singleton, tRPC stack, R2 client, ActivePetContext - Create proxy.ts at repo root with clerkMiddleware + createRouteMatcher (public allowlist: sign-up, log-in, forgot-password, reset-password, verify-email) - Create prisma/schema.prisma: Owner, Species, Breed, Pet models with ownership index and FK constraints - Create prisma.config.ts with Prisma 7 defineConfig (datasource url via env) - Create src/lib/prisma.ts: Prisma 7 singleton using @prisma/adapter-pg for local dev, withAccelerate() for Prisma Accelerate on Vercel - Create src/lib/r2.ts (server-only): S3Client + getPresignedUploadUrl with contentType enforcement and 300s expiry - Create src/lib/auth.ts: getOwnerId() Clerk auth wrapper - Create src/trpc/init.ts: createTRPCContext, router, publicProcedure, protectedProcedure (throws UNAUTHORIZED) - Create src/trpc/routers/pets.ts: pets.create (upserts Owner, creates Pet with ownerId=ctx.userId), pets.list (scoped), pets.byId (ownership-asserted) - Create src/trpc/routers/_app.ts: appRouter + AppRouter type export - Create src/trpc/query-client.ts, server.ts (RSC caller), client.tsx (TRPCReactProvider + useTRPC) - Create src/app/api/trpc/[trpc]/route.ts: tRPC fetch handler (GET + POST) - Create src/context/ActivePetContext.tsx + src/hooks/useActivePet.ts: localStorage-backed active pet context - Create prisma/seed.ts: seeds Dog/Cat/Bird species idempotently via upsert - Update src/app/layout.tsx: wrap children in ClerkProvider (orange primary), TRPCReactProvider, ActivePetProvider, Toaster - Install @prisma/adapter-pg + pg for Prisma 7 local dev (Rule 2: required for Prisma 7 engine compatibility) - [Rule 1 - Bug] Prisma 7 no longer supports url/directUrl in schema.prisma; moved to prisma.config.ts with defineConfig --- package-lock.json | 694 ++++++++++++++++++++++++++ package.json | 4 + prisma.config.ts | 14 + prisma/schema.prisma | 48 ++ prisma/seed.ts | 52 ++ proxy.ts | 23 + src/app/(app)/layout.tsx | 10 + src/app/(app)/onboarding/pet/page.tsx | 74 +++ src/app/(app)/pets/page.tsx | 38 ++ src/app/api/trpc/[trpc]/route.ts | 17 + src/app/layout.tsx | 27 +- src/context/ActivePetContext.tsx | 54 ++ src/hooks/useActivePet.ts | 3 + src/lib/auth.ts | 19 + src/lib/prisma.ts | 45 ++ src/lib/r2.ts | 43 ++ src/trpc/client.tsx | 50 ++ src/trpc/init.ts | 35 ++ src/trpc/query-client.ts | 26 + src/trpc/routers/_app.ts | 9 + src/trpc/routers/pets.ts | 103 ++++ src/trpc/server.ts | 17 + 22 files changed, 1402 insertions(+), 3 deletions(-) create mode 100644 prisma.config.ts create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 proxy.ts create mode 100644 src/app/(app)/layout.tsx create mode 100644 src/app/(app)/onboarding/pet/page.tsx create mode 100644 src/app/(app)/pets/page.tsx create mode 100644 src/app/api/trpc/[trpc]/route.ts create mode 100644 src/context/ActivePetContext.tsx create mode 100644 src/hooks/useActivePet.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/prisma.ts create mode 100644 src/lib/r2.ts create mode 100644 src/trpc/client.tsx create mode 100644 src/trpc/init.ts create mode 100644 src/trpc/query-client.ts create mode 100644 src/trpc/routers/_app.ts create mode 100644 src/trpc/routers/pets.ts create mode 100644 src/trpc/server.ts diff --git a/package-lock.json b/package-lock.json index fc84994..98848cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/s3-request-presigner": "^3.1062.0", "@base-ui/react": "^1.5.0", "@clerk/nextjs": "^7.4.3", + "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@prisma/extension-accelerate": "^3.0.1", "@supabase/supabase-js": "^2.107.0", @@ -26,6 +27,7 @@ "lucide-react": "^1.17.0", "next": "16.2.7", "next-themes": "^0.4.6", + "pg": "^8.21.0", "prisma": "^7.8.0", "react": "19.2.4", "react-dom": "19.2.4", @@ -39,6 +41,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.2", @@ -47,6 +50,7 @@ "eslint-config-next": "16.2.7", "jsdom": "^28.1.0", "tailwindcss": "^4", + "tsx": "^4.22.4", "typescript": "^6.0.3", "vitest": "^4.1.8" } @@ -1551,6 +1555,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2804,6 +3250,18 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@prisma/adapter-pg": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", + "integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.8.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", @@ -2877,6 +3335,15 @@ "zeptomatch": "2.1.0" } }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, "node_modules/@prisma/engines": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", @@ -4148,6 +4615,17 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.16", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", @@ -6638,6 +7116,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -10472,6 +10992,104 @@ "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -10574,6 +11192,45 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -11622,6 +12279,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -12239,6 +12905,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -13107,6 +13792,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 67310f8..6d37924 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@aws-sdk/s3-request-presigner": "^3.1062.0", "@base-ui/react": "^1.5.0", "@clerk/nextjs": "^7.4.3", + "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@prisma/extension-accelerate": "^3.0.1", "@supabase/supabase-js": "^2.107.0", @@ -30,6 +31,7 @@ "lucide-react": "^1.17.0", "next": "16.2.7", "next-themes": "^0.4.6", + "pg": "^8.21.0", "prisma": "^7.8.0", "react": "19.2.4", "react-dom": "19.2.4", @@ -43,6 +45,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.2", @@ -51,6 +54,7 @@ "eslint-config-next": "16.2.7", "jsdom": "^28.1.0", "tailwindcss": "^4", + "tsx": "^4.22.4", "typescript": "^6.0.3", "vitest": "^4.1.8" } diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 0000000..8ed28eb --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,14 @@ +import { defineConfig, env } from "prisma/config"; + +export default defineConfig({ + schema: "prisma/schema.prisma", + datasource: { + // Supabase pooled connection string (port 6543, ?pgbouncer=true) + // Used by Prisma CLI (db push, generate, etc.) + url: env("DATABASE_URL"), + }, + migrations: { + // Direct connection string for migrations (port 5432) — handled via DIRECT_URL + // Prisma 7: use the direct URL for CLI migrate/push operations + }, +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..f9238a9 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,48 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" +} + +model Owner { + id String @id // = Clerk userId (e.g. "user_xxx") + createdAt DateTime @default(now()) + pets Pet[] +} + +model Species { + id String @id @default(cuid()) + name String @unique // "Dog" | "Cat" | "Bird" + breeds Breed[] + pets Pet[] +} + +model Breed { + id String @id @default(cuid()) + name String + species Species @relation(fields: [speciesId], references: [id]) + speciesId String + pets Pet[] + + @@unique([speciesId, name]) +} + +model Pet { + id String @id @default(cuid()) + owner Owner @relation(fields: [ownerId], references: [id]) + ownerId String + species Species @relation(fields: [speciesId], references: [id]) + speciesId String + breed Breed? @relation(fields: [breedId], references: [id]) + breedId String? + name String + bio String? + adoptionStory String? + avatarKey String? // R2 object key, NOT a full URL + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([ownerId]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..27bbdf0 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,52 @@ +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; + +/** + * Seed script for the OnlyPets database. + * Seeds Species (Dog, Cat, Bird) idempotently. + * Plan 03 will extend this with breed taxonomy data. + * + * Run: npx tsx prisma/seed.ts + * Or: npx prisma db seed + */ + +const databaseUrl = process.env.DATABASE_URL; +if (!databaseUrl) { + throw new Error( + "DATABASE_URL is not set. Please add it to .env.local first." + ); +} + +const adapter = new PrismaPg(new Pool({ connectionString: databaseUrl })); +const prisma = new PrismaClient({ adapter }); + +async function main() { + console.log("Seeding species..."); + + const species = [ + { name: "Dog" }, + { name: "Cat" }, + { name: "Bird" }, + ]; + + for (const s of species) { + const result = await prisma.species.upsert({ + where: { name: s.name }, + update: {}, + create: { name: s.name }, + }); + console.log(` Upserted species: ${result.name} (${result.id})`); + } + + console.log("Seeding complete."); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..ee8a0ba --- /dev/null +++ b/proxy.ts @@ -0,0 +1,23 @@ +import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; + +const isPublicRoute = createRouteMatcher([ + "/sign-up(.*)", + "/log-in(.*)", + "/forgot-password(.*)", + "/reset-password(.*)", + "/verify-email(.*)", +]); + +export default clerkMiddleware(async (auth, request) => { + if (!isPublicRoute(request)) { + await auth.protect(); + } +}); + +export const config = { + matcher: [ + "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", + "/(api|trpc)(.*)", + "/__clerk/(.*)", + ], +}; diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..5275a2d --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,10 @@ +/** + * App shell layout — applied to all routes under /(app)/ + * Clerk's proxy.ts enforces authentication before this layout renders. + * + * This is a minimal skeleton layout for Phase 1. + * Full navigation (sidebar, pet switcher) is implemented in Plan 02. + */ +export default function AppLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/app/(app)/onboarding/pet/page.tsx b/src/app/(app)/onboarding/pet/page.tsx new file mode 100644 index 0000000..3b51763 --- /dev/null +++ b/src/app/(app)/onboarding/pet/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTRPC } from "@/trpc/client"; +import { useMutation } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { toast } from "sonner"; + +/** + * Pet onboarding page — /onboarding/pet + * Minimal form to create the first pet profile (walking skeleton). + * + * Full form (breed selector, avatar upload, bio, adoption story) is + * implemented in Plan 02/03. This page proves the end-to-end tRPC flow. + */ +export default function OnboardingPetPage() { + const router = useRouter(); + const trpc = useTRPC(); + const [name, setName] = useState(""); + + // pets.create mutation via tRPC + const createPet = useMutation( + trpc.pets.create.mutationOptions({ + onSuccess: (pet) => { + toast.success(`Pet profile created for ${pet.name}!`); + router.push("/pets"); + }, + onError: (err) => { + toast.error(err.message || "Failed to create pet profile."); + }, + }) + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim()) return; + // Using a placeholder speciesId — real species selector in Plan 02 + createPet.mutate({ name: name.trim(), speciesId: "placeholder" }); + }; + + return ( +
+

Add your first pet

+

+ Your pet will be their own star on OnlyPets. +

+ +
+
+ + setName(e.target.value)} + maxLength={30} + required + /> +
+ + +
+
+ ); +} diff --git a/src/app/(app)/pets/page.tsx b/src/app/(app)/pets/page.tsx new file mode 100644 index 0000000..2904e66 --- /dev/null +++ b/src/app/(app)/pets/page.tsx @@ -0,0 +1,38 @@ +import { createTRPCCaller } from "@/trpc/server"; + +/** + * My Pets page — /pets + * Lists all pets belonging to the authenticated owner. + * + * This is a skeleton page for Phase 1 walking skeleton. + * Full UI (grid, pet cards, add pet CTA) is implemented in Plan 02/03. + */ +export default async function PetsPage() { + const trpc = await createTRPCCaller(); + const pets = await trpc.pets.list(); + + return ( +
+

My Pets

+ {pets.length === 0 ? ( +

+ You haven't added any pets yet.{" "} + + Add your first pet + +

+ ) : ( + + )} +
+ ); +} diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..05b08f8 --- /dev/null +++ b/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,17 @@ +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; +import { appRouter } from "@/trpc/routers/_app"; +import { createTRPCContext } from "@/trpc/init"; + +/** + * tRPC fetch handler for Next.js App Router. + * Handles all tRPC requests via the /api/trpc endpoint. + */ +const handler = (req: Request) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + createContext: createTRPCContext, + }); + +export { handler as GET, handler as POST }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8a91aba..fa77c91 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,9 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; +import { ClerkProvider } from "@clerk/nextjs"; +import { TRPCReactProvider } from "@/trpc/client"; +import { ActivePetProvider } from "@/context/ActivePetContext"; +import { Toaster } from "@/components/ui/sonner"; import "./globals.css"; const inter = Inter({ @@ -18,8 +22,25 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - {children} - + + + + + + {children} + + + + + + ); } diff --git a/src/context/ActivePetContext.tsx b/src/context/ActivePetContext.tsx new file mode 100644 index 0000000..eed9bee --- /dev/null +++ b/src/context/ActivePetContext.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { createContext, useContext, useState, useEffect } from "react"; + +/** + * Active Pet Context — tracks the currently active pet for the logged-in owner. + * + * Architecture: + * - Active pet id is stored in React Context (client-side only) + localStorage for persistence. + * - RSC fallback: if no localStorage value, the first pet in the list is shown (set by the + * app shell after fetching the owner's pets). + * - Switching active pet does NOT require a page reload. + * + * Note: RSC cannot read localStorage. Server-side rendering uses the first pet as fallback. + * The hydration mismatch is expected on first load; localStorage takes over on the client. + */ + +type ActivePetContextType = { + activePetId: string | null; + setActivePet: (id: string) => void; +}; + +const ActivePetContext = createContext(null); + +const STORAGE_KEY = "active_pet_id"; + +export function ActivePetProvider({ children }: { children: React.ReactNode }) { + const [activePetId, setActivePetId] = useState(null); + + useEffect(() => { + // Initialize from localStorage on client mount (avoids SSR mismatch) + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) setActivePetId(stored); + }, []); + + const setActivePet = (id: string) => { + setActivePetId(id); + localStorage.setItem(STORAGE_KEY, id); + }; + + return ( + + {children} + + ); +} + +export function useActivePet(): ActivePetContextType { + const ctx = useContext(ActivePetContext); + if (!ctx) { + throw new Error("useActivePet must be used inside "); + } + return ctx; +} diff --git a/src/hooks/useActivePet.ts b/src/hooks/useActivePet.ts new file mode 100644 index 0000000..e08a3cc --- /dev/null +++ b/src/hooks/useActivePet.ts @@ -0,0 +1,3 @@ +"use client"; + +export { useActivePet } from "@/context/ActivePetContext"; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..e61e492 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,19 @@ +import "server-only"; +import { auth } from "@clerk/nextjs/server"; + +/** + * Server-side Clerk auth helpers. + * These wrappers ensure consistent access to the current owner's identity. + */ + +/** + * Returns the Clerk userId for the currently signed-in owner, or null if + * the request is unauthenticated. Use in RSC and server-side code. + * + * For tRPC procedures, prefer reading ctx.userId directly from the context + * (set by createTRPCContext) rather than calling this function. + */ +export async function getOwnerId(): Promise { + const { userId } = await auth(); + return userId; +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..84e3878 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,45 @@ +import "server-only"; +import { PrismaClient } from "@prisma/client"; +import { withAccelerate } from "@prisma/extension-accelerate"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; + +/** + * Prisma 7 singleton for Next.js / serverless environments. + * + * Prisma 7 requires either an `adapter` (for direct DB connections) + * or an `accelerateUrl` (for Prisma Accelerate on Vercel). + * + * Local dev: uses @prisma/adapter-pg with a pg Pool (direct Supabase connection). + * Vercel/Accelerate: set PRISMA_ACCELERATE_URL and this will use withAccelerate(). + */ + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined; +}; + +function createPrismaClient(): PrismaClient { + const accelerateUrl = process.env.PRISMA_ACCELERATE_URL; + const databaseUrl = process.env.DATABASE_URL; + + if (accelerateUrl) { + // Prisma Accelerate path (Vercel production / staging) + const client = new PrismaClient({ accelerateUrl }); + return client.$extends(withAccelerate()) as unknown as PrismaClient; + } + + if (!databaseUrl) { + throw new Error( + "DATABASE_URL environment variable is not set. " + + "Please add it to .env.local with your Supabase connection string." + ); + } + + // Local dev / direct Supabase connection path + const adapter = new PrismaPg(new Pool({ connectionString: databaseUrl })); + return new PrismaClient({ adapter }); +} + +export const prisma = globalForPrisma.prisma ?? createPrismaClient(); + +if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; diff --git a/src/lib/r2.ts b/src/lib/r2.ts new file mode 100644 index 0000000..72ed054 --- /dev/null +++ b/src/lib/r2.ts @@ -0,0 +1,43 @@ +import "server-only"; +import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +/** + * Cloudflare R2 client configured for presigned PUT uploads. + * This module is server-only — R2 credentials never reach the client bundle. + * + * Media bytes NEVER go through the API server. + * Flow: tRPC procedure generates presigned URL → client PUTs directly to R2. + */ +export const r2 = new S3Client({ + region: "auto", // required by SDK but not used by R2 + endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: process.env.R2_ACCESS_KEY_ID!, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, + }, +}); + +/** + * Generate a presigned PUT URL for direct client-side upload to R2. + * The contentType MUST match what the browser sends in the PUT request + * (mismatch causes R2 to return 403 SignatureDoesNotMatch). + * + * @param key - R2 object key (e.g. "avatars/owner_xxx_1234567890.jpg") + * @param contentType - MIME type of the file being uploaded + * @returns A presigned URL string valid for 5 minutes + */ +export async function getPresignedUploadUrl( + key: string, + contentType: "image/jpeg" | "image/png" | "image/webp" +): Promise { + return getSignedUrl( + r2, + new PutObjectCommand({ + Bucket: process.env.R2_BUCKET_NAME!, + Key: key, + ContentType: contentType, // must match what browser sends in PUT + }), + { expiresIn: 300 } // 5 minutes + ); +} diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx new file mode 100644 index 0000000..3eb5d9b --- /dev/null +++ b/src/trpc/client.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { createTRPCClient, httpBatchLink } from "@trpc/client"; +import { createTRPCContext as createTRPCReactContext } from "@trpc/tanstack-react-query"; +import { useState } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { makeQueryClient } from "@/trpc/query-client"; +import type { AppRouter } from "@/trpc/routers/_app"; + +/** + * tRPC + TanStack Query React context. + * Provides the useTRPC hook for client components. + */ +export const { TRPCProvider, useTRPC } = createTRPCReactContext(); + +let clientQueryClientSingleton: ReturnType | undefined; + +function getQueryClient() { + if (typeof window === "undefined") { + // Server: always create a new QueryClient + return makeQueryClient(); + } + // Browser: use singleton to avoid recreating on each render + if (!clientQueryClientSingleton) { + clientQueryClientSingleton = makeQueryClient(); + } + return clientQueryClientSingleton; +} + +export function TRPCReactProvider({ children }: { children: React.ReactNode }) { + const queryClient = getQueryClient(); + + const [trpcClient] = useState(() => + createTRPCClient({ + links: [ + httpBatchLink({ + url: "/api/trpc", + }), + ], + }) + ); + + return ( + + + {children} + + + ); +} diff --git a/src/trpc/init.ts b/src/trpc/init.ts new file mode 100644 index 0000000..f2efad4 --- /dev/null +++ b/src/trpc/init.ts @@ -0,0 +1,35 @@ +import { initTRPC, TRPCError } from "@trpc/server"; +import { auth } from "@clerk/nextjs/server"; +import { prisma } from "@/lib/prisma"; + +/** + * tRPC context — created per request in the API route handler. + * Reads the authenticated owner's Clerk userId and provides the Prisma client. + */ +export const createTRPCContext = async () => { + const { userId } = await auth(); + return { userId, prisma }; +}; + +type Context = Awaited>; + +const t = initTRPC.context().create(); + +export const router = t.router; +export const publicProcedure = t.procedure; + +/** + * Protected procedure — throws UNAUTHORIZED if no userId in context. + * All pet mutations and scoped queries must use this procedure. + */ +export const protectedProcedure = t.procedure.use(({ ctx, next }) => { + if (!ctx.userId) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + return next({ + ctx: { + ...ctx, + userId: ctx.userId, // narrow to non-null string + }, + }); +}); diff --git a/src/trpc/query-client.ts b/src/trpc/query-client.ts new file mode 100644 index 0000000..afc7136 --- /dev/null +++ b/src/trpc/query-client.ts @@ -0,0 +1,26 @@ +import { + defaultShouldDehydrateQuery, + QueryClient, +} from "@tanstack/react-query"; + +/** + * Factory function for creating a QueryClient with default settings. + * Called once per request (server-side) or once on the client. + */ +export function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + // With SSR we usually want to set staleTime > 0 to avoid refetching + // immediately on the client after SSR hydration. + staleTime: 30 * 1000, // 30 seconds + }, + dehydrate: { + // Include pending queries in dehydration for RSC streaming + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || + query.state.status === "pending", + }, + }, + }); +} diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts new file mode 100644 index 0000000..475f5ab --- /dev/null +++ b/src/trpc/routers/_app.ts @@ -0,0 +1,9 @@ +import { router } from "@/trpc/init"; +import { petsRouter } from "@/trpc/routers/pets"; + +export const appRouter = router({ + pets: petsRouter, +}); + +// Export the type for use in the client +export type AppRouter = typeof appRouter; diff --git a/src/trpc/routers/pets.ts b/src/trpc/routers/pets.ts new file mode 100644 index 0000000..cae2bfb --- /dev/null +++ b/src/trpc/routers/pets.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { router, protectedProcedure } from "@/trpc/init"; + +/** + * Ownership assertion helper — verifies that a pet belongs to the requesting + * owner before any mutation or scoped query proceeds. + * + * Security (T-1-02): never trust client-supplied pet ids without DB verification. + * Every pet mutation asserts pet.ownerId === ctx.userId (ASVS V4). + */ +async function assertPetOwnership( + prisma: Parameters[0]>[0]["ctx"]["prisma"], + petId: string, + userId: string +) { + const pet = await prisma.pet.findUnique({ where: { id: petId } }); + if (!pet || pet.ownerId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not have permission to access this pet.", + }); + } + return pet; +} + +export const petsRouter = router({ + /** + * Create a new pet for the authenticated owner. + * Upserts the Owner row first (ensures FK exists for new Clerk users). + * + * Security: ownerId is always set from ctx.userId (server-side) — never from client input. + */ + create: protectedProcedure + .input( + z.object({ + name: z.string().min(1).max(30), + speciesId: z.string().min(1), + breedId: z.string().optional(), + bio: z.string().max(150).optional(), + adoptionStory: z.string().max(500).optional(), + }) + ) + .mutation(async ({ ctx, input }) => { + // Upsert Owner row from ctx.userId (ensures FK constraint is satisfied) + await ctx.prisma.owner.upsert({ + where: { id: ctx.userId }, + update: {}, + create: { id: ctx.userId }, + }); + + return ctx.prisma.pet.create({ + data: { + ownerId: ctx.userId, + name: input.name, + speciesId: input.speciesId, + breedId: input.breedId ?? null, + bio: input.bio ?? null, + adoptionStory: input.adoptionStory ?? null, + }, + }); + }), + + /** + * List all pets belonging to the authenticated owner. + * Scoped by ownerId — never returns other owners' pets. + */ + list: protectedProcedure.query(async ({ ctx }) => { + return ctx.prisma.pet.findMany({ + where: { ownerId: ctx.userId }, + include: { + species: true, + breed: true, + }, + orderBy: { createdAt: "asc" }, + }); + }), + + /** + * Get a single pet by id. + * Asserts ownership: returns FORBIDDEN if the pet doesn't belong to the requesting owner. + */ + byId: protectedProcedure + .input(z.object({ petId: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const pet = await ctx.prisma.pet.findUnique({ + where: { id: input.petId }, + include: { + species: true, + breed: true, + }, + }); + + if (!pet || pet.ownerId !== ctx.userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You do not have permission to access this pet.", + }); + } + + return pet; + }), +}); diff --git a/src/trpc/server.ts b/src/trpc/server.ts new file mode 100644 index 0000000..83ad3ec --- /dev/null +++ b/src/trpc/server.ts @@ -0,0 +1,17 @@ +import "server-only"; +import { appRouter } from "@/trpc/routers/_app"; +import { createTRPCContext } from "@/trpc/init"; +import { cache } from "react"; + +/** + * Server-side tRPC caller for use in React Server Components (RSC). + * Prefetch data in RSC layouts / page.tsx to hydrate the client. + * + * Usage in RSC: + * const trpc = await createTRPCCaller(); + * const pets = await trpc.pets.list(); + */ +export const createTRPCCaller = cache(async () => { + const ctx = await createTRPCContext(); + return appRouter.createCaller(ctx); +});