diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..df5a29e --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,65 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" // oder mysql / sqlite – bei Bedarf anpassen + 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()) +} + + +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]) +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/follows/follows.controller.ts b/backend/src/follows/follows.controller.ts new file mode 100644 index 0000000..2c86b6e --- /dev/null +++ b/backend/src/follows/follows.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Post, Param, Req } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Controller('follows') +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), + }, + }); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/pets/dto/create-pet.dto.ts b/backend/src/pets/dto/create-pet.dto.ts new file mode 100644 index 0000000..22996d2 --- /dev/null +++ b/backend/src/pets/dto/create-pet.dto.ts @@ -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'; +} diff --git a/backend/src/pets/dto/update-pet.dto.ts b/backend/src/pets/dto/update-pet.dto.ts new file mode 100644 index 0000000..75bd291 --- /dev/null +++ b/backend/src/pets/dto/update-pet.dto.ts @@ -0,0 +1,6 @@ +export class UpdatePetDto { + birthDate?: string; // ISO – einzige Altersquelle + bio?: string; + avatarUrl?: string; + visibility?: 'public' | 'followers'; +} diff --git a/backend/src/pets/pets.controller.ts b/backend/src/pets/pets.controller.ts new file mode 100644 index 0000000..15678b9 --- /dev/null +++ b/backend/src/pets/pets.controller.ts @@ -0,0 +1,14 @@ +import { Patch, Param, Body, Req } from '@nestjs/common'; + +@Patch(':id') +updatePet( + @Param('id') id: string, + @Body() dto: UpdatePetDto, + @Req() req, +) { + return this.petsService.updatePet( + Number(id), + req.user.id, + dto, + ); +} diff --git a/backend/src/pets/pets.service.ts b/backend/src/pets/pets.service.ts new file mode 100644 index 0000000..3a97928 --- /dev/null +++ b/backend/src/pets/pets.service.ts @@ -0,0 +1,76 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +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', + }); +} + +if (!dto.birthDate && dto.ageYears === undefined) { + throw new BadRequestException({ + error: 'VALIDATION_ERROR', + message: 'Geburtsdatum oder Alter muss angegeben werden.', + field: 'birthDate', + }); +} + + // ✅ BirthDate festlegen + 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() + ); + } + + 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, + }, + }); + +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: 'Tier nicht gefunden.', + }); + } + + 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 diff --git a/backend/src/posts/posts.controller.ts b/backend/src/posts/posts.controller.ts new file mode 100644 index 0000000..478a2ea --- /dev/null +++ b/backend/src/posts/posts.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Query, Req } from '@nestjs/common'; +import { PostsService } from './posts.service'; + +@Controller('posts') +export class PostsController { + constructor(private readonly postsService: PostsService) {} + + @Get() + getFeed( + @Req() req, + @Query('take') take?: string, + @Query('cursorId') cursorId?: string, + ) { + return this.postsService.getFeed(req.user.id, { + mode: 'mixed', + take: take ? Number(take) : undefined, + cursorId: cursorId ? Number(cursorId) : undefined, + }); + } + + @Get('discovery') + getDiscovery( + @Req() req, + @Query('take') take?: string, + @Query('cursorId') cursorId?: string, + ) { + return this.postsService.getFeed(req.user.id, { + mode: 'discovery', + take: take ? Number(take) : undefined, + cursorId: cursorId ? Number(cursorId) : undefined, + }); + } +} diff --git a/backend/src/posts/posts.service.ts b/backend/src/posts/posts.service.ts new file mode 100644 index 0000000..cd4cafb --- /dev/null +++ b/backend/src/posts/posts.service.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +type FeedMode = 'mixed' | 'discovery'; + +type FeedOptions = { + mode?: FeedMode; + take?: number; + cursorId?: number; +}; + +@Injectable() +export class PostsService { + constructor(private readonly prisma: PrismaService) {} + + async getFeed(userId: number, options?: FeedOptions) { + const mode: FeedMode = options?.mode ?? 'mixed'; + const take = Math.min(Math.max(options?.take ?? 20, 1), 50); + const cursorId = options?.cursorId; + + const include = { + pet: { + select: { + id: true, + name: true, + type: true, + birthDate: true, + }, + }, + author: { + select: { + name: true, + }, + }, + } as const; + + // Sichtbarkeit + const baseWhere = + mode === 'discovery' + ? { + pet: { visibility: 'public' }, + } + : { + pet: { + OR: [ + { visibility: 'public' }, + { + visibility: 'followers', + owner: { + followers: { + some: { + followerId: userId, + }, + }, + }, + }, + ], + }, + }; + + + const commonQuery = { + orderBy: [ + { createdAt: 'desc' as const }, + { id: 'desc' as const }, + ], + include, + ...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : {}), + }; + + // -------- Discovery only -------- + if (mode === 'discovery') { + const items = await this.prisma.post.findMany({ + where: baseWhere, + take, + ...commonQuery, + }); + + return { + items, + nextCursorId: + items.length === take ? items[items.length - 1].id : null, + meta: { mode, take }, + }; + } + + // -------- Mixed Feed (85 / 15) -------- + const discoveryCount = Math.max(1, Math.round(take * 0.15)); + const normalCount = take - discoveryCount; + + const [normalItems, discoveryItems] = await Promise.all([ + this.prisma.post.findMany({ + where: baseWhere, + take: normalCount, + ...commonQuery, + }), + this.prisma.post.findMany({ + where: { pet: { visibility: 'public' } }, + take: discoveryCount, + ...commonQuery, + }), + ]); + + // Ruhige Mischung + Duplikat-Schutz + const seen = new Set(); + const merged = []; + + const n = [...normalItems]; + const d = [...discoveryItems]; + + while (merged.length < take && (n.length || d.length)) { + for (let i = 0; i < 6 && n.length && merged.length < take; i++) { + const item = n.shift(); + if (item && !seen.has(item.id)) { + seen.add(item.id); + merged.push(item); + } + } + + if (d.length && merged.length < take) { + const item = d.shift(); + if (item && !seen.has(item.id)) { + seen.add(item.id); + merged.push(item); + } + } + } + + while (merged.length < take && n.length) { + const item = n.shift(); + if (item && !seen.has(item.id)) { + seen.add(item.id); + merged.push(item); + } + } + + return { + items: merged, + nextCursorId: + merged.length === take ? merged[merged.length - 1].id : null, + meta: { + mode, + take, + normalCount, + discoveryCount, + }, + }; + } +} diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts new file mode 100644 index 0000000..e69de29