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:
parent
90c8d13814
commit
0a9def36bf
4 changed files with 112 additions and 2 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useFamily } from "@/app/FamilyProvider";
|
import { useFamily } from "@/app/FamilyProvider";
|
||||||
|
import { ChildLimitBanner } from "@/components/StorageMeter";
|
||||||
|
|
||||||
interface Child {
|
interface Child {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -24,6 +25,7 @@ export default function FamilyPage() {
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [newDob, setNewDob] = useState("");
|
const [newDob, setNewDob] = useState("");
|
||||||
const [newSex, setNewSex] = useState("male");
|
const [newSex, setNewSex] = useState("male");
|
||||||
|
const [childLimit, setChildLimit] = useState<{ currentCount: number; limit: number } | null>(null);
|
||||||
|
|
||||||
// Load children from FamilyProvider or fetch
|
// Load children from FamilyProvider or fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -35,6 +37,18 @@ export default function FamilyPage() {
|
||||||
}
|
}
|
||||||
}, [childrenFromProvider, familyId]);
|
}, [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 () => {
|
const fetchOwnChildren = async () => {
|
||||||
if (!familyId) return;
|
if (!familyId) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -85,12 +99,19 @@ export default function FamilyPage() {
|
||||||
setShowAdd(false);
|
setShowAdd(false);
|
||||||
setNewName("");
|
setNewName("");
|
||||||
setNewDob("");
|
setNewDob("");
|
||||||
|
} else if (data.reason === "child_limit_reached") {
|
||||||
|
setShowAdd(false);
|
||||||
|
setChildLimit({ currentCount: data.currentCount, limit: data.limit });
|
||||||
|
} else {
|
||||||
|
alert(data.error);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to add:", err);
|
console.error("Failed to add:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const atChildLimit = childLimit !== null && childLimit.currentCount >= childLimit.limit;
|
||||||
|
|
||||||
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">
|
<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">
|
<div className="p-4 flex items-center gap-4">
|
||||||
|
|
@ -99,8 +120,13 @@ export default function FamilyPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 space-y-4">
|
<div className="px-4 space-y-4">
|
||||||
|
{/* Child limit banner */}
|
||||||
|
{atChildLimit && childLimit && (
|
||||||
|
<ChildLimitBanner currentCount={childLimit.currentCount} limit={childLimit.limit} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Add Child Form */}
|
{/* Add Child Form */}
|
||||||
{showAdd && (
|
{showAdd && !atChildLimit && (
|
||||||
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
|
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl space-y-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -200,7 +226,7 @@ export default function FamilyPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Button */}
|
{/* Add Button */}
|
||||||
{!showAdd && children.length > 0 && (
|
{!showAdd && children.length > 0 && !atChildLimit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAdd(true)}
|
onClick={() => setShowAdd(true)}
|
||||||
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-xl text-gray-500"
|
className="w-full p-4 border-2 border-dashed border-gray-300 rounded-xl text-gray-500"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { sql } from "@/db";
|
import { sql } from "@/db";
|
||||||
import { validateSession, requireFamily } from "@/lib/auth";
|
import { validateSession, requireFamily } from "@/lib/auth";
|
||||||
|
import { checkChildLimit } from "@/lib/quota";
|
||||||
|
|
||||||
// GET - list children (family only)
|
// GET - list children (family only)
|
||||||
export async function GET(request: Request) {
|
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 });
|
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(
|
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`,
|
`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]
|
[familyId, name, birthDate, sex]
|
||||||
|
|
|
||||||
|
|
@ -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).
|
* Banner for the member limit (shown when invite is blocked).
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -208,6 +208,55 @@ export async function reconcileActualSize(
|
||||||
return { keep: true };
|
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).
|
* Member limit gate — called at invite creation (not acceptance).
|
||||||
* COUNT(family_members) is the source of truth; no separate counter.
|
* COUNT(family_members) is the source of truth; no separate counter.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue