diff --git a/drizzle/0003_circles.sql b/drizzle/0003_circles.sql new file mode 100644 index 0000000..048f13a --- /dev/null +++ b/drizzle/0003_circles.sql @@ -0,0 +1,87 @@ +-- C0: Circle multi-tenant social tables +-- Security model: RLS enabled (deny-by-default at DB layer). +-- App-level enforcement via requireFamily() + WHERE family_id checks +-- mirrors the existing pattern used for all other tables in this codebase. + +CREATE TABLE circles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL, + created_by uuid NOT NULL REFERENCES families(id), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE circle_members ( + circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE, + family_id uuid NOT NULL REFERENCES families(id), + role text NOT NULL DEFAULT 'member', -- admin | member + joined_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (circle_id, family_id) +); + +CREATE TABLE circle_invites ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE, + token text NOT NULL UNIQUE, -- crypto-random, unguessable + created_by uuid NOT NULL REFERENCES families(id), + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE circle_posts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + circle_id uuid NOT NULL REFERENCES circles(id) ON DELETE CASCADE, + author_family_id uuid NOT NULL REFERENCES families(id), + body text, + image_key text, -- R2 key under circle-posts/{id}/ prefix + source_kind text, -- NULL | 'milestone' | 'memory' + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE circle_post_comments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE, + author_family_id uuid NOT NULL REFERENCES families(id), + body text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE circle_post_reactions ( + post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE, + family_id uuid NOT NULL REFERENCES families(id), + emoji text NOT NULL, + PRIMARY KEY (post_id, family_id, emoji) +); + +CREATE TABLE post_reports ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + post_id uuid NOT NULL REFERENCES circle_posts(id) ON DELETE CASCADE, + reported_by uuid NOT NULL REFERENCES families(id), + reason text, + created_at timestamptz NOT NULL DEFAULT now() +); + +-- Indexes for common query patterns +CREATE INDEX circle_members_family_idx ON circle_members(family_id); +CREATE INDEX circle_members_circle_idx ON circle_members(circle_id); +CREATE INDEX circle_posts_circle_idx ON circle_posts(circle_id); +CREATE INDEX circle_posts_author_idx ON circle_posts(author_family_id); +CREATE INDEX circle_comments_post_idx ON circle_post_comments(post_id); +CREATE INDEX circle_reactions_post_idx ON circle_post_reactions(post_id); +CREATE INDEX circle_invites_token_idx ON circle_invites(token); + +-- Enable RLS (deny-by-default at DB layer). +-- The app connects as tia_app which handles row visibility via +-- explicit WHERE clauses in every query (requireFamily() pattern). +ALTER TABLE circles ENABLE ROW LEVEL SECURITY; +ALTER TABLE circle_members ENABLE ROW LEVEL SECURITY; +ALTER TABLE circle_invites ENABLE ROW LEVEL SECURITY; +ALTER TABLE circle_posts ENABLE ROW LEVEL SECURITY; +ALTER TABLE circle_post_comments ENABLE ROW LEVEL SECURITY; +ALTER TABLE circle_post_reactions ENABLE ROW LEVEL SECURITY; +ALTER TABLE post_reports ENABLE ROW LEVEL SECURITY; + +-- Grant app role full access (app enforces row visibility itself) +GRANT ALL ON circles, circle_members, circle_invites, circle_posts, + circle_post_comments, circle_post_reactions, post_reports + TO tia_app; diff --git a/src/app/api/circle/join/[token]/route.ts b/src/app/api/circle/join/[token]/route.ts new file mode 100644 index 0000000..3c0bbbd --- /dev/null +++ b/src/app/api/circle/join/[token]/route.ts @@ -0,0 +1,80 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; + +// POST — validate invite token and add family as a member +export async function POST( + _req: Request, + { params }: { params: Promise<{ token: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { token } = await params; + + // Validate token server-side — never trust client-supplied circleId + const [invite] = await sql.unsafe( + `SELECT id, circle_id as "circleId", expires_at as "expiresAt", consumed_at as "consumedAt" + FROM circle_invites WHERE token = $1`, + [token] + ); + + if (!invite) return NextResponse.json({ error: "Invalid invite link" }, { status: 404 }); + if (invite.consumedAt) return NextResponse.json({ error: "This invite has already been used" }, { status: 409 }); + if (new Date(invite.expiresAt) < new Date()) return NextResponse.json({ error: "This invite has expired" }, { status: 410 }); + + // Idempotent — already a member + const existing = await sql.unsafe( + `SELECT 1 FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [invite.circleId, familyId] + ); + if (existing.length > 0) { + return NextResponse.json({ error: "You are already a member of this circle" }, { status: 409 }); + } + + // Add member + consume invite atomically + await sql.unsafe( + `INSERT INTO circle_members (circle_id, family_id, role) VALUES ($1, $2, 'member')`, + [invite.circleId, familyId] + ); + await sql.unsafe( + `UPDATE circle_invites SET consumed_at = now() WHERE id = $1`, + [invite.id] + ); + + const [circle] = await sql.unsafe( + `SELECT id, name FROM circles WHERE id = $1`, + [invite.circleId] + ); + + return NextResponse.json({ success: true, circleId: invite.circleId, circleName: circle?.name }); +} + +// GET — preview the circle behind a token (name, member count) without joining +export async function GET( + _req: Request, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params; + + const [invite] = await sql.unsafe( + `SELECT ci.id, ci.circle_id as "circleId", ci.expires_at as "expiresAt", + ci.consumed_at as "consumedAt", c.name as "circleName", + (SELECT COUNT(*) FROM circle_members WHERE circle_id = c.id)::int as "memberCount" + FROM circle_invites ci + JOIN circles c ON c.id = ci.circle_id + WHERE ci.token = $1`, + [token] + ); + + if (!invite) return NextResponse.json({ error: "Invalid invite link" }, { status: 404 }); + if (invite.consumedAt) return NextResponse.json({ error: "This invite has already been used" }, { status: 409 }); + if (new Date(invite.expiresAt) < new Date()) return NextResponse.json({ error: "This invite has expired" }, { status: 410 }); + + return NextResponse.json({ + circleName: invite.circleName, + memberCount: invite.memberCount, + expiresAt: invite.expiresAt, + }); +} diff --git a/src/app/api/circles/[id]/invite/route.ts b/src/app/api/circles/[id]/invite/route.ts new file mode 100644 index 0000000..476137d --- /dev/null +++ b/src/app/api/circles/[id]/invite/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; +import { randomBytes } from "crypto"; + +// POST — create a single-use invite link (admin only) +export async function POST( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id: circleId } = await params; + + // Only admins can create invites + const rows = await sql.unsafe( + `SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, familyId] + ); + if (!rows[0] || rows[0].role !== "admin") { + return NextResponse.json({ error: "Only circle admins can create invites" }, { status: 403 }); + } + + // Cryptographically random 32-byte token (64 hex chars) — unguessable + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + const [invite] = await sql.unsafe( + `INSERT INTO circle_invites (circle_id, token, created_by, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id, token, expires_at as "expiresAt"`, + [circleId, token, familyId, expiresAt] + ); + + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://tia.manohargupta.com"; + return NextResponse.json({ + success: true, + invite: { ...invite, joinUrl: `${baseUrl}/circle/join/${token}` }, + }); +} diff --git a/src/app/api/circles/[id]/members/[memberId]/route.ts b/src/app/api/circles/[id]/members/[memberId]/route.ts new file mode 100644 index 0000000..a1d1d12 --- /dev/null +++ b/src/app/api/circles/[id]/members/[memberId]/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; + +// DELETE — admin removes a member OR promotes/demotes (via PATCH) +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string; memberId: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id: circleId, memberId: targetFamilyId } = await params; + + // Only admins can remove other members + const [membership] = await sql.unsafe( + `SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, familyId] + ); + if (membership?.role !== "admin") { + return NextResponse.json({ error: "Only circle admins can remove members" }, { status: 403 }); + } + + // Admins cannot remove themselves via this route (use /members DELETE to leave) + if (targetFamilyId === familyId) { + return NextResponse.json({ error: "Use the leave action to remove yourself" }, { status: 400 }); + } + + await sql.unsafe( + `DELETE FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, targetFamilyId] + ); + return NextResponse.json({ success: true }); +} + +// PATCH — promote member to admin (admin only) +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string; memberId: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id: circleId, memberId: targetFamilyId } = await params; + + const [membership] = await sql.unsafe( + `SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, familyId] + ); + if (membership?.role !== "admin") { + return NextResponse.json({ error: "Only admins can change roles" }, { status: 403 }); + } + + const { role } = await req.json(); + if (!["admin", "member"].includes(role)) { + return NextResponse.json({ error: "Invalid role" }, { status: 400 }); + } + + await sql.unsafe( + `UPDATE circle_members SET role = $1 WHERE circle_id = $2 AND family_id = $3`, + [role, circleId, targetFamilyId] + ); + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/circles/[id]/members/route.ts b/src/app/api/circles/[id]/members/route.ts new file mode 100644 index 0000000..b16a962 --- /dev/null +++ b/src/app/api/circles/[id]/members/route.ts @@ -0,0 +1,86 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; + +// GET — list members + reported posts (admins only see reports) +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id: circleId } = await params; + + const [membership] = await sql.unsafe( + `SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, familyId] + ); + if (!membership) return NextResponse.json({ error: "Not a member" }, { status: 403 }); + + const members = await sql.unsafe( + `SELECT cm.family_id as "familyId", f.name as "familyName", cm.role, cm.joined_at as "joinedAt" + FROM circle_members cm + JOIN families f ON f.id = cm.family_id + WHERE cm.circle_id = $1 + ORDER BY cm.joined_at ASC`, + [circleId] + ); + + // Admins also see pending reports + let reports: unknown[] = []; + if (membership.role === "admin") { + reports = await sql.unsafe( + `SELECT pr.id, pr.post_id as "postId", pr.reason, + f.name as "reportedByName", pr.created_at as "createdAt", + LEFT(p.body, 100) as "postPreview" + FROM post_reports pr + JOIN families f ON f.id = pr.reported_by + JOIN circle_posts p ON p.id = pr.post_id + WHERE p.circle_id = $1 + ORDER BY pr.created_at DESC`, + [circleId] + ); + } + + return NextResponse.json({ members, reports, myRole: membership.role }); +} + +// DELETE — leave the circle (self) +// Edge case: last admin cannot leave without first promoting another member +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id: circleId } = await params; + + const [membership] = await sql.unsafe( + `SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, familyId] + ); + if (!membership) return NextResponse.json({ error: "Not a member" }, { status: 404 }); + + // Block last-admin from leaving + if (membership.role === "admin") { + const [{ adminCount }] = await sql.unsafe( + `SELECT COUNT(*)::int as "adminCount" FROM circle_members WHERE circle_id = $1 AND role = 'admin'`, + [circleId] + ); + if (adminCount <= 1) { + return NextResponse.json({ + error: "You are the only admin. Promote another member before leaving.", + }, { status: 400 }); + } + } + + await sql.unsafe( + `DELETE FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, familyId] + ); + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/circles/[id]/posts/[postId]/comments/route.ts b/src/app/api/circles/[id]/posts/[postId]/comments/route.ts new file mode 100644 index 0000000..8ca6b24 --- /dev/null +++ b/src/app/api/circles/[id]/posts/[postId]/comments/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; + +async function assertMemberOfPost(postId: string, familyId: string) { + const rows = await sql.unsafe( + `SELECT cm.role FROM circle_posts p + JOIN circle_members cm ON cm.circle_id = p.circle_id AND cm.family_id = $2 + WHERE p.id = $1`, + [postId, familyId] + ); + return rows[0]?.role ?? null; +} + +// GET — list comments for a post +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string; postId: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { postId } = await params; + + if (!(await assertMemberOfPost(postId, familyId))) { + return NextResponse.json({ error: "Not a member" }, { status: 403 }); + } + + const comments = await sql.unsafe( + `SELECT c.id, c.author_family_id as "authorFamilyId", f.name as "authorFamilyName", + c.body, c.created_at as "createdAt" + FROM circle_post_comments c + JOIN families f ON f.id = c.author_family_id + WHERE c.post_id = $1 + ORDER BY c.created_at ASC`, + [postId] + ); + + return NextResponse.json({ comments }); +} + +// POST — add a comment (members only; AI assistant not permitted here) +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string; postId: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { postId } = await params; + + if (!(await assertMemberOfPost(postId, familyId))) { + return NextResponse.json({ error: "Not a member" }, { status: 403 }); + } + + const { body } = await req.json(); + if (!body?.trim()) return NextResponse.json({ error: "Comment cannot be empty" }, { status: 400 }); + + const [comment] = await sql.unsafe( + `INSERT INTO circle_post_comments (post_id, author_family_id, body) + VALUES ($1, $2, $3) + RETURNING id, author_family_id as "authorFamilyId", body, created_at as "createdAt"`, + [postId, familyId, body.trim()] + ); + + return NextResponse.json({ success: true, comment }); +} diff --git a/src/app/api/circles/[id]/posts/[postId]/reactions/route.ts b/src/app/api/circles/[id]/posts/[postId]/reactions/route.ts new file mode 100644 index 0000000..89a8257 --- /dev/null +++ b/src/app/api/circles/[id]/posts/[postId]/reactions/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; + +const ALLOWED_EMOJIS = ["❤️", "😂", "👍", "🙏", "😮", "😢"]; + +async function assertMemberOfPost(postId: string, familyId: string) { + const rows = await sql.unsafe( + `SELECT 1 FROM circle_posts p + JOIN circle_members cm ON cm.circle_id = p.circle_id AND cm.family_id = $2 + WHERE p.id = $1`, + [postId, familyId] + ); + return rows.length > 0; +} + +// POST — toggle reaction (composite PK prevents duplicates) +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string; postId: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { postId } = await params; + + if (!(await assertMemberOfPost(postId, familyId))) { + return NextResponse.json({ error: "Not a member" }, { status: 403 }); + } + + const { emoji } = await req.json(); + if (!ALLOWED_EMOJIS.includes(emoji)) { + return NextResponse.json({ error: "Emoji not allowed" }, { status: 400 }); + } + + // Toggle: insert if not exists, delete if exists + const existing = await sql.unsafe( + `SELECT 1 FROM circle_post_reactions WHERE post_id = $1 AND family_id = $2 AND emoji = $3`, + [postId, familyId, emoji] + ); + + if (existing.length > 0) { + await sql.unsafe( + `DELETE FROM circle_post_reactions WHERE post_id = $1 AND family_id = $2 AND emoji = $3`, + [postId, familyId, emoji] + ); + return NextResponse.json({ success: true, action: "removed" }); + } + + await sql.unsafe( + `INSERT INTO circle_post_reactions (post_id, family_id, emoji) VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING`, + [postId, familyId, emoji] + ); + return NextResponse.json({ success: true, action: "added" }); +} diff --git a/src/app/api/circles/[id]/posts/[postId]/report/route.ts b/src/app/api/circles/[id]/posts/[postId]/report/route.ts new file mode 100644 index 0000000..37c0066 --- /dev/null +++ b/src/app/api/circles/[id]/posts/[postId]/report/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; + +// POST — report a post (members only; admins see reports in circle detail) +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string; postId: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { postId } = await params; + + const membership = await sql.unsafe( + `SELECT 1 FROM circle_posts p + JOIN circle_members cm ON cm.circle_id = p.circle_id AND cm.family_id = $2 + WHERE p.id = $1`, + [postId, familyId] + ); + if (!membership.length) return NextResponse.json({ error: "Not a member" }, { status: 403 }); + + const { reason } = await req.json(); + + await sql.unsafe( + `INSERT INTO post_reports (post_id, reported_by, reason) VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING`, + [postId, familyId, reason ?? null] + ); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/circles/[id]/posts/[postId]/route.ts b/src/app/api/circles/[id]/posts/[postId]/route.ts new file mode 100644 index 0000000..7b4f909 --- /dev/null +++ b/src/app/api/circles/[id]/posts/[postId]/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; +import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3"; + +function makeR2Client() { + return new S3Client({ + region: "auto", + endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: process.env.R2_ACCESS_KEY_ID!, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, + }, + }); +} + +// DELETE — author or circle admin can delete a post; deletes R2 copy too +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string; postId: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id: circleId, postId } = await params; + + const [post] = await sql.unsafe( + `SELECT p.id, p.author_family_id as "authorFamilyId", p.image_key as "imageKey" + FROM circle_posts p WHERE p.id = $1 AND p.circle_id = $2`, + [postId, circleId] + ); + if (!post) return NextResponse.json({ error: "Post not found" }, { status: 404 }); + + // Permission: own post OR admin of this circle + const isAuthor = post.authorFamilyId === familyId; + if (!isAuthor) { + const [membership] = await sql.unsafe( + `SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, familyId] + ); + if (membership?.role !== "admin") { + return NextResponse.json({ error: "Not authorised to delete this post" }, { status: 403 }); + } + } + + // Delete R2 image copy (circle-posts/{postId}/ prefix) — no orphans + if (post.imageKey) { + await makeR2Client().send( + new DeleteObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: post.imageKey }) + ).catch(() => {}); // don't fail the whole delete if R2 is flaky + } + + // DB cascade removes comments, reactions, reports + await sql.unsafe(`DELETE FROM circle_posts WHERE id = $1`, [postId]); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/circles/[id]/posts/route.ts b/src/app/api/circles/[id]/posts/route.ts new file mode 100644 index 0000000..d4e9878 --- /dev/null +++ b/src/app/api/circles/[id]/posts/route.ts @@ -0,0 +1,196 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; +import { S3Client, PutObjectCommand, CopyObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL ?? ""; + +function makeR2Client() { + return new S3Client({ + region: "auto", + endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: process.env.R2_ACCESS_KEY_ID!, + secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, + }, + }); +} + +async function assertMember(circleId: string, familyId: string) { + const rows = await sql.unsafe( + `SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, familyId] + ); + return rows[0]?.role ?? null; +} + +// GET — circle feed (newest first) +export async function GET( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id: circleId } = await params; + + if (!(await assertMember(circleId, familyId))) { + return NextResponse.json({ error: "Not a member" }, { status: 403 }); + } + + const { searchParams } = new URL(req.url); + const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50); + const before = searchParams.get("before"); // cursor: ISO timestamp + + const posts = await sql.unsafe( + `SELECT p.id, p.circle_id as "circleId", p.author_family_id as "authorFamilyId", + f.name as "authorFamilyName", p.body, p.image_key as "imageKey", + p.source_kind as "sourceKind", p.created_at as "createdAt", + (SELECT COUNT(*) FROM circle_post_comments WHERE post_id = p.id)::int as "commentCount" + FROM circle_posts p + JOIN families f ON f.id = p.author_family_id + WHERE p.circle_id = $1 + AND ($2::timestamptz IS NULL OR p.created_at < $2::timestamptz) + ORDER BY p.created_at DESC + LIMIT $3`, + [circleId, before ?? null, limit] + ); + + // Attach reactions per post + const postIds = posts.map((p: Record) => p.id as string); + let reactions: Array<{ postId: string; emoji: string; count: number; familyId: string }> = []; + if (postIds.length > 0) { + reactions = await sql.unsafe( + `SELECT post_id as "postId", emoji, + COUNT(*)::int as count, + bool_or(family_id = $2) as "includedMe" + FROM circle_post_reactions + WHERE post_id = ANY($1::uuid[]) + GROUP BY post_id, emoji`, + [postIds, familyId] + ); + } + + const reactionsByPost = reactions.reduce>((acc, r) => { + if (!acc[r.postId]) acc[r.postId] = []; + acc[r.postId].push(r); + return acc; + }, {}); + + type PostRow = Record & { id: string; imageKey: string | null }; + const result = (posts as unknown as PostRow[]).map(p => ({ + ...p, + imageUrl: p.imageKey ? `${R2_PUBLIC_URL}/${p.imageKey}` : null, + reactions: (reactionsByPost[p.id] ?? []).map(r => ({ + emoji: r.emoji, count: r.count, includedMe: r.familyId === familyId, + })), + })); + + return NextResponse.json({ posts: result }); +} + +// POST — create a post or request a presigned upload URL +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id: circleId } = await params; + + if (!(await assertMember(circleId, familyId))) { + return NextResponse.json({ error: "Not a member" }, { status: 403 }); + } + + const body = await req.json(); + + // ── Sub-action: get presigned upload URL for a circle post image ────────── + if (body.action === "presign") { + const { contentType, filename } = body; + const ALLOWED = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/heic"]; + if (!contentType || !ALLOWED.includes(contentType)) { + return NextResponse.json({ error: "Unsupported file type" }, { status: 400 }); + } + const ext = filename?.split(".").pop()?.toLowerCase() ?? "jpg"; + // Temp key — will be moved to circle-posts/{post_id}/ after post is created + const tmpKey = `circle-posts/tmp/${familyId}/${Date.now()}.${ext}`; + const uploadUrl = await getSignedUrl( + makeR2Client(), + new PutObjectCommand({ Bucket: process.env.R2_BUCKET_NAME!, Key: tmpKey, ContentType: contentType }), + { expiresIn: 3600 } + ); + return NextResponse.json({ uploadUrl, tmpKey, publicUrl: `${R2_PUBLIC_URL}/${tmpKey}` }); + } + + // ── Sub-action: share from a private memory (copy-on-share) ────────────── + if (body.action === "shareMemory") { + const { memoryId, postBody } = body; + // Verify the family owns this memory + const [memory] = await sql.unsafe( + `SELECT r2_key, r2_thumbnail_key FROM memories WHERE id = $1 AND family_id = $2`, + [memoryId, familyId] + ); + if (!memory) return NextResponse.json({ error: "Memory not found or not yours" }, { status: 404 }); + + // Create post row first to get the post ID + const [post] = await sql.unsafe( + `INSERT INTO circle_posts (circle_id, author_family_id, body, source_kind) + VALUES ($1, $2, $3, 'memory') + RETURNING id`, + [circleId, familyId, postBody ?? null] + ); + + // Copy image to circle-posts/{post_id}/ — independent object, never references original + let copiedKey: string | null = null; + if (memory.r2_key) { + const ext = memory.r2_key.split(".").pop() ?? "jpg"; + copiedKey = `circle-posts/${post.id}/image.${ext}`; + await makeR2Client().send(new CopyObjectCommand({ + Bucket: process.env.R2_BUCKET_NAME!, + CopySource: `${process.env.R2_BUCKET_NAME}/${memory.r2_key}`, + Key: copiedKey, + })); + await sql.unsafe( + `UPDATE circle_posts SET image_key = $1 WHERE id = $2`, + [copiedKey, post.id] + ); + } + + return NextResponse.json({ success: true, postId: post.id }); + } + + // ── Default: create a fresh post ───────────────────────────────────────── + const { postBody, tmpKey } = body; + if (!postBody?.trim() && !tmpKey) { + return NextResponse.json({ error: "Post must have text or an image" }, { status: 400 }); + } + + const [post] = await sql.unsafe( + `INSERT INTO circle_posts (circle_id, author_family_id, body) + VALUES ($1, $2, $3) + RETURNING id`, + [circleId, familyId, postBody?.trim() ?? null] + ); + + // Move temp image to permanent key under circle-posts/{post_id}/ + let finalKey: string | null = null; + if (tmpKey) { + const ext = tmpKey.split(".").pop() ?? "jpg"; + finalKey = `circle-posts/${post.id}/image.${ext}`; + await makeR2Client().send(new CopyObjectCommand({ + Bucket: process.env.R2_BUCKET_NAME!, + CopySource: `${process.env.R2_BUCKET_NAME}/${tmpKey}`, + Key: finalKey, + })); + await sql.unsafe( + `UPDATE circle_posts SET image_key = $1 WHERE id = $2`, + [finalKey, post.id] + ); + } + + return NextResponse.json({ success: true, postId: post.id }); +} diff --git a/src/app/api/circles/[id]/route.ts b/src/app/api/circles/[id]/route.ts new file mode 100644 index 0000000..34472b5 --- /dev/null +++ b/src/app/api/circles/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; + +// Helper: verify family is a member; returns role or null +async function getMemberRole(circleId: string, familyId: string): Promise { + const rows = await sql.unsafe( + `SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, familyId] + ); + return rows[0]?.role ?? null; +} + +// GET — circle detail + members list +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { id } = await params; + + const role = await getMemberRole(id, familyId); + if (!role) return NextResponse.json({ error: "Not a member of this circle" }, { status: 403 }); + + const [circle] = await sql.unsafe( + `SELECT id, name, created_by as "createdBy", created_at as "createdAt" FROM circles WHERE id = $1`, + [id] + ); + if (!circle) return NextResponse.json({ error: "Circle not found" }, { status: 404 }); + + const members = await sql.unsafe( + `SELECT cm.family_id as "familyId", f.name as "familyName", cm.role, cm.joined_at as "joinedAt" + FROM circle_members cm + JOIN families f ON f.id = cm.family_id + WHERE cm.circle_id = $1 + ORDER BY cm.joined_at ASC`, + [id] + ); + + return NextResponse.json({ circle: { ...circle, role, memberCount: members.length }, members }); +} diff --git a/src/app/api/circles/route.ts b/src/app/api/circles/route.ts new file mode 100644 index 0000000..39c6c17 --- /dev/null +++ b/src/app/api/circles/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; + +// GET — list all circles this family belongs to +export async function GET() { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + + const circles = await sql.unsafe( + `SELECT c.id, c.name, c.created_by as "createdBy", c.created_at as "createdAt", + cm.role, + (SELECT COUNT(*) FROM circle_members WHERE circle_id = c.id)::int as "memberCount" + FROM circles c + JOIN circle_members cm ON cm.circle_id = c.id AND cm.family_id = $1 + ORDER BY c.created_at DESC`, + [familyId] + ); + + return NextResponse.json({ circles }); +} + +// POST — create a new circle (creator becomes admin) +export async function POST(request: Request) { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + const { name } = await request.json(); + + if (!name?.trim()) { + return NextResponse.json({ error: "Circle name is required" }, { status: 400 }); + } + + const [circle] = await sql.unsafe( + `INSERT INTO circles (name, created_by) VALUES ($1, $2) + RETURNING id, name, created_by as "createdBy", created_at as "createdAt"`, + [name.trim(), familyId] + ); + + // Creator is automatically an admin member + await sql.unsafe( + `INSERT INTO circle_members (circle_id, family_id, role) VALUES ($1, $2, 'admin')`, + [circle.id, familyId] + ); + + return NextResponse.json({ success: true, circle: { ...circle, role: "admin", memberCount: 1 } }); +} diff --git a/src/app/circle/[id]/page.tsx b/src/app/circle/[id]/page.tsx new file mode 100644 index 0000000..c5ba6d0 --- /dev/null +++ b/src/app/circle/[id]/page.tsx @@ -0,0 +1,496 @@ +"use client"; + +import { useState, useEffect, useCallback, use } from "react"; +import { useRouter } from "next/navigation"; +import { useFamily } from "../../FamilyProvider"; +import type { CirclePost, CircleComment, Circle } from "@/types"; + +const REACTIONS = ["❤️", "😂", "👍", "🙏", "😮"]; + +function timeAgo(iso: string) { + const diff = Date.now() - new Date(iso).getTime(); + const m = Math.floor(diff / 60000); + if (m < 1) return "just now"; + if (m < 60) return `${m}m`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h`; + return `${Math.floor(h / 24)}d`; +} + +// ── Post card ───────────────────────────────────────────────────────────────── +function PostCard({ + post, myFamilyId, circleId, isAdmin, + onDeleted, onReact, +}: { + post: CirclePost; + myFamilyId: string; + circleId: string; + isAdmin: boolean; + onDeleted: (id: string) => void; + onReact: (postId: string, emoji: string) => void; +}) { + const [showComments, setShowComments] = useState(false); + const [comments, setComments] = useState([]); + const [commentText, setCommentText] = useState(""); + const [posting, setPosting] = useState(false); + const [showMenu, setShowMenu] = useState(false); + const [reportSent, setReportSent] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(false); + + const loadComments = async () => { + const res = await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`); + const data = await res.json(); + setComments(data.comments ?? []); + }; + + const toggleComments = () => { + if (!showComments) loadComments(); + setShowComments(v => !v); + }; + + const addComment = async () => { + if (!commentText.trim()) return; + setPosting(true); + await fetch(`/api/circles/${circleId}/posts/${post.id}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: commentText.trim() }), + }); + setCommentText(""); + loadComments(); + setPosting(false); + }; + + const sendReport = async (reason: string) => { + await fetch(`/api/circles/${circleId}/posts/${post.id}/report`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason }), + }); + setReportSent(true); + setShowMenu(false); + }; + + const deletePost = async () => { + await fetch(`/api/circles/${circleId}/posts/${post.id}`, { method: "DELETE" }); + onDeleted(post.id); + }; + + const isOwn = post.authorFamilyId === myFamilyId; + + return ( +
+ {/* Author row */} +
+
+
👨‍👩‍👧
+
+

