feat(backend): finalize pets logic, follow system, notifications and feed hardening

This commit is contained in:
admin committed 2025-12-25 13:30:15 +01:00
1 parent 27174bef18
commit 7759bd0a05
5 files changed
+217 -71

No files matched your search

+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/
+13
View File
@@ -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())
}
+61 -6
View File
@@ -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),
},
},
});
}
@@ -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,
});
}
}
+108 -65
View File
@@ -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,
},
});
}
// -------------------------------
// 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,
},
});
}
}