Circle feature: C0–C9 multi-tenant social groups (Sprint 9 + 10)
Adds full circle functionality — private social groups for trusted families to share milestones, memories, and posts with reactions and comments. - 7-table DB migration: circles, members, invites, posts, comments, reactions, reports - 11 API routes: create/list circles, posts feed, comments, emoji reactions, invite tokens, join flow, member management, reporting - 3 new pages: /circle (list), /circle/[id] (feed + PostCard + CreatePostModal), /circle/join/[token] - Copy-on-share for memory photos (independent R2 objects, never references originals) - Admin controls: invite generation, member promote/demote/remove, last-admin guard - C9 privacy consent screen before first post - Menu entry added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c732d2d7c2
commit
5fdb69679d
17 changed files with 1659 additions and 0 deletions
87
drizzle/0003_circles.sql
Normal file
87
drizzle/0003_circles.sql
Normal file
|
|
@ -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;
|
||||||
80
src/app/api/circle/join/[token]/route.ts
Normal file
80
src/app/api/circle/join/[token]/route.ts
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
42
src/app/api/circles/[id]/invite/route.ts
Normal file
42
src/app/api/circles/[id]/invite/route.ts
Normal file
|
|
@ -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}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
66
src/app/api/circles/[id]/members/[memberId]/route.ts
Normal file
66
src/app/api/circles/[id]/members/[memberId]/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
86
src/app/api/circles/[id]/members/route.ts
Normal file
86
src/app/api/circles/[id]/members/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
69
src/app/api/circles/[id]/posts/[postId]/comments/route.ts
Normal file
69
src/app/api/circles/[id]/posts/[postId]/comments/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
57
src/app/api/circles/[id]/posts/[postId]/reactions/route.ts
Normal file
57
src/app/api/circles/[id]/posts/[postId]/reactions/route.ts
Normal file
|
|
@ -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" });
|
||||||
|
}
|
||||||
33
src/app/api/circles/[id]/posts/[postId]/report/route.ts
Normal file
33
src/app/api/circles/[id]/posts/[postId]/report/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
58
src/app/api/circles/[id]/posts/[postId]/route.ts
Normal file
58
src/app/api/circles/[id]/posts/[postId]/route.ts
Normal file
|
|
@ -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 });
|
||||||
|
}
|
||||||
196
src/app/api/circles/[id]/posts/route.ts
Normal file
196
src/app/api/circles/[id]/posts/route.ts
Normal file
|
|
@ -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<string, unknown>) => 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<Record<string, typeof reactions>>((acc, r) => {
|
||||||
|
if (!acc[r.postId]) acc[r.postId] = [];
|
||||||
|
acc[r.postId].push(r);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
type PostRow = Record<string, unknown> & { 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 });
|
||||||
|
}
|
||||||
44
src/app/api/circles/[id]/route.ts
Normal file
44
src/app/api/circles/[id]/route.ts
Normal file
|
|
@ -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<string | null> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
50
src/app/api/circles/route.ts
Normal file
50
src/app/api/circles/route.ts
Normal file
|
|
@ -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 } });
|
||||||
|
}
|
||||||
496
src/app/circle/[id]/page.tsx
Normal file
496
src/app/circle/[id]/page.tsx
Normal file
|
|
@ -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<CircleComment[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
{/* Author row */}
|
||||||
|
<div className="flex items-center justify-between px-4 pt-3 pb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-sm">👨👩👧</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{post.authorFamilyName}</p>
|
||||||
|
<p className="text-xs text-gray-400">{timeAgo(post.createdAt)}{post.sourceKind ? ` · shared a ${post.sourceKind}` : ""}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* ⋯ menu */}
|
||||||
|
<div className="relative">
|
||||||
|
<button onClick={() => setShowMenu(v => !v)} className="p-2 text-gray-400">⋯</button>
|
||||||
|
{showMenu && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
|
||||||
|
<div className="absolute right-0 top-8 z-50 bg-white dark:bg-gray-900 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700 min-w-[160px] py-1">
|
||||||
|
{(isOwn || isAdmin) && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowMenu(false); setDeleteConfirm(true); }}
|
||||||
|
className="w-full text-left px-4 py-2.5 text-sm text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||||
|
>🗑️ Delete post</button>
|
||||||
|
)}
|
||||||
|
{!isOwn && !reportSent && (
|
||||||
|
<button
|
||||||
|
onClick={() => sendReport("inappropriate")}
|
||||||
|
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||||
|
>🚩 Report post</button>
|
||||||
|
)}
|
||||||
|
{reportSent && <p className="px-4 py-2.5 text-xs text-gray-400">Reported — thank you</p>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className="mx-4 mb-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-xl space-y-2">
|
||||||
|
<p className="text-sm text-red-600 font-medium">Delete this post?</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={deletePost} className="flex-1 py-1.5 bg-red-500 text-white rounded-lg text-sm">Delete</button>
|
||||||
|
<button onClick={() => setDeleteConfirm(false)} className="flex-1 py-1.5 text-gray-400 text-sm">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
{post.body && <p className="px-4 pb-3 text-sm leading-relaxed">{post.body}</p>}
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
{post.imageUrl && (
|
||||||
|
<img src={post.imageUrl} alt="Post" className="w-full max-h-80 object-cover" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reactions */}
|
||||||
|
<div className="px-4 py-2 flex items-center gap-1 flex-wrap border-t border-gray-50 dark:border-gray-700">
|
||||||
|
{REACTIONS.map(emoji => {
|
||||||
|
const r = post.reactions.find(x => x.emoji === emoji);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
onClick={() => onReact(post.id, emoji)}
|
||||||
|
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors ${
|
||||||
|
r?.includedMe
|
||||||
|
? "bg-rose-100 dark:bg-rose-900/40 text-rose-600"
|
||||||
|
: "bg-gray-50 dark:bg-gray-700 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{emoji}{r?.count ? ` ${r.count}` : ""}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={toggleComments}
|
||||||
|
className="ml-auto flex items-center gap-1 text-xs text-gray-400 px-2 py-1"
|
||||||
|
>
|
||||||
|
💬 {post.commentCount > 0 ? post.commentCount : ""} {showComments ? "▲" : "▼"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
{showComments && (
|
||||||
|
<div className="border-t border-gray-50 dark:border-gray-700 px-4 py-3 space-y-3">
|
||||||
|
{comments.map(c => (
|
||||||
|
<div key={c.id} className="flex gap-2">
|
||||||
|
<div className="w-7 h-7 bg-rose-50 dark:bg-rose-900/30 rounded-full flex items-center justify-center text-xs flex-shrink-0">👨👩👧</div>
|
||||||
|
<div className="flex-1 bg-gray-50 dark:bg-gray-700 rounded-xl px-3 py-2">
|
||||||
|
<p className="text-xs font-medium text-gray-600 dark:text-gray-300">{c.authorFamilyName}</p>
|
||||||
|
<p className="text-sm">{c.body}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<input
|
||||||
|
value={commentText}
|
||||||
|
onChange={e => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={addComment}
|
||||||
|
disabled={posting || !commentText.trim()}
|
||||||
|
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm disabled:opacity-40"
|
||||||
|
>→</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<File | null>(null);
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||||
|
const [posting, setPosting] = useState(false);
|
||||||
|
const [step, setStep] = useState<"compose" | "confirm">("compose");
|
||||||
|
|
||||||
|
const pickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 bg-black/50 z-50" onClick={onClose} />
|
||||||
|
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-4 pb-10 shadow-xl max-h-[85vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold">{step === "confirm" ? "Confirm post" : "New post"}</h3>
|
||||||
|
<button onClick={onClose} className="text-gray-400">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === "compose" ? (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={body}
|
||||||
|
onChange={e => setBody(e.target.value)}
|
||||||
|
placeholder="What's on your mind?"
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none resize-none mb-3"
|
||||||
|
/>
|
||||||
|
{imagePreview && (
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<img src={imagePreview} alt="Preview" className="w-full max-h-48 object-cover rounded-xl" />
|
||||||
|
<button
|
||||||
|
onClick={() => { setImageFile(null); setImagePreview(null); }}
|
||||||
|
className="absolute top-2 right-2 w-6 h-6 bg-black/50 text-white rounded-full text-xs"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<label className="flex items-center gap-2 text-sm text-gray-500 cursor-pointer mb-4">
|
||||||
|
<span className="text-xl">📷</span> Add photo
|
||||||
|
<input type="file" accept="image/*" className="hidden" onChange={pickImage} />
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => setStep("confirm")}
|
||||||
|
disabled={!body.trim() && !imageFile}
|
||||||
|
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-40"
|
||||||
|
>Continue →</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* C9 — Explicit consent surface */
|
||||||
|
<div className="flex-1 flex flex-col gap-4">
|
||||||
|
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-2xl border border-amber-200 dark:border-amber-700">
|
||||||
|
<p className="text-sm font-semibold text-amber-800 dark:text-amber-200 mb-1">📣 Heads up</p>
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
This will be visible to all <strong>{memberCount} {memberCount === 1 ? "family" : "families"}</strong> in{" "}
|
||||||
|
<strong>{circleName}</strong>.{" "}
|
||||||
|
{imageFile && "The photo you selected will also be shared. "}
|
||||||
|
Once posted, other members can see it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{body && <p className="text-sm bg-gray-50 dark:bg-gray-700 rounded-xl px-3 py-2 italic text-gray-600 dark:text-gray-300">"{body}"</p>}
|
||||||
|
<div className="flex gap-3 mt-auto">
|
||||||
|
<button onClick={() => setStep("compose")} className="flex-1 py-3 border border-gray-200 dark:border-gray-600 rounded-xl text-sm">← Edit</button>
|
||||||
|
<button
|
||||||
|
onClick={submit}
|
||||||
|
disabled={posting}
|
||||||
|
className="flex-1 py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-50"
|
||||||
|
>{posting ? "Posting…" : "Post to Circle ✓"}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Circle feed page ──────────────────────────────────────────────────────────
|
||||||
|
export default function CircleFeedPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id: circleId } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const { familyId } = useFamily();
|
||||||
|
|
||||||
|
const [circle, setCircle] = useState<Circle | null>(null);
|
||||||
|
const [posts, setPosts] = useState<CirclePost[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [showMembers, setShowMembers] = useState(false);
|
||||||
|
const [members, setMembers] = useState<{ familyId: string; familyName: string; role: string }[]>([]);
|
||||||
|
const [myRole, setMyRole] = useState<"admin" | "member">("member");
|
||||||
|
|
||||||
|
const fetchFeed = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [circleRes, postsRes] = await Promise.all([
|
||||||
|
fetch(`/api/circles/${circleId}`),
|
||||||
|
fetch(`/api/circles/${circleId}/posts`),
|
||||||
|
]);
|
||||||
|
const circleData = await circleRes.json();
|
||||||
|
const postsData = await postsRes.json();
|
||||||
|
if (circleData.circle) {
|
||||||
|
setCircle(circleData.circle);
|
||||||
|
setMembers(circleData.members ?? []);
|
||||||
|
setMyRole(circleData.circle.role ?? "member");
|
||||||
|
}
|
||||||
|
setPosts(postsData.posts ?? []);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
setLoading(false);
|
||||||
|
}, [circleId]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchFeed(); }, [fetchFeed]);
|
||||||
|
|
||||||
|
const handleReact = async (postId: string, emoji: string) => {
|
||||||
|
await fetch(`/api/circles/${circleId}/posts/${postId}/reactions`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ emoji }),
|
||||||
|
});
|
||||||
|
// Refresh just this post's reactions
|
||||||
|
fetchFeed();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInvite = async () => {
|
||||||
|
const res = await fetch(`/api/circles/${circleId}/invite`, { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.invite?.joinUrl) {
|
||||||
|
await navigator.clipboard.writeText(data.invite.joinUrl).catch(() => {});
|
||||||
|
alert(`Invite link copied!\n\n${data.invite.joinUrl}\n\nExpires in 7 days.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeave = async () => {
|
||||||
|
if (!confirm("Leave this circle?")) return;
|
||||||
|
const res = await fetch(`/api/circles/${circleId}/members`, { method: "DELETE" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) router.push("/circle");
|
||||||
|
else alert(data.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
|
||||||
|
<div className="flex gap-3 text-3xl">
|
||||||
|
{["👨👩👧", "💬", "❤️"].map((e, i) => (
|
||||||
|
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!circle) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-4xl mb-2">🔒</p>
|
||||||
|
<p>Circle not found or you are not a member.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 flex items-center gap-3">
|
||||||
|
<button onClick={() => router.push("/circle")} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h1 className="text-lg font-bold truncate">{circle.name}</h1>
|
||||||
|
<p className="text-xs text-gray-400">{circle.memberCount} {circle.memberCount === 1 ? "family" : "families"}</p>
|
||||||
|
</div>
|
||||||
|
{/* Members panel toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMembers(v => !v)}
|
||||||
|
className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-sm"
|
||||||
|
>👥</button>
|
||||||
|
{/* Admin: invite */}
|
||||||
|
{myRole === "admin" && (
|
||||||
|
<button
|
||||||
|
onClick={handleInvite}
|
||||||
|
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
|
||||||
|
>+ Invite</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members panel */}
|
||||||
|
{showMembers && (
|
||||||
|
<div className="mx-4 mb-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<p className="font-medium text-sm">Members</p>
|
||||||
|
<button onClick={handleLeave} className="text-xs text-red-400">Leave circle</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{members.map(m => (
|
||||||
|
<div key={m.familyId} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-xs">👨👩👧</div>
|
||||||
|
<span className="text-sm">{m.familyName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${m.role === "admin" ? "bg-rose-100 text-rose-600" : "bg-gray-100 text-gray-500"}`}>
|
||||||
|
{m.role}
|
||||||
|
</span>
|
||||||
|
{myRole === "admin" && m.familyId !== familyId && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await fetch(`/api/circles/${circleId}/members/${m.familyId}`, { method: "DELETE" });
|
||||||
|
fetchFeed();
|
||||||
|
}}
|
||||||
|
className="text-xs text-red-400"
|
||||||
|
>Remove</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Posts feed */}
|
||||||
|
<div className="px-4 space-y-4">
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<div className="text-center py-16 px-8">
|
||||||
|
<p className="text-4xl mb-3">💬</p>
|
||||||
|
<p className="font-semibold text-gray-700 dark:text-gray-200">No posts yet</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Be the first to share something with {circle.name}!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
posts.map(p => (
|
||||||
|
<PostCard
|
||||||
|
key={p.id}
|
||||||
|
post={p}
|
||||||
|
myFamilyId={familyId ?? ""}
|
||||||
|
circleId={circleId}
|
||||||
|
isAdmin={myRole === "admin"}
|
||||||
|
onDeleted={id => setPosts(prev => prev.filter(x => x.id !== id))}
|
||||||
|
onReact={handleReact}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* FAB — create post */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="fixed bottom-20 right-5 w-14 h-14 bg-rose-400 text-white rounded-full shadow-lg flex items-center justify-center text-2xl z-40"
|
||||||
|
>+</button>
|
||||||
|
|
||||||
|
{showCreate && circle && familyId && (
|
||||||
|
<CreatePostModal
|
||||||
|
circleId={circleId}
|
||||||
|
circleName={circle.name}
|
||||||
|
memberCount={circle.memberCount}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onPosted={fetchFeed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/app/circle/join/[token]/page.tsx
Normal file
124
src/app/circle/join/[token]/page.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, use } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useFamily } from "../../../FamilyProvider";
|
||||||
|
|
||||||
|
type State = "loading" | "preview" | "joining" | "success" | "error";
|
||||||
|
|
||||||
|
export default function JoinCirclePage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const { familyId, loading: authLoading } = useFamily();
|
||||||
|
|
||||||
|
const [state, setState] = useState<State>("loading");
|
||||||
|
const [circleName, setCircleName] = useState("");
|
||||||
|
const [memberCount, setMemberCount] = useState(0);
|
||||||
|
const [errorMsg, setErrorMsg] = useState("");
|
||||||
|
|
||||||
|
// Preview the circle info (no auth required)
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`/api/circle/join/${token}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.error) { setErrorMsg(data.error); setState("error"); return; }
|
||||||
|
setCircleName(data.circleName);
|
||||||
|
setMemberCount(data.memberCount);
|
||||||
|
setState("preview");
|
||||||
|
})
|
||||||
|
.catch(() => { setErrorMsg("Could not load this invite."); setState("error"); });
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const joinCircle = async () => {
|
||||||
|
if (!familyId) {
|
||||||
|
// Not logged in — redirect to login with return URL
|
||||||
|
router.push(`/login?next=/circle/join/${token}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState("joining");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/circle/join/${token}`, { method: "POST" });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setState("success");
|
||||||
|
setTimeout(() => router.push(`/circle/${data.circleId}`), 1500);
|
||||||
|
} else {
|
||||||
|
setErrorMsg(data.error ?? "Could not join circle.");
|
||||||
|
setState("error");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setErrorMsg("Something went wrong. Please try again.");
|
||||||
|
setState("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center p-6">
|
||||||
|
<div className="w-full max-w-sm bg-white dark:bg-gray-800 rounded-3xl shadow-xl p-6 text-center">
|
||||||
|
|
||||||
|
{(state === "loading" || authLoading) && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-center gap-2 text-3xl mb-4">
|
||||||
|
{["👨👩👧", "❤️", "👶"].map((e, i) => (
|
||||||
|
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500">Loading invite…</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "preview" && !authLoading && (
|
||||||
|
<>
|
||||||
|
<div className="w-16 h-16 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-3xl mx-auto mb-4">
|
||||||
|
👨👩👧👦
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold mb-1">You're invited!</h1>
|
||||||
|
<p className="text-gray-500 text-sm mb-4">
|
||||||
|
Join <strong className="text-gray-800 dark:text-white">{circleName}</strong>
|
||||||
|
<br />
|
||||||
|
<span className="text-xs">{memberCount} {memberCount === 1 ? "family" : "families"} already inside</span>
|
||||||
|
</p>
|
||||||
|
{!familyId && (
|
||||||
|
<p className="text-xs text-amber-600 bg-amber-50 rounded-xl px-3 py-2 mb-4">
|
||||||
|
You'll need to log in to Tia first.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={joinCircle}
|
||||||
|
className="w-full py-3 bg-rose-400 text-white rounded-xl font-semibold"
|
||||||
|
>
|
||||||
|
{familyId ? `Join ${circleName}` : "Log in to Join"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => router.push("/")} className="mt-3 text-xs text-gray-400">
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "joining" && (
|
||||||
|
<>
|
||||||
|
<div className="w-12 h-12 border-4 border-rose-400 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||||
|
<p className="text-gray-500">Joining circle…</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "success" && (
|
||||||
|
<>
|
||||||
|
<div className="text-5xl mb-4">🎉</div>
|
||||||
|
<h1 className="text-xl font-bold mb-1">You're in!</h1>
|
||||||
|
<p className="text-gray-500 text-sm">Welcome to <strong>{circleName}</strong>. Taking you there…</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state === "error" && (
|
||||||
|
<>
|
||||||
|
<div className="text-5xl mb-4">😕</div>
|
||||||
|
<h1 className="text-lg font-bold mb-2 text-gray-800 dark:text-white">Invite issue</h1>
|
||||||
|
<p className="text-gray-500 text-sm mb-5">{errorMsg}</p>
|
||||||
|
<button onClick={() => router.push("/")} className="w-full py-3 bg-gray-100 dark:bg-gray-700 rounded-xl text-sm">Go to Home</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/app/circle/page.tsx
Normal file
125
src/app/circle/page.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useFamily } from "../FamilyProvider";
|
||||||
|
import type { Circle } from "@/types";
|
||||||
|
|
||||||
|
export default function CirclePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { familyId } = useFamily();
|
||||||
|
const [circles, setCircles] = useState<Circle[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (familyId) fetchCircles();
|
||||||
|
}, [familyId]);
|
||||||
|
|
||||||
|
const fetchCircles = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/circles");
|
||||||
|
const data = await res.json();
|
||||||
|
setCircles(data.circles ?? []);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCircle = async () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/circles", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: newName.trim() }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setNewName("");
|
||||||
|
setShowCreate(false);
|
||||||
|
router.push(`/circle/${data.circle.id}`);
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
setCreating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 pb-24">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 flex items-center gap-3">
|
||||||
|
<button onClick={() => router.back()} className="p-2 rounded-xl bg-white dark:bg-gray-800 shadow-sm text-xl">←</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-xl font-bold">My Circles</h1>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Private groups with trusted families</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(v => !v)}
|
||||||
|
className="px-4 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
|
||||||
|
>+ New</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create circle form */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className="mx-4 mb-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm space-y-3">
|
||||||
|
<p className="text-sm font-medium text-gray-700 dark:text-gray-200">Create a new Circle</p>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newName}
|
||||||
|
onChange={e => setNewName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === "Enter" && createCircle()}
|
||||||
|
placeholder="e.g. NCB Mamas 2024"
|
||||||
|
className="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none border border-gray-200 dark:border-gray-600 focus:border-rose-400"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={createCircle}
|
||||||
|
disabled={creating || !newName.trim()}
|
||||||
|
className="flex-1 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium disabled:opacity-50"
|
||||||
|
>{creating ? "Creating…" : "Create Circle"}</button>
|
||||||
|
<button onClick={() => setShowCreate(false)} className="px-4 py-2 text-gray-400 text-sm">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="px-4 space-y-3">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-16">
|
||||||
|
<div className="flex gap-2 text-3xl">
|
||||||
|
{["👨👩👧", "💬", "❤️"].map((e, i) => (
|
||||||
|
<span key={i} className="animate-bounce" style={{ animationDelay: `${i * 120}ms` }}>{e}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : circles.length === 0 ? (
|
||||||
|
<div className="text-center py-16 px-8">
|
||||||
|
<p className="text-4xl mb-3">👨👩👧👦</p>
|
||||||
|
<p className="font-semibold text-gray-700 dark:text-gray-200">No circles yet</p>
|
||||||
|
<p className="text-sm text-gray-400 mt-1">Create one and invite trusted families to share milestones privately.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
circles.map(c => (
|
||||||
|
<Link
|
||||||
|
key={c.id}
|
||||||
|
href={`/circle/${c.id}`}
|
||||||
|
className="flex items-center gap-4 p-4 bg-white dark:bg-gray-800 rounded-2xl shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="w-12 h-12 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-2xl flex-shrink-0">
|
||||||
|
👨👩👧
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-semibold truncate">{c.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">{c.memberCount} {c.memberCount === 1 ? "family" : "families"} · {c.role === "admin" ? "Admin" : "Member"}</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400">›</span>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ export default function MenuPage() {
|
||||||
{ icon: "🤖", label: "AI Chat", href: "/ai" },
|
{ icon: "🤖", label: "AI Chat", href: "/ai" },
|
||||||
{ icon: "🌟", label: "Milestones", href: "/milestones" },
|
{ icon: "🌟", label: "Milestones", href: "/milestones" },
|
||||||
{ icon: "👗", label: "Wardrobe", href: "/wardrobe" },
|
{ icon: "👗", label: "Wardrobe", href: "/wardrobe" },
|
||||||
|
{ icon: "👨👩👧", label: "Circle", href: "/circle" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSignOut = async () => {
|
const handleSignOut = async () => {
|
||||||
|
|
|
||||||
|
|
@ -101,3 +101,48 @@ export interface ChatSession {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Circle types ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Circle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
createdBy: string;
|
||||||
|
createdAt: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
memberCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircleMember {
|
||||||
|
familyId: string;
|
||||||
|
familyName: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
joinedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CirclePost {
|
||||||
|
id: string;
|
||||||
|
circleId: string;
|
||||||
|
authorFamilyId: string;
|
||||||
|
authorFamilyName: string;
|
||||||
|
body: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
sourceKind: "milestone" | "memory" | null;
|
||||||
|
createdAt: string;
|
||||||
|
reactions: CircleReaction[];
|
||||||
|
commentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircleReaction {
|
||||||
|
emoji: string;
|
||||||
|
count: number;
|
||||||
|
includedMe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CircleComment {
|
||||||
|
id: string;
|
||||||
|
authorFamilyId: string;
|
||||||
|
authorFamilyName: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue