diff --git a/drizzle/0004_circle_invite_email.sql b/drizzle/0004_circle_invite_email.sql new file mode 100644 index 0000000..ad23d8a --- /dev/null +++ b/drizzle/0004_circle_invite_email.sql @@ -0,0 +1,9 @@ +-- Add email-based invite columns to circle_invites. +-- invited_email: the address that was invited +-- invited_family_id: set when the email matches an existing family (for in-app notification) + +ALTER TABLE circle_invites ADD COLUMN IF NOT EXISTS invited_email text; +--> statement-breakpoint +ALTER TABLE circle_invites ADD COLUMN IF NOT EXISTS invited_family_id uuid REFERENCES families(id); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS circle_invites_invited_family_idx ON circle_invites(invited_family_id) WHERE invited_family_id IS NOT NULL; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index eeafc02..b20a66f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1748134800000, "tag": "0003_circles", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1748221200000, + "tag": "0004_circle_invite_email", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/circles/[id]/invite/route.ts b/src/app/api/circles/[id]/invite/route.ts index 27359a7..953c289 100644 --- a/src/app/api/circles/[id]/invite/route.ts +++ b/src/app/api/circles/[id]/invite/route.ts @@ -2,10 +2,15 @@ import { NextResponse } from "next/server"; import { sql } from "@/db"; import { requireFamily } from "@/lib/auth"; import { randomBytes } from "crypto"; +import { Resend } from "resend"; -// POST — create a single-use invite link (admin only) +const RESEND_API_KEY = process.env.RESEND_API_KEY; +const EMAIL_FROM = process.env.EMAIL_FROM || "Tia "; +const APP_URL = process.env.NEXT_PUBLIC_APP_URL || "https://tia.manohargupta.com"; + +// POST — invite a person by email (admin only) export async function POST( - _req: Request, + req: Request, { params }: { params: Promise<{ id: string }> } ) { try { @@ -15,31 +20,117 @@ export async function POST( const familyId = auth.session!.familyId!; const { id: circleId } = await params; - // Only admins can create invites + // Only admins can invite 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 }); + return NextResponse.json({ error: "Only circle admins can invite members" }, { 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).toISOString(); // 7 days + const body = await req.json(); + const email: string = (body.email ?? "").trim().toLowerCase(); + if (!email || !email.includes("@")) { + return NextResponse.json({ error: "Valid email address required" }, { status: 400 }); + } - 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] + // Fetch circle info for the email + const [circle] = await sql.unsafe( + `SELECT name FROM circles WHERE id = $1`, + [circleId] + ); + const [inviterFamily] = await sql.unsafe( + `SELECT name FROM families WHERE id = $1`, + [familyId] ); - const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "https://tia.manohargupta.com"; - return NextResponse.json({ - success: true, - invite: { ...invite, joinUrl: `${baseUrl}/circle/join/${token}` }, - }); + // Look up if the email belongs to an existing user → find their family + const userRows = await sql.unsafe( + `SELECT u.id as user_id, fm.family_id + FROM users u + LEFT JOIN family_members fm ON fm.user_id = u.id + WHERE u.email = $1 + LIMIT 1`, + [email] + ); + const invitedFamilyId: string | null = userRows[0]?.family_id ?? null; + + // Don't invite someone already in the circle + if (invitedFamilyId) { + const already = await sql.unsafe( + `SELECT 1 FROM circle_members WHERE circle_id = $1 AND family_id = $2`, + [circleId, invitedFamilyId] + ); + if (already.length > 0) { + return NextResponse.json({ error: "This family is already a member of the circle" }, { status: 409 }); + } + + // Check for an existing pending invite to the same family + const pending = await sql.unsafe( + `SELECT 1 FROM circle_invites + WHERE circle_id = $1 AND invited_family_id = $2 AND consumed_at IS NULL AND expires_at > now()`, + [circleId, invitedFamilyId] + ); + if (pending.length > 0) { + return NextResponse.json({ error: "An invite is already pending for this person" }, { status: 409 }); + } + } + + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); + const joinUrl = `${APP_URL}/circle/join/${token}`; + + await sql.unsafe( + `INSERT INTO circle_invites (circle_id, token, created_by, expires_at, invited_email, invited_family_id) + VALUES ($1, $2, $3, $4, $5, $6)`, + [circleId, token, familyId, expiresAt, email, invitedFamilyId] + ); + + if (invitedFamilyId) { + // Existing user — they'll see the pending invite on their circles page + return NextResponse.json({ success: true, type: "in_app" }); + } + + // Non-registered user — send email via Resend + if (RESEND_API_KEY) { + try { + const resend = new Resend(RESEND_API_KEY); + await resend.emails.send({ + from: EMAIL_FROM, + to: email, + subject: `${inviterFamily?.name ?? "Someone"} invited you to join ${circle?.name ?? "a circle"} on Tia`, + html: ` +
+

