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:
parent
9f7ab870ba
commit
c24392f0a1
6 changed files with 306 additions and 31 deletions
9
drizzle/0004_circle_invite_email.sql
Normal file
9
drizzle/0004_circle_invite_email.sql
Normal 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;
|
||||
|
|
@ -29,6 +29,13 @@
|
|||
"when": 1748134800000,
|
||||
"tag": "0003_circles",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "7",
|
||||
"when": 1748221200000,
|
||||
"tag": "0004_circle_invite_email",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 <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(
|
||||
_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: `
|
||||
<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 & 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) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
|
|
|
|||
34
src/app/api/circles/invites/route.ts
Normal file
34
src/app/api/circles/invites/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 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.invite?.joinUrl) {
|
||||
await navigator.clipboard.writeText(data.invite.joinUrl).catch(() => {});
|
||||
alert(`Invite link copied!\n\n${data.invite.joinUrl}\n\nExpires in 7 days.`);
|
||||
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" && (
|
||||
<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"
|
||||
>+ Invite</button>
|
||||
)}
|
||||
|
|
@ -496,6 +517,51 @@ export default function CircleFeedPage({ params }: { params: Promise<{ id: strin
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Circle[]>([]);
|
||||
const [pendingInvites, setPendingInvites] = useState<PendingInvite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [joiningId, setJoiningId] = useState<string | null>(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 (
|
||||
<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 */}
|
||||
|
|
@ -88,6 +127,35 @@ export default function CirclePage() {
|
|||
</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 */}
|
||||
<div className="px-4 space-y-3">
|
||||
{loading ? (
|
||||
|
|
@ -98,7 +166,7 @@ export default function CirclePage() {
|
|||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : circles.length === 0 ? (
|
||||
) : circles.length === 0 && pendingInvites.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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue