feat(backend): add follow relation and enforce followers visibility in feed
This commit is contained in:
1 parent
aba22e2855
commit
0778f188bc
11 files changed
+376
No files matched your search
@@ -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])
|
||||
}
|
||||
Whitespace-only changes.
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class UpdatePetDto {
|
||||
birthDate?: string; // ISO – einzige Altersquelle
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
visibility?: 'public' | 'followers';
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
Reference in new issue
Block a user