From 12650b101cab38aabb7ac06279ab2115b2f6abeb Mon Sep 17 00:00:00 2001 From: oscar Date: Tue, 14 Oct 2025 18:12:15 +0200 Subject: [PATCH 1/8] creacion tablas, seed para rellenar los planes, script para ejecutar el seed --- server/package.json | 3 +- .../20251014160342_init/migration.sql | 42 +++++++++++++++ server/prisma/schema.prisma | 30 +++++++++++ server/prisma/seed.ts | 53 +++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 server/prisma/migrations/20251014160342_init/migration.sql create mode 100644 server/prisma/seed.ts diff --git a/server/package.json b/server/package.json index b915e79..be0bc1c 100644 --- a/server/package.json +++ b/server/package.json @@ -12,9 +12,10 @@ "format": "prettier --write .", "db:init": "docker exec -it flowkan-server npx tsx src/init-db.ts", "db:migrate": "docker exec -it flowkan-server npx prisma migrate dev --name init", - "db": "npm run db:migrate && npm run db:init", + "db": "npm run db:migrate && npm run db:seed && npm run db:init", "db:reset": "docker exec -it flowkan-server npx prisma migrate reset && npx prisma generate && npm run db:init", "db:prod": "docker exec -it flowkan-server npx prisma migrate deploy", + "db:seed": "docker exec -it flowkan-server npx tsx prisma/seed.ts", "test": "jest" }, "keywords": [], diff --git a/server/prisma/migrations/20251014160342_init/migration.sql b/server/prisma/migrations/20251014160342_init/migration.sql new file mode 100644 index 0000000..9e1ab5c --- /dev/null +++ b/server/prisma/migrations/20251014160342_init/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "public"."SubscriptionPlan" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "maxBoards" INTEGER, + "maxTasks" INTEGER, + "maxMembersPerBoard" INTEGER, + "aiDescriptionLimit" INTEGER, + "aiAgentEnabled" BOOLEAN NOT NULL DEFAULT false, + "storageLimitMB" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SubscriptionPlan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."UserSubscription" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "planId" INTEGER NOT NULL, + "aiDescriptionCount" INTEGER NOT NULL DEFAULT 0, + "currentStorageUsedMB" INTEGER NOT NULL DEFAULT 0, + "startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "endDate" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserSubscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SubscriptionPlan_name_key" ON "public"."SubscriptionPlan"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserSubscription_userId_key" ON "public"."UserSubscription"("userId"); + +-- AddForeignKey +ALTER TABLE "public"."UserSubscription" ADD CONSTRAINT "UserSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."UserSubscription" ADD CONSTRAINT "UserSubscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "public"."SubscriptionPlan"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 64e842b..7a27bfe 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -24,6 +24,7 @@ model User { members BoardMember[] comments Comment[] assignedCards CardAssignee[] @relation("CardAssignees") + subscription UserSubscription? profile Profile? passwordResetTokens PasswordResetToken[] } @@ -141,4 +142,33 @@ model Media { updatedAt DateTime @updatedAt } +model SubscriptionPlan { + id Int @id @default(autoincrement()) + name String @unique // "Free", "Pro", "Business" + maxBoards Int? // NULL = ilimitado + maxTasks Int? + maxMembersPerBoard Int? + aiDescriptionLimit Int? + aiAgentEnabled Boolean @default(false) + storageLimitMB Int? // NULL = ilimitado + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + users UserSubscription[] +} + +model UserSubscription { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int @unique + plan SubscriptionPlan @relation(fields: [planId], references: [id]) + planId Int + aiDescriptionCount Int @default(0) // contador mensual + currentStorageUsedMB Int @default(0) + startDate DateTime @default(now()) + endDate DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts new file mode 100644 index 0000000..97738f5 --- /dev/null +++ b/server/prisma/seed.ts @@ -0,0 +1,53 @@ +import { PrismaClient } from "@prisma/client"; +const prisma = new PrismaClient(); + +async function main() { + await prisma.subscriptionPlan.upsert({ + where: { name: "Free" }, + update: {}, + create: { + name: "Free", + maxBoards: 3, + maxTasks: 50, + maxMembersPerBoard: 3, + aiDescriptionLimit: 5, + aiAgentEnabled: false, + storageLimitMB: 200, + }, + }); + + await prisma.subscriptionPlan.upsert({ + where: { name: "Pro" }, + update: {}, + create: { + name: "Pro", + maxBoards: null, + maxTasks: null, + maxMembersPerBoard: 20, + aiDescriptionLimit: 30, + aiAgentEnabled: true, + storageLimitMB: 5000, + }, + }); + + await prisma.subscriptionPlan.upsert({ + where: { name: "Business" }, + update: {}, + create: { + name: "Business", + maxBoards: null, + maxTasks: null, + maxMembersPerBoard: null, + aiDescriptionLimit: null, + aiAgentEnabled: true, + storageLimitMB: 1000000, + }, + }); +} + +main() + .then(() => { + console.log("✅ Subscription plans seeded successfully"); + }) + .catch(console.error) + .finally(() => prisma.$disconnect()); From 45baaa427c58ce0e46c136e93cd70742b34e9d9c Mon Sep 17 00:00:00 2001 From: oscar Date: Wed, 15 Oct 2025 15:30:01 +0200 Subject: [PATCH 2/8] middleware generico para limites de cantidad. Mostar en frontend popup al crear un tablero que excede limite --- client/src/components/DropdownMenu.tsx | 11 +- .../src/components/ui/modals/UpgradeModal.tsx | 77 +++++++++++++ client/src/components/ui/modals/new-board.tsx | 28 +++++ client/src/locales/en.json | 4 + client/src/locales/es.json | 4 + client/src/pages/boards/types.ts | 4 + client/src/store/boards/actions.ts | 8 +- .../20251015095346_init/migration.sql | 3 + server/prisma/schema.prisma | 2 + server/prisma/seed.ts | 75 +++++++++---- .../middlewares/checkResourceQuantityLimit.ts | 91 ++++++++++++++++ server/src/models/SubscriptionModel.ts | 101 ++++++++++++++++++ server/src/routes/boards.routes.ts | 2 + server/src/services/SubscriptionService.ts | 31 ++++++ 14 files changed, 414 insertions(+), 27 deletions(-) create mode 100644 client/src/components/ui/modals/UpgradeModal.tsx create mode 100644 server/prisma/migrations/20251015095346_init/migration.sql create mode 100644 server/src/middlewares/checkResourceQuantityLimit.ts create mode 100644 server/src/models/SubscriptionModel.ts create mode 100644 server/src/services/SubscriptionService.ts diff --git a/client/src/components/DropdownMenu.tsx b/client/src/components/DropdownMenu.tsx index 26b38eb..b2ade9c 100644 --- a/client/src/components/DropdownMenu.tsx +++ b/client/src/components/DropdownMenu.tsx @@ -24,10 +24,13 @@ const DropdownMenu: React.FC = ({ const buttonRef = useRef(null); const { t } = useTranslation(); - const toggleMenu = useCallback((e: React.MouseEvent) => { - e.stopPropagation(); - setOpen((prev) => !prev); - }, []); + const toggleMenu = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + setOpen((prev) => !prev); + }, + [setOpen], + ); const menuClasses = position === "right" ? "right-0" : "left-0"; diff --git a/client/src/components/ui/modals/UpgradeModal.tsx b/client/src/components/ui/modals/UpgradeModal.tsx new file mode 100644 index 0000000..4d66cb7 --- /dev/null +++ b/client/src/components/ui/modals/UpgradeModal.tsx @@ -0,0 +1,77 @@ +import { IconCancel } from "../../icons/IconCancel"; +import { Button } from "../Button"; +import { useTranslation } from "react-i18next"; +import { useDismiss } from "../../../hooks/useDismissClickAndEsc"; +import { useEffect } from "react"; + +interface UpgradeModalProps { + onClose: () => void; + message: string; +} + +const UpgradeModal = ({ onClose, message }: UpgradeModalProps) => { + const { t } = useTranslation(); + const { open, ref, setOpen } = useDismiss(true); + + useEffect(() => { + if (!open) { + onClose(); + } + }, [open, onClose]); + + if (!open) return null; + + const handleCloseClick = () => { + setOpen(false); + }; + + return ( +
+
+
+ +
+
+ + + + +

