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:
1 parent
7f42def478
commit
d27526e026
20 files changed
+2133
-40
No files matched your search
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user