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
This commit is contained in:
admin committed 2026-06-05 19:18:16 +02:00
1 parent dbc074e9ee
commit e0214ea0f8
22 files changed
+1402 -3

No files matched your search

+694
View File
@@ -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",
+4
View File
@@ -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"
}
+14
View File
@@ -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
},
});
+48
View File
@@ -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])
}
+52
View File
@@ -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();
});
+23
View File
@@ -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/(.*)",
],
};
+10
View File
@@ -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 <div className="min-h-screen bg-background">{children}</div>;
}
+74
View File
@@ -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 (
<main className="container max-w-md mx-auto px-6 py-12">
<h1 className="text-xl font-semibold mb-2">Add your first pet</h1>
<p className="text-sm text-muted-foreground mb-8">
Your pet will be their own star on OnlyPets.
</p>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="pet-name">Pet name</Label>
<Input
id="pet-name"
placeholder="e.g. Buddy, Luna, Max"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={30}
required
/>
</div>
<Button
type="submit"
className="w-full"
disabled={createPet.isPending || !name.trim()}
>
{createPet.isPending ? "Adding pet…" : "Add Pet"}
</Button>
</form>
</main>
);
}
+38
View File
@@ -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 (
<main className="container max-w-2xl mx-auto px-6 py-8">
<h1 className="text-xl font-semibold mb-6">My Pets</h1>
{pets.length === 0 ? (
<p className="text-muted-foreground">
You haven&apos;t added any pets yet.{" "}
<a href="/onboarding/pet" className="text-primary hover:underline">
Add your first pet
</a>
</p>
) : (
<ul className="space-y-2">
{pets.map((pet) => (
<li key={pet.id} className="p-4 border rounded-lg">
<span className="font-semibold">{pet.name}</span>
<span className="ml-2 text-xs text-muted-foreground">
{pet.species.name}
</span>
</li>
))}
</ul>
)}
</main>
);
}
+17
View File
@@ -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 };
+24 -3
View File
@@ -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 (
<html lang="en" className={`${inter.variable} h-full antialiased font-sans`}>
<body className="min-h-full flex flex-col">{children}</body>
</html>
<ClerkProvider
appearance={{
variables: {
colorPrimary: "#F97316",
colorBackground: "#FAFAFA",
colorText: "#09090B",
},
}}
>
<html lang="en" className={`${inter.variable} h-full antialiased font-sans`}>
<body className="min-h-full flex flex-col">
<TRPCReactProvider>
<ActivePetProvider>
{children}
<Toaster />
</ActivePetProvider>
</TRPCReactProvider>
</body>
</html>
</ClerkProvider>
);
}
+54
View File
@@ -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<ActivePetContextType | null>(null);
const STORAGE_KEY = "active_pet_id";
export function ActivePetProvider({ children }: { children: React.ReactNode }) {
const [activePetId, setActivePetId] = useState<string | null>(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 (
<ActivePetContext.Provider value={{ activePetId, setActivePet }}>
{children}
</ActivePetContext.Provider>
);
}
export function useActivePet(): ActivePetContextType {
const ctx = useContext(ActivePetContext);
if (!ctx) {
throw new Error("useActivePet must be used inside <ActivePetProvider>");
}
return ctx;
}
+3
View File
@@ -0,0 +1,3 @@
"use client";
export { useActivePet } from "@/context/ActivePetContext";
+19
View File
@@ -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<string | null> {
const { userId } = await auth();
return userId;
}
+45
View File
@@ -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;
+43
View File
@@ -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<string> {
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
);
}
+50
View File
@@ -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<AppRouter>();
let clientQueryClientSingleton: ReturnType<typeof makeQueryClient> | 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<AppRouter>({
links: [
httpBatchLink({
url: "/api/trpc",
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
);
}
+35
View File
@@ -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<ReturnType<typeof createTRPCContext>>;
const t = initTRPC.context<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
},
});
});
+26
View File
@@ -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",
},
},
});
}
+9
View File
@@ -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;
+103
View File
@@ -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<Parameters<typeof protectedProcedure.mutation>[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;
}),
});
+17
View File
@@ -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);
});