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:
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user