You're invited! 👨‍👩‍👧‍👦

+

+ ${inviterFamily?.name ?? "A family"} has invited you to join + their circle "${circle?.name ?? "a circle"}" on Tia — a baby tracking app for families. +

+

+ Create a free account to join them and start sharing milestones, memories, and updates. +

+ + Create Account & Join Circle + +

+ Already have an account? + Click here to join directly. +

+

+ This invite expires in 7 days. If you didn't expect this, you can ignore this email. +

+
+ `, + }); + } catch (emailErr) { + console.error("[CIRCLE-INVITE-EMAIL-FAILED]", emailErr); + } + } + + return NextResponse.json({ success: true, type: "email_sent" }); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); return NextResponse.json({ error: msg }, { status: 500 }); diff --git a/src/app/api/circles/invites/route.ts b/src/app/api/circles/invites/route.ts new file mode 100644 index 0000000..392d165 --- /dev/null +++ b/src/app/api/circles/invites/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from "next/server"; +import { sql } from "@/db"; +import { requireFamily } from "@/lib/auth"; + +// GET — pending circle invites for the current family +export async function GET() { + try { + const auth = await requireFamily(); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const familyId = auth.session!.familyId!; + + const invites = await sql.unsafe( + `SELECT ci.id, ci.token, ci.circle_id as "circleId", + c.name as "circleName", + c.id as "circleId", + f.name as "inviterName", + (SELECT COUNT(*) FROM circle_members WHERE circle_id = c.id)::int as "memberCount", + ci.expires_at as "expiresAt" + FROM circle_invites ci + JOIN circles c ON c.id = ci.circle_id + JOIN families f ON f.id = ci.created_by + WHERE ci.invited_family_id = $1 + AND ci.consumed_at IS NULL + AND ci.expires_at > now() + ORDER BY ci.created_at DESC`, + [familyId] + ); + + return NextResponse.json({ invites }); + } catch (err: unknown) { + return NextResponse.json({ error: err instanceof Error ? err.message : String(err) }, { status: 500 }); + } +} diff --git a/src/app/circle/[id]/page.tsx b/src/app/circle/[id]/page.tsx index 2e3de4a..0e50457 100644 --- a/src/app/circle/[id]/page.tsx +++ b/src/app/circle/[id]/page.tsx @@ -330,6 +330,10 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin const [showMembers, setShowMembers] = useState(false); const [members, setMembers] = useState<{ familyId: string; familyName: string; role: string }[]>([]); const [myRole, setMyRole] = useState<"admin" | "member">("member"); + const [showInviteModal, setShowInviteModal] = useState(false); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteSending, setInviteSending] = useState(false); + const [inviteResult, setInviteResult] = useState<{ type: "success" | "error"; msg: string } | null>(null); const fetchFeed = useCallback(async () => { try { @@ -361,13 +365,30 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin 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 handleInviteSend = async () => { + if (!inviteEmail.trim()) return; + setInviteSending(true); + setInviteResult(null); + try { + const res = await fetch(`/api/circles/${circleId}/invite`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: inviteEmail.trim() }), + }); + const data = await res.json(); + if (data.success) { + const msg = data.type === "in_app" + ? `Invite sent! ${inviteEmail} will see it on their Circles page.` + : `Invite email sent to ${inviteEmail}!`; + setInviteResult({ type: "success", msg }); + setInviteEmail(""); + } else { + setInviteResult({ type: "error", msg: data.error ?? "Failed to send invite" }); + } + } catch { + setInviteResult({ type: "error", msg: "Something went wrong" }); } + setInviteSending(false); }; const handleLeave = async () => { @@ -418,7 +439,7 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin {/* Admin: invite */} {myRole === "admin" && ( )} @@ -496,6 +517,51 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin onPosted={fetchFeed} /> )} + + {/* Invite by email modal */} + {showInviteModal && ( + <> +
setShowInviteModal(false)} /> +
+
+

Invite to {circle?.name}

+ +
+

+ Enter the email address of the person you want to invite. + If they already have a Tia account they'll get a notification; + otherwise we'll email them a link to sign up and join. +

+ setInviteEmail(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleInviteSend()} + placeholder="their@email.com" + className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 rounded-xl text-sm outline-none mb-3 border border-gray-200 dark:border-gray-600" + /> + {inviteResult && ( +

+ {inviteResult.type === "success" ? "✓ " : "✗ "}{inviteResult.msg} +

+ )} + +
+ + )}
); } diff --git a/src/app/circle/page.tsx b/src/app/circle/page.tsx index 4617b56..bfafd2f 100644 --- a/src/app/circle/page.tsx +++ b/src/app/circle/page.tsx @@ -6,17 +6,29 @@ import Link from "next/link"; import { useFamily } from "../FamilyProvider"; import type { Circle } from "@/types"; +type PendingInvite = { + id: string; + token: string; + circleId: string; + circleName: string; + inviterName: string; + memberCount: number; + expiresAt: string; +}; + export default function CirclePage() { const router = useRouter(); const { familyId } = useFamily(); - const [circles, setCircles] = useState([]); - const [loading, setLoading] = useState(true); - const [creating, setCreating] = useState(false); - const [newName, setNewName] = useState(""); - const [showCreate, setShowCreate] = useState(false); + const [circles, setCircles] = useState([]); + const [pendingInvites, setPendingInvites] = useState([]); + const [loading, setLoading] = useState(true); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [showCreate, setShowCreate] = useState(false); + const [joiningId, setJoiningId] = useState(null); useEffect(() => { - if (familyId) fetchCircles(); + if (familyId) { fetchCircles(); fetchPendingInvites(); } }, [familyId]); const fetchCircles = async () => { @@ -28,6 +40,14 @@ export default function CirclePage() { setLoading(false); }; + const fetchPendingInvites = async () => { + try { + const res = await fetch("/api/circles/invites"); + const data = await res.json(); + setPendingInvites(data.invites ?? []); + } catch { /* silent */ } + }; + const createCircle = async () => { if (!newName.trim()) return; setCreating(true); @@ -47,6 +67,25 @@ export default function CirclePage() { setCreating(false); }; + const acceptInvite = async (invite: PendingInvite) => { + setJoiningId(invite.id); + try { + const res = await fetch(`/api/circle/join/${invite.token}`, { method: "POST" }); + const data = await res.json(); + if (data.success) { + router.push(`/circle/${data.circleId}`); + } else { + alert(data.error ?? "Could not join circle"); + setPendingInvites(prev => prev.filter(i => i.id !== invite.id)); + } + } catch { /* silent */ } + setJoiningId(null); + }; + + const declineInvite = (inviteId: string) => { + setPendingInvites(prev => prev.filter(i => i.id !== inviteId)); + }; + return (
{/* Header */} @@ -88,6 +127,35 @@ export default function CirclePage() {
)} + {/* Pending invites */} + {pendingInvites.length > 0 && ( +
+

Pending Invites

+ {pendingInvites.map(inv => ( +
+
+ 👨‍👩‍👧‍👦 +
+
+

{inv.circleName}

+

{inv.inviterName} invited you · {inv.memberCount} {inv.memberCount === 1 ? "family" : "families"}

+
+
+ + +
+
+ ))} +
+ )} + {/* List */}
{loading ? ( @@ -98,7 +166,7 @@ export default function CirclePage() { ))}
- ) : circles.length === 0 ? ( + ) : circles.length === 0 && pendingInvites.length === 0 ? (

👨‍👩‍👧‍👦

No circles yet