feat(auth): include subscription tier in token and session payload

- Adds subscription tier info to access/refresh tokens.
- Stores tier details in session Redis record.
- Returns tier data in login/register response.
- Uses default tier when user has none.
This commit is contained in:
Evandro Fonseca Junior committed 2025-11-07 21:03:19 -03:00
1 parent 7f42def478
commit d27526e026
20 files changed
+2133 -40

No files matched your search

+64
View File
@@ -0,0 +1,64 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
import Header from "@/components/Header";
import { getSession } from "@/lib/redis/session";
import { getAllSubscriptionTiers } from "@/lib/prisma/queries/subscription-tiers";
import { AdminSubscriptionDashboard } from "@/components/admin/subscription-dashboard";
import { getUserById } from "@/lib/prisma/queries/users";
export async function generateMetadata(): Promise<Metadata> {
return {
title: "Assinaturas e Permissões",
description:
"Gerencie os planos de assinatura, recursos avançados e permissões dos usuários.",
};
}
export default async function AdminSubscriptionsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const cookieStore = await cookies();
const sessionId = cookieStore.get("sessionId")?.value;
if (!sessionId) {
redirect(`/${locale}`);
}
const session = await getSession(sessionId);
if (!session || session.userId !== 1) {
redirect(`/${locale}`);
}
const tiers = await getAllSubscriptionTiers(true);
const adminUser = await getUserById(session.userId);
return (
<div className="min-h-screen bg-background text-foreground">
<Header />
<main className="container mx-auto px-4 py-10 space-y-8">
<section className="space-y-2">
<h1 className="text-3xl font-bold">Assinaturas & Permissões</h1>
<p className="text-muted-foreground">
Configure planos, recursos e vincule usuários às assinaturas disponíveis.
</p>
{adminUser?.subscription_tier && (
<p className="text-sm text-muted-foreground">
Seu plano atual:{" "}
<span className="font-semibold uppercase">
{adminUser.subscription_tier.name} ({adminUser.subscription_tier.slug})
</span>
</p>
)}
</section>
<AdminSubscriptionDashboard initialTiers={tiers} />
</main>
</div>
);
}
+30 -2
View File
@@ -18,6 +18,10 @@ import {
} from "@/lib/prisma/queries/users"; } from "@/lib/prisma/queries/users";
import { setSession } from "@/lib/redis/session"; import { setSession } from "@/lib/redis/session";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import {
getDefaultSubscriptionTier,
getSubscriptionTierById,
} from "@/lib/prisma/queries/subscription-tiers";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -125,8 +129,20 @@ export async function POST(request: NextRequest) {
await updateLastLogin(user.user_id); await updateLastLogin(user.user_id);
// Gerar tokens // Gerar tokens
const accessToken = generateAccessToken(user.user_id, user.username); const tierInfo =
const refreshToken = generateRefreshToken(user.user_id, user.username); user.subscription_tier ??
(user.subscription_tier_id
? await getSubscriptionTierById(user.subscription_tier_id)
: await getDefaultSubscriptionTier());
const accessToken = generateAccessToken(user.user_id, user.username, {
tierSlug: tierInfo?.slug ?? undefined,
tierLevel: tierInfo?.level ?? undefined,
});
const refreshToken = generateRefreshToken(user.user_id, user.username, {
tierSlug: tierInfo?.slug ?? undefined,
tierLevel: tierInfo?.level ?? undefined,
});
// Criar sessão no Redis // Criar sessão no Redis
const sessionId = randomUUID(); const sessionId = randomUUID();
@@ -136,6 +152,10 @@ export async function POST(request: NextRequest) {
email: user.email, email: user.email,
accessToken, accessToken,
createdAt: Date.now(), createdAt: Date.now(),
subscriptionTierId: tierInfo?.subscription_tier_id ?? null,
subscriptionTierSlug: tierInfo?.slug ?? null,
subscriptionTierName: tierInfo?.name ?? null,
subscriptionTierLevel: tierInfo?.level ?? null,
}); });
// Criar resposta // Criar resposta
@@ -150,6 +170,14 @@ export async function POST(request: NextRequest) {
lastName: user.last_name, lastName: user.last_name,
accountLevel: user.account_level, accountLevel: user.account_level,
hasDonated: user.has_donated, hasDonated: user.has_donated,
subscriptionTier: tierInfo
? {
id: tierInfo.subscription_tier_id,
name: tierInfo.name,
slug: tierInfo.slug,
level: tierInfo.level,
}
: null,
}, },
accessToken, accessToken,
}, },
+24 -2
View File
@@ -23,6 +23,7 @@ import { setSession } from "@/lib/redis/session";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { createUser } from "@/lib/prisma/queries/users"; import { createUser } from "@/lib/prisma/queries/users";
import { createCharacter } from "@/lib/prisma/queries/characters"; import { createCharacter } from "@/lib/prisma/queries/characters";
import { getDefaultSubscriptionTier } from "@/lib/prisma/queries/subscription-tiers";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
@@ -186,10 +187,19 @@ export async function POST(request: NextRequest) {
}); });
// Gerar tokens // Gerar tokens
const accessToken = generateAccessToken(newUser.userId, newUser.username); const defaultTier = await getDefaultSubscriptionTier();
const accessToken = generateAccessToken(newUser.userId, newUser.username, {
tierSlug: defaultTier?.slug ?? undefined,
tierLevel: defaultTier?.level ?? undefined,
});
const refreshToken = generateRefreshToken( const refreshToken = generateRefreshToken(
newUser.userId, newUser.userId,
newUser.username newUser.username,
{
tierSlug: defaultTier?.slug ?? undefined,
tierLevel: defaultTier?.level ?? undefined,
}
); );
// Criar sessão no Redis // Criar sessão no Redis
@@ -200,6 +210,10 @@ export async function POST(request: NextRequest) {
email: newUser.email, email: newUser.email,
accessToken, accessToken,
createdAt: Date.now(), createdAt: Date.now(),
subscriptionTierId: defaultTier?.subscription_tier_id ?? null,
subscriptionTierSlug: defaultTier?.slug ?? null,
subscriptionTierName: defaultTier?.name ?? null,
subscriptionTierLevel: defaultTier?.level ?? null,
}); });
// Criar resposta // Criar resposta
@@ -210,6 +224,14 @@ export async function POST(request: NextRequest) {
userId: newUser.userId, userId: newUser.userId,
username: newUser.username, username: newUser.username,
email: newUser.email, email: newUser.email,
subscriptionTier: defaultTier
? {
id: defaultTier.subscription_tier_id,
name: defaultTier.name,
slug: defaultTier.slug,
level: defaultTier.level,
}
: null,
}, },
accessToken, accessToken,
}, },
+61 -2
View File
@@ -1,9 +1,11 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { secureLogger } from "@/lib/secure-logger"; import { secureLogger } from "@/lib/secure-logger";
import { getSession, refreshSession, deleteSession } from "@/lib/redis/session"; import { getSession, refreshSession, deleteSession, setSession } from "@/lib/redis/session";
import { verifyAccessToken } from "@/lib/auth/jwt"; import { verifyAccessToken } from "@/lib/auth/jwt";
import { getMainCharacter } from "@/lib/prisma/queries/characters"; import { getMainCharacter } from "@/lib/prisma/queries/characters";
import { getUserById } from "@/lib/prisma/queries/users";
import { getSubscriptionTierById, getDefaultSubscriptionTier } from "@/lib/prisma/queries/subscription-tiers";
/** /**
* GET /api/auth/session * GET /api/auth/session
@@ -27,7 +29,7 @@ export async function GET(request: NextRequest) {
} }
// Buscar sessão no Redis // Buscar sessão no Redis
const session = await getSession(sessionId); let session = await getSession(sessionId);
if (!session) { if (!session) {
// Limpar cookie se sessão não existe // Limpar cookie se sessão não existe
@@ -63,6 +65,55 @@ export async function GET(request: NextRequest) {
return response; return response;
} }
let tierInfo:
| {
subscription_tier_id: number | null;
name: string | null;
slug: string | null;
level: number | null;
}
| null = {
subscription_tier_id: session.subscriptionTierId,
name: session.subscriptionTierName,
slug: session.subscriptionTierSlug,
level: session.subscriptionTierLevel,
};
if (
tierInfo.subscription_tier_id === undefined ||
tierInfo.subscription_tier_id === null ||
tierInfo.slug === null
) {
const dbUser = await getUserById(session.userId);
const fetchedTier = dbUser?.subscription_tier
? await getSubscriptionTierById(dbUser.subscription_tier.subscription_tier_id)
: await getDefaultSubscriptionTier();
tierInfo = fetchedTier
? {
subscription_tier_id: fetchedTier.subscription_tier_id,
name: fetchedTier.name,
slug: fetchedTier.slug,
level: fetchedTier.level,
}
: {
subscription_tier_id: null,
name: null,
slug: null,
level: null,
};
session = {
...session,
subscriptionTierId: tierInfo.subscription_tier_id,
subscriptionTierName: tierInfo.name,
subscriptionTierSlug: tierInfo.slug,
subscriptionTierLevel: tierInfo.level,
};
await setSession(sessionId, session);
}
// Atualizar TTL da sessão // Atualizar TTL da sessão
await refreshSession(sessionId); await refreshSession(sessionId);
@@ -78,6 +129,14 @@ export async function GET(request: NextRequest) {
username: session.username, username: session.username,
email: session.email, email: session.email,
characterName: mainCharacter?.character_name || null, characterName: mainCharacter?.character_name || null,
subscriptionTier: tierInfo
? {
id: tierInfo.subscription_tier_id,
name: tierInfo.name,
slug: tierInfo.slug,
level: tierInfo.level,
}
: null,
}, },
}, },
{ status: 200 } { status: 200 }
+210
View File
@@ -0,0 +1,210 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import {
getSubscriptionTierById,
updateSubscriptionTier,
deleteSubscriptionTier,
countUsersInSubscriptionTier,
} from "@/lib/prisma/queries/subscription-tiers";
import { updateSubscriptionTierSchema } from "@/lib/validation-schemas";
import { secureLogger } from "@/lib/secure-logger";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) {
return auth;
}
try {
const { id } = await params;
const tierId = Number.parseInt(id, 10);
if (Number.isNaN(tierId) || tierId <= 0) {
return NextResponse.json(
{ success: false, error: "ID de tier inválido" },
{ status: 400 }
);
}
const tier = await getSubscriptionTierById(tierId, true);
if (!tier) {
return NextResponse.json(
{ success: false, error: "Plano não encontrado" },
{ status: 404 }
);
}
return NextResponse.json(
{
success: true,
tier,
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao consultar subscription tier", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{
success: false,
error: "Erro ao consultar plano de assinatura",
},
{ status: 500 }
);
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) {
return auth;
}
try {
const { id } = await params;
const tierId = Number.parseInt(id, 10);
if (Number.isNaN(tierId) || tierId <= 0) {
return NextResponse.json(
{ success: false, error: "ID de tier inválido" },
{ status: 400 }
);
}
const body = await request.json();
const parsed = updateSubscriptionTierSchema.parse(body);
const updatedTier = await updateSubscriptionTier(tierId, {
name: parsed.name,
slug: parsed.slug,
description: parsed.description ?? undefined,
level: parsed.level,
price_monthly: parsed.priceMonthly ?? undefined,
price_yearly: parsed.priceYearly ?? undefined,
is_active: parsed.isActive,
features: parsed.features?.map((feature, index) => ({
feature_key: feature.featureKey,
label: feature.label,
description: feature.description ?? null,
sort_order: feature.sortOrder ?? index,
_delete: feature._delete ?? false,
})),
});
return NextResponse.json(
{
success: true,
tier: updatedTier,
},
{ status: 200 }
);
} catch (error) {
if (error instanceof Error && "issues" in error) {
return NextResponse.json(
{
success: false,
error: "Dados inválidos",
details: (error as any).issues,
},
{ status: 400 }
);
}
secureLogger.error("❌ Erro ao atualizar subscription tier", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{
success: false,
error: "Erro ao atualizar plano de assinatura",
},
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) {
return auth;
}
try {
const { id } = await params;
const tierId = Number.parseInt(id, 10);
if (Number.isNaN(tierId) || tierId <= 0) {
return NextResponse.json(
{ success: false, error: "ID de tier inválido" },
{ status: 400 }
);
}
const tier = await getSubscriptionTierById(tierId, false);
if (!tier) {
return NextResponse.json(
{ success: false, error: "Plano não encontrado" },
{ status: 404 }
);
}
if (tier.slug === "free") {
return NextResponse.json(
{
success: false,
error: "O plano padrão 'free' não pode ser removido.",
},
{ status: 400 }
);
}
const userCount = await countUsersInSubscriptionTier(tierId);
if (userCount > 0) {
return NextResponse.json(
{
success: false,
error: "Plano não pode ser removido enquanto houver usuários associados.",
users: userCount,
},
{ status: 409 }
);
}
await deleteSubscriptionTier(tierId);
return NextResponse.json(
{
success: true,
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao remover subscription tier", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{
success: false,
error: "Erro ao remover plano de assinatura",
},
{ status: 500 }
);
}
}
+136
View File
@@ -0,0 +1,136 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import {
assignUserToSubscriptionTier,
getSubscriptionTierById,
getSubscriptionTierBySlug,
getDefaultSubscriptionTier,
} from "@/lib/prisma/queries/subscription-tiers";
import { assignSubscriptionTierSchema } from "@/lib/validation-schemas";
import { secureLogger } from "@/lib/secure-logger";
import { getUserById } from "@/lib/prisma/queries/users";
export async function POST(request: NextRequest) {
const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) {
return auth;
}
try {
const body = await request.json();
const parsed = assignSubscriptionTierSchema.parse(body);
const user = await getUserById(parsed.userId);
if (!user) {
return NextResponse.json(
{
success: false,
error: "Usuário não encontrado.",
},
{ status: 404 }
);
}
let targetTierId: number | null = null;
let targetTier:
| {
subscription_tier_id: number;
name: string;
slug: string;
level: number;
}
| null = null;
if (parsed.tierId !== undefined) {
if (parsed.tierId === null) {
const defaultTier = await getDefaultSubscriptionTier();
if (!defaultTier) {
return NextResponse.json(
{
success: false,
error: "Plano padrão não encontrado.",
},
{ status: 500 }
);
}
targetTier = defaultTier;
} else {
const tierById = await getSubscriptionTierById(parsed.tierId, false);
if (!tierById) {
return NextResponse.json(
{
success: false,
error: "Plano informado não existe.",
},
{ status: 404 }
);
}
targetTier = tierById;
}
} else if (parsed.tierSlug) {
const tierBySlug = await getSubscriptionTierBySlug(parsed.tierSlug, false);
if (!tierBySlug) {
return NextResponse.json(
{
success: false,
error: "Plano informado não existe.",
},
{ status: 404 }
);
}
targetTier = tierBySlug;
}
if (!targetTier) {
return NextResponse.json(
{
success: false,
error: "Plano de assinatura não identificado.",
},
{ status: 400 }
);
}
targetTierId = targetTier.subscription_tier_id;
await assignUserToSubscriptionTier(parsed.userId, targetTierId);
return NextResponse.json(
{
success: true,
userId: parsed.userId,
tier: {
id: targetTier.subscription_tier_id,
name: targetTier.name,
slug: targetTier.slug,
level: targetTier.level,
},
},
{ status: 200 }
);
} catch (error) {
if (error instanceof Error && "issues" in error) {
return NextResponse.json(
{
success: false,
error: "Dados inválidos",
details: (error as any).issues,
},
{ status: 400 }
);
}
secureLogger.error("❌ Erro ao atribuir subscription tier", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{
success: false,
error: "Erro ao atribuir plano de assinatura ao usuário",
},
{ status: 500 }
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdminUser } from "@/lib/auth/middleware";
import {
getAllSubscriptionTiers,
createSubscriptionTier,
} from "@/lib/prisma/queries/subscription-tiers";
import { createSubscriptionTierSchema } from "@/lib/validation-schemas";
import { secureLogger } from "@/lib/secure-logger";
export async function GET(request: NextRequest) {
const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) {
return auth;
}
try {
const tiers = await getAllSubscriptionTiers(true);
return NextResponse.json(
{
success: true,
tiers,
},
{ status: 200 }
);
} catch (error) {
secureLogger.error("❌ Erro ao listar subscription tiers", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{
success: false,
error: "Erro ao listar planos de assinatura",
},
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
const auth = await requireAdminUser(request);
if (auth instanceof NextResponse) {
return auth;
}
try {
const body = await request.json();
const parsed = createSubscriptionTierSchema.parse(body);
const tier = await createSubscriptionTier({
name: parsed.name,
slug: parsed.slug,
description: parsed.description ?? null,
level: parsed.level ?? 0,
price_monthly: parsed.priceMonthly ?? null,
price_yearly: parsed.priceYearly ?? null,
is_active: parsed.isActive ?? true,
features: parsed.features?.map((feature, index) => ({
feature_key: feature.featureKey,
label: feature.label,
description: feature.description ?? null,
sort_order: feature.sortOrder ?? index,
})),
});
return NextResponse.json(
{
success: true,
tier,
},
{ status: 201 }
);
} catch (error) {
if (error instanceof NextResponse) {
throw error;
}
if (error instanceof Error && "issues" in error) {
return NextResponse.json(
{
success: false,
error: "Dados inválidos",
details: (error as any).issues,
},
{ status: 400 }
);
}
secureLogger.error("❌ Erro ao criar subscription tier", {
error: error instanceof Error ? error.message : "Erro desconhecido",
});
return NextResponse.json(
{
success: false,
error: "Erro ao criar plano de assinatura",
},
{ status: 500 }
);
}
}
+15 -2
View File
@@ -31,6 +31,7 @@ import { useSession } from "@/lib/hooks/use-session";
import Link from "next/link"; import Link from "next/link";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { LogOut, User } from "lucide-react"; import { LogOut, User } from "lucide-react";
import { Badge } from "@/components/ui/badge";
export default function Header() { export default function Header() {
const t = useTranslations("header.nav"); const t = useTranslations("header.nav");
@@ -53,6 +54,13 @@ export default function Header() {
{ href: `/${locale}/apoiar`, label: t("support"), highlight: true }, { href: `/${locale}/apoiar`, label: t("support"), highlight: true },
]; ];
const navLinks = [
...links,
...(isAuthenticated && user?.userId === 1
? [{ href: `/${locale}/admin/subscriptions`, label: "Admin" }]
: []),
];
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
}; };
@@ -73,7 +81,7 @@ export default function Header() {
{/* Desktop Navigation */} {/* Desktop Navigation */}
<NavigationMenu className="hidden md:flex"> <NavigationMenu className="hidden md:flex">
<NavigationMenuList> <NavigationMenuList>
{links.map(({ href, label, highlight }) => ( {navLinks.map(({ href, label, highlight }) => (
<NavigationMenuItem key={href}> <NavigationMenuItem key={href}>
<NavigationMenuLink asChild> <NavigationMenuLink asChild>
<Link <Link
@@ -103,6 +111,11 @@ export default function Header() {
{/* Notifications */} {/* Notifications */}
{mounted && isAuthenticated && <NotificationBell />} {mounted && isAuthenticated && <NotificationBell />}
{mounted && isAuthenticated && user?.subscriptionTier && (
<Badge variant="outline" className="uppercase">
{user.subscriptionTier.slug}
</Badge>
)}
{/* Auth Section */} {/* Auth Section */}
{!mounted ? ( {!mounted ? (
@@ -172,7 +185,7 @@ export default function Header() {
{/* Mobile Menu */} {/* Mobile Menu */}
<div className="md:hidden"> <div className="md:hidden">
<MobileMenu links={links} /> <MobileMenu links={navLinks} />
</div> </div>
</div> </div>
+701
View File
@@ -0,0 +1,701 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
type FeedbackState =
| { type: "success"; message: string }
| { type: "error"; message: string }
| null;
interface AdminSubscriptionDashboardProps {
initialTiers: SubscriptionTierSummary[];
}
interface TierFormState {
name: string;
slug: string;
description: string;
level: number;
priceMonthly: string;
priceYearly: string;
isActive: boolean;
features: string;
}
function mapTierToFormState(tier: SubscriptionTierSummary): TierFormState {
return {
name: tier.name,
slug: tier.slug,
description: tier.description ?? "",
level: tier.level,
priceMonthly: tier.price_monthly ? tier.price_monthly.toString() : "",
priceYearly: tier.price_yearly ? tier.price_yearly.toString() : "",
isActive: tier.is_active,
features: (tier.features ?? [])
.map((feature) => `${feature.feature_key}|${feature.label}|${feature.description ?? ""}`)
.join("\n"),
};
}
function parseFeatures(text: string) {
if (!text.trim()) {
return [];
}
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const [featureKey, label, description] = line.split("|").map((part) => part?.trim() ?? "");
return {
feature_key: featureKey,
label: label || featureKey,
description: description || null,
sort_order: index,
};
});
}
export function AdminSubscriptionDashboard({
initialTiers,
}: AdminSubscriptionDashboardProps) {
const [tiers, setTiers] = useState<SubscriptionTierSummary[]>(initialTiers);
const [isLoading, setIsLoading] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState>(null);
const [createForm, setCreateForm] = useState<TierFormState>({
name: "",
slug: "",
description: "",
level: 0,
priceMonthly: "",
priceYearly: "",
isActive: true,
features: "",
});
const [editForms, setEditForms] = useState<Record<number, TierFormState>>(() =>
Object.fromEntries(initialTiers.map((tier) => [tier.subscription_tier_id, mapTierToFormState(tier)]))
);
const [assignUserId, setAssignUserId] = useState("");
const [assignTierId, setAssignTierId] = useState<string>("free");
const resetFeedback = () => setFeedback(null);
const refreshTiers = useCallback(async () => {
const response = await fetch("/api/subscription-tiers", {
method: "GET",
credentials: "include",
cache: "no-store",
});
if (!response.ok) {
throw new Error("Erro ao atualizar lista de planos.");
}
const data = await response.json();
setTiers(data.tiers);
setEditForms(
Object.fromEntries(
data.tiers.map((tier: SubscriptionTierSummary) => [
tier.subscription_tier_id,
mapTierToFormState(tier),
])
)
);
}, []);
const handleCreateTier = async () => {
resetFeedback();
setIsLoading(true);
try {
const payload = {
name: createForm.name,
slug: createForm.slug,
description: createForm.description || null,
level: Number.parseInt(createForm.level.toString(), 10),
priceMonthly: createForm.priceMonthly ? Number.parseFloat(createForm.priceMonthly) : null,
priceYearly: createForm.priceYearly ? Number.parseFloat(createForm.priceYearly) : null,
isActive: createForm.isActive,
features: parseFeatures(createForm.features),
};
const response = await fetch("/api/subscription-tiers", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(payload),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || "Erro ao criar plano.");
}
setCreateForm({
name: "",
slug: "",
description: "",
level: 0,
priceMonthly: "",
priceYearly: "",
isActive: true,
features: "",
});
await refreshTiers();
setFeedback({ type: "success", message: "Plano criado com sucesso." });
} catch (error) {
setFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Não foi possível criar o plano.",
});
} finally {
setIsLoading(false);
}
};
const handleUpdateTier = async (tierId: number) => {
resetFeedback();
setIsLoading(true);
try {
const form = editForms[tierId];
const payload = {
name: form.name,
slug: form.slug,
description: form.description || null,
level: Number.parseInt(form.level.toString(), 10),
priceMonthly: form.priceMonthly ? Number.parseFloat(form.priceMonthly) : null,
priceYearly: form.priceYearly ? Number.parseFloat(form.priceYearly) : null,
isActive: form.isActive,
features: parseFeatures(form.features),
};
const response = await fetch(`/api/subscription-tiers/${tierId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(payload),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || "Erro ao atualizar plano.");
}
await refreshTiers();
setFeedback({ type: "success", message: "Plano atualizado com sucesso." });
} catch (error) {
setFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Não foi possível atualizar o plano.",
});
} finally {
setIsLoading(false);
}
};
const handleDeleteTier = async (tierId: number, slug: string) => {
resetFeedback();
if (!confirm(`Deseja realmente remover o plano "${slug}"?`)) {
return;
}
setIsLoading(true);
try {
const response = await fetch(`/api/subscription-tiers/${tierId}`, {
method: "DELETE",
credentials: "include",
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || "Erro ao remover plano.");
}
await refreshTiers();
setFeedback({ type: "success", message: "Plano removido com sucesso." });
} catch (error) {
setFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Não foi possível remover o plano.",
});
} finally {
setIsLoading(false);
}
};
const handleAssignTier = async () => {
resetFeedback();
if (!assignUserId.trim()) {
setFeedback({ type: "error", message: "Informe um ID de usuário válido." });
return;
}
setIsLoading(true);
try {
const payload = {
userId: Number.parseInt(assignUserId, 10),
tierSlug: assignTierId,
};
const response = await fetch("/api/subscription-tiers/assign", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(payload),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || "Erro ao atualizar plano do usuário.");
}
setAssignUserId("");
setFeedback({ type: "success", message: "Plano do usuário atualizado." });
} catch (error) {
setFeedback({
type: "error",
message:
error instanceof Error
? error.message
: "Não foi possível atualizar o plano do usuário.",
});
} finally {
setIsLoading(false);
}
};
const orderedTiers = useMemo(
() =>
[...tiers].sort((a, b) => {
if (a.level === b.level) {
return a.subscription_tier_id - b.subscription_tier_id;
}
return a.level - b.level;
}),
[tiers]
);
return (
<div className="space-y-8">
{feedback && (
<div
className={`rounded-md border px-4 py-3 text-sm ${
feedback.type === "success"
? "border-emerald-300 bg-emerald-100 text-emerald-900"
: "border-destructive/40 bg-destructive/10 text-destructive"
}`}
>
{feedback.message}
</div>
)}
<Card>
<CardHeader>
<CardTitle>Adicionar novo plano</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-name">
Nome
</label>
<Input
id="tier-name"
placeholder="Ex.: Ultra"
value={createForm.name}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, name: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-slug">
Slug
</label>
<Input
id="tier-slug"
placeholder="ex.: ultra"
value={createForm.slug}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, slug: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-level">
Nível (herança)
</label>
<Input
id="tier-level"
type="number"
min={0}
value={createForm.level}
onChange={(event) =>
setCreateForm((prev) => ({
...prev,
level: Number.parseInt(event.target.value || "0", 10),
}))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-status">
Status
</label>
<Button
id="tier-status"
variant={createForm.isActive ? "default" : "outline"}
type="button"
onClick={() =>
setCreateForm((prev) => ({ ...prev, isActive: !prev.isActive }))
}
>
{createForm.isActive ? "Ativo" : "Inativo"}
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-price-monthly">
Preço mensal (opcional)
</label>
<Input
id="tier-price-monthly"
type="number"
min={0}
step="0.01"
value={createForm.priceMonthly}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, priceMonthly: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-price-yearly">
Preço anual (opcional)
</label>
<Input
id="tier-price-yearly"
type="number"
min={0}
step="0.01"
value={createForm.priceYearly}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, priceYearly: event.target.value }))
}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-description">
Descrição
</label>
<Textarea
id="tier-description"
rows={3}
value={createForm.description}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, description: event.target.value }))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="tier-features">
Recursos (um por linha: featureKey|Label|Descrição)
</label>
<Textarea
id="tier-features"
rows={4}
value={createForm.features}
onChange={(event) =>
setCreateForm((prev) => ({ ...prev, features: event.target.value }))
}
placeholder="premium.analytics|Analytics avançadas|Painéis detalhados"
/>
</div>
<Button
type="button"
onClick={handleCreateTier}
disabled={isLoading}
className="w-full md:w-auto"
>
Criar plano
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Planos cadastrados</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{orderedTiers.map((tier) => {
const formState = editForms[tier.subscription_tier_id] ?? mapTierToFormState(tier);
return (
<Card key={tier.subscription_tier_id} className="border-muted">
<CardHeader className="flex flex-row items-center justify-between gap-4">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-xl">
{tier.name}
<Badge variant="secondary">{tier.slug}</Badge>
<Badge variant={tier.is_active ? "default" : "outline"} className="uppercase">
{tier.is_active ? "Ativo" : "Inativo"}
</Badge>
</CardTitle>
<p className="text-sm text-muted-foreground">
Nível {tier.level} · ID #{tier.subscription_tier_id}
</p>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Nome</label>
<Input
value={formState.name}
onChange={(event) =>
setEditForms((prev) => ({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
name: event.target.value,
},
}))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Slug</label>
<Input
value={formState.slug}
onChange={(event) =>
setEditForms((prev) => ({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
slug: event.target.value,
},
}))
}
disabled={tier.slug === "free"}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Nível</label>
<Input
type="number"
min={0}
value={formState.level}
onChange={(event) =>
setEditForms((prev) => ({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
level: Number.parseInt(event.target.value || "0", 10),
},
}))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Status</label>
<Button
variant={formState.isActive ? "default" : "outline"}
type="button"
onClick={() =>
setEditForms((prev) => ({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
isActive: !prev[tier.subscription_tier_id].isActive,
},
}))
}
>
{formState.isActive ? "Ativo" : "Inativo"}
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium">Preço mensal</label>
<Input
type="number"
min={0}
step="0.01"
value={formState.priceMonthly}
onChange={(event) =>
setEditForms((prev) => ({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
priceMonthly: event.target.value,
},
}))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Preço anual</label>
<Input
type="number"
min={0}
step="0.01"
value={formState.priceYearly}
onChange={(event) =>
setEditForms((prev) => ({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
priceYearly: event.target.value,
},
}))
}
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Descrição</label>
<Textarea
rows={3}
value={formState.description}
onChange={(event) =>
setEditForms((prev) => ({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
description: event.target.value,
},
}))
}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Recursos (um por linha: featureKey|Label|Descrição)
</label>
<Textarea
rows={4}
value={formState.features}
onChange={(event) =>
setEditForms((prev) => ({
...prev,
[tier.subscription_tier_id]: {
...prev[tier.subscription_tier_id],
features: event.target.value,
},
}))
}
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={() => handleUpdateTier(tier.subscription_tier_id)}
disabled={isLoading}
>
Salvar alterações
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
setEditForms((prev) => ({
...prev,
[tier.subscription_tier_id]: mapTierToFormState(tier),
}))
}
disabled={isLoading}
>
Descartar mudanças
</Button>
{tier.slug !== "free" && (
<Button
type="button"
variant="destructive"
onClick={() => handleDeleteTier(tier.subscription_tier_id, tier.slug)}
disabled={isLoading}
>
Remover plano
</Button>
)}
</div>
</CardContent>
</Card>
);
})}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Vincular plano a usuário</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="assign-user-id">
ID do usuário
</label>
<Input
id="assign-user-id"
type="number"
min={1}
value={assignUserId}
onChange={(event) => setAssignUserId(event.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="assign-tier">
Plano de destino
</label>
<select
id="assign-tier"
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
value={assignTierId}
onChange={(event) => setAssignTierId(event.target.value)}
>
{orderedTiers.map((tier) => (
<option key={tier.subscription_tier_id} value={tier.slug}>
{tier.name} ({tier.slug})
</option>
))}
</select>
</div>
</div>
<Button type="button" onClick={handleAssignTier} disabled={isLoading}>
Atualizar plano do usuário
</Button>
</CardContent>
</Card>
</div>
);
}
+60 -2
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { Bell } from "lucide-react"; import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -33,10 +33,32 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
const [notifications, setNotifications] = useState<Notification[]>([]); const [notifications, setNotifications] = useState<Notification[]>([]);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const rawUnreadRef = useRef(0);
const baselineUnreadRef = useRef(0);
const baselineLoadedRef = useRef(false);
const storageKeyRef = useRef<string | null>(null);
const effectiveUserId = userId || user?.userId; const effectiveUserId = userId || user?.userId;
// Buscar contagem de não lidas // Buscar contagem de não lidas
const persistBaseline = (value: number) => {
baselineUnreadRef.current = value;
if (typeof window !== "undefined" && storageKeyRef.current) {
window.localStorage.setItem(storageKeyRef.current, String(value));
}
};
const loadBaselineFromStorage = () => {
if (typeof window === "undefined" || !storageKeyRef.current) {
return undefined;
}
const stored = window.localStorage.getItem(storageKeyRef.current);
if (!stored) return undefined;
const parsed = Number.parseInt(stored, 10);
if (Number.isNaN(parsed)) return undefined;
return parsed;
};
const fetchUnreadCount = async () => { const fetchUnreadCount = async () => {
if (!effectiveUserId) return; if (!effectiveUserId) return;
@@ -45,7 +67,20 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
setUnreadCount(data.count); rawUnreadRef.current = data.count;
if (!baselineLoadedRef.current) {
const storedBaseline = loadBaselineFromStorage();
if (storedBaseline !== undefined) {
const normalized = Math.min(storedBaseline, data.count);
persistBaseline(normalized);
} else {
persistBaseline(data.count);
}
baselineLoadedRef.current = true;
} else if (data.count < baselineUnreadRef.current) {
persistBaseline(data.count);
}
setUnreadCount(Math.max(0, data.count - baselineUnreadRef.current));
} }
} }
} catch (error) { } catch (error) {
@@ -74,6 +109,21 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
}; };
// Polling automático a cada 30 segundos // Polling automático a cada 30 segundos
useEffect(() => {
if (!effectiveUserId) {
return;
}
storageKeyRef.current = `notifications:lastBaseline:${effectiveUserId}`;
const storedBaseline = loadBaselineFromStorage();
if (storedBaseline !== undefined) {
baselineUnreadRef.current = storedBaseline;
baselineLoadedRef.current = true;
} else {
baselineUnreadRef.current = 0;
baselineLoadedRef.current = false;
}
}, [effectiveUserId]);
useEffect(() => { useEffect(() => {
if (!effectiveUserId) return; if (!effectiveUserId) return;
@@ -94,6 +144,8 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
fetchNotifications(); fetchNotifications();
persistBaseline(rawUnreadRef.current);
setUnreadCount(0);
} }
}, [isOpen]); }, [isOpen]);
@@ -111,6 +163,12 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
) )
); );
// Atualizar contagem // Atualizar contagem
rawUnreadRef.current = Math.max(0, rawUnreadRef.current - 1);
const newBaseline = Math.min(
baselineUnreadRef.current,
rawUnreadRef.current
);
persistBaseline(newBaseline);
setUnreadCount((prev) => Math.max(0, prev - 1)); setUnreadCount((prev) => Math.max(0, prev - 1));
} }
} catch (error) { } catch (error) {
+23 -2
View File
@@ -9,6 +9,8 @@ interface TokenPayload {
userId: number; userId: number;
username: string; username: string;
type: "access" | "refresh"; type: "access" | "refresh";
tierSlug?: string;
tierLevel?: number;
} }
/** /**
@@ -45,14 +47,22 @@ const REFRESH_TOKEN_EXPIRY = "7d"; // 7 dias
* @param username - Nome de usuário * @param username - Nome de usuário
* @returns Access token * @returns Access token
*/ */
interface TokenOptions {
tierSlug?: string | null;
tierLevel?: number | null;
}
export function generateAccessToken( export function generateAccessToken(
userId: number, userId: number,
username: string username: string,
options?: TokenOptions
): string { ): string {
const payload: TokenPayload = { const payload: TokenPayload = {
userId, userId,
username, username,
type: "access", type: "access",
tierSlug: options?.tierSlug ?? undefined,
tierLevel: options?.tierLevel ?? undefined,
}; };
return jwt.sign(payload, getJWTSecret(), { return jwt.sign(payload, getJWTSecret(), {
@@ -68,12 +78,15 @@ export function generateAccessToken(
*/ */
export function generateRefreshToken( export function generateRefreshToken(
userId: number, userId: number,
username: string username: string,
options?: TokenOptions
): string { ): string {
const payload: TokenPayload = { const payload: TokenPayload = {
userId, userId,
username, username,
type: "refresh", type: "refresh",
tierSlug: options?.tierSlug ?? undefined,
tierLevel: options?.tierLevel ?? undefined,
}; };
return jwt.sign(payload, getJWTRefreshSecret(), { return jwt.sign(payload, getJWTRefreshSecret(), {
@@ -89,6 +102,8 @@ export function generateRefreshToken(
export function verifyAccessToken(token: string): { export function verifyAccessToken(token: string): {
userId: number; userId: number;
username: string; username: string;
tierSlug?: string;
tierLevel?: number;
} | null { } | null {
try { try {
const decoded = jwt.verify(token, getJWTSecret()) as TokenPayload; const decoded = jwt.verify(token, getJWTSecret()) as TokenPayload;
@@ -100,6 +115,8 @@ export function verifyAccessToken(token: string): {
return { return {
userId: decoded.userId, userId: decoded.userId,
username: decoded.username, username: decoded.username,
tierSlug: decoded.tierSlug,
tierLevel: decoded.tierLevel,
}; };
} catch (error) { } catch (error) {
return null; return null;
@@ -114,6 +131,8 @@ export function verifyAccessToken(token: string): {
export function verifyRefreshToken(token: string): { export function verifyRefreshToken(token: string): {
userId: number; userId: number;
username: string; username: string;
tierSlug?: string;
tierLevel?: number;
} | null { } | null {
try { try {
const decoded = jwt.verify(token, getJWTRefreshSecret()) as TokenPayload; const decoded = jwt.verify(token, getJWTRefreshSecret()) as TokenPayload;
@@ -125,6 +144,8 @@ export function verifyRefreshToken(token: string): {
return { return {
userId: decoded.userId, userId: decoded.userId,
username: decoded.username, username: decoded.username,
tierSlug: decoded.tierSlug,
tierLevel: decoded.tierLevel,
}; };
} catch (error) { } catch (error) {
return null; return null;
+76 -15
View File
@@ -8,6 +8,13 @@ import { cookies } from "next/headers";
import { verifyAccessToken } from "./jwt"; import { verifyAccessToken } from "./jwt";
import { getSession } from "@/lib/redis/session"; import { getSession } from "@/lib/redis/session";
export interface AuthenticatedUser {
userId: number;
username: string;
tierSlug?: string;
tierLevel?: number;
}
/** /**
* Extrai token do header Authorization * Extrai token do header Authorization
* @param request - Request do Next.js * @param request - Request do Next.js
@@ -24,18 +31,23 @@ function extractToken(request: NextRequest): string | null {
} }
/** /**
* Middleware que requer autenticação function toAuthenticatedUser(payload: {
* Retorna dados do usuário ou NextResponse com erro userId: number;
* Suporta tanto Bearer token quanto cookie sessionId username: string;
* @param request - Request do Next.js tierSlug?: string;
* @returns Dados do usuário autenticado ou NextResponse com erro tierLevel?: number;
*/ }): AuthenticatedUser {
return {
userId: payload.userId,
username: payload.username,
tierSlug: payload.tierSlug,
tierLevel: payload.tierLevel,
};
}
export async function requireAuth( export async function requireAuth(
request: NextRequest request: NextRequest
): Promise< ): Promise<AuthenticatedUser | NextResponse<{ success: false; error: string }>> {
| { userId: number; username: string }
| NextResponse<{ success: false; error: string }>
> {
// Tentar primeiro com Bearer token // Tentar primeiro com Bearer token
const token = extractToken(request); const token = extractToken(request);
@@ -43,7 +55,7 @@ export async function requireAuth(
const payload = verifyAccessToken(token); const payload = verifyAccessToken(token);
if (payload) { if (payload) {
return payload; return toAuthenticatedUser(payload);
} }
} }
@@ -59,7 +71,7 @@ export async function requireAuth(
const tokenPayload = verifyAccessToken(session.accessToken); const tokenPayload = verifyAccessToken(session.accessToken);
if (tokenPayload) { if (tokenPayload) {
return tokenPayload; return toAuthenticatedUser(tokenPayload);
} }
} }
} }
@@ -85,14 +97,14 @@ export async function requireAuth(
*/ */
export async function optionalAuth( export async function optionalAuth(
request: NextRequest request: NextRequest
): Promise<{ userId: number; username: string } | null> { ): Promise<AuthenticatedUser | null> {
// Tentar primeiro com Bearer token // Tentar primeiro com Bearer token
const token = extractToken(request); const token = extractToken(request);
if (token) { if (token) {
const payload = verifyAccessToken(token); const payload = verifyAccessToken(token);
if (payload) { if (payload) {
return payload; return toAuthenticatedUser(payload);
} }
} }
@@ -106,7 +118,9 @@ export async function optionalAuth(
if (session) { if (session) {
const tokenPayload = verifyAccessToken(session.accessToken); const tokenPayload = verifyAccessToken(session.accessToken);
return tokenPayload; if (tokenPayload) {
return toAuthenticatedUser(tokenPayload);
}
} }
} }
} catch (error) { } catch (error) {
@@ -115,3 +129,50 @@ export async function optionalAuth(
return null; return null;
} }
export async function requireAdminUser(
request: NextRequest
): Promise<AuthenticatedUser | NextResponse<{ success: false; error: string }>> {
const auth = await requireAuth(request);
if (auth instanceof NextResponse) {
return auth;
}
if (auth.userId !== 1) {
return NextResponse.json(
{
success: false,
error: "Acesso restrito ao administrador.",
},
{ status: 403 }
);
}
return auth;
}
export async function requireTierLevel(
request: NextRequest,
minimumLevel: number
): Promise<AuthenticatedUser | NextResponse<{ success: false; error: string }>> {
const auth = await requireAuth(request);
if (auth instanceof NextResponse) {
return auth;
}
const tierLevel = auth.tierLevel ?? 0;
if (tierLevel < minimumLevel) {
return NextResponse.json(
{
success: false,
error: "Plano atual não possui acesso a este recurso.",
},
{ status: 403 }
);
}
return auth;
}
@@ -0,0 +1,93 @@
-- Migration 019: Subscription tiers e permissões iniciais
-- Criado em: 2025-02-07
CREATE TABLE IF NOT EXISTS subscription_tiers (
subscription_tier_id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
description TEXT,
level INTEGER NOT NULL DEFAULT 0,
price_monthly NUMERIC(10, 2),
price_yearly NUMERIC(10, 2),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_subscription_tiers_slug ON subscription_tiers(slug);
CREATE INDEX IF NOT EXISTS idx_subscription_tiers_level ON subscription_tiers(level);
CREATE TABLE IF NOT EXISTS subscription_tier_features (
feature_id SERIAL PRIMARY KEY,
subscription_tier_id INTEGER NOT NULL REFERENCES subscription_tiers(subscription_tier_id) ON DELETE CASCADE,
feature_key VARCHAR(100) NOT NULL,
label VARCHAR(255) NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT subscription_tier_features_unique UNIQUE (subscription_tier_id, feature_key)
);
CREATE INDEX IF NOT EXISTS subscription_tier_features_sort_idx ON subscription_tier_features(subscription_tier_id, sort_order);
ALTER TABLE users
ADD COLUMN IF NOT EXISTS subscription_tier_id INTEGER;
ALTER TABLE users
ADD CONSTRAINT users_subscription_tier_id_fkey
FOREIGN KEY (subscription_tier_id)
REFERENCES subscription_tiers(subscription_tier_id)
ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_users_subscription_tier_id ON users(subscription_tier_id);
INSERT INTO subscription_tiers (name, slug, description, level, price_monthly, price_yearly, is_active)
VALUES
('Free', 'free', 'Plano gratuito com recursos essenciais.', 0, 0, 0, TRUE),
('Pro', 'pro', 'Plano profissional com recursos avançados.', 1, 29.90, 299.00, TRUE),
('Ultra', 'ultra', 'Plano completo com todos os recursos e benefícios.', 2, 59.90, 599.00, TRUE)
ON CONFLICT (slug) DO UPDATE
SET
name = EXCLUDED.name,
description = EXCLUDED.description,
level = EXCLUDED.level,
price_monthly = EXCLUDED.price_monthly,
price_yearly = EXCLUDED.price_yearly,
is_active = EXCLUDED.is_active,
updated_at = CURRENT_TIMESTAMP;
WITH free_tier AS (
SELECT subscription_tier_id
FROM subscription_tiers
WHERE slug = 'free'
LIMIT 1
)
UPDATE users
SET subscription_tier_id = free_tier.subscription_tier_id
FROM free_tier
WHERE users.subscription_tier_id IS NULL;
INSERT INTO subscription_tier_features (subscription_tier_id, feature_key, label, description, sort_order)
SELECT tier.subscription_tier_id,
feat.feature_key,
feat.label,
feat.description,
feat.sort_order
FROM (
VALUES
('free', 'core.marketplace', 'Acesso ao marketplace', 'Publicar e negociar itens com segurança.', 0),
('free', 'core.notifications', 'Notificações básicas', 'Alertas essenciais sobre atividades da conta.', 1),
('pro', 'advanced.analytics', 'Analytics detalhadas', 'Painéis avançados com métricas de performance.', 0),
('pro', 'advanced.priority-support', 'Suporte prioritário', 'Canal exclusivo de atendimento mais rápido.', 1),
('ultra', 'premium.whitelabel', 'Whitelabel & branding', 'Personalização completa da experiência.', 0),
('ultra', 'premium.integrations', 'Integrações premium', 'Acesso antecipado a integrações e APIs.', 1)
) AS feat(slug, feature_key, label, description, sort_order)
JOIN subscription_tiers AS tier ON tier.slug = feat.slug
ON CONFLICT (subscription_tier_id, feature_key) DO UPDATE
SET
label = EXCLUDED.label,
description = EXCLUDED.description,
sort_order = EXCLUDED.sort_order,
updated_at = CURRENT_TIMESTAMP;
+1
View File
@@ -38,6 +38,7 @@ const MIGRATIONS = [
"016_create_unified_items_table.sql", "016_create_unified_items_table.sql",
"017_add_items_foreign_key.sql", "017_add_items_foreign_key.sql",
"018_migrate_listings_to_unified_items.sql", "018_migrate_listings_to_unified_items.sql",
"019_subscription_tiers.sql",
]; ];
async function runMigrations() { async function runMigrations() {
+6
View File
@@ -7,6 +7,12 @@ export interface SessionUser {
username: string; username: string;
email: string; email: string;
characterName: string | null; characterName: string | null;
subscriptionTier: {
id: number | null;
name: string | null;
slug: string | null;
level: number | null;
} | null;
} }
interface SessionResponse { interface SessionResponse {
+298
View File
@@ -0,0 +1,298 @@
/**
* Queries para gerenciamento de subscription tiers
*/
import { Prisma } from "@prisma/client";
import { prisma } from "../app";
export interface SubscriptionTierSummary {
subscription_tier_id: number;
name: string;
slug: string;
description: string | null;
level: number;
price_monthly: Prisma.Decimal | null;
price_yearly: Prisma.Decimal | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
features?: Array<{
feature_id: number;
feature_key: string;
label: string;
description: string | null;
sort_order: number;
}>;
}
export interface CreateSubscriptionTierInput {
name: string;
slug: string;
description?: string | null;
level?: number;
price_monthly?: number | null;
price_yearly?: number | null;
is_active?: boolean;
features?: Array<{
feature_key: string;
label: string;
description?: string | null;
sort_order?: number;
}>;
}
export interface UpdateSubscriptionTierInput {
name?: string;
slug?: string;
description?: string | null;
level?: number;
price_monthly?: number | null;
price_yearly?: number | null;
is_active?: boolean;
features?: Array<{
feature_id?: number;
feature_key: string;
label: string;
description?: string | null;
sort_order?: number;
_delete?: boolean;
}>;
}
const tierSelect = {
subscription_tier_id: true,
name: true,
slug: true,
description: true,
level: true,
price_monthly: true,
price_yearly: true,
is_active: true,
created_at: true,
updated_at: true,
};
const featureSelect = {
feature_id: true,
feature_key: true,
label: true,
description: true,
sort_order: true,
};
export async function getAllSubscriptionTiers(
includeFeatures = false
): Promise<SubscriptionTierSummary[]> {
const tiers = await prisma.subscriptionTier.findMany({
select: {
...tierSelect,
features: includeFeatures
? {
select: featureSelect,
orderBy: { sort_order: "asc" },
}
: false,
},
orderBy: [
{ level: "asc" },
{ subscription_tier_id: "asc" },
],
});
return tiers as SubscriptionTierSummary[];
}
export async function getSubscriptionTierBySlug(
slug: string,
includeFeatures = false
): Promise<SubscriptionTierSummary | null> {
const tier = await prisma.subscriptionTier.findUnique({
where: { slug },
select: {
...tierSelect,
features: includeFeatures
? {
select: featureSelect,
orderBy: { sort_order: "asc" },
}
: false,
},
});
return tier as SubscriptionTierSummary | null;
}
export async function getSubscriptionTierById(
tierId: number,
includeFeatures = false
): Promise<SubscriptionTierSummary | null> {
const tier = await prisma.subscriptionTier.findUnique({
where: { subscription_tier_id: tierId },
select: {
...tierSelect,
features: includeFeatures
? {
select: featureSelect,
orderBy: { sort_order: "asc" },
}
: false,
},
});
return tier as SubscriptionTierSummary | null;
}
export async function getDefaultSubscriptionTier(): Promise<SubscriptionTierSummary | null> {
const tier = await prisma.subscriptionTier.findFirst({
where: { slug: "free" },
select: tierSelect,
});
return tier as SubscriptionTierSummary | null;
}
export async function createSubscriptionTier(
input: CreateSubscriptionTierInput
): Promise<SubscriptionTierSummary> {
const tier = await prisma.subscriptionTier.create({
data: {
name: input.name,
slug: input.slug,
description: input.description ?? null,
level: input.level ?? 0,
price_monthly:
input.price_monthly !== undefined && input.price_monthly !== null
? new Prisma.Decimal(input.price_monthly)
: null,
price_yearly:
input.price_yearly !== undefined && input.price_yearly !== null
? new Prisma.Decimal(input.price_yearly)
: null,
is_active: input.is_active ?? true,
features: input.features
? {
create: input.features.map((feature, index) => ({
feature_key: feature.feature_key,
label: feature.label,
description: feature.description ?? null,
sort_order: feature.sort_order ?? index,
})),
}
: undefined,
},
select: {
...tierSelect,
features: {
select: featureSelect,
orderBy: { sort_order: "asc" },
},
},
});
return tier as SubscriptionTierSummary;
}
export async function updateSubscriptionTier(
tierId: number,
input: UpdateSubscriptionTierInput
): Promise<SubscriptionTierSummary> {
const tier = await prisma.subscriptionTier.update({
where: { subscription_tier_id: tierId },
data: {
name: input.name ?? undefined,
slug: input.slug ?? undefined,
description:
input.description !== undefined ? input.description : undefined,
level: input.level ?? undefined,
price_monthly:
input.price_monthly !== undefined
? input.price_monthly === null
? null
: new Prisma.Decimal(input.price_monthly)
: undefined,
price_yearly:
input.price_yearly !== undefined
? input.price_yearly === null
? null
: new Prisma.Decimal(input.price_yearly)
: undefined,
is_active: input.is_active ?? undefined,
},
select: {
...tierSelect,
features: {
select: featureSelect,
orderBy: { sort_order: "asc" },
},
},
});
if (input.features) {
await prisma.subscriptionTierFeature.deleteMany({
where: { subscription_tier_id: tierId },
});
if (input.features.length > 0) {
await prisma.subscriptionTierFeature.createMany({
data: input.features
.filter((feature) => !feature._delete)
.map((feature, index) => ({
subscription_tier_id: tierId,
feature_key: feature.feature_key,
label: feature.label,
description: feature.description ?? null,
sort_order: feature.sort_order ?? index,
})),
});
}
}
const updatedTier = await prisma.subscriptionTier.findUnique({
where: { subscription_tier_id: tierId },
select: {
...tierSelect,
features: {
select: featureSelect,
orderBy: { sort_order: "asc" },
},
},
});
return updatedTier as SubscriptionTierSummary;
}
export async function deleteSubscriptionTier(tierId: number): Promise<void> {
await prisma.subscriptionTier.delete({
where: { subscription_tier_id: tierId },
});
}
export async function assignUserToSubscriptionTier(
userId: number,
tierId: number | null
): Promise<void> {
await prisma.user.update({
where: { user_id: userId },
data:
tierId === null
? {
subscription_tier: {
disconnect: true,
},
}
: {
subscription_tier: {
connect: {
subscription_tier_id: tierId,
},
},
},
});
}
export async function countUsersInSubscriptionTier(tierId: number): Promise<number> {
return prisma.user.count({
where: { subscription_tier_id: tierId },
});
}
+70 -6
View File
@@ -4,6 +4,7 @@
*/ */
import { prisma } from "../app"; import { prisma } from "../app";
import { getDefaultSubscriptionTier } from "./subscription-tiers";
import type { User } from "@prisma/client"; import type { User } from "@prisma/client";
export interface CreateUserData { export interface CreateUserData {
@@ -21,6 +22,7 @@ export interface UpdateUserData {
last_name?: string; last_name?: string;
phone?: string; phone?: string;
whatsapp_notifications?: boolean; whatsapp_notifications?: boolean;
subscription_tier_id?: number | null;
} }
/** /**
@@ -29,6 +31,7 @@ export interface UpdateUserData {
export async function createUser( export async function createUser(
data: CreateUserData data: CreateUserData
): Promise<{ userId: number; username: string; email: string }> { ): Promise<{ userId: number; username: string; email: string }> {
const defaultTier = await getDefaultSubscriptionTier();
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
username: data.username, username: data.username,
@@ -38,6 +41,7 @@ export async function createUser(
last_name: data.last_name, last_name: data.last_name,
phone: data.phone || null, phone: data.phone || null,
cpf: data.cpf || null, cpf: data.cpf || null,
subscription_tier_id: defaultTier?.subscription_tier_id ?? undefined,
}, },
select: { select: {
user_id: true, user_id: true,
@@ -56,10 +60,29 @@ export async function createUser(
/** /**
* Busca usuário por email (inclui password_hash para login) * Busca usuário por email (inclui password_hash para login)
*/ */
export async function getUserByEmail(email: string): Promise<User | null> { type UserWithTier = User & {
return await prisma.user.findUnique({ subscription_tier?: {
subscription_tier_id: number;
name: string;
slug: string;
level: number;
} | null;
};
export async function getUserByEmail(email: string): Promise<UserWithTier | null> {
return (await prisma.user.findUnique({
where: { email }, where: { email },
}); include: {
subscription_tier: {
select: {
subscription_tier_id: true,
name: true,
slug: true,
level: true,
},
},
},
})) as UserWithTier | null;
} }
/** /**
@@ -67,10 +90,20 @@ export async function getUserByEmail(email: string): Promise<User | null> {
*/ */
export async function getUserByUsername( export async function getUserByUsername(
username: string username: string
): Promise<User | null> { ): Promise<UserWithTier | null> {
return await prisma.user.findUnique({ return (await prisma.user.findUnique({
where: { username }, where: { username },
}); include: {
subscription_tier: {
select: {
subscription_tier_id: true,
name: true,
slug: true,
level: true,
},
},
},
})) as UserWithTier | null;
} }
/** /**
@@ -92,6 +125,12 @@ type PublicUser = {
last_login: Date | null; last_login: Date | null;
is_active: boolean; is_active: boolean;
is_verified: boolean; is_verified: boolean;
subscription_tier?: {
subscription_tier_id: number;
name: string;
slug: string;
level: number;
} | null;
}; };
export async function getUserById(userId: number): Promise<PublicUser | null> { export async function getUserById(userId: number): Promise<PublicUser | null> {
@@ -113,6 +152,14 @@ export async function getUserById(userId: number): Promise<PublicUser | null> {
last_login: true, last_login: true,
is_active: true, is_active: true,
is_verified: true, is_verified: true,
subscription_tier: {
select: {
subscription_tier_id: true,
name: true,
slug: true,
level: true,
},
},
}, },
}) as PublicUser | null; }) as PublicUser | null;
} }
@@ -144,6 +191,15 @@ export async function updateUser(
if (data.phone !== undefined) updateData.phone = data.phone || null; if (data.phone !== undefined) updateData.phone = data.phone || null;
if (data.whatsapp_notifications !== undefined) if (data.whatsapp_notifications !== undefined)
updateData.whatsapp_notifications = data.whatsapp_notifications; updateData.whatsapp_notifications = data.whatsapp_notifications;
if (data.subscription_tier_id !== undefined) {
if (data.subscription_tier_id === null) {
updateData.subscription_tier = { disconnect: true };
} else {
updateData.subscription_tier = {
connect: { subscription_tier_id: data.subscription_tier_id },
};
}
}
const user = await prisma.user.update({ const user = await prisma.user.update({
where: { user_id: userId }, where: { user_id: userId },
@@ -164,6 +220,14 @@ export async function updateUser(
last_login: true, last_login: true,
is_active: true, is_active: true,
is_verified: true, is_verified: true,
subscription_tier: {
select: {
subscription_tier_id: true,
name: true,
slug: true,
level: true,
},
},
}, },
}); });
+13 -7
View File
@@ -9,21 +9,29 @@ const SESSION_PREFIX = "session:";
const USER_SESSION_PREFIX = "user_session:"; const USER_SESSION_PREFIX = "user_session:";
const SESSION_TTL = 60 * 60 * 24 * 7; // 7 dias (mesmo que refresh token) const SESSION_TTL = 60 * 60 * 24 * 7; // 7 dias (mesmo que refresh token)
export interface SessionTierInfo {
subscriptionTierId: number | null;
subscriptionTierSlug: string | null;
subscriptionTierName: string | null;
subscriptionTierLevel: number | null;
}
export interface SessionData { export interface SessionData {
userId: number; userId: number;
username: string; username: string;
email: string; email: string;
accessToken: string; accessToken: string;
createdAt: number; createdAt: number;
subscriptionTierId: number | null;
subscriptionTierSlug: string | null;
subscriptionTierName: string | null;
subscriptionTierLevel: number | null;
} }
/** /**
* Cria ou atualiza uma sessão * Cria ou atualiza uma sessão
*/ */
export async function setSession( export async function setSession(sessionId: string, data: SessionData): Promise<void> {
sessionId: string,
data: SessionData
): Promise<void> {
try { try {
const client = getRedisClient(); const client = getRedisClient();
if (!client.isOpen) { if (!client.isOpen) {
@@ -46,9 +54,7 @@ export async function setSession(
/** /**
* Obtém dados de uma sessão * Obtém dados de uma sessão
*/ */
export async function getSession( export async function getSession(sessionId: string): Promise<SessionData | null> {
sessionId: string
): Promise<SessionData | null> {
try { try {
const client = getRedisClient(); const client = getRedisClient();
if (!client.isOpen) { if (!client.isOpen) {
+112
View File
@@ -199,6 +199,118 @@ export const registerServerCharacterSchema = z.object({
isMain: z.boolean().optional().default(false), isMain: z.boolean().optional().default(false),
}); });
// ============================================
// SCHEMAS DE ASSINATURAS E PERMISSÕES
// ============================================
const tierSlugRegex = /^[a-z0-9-]+$/;
const featureKeyRegex = /^[a-z0-9_.-]+$/;
const subscriptionTierFeatureSchema = z.object({
featureKey: z
.string()
.min(2, "featureKey deve ter no mínimo 2 caracteres")
.max(100, "featureKey deve ter no máximo 100 caracteres")
.regex(featureKeyRegex, "featureKey deve conter apenas letras, números, ponto, hífen ou underscore")
.transform((key) => key.trim().toLowerCase()),
label: z
.string()
.min(3, "Label deve ter no mínimo 3 caracteres")
.max(255, "Label deve ter no máximo 255 caracteres")
.transform((value) => value.trim()),
description: z
.string()
.max(500, "Descrição deve ter no máximo 500 caracteres")
.transform((value) => value.trim())
.optional()
.nullable(),
sortOrder: z.number().int().min(0).max(1000).optional(),
});
export const createSubscriptionTierSchema = z
.object({
name: z
.string()
.min(3, "Nome deve ter no mínimo 3 caracteres")
.max(100, "Nome deve ter no máximo 100 caracteres")
.transform((value) => value.trim()),
slug: z
.string()
.min(2, "Slug deve ter no mínimo 2 caracteres")
.max(50, "Slug deve ter no máximo 50 caracteres")
.regex(tierSlugRegex, "Slug deve conter apenas letras minúsculas, números e hífen")
.transform((value) => value.trim().toLowerCase()),
description: z
.string()
.max(500, "Descrição deve ter no máximo 500 caracteres")
.transform((value) => value.trim())
.optional()
.nullable(),
level: z.number().int().min(0).max(99).optional(),
priceMonthly: z.number().min(0).max(100000).nullable().optional(),
priceYearly: z.number().min(0).max(1000000).nullable().optional(),
isActive: z.boolean().optional(),
features: z.array(subscriptionTierFeatureSchema).max(50).optional(),
})
.refine(
(data) => {
if (
data.priceMonthly !== undefined &&
data.priceYearly !== undefined &&
data.priceYearly !== null &&
data.priceMonthly !== null
) {
return data.priceYearly >= data.priceMonthly;
}
return true;
},
{
message: "Preço anual deve ser maior ou igual ao mensal",
path: ["priceYearly"],
}
);
export const updateSubscriptionTierSchema = createSubscriptionTierSchema
.partial()
.extend({
slug: z
.string()
.min(2, "Slug deve ter no mínimo 2 caracteres")
.max(50, "Slug deve ter no máximo 50 caracteres")
.regex(tierSlugRegex, "Slug deve conter apenas letras minúsculas, números e hífen")
.transform((value) => value.trim().toLowerCase())
.optional(),
features: z
.array(
subscriptionTierFeatureSchema.extend({
featureId: z.number().int().positive().optional(),
_delete: z.boolean().optional(),
})
)
.max(50)
.optional(),
});
export const assignSubscriptionTierSchema = z
.object({
userId: z.number().int().positive(),
tierId: z.number().int().positive().nullable().optional(),
tierSlug: z
.string()
.min(2, "Slug deve ter no mínimo 2 caracteres")
.max(50, "Slug deve ter no máximo 50 caracteres")
.regex(tierSlugRegex, "Slug deve conter apenas letras minúsculas, números e hífen")
.transform((value) => value.trim().toLowerCase())
.optional(),
})
.refine(
(data) => data.tierId !== undefined || data.tierSlug !== undefined,
{
message: "Informe tierId ou tierSlug",
path: ["tierId"],
}
);
// Schema para registro de usuário // Schema para registro de usuário
export const registerSchema = z.object({ export const registerSchema = z.object({
username: usernameSchema, username: usernameSchema,
+38
View File
@@ -7,6 +7,41 @@ datasource db {
url = env("DATABASE_URL_APP") url = env("DATABASE_URL_APP")
} }
model SubscriptionTier {
subscription_tier_id Int @id @default(autoincrement())
name String @db.VarChar(100)
slug String @unique @db.VarChar(50)
description String? @db.Text
level Int @default(0)
price_monthly Decimal? @db.Decimal(10, 2)
price_yearly Decimal? @db.Decimal(10, 2)
is_active Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
users User[]
features SubscriptionTierFeature[]
@@index([slug])
@@index([level])
@@map("subscription_tiers")
}
model SubscriptionTierFeature {
feature_id Int @id @default(autoincrement())
subscription_tier_id Int
feature_key String @db.VarChar(100)
label String @db.VarChar(255)
description String? @db.Text
sort_order Int @default(0)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
tier SubscriptionTier @relation(fields: [subscription_tier_id], references: [subscription_tier_id], onDelete: Cascade)
@@unique([subscription_tier_id, feature_key], map: "subscription_tier_features_unique")
@@index([subscription_tier_id, sort_order], map: "subscription_tier_features_sort_idx")
@@map("subscription_tier_features")
}
model User { model User {
user_id Int @id @default(autoincrement()) user_id Int @id @default(autoincrement())
username String @unique @db.VarChar(50) username String @unique @db.VarChar(50)
@@ -35,10 +70,13 @@ model User {
favorites UserFavorite[] favorites UserFavorite[]
telemetry_events TelemetryEvent[] telemetry_events TelemetryEvent[]
purchaseIntentions PurchaseIntention[] @relation("BuyerIntentions") purchaseIntentions PurchaseIntention[] @relation("BuyerIntentions")
subscription_tier_id Int?
subscription_tier SubscriptionTier? @relation(fields: [subscription_tier_id], references: [subscription_tier_id], onDelete: SetNull)
@@index([email]) @@index([email])
@@index([username]) @@index([username])
@@index([cpf]) @@index([cpf])
@@index([subscription_tier_id])
@@map("users") @@map("users")
} }