+ + + + + + + + + + + + {t("upgradeModal.title")} + + + {message} + + + + {t("upgradeModal.buttonUpgrade")} + + + +
- + {translation("newBoard.form.header")} @@ -85,6 +84,7 @@ const NewBoard = ({ onClose }: NewBoardProps) => { onChange={handleTitleChange} /> + { ref={fileRef} /> + {isSubmitting ? ( { "idle" | "loading" | "succeeded" | "failed" >("idle"); const [error, setError] = useState(null); + const dispatch = useAppDispatch(); const handleCopyLink = () => { if (invitationLink) { @@ -29,29 +31,23 @@ const ShareBoard = ({ boardId, handleHideMessage }: ShareBoardProps) => { const handleGenerateLink = async () => { if (!boardId) { - const setErrorMsgId = t("share.error.id"); - setError(setErrorMsgId); + setError(t("share.error.id")); return; } setStatus("loading"); setError(null); - try { - const response = await createInvitationLink(boardId); - const token = response.token; - const FE_BASE_URL = window.location.origin; - let fullInvitationUrl = `${FE_BASE_URL}/invitacion?token=${token}&username=${response.inviterName}&title=${response.boardTitle}&boardId=${response.boardId}&boardSlug=${response.slug}`; - if (response.inviterPhoto) { - fullInvitationUrl += `&photo=${response.inviterPhoto}`; - } + const fullInvitationUrl = await (dispatch( + shareBoard(boardId), + ) as unknown as Promise); + + if (fullInvitationUrl) { setInvitationLink(fullInvitationUrl); setStatus("succeeded"); - } catch (err) { - const setErrorMsgLink = t("share.error.link"); + } else { setStatus("failed"); - setError(setErrorMsgLink); - console.error(err); + handleClose(); } }; 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/boards-list.tsx b/client/src/pages/boards/boards-list.tsx index 5ad38c7..33ef540 100644 --- a/client/src/pages/boards/boards-list.tsx +++ b/client/src/pages/boards/boards-list.tsx @@ -57,7 +57,7 @@ const BoardsList = () => { Lista de tableros - {!boards.length ? ( + {boards.length == 0 ? ( {t("emptyList.p1")} diff --git a/client/src/pages/boards/types.ts b/client/src/pages/boards/types.ts index 6b9d1f0..3970e35 100644 --- a/client/src/pages/boards/types.ts +++ b/client/src/pages/boards/types.ts @@ -85,3 +85,8 @@ export interface TaskLabel { labelId: number; label: Label; } + +export interface LimitErrorData { + message: string; + errorCode: string; +} diff --git a/client/src/pages/login/login.tsx b/client/src/pages/login/login.tsx index 2331f79..82e53d6 100644 --- a/client/src/pages/login/login.tsx +++ b/client/src/pages/login/login.tsx @@ -88,7 +88,7 @@ export const LoginPage = () => { }, [searchParams]); useEffect(() => { - const params = new URLSearchParams(window.location.search); + const params = new URLSearchParams(globalThis.location.search); const token = searchParams.get("token"); const userEncoded = params.get("user"); if (token) { diff --git a/client/src/store/boards/actions.ts b/client/src/store/boards/actions.ts index 6d9f53a..89b0e8f 100644 --- a/client/src/store/boards/actions.ts +++ b/client/src/store/boards/actions.ts @@ -1,6 +1,12 @@ import type { AppThunk } from ".."; import type { User } from "../../pages/login/types"; -import type { Board, Column, Label, Task } from "../../pages/boards/types"; +import type { + Board, + Column, + Label, + LimitErrorData, + Task, +} from "../../pages/boards/types"; import type { DropResult } from "@hello-pangea/dnd"; import type { AuthActions, AuthActionsRejected } from "../auth/actions"; import type { @@ -9,6 +15,7 @@ import type { UserActions, UserActionsRejected, } from "../profile/actions"; +import { AxiosError } from "axios"; // // ─── BOARDS ────────────────────────────────────────────── @@ -125,6 +132,11 @@ type RemoveLabelFromCardFulfilled = { payload: { taskId: string; labelId: string }; }; +type BoardLimitReached = { + type: "boards/limitReached"; + payload: LimitErrorData; +}; + export const fetchBoardsPending = (): FetchBoardsPending => ({ type: "boards/fetchBoards/pending", }); @@ -235,6 +247,11 @@ export const deleteTaskFulfilled = ( payload: { columnId, taskId }, }); +export const boardLimitReached = (data: LimitErrorData): BoardLimitReached => ({ + type: "boards/limitReached", + payload: data, +}); + // ─── ASSIGNEES ────────────────────────────────────────────── type AddAssigneeFulfilled = { @@ -353,8 +370,18 @@ 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) { + if (error instanceof AxiosError && error.response?.status === 403) { + const errorData = error.response.data as LimitErrorData; + + if (errorData.errorCode === "LIMIT_BOARD_REACHED") { + dispatch(boardLimitReached(errorData)); + } + } + } }; } @@ -381,6 +408,36 @@ export function editBoard( }; } +export function shareBoard( + boardId: string, +): AppThunk> { + return async (dispatch, _getState, { api }) => { + try { + const response = await api.boards.createInvitationLink(boardId); + + const token = response.token; + const FE_BASE_URL = globalThis.location.origin; + + let fullInvitationUrl = `${FE_BASE_URL}/invitacion?token=${token}&username=${response.inviterName}&title=${response.boardTitle}&boardId=${response.boardId}&boardSlug=${response.slug}`; + + if (response.inviterPhoto) { + fullInvitationUrl += `&photo=${response.inviterPhoto}`; + } + + return fullInvitationUrl; + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 403) { + const errorData = error.response.data as LimitErrorData; + + if (errorData.errorCode === "LIMIT_MEMBERS_REACHED") { + dispatch(boardLimitReached(errorData)); + } + } + return undefined; + } + }; +} + export function addColumn( boardId: string, data: Column, @@ -422,8 +479,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)); + } + } + } }; } @@ -437,7 +504,16 @@ export function editTask( const updatedTask = await api.boards.updateTask(taskId, data); dispatch(editTaskFulfilled(columnId, updatedTask)); } catch (error) { - if (error instanceof Error) { + if (error instanceof AxiosError && error.response?.status === 403) { + const errorData = error.response.data as LimitErrorData; + + if ( + errorData.errorCode === "LIMIT_STORAGE_REACHED" || + errorData.errorCode == "LIMIT_AI_DESCRIPTION_REACHED" + ) { + dispatch(boardLimitReached(errorData)); + } + } else if (error instanceof Error) { dispatch(editTaskRejected(error)); } } @@ -553,6 +629,7 @@ export type Actions = | AddLabelToCardFulfilled | AddLabelFulfilled | RemoveLabelFromCardFulfilled + | BoardLimitReached | UiResetError; export type ActionsRejected = @@ -563,4 +640,5 @@ export type ActionsRejected = | FetchBoardRejected | EditColumnRejected | EditTaskRejected - | GetBoardUsersRejected; + | GetBoardUsersRejected + | BoardLimitReached; diff --git a/client/src/store/boards/reducer.ts b/client/src/store/boards/reducer.ts index adaf9dd..68703b3 100644 --- a/client/src/store/boards/reducer.ts +++ b/client/src/store/boards/reducer.ts @@ -342,6 +342,9 @@ export function ui( if (action.type.endsWith("/fulfilled")) { return { pending: false, error: null }; } + if (action.type === "boards/limitReached") { + return { ...state, pending: false, error: action.payload }; + } if (isRejectedAction(action)) { return { pending: false, error: action.payload }; } diff --git a/client/src/store/types/defaultStates.ts b/client/src/store/types/defaultStates.ts index bdf4a07..41d4701 100644 --- a/client/src/store/types/defaultStates.ts +++ b/client/src/store/types/defaultStates.ts @@ -1,4 +1,4 @@ -import type { Board } from "../../pages/boards/types"; +import type { Board, LimitErrorData } from "../../pages/boards/types"; import type { User } from "../../pages/login/types"; import type { ProfileType } from "../../pages/profile/types"; @@ -28,6 +28,7 @@ export type ProfileState = { }; }; +type UIError = Error | LimitErrorData | null; export type BoardsState = { auth: { user: User | null; @@ -46,6 +47,6 @@ export type BoardsState = { }; ui: { pending: boolean; - error: Error | null; + error: UIError; }; }; diff --git a/client/src/utils/useAcceptInvitation.ts b/client/src/utils/useAcceptInvitation.ts index 9ed1090..0c42dcd 100644 --- a/client/src/utils/useAcceptInvitation.ts +++ b/client/src/utils/useAcceptInvitation.ts @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useAppSelector } from "../store"; import { useNavigate } from "react-router-dom"; import { acceptInvitation } from "../pages/boards/service"; +import { AxiosError } from "axios"; export const useAcceptInvitation = () => { const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated); @@ -17,7 +18,15 @@ export const useAcceptInvitation = () => { .then(() => { navigate(`/boards/${boardSlug}`, { replace: true }); }) - .catch(() => { + .catch((error) => { + if ( + error instanceof AxiosError && + error.response?.status === 403 && + error.response?.data?.errorCode === "LIMIT_MEMBERS_REACHED" + ) { + return; + } + navigate("/boards", { replace: true }); }) .finally(() => { diff --git a/server/package.json b/server/package.json index 604b98c..72cc907 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/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/migrations/20251016163647_init/migration.sql b/server/prisma/migrations/20251016163647_init/migration.sql new file mode 100644 index 0000000..58c59ee --- /dev/null +++ b/server/prisma/migrations/20251016163647_init/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "public"."Media" ADD COLUMN "sizeMB" DOUBLE PRECISION NOT NULL DEFAULT 0.00; + +-- AlterTable +ALTER TABLE "public"."SubscriptionPlan" ALTER COLUMN "storageLimitMB" SET DATA TYPE DOUBLE PRECISION; + +-- AlterTable +ALTER TABLE "public"."UserSubscription" ALTER COLUMN "currentStorageUsedMB" SET DEFAULT 0.00, +ALTER COLUMN "currentStorageUsedMB" SET DATA TYPE DOUBLE PRECISION; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 64e842b..e91cab2 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[] } @@ -137,8 +138,40 @@ model Media { fileType String card Card @relation(fields: [cardId], references: [id], onDelete: Cascade) cardId Int + sizeMB Float @default(0.00) createdAt DateTime @default(now()) 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 Float? // 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 Float @default(0.00) + 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 new file mode 100644 index 0000000..002b54d --- /dev/null +++ b/server/prisma/seed.ts @@ -0,0 +1,86 @@ +import { PrismaClient, User } from "@prisma/client"; + +const prisma = new PrismaClient(); + +async function main() { + const plans = [ + { + name: "Free", + maxBoards: 3, + maxTasks: 50, + maxMembersPerBoard: 3, + aiDescriptionLimit: 5, + aiAgentEnabled: false, + storageLimitMB: 200, + }, + { + name: "Pro", + maxBoards: null, + maxTasks: null, + maxMembersPerBoard: 20, + aiDescriptionLimit: 30, + aiAgentEnabled: true, + storageLimitMB: 5000, + }, + { + name: "Business", + maxBoards: null, + maxTasks: null, + maxMembersPerBoard: null, + aiDescriptionLimit: null, + 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() + .catch((e) => { + console.error("❌ Error seeding data:", e); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/server/src/controllers/cardController.ts b/server/src/controllers/cardController.ts index 5c307df..69acb5c 100644 --- a/server/src/controllers/cardController.ts +++ b/server/src/controllers/cardController.ts @@ -1,8 +1,8 @@ import { NextFunction, Request, Response } from "express"; import CardService from "../services/CardService"; import createHttpError from "http-errors"; -import fs from "fs"; -import path from "path"; +import fs from "node:fs"; +import path from "node:path"; const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads"); @@ -74,14 +74,51 @@ export class CardController { } const uploadedFileNames: string[] = []; + let totalSizeAddedMB = 0; + let totalSizeRemovedMB = 0; try { + if (removeMediaId !== undefined) { + const mediaAttachment = + await this.cardService.getMediaAttachmentDetails(removeMediaId); + + if (mediaAttachment) { + totalSizeRemovedMB = mediaAttachment.sizeMB; + const fullPath = path.join( + UPLOAD_DIR, + String(cardId), + mediaAttachment.fileName, + ); + + try { + if (fs.existsSync(fullPath)) { + fs.unlinkSync(fullPath); + } + } catch (e) { + console.error( + `[FS ERROR] Error al eliminar el archivo ${fullPath}:`, + e, + ); + } + + await this.cardService.removeMediaFromCard( + userId, + cardId, + removeMediaId, + ); + } + } + if (files && files.length > 0) { const mediaFilesData: { url: string; fileName: string; fileType: "document" | "audio"; + sizeMB: number; }[] = files.map((file) => { + const fileSizeMB = file.size / (1024 * 1024); + totalSizeAddedMB += fileSizeMB; + const fileUrl = `/uploads/boards/${cardId}/${file.filename}`; uploadedFileNames.push(file.filename); @@ -93,26 +130,25 @@ export class CardController { url: fileUrl, fileName: file.originalname, fileType: fileType, + sizeMB: fileSizeMB, }; }); await this.cardService.addMediaToCard(cardId, mediaFilesData); } - if (removeMediaId !== undefined) { - await this.cardService.removeMediaFromCard( - userId, - cardId, - removeMediaId, - ); - } - const updatedCard = await this.cardService.updateCard( userId, cardId, data, ); + const deltaSize = totalSizeAddedMB - totalSizeRemovedMB; + console.log(deltaSize, totalSizeAddedMB, totalSizeRemovedMB); + if (deltaSize !== 0) { + await this.cardService.updateStorageUsedMB(userId, deltaSize); + } + res.json(updatedCard); } catch (err) { if (err instanceof Error && err.message.includes("permiso")) { diff --git a/server/src/middlewares/checkResourceLimit.ts b/server/src/middlewares/checkResourceLimit.ts new file mode 100644 index 0000000..408de69 --- /dev/null +++ b/server/src/middlewares/checkResourceLimit.ts @@ -0,0 +1,319 @@ +import { Request, Response, NextFunction } from "express"; +import prisma from "../config/db"; +import jwt from "jsonwebtoken"; + +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 currentUserId = req.apiUserId; + if (!currentUserId) + return res.status(401).json({ message: "Usuario no autenticado." }); + + let boardId: number | undefined; + + if (req.params.boardId) { + boardId = Number(req.params.boardId); + } else if (req.body.listId) { + const listId = Number(req.body.listId); + const list = await prisma.list.findUnique({ + where: { id: listId }, + select: { boardId: true }, + }); + if (list) boardId = list.boardId; + } + + if (!boardId) { + return res + .status(400) + .json({ message: "No se pudo determinar el ID del tablero." }); + } + + const board = await prisma.board.findUnique({ + where: { id: boardId }, + select: { ownerId: true, title: true }, + }); + + if (!board) { + return res.status(404).json({ message: "Tablero no encontrado." }); + } + + const subscription = await prisma.userSubscription.findUnique({ + where: { userId: board.ownerId }, + include: { plan: true }, + }); + + if (!subscription?.plan) { + return res.status(403).json({ + message: "El propietario de este tablero no tiene un plan activo.", + errorCode: "NO_ACTIVE_PLAN", + }); + } + + const limit = subscription.plan.maxTasks; + const planName = subscription.plan.name; + + if (limit === null || limit === undefined) return next(); + + const count = await prisma.card.count({ + where: { + list: { + boardId: boardId, + }, + }, + }); + + if (count >= limit) { + return res.status(403).json({ + message: `El tablero "${board.title}" ha alcanzado el límite de ${limit} tareas permitido por el plan (${planName}) del propietario.`, + errorCode: "LIMIT_TASK_REACHED", + limit: limit, + planName: planName, + }); + } + + next(); + } catch (error) { + console.error("Error en checkTaskLimit:", error); + res.status(500).json({ + message: "Error verificando límite de tareas.", + errorCode: "INTERNAL_SERVER_ERROR", + }); + } +}; + +export const checkBoardMembersLimit = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const userId = req.apiUserId; + let boardId: number | undefined; + if (req.body?.boardId) { + boardId = Number(req.body.boardId); + } else if (req.params?.id) { + boardId = Number(req.params.id); + } else if (req.body?.token) { + const JWT_SECRET = process.env.JWT_SECRET; + if (!JWT_SECRET) { + throw new Error( + "JWT_SECRET no está configurado en las variables de entorno", + ); + } + + try { + const payload = jwt.verify(req.body.token, JWT_SECRET) as { + boardId?: number; + }; + if (payload.boardId) boardId = Number(payload.boardId); + } catch (err) { + return res + .status(400) + .json({ message: "Token de invitación inválido." }); + } + } + + if (!userId) + return res.status(401).json({ message: "Usuario no autenticado." }); + if (!boardId) + return res.status(400).json({ message: "Falta boardId en la petición." }); + + const board = await prisma.board.findUnique({ + where: { id: boardId }, + }); + + if (!board) + return res.status(404).json({ message: "Board no encontrado." }); + + const subscription = await prisma.userSubscription.findUnique({ + where: { userId: board.ownerId }, + include: { plan: true }, + }); + + if (!subscription?.plan) { + return res.status(403).json({ + message: "El propietario de este tablero no tiene un plan activo.", + errorCode: "NO_ACTIVE_PLAN", + }); + } + + const limit = subscription.plan.maxMembersPerBoard; + if (limit === null || limit === undefined) return next(); + + const memberCount = await prisma.boardMember.count({ + where: { boardId }, + }); + + if (memberCount >= limit) { + return res.status(403).json({ + message: `Este tablero ya tiene el límite de ${limit} miembros según tu plan (${subscription.plan.name}).`, + errorCode: "LIMIT_MEMBERS_REACHED", + limit, + planName: subscription.plan.name, + }); + } + + next(); + } catch (error) { + console.error("Error en checkBoardMembersLimit:", error); + res.status(500).json({ + message: "Error verificando límite de miembros del tablero.", + errorCode: "INTERNAL_SERVER_ERROR", + }); + } +}; + +export const checkStorageLimit = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const userId = req.apiUserId; + if (!userId) + return res.status(401).json({ message: "Usuario no autenticado." }); + + const isMultipart = req.headers["content-type"]?.includes( + "multipart/form-data", + ); + if (!isMultipart) return next(); + + 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.storageLimitMB; + + const currentUsage = subscription.currentStorageUsedMB; + + if (limit === null || limit === undefined) return next(); + + if (currentUsage >= limit) { + return res.status(403).json({ + message: `Has alcanzado el límite de almacenamiento de ${limit} MB para tu plan (${subscription.plan.name}).`, + errorCode: "LIMIT_STORAGE_REACHED", + limit, + planName: subscription.plan.name, + }); + } + + next(); + } catch (error) { + console.error("Error en checkStorageLimit:", error); + res.status(500).json({ + message: "Error verificando límite de almacenamiento.", + errorCode: "INTERNAL_SERVER_ERROR", + }); + } +}; + +export async function checkAiDescriptionLimit( + 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 || !subscription.plan) { + return res.status(403).json({ + message: "No tienes una suscripción activa", + errorCode: "NO_ACTIVE_PLAN", + }); + } + + const { plan, aiDescriptionCount } = subscription; + const limit = plan.aiDescriptionLimit; + + if (limit === null || limit === undefined) { + return next(); + } + + if (aiDescriptionCount >= limit) { + return res.status(403).json({ + message: `Has alcanzado el límite de ${limit} descripciones con IA permitidas por tu plan (${plan.name}).`, + errorCode: "LIMIT_AI_DESCRIPTION_REACHED", + current: aiDescriptionCount, + limit, + }); + } + + await prisma.userSubscription.update({ + where: { userId }, + data: { aiDescriptionCount: { increment: 1 } }, + }); + + next(); + } catch (error) { + console.error("Error en checkAiDescriptionLimit:", error); + res.status(500).json({ + message: "Error verificando el límite de IA.", + errorCode: "INTERNAL_SERVER_ERROR", + }); + } +} diff --git a/server/src/models/CardModel.ts b/server/src/models/CardModel.ts index 4a9888e..ce3c448 100644 --- a/server/src/models/CardModel.ts +++ b/server/src/models/CardModel.ts @@ -172,4 +172,22 @@ export default class CardModel { where: { cardId_labelId: { cardId, labelId } }, }); } + + async getMediaDetails(mediaId: number) { + return this.prisma.media.findUnique({ + where: { id: mediaId }, + select: { fileName: true, sizeMB: true }, + }); + } + + async updateUserStorage(userId: number, deltaSize: number) { + return this.prisma.userSubscription.update({ + where: { userId }, + data: { + currentStorageUsedMB: { + increment: deltaSize, + }, + }, + }); + } } 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/ai.routes.ts b/server/src/routes/ai.routes.ts index 089c5a0..07e2169 100644 --- a/server/src/routes/ai.routes.ts +++ b/server/src/routes/ai.routes.ts @@ -1,9 +1,15 @@ import { Router } from "express"; import { generateDescription } from "../controllers/aiController"; import * as jwtAuth from "../middlewares/jwtAuthMiddleware"; +import { checkAiDescriptionLimit } from "../middlewares/checkResourceLimit"; const router = Router(); -router.post("/generate-description", jwtAuth.guard, generateDescription); +router.post( + "/generate-description", + jwtAuth.guard, + checkAiDescriptionLimit, + generateDescription, +); export default router; diff --git a/server/src/routes/boards.routes.ts b/server/src/routes/boards.routes.ts index b46cfe7..a4d396a 100644 --- a/server/src/routes/boards.routes.ts +++ b/server/src/routes/boards.routes.ts @@ -8,6 +8,10 @@ import AuthService from "../services/AuthService"; import AuthModel from "../models/AuthModel"; import { processImage, upload } from "../lib/uploadConfigure"; import CardModel from "../models/CardModel"; +import { + checkBoardLimit, + checkBoardMembersLimit, +} from "../middlewares/checkResourceLimit"; const router = Router(); @@ -24,6 +28,7 @@ router.get("/:slug", jwtAuth.guard, controller.get); router.post( "/", jwtAuth.guard, + checkBoardLimit, upload.single("image"), processImage( "boards", @@ -45,8 +50,18 @@ router.put( ); router.delete("/:id", jwtAuth.guard, controller.delete); -router.get("/:id/share", jwtAuth.guard, controller.shareBoard); -router.post("/:id/invite", jwtAuth.guard, controller.acceptInvitation); +router.get( + "/:id/share", + jwtAuth.guard, + checkBoardMembersLimit, + controller.shareBoard, +); +router.post( + "/:id/invite", + jwtAuth.guard, + checkBoardMembersLimit, + controller.acceptInvitation, +); router.get("/:id/users", jwtAuth.guard, controller.boardUsers); diff --git a/server/src/routes/card.routes.ts b/server/src/routes/card.routes.ts index 3667519..fc436f2 100644 --- a/server/src/routes/card.routes.ts +++ b/server/src/routes/card.routes.ts @@ -7,6 +7,10 @@ import * as jwtAuth from "../middlewares/jwtAuthMiddleware"; import { validateCard } from "../validators/cardValidators"; import { cardCreateSchema, cardUpdateSchema } from "../validators/cardSchema"; import { uploadMedia } from "../lib/uploadConfigure"; +import { + checkStorageLimit, + checkTaskLimit, +} from "../middlewares/checkResourceLimit"; const router = Router(); @@ -19,12 +23,14 @@ router.get("/:id", jwtAuth.guard, controller.getCard); router.post( "/", jwtAuth.guard, + checkTaskLimit, validateCard(cardCreateSchema), controller.addCard, ); router.put( "/:id", jwtAuth.guard, + checkStorageLimit, uploadMedia.array("attachments"), validateCard(cardUpdateSchema), controller.updateCard, diff --git a/server/src/services/CardService.ts b/server/src/services/CardService.ts index 6e1358d..6e110e9 100644 --- a/server/src/services/CardService.ts +++ b/server/src/services/CardService.ts @@ -42,6 +42,7 @@ export default class CardService { url: string; fileName: string; fileType: "document" | "audio"; + sizeMB: number; }[], ) { const dataToCreate = mediaList.map((media) => ({ @@ -131,4 +132,17 @@ export default class CardService { } return this.cardModel.removeLabelFromCard(cardId, labelId); } + + async getMediaAttachmentDetails(mediaId: number) { + return this.cardModel.getMediaDetails(mediaId); + } + + async updateStorageUsedMB(userId: number, deltaSize: number) { + try { + const result = await this.cardModel.updateUserStorage(userId, deltaSize); + return result; + } catch (e) { + console.error(e); + } + } } 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;