From 0e5d8589781490d21485d4c50cfb723134fd0dca Mon Sep 17 00:00:00 2001 From: Rajpreet0 Date: Thu, 30 Jul 2026 17:28:42 +0200 Subject: [PATCH 1/2] added automatic image downsizer for vercel image optimization --- DEPLOYMENT.md | 78 +++++++++++++++++++ .../layout/ImageUploadComponent.tsx | 38 ++++++--- src/components/ui/progress.tsx | 25 ++++++ src/lib/image/downsize.ts | 60 ++++++++++++++ .../courses/ui/views/course-create-view.tsx | 26 ++++++- 5 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 DEPLOYMENT.md create mode 100644 src/components/ui/progress.tsx create mode 100644 src/lib/image/downsize.ts diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..311e96f --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,78 @@ +# Deployment: Staging (Vercel) + +Kumasy läuft als Staging-Umgebung auf Vercel, getriggert über den `dev`-Branch. Dieses Dokument beschreibt den aktuellen Aufbau, damit er reproduzierbar ist und man bei Fehlern schnell die Ursache findet. + +Kein Docker: die App ist ein reines Next.js 16 (App Router) Full-Stack-Projekt ohne separaten Server/WebSockets — alles läuft als Vercel Serverless/Edge Functions. Ein eigenes Image würde hier nur Komplexität ohne Nutzen hinzufügen. + +## Architektur + +- **Hosting:** Vercel, Git-Integration mit `Since-3/Kuma`. Push auf `dev` → automatisches Preview-Deployment (= Staging). `main` ist als Production-Branch gesetzt, aber Production ist bislang **nicht** eingerichtet (kein eigenes Supabase-Projekt, keine Production-Env-Vars) — dieses Dokument behandelt nur Staging. +- **Staging-URL (fest, an `dev` gepinnt):** `https://kumasy-git-dev-kumasy.vercel.app` +- **Region:** Vercel Functions laufen in `fra1` (Frankfurt) — bewusst umgestellt, weil die Default-Region (Cleveland/US East) wegen der Entfernung zur Supabase-DB in `eu-central-1` spürbar langsam war. +- **Datenbank/Auth:** eigenes, separates Supabase-Projekt für Staging (Projekt-Ref `pyxjpsalchmnlyqfnzfw`), **nicht** dasselbe wie Produktion. Postgres + Supabase Auth (E-Mail/Passwort + Google OAuth). +- **Storage:** Supabase Storage, Bucket `avatars` (public) — wird für Profilbilder und Kursbilder verwendet. +- **Zahlungen:** Stripe, Test-Mode. Eigener Webhook-Endpoint für die Staging-URL (Webhook-Secrets sind pro Endpoint eindeutig, können nicht mit Produktion geteilt werden). +- **E-Mail:** Resend, gleicher API-Key wie lokal/Produktion (kein Test/Live-Split wie bei Stripe). + +## Environment-Variablen + +Alle in Vercel unter **Project Settings → Environment Variables**, Scope **"Preview"** (niemals "Production" — das hält Staging- und Produktions-Secrets sauber getrennt). + +| Variable | Quelle | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | Supabase Staging, Transaction Pooler, Port 6543, `?pgbouncer=true&connection_limit=1` | +| `DIRECT_URL` | Supabase Staging, **Session Pooler** (nicht Direct Connection!), Port 5432 — siehe Stolperfalle unten | +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase Staging Projekt-URL | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase Staging anon key | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Staging service_role key | +| `RESEND_API_KEY` / `RESEND_FROM` / `SMTP_TEST_MAIL` | wiederverwendet von lokal | +| `STRIPE_SECRET_KEY` / `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Test-Mode-Keys, wiederverwendet von lokal | +| `STRIPE_WEBHOOK_SECRET` | eigener Wert, siehe Stripe-Webhook unten | +| `STRIPE_PLATFORM_FEE_PERCENT` | gleicher Wert wie lokal | +| `NEXT_PUBLIC_SITE_URL` / `NEXT_PUBLIC_APP_URL` | `https://kumasy-git-dev-kumasy.vercel.app` | +| `COMPANY_NAME` | `"Kumasy"` | + +Lokales Pendant für einmalige DB-Operationen gegen Staging (z.B. neue Migrationen einspielen): `.env.staging` im Repo-Root, gitignored (`.env*` steht in `.gitignore`), wird nie committed. + +## Build Command (Vercel Project Settings → Build & Development Settings) + +``` +npx prisma generate && npx prisma migrate deploy && next build +``` + +`package.json` hat kein `postinstall` für Prisma — ohne diesen Override würde der Build mit fehlendem/veraltetem Prisma Client scheitern, und neue Migrationen würden nie automatisch angewendet. + +## Bekannte Stolperfallen (aufgetreten während des Erstaufbaus) + +1. **`prisma.config.ts` + Vercel = Crash beim Config-Laden.** Prisma 6.19's `env()`-Helper wirft synchron beim Laden der Config, wenn die Variable zu dem Zeitpunkt nicht sichtbar ist (bekannter Prisma-Bug, siehe [prisma/prisma#28607](https://github.com/prisma/prisma/issues/28607)). Fix: in `prisma.config.ts` `process.env.DATABASE_URL!` statt `env("DATABASE_URL")` verwenden. +2. **Supabase Direct Connection ist IPv6-only.** `db..supabase.co:5432` (die klassische "Direct Connection") funktioniert nicht von Vercels Build-Containern aus (`P1001: Can't reach database server`), weil dort kein IPv6 verfügbar ist. Für `DIRECT_URL` stattdessen den **Session Pooler** verwenden: `aws-0-eu-central-1.pooler.supabase.com:5432` (gleicher Host wie der Transaction Pooler, nur Port 5432 statt 6543, ohne `pgbouncer=true`). +3. **`.env.example` hat einen Fehler:** zeigt `DIRECT_URL` mit Port 6543, obwohl der Kommentar "für Migrationen" sagt. Der Pooler/Direct-Unterschied ist der Port, nicht nur der Host — siehe Punkt 2 für den korrekten Wert. +4. **Schema-Drift zwischen `schema.prisma` und den committeten Migrationen.** Mehrere Felder (`Employee.permissions/roles/locations/onboardingToken`, `Business.stripeAccountId/slug`, `CourseBooking`-Zahlungsfelder, `GuestLead`-Tabelle) existierten im Schema, aber in keiner Migrationsdatei — offenbar wurden sie irgendwann per `prisma db push` direkt an einer DB angelegt, ohne die Migration zu committen. Behoben durch `prisma migrate diff --from-url $DIRECT_URL --to-schema-datamodel prisma/schema.prisma --script`, das Ergebnis als neue Migration (`prisma/migrations/20260729161133_sync_schema_drift/`) committed. **Wichtig für Produktion:** dort existieren dieselben Spalten vermutlich schon (per `db push`) — diese Migration darf dort nicht ausgeführt, sondern muss mit `prisma migrate resolve --applied 20260729161133_sync_schema_drift` als bereits angewendet markiert werden, sonst schlägt `migrate deploy` mit "column already exists" fehl. +5. **Next.js Image Optimization braucht die Storage-Domain explizit.** `next.config.ts` → `images.remotePatterns` muss den Hostnamen des jeweiligen Supabase-Projekts enthalten. Staging-Domain wurde ergänzt, Produktions-Domain bleibt zusätzlich bestehen (dieselbe Datei bedient später auch Production). +6. **Storage-Buckets werden nicht mitkopiert.** Ein neues Supabase-Projekt hat keine Buckets — `avatars` (public) musste manuell im Staging-Projekt angelegt werden, sonst schlagen Uploads mit "Bucket not found" fehl. + +## Reihenfolge, falls das Setup je neu aufgebaut werden muss + +1. Neues Supabase-Projekt anlegen, Connection-Strings/Keys notieren (Pooler für `DATABASE_URL`, **Session Pooler** für `DIRECT_URL`). +2. Migrationen einmalig lokal einspielen: `npx prisma migrate deploy` mit `.env.staging`. +3. Storage-Bucket `avatars` (public) im neuen Projekt anlegen. +4. Vercel-Projekt importieren, Branch-Domain auf `dev` pinnen (Project Settings → Domains). +5. Region auf `fra1` stellen (Project Settings → Functions). +6. Stripe-Test-Webhook auf die Staging-Domain anlegen, Signing Secret notieren. +7. Alle Env-Vars in Vercel eintragen (Scope: Preview), Build Command überschreiben. +8. Ersten Deploy auslösen, danach Google OAuth in Supabase (Site URL, Redirect URLs) auf die bestätigte Staging-Domain konfigurieren. +9. `next.config.ts` um die neue Storage-Domain ergänzen, committen. + +## Verifikations-Checkliste + +- [x] Homepage lädt, Bilder rendern (dicebear, Supabase Storage, Google-Avatare) +- [x] Öffentliche Listing-Seite zeigt echte Daten aus der Staging-DB +- [x] E-Mail/Passwort-Registrierung funktioniert +- [x] Google-Login funktioniert Ende-zu-Ende +- [x] Schreibvorgang (z.B. Kurs anlegen) funktioniert +- [x] Bild-Upload rendert korrekt +- [ ] Stripe-Checkout mit Testkarte `4242 4242 4242 4242` — **noch offen**, hängt an Stripe Connect Onboarding +- [ ] Stripe-Webhook zeigt "delivered", `CourseBooking.paymentStatus` wechselt auf `"paid"` — **noch offen** +- [ ] Buchungsbestätigungs-Mail kommt tatsächlich an (Resend) — **noch offen** +- [ ] Stripe Connect Onboarding — **noch offen** +- [x] Beide Sprachen (de/en) spot-gecheckt diff --git a/src/components/layout/ImageUploadComponent.tsx b/src/components/layout/ImageUploadComponent.tsx index 6980866..97ba883 100644 --- a/src/components/layout/ImageUploadComponent.tsx +++ b/src/components/layout/ImageUploadComponent.tsx @@ -2,7 +2,9 @@ import { useRef, useState } from "react"; import Image from "next/image"; -import { Camera, X } from "lucide-react"; +import { Camera, Loader2, X } from "lucide-react"; +import { downsizeImage } from "@/src/lib/image/downsize"; +import { Progress } from "@/src/components/ui/progress"; interface ImageUploadComponentProps { value: File | null; @@ -14,11 +16,18 @@ interface ImageUploadComponentProps { const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadComponentProps) => { const inputRef = useRef(null); const [isDragging, setIsDragging] = useState(false); + const [isOptimizing, setIsOptimizing] = useState(false); - const handleFile = (file: File) => { + const handleFile = async (file: File) => { if (!file.type.startsWith("image/")) return; - const url = URL.createObjectURL(file); - onChange(file, url); + setIsOptimizing(true); + try { + const resizedFile = await downsizeImage(file); + const url = URL.createObjectURL(resizedFile); + onChange(resizedFile, url); + } finally { + setIsOptimizing(false); + } }; const handleInputChange = (e: React.ChangeEvent) => { @@ -42,19 +51,26 @@ const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadCo return (
inputRef.current?.click()} + onClick={() => !isOptimizing && inputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); - setIsDragging(true); + if (!isOptimizing) setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} - onDrop={handleDrop} - className={`relative w-28 h-28 rounded-full border-2 border-dashed flex items-center justify-center cursor-pointer transition-colors overflow-hidden + onDrop={isOptimizing ? undefined : handleDrop} + className={`relative w-28 h-28 rounded-full border-2 border-dashed flex items-center justify-center transition-colors overflow-hidden + ${isOptimizing ? "cursor-wait" : "cursor-pointer"} ${isDragging ? "border-blue bg-blue/5" : "border-gray-300 hover:border-blue hover:bg-gray-50"} ${preview ? "border-solid border-blue" : ""} `} > - {preview ? ( + {isOptimizing ? ( +
+ + Bild wird optimiert… + +
+ ) : preview ? ( <> Profilbild Vorschau @@ -86,6 +103,7 @@ const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadCo type="file" accept="image/*" className="hidden" + disabled={isOptimizing} onChange={handleInputChange} /> diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx new file mode 100644 index 0000000..2d4e1ab --- /dev/null +++ b/src/components/ui/progress.tsx @@ -0,0 +1,25 @@ +import { cn } from "@/src/lib/utils" + +function Progress({ + className, + value = 0, + ...props +}: React.ComponentProps<"div"> & { value?: number }) { + return ( +
+
+
+ ) +} + +export { Progress } diff --git a/src/lib/image/downsize.ts b/src/lib/image/downsize.ts new file mode 100644 index 0000000..6550be4 --- /dev/null +++ b/src/lib/image/downsize.ts @@ -0,0 +1,60 @@ +"use client"; + +const MAX_DIMENSION = 1920; +const JPEG_QUALITY = 0.8; + +/** + * Downsizes the Image on the Client via Canvas so it will + * get through Vercels Image-Optimization-Limits (max. 4 MB). + * Not Image Files or already downsized Images will be given back. + */ +export async function downsizeImage( + file: File, + maxDimension: number = MAX_DIMENSION, + quality: number = JPEG_QUALITY +): Promise { + if (!file.type.startsWith("image/") || file.type === "image/svg+xml") { + return file; + } + + const bitmap = await createImageBitmap(file).catch(() => null); + if (!bitmap) return file; + + const { width, height } = bitmap; + const scale = Math.min(1, maxDimension / Math.max(width, height)); + + if (scale === 1 && file.type !== "image/gif") { + bitmap.close(); + return file; + } + + const targetWidth = Math.round(width * scale); + const targetHeight = Math.round(height * scale); + + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = targetHeight; + + const ctx = canvas.getContext("2d"); + if (!ctx) { + bitmap.close(); + return file; + } + + ctx.drawImage(bitmap, 0, 0, targetWidth, targetHeight); + bitmap.close(); + + const outputType = file.type === "image/png" ? "image/png" : "image/jpeg"; + + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, outputType, outputType === "image/jpeg" ? quality : undefined) + ); + + if (!blob || blob.size >= file.size) { + return file; + } + + const newName = outputType === file.type ? file.name : file.name.replace(/\.[^.]+$/, "") + ".jpg"; + + return new File([blob], newName, { type: outputType, lastModified: Date.now() }); +} diff --git a/src/modules/courses/ui/views/course-create-view.tsx b/src/modules/courses/ui/views/course-create-view.tsx index fd6e845..9a67ecf 100644 --- a/src/modules/courses/ui/views/course-create-view.tsx +++ b/src/modules/courses/ui/views/course-create-view.tsx @@ -13,9 +13,11 @@ import { getMyRooms } from "@/src/modules/rooms/actions/room-actions"; import { getMyTrainers } from "@/src/modules/employee/actions/employee-actions"; import { toast } from "sonner"; import { Course } from "../../types/course.types"; -import { Trash2, ImagePlus, X } from "lucide-react"; +import { Trash2, ImagePlus, X, Loader2 } from "lucide-react"; import Image from "next/image"; import DeleteDialog from "@/src/components/layout/DeleteDialog"; +import { Progress } from "@/src/components/ui/progress"; +import { downsizeImage } from "@/src/lib/image/downsize"; import { useDeleteCourse } from "../../hooks/useDeleteCourse"; import { StandingOrderScopeDialog, @@ -149,6 +151,7 @@ const CourseCreateView = ({ isEdit ? (initialData!.coverImage ?? null) : null ); const [isUploadingImage, setIsUploadingImage] = useState(false); + const [imageUploadPhase, setImageUploadPhase] = useState<"optimizing" | "uploading" | null>(null); const isStandingOrderRelated = isEdit && !!(initialData?.parentCourseId || initialData?.isStandingOrder); @@ -219,9 +222,12 @@ const CourseCreateView = ({ const file = e.target.files?.[0]; if (!file) return; setIsUploadingImage(true); + setImageUploadPhase("optimizing"); try { + const resizedFile = await downsizeImage(file); + setImageUploadPhase("uploading"); const formData = new FormData(); - formData.append("file", file); + formData.append("file", resizedFile); const res = await fetch("/api/upload/course-image", { method: "POST", body: formData }); const json = await res.json(); if (!res.ok) throw new Error(json.error); @@ -229,6 +235,7 @@ const CourseCreateView = ({ } catch (err) { toast.error(err instanceof Error ? err.message : "Fehler beim Hochladen"); } finally { + setImageUploadPhase(null); setIsUploadingImage(false); } }; @@ -577,9 +584,20 @@ const CourseCreateView = ({
) : ( -