feat(invites): auto-send invite email via Resend + register migration in journal

- Add sendFamilyInviteEmail() to email.ts using existing Resend setup
- Wire into POST /api/invites — fetches inviter name + family name, sends
  warm invite email with Accept Invitation button linking to /invite/{token}
- Email is non-fatal: invite is created even if email send fails
- Register 0006_family_invites_missing_cols in _journal.json so Dokploy
  auto-applies the migration on next deploy

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-24 14:24:31 +05:30
parent 0b67631fda
commit b01e0596c1
3 changed files with 77 additions and 1 deletions

View file

@ -43,6 +43,13 @@
"when": 1748307600000, "when": 1748307600000,
"tag": "0005_email_verification", "tag": "0005_email_verification",
"breakpoints": true "breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1748394000000,
"tag": "0006_family_invites_missing_cols",
"breakpoints": true
} }
] ]
} }

View file

@ -2,6 +2,7 @@ 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 { sendFamilyInviteEmail } from "@/lib/email";
// GET - list invites for a family // GET - list invites for a family
export async function GET(request: Request) { export async function GET(request: Request) {
@ -61,9 +62,32 @@ export async function POST(request: Request) {
`INSERT INTO family_invites (family_id, email, role, display_name, token, expires_at) `INSERT INTO family_invites (family_id, email, role, display_name, token, expires_at)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, email, role, display_name as "displayName", expires_at as "expiresAt"`, RETURNING id, email, role, display_name as "displayName", expires_at as "expiresAt"`,
[auth.session!.familyId, email, role || "caregiver", displayName, token, expiresAt.toISOString()] [auth.session!.familyId, email, role || "caregiver", displayName || null, token, expiresAt.toISOString()]
); );
// Fetch inviter name + family name for the email
try {
const [meta] = await sql.unsafe(
`SELECT u.name as inviter_name, f.name as family_name
FROM family_members fm
JOIN users u ON u.id = fm.user_id
JOIN families f ON f.id = fm.family_id
WHERE fm.family_id = $1 AND fm.user_id = $2
LIMIT 1`,
[auth.session!.familyId, auth.session!.userId]
);
await sendFamilyInviteEmail({
to: email,
inviterName: meta?.inviter_name || "Someone",
familyName: meta?.family_name || "your family",
token,
role: role || "caregiver",
});
} catch (emailErr) {
console.error("[INVITE-EMAIL-ERROR]", emailErr);
// non-fatal — invite was created, email just didn't send
}
return NextResponse.json({ success: true, invite, inviteUrl: `/invite/${token}` }); return NextResponse.json({ success: true, invite, inviteUrl: `/invite/${token}` });
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View file

@ -4,6 +4,51 @@ const RESEND_API_KEY = process.env.RESEND_API_KEY;
const EMAIL_FROM = process.env.EMAIL_FROM || "Tia <noreply@tia.manohargupta.com>"; const EMAIL_FROM = process.env.EMAIL_FROM || "Tia <noreply@tia.manohargupta.com>";
const APP_URL = process.env.NEXT_PUBLIC_APP_URL || "https://tia.manohargupta.com"; const APP_URL = process.env.NEXT_PUBLIC_APP_URL || "https://tia.manohargupta.com";
/** Sends a family invite email, or logs the link in dev. */
export async function sendFamilyInviteEmail(opts: {
to: string;
inviterName: string;
familyName: string;
token: string;
role: string;
}) {
const { to, inviterName, familyName, token, role } = opts;
const link = `${APP_URL}/invite/${token}`;
const roleLabel = role === "admin" ? "an admin" : "a caregiver";
if (!RESEND_API_KEY) {
console.log(`[INVITE-LINK] to=${to} family=${familyName} link=${link}`);
return;
}
try {
const resend = new Resend(RESEND_API_KEY);
await resend.emails.send({
from: EMAIL_FROM,
to,
subject: `${inviterName} invited you to join their family on Tia 🍼`,
html: `
<div style="font-family:system-ui,sans-serif;max-width:500px;margin:0 auto;">
<h2 style="color:#1f2937;">You're invited! 🎉</h2>
<p style="color:#374151;font-size:16px;">
<strong>${inviterName}</strong> has invited you to join <strong>${familyName}</strong> on Tia as ${roleLabel}.
</p>
<p style="color:#374151;font-size:15px;">
Tia helps families track feeds, sleep, growth, memories, and more all in one place.
</p>
<a href="${link}"
style="display:inline-block;background:#fb7185;color:#fff;padding:14px 28px;text-decoration:none;border-radius:8px;margin:20px 0;font-size:16px;font-weight:600;">
Accept Invitation
</a>
<p style="color:#9ca3af;font-size:13px;">This link expires in 7 days. If you didn't expect this, you can safely ignore it.</p>
</div>`,
});
console.log(`[INVITE-EMAIL-SENT] to=${to} family=${familyName}`);
} catch (e) {
console.error("[INVITE-EMAIL-FAILED]", e);
}
}
/** Sends an account-verification email, or logs the link in dev. */ /** Sends an account-verification email, or logs the link in dev. */
export async function sendVerificationEmail(email: string, token: string, userId: string) { export async function sendVerificationEmail(email: string, token: string, userId: string) {
const link = `${APP_URL}/api/auth/verify-email?token=${token}`; const link = `${APP_URL}/api/auth/verify-email?token=${token}`;