feat: email-based circle invites with in-app notifications

- Admin invites by entering email instead of copying a link
- If email matches existing Tia user → creates pending invite visible
  on their Circles page with Accept/Decline buttons
- If email is not registered → sends Resend email with signup link
  that lands them directly in the circle after account creation
- DB migration adds invited_email + invited_family_id to circle_invites
- New GET /api/circles/invites endpoint for pending invite banners
- Remove clipboard-copy approach entirely

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 02:05:10 +05:30
parent 9f7ab870ba
commit c24392f0a1
6 changed files with 306 additions and 31 deletions

View file

@ -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;

View file

@ -29,6 +29,13 @@
"when": 1748134800000, "when": 1748134800000,
"tag": "0003_circles", "tag": "0003_circles",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1748221200000,
"tag": "0004_circle_invite_email",
"breakpoints": true
} }
] ]
} }

View file

@ -2,10 +2,15 @@ import { NextResponse } from "next/server";
import { sql } from "@/db"; import { sql } from "@/db";
import { requireFamily } from "@/lib/auth"; import { requireFamily } from "@/lib/auth";
import { randomBytes } from "crypto"; 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 <tia@manohargupta.com>";
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( export async function POST(
_req: Request, req: Request,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try { try {
@ -15,31 +20,117 @@ export async function POST(
const familyId = auth.session!.familyId!; const familyId = auth.session!.familyId!;
const { id: circleId } = await params; const { id: circleId } = await params;
// Only admins can create invites // Only admins can invite
const rows = await sql.unsafe( const rows = await sql.unsafe(
`SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`, `SELECT role FROM circle_members WHERE circle_id = $1 AND family_id = $2`,
[circleId, familyId] [circleId, familyId]
); );
if (!rows[0] || rows[0].role !== "admin") { 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 body = await req.json();
const token = randomBytes(32).toString("hex"); const email: string = (body.email ?? "").trim().toLowerCase();
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); // 7 days if (!email || !email.includes("@")) {
return NextResponse.json({ error: "Valid email address required" }, { status: 400 });
}
const [invite] = await sql.unsafe( // Fetch circle info for the email
`INSERT INTO circle_invites (circle_id, token, created_by, expires_at) const [circle] = await sql.unsafe(
VALUES ($1, $2, $3, $4) `SELECT name FROM circles WHERE id = $1`,
RETURNING id, token, expires_at as "expiresAt"`, [circleId]
[circleId, token, familyId, expiresAt] );
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"; // Look up if the email belongs to an existing user → find their family
return NextResponse.json({ const userRows = await sql.unsafe(
success: true, `SELECT u.id as user_id, fm.family_id
invite: { ...invite, joinUrl: `${baseUrl}/circle/join/${token}` }, 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: `
<div style="font-family:system-ui,sans-serif;max-width:520px;margin:0 auto;padding:24px">
<h2 style="margin-bottom:8px">You're invited! 👨👩👧👦</h2>
<p style="color:#374151">
<strong>${inviterFamily?.name ?? "A family"}</strong> has invited you to join
their circle <strong>"${circle?.name ?? "a circle"}"</strong> on Tia a baby tracking app for families.
</p>
<p style="color:#6b7280;font-size:14px">
Create a free account to join them and start sharing milestones, memories, and updates.
</p>
<a href="${APP_URL}/login?next=/circle/join/${token}"
style="display:inline-block;background:#fb7185;color:white;padding:12px 24px;
text-decoration:none;border-radius:10px;margin:16px 0;font-weight:600">
Create Account &amp; Join Circle
</a>
<p style="color:#6b7280;font-size:13px">
Already have an account?
<a href="${joinUrl}" style="color:#fb7185">Click here to join directly</a>.
</p>
<p style="color:#9ca3af;font-size:12px;margin-top:24px">
This invite expires in 7 days. If you didn't expect this, you can ignore this email.
</p>
</div>
`,
});
} catch (emailErr) {
console.error("[CIRCLE-INVITE-EMAIL-FAILED]", emailErr);
}
}
return NextResponse.json({ success: true, type: "email_sent" });
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 }); return NextResponse.json({ error: msg }, { status: 500 });

View file

@ -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 });
}
}

View file

@ -330,6 +330,10 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin
const [showMembers, setShowMembers] = useState(false); const [showMembers, setShowMembers] = useState(false);
const [members, setMembers] = useState<{ familyId: string; familyName: string; role: string }[]>([]); const [members, setMembers] = useState<{ familyId: string; familyName: string; role: string }[]>([]);
const [myRole, setMyRole] = useState<"admin" | "member">("member"); 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 () => { const fetchFeed = useCallback(async () => {
try { try {
@ -361,13 +365,30 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin
fetchFeed(); fetchFeed();
}; };
const handleInvite = async () => { const handleInviteSend = async () => {
const res = await fetch(`/api/circles/${circleId}/invite`, { method: "POST" }); if (!inviteEmail.trim()) return;
const data = await res.json(); setInviteSending(true);
if (data.invite?.joinUrl) { setInviteResult(null);
await navigator.clipboard.writeText(data.invite.joinUrl).catch(() => {}); try {
alert(`Invite link copied!\n\n${data.invite.joinUrl}\n\nExpires in 7 days.`); 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 () => { const handleLeave = async () => {
@ -418,7 +439,7 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin
{/* Admin: invite */} {/* Admin: invite */}
{myRole === "admin" && ( {myRole === "admin" && (
<button <button
onClick={handleInvite} onClick={() => { setShowInviteModal(true); setInviteResult(null); setInviteEmail(""); }}
className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium" className="px-3 py-2 bg-rose-400 text-white rounded-xl text-sm font-medium"
>+ Invite</button> >+ Invite</button>
)} )}
@ -496,6 +517,51 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin
onPosted={fetchFeed} onPosted={fetchFeed}
/> />
)} )}
{/* Invite by email modal */}
{showInviteModal && (
<>
<div className="fixed inset-0 bg-black/50 z-50" onClick={() => setShowInviteModal(false)} />
<div className="fixed bottom-0 inset-x-0 z-50 bg-white dark:bg-gray-900 rounded-t-2xl p-5 pb-10 shadow-xl">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold">Invite to {circle?.name}</h3>
<button onClick={() => setShowInviteModal(false)} className="text-gray-400 text-xl"></button>
</div>
<p className="text-sm text-gray-500 mb-4">
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.
</p>
<input
id="invite-email"
name="invite-email"
type="email"
autoFocus
value={inviteEmail}
onChange={e => 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 && (
<p className={`text-sm mb-3 px-3 py-2 rounded-xl ${
inviteResult.type === "success"
? "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300"
: "bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-300"
}`}>
{inviteResult.type === "success" ? "✓ " : "✗ "}{inviteResult.msg}
</p>
)}
<button
onClick={handleInviteSend}
disabled={inviteSending || !inviteEmail.trim()}
className="w-full py-3 bg-rose-400 text-white rounded-xl font-medium disabled:opacity-40"
>
{inviteSending ? "Sending…" : "Send Invite"}
</button>
</div>
</>
)}
</div> </div>
); );
} }

View file

@ -6,17 +6,29 @@ import Link from "next/link";
import { useFamily } from "../FamilyProvider"; import { useFamily } from "../FamilyProvider";
import type { Circle } from "@/types"; 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() { export default function CirclePage() {
const router = useRouter(); const router = useRouter();
const { familyId } = useFamily(); const { familyId } = useFamily();
const [circles, setCircles] = useState<Circle[]>([]); const [circles, setCircles] = useState<Circle[]>([]);
const [loading, setLoading] = useState(true); const [pendingInvites, setPendingInvites] = useState<PendingInvite[]>([]);
const [creating, setCreating] = useState(false); const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState(""); const [creating, setCreating] = useState(false);
const [showCreate, setShowCreate] = useState(false); const [newName, setNewName] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [joiningId, setJoiningId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (familyId) fetchCircles(); if (familyId) { fetchCircles(); fetchPendingInvites(); }
}, [familyId]); }, [familyId]);
const fetchCircles = async () => { const fetchCircles = async () => {
@ -28,6 +40,14 @@ export default function CirclePage() {
setLoading(false); 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 () => { const createCircle = async () => {
if (!newName.trim()) return; if (!newName.trim()) return;
setCreating(true); setCreating(true);
@ -47,6 +67,25 @@ export default function CirclePage() {
setCreating(false); 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 ( 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"> <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 */} {/* Header */}
@ -88,6 +127,35 @@ export default function CirclePage() {
</div> </div>
)} )}
{/* Pending invites */}
{pendingInvites.length > 0 && (
<div className="px-4 mb-4 space-y-2">
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide px-1 mb-1">Pending Invites</p>
{pendingInvites.map(inv => (
<div key={inv.id} className="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-2xl p-4 flex items-center gap-3">
<div className="w-10 h-10 bg-rose-100 dark:bg-rose-900/40 rounded-full flex items-center justify-center text-xl flex-shrink-0">
👨👩👧👦
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{inv.circleName}</p>
<p className="text-xs text-gray-500">{inv.inviterName} invited you · {inv.memberCount} {inv.memberCount === 1 ? "family" : "families"}</p>
</div>
<div className="flex gap-2 flex-shrink-0">
<button
onClick={() => acceptInvite(inv)}
disabled={joiningId === inv.id}
className="px-3 py-1.5 bg-rose-400 text-white rounded-xl text-xs font-medium disabled:opacity-50"
>{joiningId === inv.id ? "…" : "Join"}</button>
<button
onClick={() => declineInvite(inv.id)}
className="px-3 py-1.5 bg-gray-100 dark:bg-gray-700 text-gray-500 rounded-xl text-xs"
></button>
</div>
</div>
))}
</div>
)}
{/* List */} {/* List */}
<div className="px-4 space-y-3"> <div className="px-4 space-y-3">
{loading ? ( {loading ? (
@ -98,7 +166,7 @@ export default function CirclePage() {
))} ))}
</div> </div>
</div> </div>
) : circles.length === 0 ? ( ) : circles.length === 0 && pendingInvites.length === 0 ? (
<div className="text-center py-16 px-8"> <div className="text-center py-16 px-8">
<p className="text-4xl mb-3">👨👩👧👦</p> <p className="text-4xl mb-3">👨👩👧👦</p>
<p className="font-semibold text-gray-700 dark:text-gray-200">No circles yet</p> <p className="font-semibold text-gray-700 dark:text-gray-200">No circles yet</p>