feat(quota): enforce 1-baby limit on free plan

- quota.ts: checkChildLimit() mirrors checkMemberLimit() using families.max_children
- POST /api/children: returns 403 child_limit_reached when free family is at limit
- ChildLimitBanner: new banner component for the family page
- /family page: shows banner + hides Add Baby button when at limit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-28 00:52:23 +05:30
parent 90c8d13814
commit 0a9def36bf
4 changed files with 112 additions and 2 deletions

View file

@ -3,6 +3,7 @@
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useFamily } from "@/app/FamilyProvider";
import { ChildLimitBanner } from "@/components/StorageMeter";
interface Child {
id: string;
@ -24,6 +25,7 @@ export default function FamilyPage() {
const [newName, setNewName] = useState("");
const [newDob, setNewDob] = useState("");
const [newSex, setNewSex] = useState("male");
const [childLimit, setChildLimit] = useState<{ currentCount: number; limit: number } | null>(null);
// Load children from FamilyProvider or fetch
useEffect(() => {
@ -35,6 +37,18 @@ export default function FamilyPage() {
}
}, [childrenFromProvider, familyId]);
// Sync limit state whenever children list changes
useEffect(() => {
if (!familyId) return;
fetch("/api/family")
.then(r => r.json())
.then(d => {
const maxChildren = d.family?.max_children ?? 1;
setChildLimit({ currentCount: children.length, limit: maxChildren });
})
.catch(() => {});
}, [familyId, children.length]);
const fetchOwnChildren = async () => {
if (!familyId) return;
try {
@ -85,12 +99,19 @@ export default function FamilyPage() {
setShowAdd(false);
setNewName("");
setNewDob("");
} else if (data.reason === "child_limit_reached") {
setShowAdd(false);
setChildLimit({ currentCount: data.currentCount, limit: data.limit });
} else {
alert(data.error);
}
} catch (err) {
console.error("Failed to add:", err);
}
};
const atChildLimit = childLimit !== null && childLimit.currentCount >= childLimit.limit;
return (
<div className="min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
<div className="p-4 flex items-center gap-4">
@ -99,8 +120,13 @@ export default function FamilyPage() {
</div>
<div className="px-4 space-y-4">
{/* Child limit banner */}
{atChildLimit && childLimit && (
<ChildLimitBanner currentCount={childLimit.currentCount} limit={childLimit.limit} />
)}
{/* Add Child Form */}
{showAdd && (
{showAdd && !atChildLimit && (
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
<input
type="text"
@ -200,7 +226,7 @@ export default function FamilyPage() {
)}
{/* Add Button */}
{!showAdd && children.length > 0 && (
{!showAdd && children.length > 0 && !atChildLimit && (
<button
onClick={() => setShowAdd(true)}
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-xl text-gray-500"

View file

@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { sql } from "@/db";
import { validateSession, requireFamily } from "@/lib/auth";
import { checkChildLimit } from "@/lib/quota";
// GET - list children (family only)
export async function GET(request: Request) {
@ -39,6 +40,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
}
const limitCheck = await checkChildLimit(familyId);
if (!limitCheck.allowed) {
return NextResponse.json(
{ error: limitCheck.message, reason: limitCheck.reason, currentCount: limitCheck.currentCount, limit: limitCheck.limit },
{ status: 403 }
);
}
const [child] = await sql.unsafe(
`INSERT INTO children (family_id, name, birth_date, sex, stage) VALUES ($1, $2, $3, $4, 'newborn') RETURNING id, name, birth_date as "birthDate", sex, stage`,
[familyId, name, birthDate, sex]

View file

@ -128,6 +128,32 @@ export function StorageQuotaBanner({
);
}
/**
* Banner for the child limit (shown when adding a second baby is blocked).
*/
export function ChildLimitBanner({
currentCount,
limit,
}: {
currentCount: number;
limit: number;
}) {
return (
<div className="rounded-xl border border-amber-200 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-800 p-4 space-y-1">
<p className="text-sm font-semibold text-amber-700 dark:text-amber-300">Baby limit reached</p>
<p className="text-sm text-amber-600 dark:text-amber-400">
Your free plan supports {limit} baby profile. You already have {currentCount}. Upgrade to track multiple children.
</p>
<a
href="/settings#upgrade"
className="inline-block mt-2 text-sm font-medium text-rose-600 dark:text-rose-400 underline"
>
Upgrade to add more
</a>
</div>
);
}
/**
* Banner for the member limit (shown when invite is blocked).
*/

View file

@ -208,6 +208,55 @@ export async function reconcileActualSize(
return { keep: true };
}
// ─── Child limit ─────────────────────────────────────────────────────────────
export type ChildLimitResult =
| { allowed: true; currentCount: number; limit: number }
| {
allowed: false;
reason: "child_limit_reached";
currentCount: number;
limit: number;
message: string;
};
/**
* Child limit gate called at child creation.
* Free families are limited to max_children (default 1).
* Paid families: always allowed.
*/
export async function checkChildLimit(familyId: string): Promise<ChildLimitResult> {
const [row] = await sql<{ count: string; max_children: number | null; tier: string | null }[]>`
SELECT
COUNT(c.id)::text AS count,
f.max_children,
f.tier
FROM families f
LEFT JOIN children c ON c.family_id = f.id
WHERE f.id = ${familyId}
GROUP BY f.id
`;
if (!row) return { allowed: false, reason: "child_limit_reached", currentCount: 0, limit: 1, message: "Family not found." };
const currentCount = Number(row.count);
const limit = row.max_children ?? 1;
if (isPaidFamily(row.tier)) return { allowed: true, currentCount, limit };
if (currentCount >= limit) {
return {
allowed: false,
reason: "child_limit_reached",
currentCount,
limit,
message: `Your free plan supports ${limit} baby profile. Upgrade to track multiple children.`,
};
}
return { allowed: true, currentCount, limit };
}
/**
* Member limit gate called at invite creation (not acceptance).
* COUNT(family_members) is the source of truth; no separate counter.