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
+23
-2
@@ -9,6 +9,8 @@ interface TokenPayload {
|
||||
userId: number;
|
||||
username: string;
|
||||
type: "access" | "refresh";
|
||||
tierSlug?: string;
|
||||
tierLevel?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,14 +47,22 @@ const REFRESH_TOKEN_EXPIRY = "7d"; // 7 dias
|
||||
* @param username - Nome de usuário
|
||||
* @returns Access token
|
||||
*/
|
||||
interface TokenOptions {
|
||||
tierSlug?: string | null;
|
||||
tierLevel?: number | null;
|
||||
}
|
||||
|
||||
export function generateAccessToken(
|
||||
userId: number,
|
||||
username: string
|
||||
username: string,
|
||||
options?: TokenOptions
|
||||
): string {
|
||||
const payload: TokenPayload = {
|
||||
userId,
|
||||
username,
|
||||
type: "access",
|
||||
tierSlug: options?.tierSlug ?? undefined,
|
||||
tierLevel: options?.tierLevel ?? undefined,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, getJWTSecret(), {
|
||||
@@ -68,12 +78,15 @@ export function generateAccessToken(
|
||||
*/
|
||||
export function generateRefreshToken(
|
||||
userId: number,
|
||||
username: string
|
||||
username: string,
|
||||
options?: TokenOptions
|
||||
): string {
|
||||
const payload: TokenPayload = {
|
||||
userId,
|
||||
username,
|
||||
type: "refresh",
|
||||
tierSlug: options?.tierSlug ?? undefined,
|
||||
tierLevel: options?.tierLevel ?? undefined,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, getJWTRefreshSecret(), {
|
||||
@@ -89,6 +102,8 @@ export function generateRefreshToken(
|
||||
export function verifyAccessToken(token: string): {
|
||||
userId: number;
|
||||
username: string;
|
||||
tierSlug?: string;
|
||||
tierLevel?: number;
|
||||
} | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, getJWTSecret()) as TokenPayload;
|
||||
@@ -100,6 +115,8 @@ export function verifyAccessToken(token: string): {
|
||||
return {
|
||||
userId: decoded.userId,
|
||||
username: decoded.username,
|
||||
tierSlug: decoded.tierSlug,
|
||||
tierLevel: decoded.tierLevel,
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
@@ -114,6 +131,8 @@ export function verifyAccessToken(token: string): {
|
||||
export function verifyRefreshToken(token: string): {
|
||||
userId: number;
|
||||
username: string;
|
||||
tierSlug?: string;
|
||||
tierLevel?: number;
|
||||
} | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, getJWTRefreshSecret()) as TokenPayload;
|
||||
@@ -125,6 +144,8 @@ export function verifyRefreshToken(token: string): {
|
||||
return {
|
||||
userId: decoded.userId,
|
||||
username: decoded.username,
|
||||
tierSlug: decoded.tierSlug,
|
||||
tierLevel: decoded.tierLevel,
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
|
||||
+76
-15
@@ -8,6 +8,13 @@ import { cookies } from "next/headers";
|
||||
import { verifyAccessToken } from "./jwt";
|
||||
import { getSession } from "@/lib/redis/session";
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
userId: number;
|
||||
username: string;
|
||||
tierSlug?: string;
|
||||
tierLevel?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrai token do header Authorization
|
||||
* @param request - Request do Next.js
|
||||
@@ -24,18 +31,23 @@ function extractToken(request: NextRequest): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware que requer autenticação
|
||||
* Retorna dados do usuário ou NextResponse com erro
|
||||
* Suporta tanto Bearer token quanto cookie sessionId
|
||||
* @param request - Request do Next.js
|
||||
* @returns Dados do usuário autenticado ou NextResponse com erro
|
||||
*/
|
||||
function toAuthenticatedUser(payload: {
|
||||
userId: number;
|
||||
username: string;
|
||||
tierSlug?: string;
|
||||
tierLevel?: number;
|
||||
}): AuthenticatedUser {
|
||||
return {
|
||||
userId: payload.userId,
|
||||
username: payload.username,
|
||||
tierSlug: payload.tierSlug,
|
||||
tierLevel: payload.tierLevel,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireAuth(
|
||||
request: NextRequest
|
||||
): Promise<
|
||||
| { userId: number; username: string }
|
||||
| NextResponse<{ success: false; error: string }>
|
||||
> {
|
||||
): Promise<AuthenticatedUser | NextResponse<{ success: false; error: string }>> {
|
||||
// Tentar primeiro com Bearer token
|
||||
const token = extractToken(request);
|
||||
|
||||
@@ -43,7 +55,7 @@ export async function requireAuth(
|
||||
const payload = verifyAccessToken(token);
|
||||
|
||||
if (payload) {
|
||||
return payload;
|
||||
return toAuthenticatedUser(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +71,7 @@ export async function requireAuth(
|
||||
const tokenPayload = verifyAccessToken(session.accessToken);
|
||||
|
||||
if (tokenPayload) {
|
||||
return tokenPayload;
|
||||
return toAuthenticatedUser(tokenPayload);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,14 +97,14 @@ export async function requireAuth(
|
||||
*/
|
||||
export async function optionalAuth(
|
||||
request: NextRequest
|
||||
): Promise<{ userId: number; username: string } | null> {
|
||||
): Promise<AuthenticatedUser | null> {
|
||||
// Tentar primeiro com Bearer token
|
||||
const token = extractToken(request);
|
||||
|
||||
if (token) {
|
||||
const payload = verifyAccessToken(token);
|
||||
if (payload) {
|
||||
return payload;
|
||||
return toAuthenticatedUser(payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +118,9 @@ export async function optionalAuth(
|
||||
|
||||
if (session) {
|
||||
const tokenPayload = verifyAccessToken(session.accessToken);
|
||||
return tokenPayload;
|
||||
if (tokenPayload) {
|
||||
return toAuthenticatedUser(tokenPayload);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -115,3 +129,50 @@ export async function optionalAuth(
|
||||
|
||||
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",
|
||||
"017_add_items_foreign_key.sql",
|
||||
"018_migrate_listings_to_unified_items.sql",
|
||||
"019_subscription_tiers.sql",
|
||||
];
|
||||
|
||||
async function runMigrations() {
|
||||
|
||||
@@ -7,6 +7,12 @@ export interface SessionUser {
|
||||
username: string;
|
||||
email: string;
|
||||
characterName: string | null;
|
||||
subscriptionTier: {
|
||||
id: number | null;
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
level: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
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 { getDefaultSubscriptionTier } from "./subscription-tiers";
|
||||
import type { User } from "@prisma/client";
|
||||
|
||||
export interface CreateUserData {
|
||||
@@ -21,6 +22,7 @@ export interface UpdateUserData {
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
whatsapp_notifications?: boolean;
|
||||
subscription_tier_id?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,6 +31,7 @@ export interface UpdateUserData {
|
||||
export async function createUser(
|
||||
data: CreateUserData
|
||||
): Promise<{ userId: number; username: string; email: string }> {
|
||||
const defaultTier = await getDefaultSubscriptionTier();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: data.username,
|
||||
@@ -38,6 +41,7 @@ export async function createUser(
|
||||
last_name: data.last_name,
|
||||
phone: data.phone || null,
|
||||
cpf: data.cpf || null,
|
||||
subscription_tier_id: defaultTier?.subscription_tier_id ?? undefined,
|
||||
},
|
||||
select: {
|
||||
user_id: true,
|
||||
@@ -56,10 +60,29 @@ export async function createUser(
|
||||
/**
|
||||
* Busca usuário por email (inclui password_hash para login)
|
||||
*/
|
||||
export async function getUserByEmail(email: string): Promise<User | null> {
|
||||
return await prisma.user.findUnique({
|
||||
type UserWithTier = User & {
|
||||
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 },
|
||||
});
|
||||
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(
|
||||
username: string
|
||||
): Promise<User | null> {
|
||||
return await prisma.user.findUnique({
|
||||
): Promise<UserWithTier | null> {
|
||||
return (await prisma.user.findUnique({
|
||||
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;
|
||||
is_active: 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> {
|
||||
@@ -113,6 +152,14 @@ export async function getUserById(userId: number): Promise<PublicUser | null> {
|
||||
last_login: true,
|
||||
is_active: true,
|
||||
is_verified: true,
|
||||
subscription_tier: {
|
||||
select: {
|
||||
subscription_tier_id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
level: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as PublicUser | null;
|
||||
}
|
||||
@@ -144,6 +191,15 @@ export async function updateUser(
|
||||
if (data.phone !== undefined) updateData.phone = data.phone || null;
|
||||
if (data.whatsapp_notifications !== undefined)
|
||||
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({
|
||||
where: { user_id: userId },
|
||||
@@ -164,6 +220,14 @@ export async function updateUser(
|
||||
last_login: true,
|
||||
is_active: 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 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 {
|
||||
userId: number;
|
||||
username: string;
|
||||
email: string;
|
||||
accessToken: string;
|
||||
createdAt: number;
|
||||
subscriptionTierId: number | null;
|
||||
subscriptionTierSlug: string | null;
|
||||
subscriptionTierName: string | null;
|
||||
subscriptionTierLevel: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria ou atualiza uma sessão
|
||||
*/
|
||||
export async function setSession(
|
||||
sessionId: string,
|
||||
data: SessionData
|
||||
): Promise<void> {
|
||||
export async function setSession(sessionId: string, data: SessionData): Promise<void> {
|
||||
try {
|
||||
const client = getRedisClient();
|
||||
if (!client.isOpen) {
|
||||
@@ -46,9 +54,7 @@ export async function setSession(
|
||||
/**
|
||||
* Obtém dados de uma sessão
|
||||
*/
|
||||
export async function getSession(
|
||||
sessionId: string
|
||||
): Promise<SessionData | null> {
|
||||
export async function getSession(sessionId: string): Promise<SessionData | null> {
|
||||
try {
|
||||
const client = getRedisClient();
|
||||
if (!client.isOpen) {
|
||||
|
||||
@@ -199,6 +199,118 @@ export const registerServerCharacterSchema = z.object({
|
||||
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
|
||||
export const registerSchema = z.object({
|
||||
username: usernameSchema,
|
||||
|
||||
Reference in new issue
Block a user