diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..204e181 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index df5a29e..fd11250 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -19,6 +19,8 @@ model User { followers Follow[] @relation("Following") createdAt DateTime @default(now()) + + notifications Notification[] } @@ -63,3 +65,14 @@ model Follow { @@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()) +} diff --git a/backend/src/follows/follows.controller.ts b/backend/src/follows/follows.controller.ts index 2c86b6e..deb870a 100644 --- a/backend/src/follows/follows.controller.ts +++ b/backend/src/follows/follows.controller.ts @@ -1,4 +1,11 @@ -import { Controller, Post, Param, Req } from '@nestjs/common'; +import { + Controller, + Post, + Delete, + Param, + Req, + BadRequestException, +} from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; @Controller('follows') @@ -6,11 +13,59 @@ export class FollowsController { constructor(private readonly prisma: PrismaService) {} @Post(':userId') - follow(@Req() req, @Param('userId') userId: string) { - return this.prisma.follow.create({ - data: { - followerId: req.user.id, - followingId: Number(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), + }, }, }); } diff --git a/backend/src/notifications/notifications.controller.ts b/backend/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..5c0cf66 --- /dev/null +++ b/backend/src/notifications/notifications.controller.ts @@ -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, + }); + } +} diff --git a/backend/src/pets/pets.service.ts b/backend/src/pets/pets.service.ts index 3a97928..45d65a3 100644 --- a/backend/src/pets/pets.service.ts +++ b/backend/src/pets/pets.service.ts @@ -1,76 +1,119 @@ -import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { + Injectable, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreatePetDto } from './dto/create-pet.dto'; +import { UpdatePetDto } from './dto/update-pet.dto'; -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.', - field: 'birthDate', - }); -} +@Injectable() +export class PetsService { + constructor(private readonly prisma: PrismaService) {} -if (!dto.birthDate && dto.ageYears === undefined) { - throw new BadRequestException({ - error: 'VALIDATION_ERROR', - message: 'Geburtsdatum oder Alter muss angegeben werden.', - field: 'birthDate', - }); -} + // ------------------------------- + // 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', + }); + } - // ✅ BirthDate festlegen - let birthDate: Date | null = null; + // ❌ Nichts angegeben verboten + if (!dto.birthDate && dto.ageYears === undefined) { + throw new BadRequestException({ + error: 'VALIDATION_ERROR', + message: 'Geburtsdatum oder Alter muss angegeben werden.', + field: 'birthDate', + }); + } - if (dto.birthDate) { - birthDate = new Date(dto.birthDate); - } + // ✅ BirthDate ableiten (Single Source of Truth) + let birthDate: Date | null = null; - if (dto.ageYears !== undefined) { - const today = new Date(); - birthDate = new Date( - today.getFullYear() - dto.ageYears, - today.getMonth(), - today.getDate() - ); - } + if (dto.birthDate) { + birthDate = new Date(dto.birthDate); + } - return this.prisma.pet.create({ - data: { - name: dto.name, - type: dto.type, - breed: dto.breed, - birthDate, - gender: dto.gender, - bio: dto.bio, - avatarUrl: dto.avatarUrl, - visibility: dto.visibility ?? 'public', - ownerId: userId, - }, - }); + if (dto.ageYears !== undefined) { + const today = new Date(); + birthDate = new Date( + today.getFullYear() - dto.ageYears, + today.getMonth(), + today.getDate(), + ); + } -async updatePet( - petId: number, - userId: number, - dto: UpdatePetDto, -) { - const pet = await this.prisma.pet.findUnique({ - where: { id: petId }, - }); + // ❌ Zukunftsdatum verhindern + if (birthDate && birthDate > new Date()) { + throw new BadRequestException({ + error: 'VALIDATION_ERROR', + message: 'Geburtsdatum darf nicht in der Zukunft liegen.', + field: 'birthDate', + }); + } - if (!pet || pet.ownerId !== userId) { - throw new NotFoundException({ - error: 'NOT_FOUND', - message: 'Tier nicht gefunden.', + 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, + }, }); } - return this.prisma.pet.update({ - where: { id: petId }, - data: { - birthDate: dto.birthDate ? new Date(dto.birthDate) : pet.birthDate, - bio: dto.bio, - avatarUrl: dto.avatarUrl, - visibility: dto.visibility, - }, - }); -} \ No newline at end of file + // ------------------------------- + // 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, + }, + }); + } +}