feat(backend): add follow relation and enforce followers visibility in feed

This commit is contained in:
admin committed 2025-12-25 13:09:24 +01:00
1 parent aba22e2855
commit 0778f188bc
11 files changed
+376

No files matched your search

+65
View File
@@ -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])
}
View File
Whitespace-only changes.
+17
View File
@@ -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),
},
});
}
}
View File
Whitespace-only changes.
+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';
}
+14
View File
@@ -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,
);
}
+76
View File
@@ -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,
},
});
}
+33
View File
@@ -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,
});
}
}
+149
View File
@@ -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<number>();
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,
},
};
}
}
Whitespace-only changes.