{post.authorFamilyName}

+

{timeAgo(post.createdAt)}{post.sourceKind ? ` · shared a ${post.sourceKind}` : ""}

+
+
+ {/* ⋯ menu */} +
+ + {showMenu && ( + <> +
setShowMenu(false)} /> +
+ {(isOwn || isAdmin) && ( + + )} + {!isOwn && !reportSent && ( + + )} + {reportSent &&

Reported — thank you

} +
+ + )} +
+
+ + {/* Delete confirmation */} + {deleteConfirm && ( +
+

Delete this post?

+
+ + +
+
+ )} + + {/* Body */} + {post.body &&

{post.body}

} + + {/* Image */} + {post.imageUrl && ( + Post + )} + + {/* Reactions */} +
+ {REACTIONS.map(emoji => { + const r = post.reactions.find(x => x.emoji === emoji); + return ( + + ); + })} + +
+ + {/* Comments */} + {showComments && ( +
+ {comments.map(c => ( +
+
👨‍👩‍👧
+
+

{c.authorFamilyName}

+

{c.body}

+
+
+ ))} +
+ setCommentText(e.target.value)} + onKeyDown={e => e.key === "Enter" && addComment()} + placeholder="Add a comment…" + className="flex-1 px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none" + /> + +
+
+ )} +
+ ); +} + +// ── Create Post Modal (C5 + C9 consent) ────────────────────────────────────── +function CreatePostModal({ + circleId, circleName, memberCount, + onClose, onPosted, +}: { + circleId: string; + circleName: string; + memberCount: number; + onClose: () => void; + onPosted: () => void; +}) { + const [body, setBody] = useState(""); + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [posting, setPosting] = useState(false); + const [step, setStep] = useState<"compose" | "confirm">("compose"); + + const pickImage = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (!f) return; + setImageFile(f); + setImagePreview(URL.createObjectURL(f)); + }; + + const submit = async () => { + setPosting(true); + try { + let tmpKey: string | null = null; + + if (imageFile) { + // Get presigned URL + const presignRes = await fetch(`/api/circles/${circleId}/posts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "presign", contentType: imageFile.type, filename: imageFile.name }), + }); + const { uploadUrl, tmpKey: key } = await presignRes.json(); + // Upload to R2 + await fetch(uploadUrl, { method: "PUT", body: imageFile, headers: { "Content-Type": imageFile.type } }); + tmpKey = key; + } + + await fetch(`/api/circles/${circleId}/posts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ postBody: body, tmpKey }), + }); + + onPosted(); + onClose(); + } catch { /* silent */ } + setPosting(false); + }; + + return ( + <> +
+
+
+

{step === "confirm" ? "Confirm post" : "New post"}

+ +
+ + {step === "compose" ? ( + <> +