feat(social): follows, favorites, comments, reactions, collections, public profiles

Follow/unfollow users. Recipe favorites. Threaded comments with emoji reactions.
Collections (public/private) with shared member invite. Activity feed.
Public profile pages at /u/[username].
This commit is contained in:
Arnaud
2026-07-01 08:10:30 +02:00
parent d9d58fd01a
commit 9d02a69250
23 changed files with 1825 additions and 0 deletions
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { db, collections, collectionRecipes, eq, and, or } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
// Allow forking public collections or own collections
const source = await db.query.collections.findFirst({
where: and(
eq(collections.id, id),
or(eq(collections.isPublic, true), eq(collections.userId, session!.user.id))
),
with: { recipes: { columns: { recipeId: true } } },
});
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
const newId = crypto.randomUUID();
await db.insert(collections).values({
id: newId,
userId: session!.user.id,
name: `${source.name} (fork)`,
description: source.description,
isPublic: false,
createdAt: new Date(),
updatedAt: new Date(),
});
if (source.recipes.length > 0) {
await db.insert(collectionRecipes).values(
source.recipes.map((r) => ({ collectionId: newId, recipeId: r.recipeId }))
);
}
return NextResponse.json({ id: newId }, { status: 201 });
}
@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, collections, collectionMembers, users, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
// ─── GET /api/v1/collections/[id]/members ────────────────────────────────────
// Owner only — returns members joined with basic user info.
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
// Verify ownership
const col = await db.query.collections.findFirst({
where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
});
if (!col) return NextResponse.json({ error: "Not found" }, { status: 404 });
const members = await db.query.collectionMembers.findMany({
where: eq(collectionMembers.collectionId, id),
with: { user: true },
});
const result = members.map((m) => ({
id: m.id,
userId: m.userId,
role: m.role,
createdAt: m.createdAt,
user: {
name: m.user.name,
username: m.user.username,
avatarUrl: m.user.avatarUrl,
},
}));
return NextResponse.json(result);
}
// ─── POST /api/v1/collections/[id]/members ───────────────────────────────────
// Owner only — invite by email or userId.
const InviteSchema = z
.object({
email: z.string().email().optional(),
userId: z.string().optional(),
role: z.enum(["viewer", "editor"]),
})
.refine((d) => d.email !== undefined || d.userId !== undefined, {
message: "Provide either email or userId",
});
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
// Verify ownership
const col = await db.query.collections.findFirst({
where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
});
if (!col) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json() as unknown;
const parsed = InviteSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const { email, userId, role } = parsed.data;
// Resolve target user
const targetUser = await db.query.users.findFirst({
where: email ? eq(users.email, email) : eq(users.id, userId!),
});
if (!targetUser) return NextResponse.json({ error: "User not found" }, { status: 404 });
// Prevent owner adding themselves
if (targetUser.id === session!.user.id) {
return NextResponse.json({ error: "Cannot invite yourself" }, { status: 400 });
}
// Check not already a member
const existing = await db.query.collectionMembers.findFirst({
where: and(
eq(collectionMembers.collectionId, id),
eq(collectionMembers.userId, targetUser.id),
),
});
if (existing) return NextResponse.json({ error: "Already a member" }, { status: 409 });
const memberId = crypto.randomUUID();
await db.insert(collectionMembers).values({
id: memberId,
collectionId: id,
userId: targetUser.id,
role,
});
return NextResponse.json({ id: memberId }, { status: 201 });
}
// ─── DELETE /api/v1/collections/[id]/members?memberId=… ──────────────────────
// Owner OR the member themselves can remove.
export async function DELETE(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const memberId = req.nextUrl.searchParams.get("memberId");
if (!memberId) return NextResponse.json({ error: "memberId required" }, { status: 400 });
// Load member record
const member = await db.query.collectionMembers.findFirst({
where: and(eq(collectionMembers.id, memberId), eq(collectionMembers.collectionId, id)),
});
if (!member) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Load collection to check owner
const col = await db.query.collections.findFirst({
where: eq(collections.id, id),
});
const isOwner = col?.userId === session!.user.id;
const isSelf = member.userId === session!.user.id;
if (!isOwner && !isSelf) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await db.delete(collectionMembers).where(eq(collectionMembers.id, memberId));
return new NextResponse(null, { status: 204 });
}
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, collections, collectionRecipes, recipes, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const col = await db.query.collections.findFirst({
where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
with: { recipes: { with: { recipe: { with: { photos: true } } } } },
});
if (!col) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(col);
}
export async function PUT(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const existing = await db.query.collections.findFirst({
where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json() as unknown;
const parsed = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
isPublic: z.boolean().optional(),
addRecipeId: z.string().optional(),
removeRecipeId: z.string().optional(),
}).safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const data = parsed.data;
if (data.name || data.description !== undefined || data.isPublic !== undefined) {
await db.update(collections).set({
...(data.name && { name: data.name }),
...(data.description !== undefined && { description: data.description }),
...(data.isPublic !== undefined && { isPublic: data.isPublic }),
updatedAt: new Date(),
}).where(eq(collections.id, id));
}
if (data.addRecipeId) {
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, data.addRecipeId) });
if (recipe) {
await db.insert(collectionRecipes).values({ collectionId: id, recipeId: data.addRecipeId }).onConflictDoNothing();
}
}
if (data.removeRecipeId) {
await db.delete(collectionRecipes).where(
and(eq(collectionRecipes.collectionId, id), eq(collectionRecipes.recipeId, data.removeRecipeId))
);
}
return NextResponse.json({ updated: true });
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const existing = await db.query.collections.findFirst({
where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.delete(collections).where(eq(collections.id, id));
return new NextResponse(null, { status: 204 });
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, collections, eq, desc } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const Schema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
isPublic: z.boolean().default(false),
});
export async function GET(_req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const rows = await db.query.collections.findMany({
where: eq(collections.userId, session!.user.id),
orderBy: desc(collections.updatedAt),
with: { recipes: { limit: 4, with: { recipe: { with: { photos: true } } } } },
});
return NextResponse.json(rows);
}
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const id = crypto.randomUUID();
await db.insert(collections).values({
id,
userId: session!.user.id,
name: parsed.data.name,
description: parsed.data.description,
isPublic: parsed.data.isPublic,
});
return NextResponse.json({ id }, { status: 201 });
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { db, users, userFollows, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ username: string }> };
export async function POST(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { username } = await params;
const target = await db.query.users.findFirst({ where: eq(users.username, username) });
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (target.id === session!.user.id) return NextResponse.json({ error: "Cannot follow yourself" }, { status: 400 });
await db.insert(userFollows)
.values({ followerId: session!.user.id, followingId: target.id })
.onConflictDoNothing();
return NextResponse.json({ following: true });
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { username } = await params;
const target = await db.query.users.findFirst({ where: eq(users.username, username) });
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.delete(userFollows).where(
and(eq(userFollows.followerId, session!.user.id), eq(userFollows.followingId, target.id))
);
return NextResponse.json({ following: false });
}
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, recipes, userFollows, eq, and, count } from "@epicure/db";
type Params = { params: Promise<{ username: string }> };
export async function GET(req: NextRequest, { params }: Params) {
const { username } = await params;
const user = await db.query.users.findFirst({ where: eq(users.username, username) });
if (!user) return NextResponse.json({ error: "Not found" }, { status: 404 });
const [followerCountRow, followingCountRow, recipeCountRow] = await Promise.all([
db
.select({ count: count() })
.from(userFollows)
.where(eq(userFollows.followingId, user.id)),
db
.select({ count: count() })
.from(userFollows)
.where(eq(userFollows.followerId, user.id)),
db
.select({ count: count() })
.from(recipes)
.where(and(eq(recipes.authorId, user.id), eq(recipes.visibility, "public"))),
]);
let isFollowing = false;
try {
const session = await auth.api.getSession({ headers: await headers() });
if (session && session.user.id !== user.id) {
const followRow = await db.query.userFollows.findFirst({
where: and(
eq(userFollows.followerId, session.user.id),
eq(userFollows.followingId, user.id),
),
});
isFollowing = !!followRow;
}
} catch {
// unauthenticated — isFollowing stays false
}
return NextResponse.json({
id: user.id,
name: user.name,
username: user.username,
avatarUrl: user.avatarUrl,
bio: user.bio,
createdAt: user.createdAt,
followerCount: followerCountRow[0]?.count ?? 0,
followingCount: followingCountRow[0]?.count ?? 0,
recipeCount: recipeCountRow[0]?.count ?? 0,
isFollowing,
});
}
@@ -0,0 +1,42 @@
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { db, userModelPrefs, eq } from "@epicure/db";
const Schema = z.object({
textProvider: z.string().nullable().optional(),
textModel: z.string().nullable().optional(),
visionProvider: z.string().nullable().optional(),
visionModel: z.string().nullable().optional(),
mealPlanProvider: z.string().nullable().optional(),
mealPlanModel: z.string().nullable().optional(),
});
export async function GET() {
const { session, response } = await requireSession();
if (response) return response;
const prefs = await db.query.userModelPrefs.findFirst({
where: eq(userModelPrefs.userId, session!.user.id),
});
return NextResponse.json(prefs ?? null);
}
export async function PUT(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = Schema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
await db
.insert(userModelPrefs)
.values({ id: crypto.randomUUID(), userId: session!.user.id, ...body.data, updatedAt: new Date() })
.onConflictDoUpdate({
target: userModelPrefs.userId,
set: { ...body.data, updatedAt: new Date() },
});
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { db, userNutritionGoals, eq } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { z } from "zod";
const PutSchema = z.object({
caloriesKcal: z.number().int().min(0).optional(),
proteinG: z.number().int().min(0).optional(),
carbsG: z.number().int().min(0).optional(),
fatG: z.number().int().min(0).optional(),
});
export async function GET() {
const { session, response } = await requireSession();
if (response) return response;
const goals = await db.query.userNutritionGoals.findFirst({
where: eq(userNutritionGoals.userId, session!.user.id),
});
return NextResponse.json({ data: goals ?? null });
}
export async function PUT(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const parsed = PutSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const body = parsed.data;
const userId = session!.user.id;
await db
.insert(userNutritionGoals)
.values({ id: crypto.randomUUID(), userId, ...body, updatedAt: new Date() })
.onConflictDoUpdate({
target: userNutritionGoals.userId,
set: { ...body, updatedAt: new Date() },
});
return NextResponse.json({ ok: true });
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, eq } from "@epicure/db";
import { z } from "zod";
const PatchSchema = z.object({
name: z.string().min(1).max(100).optional(),
locale: z.string().max(10).optional(),
});
export async function PATCH(req: Request) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = PatchSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
await db.update(users).set(body.data).where(eq(users.id, session.user.id));
return NextResponse.json({ ok: true });
}