19 Commits
Author SHA1 Message Date
admin baadfe618c feat(feed): handle followers-only posts with locked UI (issue #246) 2026-01-02 15:35:52 +01:00
admin cc5db2b37e feat: enable CORS and add frontend API test page 2026-01-02 14:55:56 +01:00
admin 06e9240909 feat(backend): feed, posts & pets APIs frontend-ready with visibility enforcement 2026-01-02 14:45:40 +01:00
admin e1e1b7ab21 Fundament: Zentrale Auth-Leitlinie & RequestUser vereinheitlicht (#241)
- Guard-first Auth-Architektur dokumentiert
- RequestUser als einzige User-Wahrheit etabliert
- Keine Auth-Logik in Services
- Public-Routen explizit via @Public()
- Vorbereitung für JWT ohne Service-Änderungen
2026-01-01 14:12:49 +01:00
admin 831e6b0cbd Phase 2.1: Feed angebunden & Visibility serverseitig enforced (#245 #246)
- Echter /feed Endpoint angebunden (Mock entfernt)
- Visibility-Regeln serverseitig umgesetzt (public / followers)
- RequestUser als zentraler Auth-Typ vereinheitlicht
- Visibility-Leak behoben
2026-01-01 14:08:14 +01:00
admin b492ecdaa3 feat(frontend): stabilize feed mock, align PostCard types and polish UX 2025-12-27 12:57:05 +01:00
admin 09599ff7e9 chore(frontend): before ux polish 2025-12-27 12:35:58 +01:00
admin 5b66e57d2e feat(backend): add /feed endpoint mirrored from /posts
- Introduced FeedModule with controller and service
- /feed mirrors /posts 1:1 (baseline, deterministic)
- No discovery or random logic yet
- Prisma + DI fully wired and stable
- Backend now frontend-ready for feed consumption
2025-12-27 10:46:05 +01:00
admin 703470e501 feat(backend): activate posts feed with prisma + strict DI 2025-12-27 10:42:10 +01:00
admin 1081fc8f7f feat(backend): activate posts feed with prisma + strict DI 2025-12-27 10:42:06 +01:00
admin c0a814081d feat(backend): bootstrap NestJS app with dummy auth and health endpoint 2025-12-26 12:42:10 +01:00
admin fb4c442542 docs(auth): document dummy auth architecture 2025-12-26 12:03:55 +01:00
admin 122dad35ce feat(auth): add dummy auth foundation with global guard 2025-12-26 12:03:00 +01:00
admin 9253cac1b2 feat(backend): finalize OnlyPets backend architecture
Refs #3

Backend architecture for OnlyPets was finalized and hardened.

[Text wie oben]
2025-12-25 15:38:57 +01:00
admin 7759bd0a05 feat(backend): finalize pets logic, follow system, notifications and feed hardening 2025-12-25 13:30:15 +01:00
admin 27174bef18 feat(backend): add follow/unfollow endpoints and live follower counts 2025-12-25 13:12:56 +01:00
admin 0778f188bc feat(backend): add follow relation and enforce followers visibility in feed 2025-12-25 13:09:24 +01:00
admin aba22e2855 feat(feed): add birthday highlight for pets in feed and explore
- display subtle birthday marker on posts when pet has birthday
- highlight birthday posts with soft accent border
- reuse central petAge utilities for consistent logic
- preserve all existing post meta information (author, time)
- avoid layout shifts by using decorative indicators only
2025-12-25 12:45:14 +01:00
admin 13f2dedf13 feat(pets): add birthdate-based age system with birthday support
- introduce central pet age utilities (calculate, format, birthday check)
- replace static pet.age with dynamic birthDate logic
- display growing age correctly in pet detail view
- prepare foundation for birthday highlights and notifications
- fix duplicate imports and ensure stable build
2025-12-25 11:15:47 +01:00
60 changed files with 7924 additions and 412 deletions
+19
View File
@@ -0,0 +1,19 @@
# Node.js
node_modules/
npm-debug.log
# Environment Dateien (Geheimnisse!)
.env
.env.local
# Build-Ausgaben
dist/
build/
.next/
# Betriebssystem
.DS_Store
Thumbs.db
# Docker
docker-data/
+129
View File
@@ -0,0 +1,129 @@
# 🧊 Backend Freeze Frontend Ready
**Projekt:** OnlyPets
**Stand:** Backend eingefroren (Frontend-ready)
**Datum:** 27.12.2025
Ziel dieses Dokuments ist es, klar festzuhalten:
> Das Backend ist technisch und strukturell abgeschlossen,
> sodass die weitere Entwicklung primär im Frontend stattfinden kann.
Spezial-Features werden bewusst **getrennt** und **nachgelagert** umgesetzt.
---
## 🧱 1. Architektur & Infrastruktur
- NestJS stabil gebootstrapped
- Module sauber getrennt:
- Posts
- Feed
- Prisma
- Auth
- Dependency Injection stabil (keine offenen DI-Fehler)
- Prisma korrekt als Service eingebunden
- Strict TypeScript aktiv (keine `any`-Leaks)
- Ordnerstruktur final (kein späteres Refactoring notwendig)
**Status:** ✅ abgeschlossen
---
## 🔐 2. Auth-Grundlage (Phase 1)
- Globaler `DummyAuthGuard` aktiv
- `req.user` immer definiert (`null | FakeUser`)
- Rollenmodell vorbereitet (`USER | MOD | ADMIN`)
- `@Public()` Decorator vorhanden
- Guard-first-Architektur (keine Auth-Logik in Services)
> Echte Auth (JWT, Login) ist **bewusst nicht Teil** des Freeze-Zustands.
**Status:** ✅ abgeschlossen (Phase-1-Scope)
---
## 🗃️ 3. Datenmodell & Prisma
- Prisma Schema stabil
- Keine redundanten oder abgeleiteten Felder
- Alter wird **nicht gespeichert**
- `birthDate` ist Single Source of Truth
- Beziehungen konsistent:
- User ↔ Pet ↔ Post
- Seed reproduzierbar & idempotent
- DB-Zugriff ausschließlich über Services
**Status:** ✅ abgeschlossen
---
## 📰 4. Feed & Content (Frontend-relevant)
Aktive Endpoints:
- `GET /posts`
- `GET /feed`
Eigenschaften:
- `/feed` ist 1:1 aus `/posts` gespiegelt
- Deterministischer Feed
- Keine Random- oder Discovery-Logik
- Sortierung: `createdAt DESC`
- Inkludierte Relationen:
- `pet`
- `author`
Smoke-Tests erfolgreich (Invoke-RestMethod).
**Status:** ✅ abgeschlossen / frontend-ready
---
## 🩺 5. Betrieb & Stabilität
- `GET /health` Endpoint vorhanden
- Server startet ohne Errors
- Keine offenen TypeScript- oder DI-Probleme
- Lokales DEV-Setup reproduzierbar
**Status:** ✅ abgeschlossen
---
## 🧩 6. Bewusst ausgeklammerte Spezial-Features
Diese Themen sind **nicht Teil des Backend-Freeze** und werden separat umgesetzt:
- Discovery Feed (siehe Issue #243)
- Likes
- Kommentare
- Notifications
- Admin / Moderation
- Echte Auth (JWT, Login)
> Diese Features sind **entkoppelt geplant**,
> sodass sie nach Live-Frontend **ohne Rewrites** ergänzt werden können.
---
## 🧊 Freeze-Regel
Ab diesem Punkt gilt für das Backend:
- ❄️ Keine neuen Features
- ❄️ Nur Bugfixes
- ❄️ Keine API-Breaking-Changes
- ❄️ Fokus liegt auf Frontend-Entwicklung
---
## ✅ Fazit
Das Backend befindet sich im **frontend-ready Freeze-Zustand**.
- Die API ist stabil
- Die Architektur ist vorbereitet
- Erweiterungen sind planbar, aber nicht blockierend
➡️ **Empfohlener nächster Schritt:** Frontend entwickeln.
+26
View File
@@ -0,0 +1,26 @@
## Lokales Dev-Setup
Frontend:
- URL: http://localhost:3000
Backend:
- URL: http://localhost:3001
ENV:
- NEXT_PUBLIC_API_URL=http://localhost:3001
Hinweis:
Backend ist frontend-ready eingefroren.
Neue Features nur über separate Issues.
# 🛠 DEV SETUP OnlyPets
## Ports
- Frontend: http://localhost:3000
- Backend: http://localhost:3001
## Frontend (Next.js)
```bash
cd frontend
npm install
npm run dev
+542
View File
@@ -0,0 +1,542 @@
PROJECT: OnlyPets (ehemals OnlyCats)
STACK:
- Backend: NestJS + Prisma
- DB: PostgreSQL (Docker, DEV)
- Frontend: Next.js (separat, noch nicht Thema dieses Blocks)
CORE IDEA:
- Tiere stehen IMMER im Mittelpunkt
- Kein Post ohne Pet
- User sind sekundär, Pets sind primäre Entität
DATA PRINCIPLES:
- Single Source of Truth
- Pet.age wird NIE gespeichert
- Stattdessen: Pet.birthDate (DateTime)
- Alter & Geburtstag werden IMMER berechnet (Utils / Frontend)
PET RULES:
- birthDate optional, aber bevorzugt
- fallbackAge nur temporär (kein Persist)
- Pet Edit erlaubt birthDate-Korrektur
- type & breed sind immutable
- visibility: public | followers (serverseitig enforced)
BACKEND STATUS:
- Prisma Schema vorhanden
- Migration erfolgreich ausgeführt
- Tabellen existieren
- Follow-System implementiert
- Visibility-Logik vorbereitet
- Notifications vorbereitet (FOLLOW, BIRTHDAY später)
DATABASE (DEV):
- PostgreSQL läuft im Docker
- Eigener Port: 55432
- Credentials:
user: onlypets_user
pass: onlypets_pass
db: onlypets
- DATABASE_URL:
postgresql://onlypets_user:onlypets_pass@localhost:55432/onlypets
SEED STATUS:
- seed.ts vollständig funktionsfähig
- Seed OHNE Passwort (Auth kommt später)
- User werden per upsert erstellt (idempotent)
- Enthält:
- 3 Alpha-User
- Pets mit birthDate (DateTime via new Date())
- Posts
- Follow-Beziehungen
- Seed läuft nur NACH Migration
- Seed mehrfach ausführbar ohne Fehler
AUTH STATUS:
- Noch kein Passwortfeld im User-Model
- Auth bewusst verschoben
- Schema & Seed auth-ready gedacht (passwordHash später)
ISSUES:
- Issue #1: Seed / DB → erledigt
- Issue #242: Teilweise vorbereitet (Seed & Datenbasis), noch offen
LAST ACTIONS:
- Docker-Port-Konflikte gelöst
- ENV-Probleme identifiziert & behoben
- Migration → Seed Reihenfolge geklärt
- Seed finalisiert & stabil gemacht
NEXT INTENDED STEPS:
- Frontend anbinden & Feed testen
- Oder Auth vorbereiten (passwordHash + Migration)
- Oder Seed erweitern (Likes, Comments, Discovery)
-----------------------------------------------------
Gitea - Issue Liste zum abarbeiten !
Projekt: OnlyPets (Social Network für Haustiere)
Tech-Stack:
- Backend: NestJS + Prisma
- DB: PostgreSQL (lokal, aktuell instabil / Multi-Versionen 12,16,17)
- Frontend: Next.js (läuft getrennt unter C:/Projekte/OnlyPets/)
- Repo: Gitea (selfhosted), Issues aktiv genutzt
- OS: Windows (PowerShell)
Status Backend (wichtig):
- Prisma Schema vorhanden (User, Pet, Post, Follow etc.)
- birthDate wird als DateTime gespeichert (nicht Alter)
- Follow/Unfollow Controller existiert
- Feed + Discovery Feed Logik umgesetzt (Visibility public / followers)
- Fake-Auth / Dev-User Konzept beschlossen (kein echter Login yet)
- Seed läuft prinzipiell, aber:
- PostgreSQL Auth & Rollen waren fehlerhaft
- Mehrere PG-Versionen gleichzeitig aktiv
- Tabellen fehlten → Migration nötig
- Seed jetzt OHNE Passwort (Auth nur vorbereitet)
- Seed-Fehler zuletzt: Unique constraint (email) → Seed muss idempotent werden
Prisma / DB Probleme:
- DATABASE_URL mehrfach getestet (5432 / 55432)
- Auth-Fehler → User/Rolle existierte nicht
- Lösungspfad:
- Entweder PostgreSQL sauber neu (1 Version)
- ODER Übergang auf Docker-Postgres
- Migration (`prisma migrate deploy`) vor Seed ist Pflicht
Seed-Ziel:
- Dev-User (ohne Passwort)
- Pets mit birthDate als Date (ISO / new Date())
- Posts, Follows
- Kein Auth-Zwang
Auth-Strategie (entscheidend):
- Guard-first, KEINE Auth-Logik in Services
- Globaler Dummy Auth Guard
- @Public() Decorator für offene Endpunkte
- req.user immer definiert (null oder Fake-User)
- Rollen vorbereitet (USER / ADMIN / MOD)
Aktive Issues (Auszug, merken!):
- #242 Fake-User für Dev/Admin
- #241 Zentrale Auth-Regeln
- #240 Public Decorator
- #239 Globaler Auth-Guard (Dummy)
- #238 Request-User Typisierung
- Phase 1 Fundament ist aktuell aktiv
- Phase 2 Tier & Social danach
Arbeitsstil:
- Klare Pfade nennen (vollständig)
- Kurz & gebündelt, kein Datei-Hopping
- Backend zuerst stabil, Frontend später anbinden
---
# ✅ PROJEKT-BACKUP · STAND **26.12.2025 (TAGESABSCHLUSS)**
**Projekt:** OnlyPets (ehemals OnlyCats)
---
## STACK
* **Backend:** NestJS + Prisma
* **DB (DEV):** PostgreSQL (Docker)
* **Frontend:** Next.js (separat, nicht Teil dieses Blocks)
---
## CORE IDEA
* Tiere sind **primäre Entität**
* Kein Post ohne Pet
* User sind sekundär
---
## DATA PRINCIPLES
* Single Source of Truth
* `Pet.age` wird **niemals gespeichert**
* Stattdessen: `Pet.birthDate: DateTime`
* Alter & Geburtstag werden **immer berechnet**
* Keine redundanten Altersfelder
---
## PET RULES
* `birthDate` optional, aber bevorzugt
* `fallbackAge` nur temporär (nicht persistent)
* Pet-Edit erlaubt birthDate-Korrektur
* `type` & `breed` immutable
* `visibility`: `public | followers` (serverseitig enforced)
---
## DATABASE (DEV AKTUELL STABIL)
* PostgreSQL läuft **im Docker**
* Port: **55432**
* Credentials:
* user: `onlypets_user`
* pass: `onlypets_pass`
* db: `onlypets`
* `DATABASE_URL`:
```
postgresql://onlypets_user:onlypets_pass@localhost:55432/onlypets
```
---
## SEED STATUS
* `seed.ts` vollständig funktionsfähig
* Seed **ohne Passwort** (Auth kommt später)
* User via **upsert** (idempotent)
* Enthält:
* Alpha-User
* Pets mit `birthDate`
* Posts
* Follow-Beziehungen
* Reihenfolge fix:
* Migration → Seed
* Seed mehrfach ausführbar **ohne Fehler**
---
## AUTH STATUS **PHASE 1 ABGESCHLOSSEN (HEUTE)**
* ❌ Noch kein Passwortfeld im User-Model
* ❌ Kein echter Login (bewusst verschoben)
* ✅ Guard-first Architektur umgesetzt
* ✅ Globaler DummyAuthGuard aktiv
* ✅ `@Public()` Decorator implementiert
* ✅ `req.user` ist **immer definiert** (`null` oder Fake-User)
* ✅ Rollen vorbereitet (`USER | MOD | ADMIN`)
* ✅ Fake-User steuerbar über ENV
* ✅ Keine Auth-Logik in Services
---
## BACKEND STATUS **HEUTE ERREICHT**
* NestJS **korrekt gebootstrapped**
* HTTP-Driver (`@nestjs/platform-express`) installiert
* TypeScript **strict & stabil**
* Decorators korrekt konfiguriert:
* `experimentalDecorators`
* `emitDecoratorMetadata`
* Globaler Auth-Guard registriert
* **HealthController aktiv**
* `GET /health`
* Antwort: `{ status: "ok", timestamp }`
* **Smoke Test erfolgreich**
* Server antwortet
* Routing funktioniert
* Auth blockiert Public-Routen nicht
---
## ISSUE STATUS (GITEA)
**HEUTE ERLEDIGT & GESCHLOSSEN:**
* #238 Request-User Typisierung
* #239 Globaler Auth-Guard (Dummy)
* #240 Public Decorator
* #241 Zentrale Auth-Regeln
* #242 Fake-User für Dev/Admin
➡️ **Phase 1 Fundament offiziell abgeschlossen**
---
## LAST ACTIONS (HEUTE)
* NestJS Backend startfähig gemacht
* ts-node / TS / Decorator-Fehler sauber gelöst
* DummyAuth produktiv aktiviert
* Health-Endpoint als Smoke Test eingebaut
* Vollständiger **Backup-Commit** erstellt
* Push zu Gitea durchgeführt
---
## AKTUELLER PROJEKTSTATUS
🟢 **Backend Fundament stabil**
🟢 **Auth-Architektur vorbereitet**
🟢 **DB & Seed zuverlässig**
🟢 **Bereit für Phase 2**
---
## NEXT INTENDED STEPS (NICHT HEUTE)
* Phase 2:
* Pets / Posts Controller aktivieren
* Feed & Discovery real testen
* Oder:
* Auth Phase 2 (passwordHash + JWT)
* Oder:
* Frontend anbinden
---
## FAZIT (intern & ehrlich)
> Das heutige Ziel war **nicht Features**,
> sondern **Stabilität, Architektur und Reproduzierbarkeit**.
>
> **Ziel erreicht.**
---
Wenn du willst, mache ich dir das beim nächsten Start:
* als **CHANGELOG.md**
* oder als **`PROJECT_STATUS.md`**
* oder direkt als **README-Abschnitt**
---
Mega gut gepflegtes Tagebuch 👍
Ich habe deinen Text **inhaltlich geprüft, konsolidiert und auf den HEUTIGEN Stand erweitert**
ohne deinen Stil kaputtzumachen.
Unten bekommst du **eine neue, saubere TXT-Version**, **1:1 Copy-Paste**, die du:
* als neues Tagebuch ablegen kannst **ODER**
* als Ersatz für die bisherige Datei nutzen kannst
Ich habe **nur ergänzt & präzisiert**, nichts „schöngefärbt“.
---
# 📘 PROJECT ONLYPETS ENTWICKLERTAGEBUCH
**(ehemals OnlyCats)**
**Stand:** **27.12.2025 FEED & FRONTEND STABILISIERT**
---
## STACK
* **Backend:** NestJS + Prisma
* **DB (DEV):** PostgreSQL (Docker)
* **Frontend:** Next.js (App Router, getrenntes Projekt)
* **Repo:** Gitea (selfhosted, Issues aktiv)
* **OS:** Windows (PowerShell)
---
## CORE IDEA
* Tiere sind **primäre Entität**
* Kein Post ohne Pet
* User sind sekundär (Owner / Kontext)
---
## DATA PRINCIPLES
* Single Source of Truth
* `Pet.age` wird **niemals gespeichert**
* Stattdessen: `Pet.birthDate: DateTime`
* Alter & Geburtstag werden **immer berechnet**
* Keine redundanten Altersfelder
---
## PET RULES
* `birthDate` optional, aber bevorzugt
* `fallbackAge` nur temporär (nicht persistent)
* Pet-Edit erlaubt birthDate-Korrektur
* `type` & `breed` immutable
* `visibility`: `public | followers` (serverseitig enforced)
---
## DATABASE (DEV STABIL)
* PostgreSQL läuft **im Docker**
* Port: **55432**
* Credentials:
* user: `onlypets_user`
* pass: `onlypets_pass`
* db: `onlypets`
* `DATABASE_URL`:
```
postgresql://onlypets_user:onlypets_pass@localhost:55432/onlypets
```
---
## BACKEND STATUS (AKTUELL)
* Prisma Schema vollständig vorhanden
* Migration erfolgreich ausgeführt
* Tabellen existieren
* Follow / Unfollow implementiert
* Visibility-Logik (`public | followers`) vorbereitet
* Notifications vorbereitet (FOLLOW, BIRTHDAY später)
* Feed-Endpoint `/feed` funktionsfähig
* Backend läuft stabil auf **Port 3001**
---
## SEED STATUS
* `seed.ts` vollständig funktionsfähig
* Seed **ohne Passwort** (Auth kommt später)
* User via **upsert** (idempotent)
* Enthält:
* Alpha-User
* Pets mit `birthDate`
* Posts
* Follow-Beziehungen
* Reihenfolge:
* Migration → Seed
* Seed mehrfach ausführbar **ohne Fehler**
---
## AUTH STATUS PHASE 1 ABGESCHLOSSEN
* ❌ Kein Passwortfeld im User-Model
* ❌ Kein echter Login (bewusst verschoben)
* ✅ Guard-first Architektur
* ✅ Globaler `DummyAuthGuard`
* ✅ `@Public()` Decorator
* ✅ `req.user` immer definiert (Fake-User oder `null`)
* ✅ Rollen vorbereitet (`USER | MOD | ADMIN`)
* ✅ Fake-User steuerbar per ENV
* ✅ Keine Auth-Logik in Services
---
## FRONTEND STATUS **HEUTE ERREICHT**
* Frontend läuft stabil auf **Port 3000**
* API-Anbindung an Backend (`3001`) erfolgreich getestet
* `/feed` Route existiert & funktioniert
* App Router korrekt genutzt
* **Mock-Feed aktiv**, kein Backend-Zwang
### Feed-Logik (Frontend)
* Follow-Feed + Discovery-Feed kombiniert
* Discovery-Ratio: **15 %**
* Relevance-Score:
* Pet-Typ
* Rasse
* Altersnähe
* Likes & Comments
* `isDiscovery` sauber an `PostCard` durchgereicht
### PostCard Status
* Typen konsolidiert (`petBirthDate?` ergänzt)
* Birthday-Logik vorbereitet
* Relative Zeit via `timeAgo`
* UX-Polish:
* klare Hierarchie (Titel / Meta / Text)
* Action-Zone (Like / Comment)
* Preview-Modal mit Touch-Dismiss
➡️ **Mock → Backend-Feed später nahezu 1:1 möglich**
---
## ISSUE STATUS (GITEA)
### Heute neu abgeschlossen:
* **#244 Frontend: Feed-Route stabilisieren & PostCard UX-Polish** ✅
### Bereits erledigt (Phase 1 Fundament):
* #238 Request-User Typisierung
* #239 Globaler Auth-Guard (Dummy)
* #240 Public Decorator
* #241 Zentrale Auth-Regeln
* #242 Fake-User für Dev/Admin
➡️ **Phase 1 offiziell abgeschlossen**
➡️ **Frontend-Feed vorbereitet**
---
## LAST ACTIONS (HEUTE)
* Backend-Ports bereinigt (3001)
* Frontend / Backend parallel stabilisiert
* Feed-Route verifiziert
* Mock-Feed & Discovery-Logik geprüft
* Typ-Inkonsistenzen behoben
* UX-Polish umgesetzt
* Commit & Push durchgeführt
* Issue #244 dokumentiert & geschlossen
---
## AKTUELLER PROJEKTSTATUS
🟢 Backend Fundament stabil
🟢 Auth-Architektur vorbereitet
🟢 DB & Seed zuverlässig
🟢 Frontend-Feed stabil (Mock)
🟢 Klarer Übergang zu Phase 2
---
## NEXT INTENDED STEPS (NACH BREAK)
* Phase 2:
* Mock-Feed → echtes `/feed` Backend
* Pets / Posts Controller vertiefen
* Oder:
* Auth Phase 2 (passwordHash + JWT)
* Oder:
* Frontend weiter UX-polishen
---
## FAZIT (INTERN)
> Heute ging es **nicht um neue Features**,
> sondern um **Stabilität, Konsistenz und Zukunftssicherheit**.
>
> **Ziel erreicht. Sehr guter Stand für eine Pause.**
+2741
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "onlypets-backend",
"version": "1.0.0",
"scripts": {
"dev": "ts-node src/main.ts",
"start": "node dist/main.js",
"build": "tsc",
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@nestjs/core": "11.1.10",
"@nestjs/platform-express": "^11.1.10",
"@prisma/client": "^6.19.1",
"express": "^4.17.1",
"prisma": "^6.19.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"devDependencies": {
"@types/express": "^5.0.6",
"nodemon": "^3.1.11"
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'prisma/config';
export default defineConfig({
datasource: {
url: process.env.DATABASE_URL!,
},
migrations: {
seed: 'ts-node ./prisma/seed.ts',
},
});
@@ -0,0 +1,82 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Pet" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"breed" TEXT NOT NULL,
"birthDate" TIMESTAMP(3),
"gender" TEXT,
"bio" TEXT,
"avatarUrl" TEXT,
"visibility" TEXT NOT NULL DEFAULT 'public',
"ownerId" INTEGER NOT NULL,
CONSTRAINT "Pet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Post" (
"id" SERIAL NOT NULL,
"content" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"petId" INTEGER NOT NULL,
"authorId" INTEGER NOT NULL,
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Follow" (
"id" SERIAL NOT NULL,
"followerId" INTEGER NOT NULL,
"followingId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Follow_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Notification" (
"id" SERIAL NOT NULL,
"type" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"read" BOOLEAN NOT NULL DEFAULT false,
"userId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Follow_followerId_followingId_key" ON "Follow"("followerId", "followingId");
-- AddForeignKey
ALTER TABLE "Pet" ADD CONSTRAINT "Pet_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_petId_fkey" FOREIGN KEY ("petId") REFERENCES "Pet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Follow" ADD CONSTRAINT "Follow_followerId_fkey" FOREIGN KEY ("followerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Follow" ADD CONSTRAINT "Follow_followingId_fkey" FOREIGN KEY ("followingId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'prisma/config';
export default defineConfig({
datasource: {
url: process.env.DATABASE_URL!,
},
migrations: {
seed: 'ts-node ./prisma/seed.ts',
},
});
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+79
View File
@@ -0,0 +1,79 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
pets Pet[]
posts Post[]
following Follow[] @relation("Follower")
followers Follow[] @relation("Following")
createdAt DateTime @default(now())
notifications Notification[]
}
model Pet {
id Int @id @default(autoincrement())
name String
type String // "Hund" | "Katze"
breed String
birthDate DateTime? // ⭐ Single Source of Truth für Alter
gender String?
bio String?
avatarUrl String?
visibility String @default("public") // public | followers
ownerId Int
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
content String?
createdAt DateTime @default(now())
petId Int
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
authorId Int
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
}
model Follow {
id Int @id @default(autoincrement())
followerId Int
follower User @relation("Follower", fields: [followerId], references: [id], onDelete: Cascade)
followingId Int
following User @relation("Following", fields: [followingId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([followerId, followingId])
}
model Notification {
id Int @id @default(autoincrement())
type String // 'FOLLOW' | 'BIRTHDAY'
payload Json
read Boolean @default(false)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
+137
View File
@@ -0,0 +1,137 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('DATABASE_URL:', process.env.DATABASE_URL);
/**
* 1. USERS (ohne Passwort Auth kommt später)
*/
const users = await Promise.all([
prisma.user.upsert({
where: { email: 'alice@onlypets.dev' },
update: {},
create: {
email: 'alice@onlypets.dev',
name: 'Alice',
},
}),
prisma.user.upsert({
where: { email: 'bob@onlypets.dev' },
update: {},
create: {
email: 'bob@onlypets.dev',
name: 'Bob',
},
}),
prisma.user.upsert({
where: { email: 'carla@onlypets.dev' },
update: {},
create: {
email: 'carla@onlypets.dev',
name: 'Carla',
},
}),
]);
/**
* 2. PETS
*/
const pets = await Promise.all([
prisma.pet.create({
data: {
name: 'Milo',
type: 'Katze',
breed: 'EKH',
birthDate: new Date('2020-05-12'),
ownerId: users[0].id,
visibility: 'public',
},
}),
prisma.pet.create({
data: {
name: 'Luna',
type: 'Hund',
breed: 'Labrador',
birthDate: new Date('2019-11-03'),
ownerId: users[1].id,
visibility: 'followers',
},
}),
prisma.pet.create({
data: {
name: 'Oscar',
type: 'Katze',
breed: 'BKH',
birthDate: new Date('2022-02-01'),
ownerId: users[2].id,
visibility: 'public',
},
}),
]);
/**
* 3. POSTS
*/
await Promise.all([
prisma.post.create({
data: {
content: 'Milo liebt die Sonne ☀️',
authorId: users[0].id,
petId: pets[0].id,
},
}),
prisma.post.create({
data: {
content: 'Luna beim Spaziergang 🐕',
authorId: users[1].id,
petId: pets[1].id,
},
}),
prisma.post.create({
data: {
content: 'Oscar schläft schon wieder 😴',
authorId: users[2].id,
petId: pets[2].id,
},
}),
]);
/**
* 4. FOLLOWS
*/
await Promise.all([
prisma.follow.create({
data: {
followerId: users[0].id,
followingId: users[1].id,
},
}),
prisma.follow.create({
data: {
followerId: users[0].id,
followingId: users[2].id,
},
}),
prisma.follow.create({
data: {
followerId: users[1].id,
followingId: users[2].id,
},
}),
]);
console.log('✅ Seed erfolgreich ausgeführt');
}
main()
.catch((e) => {
console.error('❌ Seed fehlgeschlagen:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+28
View File
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { PostsModule } from './posts/posts.module';
import { FeedModule } from './feed/feed.module';
import { PrismaModule } from './prisma/prisma.module';
import { PetsModule } from './pets/pets.module';
import { DummyAuthGuard } from './auth/guards/dummy-auth.guard';
import { HealthController } from './health.controller';
@Module({
imports: [
PrismaModule, // Basis zuerst
PostsModule, // /posts
FeedModule, // /feed (1:1 Spiegel)
PetsModule, // /pets
],
controllers: [
HealthController,
],
providers: [
{
provide: APP_GUARD,
useClass: DummyAuthGuard,
},
],
})
export class AppModule {}
+25
View File
@@ -0,0 +1,25 @@
# Auth (Backend)
Dieses Verzeichnis enthält das **Auth-Fundament** für OnlyPets.
## Architekturprinzipien
- Guard-first-Ansatz
- Keine Auth-Logik in Services
- Controller entscheiden nur über @Public()
- `req.user` ist immer definiert (FakeUser oder null)
## Aktueller Status
- DummyAuthGuard ist global aktiv
- Fake-User für DEV via ENV steuerbar
- Rollen vorbereitet: USER / ADMIN / MOD
- Keine echte Auth (JWT/Password) implementiert
## ENV (DEV)
```env
AUTH_MODE=dummy
DEV_FAKE_USER_ID=1
DEV_FAKE_USER_ROLE=ADMIN
DEV_FAKE_USER=true
View File
@@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
@@ -0,0 +1,43 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { RequestUser, UserRole } from '../types/request-user.type';
@Injectable()
export class DummyAuthGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(
IS_PUBLIC_KEY,
[context.getHandler(), context.getClass()],
);
const request = context.switchToHttp().getRequest();
if (isPublic) {
request.user = null;
return true;
}
if (process.env.AUTH_MODE !== 'dummy') {
request.user = null;
return true;
}
const rawRole = process.env.DEV_FAKE_USER_ROLE;
const role: UserRole =
rawRole === 'ADMIN' || rawRole === 'MOD' || rawRole === 'USER'
? rawRole
: 'USER';
const fakeUser: RequestUser = {
id: Number(process.env.DEV_FAKE_USER_ID ?? 0),
role,
isFake: process.env.DEV_FAKE_USER === 'true',
};
request.user = fakeUser;
return true;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { Request } from 'express';
import { RequestUser } from '../../types/request-user.type';
export interface AuthRequest extends Request {
user: RequestUser;
}
@@ -0,0 +1,15 @@
export type UserRole = 'USER' | 'ADMIN' | 'MOD';
export interface RequestUser {
/** User-ID aus DB oder Fake-User */
id: number;
/** Rollenbasis für Guards & Admin-Features */
role: UserRole;
/** Für DEV / Dummy-Auth */
isFake?: boolean;
/** Optional, future-proof (z. B. Feed-Optimierung) */
petIds?: number[];
}
+13
View File
@@ -0,0 +1,13 @@
import { Controller, Get, Req } from '@nestjs/common';
import { FeedService } from './feed.service';
import { AuthRequest } from '../auth/types/auth-request';
@Controller('feed')
export class FeedController {
constructor(private readonly feedService: FeedService) {}
@Get()
async getFeed(@Req() req: AuthRequest) {
return this.feedService.getFeed(req.user);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FeedController } from './feed.controller';
import { FeedService } from './feed.service';
import { PrismaService } from '../prisma/prisma.service';
@Module({
controllers: [FeedController],
providers: [FeedService, PrismaService],
})
export class FeedModule {}
+60
View File
@@ -0,0 +1,60 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RequestUser } from '../types/request-user.type';
@Injectable()
export class FeedService {
constructor(private readonly prisma: PrismaService) {}
async getFeed(user: RequestUser | null) {
const userId = user?.id;
const posts = await this.prisma.post.findMany({
where: {
OR: [
{
pet: {
visibility: 'public',
},
},
...(userId
? [
{
pet: {
ownerId: userId,
},
},
{
pet: {
visibility: 'followers',
owner: {
followers: {
some: {
followerId: userId,
},
},
},
},
},
]
: []),
],
},
include: {
pet: true,
author: true,
},
orderBy: {
createdAt: 'desc',
},
});
return posts.map((post) => ({
...post,
isDiscovery: false,
}));
}
}
+72
View File
@@ -0,0 +1,72 @@
import {
Controller,
Post,
Delete,
Param,
Req,
BadRequestException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Controller('follows')
export class FollowsController {
constructor(private readonly prisma: PrismaService) {}
@Post(':userId')
async follow(@Req() req, @Param('userId') userId: string) {
const followerId = req.user.id;
const followingId = Number(userId);
// ❌ Selbst-Follow verhindern
if (followerId === followingId) {
throw new BadRequestException({
error: 'VALIDATION_ERROR',
message: 'Du kannst dir nicht selbst folgen.',
field: 'userId',
});
}
try {
// 1️⃣ Follow erstellen
const follow = await this.prisma.follow.create({
data: {
followerId,
followingId,
},
});
// 2️⃣ Notification für den Gefolgten erzeugen
await this.prisma.notification.create({
data: {
type: 'FOLLOW',
userId: followingId,
payload: {
followerId,
followerName: req.user.name,
},
},
});
return follow;
} catch (err) {
// ❌ Bereits gefolgt (Unique-Constraint)
throw new BadRequestException({
error: 'ALREADY_FOLLOWING',
message: 'Du folgst diesem Nutzer bereits.',
field: 'userId',
});
}
}
@Delete(':userId')
async unfollow(@Req() req, @Param('userId') userId: string) {
return this.prisma.follow.delete({
where: {
followerId_followingId: {
followerId: req.user.id,
followingId: Number(userId),
},
},
});
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from './auth/decorators/public.decorator';
@Controller()
export class HealthController {
@Public()
@Get('/health')
health() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
};
}
}
+16
View File
@@ -0,0 +1,16 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: 'http://localhost:3000',
credentials: true,
});
const port = process.env.PORT || 3001;
await app.listen(port);
console.log(`🚀 Backend running on http://localhost:${port}`);
}
bootstrap();
@@ -0,0 +1,16 @@
import { Controller, Get, Req } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Controller('notifications')
export class NotificationsController {
constructor(private readonly prisma: PrismaService) {}
@Get()
getMyNotifications(@Req() req) {
return this.prisma.notification.findMany({
where: { userId: req.user.id },
orderBy: { createdAt: 'desc' },
take: 20,
});
}
}
+16
View File
@@ -0,0 +1,16 @@
export class CreatePetDto {
name: string;
type: 'Hund' | 'Katze';
breed: string;
// ENTWEDER:
birthDate?: string; // ISO
// ODER:
ageYears?: number; // nur beim Erstellen erlaubt
gender?: string;
bio?: string;
avatarUrl?: string;
visibility?: 'public' | 'followers';
}
+6
View File
@@ -0,0 +1,6 @@
export class UpdatePetDto {
birthDate?: string; // ISO einzige Altersquelle
bio?: string;
avatarUrl?: string;
visibility?: 'public' | 'followers';
}
+40
View File
@@ -0,0 +1,40 @@
import {
Controller,
Get,
Patch,
Param,
Body,
Req,
} from '@nestjs/common';
import { PetsService } from './pets.service';
import { UpdatePetDto } from './dto/update-pet.dto';
import { AuthRequest } from '../auth/types/auth-request';
@Controller('pets')
export class PetsController {
constructor(private readonly petsService: PetsService) {}
// ✅ READ-ONLY: Pet-Profil anzeigen
@Get(':id')
getPet(
@Param('id') id: string,
@Req() req: AuthRequest,
): Promise<{ owner: { id: number; name: string; createdAt: Date; email: string; }; } & { id: number; name: string; type: string; breed: string; birthDate: Date | null; gender: string | null; bio: string | null; avatarUrl: string | null; visibility: string; ownerId: number; }> {
return this.petsService.getPetById(Number(id), req.user);
}
@Patch(':id')
updatePet(
@Param('id') id: string,
@Body() dto: UpdatePetDto,
@Req() req: AuthRequest,
) {
return this.petsService.updatePet(
Number(id),
req.user!.id,
dto,
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PetsService } from './pets.service';
import { PetsController } from './pets.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [PetsController],
providers: [PetsService],
})
export class PetsModule {}
+168
View File
@@ -0,0 +1,168 @@
import {
Injectable,
BadRequestException,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreatePetDto } from './dto/create-pet.dto';
import { UpdatePetDto } from './dto/update-pet.dto';
import { RequestUser } from '../types/request-user.type';
@Injectable()
export class PetsService {
constructor(private readonly prisma: PrismaService) {}
/**
* Read-only: einzelnes Pet für Profilansicht
* Visibility-Regeln:
* - public: immer sichtbar
* - owner: immer sichtbar
* - followers: nur für Follower
*/
async getPetById(id: number, user: RequestUser) {
const pet = await this.prisma.pet.findUnique({
where: { id },
include: {
owner: true,
},
});
if (!pet) {
throw new NotFoundException('Pet not found');
}
// 1️⃣ Public Pets → immer sichtbar
if (pet.visibility === 'public') {
return pet;
}
// 2️⃣ Owner darf sein Pet immer sehen
if (user && pet.ownerId === user.id) {
return pet;
}
// 3️⃣ Followers-Visibility
if (user && pet.visibility === 'followers') {
const isFollower = await this.prisma.follow.findFirst({
where: {
followerId: user.id,
followingId: pet.ownerId,
},
});
if (isFollower) {
return pet;
}
}
// 4️⃣ Alles andere → verboten
throw new ForbiddenException('You are not allowed to view this pet');
}
// -------------------------------
// CREATE PET
// -------------------------------
async createPet(userId: number, dto: CreatePetDto) {
// ❌ Beides gleichzeitig verboten
if (dto.birthDate && dto.ageYears !== undefined) {
throw new BadRequestException({
error: 'VALIDATION_ERROR',
message: 'Entweder Geburtsdatum ODER Alter angeben, nicht beides.',
field: 'birthDate',
});
}
// ❌ Nichts angegeben verboten
if (!dto.birthDate && dto.ageYears === undefined) {
throw new BadRequestException({
error: 'VALIDATION_ERROR',
message: 'Geburtsdatum oder Alter muss angegeben werden.',
field: 'birthDate',
});
}
// ✅ BirthDate ableiten (Single Source of Truth)
let birthDate: Date | null = null;
if (dto.birthDate) {
birthDate = new Date(dto.birthDate);
}
if (dto.ageYears !== undefined) {
const today = new Date();
birthDate = new Date(
today.getFullYear() - dto.ageYears,
today.getMonth(),
today.getDate(),
);
}
// ❌ Zukunftsdatum verhindern
if (birthDate && birthDate > new Date()) {
throw new BadRequestException({
error: 'VALIDATION_ERROR',
message: 'Geburtsdatum darf nicht in der Zukunft liegen.',
field: 'birthDate',
});
}
return this.prisma.pet.create({
data: {
name: dto.name,
type: dto.type,
breed: dto.breed,
birthDate, // ⭐ WICHTIG: berechnetes Datum
gender: dto.gender,
bio: dto.bio,
avatarUrl: dto.avatarUrl,
visibility: dto.visibility ?? 'public',
ownerId: userId,
},
});
}
// -------------------------------
// UPDATE PET
// -------------------------------
async updatePet(
petId: number,
userId: number,
dto: UpdatePetDto,
) {
const pet = await this.prisma.pet.findUnique({
where: { id: petId },
});
if (!pet || pet.ownerId !== userId) {
throw new NotFoundException({
error: 'NOT_FOUND',
message: 'Pet nicht gefunden.',
});
}
let birthDate = pet.birthDate;
if (dto.birthDate) {
birthDate = new Date(dto.birthDate);
if (birthDate > new Date()) {
throw new BadRequestException({
error: 'VALIDATION_ERROR',
message: 'Geburtsdatum darf nicht in der Zukunft liegen.',
field: 'birthDate',
});
}
}
return this.prisma.pet.update({
where: { id: petId },
data: {
birthDate,
bio: dto.bio,
avatarUrl: dto.avatarUrl,
visibility: dto.visibility,
},
});
}
}
+18
View File
@@ -0,0 +1,18 @@
import { Controller, Get, Req } from '@nestjs/common';
import { PostsService } from './posts.service';
import { AuthRequest } from '../auth/types/auth-request';
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Get()
async getPosts(@Req() req: AuthRequest) {
return this.postsService.getPosts(req.user);
}
@Get('my')
async getMyPosts(@Req() req: AuthRequest) {
return this.postsService.getMyPosts(req.user);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PostsController } from './posts.controller';
import { PostsService } from './posts.service';
import { PrismaModule } from '../prisma/prisma.module';
import { PrismaService } from '../prisma/prisma.service';
@Module({
imports: [
PrismaModule, // 👈 DAS ist der Schlüssel
],
controllers: [PostsController],
providers: [PostsService, PrismaService,],
})
export class PostsModule {}
+70
View File
@@ -0,0 +1,70 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { RequestUser } from '../types/request-user.type';
@Injectable()
export class PostsService {
constructor(private readonly prisma: PrismaService) {}
async getPosts(user: RequestUser) {
const userId = user?.id;
return this.prisma.post.findMany({
where: {
OR: [
{
pet: {
visibility: 'public',
},
},
...(userId
? [
{
pet: {
ownerId: userId,
},
},
{
pet: {
visibility: 'followers',
owner: {
followers: {
some: {
followerId: userId,
},
},
},
},
},
]
: []),
],
},
include: {
pet: true,
author: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
async getMyPosts(user: RequestUser) {
return this.prisma.post.findMany({
where: {
authorId: user!.id, // 👈 explizit casten
},
include: {
pet: true,
author: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -0,0 +1,16 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}
+11
View File
@@ -0,0 +1,11 @@
import { RequestUser } from '../auth/types/request-user.type';
declare global {
namespace Express {
interface Request {
user: RequestUser;
}
}
}
+7
View File
@@ -0,0 +1,7 @@
export type UserRole = 'USER' | 'ADMIN' | 'MOD';
export type RequestUser = {
id: number;
role: UserRole;
isFake?: boolean;
} | null;
+23
View File
@@ -0,0 +1,23 @@
async getUserProfile(userId: number) {
const [user, followersCount, followingCount] = await Promise.all([
this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
},
}),
this.prisma.follow.count({
where: { followingId: userId },
}),
this.prisma.follow.count({
where: { followerId: userId },
}),
]);
return {
...user,
followersCount,
followingCount,
};
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "CommonJS",
"target": "ES2021",
"moduleResolution": "node",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"typeRoots": ["./node_modules/@types", "./src/types"]
},
"include": ["src/**/*.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
version: "3.9"
services:
postgres:
image: postgres:16
container_name: onlypets-postgres
restart: unless-stopped
ports:
- "55432:5432"
environment:
POSTGRES_USER: onlypets_user
POSTGRES_PASSWORD: onlypets_pass
POSTGRES_DB: onlypets
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
+22
View File
@@ -34,3 +34,25 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Auth-Architektur (Grundlagen)
### Prinzipien
- Guard-first Architektur
- Keine Auth-Logik in Services
- Controller bleiben dünn
- Zentrale User-Wahrheit über Request-Kontext
### RequestUser
- `req.user` ist der einzige User-Kontext im Request
- Typ: `RequestUser`
- ID ist **number** (DB-kompatibel)
- Kann `null` sein (Public Routes)
```ts
export type UserRole = 'USER' | 'ADMIN' | 'MOD';
export type RequestUser = {
id: number;
role: UserRole;
isFake?: boolean;
} | null;
+33 -10
View File
@@ -1,6 +1,8 @@
'use client';
import { useRef } from 'react';
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
import { timeAgo } from "@/lib/time";
export type PostCardData = {
id: number;
@@ -11,6 +13,7 @@ export type PostCardData = {
text: string;
likesCount: number;
commentsCount: number;
petbirthDate?: string;
};
@@ -31,6 +34,7 @@ export default function PostCard({
}: PostCardProps) {
const likeRef = useRef<HTMLButtonElement | null>(null);
const commentRef = useRef<HTMLButtonElement | null>(null);
const isBirthday = post.petBirthDate && isBirthdayToday(post.petBirthDate);
function animate(
ref: React.RefObject<HTMLButtonElement>,
@@ -50,11 +54,13 @@ export default function PostCard({
return (
<article
className={`postcard ${
isDiscovery ? 'postcard--discovery' : ''
}`}
className={`postcard
${isDiscovery ? 'postcard--discovery' : ''}
${isBirthday ? 'postcard--birthday' : ''}
`}
>
{/* Klickbarer Bereich → Preview */}
<div
className="postcard-main"
@@ -70,14 +76,31 @@ export default function PostCard({
</div>
)}
<div className="postcard-title-row">
<strong className="postcard-title">
{post.petName}
</strong>
<span className="postcard-meta">
· {post.petType} · {post.authorName} · {post.createdAt}
<div className="postcard-title-row">
<strong className="postcard-title">
{post.petName}
</strong>
<span className="postcard-meta">
· {post.petType}
{post.petBirthDate && ` · ${formatPetAge(post.petBirthDate)}`}
· {post.authorName}
· {timeAgo(post.createdAt)}
</span>
{isBirthday && (
<span
className="birthday-dot"
title="Heute ist Geburtstag 🎉"
aria-label="Geburtstag"
>
🎂
</span>
</div>
)}
</div>
</div>
<p className="postcard-text">{post.text}</p>
+1
View File
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
import PostCard, {
PostCardData,
} from '@/app/components/PostCard/PostCard';
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
/* =======================
MOCK DATA
+5 -4
View File
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
import PostCard, {
PostCardData,
} from '@/app/components/PostCard/PostCard';
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
/* =======================
MOCK: USER KONTEXT
@@ -39,7 +40,7 @@ const allPosts: FeedPost[] = [
breed: 'Bengal',
ageMonths: 26,
authorName: 'Anna',
createdAt: 'vor 10 Minuten',
createdAt: new Date().toISOString(),
text: 'Luna liebt den neuen Kratzbaum.',
likesCount: 8,
commentsCount: 2,
@@ -52,7 +53,7 @@ const allPosts: FeedPost[] = [
breed: 'Labrador',
ageMonths: 30,
authorName: 'Tim',
createdAt: 'vor 1 Stunde',
createdAt: new Date().toISOString(),
text: 'Rocky war schwimmen 🐕‍🦺',
likesCount: 15,
commentsCount: 4,
@@ -65,7 +66,7 @@ const allPosts: FeedPost[] = [
breed: 'Bengal',
ageMonths: 22,
authorName: 'Sophie',
createdAt: 'heute',
createdAt: new Date().toISOString(),
text: 'Milo entdeckt den Balkon.',
likesCount: 11,
commentsCount: 3,
@@ -78,7 +79,7 @@ const allPosts: FeedPost[] = [
breed: 'Maine Coon',
ageMonths: 40,
authorName: 'Laura',
createdAt: 'gestern',
createdAt: new Date().toISOString(),
text: 'Nala beobachtet alles ganz ruhig.',
likesCount: 6,
commentsCount: 1,
+70 -2
View File
@@ -180,11 +180,11 @@ body {
padding: var(--space-lg) var(--space-md) var(--space-md);
}
.feed-header p,
/*.feed-header p,
.explore-header p {
color: var(--color-text-secondary);
margin: 0.25rem 0 0;
}
}*/
.post-list {
display: flex;
flex-direction: column;
@@ -721,3 +721,71 @@ select:disabled {
color: var(--color-text-muted);
cursor: not-allowed;
}
.postcard-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.birthday-dot {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 12px;
background: var(--color-primary-soft);
border: 1px solid rgba(46, 125, 50, 0.25);
box-shadow: var(--shadow-sm);
font-size: 0.95rem;
}
/* Birthday Highlight */
.postcard--birthday {
border-color: rgba(46, 125, 50, 0.35);
box-shadow:
0 0 0 2px rgba(46, 125, 50, 0.18),
var(--shadow-sm);
}
/* ===========================
UX Polish PostCard hierarchy & actions
(safe additions, no refactor)
=========================== */
.postcard-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--color-text-main, #111);
}
.postcard-meta {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
white-space: nowrap;
}
.postcard-text {
margin-top: var(--space-sm, 10px);
font-size: 0.95rem;
line-height: 1.55;
color: var(--color-text-secondary, #1f2937);
max-width: 65ch;
}
/* Click affordance: main area opens preview, actions do not */
.postcard-main {
cursor: pointer;
}
.postcard-actions {
cursor: default;
margin-top: var(--space-sm, 10px);
padding-top: var(--space-sm, 10px);
border-top: 1px solid var(--color-border, rgba(0,0,0,0.08));
}
.post-locked-hint {
margin-top: 0.5rem;
font-size: 0.85rem;
opacity: 0.7;
}
+34
View File
@@ -0,0 +1,34 @@
export function calculatePetAge(birthDate: string) {
const today = new Date();
const birth = new Date(birthDate);
let age = today.getFullYear() - birth.getFullYear();
const hasHadBirthdayThisYear =
today.getMonth() > birth.getMonth() ||
(today.getMonth() === birth.getMonth() &&
today.getDate() >= birth.getDate());
if (!hasHadBirthdayThisYear) {
age -= 1;
}
return age < 0 ? 0 : age;
}
export function isBirthdayToday(birthDate: string) {
const today = new Date();
const birth = new Date(birthDate);
return (
today.getDate() === birth.getDate() &&
today.getMonth() === birth.getMonth()
);
}
export function formatPetAge(birthDate: string) {
const age = calculatePetAge(birthDate);
if (age === 0) return 'unter 1 Jahr';
if (age === 1) return '1 Jahr';
return `${age} Jahre`;
}
+162 -257
View File
@@ -1,326 +1,231 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { dogBreeds, catBreeds } from '@/app/data/breeds';
type PetType = 'Hund' | 'Katze';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
formatPetAge,
} from '@/app/lib/petAge';
/* Mock später API */
type Pet = {
id: number;
name: string;
type: PetType;
type: 'Hund' | 'Katze';
breed: string;
birthDate?: string;
gender?: 'Männlich' | 'Weiblich' | 'Unbekannt';
bio?: string;
visibility: 'public' | 'followers';
};
// 🔧 Mock-Daten (später Backend)
/* MOCK-DATEN */
const mockPets: Pet[] = [
{
id: 1,
name: 'Luna',
type: 'Katze',
breed: 'Bengal x Mix',
bio: 'Neugierig, verspielt und liebt Sonne.',
},
{
id: 2,
name: 'Bello',
type: 'Hund',
breed: 'Labrador Retriever x Mix',
bio: 'Immer gute Laune.',
breed: 'Bengal',
birthDate: '2021-05-14',
gender: 'Weiblich',
bio: 'Neugierig und verspielt',
visibility: 'public',
},
];
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function EditPetPage({ params }: PageProps) {
const { id } = await params;
const petId = Number(id);
export default function EditPetPage({
params,
}: {
params: { id: string };
}) {
const router = useRouter();
const petId = Number(params.id);
const pet = mockPets.find((p) => p.id === petId);
if (!pet) {
return <p>Haustier nicht gefunden.</p>;
}
return <EditPetForm pet={pet} />;
}
/* ---------- FORMULAR (Client Component) ---------- */
function EditPetForm({ pet }: { pet: Pet }) {
const [name, setName] = useState(pet.name);
const [type, setType] = useState<PetType>(pet.type);
const [breed, setBreed] = useState(pet.breed);
const [breedSearch, setBreedSearch] = useState('');
const [bio, setBio] = useState(pet.bio ?? '');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const breeds = type === 'Hund' ? dogBreeds : catBreeds;
const filteredBreeds = breeds.filter((b) =>
b.toLowerCase().includes(breedSearch.toLowerCase())
const [name, setName] = useState('');
const [birthDate, setBirthDate] = useState('');
const [ageFallback, setAgeFallback] = useState('');
const [gender, setGender] = useState<'Männlich' | 'Weiblich' | 'Unbekannt'>(
'Unbekannt'
);
const [bio, setBio] = useState('');
const [visibility, setVisibility] = useState<'public' | 'followers'>(
'public'
);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
/* Initialwerte setzen */
useEffect(() => {
if (!pet) return;
setName(pet.name);
setBirthDate(pet.birthDate || '');
setGender(pet.gender || 'Unbekannt');
setBio(pet.bio || '');
setVisibility(pet.visibility);
}, [pet]);
if (!pet) {
return (
<section className="empty-state">
<strong>Haustier nicht gefunden</strong>
<span>Bitte gehe zurück zur Übersicht.</span>
</section>
);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSuccess('');
if (!name.trim()) {
setError('Name darf nicht leer sein.');
setError('Der Name darf nicht leer sein.');
return;
}
if (!breed) {
setError('Bitte wähle eine Rasse aus.');
return;
}
const updatedPetPayload = {
id: pet.id,
const updatedPet = {
...pet,
name,
type,
breed,
bio: bio || null,
updatedAt: new Date().toISOString(),
birthDate: birthDate || null,
ageFallback: birthDate ? null : ageFallback || null,
gender,
bio,
visibility,
};
console.log('Haustier aktualisiert (Mock):', updatedPetPayload);
console.log('UPDATED PET:', updatedPet);
setSuccess('Änderungen gespeichert');
setSuccess('Änderungen wurden gespeichert.');
setTimeout(() => {
router.push(`/pets/${pet.id}`);
}, 800);
}
function handleDelete() {
console.log('Haustier gelöscht (Mock):', pet.id);
// Später:
// await fetch(`/api/pets/${pet.id}`, { method: 'DELETE' })
// router.push('/pets');
setShowDeleteConfirm(false);
alert('Haustier wurde technisch gelöscht (Mock)');
}
function handleCancel() {
router.back();
}
return (
<section
style={{
maxWidth: '600px',
margin: '0 auto',
display: 'grid',
gap: '1.5rem',
}}
>
<header>
<section className="pets-page">
<header className="pets-header">
<h1>Haustier bearbeiten</h1>
<p style={{ color: '#555' }}>
Passe die Informationen deines Haustiers an.
</p>
<p>Du kannst die Angaben jederzeit korrigieren.</p>
</header>
<form
onSubmit={handleSubmit}
style={{
border: '1px solid #e5e5e5',
borderRadius: '16px',
padding: '1rem',
display: 'grid',
gap: '1rem',
backgroundColor: 'white',
}}
>
<form className="pet-form" onSubmit={handleSubmit}>
{/* Name */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Name</strong>
<input
value={name}
onChange={(e) => setName(e.target.value)}
style={{
padding: '0.7rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
/>
<div className="form-group">
<label>
Name <span className="required">*</span>
</label>
<input value={name} onChange={(e) => setName(e.target.value)} />
</div>
{/* Art */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Tier-Art</strong>
<select
value={type}
{/* Art & Rasse (read-only) */}
<div className="form-group">
<label>Tier-Art</label>
<input value={pet.type} disabled />
<small>Kann nachträglich nicht geändert werden.</small>
</div>
<div className="form-group">
<label>Rasse</label>
<input value={pet.breed} disabled />
<small>Kann nachträglich nicht geändert werden.</small>
</div>
{/* Geburtsdatum */}
<div className="form-group">
<label>Geburtsdatum</label>
<input
type="date"
value={birthDate}
onChange={(e) => {
setType(e.target.value as PetType);
setBreed('');
setBreedSearch('');
}}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
maxWidth: '220px',
}}
>
<option value="Katze">Katze</option>
<option value="Hund">Hund</option>
</select>
</div>
{/* Rasse */}
<div style={{ display: 'grid', gap: '0.5rem' }}>
<strong>Rasse</strong>
<input
placeholder="Rasse suchen …"
value={breedSearch}
onChange={(e) => setBreedSearch(e.target.value)}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
setBirthDate(e.target.value);
if (e.target.value) setAgeFallback('');
}}
/>
{birthDate && (
<small>
Aktuelles Alter: <strong>{formatPetAge(birthDate)}</strong>
</small>
)}
</div>
{/* Fallback-Alter */}
{!birthDate && (
<div className="form-group">
<label>Alter (geschätzt)</label>
<input
type="number"
min={0}
max={30}
value={ageFallback}
onChange={(e) => setAgeFallback(e.target.value)}
/>
<small>Wird verwendet, wenn kein Geburtsdatum bekannt ist.</small>
</div>
)}
{/* Geschlecht */}
<div className="form-group">
<label>Geschlecht</label>
<select
value={breed}
onChange={(e) => setBreed(e.target.value)}
size={Math.min(filteredBreeds.length, 6)}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
value={gender}
onChange={(e) =>
setGender(e.target.value as 'Männlich' | 'Weiblich' | 'Unbekannt')
}
>
{filteredBreeds.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
<option value="Unbekannt">Unbekannt</option>
<option value="Männlich">Männlich</option>
<option value="Weiblich">Weiblich</option>
</select>
</div>
{/* Bio */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Kurzbeschreibung</strong>
<div className="form-group">
<label>Kurzbeschreibung</label>
<textarea
rows={3}
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={3}
style={{
padding: '0.8rem',
borderRadius: '12px',
border: '1px solid #ccc',
}}
/>
</div>
{/* Sichtbarkeit */}
<div className="form-group">
<label>Sichtbarkeit</label>
<select
value={visibility}
onChange={(e) =>
setVisibility(e.target.value as 'public' | 'followers')
}
>
<option value="public">Öffentlich</option>
<option value="followers">Nur für Abonnenten</option>
</select>
</div>
{/* Meldungen */}
{error && <div style={{ background: '#f8d7da', padding: '0.7rem' }}>{error}</div>}
{success && <div style={{ background: '#d1e7dd', padding: '0.7rem' }}>{success}</div>}
{error && <div className="form-error">{error}</div>}
{success && <div className="form-success">{success}</div>}
{/* Aktionen */}
<div style={{ display: 'flex', gap: '0.8rem' }}>
<button
type="submit"
style={{
padding: '0.8rem 1.2rem',
borderRadius: '12px',
border: 'none',
backgroundColor: '#2E7D32',
color: 'white',
fontWeight: 700,
}}
>
<div className="form-actions">
<button type="submit" className="button-primary">
Änderungen speichern
</button>
<Link href={`/pets/${pet.id}`}>Abbrechen</Link>
<hr style={{ margin: '1rem 0', borderColor: '#eee' }} />
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
style={{
padding: '0.7rem 1.2rem',
borderRadius: '12px',
border: '1px solid #d32f2f',
backgroundColor: 'white',
color: '#d32f2f',
fontWeight: 700,
cursor: 'pointer',
}}
>
Haustier löschen
</button>
<button
type="button"
className="button-danger"
onClick={handleCancel}
>
Abbrechen
</button>
</div>
</form>
{showDeleteConfirm && (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<div
style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '1.2rem',
maxWidth: '420px',
width: '100%',
display: 'grid',
gap: '1rem',
}}
>
<h3>Haustier wirklich löschen?</h3>
<p style={{ color: '#555' }}>
Alle Beiträge dieses Haustiers gehen dabei verloren.
Diese Aktion kann nicht rückgängig gemacht werden. Bist du dir sicher?
</p>
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowDeleteConfirm(false)}
style={{
padding: '0.6rem 1rem',
borderRadius: '10px',
border: '1px solid #ccc',
backgroundColor: 'white',
cursor: 'pointer',
}}
>
Abbrechen
</button>
<button
onClick={handleDelete}
style={{
padding: '0.6rem 1rem',
borderRadius: '10px',
border: 'none',
backgroundColor: '#d32f2f',
color: 'white',
fontWeight: 700,
cursor: 'pointer',
}}
>
Löschen
</button>
</div>
</div>
</div>
)}
</section>
);
}
+11 -3
View File
@@ -2,6 +2,9 @@
import { useParams } from 'next/navigation';
import PostCard from '@/app/components/PostCard/PostCard';
import {calculatePetAge, formatPetAge, isBirthdayToday,} from '@/app/lib/petAge';
type Pet = {
id: number;
@@ -32,6 +35,7 @@ const mockPets: Pet[] = [
breed: 'Bengal',
age: '2 Jahre',
owner: 'Anna',
birthDate: '2021-06-15',
},
{
id: 2,
@@ -40,6 +44,7 @@ const mockPets: Pet[] = [
breed: 'Labrador',
age: '4 Jahre',
owner: 'Markus',
birthDate: '2020-03-22',
},
];
@@ -54,6 +59,7 @@ const mockPosts: Post[] = [
createdAt: 'vor 3 Stunden',
likes: 12,
comments: 3,
},
{
id: 102,
@@ -65,6 +71,7 @@ const mockPosts: Post[] = [
createdAt: 'gestern',
likes: 7,
comments: 1,
},
];
@@ -94,9 +101,10 @@ export default function PetDetailPage() {
<h1>{pet.name}</h1>
<span className="pet-detail-meta">
{pet.type} · {pet.breed}
{pet.age && ` · ${pet.age}`}
</span>
{pet.type} · {pet.breed}
{pet.birthDate && ` · ${formatPetAge(pet.birthDate)}`}
</span>
<span className="pet-detail-owner">
Betreut von {pet.owner}
+130 -135
View File
@@ -3,43 +3,42 @@
import { useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { dogBreeds, catBreeds } from '@/app/data/breeds';
type PetType = 'Hund' | 'Katze' | '';
export default function NewPetPage() {
const router = useRouter();
// Pflichtfelder
const [name, setName] = useState('');
const [type, setType] = useState<PetType>('');
const [breed, setBreed] = useState('');
const [breedSearch, setBreedSearch] = useState('');
// Erweiterte Felder
const [birthDate, setBirthDate] = useState('');
const [ageFallback, setAgeFallback] = useState('');
const [gender, setGender] = useState<'Männlich' | 'Weiblich' | 'Unbekannt'>(
'Unbekannt'
);
const [bio, setBio] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const router = useRouter();
function handleCancel() {
router.back(); // geht zur vorherigen Seite zurück
}
const breeds =
type === 'Hund'
? dogBreeds
: type === 'Katze'
? catBreeds
: [];
const filteredBreeds = breeds.filter((b) =>
b.toLowerCase().includes(breedSearch.toLowerCase())
const [visibility, setVisibility] = useState<'public' | 'followers'>(
'public'
);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
// UX States
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const breeds = type === 'Hund' ? dogBreeds : catBreeds;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSuccess('');
if (!name.trim()) {
setError('Bitte gib einen Namen für dein Haustier ein.');
setError('Bitte gib einen Namen für dein Tier an.');
return;
}
@@ -49,120 +48,80 @@ function handleCancel() {
}
if (!breed) {
setError('Bitte wähle eine Rasse aus.');
setError('Bitte wähle eine Rasse oder „Mix“ aus.');
return;
}
const newPetPayload = {
const petPayload = {
name,
type,
breed,
bio: bio || null,
createdAt: new Date().toISOString(),
birthDate: birthDate || null,
ageFallback: birthDate ? null : ageFallback || null,
gender,
bio,
visibility,
};
console.log('Neues Haustier (Mock):', newPetPayload);
console.log('NEW PET:', petPayload);
setSuccess('Haustier wurde technisch angelegt');
setName('');
setType('');
setBreed('');
setBreedSearch('');
setBio('');
setSuccess('Haustier wurde erfolgreich angelegt.');
setTimeout(() => {
router.push('/pets');
}, 800);
}
function handleCancel() {
router.back();
}
return (
<section
style={{
maxWidth: '600px',
margin: '0 auto',
display: 'grid',
gap: '1.5rem',
}}
>
<header>
<h1 style={{ marginBottom: '0.5rem' }}>Haustier hinzufügen</h1>
<p style={{ margin: 0, color: '#555' }}>
Lege ein neues Haustier für dein Profil an.
</p>
<section className="pets-page">
<header className="pets-header">
<h1>Haustier hinzufügen</h1>
<p>Lege ein neues Tier für dein Profil an.</p>
</header>
<form
onSubmit={handleSubmit}
style={{
border: '1px solid #e5e5e5',
borderRadius: '16px',
padding: '1rem',
display: 'grid',
gap: '1rem',
backgroundColor: 'white',
}}
>
<form className="pet-form" onSubmit={handleSubmit}>
{/* Name */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Name</strong>
<div className="form-group">
<label>
Name <span className="required">*</span>
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z. B. Luna"
style={{
padding: '0.7rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
/>
</div>
{/* Art */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Tier-Art</strong>
{/* Tier-Art */}
<div className="form-group">
<label>
Tier-Art <span className="required">*</span>
</label>
<select
value={type}
onChange={(e) => {
setType(e.target.value as PetType);
setBreed('');
setBreedSearch('');
}}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
maxWidth: '220px',
}}
>
<option value="">Bitte auswählen</option>
<option value="Katze">Katze</option>
<option value="Hund">Hund</option>
<option value="Katze">Katze</option>
</select>
</div>
{/* Rasse */}
{type && (
<div style={{ display: 'grid', gap: '0.5rem' }}>
<strong>Rasse</strong>
<input
placeholder="Rasse suchen …"
value={breedSearch}
onChange={(e) => setBreedSearch(e.target.value)}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
/>
<select
value={breed}
onChange={(e) => setBreed(e.target.value)}
size={Math.min(filteredBreeds.length, 6)}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
>
{filteredBreeds.map((b) => (
<div className="form-group">
<label>
Rasse <span className="required">*</span>
</label>
<select value={breed} onChange={(e) => setBreed(e.target.value)}>
<option value="">Bitte auswählen</option>
{breeds.map((b) => (
<option key={b} value={b}>
{b}
</option>
@@ -171,58 +130,94 @@ function handleCancel() {
</div>
)}
{/* Geburtsdatum */}
<div className="form-group">
<label>Geburtsdatum (optional)</label>
<input
type="date"
value={birthDate}
onChange={(e) => {
setBirthDate(e.target.value);
if (e.target.value) setAgeFallback('');
}}
/>
<small>
Wenn bekannt das Alter wird automatisch berechnet.
</small>
</div>
{/* Fallback Alter */}
{!birthDate && (
<div className="form-group">
<label>Alter (geschätzt)</label>
<input
type="number"
min={0}
max={30}
value={ageFallback}
onChange={(e) => setAgeFallback(e.target.value)}
placeholder="z. B. 3"
/>
<small>Optional eine grobe Angabe reicht.</small>
</div>
)}
{/* Geschlecht */}
<div className="form-group">
<label>Geschlecht</label>
<select
value={gender}
onChange={(e) =>
setGender(e.target.value as 'Männlich' | 'Weiblich' | 'Unbekannt')
}
>
<option value="Unbekannt">Unbekannt</option>
<option value="Männlich">Männlich</option>
<option value="Weiblich">Weiblich</option>
</select>
</div>
{/* Bio */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Kurzbeschreibung (optional)</strong>
<div className="form-group">
<label>Kurzbeschreibung</label>
<textarea
rows={3}
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder="Alter, Charakter, Besonderheiten …"
rows={3}
style={{
padding: '0.8rem',
borderRadius: '12px',
border: '1px solid #ccc',
}}
placeholder="Charakter, Besonderheiten, Alter …"
/>
</div>
{/* Sichtbarkeit */}
<div className="form-group">
<label>Sichtbarkeit</label>
<select
value={visibility}
onChange={(e) =>
setVisibility(e.target.value as 'public' | 'followers')
}
>
<option value="public">Öffentlich</option>
<option value="followers">Nur für Abonnenten</option>
</select>
</div>
{/* Meldungen */}
{error && (
<div style={{ background: '#f8d7da', padding: '0.7rem' }}>
{error}
</div>
)}
{success && (
<div style={{ background: '#d1e7dd', padding: '0.7rem' }}>
{success}
</div>
)}
{error && <div className="form-error">{error}</div>}
{success && <div className="form-success">{success}</div>}
{/* Aktionen */}
<div style={{ display: 'flex', gap: '0.8rem' }}>
<button
type="submit"
style={{
padding: '0.8rem 1.2rem',
borderRadius: '12px',
border: 'none',
backgroundColor: '#2E7D32',
color: 'white',
fontWeight: 700,
}}
>
<div className="form-actions">
<button type="submit" className="button-primary">
Haustier speichern
</button>
<button
type="button"
onClick={handleCancel}
className="button-danger"
>
onClick={handleCancel}
>
Abbrechen
</button>
</div>
</form>
</section>
+63
View File
@@ -0,0 +1,63 @@
'use client';
import { useEffect } from 'react';
export default function TestPage() {
useEffect(() => {
console.log('--- FE TEST START ---');
// Feed testen
fetch('http://localhost:3001/feed')
.then(res => res.json())
.then(data => {
console.log('FEED RESPONSE:', data);
})
.catch(err => {
console.error('FEED ERROR:', err);
});
// Öffentliches Pet testen
fetch('http://localhost:3001/pets/2')
.then(async res => {
if (res.status === 403) {
console.log('PET 2: FORBIDDEN');
return;
}
const data = await res.json();
console.log('PET 2 RESPONSE:', data);
})
.catch(err => {
console.error('PET 2 ERROR:', err);
});
// Followers-Pet testen (soll 403 geben)
fetch('http://localhost:3001/pets/5')
.then(async res => {
if (res.status === 403) {
console.log('PET 5: FORBIDDEN (expected)');
return;
}
const data = await res.json();
console.log('PET 5 RESPONSE:', data);
})
.catch(err => {
console.error('PET 5 ERROR:', err);
});
}, []);
return (
<section style={{ padding: '2rem' }}>
<h1>🧪 Frontend API Test</h1>
<p>
Diese Seite dient nur zum Testen der Backend-APIs.
<br />
👉 Öffne die <strong>Browser-Konsole</strong>.
</p>
<ul>
<li>Feed wird geladen</li>
<li>Pet 2 (public) wird geladen</li>
<li>Pet 5 (followers) sollte 403 sein</li>
</ul>
</section>
);
}
+3 -1
View File
@@ -1,7 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
turbopack: {
root: __dirname,
},
};
export default nextConfig;
+1
View File
@@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"dev": "set NODE_OPTIONS=--max-old-space-size=4096 && next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
+20
View File
@@ -0,0 +1,20 @@
export function timeAgo(date: string | Date) {
const ts = new Date(date).getTime();
const diffMs = Date.now() - ts;
const minutes = Math.floor(diffMs / 60000);
if (minutes < 1) return "gerade eben";
if (minutes < 60) return `vor ${minutes} Minute${minutes === 1 ? "" : "n"}`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? "" : "n"}`;
const days = Math.floor(hours / 24);
if (days < 7) return `vor ${days} Tag${days === 1 ? "" : "en"}`;
const weeks = Math.floor(days / 7);
if (weeks < 4) return `vor ${weeks} Woche${weeks === 1 ? "" : "n"}`;
// Fallback: Datum kurz
return new Date(date).toLocaleDateString("de-DE");
}
+2737
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "onlypets-frontend",
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"export": "next export"
},
"dependencies": {
"next": "^16.1.1",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"devDependencies": {
"autoprefixer": "^10.4.23",
"eslint": "^8.40.0",
"next-auth": "^4.20.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
}
}
+24
View File
@@ -0,0 +1,24 @@
@echo off
title OnlyPets Dev Starter
echo ===============================
echo Starting OnlyPets (DEV)
echo ===============================
REM --- Backend ---
echo Starting Backend on port 3001...
start "OnlyPets Backend" cmd /k ^
cd /d C:\Projekte\OnlyPets\backend ^& ^
npm run dev
REM --- Frontend ---
echo Starting Frontend on port 3000...
start "OnlyPets Frontend" cmd /k ^
cd /d C:\Projekte\OnlyPets\frontend ^& ^
npm run dev
echo.
echo All services started.
echo Backend -> http://localhost:3001
echo Frontend -> http://localhost:3000
echo.
BIN
View File
Binary file not shown.