+ {t("upgradeModal.title")} +

+ +

{message}

+ +
+ +
+
+
+
+ ); +}; + +export default UpgradeModal; diff --git a/client/src/components/ui/modals/new-board.tsx b/client/src/components/ui/modals/new-board.tsx index cdcbcdd..fc8ef2b 100644 --- a/client/src/components/ui/modals/new-board.tsx +++ b/client/src/components/ui/modals/new-board.tsx @@ -10,6 +10,9 @@ import { useAppDispatch } from "../../../store"; import { SpinnerLoadingText } from "../Spinner"; import toast from "react-hot-toast"; import { CustomToast } from "../../CustomToast"; +import type { AxiosError } from "axios"; +import UpgradeModal from "./UpgradeModal"; +import type { LimitErrorData } from "../../../pages/boards/types"; interface NewBoardProps { onClose: () => void; @@ -19,6 +22,10 @@ const NewBoard = ({ onClose }: NewBoardProps) => { const { t: translation } = useTranslation(); const [titleInput, setTitleInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const [showLimitModal, setShowLimitModal] = useState(false); + const [limitErrorData, setLimitErrorData] = useState( + null, + ); const dispatch = useAppDispatch(); const fileRef = useRef(null); @@ -61,11 +68,32 @@ const NewBoard = ({ onClose }: NewBoardProps) => { onClose(); // Cierra el modal solo si la creación fue exitosa } catch (error) { console.error(translation("newBoard.error"), error); + const apiError = error as AxiosError; + const status = apiError.response?.status; + const errorData = apiError.response?.data; + + if (status === 403 && errorData) { + const limitData = errorData as LimitErrorData; + setLimitErrorData(limitData); + setShowLimitModal(true); + } } finally { setIsSubmitting(false); } }; + if (showLimitModal && limitErrorData) { + return ( + { + setShowLimitModal(false); + onClose(); + }} + message={limitErrorData.message} + /> + ); + } + return (
diff --git a/client/src/locales/en.json b/client/src/locales/en.json index fbf0c9d..1743944 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -664,5 +664,9 @@ "arialabel": "Flag of {{label}}", "title": "Flag of {{label}}", "span": "Flag of {{label}}" + }, + "upgradeModal": { + "title": "Limit Reached", + "buttonUpgrade": "Upgrade My Plan" } } diff --git a/client/src/locales/es.json b/client/src/locales/es.json index e362408..a3abdb9 100644 --- a/client/src/locales/es.json +++ b/client/src/locales/es.json @@ -664,5 +664,9 @@ "arialabel": "Bandera de {{label}}", "title": "Bandera de {{label}}", "span": "Bandera de {{label}}" + }, + "upgradeModal": { + "title": "Límite Alcanzado", + "buttonUpgrade": "Mejorar Mi Plan" } } diff --git a/client/src/pages/boards/types.ts b/client/src/pages/boards/types.ts index 6b9d1f0..7d71cf9 100644 --- a/client/src/pages/boards/types.ts +++ b/client/src/pages/boards/types.ts @@ -85,3 +85,7 @@ export interface TaskLabel { labelId: number; label: Label; } + +export interface LimitErrorData { + message: string; +} diff --git a/client/src/store/boards/actions.ts b/client/src/store/boards/actions.ts index 6d9f53a..be77c9b 100644 --- a/client/src/store/boards/actions.ts +++ b/client/src/store/boards/actions.ts @@ -353,8 +353,12 @@ export function getBoardUsers(id: string): AppThunk> { export function addBoard(data: FormData): AppThunk> { return async (dispatch, _getState, { api }) => { - const board = await api.boards.createBoard(data); - dispatch(addBoardFulfilled(board)); + try { + const board = await api.boards.createBoard(data); + dispatch(addBoardFulfilled(board)); + } catch (error) { + throw error as Error; + } }; } diff --git a/server/prisma/migrations/20251015095346_init/migration.sql b/server/prisma/migrations/20251015095346_init/migration.sql new file mode 100644 index 0000000..d7c02a3 --- /dev/null +++ b/server/prisma/migrations/20251015095346_init/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."UserSubscription" ADD COLUMN "autoRenew" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "status" TEXT NOT NULL DEFAULT 'ACTIVE'; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7a27bfe..3b33dae 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -167,6 +167,8 @@ model UserSubscription { currentStorageUsedMB Int @default(0) startDate DateTime @default(now()) endDate DateTime? + autoRenew Boolean @default(false) + status String @default("ACTIVE") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/server/prisma/seed.ts b/server/prisma/seed.ts index 97738f5..002b54d 100644 --- a/server/prisma/seed.ts +++ b/server/prisma/seed.ts @@ -1,11 +1,10 @@ -import { PrismaClient } from "@prisma/client"; +import { PrismaClient, User } from "@prisma/client"; + const prisma = new PrismaClient(); async function main() { - await prisma.subscriptionPlan.upsert({ - where: { name: "Free" }, - update: {}, - create: { + const plans = [ + { name: "Free", maxBoards: 3, maxTasks: 50, @@ -14,12 +13,7 @@ async function main() { aiAgentEnabled: false, storageLimitMB: 200, }, - }); - - await prisma.subscriptionPlan.upsert({ - where: { name: "Pro" }, - update: {}, - create: { + { name: "Pro", maxBoards: null, maxTasks: null, @@ -28,12 +22,7 @@ async function main() { aiAgentEnabled: true, storageLimitMB: 5000, }, - }); - - await prisma.subscriptionPlan.upsert({ - where: { name: "Business" }, - update: {}, - create: { + { name: "Business", maxBoards: null, maxTasks: null, @@ -42,12 +31,56 @@ async function main() { aiAgentEnabled: true, storageLimitMB: 1000000, }, + ]; + + for (const plan of plans) { + await prisma.subscriptionPlan.upsert({ + where: { name: plan.name }, + update: { ...plan }, + create: plan, + }); + } + + console.log("✅ Subscription plans seeded successfully"); + + const freePlan = await prisma.subscriptionPlan.findUnique({ + where: { name: "Free" }, + }); + + if (!freePlan) throw new Error("❌ Plan 'Free' no encontrado"); + + const usersWithoutSub = await prisma.user.findMany({ + where: { subscription: null }, }); + + if (usersWithoutSub.length > 0) { + await prisma.$transaction( + usersWithoutSub.map((user: User) => + prisma.userSubscription.create({ + data: { + userId: user.id, + planId: freePlan.id, + aiDescriptionCount: 0, + currentStorageUsedMB: 0, + startDate: new Date(), + endDate: null, + }, + }), + ), + ); + + console.log( + `✅ Se asignó el plan "Free" a ${usersWithoutSub.length} usuario(s)`, + ); + } else { + console.log("ℹ️ Todos los usuarios ya tienen suscripción"); + } } main() - .then(() => { - console.log("✅ Subscription plans seeded successfully"); + .catch((e) => { + console.error("❌ Error seeding data:", e); }) - .catch(console.error) - .finally(() => prisma.$disconnect()); + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/server/src/middlewares/checkResourceQuantityLimit.ts b/server/src/middlewares/checkResourceQuantityLimit.ts new file mode 100644 index 0000000..5f3f2c4 --- /dev/null +++ b/server/src/middlewares/checkResourceQuantityLimit.ts @@ -0,0 +1,91 @@ +import { Request, Response, NextFunction } from "express"; +import prisma from "../config/db"; +import { PrismaClient } from "@prisma/client"; + +type CountableResourceModel = "board" | "column" | "label" | "card"; + +type CountablePlanLimitField = + | "maxBoards" + | "maxTasks" + | "maxColumns" + | "maxLabels" + | "maxCards"; + +/** + * Función de orden superior que genera un middleware para verificar límites de cantidad de recursos (COUNT). + * + * @param resourceModel El nombre del modelo en Prisma a contar (ej: 'board'). + * @param planLimitField El campo en el objeto 'plan' de la suscripción que contiene el límite (ej: 'maxBoards'). + * @param ownerField El campo en el recurso a contar que indica la propiedad (por defecto 'ownerId'). + * @returns + */ +export const checkResourceQuantityLimit = ( + resourceModel: CountableResourceModel, + planLimitField: CountablePlanLimitField, + ownerField: string = "ownerId", +) => { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const userId = req.apiUserId; + if (!userId) { + return res.status(401).json({ message: "Usuario no autenticado." }); + } + + const subscription = await prisma.userSubscription.findUnique({ + where: { userId }, + include: { plan: true }, + }); + + if (!subscription?.plan) { + return res.status(403).json({ + message: "No tienes un plan activo. Es necesario un plan base.", + errorCode: "NO_ACTIVE_PLAN", + }); + } + + const { plan } = subscription; + const typedPlan = plan as Partial< + Record + >; + const limit = typedPlan[planLimitField]; + + if (limit === null || limit === undefined) { + return next(); + } + + const modelDelegate = prisma[resourceModel as keyof typeof prisma]; + const modelWithCount = modelDelegate as unknown as { + count: (args: { + where: { [key: string]: string | number }; + }) => Promise; + }; + const count = await modelWithCount.count({ + where: { [ownerField]: userId }, + }); + + console.log( + `Verificando límite para ${resourceModel}: Actual ${count} / Límite ${limit}`, + ); + + if (count >= limit) { + return res.status(403).json({ + message: `Has alcanzado el límite de ${limit} ${resourceModel}s permitido para tu plan (${plan.name}).`, + errorCode: "LIMIT_BOARD_REACHED", + limit: limit, + planName: plan.name, + }); + } + + next(); + } catch (error) { + console.error( + `Error en checkResourceQuantityLimit para ${resourceModel}:`, + error, + ); + res.status(500).json({ + message: "Error verificando el límite del plan.", + errorCode: "INTERNAL_SERVER_ERROR", + }); + } + }; +}; diff --git a/server/src/models/SubscriptionModel.ts b/server/src/models/SubscriptionModel.ts new file mode 100644 index 0000000..f3bec67 --- /dev/null +++ b/server/src/models/SubscriptionModel.ts @@ -0,0 +1,101 @@ +import { PrismaClient, UserSubscription } from "@prisma/client"; + +class SubscriptionModel { + private readonly prisma: PrismaClient; + constructor(prisma: PrismaClient) { + this.prisma = prisma; + } + + async findByUserId(userId: number): Promise { + return this.prisma.userSubscription.findUnique({ where: { userId } }); + } + + async createSubscription(data: { + userId: number; + planId: number; + endDate?: Date; + autoRenew?: boolean; + paymentProvider?: string; + externalSubscriptionId?: string; + }): Promise { + return this.prisma.userSubscription.create({ + data, + }); + } + + async cancelSubscription(userId: number): Promise { + return this.prisma.userSubscription.update({ + where: { userId }, + data: { status: "CANCELED" }, + }); + } + + async expireSubscription(userId: number): Promise { + // Útil para un job nocturno que marque expiradas + return this.prisma.userSubscription.update({ + where: { userId }, + data: { status: "EXPIRED" }, + }); + } + + async renewSubscription( + userId: number, + months: number = 1, + ): Promise { + const sub = await this.findByUserId(userId); + if (!sub) return null; + + const newEnd = sub.endDate ? new Date(sub.endDate) : new Date(); + + newEnd.setMonth(newEnd.getMonth() + months); + + return this.prisma.userSubscription.update({ + where: { userId }, + data: { + endDate: newEnd, + status: "ACTIVE", + }, + }); + } + + // Metodo para llamar desde cron y bajar a free los que tengan cancelada la subs + async downgradeExpiredSubscriptionsToFree(): Promise { + const now = new Date(); + + const freePlan = await this.prisma.subscriptionPlan.findUnique({ + where: { name: "Free" }, + }); + if (!freePlan) + throw new Error("Plan Free no encontrado en la base de datos."); + + const expiredSubs = await this.prisma.userSubscription.findMany({ + where: { + endDate: { lt: now }, + plan: { name: { not: "Free" } }, + }, + select: { userId: true }, + }); + + const expiredUserIds: { userId: number }[] = expiredSubs; + + if (expiredUserIds.length === 0) return 0; + + await Promise.all( + expiredUserIds.map(({ userId }: { userId: number }) => + this.prisma.userSubscription.update({ + where: { userId }, + data: { + planId: freePlan.id, + endDate: null, + aiDescriptionCount: 0, + currentStorageUsedMB: 0, + }, + }), + ), + ); + + return expiredUserIds.length; + } +} + +export default SubscriptionModel; diff --git a/server/src/routes/boards.routes.ts b/server/src/routes/boards.routes.ts index b46cfe7..20071a2 100644 --- a/server/src/routes/boards.routes.ts +++ b/server/src/routes/boards.routes.ts @@ -8,6 +8,7 @@ import AuthService from "../services/AuthService"; import AuthModel from "../models/AuthModel"; import { processImage, upload } from "../lib/uploadConfigure"; import CardModel from "../models/CardModel"; +import { checkResourceQuantityLimit } from "../middlewares/checkResourceQuantityLimit"; const router = Router(); @@ -24,6 +25,7 @@ router.get("/:slug", jwtAuth.guard, controller.get); router.post( "/", jwtAuth.guard, + checkResourceQuantityLimit("board", "maxBoards"), upload.single("image"), processImage( "boards", diff --git a/server/src/services/SubscriptionService.ts b/server/src/services/SubscriptionService.ts new file mode 100644 index 0000000..4319462 --- /dev/null +++ b/server/src/services/SubscriptionService.ts @@ -0,0 +1,31 @@ +import SubscriptionModel from "../models/SubscriptionModel"; + +class SubscriptionService { + private readonly subscriptionModel: SubscriptionModel; + constructor(subscriptionModel: SubscriptionModel) { + this.subscriptionModel = subscriptionModel; + } + + getSubscription(userId: number) { + return this.subscriptionModel.findByUserId(userId); + } + + create(userId: number, planId: number, endDate?: Date, autoRenew = false) { + return this.subscriptionModel.createSubscription({ + userId, + planId, + endDate, + autoRenew, + }); + } + + cancel(userId: number) { + return this.subscriptionModel.cancelSubscription(userId); + } + + renew(userId: number, months: number = 1) { + return this.subscriptionModel.renewSubscription(userId, months); + } +} + +export default SubscriptionService; From 4c4b28835b4f41c7a16de535b035e739ff2f7523 Mon Sep 17 00:00:00 2001 From: oscar Date: Thu, 16 Oct 2025 10:56:18 +0200 Subject: [PATCH 3/8] controlar en thunk errror por limite en suscripcion --- client/src/components/ui/modals/new-board.tsx | 99 ++++++++----------- client/src/pages/boards/types.ts | 1 + client/src/store/boards/actions.ts | 38 ++++++- client/src/store/boards/reducer.ts | 3 + client/src/store/types/defaultStates.ts | 5 +- 5 files changed, 84 insertions(+), 62 deletions(-) diff --git a/client/src/components/ui/modals/new-board.tsx b/client/src/components/ui/modals/new-board.tsx index fc8ef2b..a59084a 100644 --- a/client/src/components/ui/modals/new-board.tsx +++ b/client/src/components/ui/modals/new-board.tsx @@ -6,13 +6,13 @@ import CloseButton from "../close-button"; import "./modal-boards.css"; import { Button } from "../Button"; import { addBoard } from "../../../store/boards/actions"; -import { useAppDispatch } from "../../../store"; +import { useAppDispatch, useAppSelector } from "../../../store"; import { SpinnerLoadingText } from "../Spinner"; import toast from "react-hot-toast"; import { CustomToast } from "../../CustomToast"; -import type { AxiosError } from "axios"; import UpgradeModal from "./UpgradeModal"; import type { LimitErrorData } from "../../../pages/boards/types"; +import { useUiResetError } from "../../../store/boards/hooks"; interface NewBoardProps { onClose: () => void; @@ -22,14 +22,18 @@ const NewBoard = ({ onClose }: NewBoardProps) => { const { t: translation } = useTranslation(); const [titleInput, setTitleInput] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); - const [showLimitModal, setShowLimitModal] = useState(false); - const [limitErrorData, setLimitErrorData] = useState( - null, - ); const dispatch = useAppDispatch(); const fileRef = useRef(null); + const resetError = useUiResetError(); + + const limitErrorData = useAppSelector( + (state) => state.ui.error as LimitErrorData | null, + ); - const isDisabled = !titleInput && isSubmitting; + const handleClose = () => { + resetError(); + onClose(); + }; const handleTitleChange = (event: ChangeEvent) => { setTitleInput(event.target.value); @@ -38,66 +42,47 @@ const NewBoard = ({ onClose }: NewBoardProps) => { const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - try { - const boardData = new FormData(); - boardData.append("title", titleInput); - const file = fileRef.current?.files?.[0]; - - if (isSubmitting) return; - setIsSubmitting(true); - - if (file) { - const maxSizeMB = 5; - - if (file.size > maxSizeMB * 1024 * 1024) { - toast.custom((t) => ( - - )); - - return; - } - - boardData.append("image", file); + if (isSubmitting) return; + setIsSubmitting(true); + resetError(); + + const boardData = new FormData(); + boardData.append("title", titleInput); + + const file = fileRef.current?.files?.[0]; + if (file) { + const maxSizeMB = 5; + if (file.size > maxSizeMB * 1024 * 1024) { + toast.custom((t) => ( + + )); + setIsSubmitting(false); + return; } + boardData.append("image", file); + } - await dispatch(addBoard(boardData)); - onClose(); // Cierra el modal solo si la creación fue exitosa - } catch (error) { - console.error(translation("newBoard.error"), error); - const apiError = error as AxiosError; - const status = apiError.response?.status; - const errorData = apiError.response?.data; - - if (status === 403 && errorData) { - const limitData = errorData as LimitErrorData; - setLimitErrorData(limitData); - setShowLimitModal(true); - } - } finally { - setIsSubmitting(false); + const success = await dispatch(addBoard(boardData)); + if (success) { + onClose(); } + setIsSubmitting(false); }; - if (showLimitModal && limitErrorData) { + if (limitErrorData?.errorCode === "LIMIT_BOARD_REACHED") { return ( - { - setShowLimitModal(false); - onClose(); - }} - message={limitErrorData.message} - /> + ); } return (
- +

{translation("newBoard.form.header")}

@@ -113,6 +98,7 @@ const NewBoard = ({ onClose }: NewBoardProps) => { onChange={handleTitleChange} />
+
{ ref={fileRef} />
+
+ } + > + + + + } + /> + + + + } + /> + + + +
+ } + > + + + + } + /> + } > - + - - } - /> - - - - } - /> - + } + /> + } > - + - - } - /> - - - - } - > - - - } - /> - - - - } - > - - - } - /> - } /> - } /> - } /> - } /> - } /> - + } + /> + } /> + } /> + } /> + } /> + } /> + - {/* Backoffice routes */} - - - - } - > - - - - } - > - - - } - /> + {/* Backoffice routes */} - - - } - > - - - - + + + } - /> - - - - - } - > + > + + + + } + > + + + } + /> + + + + } + > + + + + + } + /> + - - - } - > - - + + + } - /> - - + > + + + + } + > + + + } + /> + + + + ); } diff --git a/client/src/components/ui/modals/UpgradeModalContainer.tsx b/client/src/components/ui/modals/UpgradeModalContainer.tsx new file mode 100644 index 0000000..c77ba0e --- /dev/null +++ b/client/src/components/ui/modals/UpgradeModalContainer.tsx @@ -0,0 +1,19 @@ +import { useAppSelector } from "../../../store"; +import UpgradeModal from "./UpgradeModal"; +import { useUiResetError } from "../../../store/boards/hooks"; +import type { LimitErrorData } from "../../../pages/boards/types"; + +export default function UpgradeModalContainer() { + const limitErrorData = useAppSelector( + (state) => state.ui.error as LimitErrorData | null, + ); + const resetError = useUiResetError(); + + if (!limitErrorData) return null; + + const handleClose = () => resetError(); + + return ( + + ); +} diff --git a/client/src/components/ui/modals/new-board.tsx b/client/src/components/ui/modals/new-board.tsx index a59084a..b557b8e 100644 --- a/client/src/components/ui/modals/new-board.tsx +++ b/client/src/components/ui/modals/new-board.tsx @@ -6,12 +6,10 @@ import CloseButton from "../close-button"; import "./modal-boards.css"; import { Button } from "../Button"; import { addBoard } from "../../../store/boards/actions"; -import { useAppDispatch, useAppSelector } from "../../../store"; +import { useAppDispatch } from "../../../store"; import { SpinnerLoadingText } from "../Spinner"; import toast from "react-hot-toast"; import { CustomToast } from "../../CustomToast"; -import UpgradeModal from "./UpgradeModal"; -import type { LimitErrorData } from "../../../pages/boards/types"; import { useUiResetError } from "../../../store/boards/hooks"; interface NewBoardProps { @@ -26,10 +24,6 @@ const NewBoard = ({ onClose }: NewBoardProps) => { const fileRef = useRef(null); const resetError = useUiResetError(); - const limitErrorData = useAppSelector( - (state) => state.ui.error as LimitErrorData | null, - ); - const handleClose = () => { resetError(); onClose(); @@ -66,19 +60,11 @@ const NewBoard = ({ onClose }: NewBoardProps) => { boardData.append("image", file); } - const success = await dispatch(addBoard(boardData)); - if (success) { - onClose(); - } + await dispatch(addBoard(boardData)); setIsSubmitting(false); + onClose(); }; - if (limitErrorData?.errorCode === "LIMIT_BOARD_REACHED") { - return ( - - ); - } - return (
diff --git a/client/src/store/boards/actions.ts b/client/src/store/boards/actions.ts index 64a9d41..d7ab907 100644 --- a/client/src/store/boards/actions.ts +++ b/client/src/store/boards/actions.ts @@ -368,22 +368,17 @@ export function getBoardUsers(id: string): AppThunk> { }; } -export function addBoard( - data: FormData, -): AppThunk> { +export function addBoard(data: FormData): AppThunk> { return async (dispatch, _getState, { api }) => { try { const board = await api.boards.createBoard(data); dispatch(addBoardFulfilled(board)); - return true; } catch (error) { if (error instanceof AxiosError && error.response?.status === 403) { const errorData = error.response.data as LimitErrorData; if (errorData.errorCode === "LIMIT_BOARD_REACHED") { dispatch(boardLimitReached(errorData)); - - return false; } } } @@ -454,8 +449,18 @@ export function addTask( data: Partial, ): AppThunk> { return async (dispatch, _getState, { api }) => { - const task = await api.boards.createTask(columnId, data); - dispatch(addTaskFulfilled(columnId, task)); + try { + const task = await api.boards.createTask(columnId, data); + dispatch(addTaskFulfilled(columnId, task)); + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 403) { + const errorData = error.response.data as LimitErrorData; + + if (errorData.errorCode === "LIMIT_TASK_REACHED") { + dispatch(boardLimitReached(errorData)); + } + } + } }; } diff --git a/server/src/middlewares/checkResourceLimit.ts b/server/src/middlewares/checkResourceLimit.ts new file mode 100644 index 0000000..9b06894 --- /dev/null +++ b/server/src/middlewares/checkResourceLimit.ts @@ -0,0 +1,100 @@ +import { Request, Response, NextFunction } from "express"; +import prisma from "../config/db"; + +export const checkBoardLimit = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const userId = req.apiUserId; + if (!userId) + return res.status(401).json({ message: "Usuario no autenticado." }); + + const subscription = await prisma.userSubscription.findUnique({ + where: { userId }, + include: { plan: true }, + }); + + if (!subscription?.plan) { + return res.status(403).json({ + message: "No tienes un plan activo. Es necesario un plan base.", + errorCode: "NO_ACTIVE_PLAN", + }); + } + + const limit = subscription.plan.maxBoards; + if (limit === null || limit === undefined) return next(); + + const count = await prisma.board.count({ where: { ownerId: userId } }); + + if (count >= limit) { + return res.status(403).json({ + message: `Has alcanzado el límite de ${limit} tableros para tu plan (${subscription.plan.name}).`, + errorCode: "LIMIT_BOARD_REACHED", + limit, + planName: subscription.plan.name, + }); + } + + next(); + } catch (error) { + console.error("Error en checkBoardLimit:", error); + res.status(500).json({ + message: "Error verificando límite de tableros.", + errorCode: "INTERNAL_SERVER_ERROR", + }); + } +}; + +export const checkTaskLimit = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const userId = req.apiUserId; + if (!userId) + return res.status(401).json({ message: "Usuario no autenticado." }); + + const subscription = await prisma.userSubscription.findUnique({ + where: { userId }, + include: { plan: true }, + }); + + if (!subscription?.plan) { + return res.status(403).json({ + message: "No tienes un plan activo. Es necesario un plan base.", + errorCode: "NO_ACTIVE_PLAN", + }); + } + + const limit = subscription.plan.maxTasks; + if (limit === null || limit === undefined) return next(); + + const count = await prisma.card.count({ + where: { + list: { + board: { ownerId: userId }, + }, + }, + }); + + if (count >= limit) { + return res.status(403).json({ + message: `Has alcanzado el límite de ${limit} tareas para tu plan (${subscription.plan.name}).`, + errorCode: "LIMIT_TASK_REACHED", + limit, + planName: subscription.plan.name, + }); + } + + next(); + } catch (error) { + console.error("Error en checkTaskLimit:", error); + res.status(500).json({ + message: "Error verificando límite de tareas.", + errorCode: "INTERNAL_SERVER_ERROR", + }); + } +}; diff --git a/server/src/middlewares/checkResourceQuantityLimit.ts b/server/src/middlewares/checkResourceQuantityLimit.ts deleted file mode 100644 index 5f3f2c4..0000000 --- a/server/src/middlewares/checkResourceQuantityLimit.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import prisma from "../config/db"; -import { PrismaClient } from "@prisma/client"; - -type CountableResourceModel = "board" | "column" | "label" | "card"; - -type CountablePlanLimitField = - | "maxBoards" - | "maxTasks" - | "maxColumns" - | "maxLabels" - | "maxCards"; - -/** - * Función de orden superior que genera un middleware para verificar límites de cantidad de recursos (COUNT). - * - * @param resourceModel El nombre del modelo en Prisma a contar (ej: 'board'). - * @param planLimitField El campo en el objeto 'plan' de la suscripción que contiene el límite (ej: 'maxBoards'). - * @param ownerField El campo en el recurso a contar que indica la propiedad (por defecto 'ownerId'). - * @returns - */ -export const checkResourceQuantityLimit = ( - resourceModel: CountableResourceModel, - planLimitField: CountablePlanLimitField, - ownerField: string = "ownerId", -) => { - return async (req: Request, res: Response, next: NextFunction) => { - try { - const userId = req.apiUserId; - if (!userId) { - return res.status(401).json({ message: "Usuario no autenticado." }); - } - - const subscription = await prisma.userSubscription.findUnique({ - where: { userId }, - include: { plan: true }, - }); - - if (!subscription?.plan) { - return res.status(403).json({ - message: "No tienes un plan activo. Es necesario un plan base.", - errorCode: "NO_ACTIVE_PLAN", - }); - } - - const { plan } = subscription; - const typedPlan = plan as Partial< - Record - >; - const limit = typedPlan[planLimitField]; - - if (limit === null || limit === undefined) { - return next(); - } - - const modelDelegate = prisma[resourceModel as keyof typeof prisma]; - const modelWithCount = modelDelegate as unknown as { - count: (args: { - where: { [key: string]: string | number }; - }) => Promise; - }; - const count = await modelWithCount.count({ - where: { [ownerField]: userId }, - }); - - console.log( - `Verificando límite para ${resourceModel}: Actual ${count} / Límite ${limit}`, - ); - - if (count >= limit) { - return res.status(403).json({ - message: `Has alcanzado el límite de ${limit} ${resourceModel}s permitido para tu plan (${plan.name}).`, - errorCode: "LIMIT_BOARD_REACHED", - limit: limit, - planName: plan.name, - }); - } - - next(); - } catch (error) { - console.error( - `Error en checkResourceQuantityLimit para ${resourceModel}:`, - error, - ); - res.status(500).json({ - message: "Error verificando el límite del plan.", - errorCode: "INTERNAL_SERVER_ERROR", - }); - } - }; -}; diff --git a/server/src/routes/boards.routes.ts b/server/src/routes/boards.routes.ts index 20071a2..0db9910 100644 --- a/server/src/routes/boards.routes.ts +++ b/server/src/routes/boards.routes.ts @@ -8,7 +8,7 @@ import AuthService from "../services/AuthService"; import AuthModel from "../models/AuthModel"; import { processImage, upload } from "../lib/uploadConfigure"; import CardModel from "../models/CardModel"; -import { checkResourceQuantityLimit } from "../middlewares/checkResourceQuantityLimit"; +import { checkBoardLimit } from "../middlewares/checkResourceLimit"; const router = Router(); @@ -25,7 +25,7 @@ router.get("/:slug", jwtAuth.guard, controller.get); router.post( "/", jwtAuth.guard, - checkResourceQuantityLimit("board", "maxBoards"), + checkBoardLimit, upload.single("image"), processImage( "boards", diff --git a/server/src/routes/card.routes.ts b/server/src/routes/card.routes.ts index 3667519..731df2f 100644 --- a/server/src/routes/card.routes.ts +++ b/server/src/routes/card.routes.ts @@ -7,6 +7,7 @@ import * as jwtAuth from "../middlewares/jwtAuthMiddleware"; import { validateCard } from "../validators/cardValidators"; import { cardCreateSchema, cardUpdateSchema } from "../validators/cardSchema"; import { uploadMedia } from "../lib/uploadConfigure"; +import { checkTaskLimit } from "../middlewares/checkResourceLimit"; const router = Router(); @@ -19,6 +20,7 @@ router.get("/:id", jwtAuth.guard, controller.getCard); router.post( "/", jwtAuth.guard, + checkTaskLimit, validateCard(cardCreateSchema), controller.addCard, ); From 5d0e786cac56707bcd7f55269227cad02148c8e6 Mon Sep 17 00:00:00 2001 From: oscar Date: Thu, 16 Oct 2025 16:08:52 +0200 Subject: [PATCH 5/8] limite miembros tablero --- .../src/components/ui/modals/UpgradeModal.tsx | 2 +- .../src/components/ui/modals/share-board.tsx | 26 +++--- client/src/pages/boards/boards-list.tsx | 2 +- client/src/pages/login/login.tsx | 2 +- client/src/store/boards/actions.ts | 30 +++++++ client/src/utils/useAcceptInvitation.ts | 11 ++- server/src/middlewares/checkResourceLimit.ts | 83 +++++++++++++++++++ server/src/routes/boards.routes.ts | 19 ++++- 8 files changed, 153 insertions(+), 22 deletions(-) diff --git a/client/src/components/ui/modals/UpgradeModal.tsx b/client/src/components/ui/modals/UpgradeModal.tsx index 4d66cb7..391e694 100644 --- a/client/src/components/ui/modals/UpgradeModal.tsx +++ b/client/src/components/ui/modals/UpgradeModal.tsx @@ -62,7 +62,7 @@ const UpgradeModal = ({ onClose, message }: UpgradeModalProps) => {