enforce membership feature access in api and surface plans in admin
This commit is contained in:
1 parent
1f4800c055
commit
ebfd633a11
14 files changed
+573
-5
No files matched your search
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import log from "@/lib/logger";
|
||||
import { getUserMembershipContext } from "@/lib/membership/access";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -11,6 +12,18 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const db = await getDatabase();
|
||||
await ensureUserSynced(userId, db);
|
||||
const { features, membershipType } = await getUserMembershipContext(userId);
|
||||
|
||||
if (!features.hydrationTracking) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Hydration tracking is available on Premium and VIP memberships",
|
||||
membershipType,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { date, entries, totalWater, waterGoal } = body;
|
||||
@@ -58,6 +71,18 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const db = await getDatabase();
|
||||
await ensureUserSynced(userId, db);
|
||||
const { features, membershipType } = await getUserMembershipContext(userId);
|
||||
|
||||
if (!features.hydrationTracking) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Hydration tracking is available on Premium and VIP memberships",
|
||||
membershipType,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const date = url.searchParams.get("date");
|
||||
@@ -100,6 +125,18 @@ export async function DELETE(req: NextRequest) {
|
||||
|
||||
const db = await getDatabase();
|
||||
await ensureUserSynced(userId, db);
|
||||
const { features, membershipType } = await getUserMembershipContext(userId);
|
||||
|
||||
if (!features.hydrationTracking) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Hydration tracking is available on Premium and VIP memberships",
|
||||
membershipType,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const id = url.searchParams.get("id");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import log from "@/lib/logger";
|
||||
import { getUserMembershipContext } from "@/lib/membership/access";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -11,6 +12,18 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const db = await getDatabase();
|
||||
await ensureUserSynced(userId, db);
|
||||
const { features, membershipType } = await getUserMembershipContext(userId);
|
||||
|
||||
if (!features.nutritionTracking) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Nutrition tracking is available on Premium and VIP memberships",
|
||||
membershipType,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const {
|
||||
@@ -59,6 +72,18 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const db = await getDatabase();
|
||||
await ensureUserSynced(userId, db);
|
||||
const { features, membershipType } = await getUserMembershipContext(userId);
|
||||
|
||||
if (!features.nutritionTracking) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Nutrition tracking is available on Premium and VIP memberships",
|
||||
membershipType,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const date = url.searchParams.get("date");
|
||||
@@ -88,6 +113,18 @@ export async function DELETE(req: NextRequest) {
|
||||
|
||||
const db = await getDatabase();
|
||||
await ensureUserSynced(userId, db);
|
||||
const { features, membershipType } = await getUserMembershipContext(userId);
|
||||
|
||||
if (!features.nutritionTracking) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Nutrition tracking is available on Premium and VIP memberships",
|
||||
membershipType,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const id = url.searchParams.get("id");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import log from "@/lib/logger";
|
||||
import { getUserMembershipContext } from "@/lib/membership/access";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -11,6 +12,18 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const db = await getDatabase();
|
||||
await ensureUserSynced(userId, db);
|
||||
const { features, membershipType } = await getUserMembershipContext(userId);
|
||||
|
||||
if (!features.nutritionTracking) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Nutrition tracking is available on Premium and VIP memberships",
|
||||
membershipType,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const { date, meals, totalCalories, calorieGoal } = body;
|
||||
@@ -58,6 +71,18 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const db = await getDatabase();
|
||||
await ensureUserSynced(userId, db);
|
||||
const { features, membershipType } = await getUserMembershipContext(userId);
|
||||
|
||||
if (!features.nutritionTracking) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Nutrition tracking is available on Premium and VIP memberships",
|
||||
membershipType,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const date = url.searchParams.get("date");
|
||||
@@ -100,6 +125,18 @@ export async function DELETE(req: NextRequest) {
|
||||
|
||||
const db = await getDatabase();
|
||||
await ensureUserSynced(userId, db);
|
||||
const { features, membershipType } = await getUserMembershipContext(userId);
|
||||
|
||||
if (!features.nutritionTracking) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Nutrition tracking is available on Premium and VIP memberships",
|
||||
membershipType,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const id = url.searchParams.get("id");
|
||||
|
||||
@@ -5,6 +5,7 @@ import { buildAIContext } from "@/lib/ai/ai-context";
|
||||
import { buildEnhancedPrompt, buildBasicPrompt } from "@/lib/ai/prompt-builder";
|
||||
import log from "@/lib/logger";
|
||||
import { ensureUserSynced } from "@/lib/sync-user";
|
||||
import { getUserMembershipContext } from "@/lib/membership/access";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
@@ -49,6 +50,41 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { membershipType, features } = await getUserMembershipContext(userId);
|
||||
|
||||
if (features.recommendationsPerMonth === 1) {
|
||||
const currentMonth = new Date();
|
||||
const monthStart = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth(),
|
||||
1,
|
||||
);
|
||||
const monthEnd = new Date(
|
||||
currentMonth.getFullYear(),
|
||||
currentMonth.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
const existingRecommendations =
|
||||
await db.getRecommendationsByUserId(userId);
|
||||
const recommendationsThisMonth = existingRecommendations.filter(
|
||||
(recommendation) =>
|
||||
recommendation.generatedAt >= monthStart &&
|
||||
recommendation.generatedAt < monthEnd,
|
||||
).length;
|
||||
|
||||
if (recommendationsThisMonth >= 1) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Basic membership includes 1 recommendation per month. Upgrade to Premium or VIP for unlimited recommendations.",
|
||||
membershipType,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentUser.role !== "superAdmin") {
|
||||
if (!currentUser.gymId || targetUser.gymId !== currentUser.gymId) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import log from "@/lib/logger";
|
||||
import { MEMBERSHIP_FEATURES } from "@/lib/membership/features";
|
||||
|
||||
interface Backup {
|
||||
name: string;
|
||||
@@ -558,6 +559,109 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Membership Feature Access */}
|
||||
<div className="mt-6">
|
||||
<h5 className="text-sm font-medium text-slate-700 mb-2">
|
||||
Membership Feature Access
|
||||
</h5>
|
||||
<div className="overflow-x-auto border rounded-lg">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="bg-slate-50 border-b">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-semibold text-slate-900">
|
||||
Feature
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-slate-900">
|
||||
Basic
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-slate-900">
|
||||
Premium
|
||||
</th>
|
||||
<th className="px-4 py-3 font-semibold text-slate-900">
|
||||
VIP
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
Recommendations per month
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{MEMBERSHIP_FEATURES.basic.recommendationsPerMonth}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
Unlimited
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
Unlimited
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
Nutrition tracking
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{MEMBERSHIP_FEATURES.basic.nutritionTracking
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{MEMBERSHIP_FEATURES.premium.nutritionTracking
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{MEMBERSHIP_FEATURES.vip.nutritionTracking
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
Hydration tracking
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{MEMBERSHIP_FEATURES.basic.hydrationTracking
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{MEMBERSHIP_FEATURES.premium.hydrationTracking
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{MEMBERSHIP_FEATURES.vip.hydrationTracking
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
Advanced statistics
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{MEMBERSHIP_FEATURES.basic.advancedStatistics
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{MEMBERSHIP_FEATURES.premium.advancedStatistics
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
{MEMBERSHIP_FEATURES.vip.advancedStatistics
|
||||
? "Yes"
|
||||
: "No"}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 bg-slate-50 rounded-lg">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getDatabase } from "@/lib/database";
|
||||
import { getMembershipFeatures } from "./features";
|
||||
|
||||
export async function getUserMembershipContext(userId: string): Promise<{
|
||||
membershipType: "basic" | "premium" | "vip";
|
||||
features: ReturnType<typeof getMembershipFeatures>;
|
||||
}> {
|
||||
const db = await getDatabase();
|
||||
const user = await db.getUserById(userId);
|
||||
|
||||
if (!user || user.role !== "client") {
|
||||
const membershipType = "vip" as const;
|
||||
return {
|
||||
membershipType,
|
||||
features: getMembershipFeatures(membershipType),
|
||||
};
|
||||
}
|
||||
|
||||
const client = await db.getClientByUserId(userId);
|
||||
const membershipType = client?.membershipType ?? "basic";
|
||||
|
||||
return {
|
||||
membershipType,
|
||||
features: getMembershipFeatures(membershipType),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { MembershipType } from "@/lib/validation/schemas";
|
||||
|
||||
export interface MembershipFeatures {
|
||||
recommendationsPerMonth: number;
|
||||
hydrationTracking: boolean;
|
||||
nutritionTracking: boolean;
|
||||
advancedStatistics: boolean;
|
||||
}
|
||||
|
||||
export const MEMBERSHIP_FEATURES: Record<MembershipType, MembershipFeatures> = {
|
||||
basic: {
|
||||
recommendationsPerMonth: 1,
|
||||
hydrationTracking: false,
|
||||
nutritionTracking: false,
|
||||
advancedStatistics: false,
|
||||
},
|
||||
premium: {
|
||||
recommendationsPerMonth: -1,
|
||||
hydrationTracking: true,
|
||||
nutritionTracking: true,
|
||||
advancedStatistics: true,
|
||||
},
|
||||
vip: {
|
||||
recommendationsPerMonth: -1,
|
||||
hydrationTracking: true,
|
||||
nutritionTracking: true,
|
||||
advancedStatistics: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function getMembershipFeatures(
|
||||
membershipType: MembershipType,
|
||||
): MembershipFeatures {
|
||||
return MEMBERSHIP_FEATURES[membershipType];
|
||||
}
|
||||
Reference in new issue
Block a user