From 10aa2515d2e0406b3e4a36b704fe07045e750b97 Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 30 Jul 2026 14:21:47 +0800 Subject: [PATCH] feat(infra): migrate Postgres to Neon and object storage to Cloudflare R2 Supabase's paused free-tier project can no longer serve as database or storage backend. Points Prisma at Neon (pooled + direct connection strings) and reimplements storageService.ts against R2, preserving object keys and function signatures so no caller changes. Both verified live: real DB reads, a real GeoTIFF round-trip, and a panel recompute with zero calls to solar.googleapis.com. Closes #4, #5 --- .env.example | 18 +- RUNBOOK.md | 30 +- backend/package.json | 2 + backend/src/config/env.ts | 7 +- backend/src/config/r2.ts | 12 + .../services/__tests__/storageService.test.ts | 97 ++++++ backend/src/services/storageService.ts | 46 ++- pnpm-lock.yaml | 318 ++++++++++++++++++ prisma/schema.prisma | 5 +- 9 files changed, 509 insertions(+), 26 deletions(-) create mode 100644 backend/src/config/r2.ts create mode 100644 backend/src/services/__tests__/storageService.test.ts diff --git a/.env.example b/.env.example index 19226e8..f7c3d5a 100644 --- a/.env.example +++ b/.env.example @@ -19,11 +19,25 @@ CHAT_MODEL=gemini-3.1-flash-lite GOOGLE_OAUTH_CLIENT_ID=your_google_oauth_client_id GOOGLE_OAUTH_SECRET=your_google_oauth_secret -# Supabase +# Neon Postgres +# DATABASE_URL is the pooled endpoint (hostname ends in -pooler) used by the running app. +# DIRECT_URL is the unpooled endpoint used by Prisma Migrate, which cannot run through a +# transaction-mode pooler. Both are shown by `neon connection-string `. +DATABASE_URL=postgresql://user:password@ep-example-pooler.region.aws.neon.tech/neondb?sslmode=require +DIRECT_URL=postgresql://user:password@ep-example.region.aws.neon.tech/neondb?sslmode=require + +# Cloudflare R2 object storage (S3-compatible) +# The API token needs Object Read & Write on this bucket only. The endpoint is derived as +# https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com — do not set it separately. +R2_ACCOUNT_ID=your_cloudflare_account_id +R2_ACCESS_KEY_ID=your_r2_access_key_id +R2_SECRET_ACCESS_KEY=your_r2_secret_access_key +R2_BUCKET=your_r2_bucket_name + +# Supabase (auth only — Postgres and Storage have migrated to Neon and R2) SUPABASE_URL=https://your-project.supabase.co SUPABASE_ANON_KEY=your_supabase_anon_key SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key -SUPABASE_DATABASE_URL=postgresql://postgres:password@db.your-project.supabase.co:5432/postgres VITE_SUPABASE_URL=${SUPABASE_URL} VITE_SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY} diff --git a/RUNBOOK.md b/RUNBOOK.md index 8d077de..5f72f4c 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -147,7 +147,8 @@ Copy these values from Supabase Settings: - Project URL -> `SUPABASE_URL` - anon public key -> `SUPABASE_ANON_KEY` - service_role key -> `SUPABASE_SERVICE_ROLE_KEY` -- database connection string -> `SUPABASE_DATABASE_URL` + +Postgres and object storage no longer come from Supabase. Take the database connection strings from Neon (`neon connection-string `, once pooled and once without `--pooled`, into `DATABASE_URL` and `DIRECT_URL`) and the four `R2_*` values from Cloudflare R2. Create the bucket that stores Solar API GeoTIFFs. The backend expects the bucket name `geotiffs`. @@ -215,10 +216,15 @@ Set the root `.env` from the table below. `VITE_*` variables are baked into the | `CHAT_MODEL` | Gemini model name | Chat boots with the wrong or default model | | `GOOGLE_OAUTH_CLIENT_ID` | GCP OAuth client | Google sign-in fails | | `GOOGLE_OAUTH_SECRET` | Same OAuth client | Google sign-in fails | -| `SUPABASE_URL` | Supabase Settings -> API | Auth, storage, and backend client creation fail | +| `DATABASE_URL` | Neon pooled connection string (host ends in `-pooler`) | Runtime database access fails | +| `DIRECT_URL` | Neon unpooled connection string | Prisma Migrate cannot run | +| `R2_ACCOUNT_ID` | Cloudflare account ID; forms the S3 endpoint hostname | Storage client cannot resolve the R2 endpoint | +| `R2_ACCESS_KEY_ID` | R2 API token, Object Read & Write on the bucket | GeoTIFF upload and download fail | +| `R2_SECRET_ACCESS_KEY` | Secret half of the same R2 API token | GeoTIFF upload and download fail | +| `R2_BUCKET` | R2 bucket name holding the cached GeoTIFFs and imagery | Storage paths resolve to a nonexistent bucket | +| `SUPABASE_URL` | Supabase Settings -> API | Auth and backend client creation fail | | `SUPABASE_ANON_KEY` | Supabase Settings -> API | Frontend Supabase client cannot sign in | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Settings -> API | Backend cannot read/write privileged data | -| `SUPABASE_DATABASE_URL` | Supabase Settings -> Database | Prisma migrations and runtime DB access fail | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Settings -> API | Backend cannot verify bearer tokens | | `VITE_SUPABASE_URL` | `SUPABASE_URL` via dotenv-expand | Frontend Supabase client cannot boot | | `VITE_SUPABASE_ANON_KEY` | `SUPABASE_ANON_KEY` via dotenv-expand | Frontend auth fails | | `SITE_URL` | Final public origin, local or production | Supabase redirect links point to the wrong place | @@ -301,10 +307,15 @@ heroku config:set \ GOOGLE_CLOUD_LOCATION="global" \ GEMINI_API_KEY="..." \ CHAT_MODEL="gemini-3.1-flash-lite-preview" \ + DATABASE_URL="postgresql://...-pooler...neon.tech/neondb?sslmode=require" \ + DIRECT_URL="postgresql://...neon.tech/neondb?sslmode=require" \ + R2_ACCOUNT_ID="..." \ + R2_ACCESS_KEY_ID="..." \ + R2_SECRET_ACCESS_KEY="..." \ + R2_BUCKET="solarsim" \ SUPABASE_URL="https://.supabase.co" \ SUPABASE_ANON_KEY="..." \ SUPABASE_SERVICE_ROLE_KEY="..." \ - SUPABASE_DATABASE_URL="postgresql://..." \ VITE_SUPABASE_URL="https://.supabase.co" \ VITE_SUPABASE_ANON_KEY="..." \ SITE_URL="https://solarsim.tech" \ @@ -506,10 +517,15 @@ Supabase project deletion is still dashboard-only. After deleting the hosted pro | `CHAT_MODEL` | Gemini model name | Chat model selector | | `GOOGLE_OAUTH_CLIENT_ID` | GCP OAuth client | Google sign-in | | `GOOGLE_OAUTH_SECRET` | GCP OAuth client | Google sign-in | +| `DATABASE_URL` | Neon pooled connection string | Prisma runtime queries | +| `DIRECT_URL` | Neon unpooled connection string | Prisma Migrate | +| `R2_ACCOUNT_ID` | Cloudflare account ID | R2 S3 endpoint hostname | +| `R2_ACCESS_KEY_ID` | R2 API token | GeoTIFF storage | +| `R2_SECRET_ACCESS_KEY` | R2 API token | GeoTIFF storage | +| `R2_BUCKET` | R2 bucket name | GeoTIFF storage | | `SUPABASE_URL` | Supabase settings | Backend + auth | | `SUPABASE_ANON_KEY` | Supabase settings | Frontend auth | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase settings | Backend privileged access | -| `SUPABASE_DATABASE_URL` | Supabase DB connection string | Prisma | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase settings | Backend token verification | | `VITE_SUPABASE_URL` | Derived from `SUPABASE_URL` | Frontend auth | | `VITE_SUPABASE_ANON_KEY` | Derived from `SUPABASE_ANON_KEY` | Frontend auth | | `SITE_URL` | Final public origin | Supabase auth redirects | diff --git a/backend/package.json b/backend/package.json index 72c1913..4aeebd1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,8 @@ "test": "vitest run" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1098.0", + "@aws-sdk/s3-request-presigner": "^3.1098.0", "@google/genai": "1.51.0", "@prisma/client": "^6.6.0", "@shared/types": "workspace:*", diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 2a1e91b..75f4913 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -22,10 +22,15 @@ const envSchema = z (val) => (val === '' ? undefined : val), z.enum(['development', 'production', 'test']).default('development') ), - SUPABASE_DATABASE_URL: z.string().min(1), + DATABASE_URL: z.string().min(1), + DIRECT_URL: z.string().min(1), GOOGLE_API_KEY: z.string().min(1), SUPABASE_URL: z.string().url(), SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), + R2_ACCOUNT_ID: z.string().min(1), + R2_ACCESS_KEY_ID: z.string().min(1), + R2_SECRET_ACCESS_KEY: z.string().min(1), + R2_BUCKET: z.string().min(1), FRONTEND_URL: z.string().url().optional().default('http://localhost:5173'), PDF_TOKEN_SECRET: z.string().min(32), GEMINI_API_KEY: z.preprocess((val) => (val === '' ? undefined : val), z.string().min(1).optional()), diff --git a/backend/src/config/r2.ts b/backend/src/config/r2.ts new file mode 100644 index 0000000..acad6ca --- /dev/null +++ b/backend/src/config/r2.ts @@ -0,0 +1,12 @@ +import { S3Client } from '@aws-sdk/client-s3' +import { env } from './env.js' + +/** Configured Cloudflare R2 client used by the backend for private asset storage. */ +export const r2 = new S3Client({ + region: 'auto', + endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: env.R2_ACCESS_KEY_ID, + secretAccessKey: env.R2_SECRET_ACCESS_KEY + } +}) diff --git a/backend/src/services/__tests__/storageService.test.ts b/backend/src/services/__tests__/storageService.test.ts new file mode 100644 index 0000000..71b0cbc --- /dev/null +++ b/backend/src/services/__tests__/storageService.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const s3SendMock = vi.hoisted(() => vi.fn()) +const createPresignedUrlMock = vi.hoisted(() => vi.fn()) + +vi.mock('../../config/env.js', () => ({ + env: { R2_BUCKET: 'solar-assets' } +})) + +vi.mock('../../config/r2.js', () => ({ + r2: { send: (...args: unknown[]) => s3SendMock(...args) } +})) + +vi.mock('@aws-sdk/s3-request-presigner', () => ({ + getSignedUrl: (...args: unknown[]) => createPresignedUrlMock(...args) +})) + +import { downloadFromStorage, getSignedUrl, uploadToStorage } from '../storageService.js' + +const STORAGE_PATH = 'locations/10f3f3f6-112f-47bd-b121-8bfa7b41e1f1/monthly_flux.tif' +const R2_BUCKET = 'solar-assets' + +describe('storageService', () => { + beforeEach(() => { + s3SendMock.mockReset() + createPresignedUrlMock.mockReset() + }) + + it('uploads the exact storage path, configured bucket, and content type', async () => { + const buffer = Buffer.from('solar-data') + s3SendMock.mockResolvedValue({}) + + await uploadToStorage(STORAGE_PATH, buffer, 'image/tiff') + + expect(s3SendMock).toHaveBeenCalledOnce() + expect(s3SendMock.mock.calls[0]?.[0]).toMatchObject({ + input: { Bucket: R2_BUCKET, Key: STORAGE_PATH, Body: buffer, ContentType: 'image/tiff' } + }) + }) + + it('downloads object bytes as an ArrayBuffer using the exact storage path', async () => { + const bytes = new Uint8Array([1, 2, 3]) + s3SendMock.mockResolvedValue({ Body: { transformToByteArray: vi.fn().mockResolvedValue(bytes) } }) + + const result = await downloadFromStorage(STORAGE_PATH) + + expect(s3SendMock).toHaveBeenCalledOnce() + expect(s3SendMock.mock.calls[0]?.[0]).toMatchObject({ input: { Bucket: R2_BUCKET, Key: STORAGE_PATH } }) + expect(result).toBeInstanceOf(ArrayBuffer) + expect([...new Uint8Array(result)]).toEqual([1, 2, 3]) + }) + + it('creates a private signed URL with the exact storage path and requested lifetime', async () => { + createPresignedUrlMock.mockResolvedValue('https://signed.example/object') + + const result = await getSignedUrl(STORAGE_PATH, 900) + + expect(result).toBe('https://signed.example/object') + expect(createPresignedUrlMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ input: { Bucket: R2_BUCKET, Key: STORAGE_PATH } }), + { expiresIn: 900 } + ) + }) + + it('uses the existing 3600 second lifetime by default', async () => { + createPresignedUrlMock.mockResolvedValue('https://signed.example/object') + + await getSignedUrl(STORAGE_PATH) + + expect(createPresignedUrlMock).toHaveBeenCalledWith(expect.anything(), expect.anything(), { expiresIn: 3600 }) + }) + + it('wraps upload failures with the existing message shape', async () => { + s3SendMock.mockRejectedValue(new Error('R2 unavailable')) + + await expect(uploadToStorage(STORAGE_PATH, Buffer.from('solar-data'), 'image/tiff')).rejects.toThrow( + `Storage upload failed for ${STORAGE_PATH}: R2 unavailable` + ) + }) + + it('wraps download failures with the existing message shape', async () => { + s3SendMock.mockRejectedValue(new Error('R2 unavailable')) + + await expect(downloadFromStorage(STORAGE_PATH)).rejects.toThrow( + `Storage download failed for ${STORAGE_PATH}: R2 unavailable` + ) + }) + + it('wraps signed URL failures with the existing message shape', async () => { + createPresignedUrlMock.mockRejectedValue(new Error('R2 unavailable')) + + await expect(getSignedUrl(STORAGE_PATH)).rejects.toThrow( + `Failed to create signed URL for ${STORAGE_PATH}: R2 unavailable` + ) + }) +}) diff --git a/backend/src/services/storageService.ts b/backend/src/services/storageService.ts index 7cfe929..06c2f21 100644 --- a/backend/src/services/storageService.ts +++ b/backend/src/services/storageService.ts @@ -1,24 +1,31 @@ /** - * Supabase Storage wrapper for generated Solar API assets. + * Cloudflare R2 storage wrapper for generated Solar API assets. * * Centralises uploads, downloads, and signed URL creation for the shared * GeoTIFF/PNG bucket used by the location pipeline. */ -import { supabase } from '../config/supabase.js' - -const BUCKET = 'geotiffs' +import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3' +import { getSignedUrl as createPresignedUrl } from '@aws-sdk/s3-request-presigner' +import { env } from '../config/env.js' +import { r2 } from '../config/r2.js' /** * Uploads a blob to the shared storage bucket, replacing any existing object. * * @param storagePath - Bucket-relative object path * @param buffer - File contents to store - * @param contentType - MIME type sent to Supabase Storage + * @param contentType - MIME type stored with the R2 object */ export async function uploadToStorage(storagePath: string, buffer: Buffer, contentType: string): Promise { - const { error } = await supabase.storage.from(BUCKET).upload(storagePath, buffer, { contentType, upsert: true }) - if (error) throw new Error(`Storage upload failed for ${storagePath}: ${error.message}`) + try { + await r2.send( + new PutObjectCommand({ Bucket: env.R2_BUCKET, Key: storagePath, Body: buffer, ContentType: contentType }) + ) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Storage upload failed for ${storagePath}: ${message}`) + } } /** @@ -28,9 +35,15 @@ export async function uploadToStorage(storagePath: string, buffer: Buffer, conte * @returns Raw object bytes as an ArrayBuffer */ export async function downloadFromStorage(storagePath: string): Promise { - const { data, error } = await supabase.storage.from(BUCKET).download(storagePath) - if (error) throw new Error(`Storage download failed for ${storagePath}: ${error.message}`) - return data.arrayBuffer() + try { + const response = await r2.send(new GetObjectCommand({ Bucket: env.R2_BUCKET, Key: storagePath })) + if (!response.Body) throw new Error('R2 response body is missing') + + return new Uint8Array(await response.Body.transformToByteArray()).buffer + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Storage download failed for ${storagePath}: ${message}`) + } } /** @@ -38,10 +51,15 @@ export async function downloadFromStorage(storagePath: string): Promise { - const { data, error } = await supabase.storage.from(BUCKET).createSignedUrl(storagePath, expiresIn) - if (error) throw new Error(`Failed to create signed URL for ${storagePath}: ${error.message}`) - return data.signedUrl + try { + return await createPresignedUrl(r2, new GetObjectCommand({ Bucket: env.R2_BUCKET, Key: storagePath }), { + expiresIn + }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to create signed URL for ${storagePath}: ${message}`) + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84e4f8e..3f05b53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,12 @@ importers: backend: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1098.0 + version: 3.1098.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1098.0 + version: 3.1098.0 '@google/genai': specifier: 1.51.0 version: 1.51.0 @@ -281,6 +287,82 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@aws-sdk/checksums@3.1000.23': + resolution: {integrity: sha512-Hv4VX5+IdDYmAbOdiwZguz8fLh3WnE4VtyJwVNEHLulA+OYy7Z5L0ETGUlX2T4g8DLO8XxCV8CyWrtVL4uwqkg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1098.0': + resolution: {integrity: sha512-6Z53QG2jdujsCt3eHO2cjCpdfvz2Pgs+hP/NINuGONZUJmdrHLLDvWM9F114Yq0NPIGtk+yA/GCjG3XiUhw+gA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.3': + resolution: {integrity: sha512-6YZTpF5Zzl0KdSrztM0l6ErKdnXrnnodIDYfayHE9SFfZU8Ubxls7H2XnfWT121jgwiG+WP1z5kyfVDsH5V4qA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.64': + resolution: {integrity: sha512-14kkR5aj1c7D+cYCrEcG2W3xw87wMYAEUeB/tMiQ2j0RVKzzdewd4mJw1+Q0JVLr4IFU1JHGDCyj0WqLrtIixg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.66': + resolution: {integrity: sha512-BrZxoXScBZ+nTOYy388zHkz1n5cS8C+uGFMozD4w9OKEWMq39Cu/9l/k385Hb54dtv0VIeh12f8h67o3xDNH9g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.9': + resolution: {integrity: sha512-d/mMvS+sQjSWNA6+JCldK1YB/jQmtoWM8Izj10mrOZdRCeWNVb8IYQuuyHHKKKXcpGo5Pd0LIK6onHbLvlhuVQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.71': + resolution: {integrity: sha512-l93922lXnTs5RjM3QrMCXSmXiEgLFLaQ0Lco9F3tJ08o0mzGdAj6m2nAXDuTMHoUuL0u7RKmrhm+Z8/7GCilyg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.75': + resolution: {integrity: sha512-sdFhR4E3HWZefGL6OsBhxqXdqtFvxGOh8VLfiu9yttPgVNcpEozEDPmrTYmXA3CIf6npUETVLveQPGQ7GBzVhg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.64': + resolution: {integrity: sha512-Q5noG5vfFo++bUaLGcjj8OHX5tmwa1O/5nDVMeaSSZgjzgvWbW6tgu/gSa+aENwcJXjoXDQh+8TRZR2zHds/aw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.8': + resolution: {integrity: sha512-37okeZyRdVkGrU6nyKBPGqQvyl/7CEiMpUVOb0+BCNCIR0VAuBxNgjMb0mnsR1EXm8dqQFfQZOJ5YW/0sBfa1w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.70': + resolution: {integrity: sha512-Ps/f9USafScb6CSQTRPNMzdKL5x5XRwDIRgFvnuFWfClGZe7/fI7iCFqXdxKjc9LBxP28E7iUgNloGgfgw406w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.69': + resolution: {integrity: sha512-ziSQA8AkB+u4K/5t/SStFbeoiNTG04reUeddCLNCAgmdRJGQ3+MJcpZOXXLdD6uyfCqSK1/R6004gRdXvHbYQA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.38': + resolution: {integrity: sha512-jQDs+nxnmgo4+WPOmY373HIC8jx4KyYYMYDI74FWrzX3Ba9fYsV0dSA+7PNYNkhRbbRCRLre83tQ31F1ACCHkA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1098.0': + resolution: {integrity: sha512-1FQp9O22rWMGBhLZI/fP14kVoxsylk4b8yHh3n78pbTvo1+NUEoe5gG4tFLTz+p6UpCna5qqjp28VlSJO3v8fA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1098.0': + resolution: {integrity: sha512-6cFAviffeqYGjrXn4FqGbWcTxB3nbpBpQXUm6MvnrWZThJaVBTHHnespHgSUh4yVLfzhipSh9H/XjzkHQd9P4g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1631,6 +1713,30 @@ packages: cpu: [x64] os: [win32] + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2140,6 +2246,9 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.16: resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} @@ -4264,6 +4373,180 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@aws-sdk/checksums@3.1000.23': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1098.0': + dependencies: + '@aws-sdk/checksums': 3.1000.23 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/credential-provider-node': 3.972.75 + '@aws-sdk/middleware-sdk-s3': 3.972.69 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.977.3': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.64': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.66': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.9': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/credential-provider-env': 3.972.64 + '@aws-sdk/credential-provider-http': 3.972.66 + '@aws-sdk/credential-provider-login': 3.972.71 + '@aws-sdk/credential-provider-process': 3.972.64 + '@aws-sdk/credential-provider-sso': 3.973.8 + '@aws-sdk/credential-provider-web-identity': 3.972.70 + '@aws-sdk/nested-clients': 3.997.38 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.71': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/nested-clients': 3.997.38 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.75': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.64 + '@aws-sdk/credential-provider-http': 3.972.66 + '@aws-sdk/credential-provider-ini': 3.973.9 + '@aws-sdk/credential-provider-process': 3.972.64 + '@aws-sdk/credential-provider-sso': 3.973.8 + '@aws-sdk/credential-provider-web-identity': 3.972.70 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.64': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.8': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/nested-clients': 3.997.38 + '@aws-sdk/token-providers': 3.1098.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.70': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/nested-clients': 3.997.38 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.38': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1098.0': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.43': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1098.0': + dependencies: + '@aws-sdk/core': 3.977.3 + '@aws-sdk/nested-clients': 3.997.38 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.37': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5356,6 +5639,39 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@smithy/core@3.31.1': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.16': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.12': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -5914,6 +6230,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.14.1: {} + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6e82e89..6331f3e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,8 +3,9 @@ generator client { } datasource db { - provider = "postgresql" - url = env("SUPABASE_DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") } enum LocationStatus {