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

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

No files matched your search

+23 -2
View File
@@ -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
View File
@@ -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;
+1
View File
@@ -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() {
+6
View File
@@ -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 {
+298
View File
@@ -0,0 +1,298 @@
/**
* Queries para gerenciamento de subscription tiers
*/
import { Prisma } from "@prisma/client";
import { prisma } from "../app";
export interface SubscriptionTierSummary {
subscription_tier_id: number;
name: string;
slug: string;
description: string | null;
level: number;
price_monthly: Prisma.Decimal | null;
price_yearly: Prisma.Decimal | null;
is_active: boolean;
created_at: Date;
updated_at: Date;
features?: Array<{
feature_id: number;
feature_key: string;
label: string;
description: string | null;
sort_order: number;
}>;
}
export interface CreateSubscriptionTierInput {
name: string;
slug: string;
description?: string | null;
level?: number;
price_monthly?: number | null;
price_yearly?: number | null;
is_active?: boolean;
features?: Array<{
feature_key: string;
label: string;
description?: string | null;
sort_order?: number;
}>;
}
export interface UpdateSubscriptionTierInput {
name?: string;
slug?: string;
description?: string | null;
level?: number;
price_monthly?: number | null;
price_yearly?: number | null;
is_active?: boolean;
features?: Array<{
feature_id?: number;
feature_key: string;
label: string;
description?: string | null;
sort_order?: number;
_delete?: boolean;
}>;
}
const tierSelect = {
subscription_tier_id: true,
name: true,
slug: true,
description: true,
level: true,
price_monthly: true,
price_yearly: true,
is_active: true,
created_at: true,
updated_at: true,
};
const featureSelect = {
feature_id: true,
feature_key: true,
label: true,
description: true,
sort_order: true,
};
export async function getAllSubscriptionTiers(
includeFeatures = false
): Promise<SubscriptionTierSummary[]> {
const tiers = await prisma.subscriptionTier.findMany({
select: {
...tierSelect,
features: includeFeatures
? {
select: featureSelect,
orderBy: { sort_order: "asc" },
}
: false,
},
orderBy: [
{ level: "asc" },
{ subscription_tier_id: "asc" },
],
});
return tiers as SubscriptionTierSummary[];
}
export async function getSubscriptionTierBySlug(
slug: string,
includeFeatures = false
): Promise<SubscriptionTierSummary | null> {
const tier = await prisma.subscriptionTier.findUnique({
where: { slug },
select: {
...tierSelect,
features: includeFeatures
? {
select: featureSelect,
orderBy: { sort_order: "asc" },
}
: false,
},
});
return tier as SubscriptionTierSummary | null;
}
export async function getSubscriptionTierById(
tierId: number,
includeFeatures = false
): Promise<SubscriptionTierSummary | null> {
const tier = await prisma.subscriptionTier.findUnique({
where: { subscription_tier_id: tierId },
select: {
...tierSelect,
features: includeFeatures
? {
select: featureSelect,
orderBy: { sort_order: "asc" },
}
: false,
},
});
return tier as SubscriptionTierSummary | null;
}
export async function getDefaultSubscriptionTier(): Promise<SubscriptionTierSummary | null> {
const tier = await prisma.subscriptionTier.findFirst({
where: { slug: "free" },
select: tierSelect,
});
return tier as SubscriptionTierSummary | null;
}
export async function createSubscriptionTier(
input: CreateSubscriptionTierInput
): Promise<SubscriptionTierSummary> {
const tier = await prisma.subscriptionTier.create({
data: {
name: input.name,
slug: input.slug,
description: input.description ?? null,
level: input.level ?? 0,
price_monthly:
input.price_monthly !== undefined && input.price_monthly !== null
? new Prisma.Decimal(input.price_monthly)
: null,
price_yearly:
input.price_yearly !== undefined && input.price_yearly !== null
? new Prisma.Decimal(input.price_yearly)
: null,
is_active: input.is_active ?? true,
features: input.features
? {
create: input.features.map((feature, index) => ({
feature_key: feature.feature_key,
label: feature.label,
description: feature.description ?? null,
sort_order: feature.sort_order ?? index,
})),
}
: undefined,
},
select: {
...tierSelect,
features: {
select: featureSelect,
orderBy: { sort_order: "asc" },
},
},
});
return tier as SubscriptionTierSummary;
}
export async function updateSubscriptionTier(
tierId: number,
input: UpdateSubscriptionTierInput
): Promise<SubscriptionTierSummary> {
const tier = await prisma.subscriptionTier.update({
where: { subscription_tier_id: tierId },
data: {
name: input.name ?? undefined,
slug: input.slug ?? undefined,
description:
input.description !== undefined ? input.description : undefined,
level: input.level ?? undefined,
price_monthly:
input.price_monthly !== undefined
? input.price_monthly === null
? null
: new Prisma.Decimal(input.price_monthly)
: undefined,
price_yearly:
input.price_yearly !== undefined
? input.price_yearly === null
? null
: new Prisma.Decimal(input.price_yearly)
: undefined,
is_active: input.is_active ?? undefined,
},
select: {
...tierSelect,
features: {
select: featureSelect,
orderBy: { sort_order: "asc" },
},
},
});
if (input.features) {
await prisma.subscriptionTierFeature.deleteMany({
where: { subscription_tier_id: tierId },
});
if (input.features.length > 0) {
await prisma.subscriptionTierFeature.createMany({
data: input.features
.filter((feature) => !feature._delete)
.map((feature, index) => ({
subscription_tier_id: tierId,
feature_key: feature.feature_key,
label: feature.label,
description: feature.description ?? null,
sort_order: feature.sort_order ?? index,
})),
});
}
}
const updatedTier = await prisma.subscriptionTier.findUnique({
where: { subscription_tier_id: tierId },
select: {
...tierSelect,
features: {
select: featureSelect,
orderBy: { sort_order: "asc" },
},
},
});
return updatedTier as SubscriptionTierSummary;
}
export async function deleteSubscriptionTier(tierId: number): Promise<void> {
await prisma.subscriptionTier.delete({
where: { subscription_tier_id: tierId },
});
}
export async function assignUserToSubscriptionTier(
userId: number,
tierId: number | null
): Promise<void> {
await prisma.user.update({
where: { user_id: userId },
data:
tierId === null
? {
subscription_tier: {
disconnect: true,
},
}
: {
subscription_tier: {
connect: {
subscription_tier_id: tierId,
},
},
},
});
}
export async function countUsersInSubscriptionTier(tierId: number): Promise<number> {
return prisma.user.count({
where: { subscription_tier_id: tierId },
});
}
+70 -6
View File
@@ -4,6 +4,7 @@
*/
import { prisma } from "../app";
import { 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
View File
@@ -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) {
+112
View File
@@ -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,