add bot wsp

This commit is contained in:
Valentin Romero
2026-06-30 22:04:56 -03:00
parent 6859b89262
commit 25d4ba6cc0
166 changed files with 29830 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# Build artifacts
dist/*
node_modules
# Environment files (excepto plantillas)
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
pnpm-debug.log*
# Runtime data
*_sessions
*tokens
.wwebjs*
tmp/*
# Git y control de versiones
.git
.gitignore
# Docker
Dockerfile*
docker-compose*
# IDE y editores
.vscode
.idea
*.swp
*.swo
*~
# Testing
coverage
.cache
# OS
.DS_Store
Thumbs.db
# Archivos temporales
*.tmp
*.bak
# README y docs (opcional, comentar si los necesitas)
README.md
+13
View File
@@ -0,0 +1,13 @@
DATABASE_URL=
SECRET_JWT=mi_clave_secreta
EXPIRATION_TOKEN_MINUTES=30
URL_FRONT=
URL_ODOO=
<<<<<<< Updated upstream
PHONE_NUMBER=5
NODE_ENV=d
=======
PHONE_NUMBER=
NODE_ENV=development
>>>>>>> Stashed changes
PROVIDER=baileys
+26
View File
@@ -0,0 +1,26 @@
{
"env": {
"browser": true,
"node": true,
"es2021": true
},
"ignorePatterns": ["dist/**/*.js"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:builderbot/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "builderbot"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/ban-types": "off",
"no-unsafe-optional-chaining": "off"
}
}
+16
View File
@@ -0,0 +1,16 @@
dist/*
node_modules
.vscode/*
.env
tmp/*
!tmp/.gitkeep
*_sessions
*tokens
.wwebjs*
*.log
*qr.png
/src/generated/prisma
*.Zone.Identifier
+4
View File
@@ -0,0 +1,4 @@
enable-pre-post-scripts=true
auto-install-peers=true
shamefully-hoist=false
strict-peer-dependencies=false
+139
View File
@@ -0,0 +1,139 @@
# ---------- BUILDER ----------
<<<<<<< Updated upstream
FROM node:22-alpine AS builder
WORKDIR /app
# Instalar dependencias del sistema necesarias para compilación
RUN apk add --no-cache \
python3 \
make \
g++ \
git \
openssl \
curl \
ffmpeg
# Instalar pnpm globalmente
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copiar archivos de configuración de pnpm y dependencias
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc* ./
COPY patches ./patches
# Instalar dependencias con pnpm (permite build scripts automáticamente)
RUN pnpm config set enable-pre-post-scripts true \
&& pnpm install --frozen-lockfile
# Copiar código fuente
COPY . .
# Generar Prisma client y compilar
RUN pnpm prisma generate && pnpm run build
# ---------- RUNTIME ----------
FROM node:22-alpine AS deploy
WORKDIR /app
ARG PORT=3001
ENV PORT=$PORT
ENV NODE_ENV=production
EXPOSE $PORT
# Instalar dependencias runtime (ffmpeg incluido para evitar problemas)
RUN apk add --no-cache \
curl \
openssl \
libc6-compat \
ffmpeg \
postgresql-client
# Instalar pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Crear usuario no-root ANTES de copiar archivos
RUN addgroup -g 1001 -S nodejs \
&& adduser -S -u 1001 nodejs \
&& mkdir -p /app/bot_sessions /app/tokens /app/assets /app/tmp \
&& chown nodejs:nodejs /app /app/bot_sessions /app/tokens /app/assets /app/tmp
# Copiar archivos necesarios del builder con ownership correcto
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nodejs:nodejs /app/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=builder --chown=nodejs:nodejs /app/assets ./assets
# Copiar entrypoint script
# COPY docker-entrypoint.sh /usr/local/bin/
# RUN chmod +x /usr/local/bin/docker-entrypoint.sh
USER nodejs
# Healthcheck
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:${PORT}/health || exit 1
CMD ["sh", "-c", "pnpm prisma migrate deploy && node dist/main.js"]
=======
FROM node:22-alpine3.20 AS builder
WORKDIR /app
RUN apk add --no-cache --virtual .gyp \
python3 \
make \
g++ \
&& apk add --no-cache git openssl
# Instalar pnpm
RUN npm install -g pnpm
# Copiar archivos de dependencias (incluyendo lockfile y patches)
COPY package*.json pnpm-lock.yaml ./
COPY patches/ ./patches/
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm exec prisma generate && pnpm run build
RUN apk del .gyp
# ---------- RUNTIME ----------
FROM node:22-alpine3.20 AS deploy
WORKDIR /app
ARG PORT=3001
ARG PORT_WSP=3008
ENV PORT=$PORT
ENV PORT_WSP=$PORT_WSP
ENV NODE_ENV=production
EXPOSE $PORT
EXPOSE $PORT_WSP
# Herramientas mínimas necesarias
RUN apk add --no-cache curl openssl libc6-compat
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/assets ./assets
RUN addgroup -g 1001 -S nodejs \
&& adduser -S -u 1001 nodejs \
&& mkdir -p /app/bot_sessions \
&& chown -R nodejs:nodejs /app
USER nodejs
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
>>>>>>> Stashed changes
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

+17
View File
@@ -0,0 +1,17 @@
version: '3.8'
services:
postgres:
image: postgres:15
container_name: postgres_urban
environment:
POSTGRES_DB: urban
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5431:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
+17
View File
@@ -0,0 +1,17 @@
node_modules
dist
tmp
.git
.gitignore
Dockerfile*
docker-compose*
**/*.log
.env*
coverage
pnpm-lock.yamldist/*
node_modules
.env
*_sessions
*tokens
.wwebjs*
+4
View File
@@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}
+14
View File
@@ -0,0 +1,14 @@
{
"watch": ["src"],
"ext": "ts",
"ignore": [
"**/*.test.ts",
"**/*.spec.ts"
],
"delay": "1000",
"signal": "SIGTERM",
"restartable": "rs",
"execMap": {
"ts": "tsx"
}
}
+11293
View File
File diff suppressed because it is too large Load Diff
+81
View File
@@ -0,0 +1,81 @@
{
"name": "bot-wsp",
"version": "1.0.0",
"description": "",
"main": "dist/main.js",
"type": "commonjs",
"engines": {
"node": ">=22"
},
"scripts": {
"start": "nest start --exec \"node --experimental-require-module\"",
"generar": "prisma format && prisma generate && prisma db push",
"prisma:generate": "prisma generate",
"kill:port": "lsof -ti:3001 | xargs kill -9 2>/dev/null || true",
"prestart:dev": "npm run kill:port && npm run prisma:generate",
"start:dev": "nest start --exec \"node --experimental-require-module\" --watch",
"lint": "eslint .",
"dev": "npm run lint && npm run start:dev",
"build": "nest build"
},
"keywords": [],
"dependencies": {
"@builderbot/bot": "1.3.15",
"@builderbot/provider-baileys": "1.3.15",
"@builderbot/provider-sherpa": "1.3.15",
"@nestjs/axios": "^4.0.0",
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/schedule": "^6.0.0",
"@nestjs/swagger": "7.4.2",
"@nestjs/throttler": "^6.4.0",
"@prisma/client": "^6.10.1",
"aurik3-builderbot-baileys-custom": "^0.0.9",
"axios": "^1.7.2",
"baileys": "^7.0.0-rc.9",
"chrono-node": "^2.8.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"dayjs": "^1.11.13",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"multer": "^2.0.2",
"nanoid": "^3.3.6",
"passport": "^0.7.0",
"pnpm": "^10.32.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"uuid": "^11.1.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.11",
"@types/node": "^20.19.6",
"@types/passport": "^1.0.16",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.4.0",
"eslint": "^8.52.0",
"eslint-plugin-builderbot": "latest",
"nodemon": "^3.1.0",
"prisma": "^6.10.1",
"rollup": "^4.10.0",
"rollup-plugin-typescript2": "^0.36.0",
"tsx": "^4.7.1",
"typescript": "^5.4.3"
},
"author": "",
"license": "ISC",
"overrides": {
"supports-color": "8.1.1"
},
"pnpm": {
"patchedDependencies": {
"@builderbot/provider-baileys@1.3.15": "patches/@builderbot__provider-baileys@1.3.15.patch"
}
}
}
@@ -0,0 +1,16 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 96df9de111b53cd97228e4fa05d46e7d24428459..c2d1fc0f7e9a5de1bc1afd1839b01f9561e8b882 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -31032,9 +31032,9 @@ class BaileysProvider extends bot.ProviderClass {
this.initVendor().then((v) => this.listenOnEvents(v));
}
try {
- const sock = makeWASocketOther({
+ const sock = makeWASocketOther.makeWASocket({
logger: loggerBaileys,
- version: [2, 3000, 1025190524],
+ version: this.globalVendorArgs.version ?? [2, 3000, 1025190524],
printQRInTerminal: false,
auth: {
creds: state.creds,
+7813
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
patchedDependencies:
'@builderbot/provider-baileys@1.3.15': patches/@builderbot__provider-baileys@1.3.15.patch
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
@@ -0,0 +1,40 @@
/*
Warnings:
- You are about to drop the column `email` on the `User` table. All the data in the column will be lost.
- A unique constraint covering the columns `[phone]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'USER', 'GUEST');
-- CreateEnum
CREATE TYPE "LogDirection" AS ENUM ('INGRESS', 'EGRESS');
-- DropIndex
DROP INDEX "User_email_key";
-- AlterTable
ALTER TABLE "User" DROP COLUMN "email",
ADD COLUMN "phone" TEXT,
ADD COLUMN "rfid" TEXT,
ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'USER';
-- CreateTable
CREATE TABLE "AccessLog" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"direction" "LogDirection" NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccessLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AccessLog_userId_timestamp_idx" ON "AccessLog"("userId", "timestamp");
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- AddForeignKey
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,243 @@
/*
Warnings:
- The primary key for the `AccessLog` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint.
- Added the required column `customId` to the `User` table without a default value. This is not possible if the table is not empty.
- Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- CreateEnum
CREATE TYPE "SessionType" AS ENUM ('RECURRING', 'ONE_TIME');
-- CreateEnum
CREATE TYPE "InvoiceStatus" AS ENUM ('PENDING', 'PAID', 'CANCELED');
-- AlterEnum
ALTER TYPE "UserRole" ADD VALUE 'INSTRUCTOR';
-- DropForeignKey
ALTER TABLE "AccessLog" DROP CONSTRAINT "AccessLog_userId_fkey";
-- DropIndex
DROP INDEX "User_phone_key";
-- AlterTable
ALTER TABLE "AccessLog" DROP CONSTRAINT "AccessLog_pkey",
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "id" SET DATA TYPE TEXT,
ALTER COLUMN "userId" SET DATA TYPE TEXT,
ADD CONSTRAINT "AccessLog_pkey" PRIMARY KEY ("id");
DROP SEQUENCE "AccessLog_id_seq";
-- AlterTable
ALTER TABLE "User" DROP CONSTRAINT "User_pkey",
ADD COLUMN "birth" TIMESTAMP(3),
ADD COLUMN "customId" TEXT NOT NULL,
ADD COLUMN "deleted" TIMESTAMP(3),
ADD COLUMN "dni" TEXT,
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "id" SET DATA TYPE TEXT,
ALTER COLUMN "name" SET NOT NULL,
ADD CONSTRAINT "User_pkey" PRIMARY KEY ("id");
DROP SEQUENCE "User_id_seq";
-- CreateTable
CREATE TABLE "Token" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"redirectUrl" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Token_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"customId" TEXT NOT NULL,
"description" TEXT NOT NULL,
"type" "SessionType" NOT NULL,
"startDate" TIMESTAMP(3),
"endDate" TIMESTAMP(3),
"isActive" BOOLEAN NOT NULL DEFAULT true,
"amount" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SessionPriceHistory" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"effectiveFrom" TIMESTAMP(3) NOT NULL,
"effectiveTo" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SessionPriceHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SessionDateRange" (
"id" TEXT NOT NULL,
"start" TIMESTAMP(3) NOT NULL,
"end" TIMESTAMP(3) NOT NULL,
"sessionId" TEXT NOT NULL,
CONSTRAINT "SessionDateRange_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SessionDateSnapshot" (
"id" TEXT NOT NULL,
"dateRangeId" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"notes" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SessionDateSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Invoice" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"base64Invoice" TEXT,
"linkPayment" TEXT,
"status" "InvoiceStatus" NOT NULL DEFAULT 'PENDING',
"amount" DOUBLE PRECISION NOT NULL,
"dateInvoice" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_InstructorSessions" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_InstructorSessions_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateTable
CREATE TABLE "_AssistantSessions" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_AssistantSessions_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateTable
CREATE TABLE "_SubstituteInstructorsOnDate" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_SubstituteInstructorsOnDate_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateTable
CREATE TABLE "_PresentInstructorsOnDate" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_PresentInstructorsOnDate_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateTable
CREATE TABLE "_PresentAssistantsOnDate" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_PresentAssistantsOnDate_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "Token_token_key" ON "Token"("token");
-- CreateIndex
CREATE UNIQUE INDEX "Session_customId_key" ON "Session"("customId");
-- CreateIndex
CREATE INDEX "Invoice_userId_dateInvoice_idx" ON "Invoice"("userId", "dateInvoice");
-- CreateIndex
CREATE INDEX "Invoice_sessionId_dateInvoice_idx" ON "Invoice"("sessionId", "dateInvoice");
-- CreateIndex
CREATE INDEX "_InstructorSessions_B_index" ON "_InstructorSessions"("B");
-- CreateIndex
CREATE INDEX "_AssistantSessions_B_index" ON "_AssistantSessions"("B");
-- CreateIndex
CREATE INDEX "_SubstituteInstructorsOnDate_B_index" ON "_SubstituteInstructorsOnDate"("B");
-- CreateIndex
CREATE INDEX "_PresentInstructorsOnDate_B_index" ON "_PresentInstructorsOnDate"("B");
-- CreateIndex
CREATE INDEX "_PresentAssistantsOnDate_B_index" ON "_PresentAssistantsOnDate"("B");
-- AddForeignKey
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Token" ADD CONSTRAINT "Token_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SessionPriceHistory" ADD CONSTRAINT "SessionPriceHistory_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SessionDateRange" ADD CONSTRAINT "SessionDateRange_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SessionDateSnapshot" ADD CONSTRAINT "SessionDateSnapshot_dateRangeId_fkey" FOREIGN KEY ("dateRangeId") REFERENCES "SessionDateRange"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SessionDateSnapshot" ADD CONSTRAINT "SessionDateSnapshot_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_InstructorSessions" ADD CONSTRAINT "_InstructorSessions_A_fkey" FOREIGN KEY ("A") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_InstructorSessions" ADD CONSTRAINT "_InstructorSessions_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AssistantSessions" ADD CONSTRAINT "_AssistantSessions_A_fkey" FOREIGN KEY ("A") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_AssistantSessions" ADD CONSTRAINT "_AssistantSessions_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_SubstituteInstructorsOnDate" ADD CONSTRAINT "_SubstituteInstructorsOnDate_A_fkey" FOREIGN KEY ("A") REFERENCES "SessionDateSnapshot"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_SubstituteInstructorsOnDate" ADD CONSTRAINT "_SubstituteInstructorsOnDate_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_PresentInstructorsOnDate" ADD CONSTRAINT "_PresentInstructorsOnDate_A_fkey" FOREIGN KEY ("A") REFERENCES "SessionDateSnapshot"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_PresentInstructorsOnDate" ADD CONSTRAINT "_PresentInstructorsOnDate_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_PresentAssistantsOnDate" ADD CONSTRAINT "_PresentAssistantsOnDate_A_fkey" FOREIGN KEY ("A") REFERENCES "SessionDateSnapshot"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_PresentAssistantsOnDate" ADD CONSTRAINT "_PresentAssistantsOnDate_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,18 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "profilePicture" TEXT,
ADD COLUMN "profilePictureUpdatedAt" TIMESTAMP(3),
ALTER COLUMN "customId" DROP NOT NULL;
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL,
"sessionCleanupIntervalMinutes" INTEGER NOT NULL DEFAULT 30,
"invoiceGenerationDayOfMonth" INTEGER NOT NULL DEFAULT 1,
"tokenExpirationMinutes" INTEGER NOT NULL DEFAULT 30,
"tokenCleanupIntervalMinutes" INTEGER NOT NULL DEFAULT 60,
"profilePictureUpdateIntervalDays" INTEGER NOT NULL DEFAULT 2,
"updatedAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+159
View File
@@ -0,0 +1,159 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
/// Un ejemplo de modelo "User"
model User {
id String @id @default(cuid())
customId String?
name String
dni String?
phone String?
birth DateTime?
rfid String?
deleted DateTime?
role UserRole @default(USER)
profilePicture String?
profilePictureUpdatedAt DateTime?
createdAt DateTime @default(now())
AccessLog AccessLog[]
invoices Invoice[]
tokens Token[]
assistantSessions Session[] @relation("AssistantSessions")
instructorSessions Session[] @relation("InstructorSessions")
presentAssistantsOnDates SessionDateSnapshot[] @relation("PresentAssistantsOnDate")
presentInstructorsOnDates SessionDateSnapshot[] @relation("PresentInstructorsOnDate")
SessionDateSnapshot SessionDateSnapshot[] @relation("SubstituteInstructorsOnDate")
}
/// Registro de acceso de un usuario
model AccessLog {
id String @id @default(cuid())
userId String
direction LogDirection
timestamp DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([userId, timestamp])
}
model Token {
id String @id @default(cuid())
userId String?
token String @unique
isActive Boolean @default(true)
redirectUrl String?
expiresAt DateTime
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id])
}
model Session {
id String @id @default(cuid())
customId String @unique
description String
type SessionType
startDate DateTime?
endDate DateTime?
isActive Boolean @default(true)
amount Float?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
invoices Invoice[]
dates SessionDateRange[]
SessionDateSnapshot SessionDateSnapshot[]
priceHistories SessionPriceHistory[]
assistants User[] @relation("AssistantSessions")
instructors User[] @relation("InstructorSessions")
}
model SessionPriceHistory {
id String @id @default(cuid())
sessionId String
amount Float
effectiveFrom DateTime
effectiveTo DateTime?
createdAt DateTime @default(now())
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
}
model SessionDateRange {
id String @id @default(cuid())
start DateTime
end DateTime
sessionId String
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
SessionDateSnapshot SessionDateSnapshot[]
}
model SessionDateSnapshot {
id String @id @default(cuid())
dateRangeId String
sessionId String
notes String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
dateRange SessionDateRange @relation(fields: [dateRangeId], references: [id])
session Session @relation(fields: [sessionId], references: [id])
presentAssistants User[] @relation("PresentAssistantsOnDate")
presentInstructors User[] @relation("PresentInstructorsOnDate")
substituteInstructors User[] @relation("SubstituteInstructorsOnDate")
}
model Invoice {
id String @id @default(cuid())
userId String
sessionId String
base64Invoice String?
linkPayment String?
status InvoiceStatus @default(PENDING)
amount Float
dateInvoice DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
session Session @relation(fields: [sessionId], references: [id])
user User @relation(fields: [userId], references: [id])
@@index([userId, dateInvoice])
@@index([sessionId, dateInvoice])
}
model SystemConfig {
id String @id @default(cuid())
sessionCleanupIntervalMinutes Int @default(30)
invoiceGenerationDayOfMonth Int @default(1)
tokenExpirationMinutes Int @default(30)
tokenCleanupIntervalMinutes Int @default(60)
profilePictureUpdateIntervalDays Int @default(2)
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}
enum UserRole {
ADMIN
INSTRUCTOR
USER
GUEST
}
/// Dirección de un registro (INGRESO o EGRESO)
enum LogDirection {
INGRESS
EGRESS
}
enum SessionType {
RECURRING
ONE_TIME
}
enum InvoiceStatus {
PENDING
PAID
CANCELED
}
+13
View File
@@ -0,0 +1,13 @@
import typescript from 'rollup-plugin-typescript2'
export default {
input: 'src/app.ts',
output: {
file: 'dist/app.js',
format: 'esm',
},
onwarn: (warning) => {
if (warning.code === 'UNRESOLVED_IMPORT') return
},
plugins: [typescript()],
}
+67
View File
@@ -0,0 +1,67 @@
#include <WiFi.h>
#include <HTTPClient.h>
#include <SPI.h>
#include <MFRC522.h>
#define SS_PIN 21 // GPIO21 para SS (SDA)
#define RST_PIN 22 // GPIO22 para RST
MFRC522 rfid(SS_PIN, RST_PIN);
const char* ssid = "Personal-915-2.4GHz";
const char* password = "00439028922";
const char* post_url = "http://localhost:3000/rfid/ping";
void setup() {
Serial.begin(115200);
SPI.begin(18, 19, 23); // SCK, MISO, MOSI (GPIO18,19,23)
WiFi.begin(ssid, password);
Serial.print("Conectando a WiFi...");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi conectado.");
rfid.PCD_Init();
Serial.println("Lector RFID listo.");
}
void loop() {
if (!rfid.PICC_IsNewCardPresent() || !rfid.PICC_ReadCardSerial()) {
delay(100);
return;
}
// Leer UID
String uid = "";
for (byte i = 0; i < rfid.uid.size; i++) {
uid += String(rfid.uid.uidByte[i] < 0x10 ? "0" : "");
uid += String(rfid.uid.uidByte[i], HEX);
}
uid.toUpperCase(); // Ej: B31D7313
Serial.println("UID detectado: " + uid);
// Enviar POST
if (WiFi.status() == WL_CONNECTED) {
HTTPClient http;
http.begin(post_url);
http.addHeader("Content-Type", "application/json");
String jsonBody = "{\"id\": \"" + uid + "\"}";
Serial.println("Enviando POST: " + jsonBody);
int httpResponseCode = http.POST(jsonBody);
Serial.printf("Código de respuesta: %d\n", httpResponseCode);
String payload = http.getString();
Serial.println("Respuesta: " + payload);
http.end();
} else {
Serial.println("WiFi no conectado.");
}
rfid.PICC_HaltA(); // Detener comunicación
rfid.PCD_StopCrypto1();
delay(2000); // Esperar antes de siguiente lectura
}
+56
View File
@@ -0,0 +1,56 @@
import { Module } from '@nestjs/common';
import { WhatsappModule } from './whatsapp/whatsapp.module.js';
import { UserModule } from './user/user.module.js';
import { PrismaModule } from './prisma/prisma.module.js';
import { RfidModule } from './rfid/rfid.module';
import { AttendanceModule } from './attendance/attendance.module';
import { SessionsModule } from './sessions/sessions.module';
import { IaModule } from './ia/ia.module';
import { CustomIdModule } from './custom-id/custom-id.module';
import { OdooModule } from './odoo/odoo.module';
import { AuthModule } from './auth/auth.module';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { InvoicesModule } from './invoices/invoices.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { BackupModule } from './backup/backup.module';
import { SchedulingModule } from './scheduling/scheduling.module';
import { SystemConfigModule } from './common/system-config.module';
@Module({
imports: [
ThrottlerModule.forRoot({
throttlers: [
{
ttl: 60, // Tiempo en segundos
limit: 5, // Máximo 5 requests por IP
},
],
}),
PrismaModule,
WhatsappModule,
UserModule,
RfidModule,
AttendanceModule,
SessionsModule,
IaModule,
CustomIdModule,
OdooModule,
AuthModule,
InvoicesModule,
DashboardModule,
SubscriptionsModule,
BackupModule,
SchedulingModule,
SystemConfigModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
]
})
export class AppModule { }
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AttendanceController } from './attendance.controller';
import { AttendanceService } from './attendance.service';
describe('AttendanceController', () => {
let controller: AttendanceController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AttendanceController],
providers: [AttendanceService],
}).compile();
controller = module.get<AttendanceController>(AttendanceController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -0,0 +1,52 @@
import { Controller, ForbiddenException, Get, Param, Put, Query, UseGuards } from '@nestjs/common';
import { UserRole } from '@prisma/client';
import { User } from '~/common/decoratos/user.decorator';
import { AuthTokenGuard } from '~/common/guards/auth-token.guard';
import { AuthUser } from '~/common/types';
import { AttendanceService } from './attendance.service';
@Controller('attendance')
@UseGuards(AuthTokenGuard)
export class AttendanceController {
constructor(private readonly attendanceService: AttendanceService) { }
@Get()
async getAccessLogsByDate(
@Query('userId') userId: string,
@Query('date') date: string,
@User() user: AuthUser,
) {
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR || user?.id === userId;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para ver esta información');
}
if (!userId || !date) {
throw new Error('userId and date are required');
}
return await this.attendanceService.getAccessLogsByDate(userId, date);
}
@Put('/:userId/:snapshotId')
async updateAttendanceSnapshot(
@Param('userId') userId: string,
@Param('snapshotId') snapshotId: string,
@Query('unmark') unmark: string | undefined,
@User() user: AuthUser,
) {
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para actualizar asistencias');
}
const shouldUnmark = unmark === 'true';
if (shouldUnmark) {
return await this.attendanceService.unmarkAttendance(userId, snapshotId);
} else {
return await this.attendanceService.markAttendance(userId, snapshotId);
}
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AttendanceService } from './attendance.service';
import { AttendanceController } from './attendance.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { InvoicesModule } from '../invoices/invoices.module';
@Module({
controllers: [AttendanceController],
providers: [AttendanceService],
imports: [PrismaModule, InvoicesModule],
exports: [AttendanceService],
})
export class AttendanceModule {}
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AttendanceService } from './attendance.service';
describe('AttendanceService', () => {
let service: AttendanceService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AttendanceService],
}).compile();
service = module.get<AttendanceService>(AttendanceService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,414 @@
import { Injectable, Logger } from '@nestjs/common';
import { AccessLog, LogDirection } from '@prisma/client';
import dayjs from 'dayjs';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AttendanceService {
private logger = new Logger(AttendanceService.name);
constructor(private prisma: PrismaService) { }
/**
* Registra un ingreso o egreso alternando según el último registro del día.
*/
async logAccess(userId: string): Promise<AccessLog> {
const startOfDay = dayjs().startOf('day').toDate();
const endOfDay = dayjs().endOf('day').toDate();
const timestamp = new Date();
// 2) Buscamos el último log de hoy
const lastLog = await this.prisma.accessLog.findFirst({
where: {
userId,
timestamp: {
gte: startOfDay,
lte: endOfDay,
},
},
orderBy: { timestamp: 'desc' },
});
// 3) Inferimos la dirección
const direction: LogDirection =
lastLog?.direction === LogDirection.INGRESS
? LogDirection.EGRESS
: LogDirection.INGRESS;
// 4) Creamos el registro
const accesLog = await this.prisma.accessLog.create({
data: { userId, direction, timestamp },
});
// 5) Si es un INGRESS, intentar marcar asistencia automáticamente
if (direction === LogDirection.INGRESS) {
await this.autoMarkAttendance(userId, timestamp);
}
return accesLog
}
/**
* Marca automáticamente la asistencia cuando un usuario hace INGRESS.
* Busca sesiones activas (como asistente o instructor) dentro de una ventana de ±1 hora.
* @param userId ID del usuario
* @param timestamp Momento del ingreso
*/
private async autoMarkAttendance(userId: string, timestamp: Date): Promise<void> {
try {
// Ventana de ±1 hora
const oneHourBefore = dayjs(timestamp).subtract(1, 'hour').toDate();
const oneHourAfter = dayjs(timestamp).add(1, 'hour').toDate();
// Buscar snapshots activos donde:
// 1. El dateRange.start <= oneHourAfter
// 2. El dateRange.end >= oneHourBefore
// 3. El usuario es asistente o instructor de la sesión
const candidateSnapshots = await this.prisma.sessionDateSnapshot.findMany({
where: {
isActive: true,
dateRange: {
start: { lte: oneHourAfter },
end: { gte: oneHourBefore }
},
session: {
OR: [
{ assistants: { some: { id: userId } } },
{ instructors: { some: { id: userId } } }
]
}
},
include: {
session: {
include: {
assistants: { where: { id: userId }, select: { id: true } },
instructors: { where: { id: userId }, select: { id: true } }
}
},
presentAssistants: { where: { id: userId }, select: { id: true } },
presentInstructors: { where: { id: userId }, select: { id: true } }
}
});
if (candidateSnapshots.length === 0) {
this.logger.log(`No active snapshots found for user ${userId} within ±1 hour window`);
return;
}
// Marcar en cada snapshot encontrado
for (const snapshot of candidateSnapshots) {
const isAlreadyMarked =
snapshot.presentAssistants.length > 0 ||
snapshot.presentInstructors.length > 0;
if (isAlreadyMarked) {
this.logger.log(`User ${userId} already marked in snapshot ${snapshot.id} - skipping`);
continue;
}
// Determinar si es instructor o asistente en esta sesión
const isInstructor = snapshot.session.instructors.length > 0;
const isAssistant = snapshot.session.assistants.length > 0;
if (isInstructor) {
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshot.id },
data: {
presentInstructors: {
connect: { id: userId }
}
}
});
this.logger.log(`Auto-marked user ${userId} as instructor in snapshot ${snapshot.id}`);
} else if (isAssistant) {
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshot.id },
data: {
presentAssistants: {
connect: { id: userId }
}
}
});
this.logger.log(`Auto-marked user ${userId} as assistant in snapshot ${snapshot.id}`);
}
}
} catch (error: any) {
this.logger.error(`Error in autoMarkAttendance for user ${userId}: ${error.message}`, error.stack);
// No lanzamos el error para no interrumpir el flujo del logAccess
}
}
/**
* Obtiene los últimos N registros de acceso de un usuario.
* @param userId ID del usuario
* @param limit Número máximo de registros a devolver (por defecto 5)
*/
async getLastAccesses(
userId: string,
limit = 5,
): Promise<AccessLog[]> {
return this.prisma.accessLog.findMany({
where: { userId },
orderBy: { timestamp: 'desc' },
take: limit,
});
}
async attendanceSnapshotSession(userId: string) {
const lastAccesses = await this.getLastAccesses(userId);
// Aquí puedes agregar lógica adicional para crear un snapshot de la asistencia
return lastAccesses;
}
/**
* Obtiene todos los registros de acceso de un usuario en una fecha específica
* @param userId ID del usuario
* @param date Fecha en formato ISO string
* @returns Objeto con información del usuario y sus registros de acceso
*/
async getAccessLogsByDate(userId: string, date: string) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true }
});
if (!user) {
throw new Error('User not found');
}
const targetDate = dayjs(date);
const startOfDay = targetDate.startOf('day').toDate();
const endOfDay = targetDate.endOf('day').toDate();
const accessLogs = await this.prisma.accessLog.findMany({
where: {
userId,
timestamp: {
gte: startOfDay,
lte: endOfDay
}
},
orderBy: { timestamp: 'asc' }
});
return {
userId: user.id,
name: user.name,
accessLogs
};
}
async updateAttendanceSnapshot(userId: string, snapShotId: string) {
try {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new Error(`User not found`);
}
const snapshot = await this.prisma.sessionDateSnapshot.findUnique({ where: { id: snapShotId } });
if (!snapshot) {
throw new Error(`Snapshot not found`);
}
const updatedSnapshot = await this.prisma.sessionDateSnapshot.update({
where: { id: snapShotId },
data: {
presentAssistants: {
connect: {
id: userId
}
}
},
});
return !!updatedSnapshot;
}
catch (error: any) {
this.logger.error(`Error updating attendance snapshot: ${error.message}`);
throw new Error(`Error updating attendance snapshot: ${error.message}`);
}
}
/**
* Marca la asistencia de un usuario en un snapshot específico
* @param userId ID del usuario
* @param snapshotId ID del snapshot de sesión
* @returns Objeto con el estado de la operación
*/
async markAttendance(userId: string, snapshotId: string) {
try {
// Verificar que el usuario existe
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, role: true }
});
if (!user) {
this.logger.warn(`Attempt to mark attendance for non-existent user: ${userId}`);
throw new Error('User not found');
}
// Verificar que el snapshot existe y obtener información de asistentes presentes
const snapshot = await this.prisma.sessionDateSnapshot.findUnique({
where: { id: snapshotId },
include: {
presentAssistants: { select: { id: true } },
presentInstructors: { select: { id: true } }
}
});
if (!snapshot) {
this.logger.warn(`Attempt to mark attendance for non-existent snapshot: ${snapshotId}`);
throw new Error('Snapshot not found');
}
// Verificar si el usuario ya está marcado como presente
const isAssistantPresent = snapshot.presentAssistants.some(a => a.id === userId);
const isInstructorPresent = snapshot.presentInstructors.some(i => i.id === userId);
const isAlreadyMarked = isAssistantPresent || isInstructorPresent;
if (isAlreadyMarked) {
this.logger.log(`User ${userId} already marked as present in snapshot ${snapshotId} - idempotent behavior`);
return {
status: 'marked',
userId,
snapshotId,
message: 'User already marked as present',
alreadyMarked: true
};
}
// Determinar si es instructor o asistente basado en el rol
if (user.role === 'INSTRUCTOR' || user.role === 'ADMIN') {
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshotId },
data: {
presentInstructors: {
connect: { id: userId }
}
}
});
this.logger.log(`Instructor ${userId} marked as present in snapshot ${snapshotId}`);
} else {
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshotId },
data: {
presentAssistants: {
connect: { id: userId }
}
}
});
this.logger.log(`Assistant ${userId} marked as present in snapshot ${snapshotId}`);
}
return {
status: 'marked',
userId,
snapshotId,
message: 'Attendance marked successfully',
alreadyMarked: false
};
} catch (error: any) {
this.logger.error(`Error marking attendance: ${error.message}`, error.stack);
throw error;
}
}
/**
* Desmarca la asistencia de un usuario en un snapshot específico
* @param userId ID del usuario
* @param snapshotId ID del snapshot de sesión
* @returns Objeto con el estado de la operación
*/
async unmarkAttendance(userId: string, snapshotId: string) {
try {
// Verificar que el usuario existe
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, role: true }
});
if (!user) {
this.logger.warn(`Attempt to unmark attendance for non-existent user: ${userId}`);
throw new Error('User not found');
}
// Verificar que el snapshot existe y obtener información de asistentes presentes
const snapshot = await this.prisma.sessionDateSnapshot.findUnique({
where: { id: snapshotId },
include: {
presentAssistants: { select: { id: true } },
presentInstructors: { select: { id: true } }
}
});
if (!snapshot) {
this.logger.warn(`Attempt to unmark attendance for non-existent snapshot: ${snapshotId}`);
throw new Error('Snapshot not found');
}
// Verificar si el usuario está marcado como presente
const isAssistantPresent = snapshot.presentAssistants.some(a => a.id === userId);
const isInstructorPresent = snapshot.presentInstructors.some(i => i.id === userId);
const isMarked = isAssistantPresent || isInstructorPresent;
if (!isMarked) {
this.logger.log(`User ${userId} not marked as present in snapshot ${snapshotId} - idempotent behavior`);
return {
status: 'unmarked',
userId,
snapshotId,
message: 'User was not marked as present',
wasMarked: false
};
}
// Desconectar de la relación correspondiente usando set con el array filtrado
if (isInstructorPresent) {
const remainingInstructors = snapshot.presentInstructors
.filter(i => i.id !== userId)
.map(i => ({ id: i.id }));
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshotId },
data: {
presentInstructors: {
set: remainingInstructors
}
}
});
this.logger.log(`Instructor ${userId} unmarked from snapshot ${snapshotId}`);
} else {
const remainingAssistants = snapshot.presentAssistants
.filter(a => a.id !== userId)
.map(a => ({ id: a.id }));
await this.prisma.sessionDateSnapshot.update({
where: { id: snapshotId },
data: {
presentAssistants: {
set: remainingAssistants
}
}
});
this.logger.log(`Assistant ${userId} unmarked from snapshot ${snapshotId}`);
}
return {
status: 'unmarked',
userId,
snapshotId,
message: 'Attendance unmarked successfully',
wasMarked: true
};
} catch (error: any) {
this.logger.error(`Error unmarking attendance: ${error.message}`, error.stack);
throw error;
}
}
}
@@ -0,0 +1 @@
export class CreateAttendanceDto {}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateAttendanceDto } from './create-attendance.dto';
export class UpdateAttendanceDto extends PartialType(CreateAttendanceDto) {}
@@ -0,0 +1 @@
export class Attendance {}
+35
View File
@@ -0,0 +1,35 @@
import { Controller, Get, NotFoundException, Query, Res } from '@nestjs/common';
import { Response } from 'express';
import { AuthService } from './auth.service';
import { Inject, forwardRef } from '@nestjs/common';
import { WhatsappService } from '../whatsapp/whatsapp.service';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
@Inject(forwardRef(() => WhatsappService))
private readonly whatsappService: WhatsappService,
) { }
@Get('verify')
verificar(@Query('t') token: string) {
return this.authService.verificarToken(token);
}
@Get('getToken')
async getToken() {
if (process.env.NODE_ENV === 'production') {
throw new NotFoundException();
}
return await this.authService.getTokenDev();
}
@Get('init')
async redirectDynamic(@Res() res: Response) {
const whatsappStatus = await this.whatsappService.status().catch(() => null);
return await this.authService.init(res, whatsappStatus?.status ?? null);
}
}
//650030
+33
View File
@@ -0,0 +1,33 @@
import { forwardRef, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from '../prisma/prisma.module';
import { SystemConfigModule } from '../common/system-config.module';
import { SystemConfigService } from '../common/system-config.service';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { WhatsappModule } from '../whatsapp/whatsapp.module';
@Module({
controllers: [AuthController],
providers: [AuthService],
imports: [
JwtModule.registerAsync({
imports: [SystemConfigModule],
useFactory: async (systemConfig: SystemConfigService) => {
// Esperar a que se cargue la configuración
await new Promise(resolve => setTimeout(resolve, 100));
const expirationMinutes = systemConfig.getTokenExpirationMinutes();
return {
secret: process.env.SECRET_JWT,
signOptions: { expiresIn: `${expirationMinutes}m` },
};
},
inject: [SystemConfigService],
}),
PrismaModule,
SystemConfigModule,
forwardRef(() => WhatsappModule),
],
exports: [AuthService],
})
export class AuthModule { }
+142
View File
@@ -0,0 +1,142 @@
import { forwardRef, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { User } from '@prisma/client';
import { customAlphabet } from 'nanoid';
import { PrismaService } from '../prisma/prisma.service';
import { SystemConfigService } from '../common/system-config.service';
import { Response } from 'express';
export enum TokenRedirectURL {
FRONT = 'FRONT',
SESSION = 'SESSION',
USER = 'USER',
WHATSAPP = 'WHATSAPP',
CONFIG = 'CONFIG',
}
const RedirectToUrlParams = {
[TokenRedirectURL.FRONT]: '/',
[TokenRedirectURL.SESSION]: 'clases',
[TokenRedirectURL.USER]: 'usuarios',
[TokenRedirectURL.WHATSAPP]: 'whatsapp',
[TokenRedirectURL.CONFIG]: 'configuracion/sistema',
};
@Injectable()
export class AuthService {
private readonly nanoid = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 10);
private readonly logger = new Logger(AuthService.name);
constructor(
private prisma: PrismaService,
@Inject(forwardRef(() => SystemConfigService))
private systemConfig: SystemConfigService
) { }
async getTokenDev() {
try {
const user = await this.prisma.user.findFirst({
where: {
role: 'ADMIN',
phone: { not: null }
}
});
return await this.generarToken(user?.id, TokenRedirectURL.SESSION);
}
catch (error: any) {
}
}
getLoginLink(user: User): Promise<string> {
return this.generarToken(user.id).then(token => `${process.env.URL_FRONT}?t=${token}`);
}
//NOTE duration opcional para el primer arranque , MINUTOS
async generarToken(userId?: string, redirectUrl?: TokenRedirectURL, duration?: number): Promise<string> {
try {
const now = Date.now();
// Buscar token activo existente
const existing = userId ? await this.prisma.token.findFirst({
where: {
userId,
expiresAt: { gt: new Date(now) },
isActive: { equals: true }
},
})
: false
if (existing) {
return existing.token;
}
// Si no hay uno válido, crear uno nuevo
const token = this.nanoid();
const expirationMinutes = this.systemConfig.getTokenExpirationMinutes();
const expiresAt = new Date(now + (duration ?? expirationMinutes) * 60 * 1000);
await this.prisma.token.create({
data: {
token,
userId,
expiresAt,
...(redirectUrl
? { redirectUrl: RedirectToUrlParams[redirectUrl] }
: { redirectUrl: RedirectToUrlParams[TokenRedirectURL.FRONT] })
},
});
return token;
}
catch (error: any) {
this.logger.error('Error generating token', error);
throw new InternalServerErrorException('Error generating token');
}
}
async verificarToken(token: string): Promise<{ autorizado: boolean; user?: User, redirectUrl?: string }> {
try {
const found = await this.prisma.token.findUnique({
where: { token, isActive: { equals: true } },
include: { user: true },
});
if (!found) return { autorizado: false };
const expired = new Date() > found.expiresAt;
if (expired) return { autorizado: false };
await this.prisma.token.update({ where: { id: found.id }, data: { isActive: false } });
return { autorizado: true, user: found?.user || undefined, redirectUrl: found.redirectUrl! };
} catch {
return { autorizado: false };
}
}
async init(res: Response, whatsappStatus?: 'Correcto' | 'Sin session' | null) {
try {
// Caso 1: primera vez que arranca el sistema (admin creado, token listo)
const firstUrl = this.systemConfig.getFirstUrl();
if (firstUrl) {
return res.redirect(302, firstUrl);
}
// Caso 2: WhatsApp sin sesión → redirigir al front en la sección de WhatsApp
if (whatsappStatus === 'Sin session') {
const admin = await this.prisma.user.findFirst({ where: { role: 'ADMIN' } });
const token = await this.generarToken(admin?.id, TokenRedirectURL.WHATSAPP);
return res.redirect(302, `${process.env.URL_FRONT}?t=${token}`);
}
// Caso 3: sistema normal no dar acceso
return res.sendStatus(403);
} catch (error: any) {
this.logger.error('Error in init', error);
return res.sendStatus(500);
}
}
}
+254
View File
@@ -0,0 +1,254 @@
import {
Controller,
Get,
Post,
Res,
UseGuards,
ForbiddenException,
InternalServerErrorException,
Logger,
UseInterceptors,
UploadedFile,
BadRequestException
} from '@nestjs/common';
import { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import * as path from 'path';
import { BackupService } from './backup.service';
import { AuthTokenGuard } from '~/common/guards/auth-token.guard';
import { User } from '~/common/decoratos/user.decorator';
import { AuthUser } from '~/common/types';
import { UserRole } from '@prisma/client';
import * as fs from 'fs';
import { AuthService, TokenRedirectURL } from '~/auth/auth.service';
@Controller('backup')
@UseGuards(AuthTokenGuard)
export class BackupController {
private readonly logger = new Logger(BackupController.name);
constructor(
private readonly backupService: BackupService,
private readonly authService: AuthService
) { }
@Get('download')
async downloadBackup(
@User() user: AuthUser,
@Res() res: Response,
): Promise<void> {
// Solo administradores pueden descargar backups
if (user?.role !== UserRole.ADMIN) {
throw new ForbiddenException('Solo los administradores pueden descargar backups');
}
let backupInfo: { filePath: string; fileName: string } | null = null;
try {
this.logger.log(`Admin ${user.id} solicitó backup de la base de datos`);
// Limpiar backups antiguos antes de crear uno nuevo
await this.backupService.cleanupOldBackups();
// Crear el backup
backupInfo = await this.backupService.createBackup();
// Verificar que el archivo existe
if (!fs.existsSync(backupInfo.filePath)) {
throw new Error('El archivo de backup no se encontró');
}
const stats = fs.statSync(backupInfo.filePath);
// Configurar headers para la descarga
res.setHeader('Content-Type', 'application/sql');
res.setHeader('Content-Disposition', `attachment; filename="${backupInfo.fileName}"`);
res.setHeader('Content-Length', stats.size);
// Crear stream para enviar el archivo
const fileStream = fs.createReadStream(backupInfo.filePath);
// Manejar errores del stream
fileStream.on('error', (error) => {
this.logger.error('Error leyendo archivo de backup:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Error al leer el archivo de backup' });
}
});
// Enviar el archivo
fileStream.pipe(res);
// Limpiar el archivo después de enviarlo
fileStream.on('end', () => {
if (backupInfo) {
this.logger.log(`Backup enviado exitosamente: ${backupInfo.fileName}`);
// Eliminar el archivo después de un pequeño delay para asegurar que se envió completamente
setTimeout(() => {
this.backupService.deleteBackupFile(backupInfo!.filePath);
}, 1000);
}
});
} catch (error: any) {
this.logger.error('Error generando o enviando backup:', error.message);
// Limpiar archivo si se creó pero hubo error
if (backupInfo?.filePath) {
this.backupService.deleteBackupFile(backupInfo.filePath);
}
if (!res.headersSent) {
throw new InternalServerErrorException('Error al generar el backup de la base de datos');
}
}
}
@Post('upload')
@UseInterceptors(
FileInterceptor('backup', {
storage: diskStorage({
destination: (req, file, cb) => {
const uploadPath = path.join(process.cwd(), 'tmp');
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `restore-${timestamp}-${file.originalname}`;
cb(null, filename);
},
}),
fileFilter: (req, file, cb) => {
// Solo permitir archivos .sql
if (file.mimetype === 'application/sql' ||
file.originalname.endsWith('.sql') ||
file.mimetype === 'text/plain') {
cb(null, true);
} else {
cb(new BadRequestException('Solo se permiten archivos .sql'), false);
}
},
limits: {
fileSize: 100 * 1024 * 1024, // 100MB máximo
},
})
)
async uploadBackup(
@UploadedFile() file: Express.Multer.File,
@User() user: AuthUser,
) {
// Solo administradores pueden restaurar backups
if (user?.role && user?.role !== UserRole.ADMIN) {
throw new ForbiddenException('Solo los administradores pueden restaurar backups');
}
if (!file) {
throw new BadRequestException('No se proporcionó ningún archivo');
}
try {
this.logger.log(`Admin ${user?.id} subió backup: ${file.originalname} (${file.size} bytes)`);
const result = await this.backupService.restoreBackup(file.path, file.originalname);
// El usuario actual puede no existir en el backup restaurado (si el backup es de un
// estado anterior)
let urlCallback = `${process.env.BACKEND_URL ?? ''}/auth/init`;
try {
const token = await this.authService.generarToken(user?.id, TokenRedirectURL.FRONT);
urlCallback = `${process.env.URL_FRONT}?t=${token}`;
} catch {
this.logger.warn('El usuario actual no existe en el backup restaurado. Redirigiendo a /auth/init.');
}
return {
success: true,
message: 'Backup restaurado exitosamente',
urlCallback,
details: result,
timestamp: new Date().toISOString(),
};
} catch (error: any) {
this.logger.error('Error restaurando backup:', error.message);
// Limpiar archivo en caso de error
this.backupService.deleteBackupFile(file.path);
throw new InternalServerErrorException('Error al restaurar el backup');
}
}
@Get('status')
async getBackupStatus(@User() user: AuthUser) {
// Solo administradores pueden ver el estado
if (user?.role !== UserRole.ADMIN) {
throw new ForbiddenException('Solo los administradores pueden ver el estado de backups');
}
return {
message: 'Servicio de backup disponible',
timestamp: new Date().toISOString(),
databaseUrl: process.env.DATABASE_URL ? 'Configurado' : 'No configurado',
};
}
@Post('validate')
@UseInterceptors(
FileInterceptor('backup', {
storage: diskStorage({
destination: (req, file, cb) => {
const uploadPath = path.join(process.cwd(), 'tmp');
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `validate-${timestamp}-${file.originalname}`;
cb(null, filename);
},
}),
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/sql' ||
file.originalname.endsWith('.sql') ||
file.mimetype === 'text/plain') {
cb(null, true);
} else {
cb(new BadRequestException('Solo se permiten archivos .sql'), false);
}
},
limits: {
fileSize: 100 * 1024 * 1024, // 100MB máximo
},
})
)
async validateBackupFile(
@UploadedFile() file: Express.Multer.File,
@User() user: AuthUser,
) {
// Solo administradores pueden validar backups
if (user?.role !== UserRole.ADMIN) {
throw new ForbiddenException('Solo los administradores pueden validar backups');
}
if (!file) {
throw new BadRequestException('No se proporcionó ningún archivo');
}
try {
const isValid = await this.backupService.validateBackupFile(file.path);
return {
valid: isValid.valid,
message: isValid.message,
details: isValid.details,
};
} catch (error: any) {
throw new BadRequestException('Error al validar el archivo');
} finally {
// Siempre limpiar el archivo de validación
this.backupService.deleteBackupFile(file.path);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { BackupController } from './backup.controller';
import { BackupService } from './backup.service';
import { AuthModule } from '~/auth/auth.module';
@Module({
imports: [PrismaModule, AuthModule],
controllers: [BackupController],
providers: [BackupService],
exports: [BackupService],
})
export class BackupModule { }
+415
View File
@@ -0,0 +1,415 @@
import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
import { PrismaService } from '../prisma/prisma.service';
const execAsync = promisify(exec);
@Injectable()
export class BackupService {
private readonly logger = new Logger(BackupService.name);
constructor(private readonly prisma: PrismaService) {}
private cleanDatabaseUrl(databaseUrl: string): string {
try {
const url = new URL(databaseUrl);
// Remover parámetros de query que pg_dump no reconoce
url.search = '';
return url.toString();
} catch (error) {
// Si no es una URL válida, intentar limpiar manualmente
return databaseUrl.split('?')[0];
}
}
async createBackup(): Promise<{ filePath: string; fileName: string }> {
try {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL no está configurada');
}
// Limpiar la URL de parámetros que pg_dump no reconoce
const cleanDatabaseUrl = this.cleanDatabaseUrl(databaseUrl);
// Generar nombre único para el archivo
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const fileName = `backup-${timestamp}.sql`;
const backupDir = path.join(process.cwd(), 'tmp');
const filePath = path.join(backupDir, fileName);
// Crear directorio tmp si no existe
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
// Ejecutar pg_dump (instalado en el contenedor)
this.logger.log('Iniciando backup de la base de datos...');
const command = `pg_dump "${cleanDatabaseUrl}" -f "${filePath}"`;
await execAsync(command);
// Verificar que el archivo se creó correctamente
if (!fs.existsSync(filePath)) {
throw new Error('El archivo de backup no se creó correctamente');
}
const stats = fs.statSync(filePath);
this.logger.log(`Backup creado exitosamente: ${fileName} (${stats.size} bytes)`);
return { filePath, fileName };
} catch (error: any) {
this.logger.error('Error creando backup:', error.message);
throw new InternalServerErrorException('Error al crear el backup de la base de datos');
}
}
async restoreBackup(filePath: string, originalName: string): Promise<{ tablesRestored: string; fileSize: number }> {
try {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL no está configurada');
}
// Limpiar la URL de parámetros que psql no reconoce
const cleanDatabaseUrl = this.cleanDatabaseUrl(databaseUrl);
// Verificar que el archivo existe
if (!fs.existsSync(filePath)) {
throw new Error('Archivo de backup no encontrado');
}
const stats = fs.statSync(filePath);
this.logger.log(`Iniciando restauración desde: ${originalName} (${stats.size} bytes)`);
// DEBUG: Analizar contenido del backup
await this.debugBackupContent(filePath);
// **IMPORTANTE: Esto eliminará TODOS los datos actuales y desconectará la aplicación temporalmente**
this.logger.log('Eliminando base de datos completa y recreando desde cero...');
// Paso 1: Obtener el nombre de la base de datos de la URL
const dbUrl = new URL(cleanDatabaseUrl);
const dbName = dbUrl.pathname.substring(1); // Remover el "/"
// Paso 2: Conectar a postgres (base de datos administrativa) para eliminar y recrear
const adminUrl = cleanDatabaseUrl.replace(`/${dbName}`, '/postgres');
// Paso 3: Terminar todas las conexiones activas a la base de datos (incluyendo la aplicación)
this.logger.log(`⚠️ Terminando TODAS las conexiones a la base de datos ${dbName}...`);
const terminateConnectionsCommand = `psql "${adminUrl}" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${dbName}' AND pid <> pg_backend_pid();"`;
try {
const { stdout: terminated } = await execAsync(terminateConnectionsCommand);
this.logger.log('Conexiones terminadas:', terminated.trim());
} catch (error: any) {
this.logger.warn('Error terminando conexiones (puede ser normal):', error.message);
}
// Paso 4: Esperar a que las conexiones se cierren
this.logger.log('Esperando que las conexiones se cierren completamente...');
await new Promise(resolve => setTimeout(resolve, 2000));
// Paso 5: Eliminar la base de datos actual
this.logger.log(`🗑️ Eliminando base de datos ${dbName}...`);
const dropCommand = `psql "${adminUrl}" -c "DROP DATABASE IF EXISTS \"${dbName}\";"`;
await execAsync(dropCommand);
// Paso 6: Crear la base de datos nuevamente (vacía)
this.logger.log(`🔨 Creando base de datos ${dbName} limpia...`);
const createCommand = `psql "${adminUrl}" -c "CREATE DATABASE \"${dbName}\";"`;
await execAsync(createCommand);
// Paso 7: Restaurar el backup completo en la base de datos limpia
this.logger.log('📦 Restaurando backup completo en la nueva base de datos...');
const restoreCommand = `psql "${cleanDatabaseUrl}" -f "${filePath}"`;
const { stdout, stderr } = await execAsync(restoreCommand);
// Paso 8: Verificar que TODAS las tablas fueron restauradas correctamente
this.logger.log('Verificando que todas las tablas del schema fueron restauradas...');
const verifyCommand = `psql "${cleanDatabaseUrl}" -c "
SELECT 'User' as tabla, COUNT(*) as registros FROM \\"User\\"
UNION ALL SELECT 'AccessLog', COUNT(*) FROM \\"AccessLog\\"
UNION ALL SELECT 'Token', COUNT(*) FROM \\"Token\\"
UNION ALL SELECT 'Session', COUNT(*) FROM \\"Session\\"
UNION ALL SELECT 'SessionPriceHistory', COUNT(*) FROM \\"SessionPriceHistory\\"
UNION ALL SELECT 'SessionDateRange', COUNT(*) FROM \\"SessionDateRange\\"
UNION ALL SELECT 'SessionDateSnapshot', COUNT(*) FROM \\"SessionDateSnapshot\\"
UNION ALL SELECT 'Invoice', COUNT(*) FROM \\"Invoice\\"
UNION ALL SELECT 'SystemConfig', COUNT(*) FROM \\"SystemConfig\\"
ORDER BY tabla;
"`;
try {
const { stdout: verifyOutput } = await execAsync(verifyCommand);
this.logger.log('✅ Verificación completa de todas las tablas:');
this.logger.log(verifyOutput.trim());
} catch (error: any) {
this.logger.error('❌ Error en verificación de tablas:', error.message);
throw new Error('La restauración no incluyó todas las tablas esperadas');
}
// Paso 9: Verificar integridad de relaciones many-to-many
this.logger.log('Verificando tablas de relación many-to-many...');
const verifyRelationsCommand = `psql "${cleanDatabaseUrl}" -c "
SELECT
'_InstructorSessions' as relacion, COUNT(*) as registros
FROM \\"_InstructorSessions\\"
UNION ALL
SELECT '_AssistantSessions', COUNT(*)
FROM \\"_AssistantSessions\\"
UNION ALL
SELECT '_PresentInstructorsOnDate', COUNT(*)
FROM \\"_PresentInstructorsOnDate\\"
UNION ALL
SELECT '_PresentAssistantsOnDate', COUNT(*)
FROM \\"_PresentAssistantsOnDate\\"
UNION ALL
SELECT '_SubstituteInstructorsOnDate', COUNT(*)
FROM \\"_SubstituteInstructorsOnDate\\";
"`;
try {
const { stdout: relationsOutput } = await execAsync(verifyRelationsCommand);
this.logger.log('✅ Verificación de relaciones many-to-many:');
this.logger.log(relationsOutput.trim());
} catch (error: any) {
this.logger.warn('⚠️ Error verificando relaciones (puede ser normal si están vacías):', error.message);
}
// Extraer información del resultado
const output = stdout + stderr;
// Contar las operaciones procesadas con más detalle
const createTableMatches = output.match(/CREATE TABLE/g) || [];
const copyMatches = output.match(/COPY \d+/g) || [];
const createIndexMatches = output.match(/CREATE INDEX/g) || [];
const alterTableMatches = output.match(/ALTER TABLE/g) || [];
this.logger.log(`📊 Resumen de operaciones completadas:`);
this.logger.log(`- Tablas creadas: ${createTableMatches.length}`);
this.logger.log(`- Operaciones COPY (datos): ${copyMatches.length}`);
this.logger.log(`- Índices creados: ${createIndexMatches.length}`);
this.logger.log(`- Constraints/FKs: ${alterTableMatches.length}`);
// Verificar que tenemos al menos las 9 tablas principales + 5 tablas de relación
const expectedMinTables = 14; // 9 modelos + 5 relaciones many-to-many
if (createTableMatches.length < expectedMinTables) {
this.logger.warn(`⚠️ Se esperaban al menos ${expectedMinTables} tablas, se encontraron ${createTableMatches.length}`);
}
if (output.includes('ERROR') || stderr.includes('ERROR')) {
this.logger.error('❌ Se encontraron errores en la restauración:', stderr);
throw new Error('La restauración completó con errores');
}
// Reconectar Prisma: el pool quedó roto porque se terminaron todas las
// conexiones durante el DROP/CREATE DATABASE
this.logger.log('🔌 Reconectando Prisma al nuevo estado de la base de datos...');
await this.prisma.$disconnect();
await this.prisma.$connect();
this.logger.log('✅ Prisma reconectado exitosamente');
// Limpiar archivo después de restaurar
setTimeout(() => {
this.deleteBackupFile(filePath);
}, 1000);
return {
tablesRestored: `${createTableMatches.length} tablas, ${copyMatches.length} operaciones de datos`,
fileSize: stats.size,
};
} catch (error: any) {
this.logger.error('Error restaurando backup:', error.message);
// Limpiar archivo en caso de error
this.deleteBackupFile(filePath);
throw new InternalServerErrorException('Error al restaurar el backup de la base de datos');
}
}
async validateBackupFile(filePath: string): Promise<{ valid: boolean; message: string; details?: any }> {
try {
if (!fs.existsSync(filePath)) {
return { valid: false, message: 'Archivo no encontrado' };
}
const stats = fs.statSync(filePath);
if (stats.size === 0) {
return { valid: false, message: 'El archivo está vacío' };
}
// Leer las primeras líneas del archivo para validar el formato
const fileContent = fs.readFileSync(filePath, 'utf8');
const lines = fileContent.split('\n').slice(0, 20); // Primeras 20 líneas
// Verificar que parece un dump de PostgreSQL
const hasSQLHeader = lines.some(line =>
line.includes('PostgreSQL database dump') ||
line.includes('pg_dump') ||
line.startsWith('--') ||
line.includes('CREATE TABLE') ||
line.includes('INSERT INTO') ||
line.includes('COPY ')
);
if (!hasSQLHeader) {
return {
valid: false,
message: 'El archivo no parece ser un backup válido de PostgreSQL'
};
}
return {
valid: true,
message: 'Archivo válido',
details: {
size: stats.size,
sizeFormatted: this.formatBytes(stats.size),
lines: fileContent.split('\n').length,
}
};
} catch (error: any) {
this.logger.error('Error validando archivo:', error.message);
return {
valid: false,
message: 'Error al procesar el archivo'
};
}
}
private formatBytes(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async cleanupOldBackups(maxAge: number = 24 * 60 * 60 * 1000): Promise<void> {
try {
const backupDir = path.join(process.cwd(), 'tmp');
if (!fs.existsSync(backupDir)) {
return;
}
const files = fs.readdirSync(backupDir);
const now = Date.now();
for (const file of files) {
if (file.startsWith('backup-') && file.endsWith('.sql')) {
const filePath = path.join(backupDir, file);
const stats = fs.statSync(filePath);
if (now - stats.mtime.getTime() > maxAge) {
fs.unlinkSync(filePath);
this.logger.log(`Backup antiguo eliminado: ${file}`);
}
}
}
} catch (error: any) {
this.logger.warn('Error limpiando backups antiguos:', error.message);
}
}
async debugBackupContent(filePath: string): Promise<void> {
try {
this.logger.log('=== DEBUG: Analizando contenido del backup ===');
// Verificar todas las tablas principales del schema
const tables = [
'User', 'AccessLog', 'Token', 'Session',
'SessionPriceHistory', 'SessionDateRange', 'SessionDateSnapshot',
'Invoice', 'SystemConfig'
];
for (const table of tables) {
const grepCommand = `grep -c 'COPY public."${table}"' "${filePath}" || echo "0"`;
try {
const { stdout } = await execAsync(grepCommand);
const count = parseInt(stdout.trim());
if (count > 0) {
this.logger.log(`✅ Tabla "${table}" encontrada en backup`);
} else {
this.logger.warn(`⚠️ Tabla "${table}" NO encontrada en backup`);
}
} catch (error: any) {
this.logger.warn(`❌ Error verificando tabla "${table}":`, error.message);
}
}
// Verificar tablas de relación many-to-many
const relationTables = [
'_InstructorSessions', '_AssistantSessions',
'_PresentInstructorsOnDate', '_PresentAssistantsOnDate',
'_SubstituteInstructorsOnDate'
];
this.logger.log('--- Verificando tablas de relación ---');
for (const table of relationTables) {
const grepCommand = `grep -c 'COPY public."${table}"' "${filePath}" || echo "0"`;
try {
const { stdout } = await execAsync(grepCommand);
const count = parseInt(stdout.trim());
if (count > 0) {
this.logger.log(`✅ Relación "${table}" encontrada en backup`);
} else {
this.logger.log(`️ Relación "${table}" vacía o no encontrada (normal si no hay datos)`);
}
} catch (error: any) {
this.logger.warn(`Error verificando relación "${table}":`, error.message);
}
}
// Contar líneas totales
const wcCommand = `wc -l "${filePath}"`;
const { stdout: lineCount } = await execAsync(wcCommand);
this.logger.log(`📄 Total de líneas en backup: ${lineCount.trim()}`);
// Verificar que tiene la estructura completa de PostgreSQL dump
const structureChecks = [
{ pattern: 'PostgreSQL database dump', name: 'Header de PostgreSQL' },
{ pattern: 'CREATE TABLE', name: 'Definiciones de tablas' },
{ pattern: 'CREATE INDEX', name: 'Índices' },
{ pattern: 'ALTER TABLE.*ADD CONSTRAINT', name: 'Foreign Keys' },
{ pattern: 'CREATE TYPE', name: 'Enums (tipos personalizados)' },
];
for (const check of structureChecks) {
const grepCommand = `grep -c '${check.pattern}' "${filePath}" || echo "0"`;
try {
const { stdout } = await execAsync(grepCommand);
const count = parseInt(stdout.trim());
this.logger.log(`${count > 0 ? '✅' : '⚠️ '} ${check.name}: ${count} encontrados`);
} catch (error: any) {
this.logger.warn(`Error verificando ${check.name}`);
}
}
this.logger.log('=== FIN DEBUG ===');
} catch (error: any) {
this.logger.error('Error en debug:', error.message);
}
}
deleteBackupFile(filePath: string): void {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
this.logger.log(`Archivo de backup eliminado: ${path.basename(filePath)}`);
}
} catch (error: any) {
this.logger.warn('Error eliminando archivo de backup:', error.message);
}
}
}
@@ -0,0 +1,9 @@
// src/common/decorators/user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user || null;
},
);
+46
View File
@@ -0,0 +1,46 @@
import {
CanActivate,
ExecutionContext,
Injectable,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { UserRole } from '@prisma/client';
@Injectable()
export class AdminGuard implements CanActivate {
constructor(private prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers['authorization'];
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) {
throw new UnauthorizedException('Token no proporcionado');
}
const token = authHeader.substring(7).trim();
const found = await this.prisma.token.findUnique({
where: { token },
include: { user: true },
});
if (!found) {
throw new UnauthorizedException('Token inválido');
}
if (new Date() > found.expiresAt) {
throw new UnauthorizedException('Token expirado');
}
// Verificar que el usuario sea ADMIN
// if (found?.user?.role !== UserRole.ADMIN) {
// throw new ForbiddenException('Acceso denegado: se requieren permisos de administrador');
// }
request.user = found.user;
return true;
}
}
@@ -0,0 +1,40 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class AuthTokenGuard implements CanActivate {
constructor(private prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers['authorization'];
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer ')) {
throw new UnauthorizedException('Token no proporcionado');
}
const token = authHeader.substring(7).trim(); // lo que viene después de "Bearer "
const found = await this.prisma.token.findUnique({
where: { token },
include: { user: true },
});
if (!found) {
throw new UnauthorizedException('Token inválido');
}
if (new Date() > found.expiresAt) {
throw new UnauthorizedException('Token expirado');
}
// guardamos el usuario en request para que @User() lo pueda leer
request.user = found?.user || undefined;
return true;
}
}
@@ -0,0 +1,227 @@
import { Body, Controller, Get, Patch, UseGuards, ValidationPipe, UsePipes, Post } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiProperty } from '@nestjs/swagger';
import { SystemConfigService } from './system-config.service';
import { AdminGuard } from './guards/admin.guard';
import { User } from './decoratos/user.decorator';
import { AuthUser } from './types';
import { IsOptional, IsInt, Min, Max, IsDateString } from 'class-validator';
class UpdateSystemConfigDto {
@ApiProperty({
description: 'Intervalo en minutos para desactivar sesiones vencidas',
example: 30,
required: false,
minimum: 1,
})
@IsOptional()
@IsInt()
@Min(1)
sessionCleanupIntervalMinutes?: number;
@ApiProperty({
description: 'Día del mes (1-28) en que se generan las facturas mensuales',
example: 1,
required: false,
minimum: 1,
maximum: 28,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(28)
invoiceGenerationDayOfMonth?: number;
@ApiProperty({
description: 'Tiempo de expiración de los tokens en minutos',
example: 30,
required: false,
minimum: 1,
})
@IsOptional()
@IsInt()
@Min(1)
tokenExpirationMinutes?: number;
@ApiProperty({
description: 'Intervalo en minutos para limpiar tokens expirados',
example: 60,
required: false,
minimum: 1,
})
@IsOptional()
@IsInt()
@Min(1)
tokenCleanupIntervalMinutes?: number;
@ApiProperty({
description: 'Intervalo en días para actualizar fotos de perfil de WhatsApp',
example: 2,
required: false,
minimum: 1,
})
@IsOptional()
@IsInt()
@Min(1)
profilePictureUpdateIntervalDays?: number;
}
class ForceInvoicesDto {
@ApiProperty({
description: 'Fecha del mes para el cual generar facturas (formato ISO 8601)',
example: '2025-11-01T00:00:00.000Z',
required: true,
})
@IsDateString()
targetMonth!: string;
}
@ApiTags('Configuración del Sistema')
@Controller('system-config')
export class SystemConfigController {
constructor(private readonly systemConfigService: SystemConfigService) { }
@Get()
//@UseGuards(AdminGuard)
@ApiOperation({
summary: 'Obtener configuración actual del sistema',
description: 'Retorna todos los parámetros de configuración actuales que controlan los trabajos programados y tokens'
})
@ApiResponse({
status: 200,
description: 'Configuración obtenida exitosamente',
schema: {
example: {
id: 'clxxx123456',
sessionCleanupIntervalMinutes: 30,
invoiceGenerationDayOfMonth: 1,
tokenExpirationMinutes: 30,
tokenCleanupIntervalMinutes: 60,
profilePictureUpdateIntervalDays: 2,
createdAt: '2025-11-10T00:00:00.000Z',
updatedAt: '2025-11-10T00:00:00.000Z'
}
}
})
@ApiResponse({ status: 401, description: 'No autorizado - Token no proporcionado o inválido' })
@ApiResponse({ status: 403, description: 'Acceso denegado - Se requieren permisos de administrador' })
//@ApiBearerAuth()
async getConfig() {
return this.systemConfigService.getConfig();
}
@Patch()
@UseGuards(AdminGuard)
@ApiOperation({
summary: 'Actualizar configuración del sistema',
description: 'Actualiza uno o más parámetros de configuración. Los cambios toman efecto inmediatamente, excepto tokenExpirationMinutes que requiere reinicio.'
})
@ApiResponse({
status: 200,
description: 'Configuración actualizada exitosamente',
schema: {
example: {
id: 'clxxx123456',
sessionCleanupIntervalMinutes: 45,
invoiceGenerationDayOfMonth: 5,
tokenExpirationMinutes: 60,
tokenCleanupIntervalMinutes: 120,
profilePictureUpdateIntervalDays: 3,
createdAt: '2025-11-10T00:00:00.000Z',
updatedAt: '2025-11-10T12:30:00.000Z',
warnings: [
'El cambio en tokenExpirationMinutes requiere reiniciar la aplicación para que JwtModule lo tome en cuenta.'
]
}
}
})
@ApiResponse({ status: 400, description: 'Datos inválidos - Revisa las validaciones de cada campo' })
@ApiResponse({ status: 401, description: 'No autorizado - Token no proporcionado o inválido' })
@ApiResponse({ status: 403, description: 'Acceso denegado - Se requieren permisos de administrador' })
@ApiBearerAuth()
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
async updateConfig(
@Body() data: UpdateSystemConfigDto,
@User() user: AuthUser,
) {
console.log('=== CONTROLLER UPDATE CONFIG ===');
console.log('Body received:', JSON.stringify(data, null, 2));
console.log('Body type:', typeof data);
console.log('Body constructor:', data.constructor.name);
console.log('User:', JSON.stringify(user, null, 2));
console.log('Body keys:', Object.keys(data));
console.log('Body values:', Object.values(data));
console.log('invoiceGenerationDayOfMonth type:', typeof data.invoiceGenerationDayOfMonth);
console.log('invoiceGenerationDayOfMonth value:', data.invoiceGenerationDayOfMonth);
console.log('================================');
const result = await this.systemConfigService.updateConfig(data, user);
// Advertencia si se cambió tokenExpirationMinutes
const warnings = [];
if (data.tokenExpirationMinutes !== undefined) {
warnings.push('El cambio en tokenExpirationMinutes requiere reiniciar la aplicación para que JwtModule lo tome en cuenta.');
}
return {
...result,
...(warnings.length > 0 && { warnings })
};
}
@Get('reload')
@UseGuards(AdminGuard)
@ApiOperation({
summary: 'Recargar configuración desde la base de datos',
description: 'Fuerza la recarga de la configuración desde la base de datos en memoria. Útil después de cambios manuales en la BD.'
})
@ApiResponse({
status: 200,
description: 'Configuración recargada exitosamente',
schema: {
example: {
message: 'Configuración recargada exitosamente'
}
}
})
@ApiResponse({ status: 401, description: 'No autorizado - Token no proporcionado o inválido' })
@ApiResponse({ status: 403, description: 'Acceso denegado - Se requieren permisos de administrador' })
@ApiBearerAuth()
async reloadConfig() {
await this.systemConfigService.reloadConfig();
return { message: 'Configuración recargada exitosamente' };
}
@Post('force-invoices')
@UseGuards(AdminGuard)
@ApiOperation({
summary: 'Forzar generación de facturas para un mes específico',
description: 'Genera facturas para todas las sesiones activas y sus asistentes en el mes especificado. Solo crea facturas que no existan previamente.'
})
@ApiResponse({
status: 200,
description: 'Facturas generadas exitosamente',
schema: {
example: {
message: 'Facturas generadas exitosamente para 2025-11',
totalCreated: 15,
details: [
{ sessionId: 'xxx', assistantId: 'yyy', invoiceId: 'zzz' }
]
}
}
})
@ApiResponse({ status: 400, description: 'Datos inválidos - Fecha requerida' })
@ApiResponse({ status: 401, description: 'No autorizado' })
@ApiResponse({ status: 403, description: 'Acceso denegado' })
@ApiBearerAuth()
async forceGenerateInvoices(
@Body() data: ForceInvoicesDto,
@User() user: AuthUser,
) {
const result = await this.systemConfigService.forceGenerateMonthlyInvoices(
new Date(data.targetMonth),
user
);
return result;
}
}
@@ -0,0 +1,20 @@
import { forwardRef, Module } from '@nestjs/common';
import { SystemConfigService } from './system-config.service';
import { SystemConfigController } from './system-config.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { InvoicesModule } from '~/invoices/invoices.module';
import { UserModule } from '~/user/user.module';
import { AuthModule } from '~/auth/auth.module';
@Module({
controllers: [SystemConfigController],
providers: [SystemConfigService],
imports: [
PrismaModule,
forwardRef(() => InvoicesModule),
forwardRef(() => UserModule),
forwardRef(() => AuthModule),
],
exports: [SystemConfigService],
})
export class SystemConfigModule { }
+295
View File
@@ -0,0 +1,295 @@
import { forwardRef, Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { PrismaService } from '~/prisma/prisma.service';
import { AuthUser } from './types';
import { InvoicesService } from '~/invoices/invoices.service';
import dayjs from 'dayjs';
import { UserService } from '~/user/user.service';
import { UpsertUserDto } from '~/user/user.dto';
import { AuthService } from '~/auth/auth.service';
@Injectable()
export class SystemConfigService implements OnModuleInit {
private readonly logger = new Logger(SystemConfigService.name);
private config: {
sessionCleanupIntervalMinutes: number;
invoiceGenerationDayOfMonth: number;
tokenExpirationMinutes: number;
tokenCleanupIntervalMinutes: number;
profilePictureUpdateIntervalDays: number;
} | null = null;
private firstUrl: string | null = null;
constructor(
private readonly prisma: PrismaService,
@Inject(forwardRef(() => InvoicesService))
private readonly invoicesService: InvoicesService,
private readonly userService: UserService,
@Inject(forwardRef(() => AuthService))
private readonly authService: AuthService,
) { }
async onModuleInit() {
await this.loadConfig();
}
/**
* Carga la configuración desde la base de datos.
* Si no existe, crea una configuración por defecto.
*/
private async loadConfig() {
try {
let config = await this.prisma.systemConfig.findFirst();
if (!config) {
//NOTE cREO EL PRIMER USUARIO DEL SISTEMA
const dataUser: UpsertUserDto = {
name: 'ADMIN',
role: 'ADMIN',
customId: ''
}
const user = await this.userService.upsert(dataUser)
const token = await this.authService.generarToken(user.id, undefined, 1440); // Token válido por 24 horas
this.logger.log(`Primer usuario ADMIN creado. token para iniciar sesión: ${token}`);
this.firstUrl = `${process.env.URL_FRONT}?t=${token}`;
this.logger.log('No se encontró configuración del sistema, creando valores por defecto...');
config = await this.prisma.systemConfig.create({
data: {
sessionCleanupIntervalMinutes: 30,
invoiceGenerationDayOfMonth: 1,
tokenExpirationMinutes: 30,
tokenCleanupIntervalMinutes: 60,
profilePictureUpdateIntervalDays: 2,
},
});
}
this.config = {
sessionCleanupIntervalMinutes: config.sessionCleanupIntervalMinutes,
invoiceGenerationDayOfMonth: config.invoiceGenerationDayOfMonth,
tokenExpirationMinutes: config.tokenExpirationMinutes,
tokenCleanupIntervalMinutes: config.tokenCleanupIntervalMinutes,
profilePictureUpdateIntervalDays: config.profilePictureUpdateIntervalDays,
};
this.logger.log('Configuración del sistema cargada exitosamente');
} catch (err: any) {
this.logger.error('Error cargando configuración del sistema', err?.stack || err);
// Valores por defecto en caso de error
this.config = {
sessionCleanupIntervalMinutes: 30,
invoiceGenerationDayOfMonth: 1,
tokenExpirationMinutes: 30,
tokenCleanupIntervalMinutes: 60,
profilePictureUpdateIntervalDays: 2,
};
}
}
/**
* Recarga la configuración desde la base de datos
*/
async reloadConfig() {
await this.loadConfig();
}
/**
* Obtiene el intervalo de limpieza de sesiones en minutos
*/
getSessionCleanupInterval(): number {
return this.config?.sessionCleanupIntervalMinutes ?? 30;
}
/**
* Obtiene el día del mes para generar facturas (1-28)
*/
getInvoiceGenerationDay(): number {
return this.config?.invoiceGenerationDayOfMonth ?? 1;
}
/**
* Obtiene el tiempo de expiración de tokens en minutos
*/
getTokenExpirationMinutes(): number {
return this.config?.tokenExpirationMinutes ?? 30;
}
/**
* Obtiene el intervalo de limpieza de tokens en minutos
*/
getTokenCleanupInterval(): number {
return this.config?.tokenCleanupIntervalMinutes ?? 60;
}
/**
* Obtiene el intervalo de actualización de fotos de perfil en días
*/
getProfilePictureUpdateInterval(): number {
return this.config?.profilePictureUpdateIntervalDays ?? 2;
}
/**
* Actualiza la configuración del sistema
*/
async updateConfig(data: {
sessionCleanupIntervalMinutes?: number;
invoiceGenerationDayOfMonth?: number;
tokenExpirationMinutes?: number;
tokenCleanupIntervalMinutes?: number;
profilePictureUpdateIntervalDays?: number;
}, actionBy?: AuthUser) {
const actionByInfo = actionBy ? `${actionBy.name || actionBy.id} (${actionBy.role})` : 'Unknown';
this.logger.log(`Update system config request - data: ${JSON.stringify(data)}, actionBy: ${actionByInfo}`);
try {
// Validaciones
if (data.invoiceGenerationDayOfMonth !== undefined) {
if (data.invoiceGenerationDayOfMonth < 1 || data.invoiceGenerationDayOfMonth > 28) {
this.logger.warn(`Invalid invoiceGenerationDayOfMonth: ${data.invoiceGenerationDayOfMonth}, attemptedBy: ${actionByInfo}`);
throw new Error('El día de generación de facturas debe estar entre 1 y 28');
}
}
if (data.sessionCleanupIntervalMinutes !== undefined && data.sessionCleanupIntervalMinutes < 1) {
this.logger.warn(`Invalid sessionCleanupIntervalMinutes: ${data.sessionCleanupIntervalMinutes}, attemptedBy: ${actionByInfo}`);
throw new Error('El intervalo de limpieza de sesiones debe ser mayor a 0');
}
if (data.tokenExpirationMinutes !== undefined && data.tokenExpirationMinutes < 1) {
this.logger.warn(`Invalid tokenExpirationMinutes: ${data.tokenExpirationMinutes}, attemptedBy: ${actionByInfo}`);
throw new Error('El tiempo de expiración de tokens debe ser mayor a 0');
}
if (data.tokenCleanupIntervalMinutes !== undefined && data.tokenCleanupIntervalMinutes < 1) {
this.logger.warn(`Invalid tokenCleanupIntervalMinutes: ${data.tokenCleanupIntervalMinutes}, attemptedBy: ${actionByInfo}`);
throw new Error('El intervalo de limpieza de tokens debe ser mayor a 0');
}
if (data.profilePictureUpdateIntervalDays !== undefined && data.profilePictureUpdateIntervalDays < 1) {
this.logger.warn(`Invalid profilePictureUpdateIntervalDays: ${data.profilePictureUpdateIntervalDays}, attemptedBy: ${actionByInfo}`);
throw new Error('El intervalo de actualización de fotos debe ser mayor a 0');
}
this.logger.log(`Validations passed, searching for existing config - actionBy: ${actionByInfo}`);
// Buscar configuración existente
const existing = await this.prisma.systemConfig.findFirst();
if (!existing) {
this.logger.error(`System config not found in database - attemptedBy: ${actionByInfo}`);
throw new Error('No se encontró configuración del sistema');
}
this.logger.log(`Found existing config - id: ${existing.id}, updating with data: ${JSON.stringify(data)}, by: ${actionByInfo}`);
this.logger.log(`Current values in DB - sessionCleanup: ${existing.sessionCleanupIntervalMinutes}, invoiceDay: ${existing.invoiceGenerationDayOfMonth}, tokenExp: ${existing.tokenExpirationMinutes}, tokenCleanup: ${existing.tokenCleanupIntervalMinutes}`);
console.log('=== SERVICE BEFORE PRISMA UPDATE ===');
console.log('Existing ID:', existing.id);
console.log('Data to update:', JSON.stringify(data, null, 2));
console.log('Data keys:', Object.keys(data));
console.log('Data values:', Object.values(data));
console.log('Data object type:', typeof data);
console.log('Is data empty?', Object.keys(data).length === 0);
// Construir el objeto de actualización explícitamente
const updateData: any = {};
if (data.sessionCleanupIntervalMinutes !== undefined) {
updateData.sessionCleanupIntervalMinutes = data.sessionCleanupIntervalMinutes;
console.log('Adding sessionCleanupIntervalMinutes:', data.sessionCleanupIntervalMinutes);
}
if (data.invoiceGenerationDayOfMonth !== undefined) {
updateData.invoiceGenerationDayOfMonth = data.invoiceGenerationDayOfMonth;
console.log('Adding invoiceGenerationDayOfMonth:', data.invoiceGenerationDayOfMonth);
}
if (data.tokenExpirationMinutes !== undefined) {
updateData.tokenExpirationMinutes = data.tokenExpirationMinutes;
console.log('Adding tokenExpirationMinutes:', data.tokenExpirationMinutes);
}
if (data.tokenCleanupIntervalMinutes !== undefined) {
updateData.tokenCleanupIntervalMinutes = data.tokenCleanupIntervalMinutes;
console.log('Adding tokenCleanupIntervalMinutes:', data.tokenCleanupIntervalMinutes);
}
if (data.profilePictureUpdateIntervalDays !== undefined) {
updateData.profilePictureUpdateIntervalDays = data.profilePictureUpdateIntervalDays;
console.log('Adding profilePictureUpdateIntervalDays:', data.profilePictureUpdateIntervalDays);
}
console.log('Final updateData object:', JSON.stringify(updateData, null, 2));
console.log('UpdateData keys:', Object.keys(updateData));
console.log('====================================');
// Actualizar configuración
const updated = await this.prisma.systemConfig.update({
where: { id: existing.id },
data: updateData,
});
console.log('=== SERVICE AFTER PRISMA UPDATE ===');
console.log('Updated result:', JSON.stringify(updated, null, 2));
console.log('===================================');
this.logger.log(`Database updated successfully - configId: ${existing.id}, newValues: ${JSON.stringify(updated)}, by: ${actionByInfo}`);
// Recargar configuración en memoria
await this.loadConfig();
this.logger.log(`Configuration reloaded in memory - by: ${actionByInfo}`);
this.logger.log(`System config update completed successfully - by: ${actionByInfo}`);
return updated;
} catch (err: any) {
this.logger.error(`Error updating system config by ${actionByInfo}: ${err.message}`, err?.stack || err);
throw err;
}
}
/**
* Obtiene toda la configuración
*/
async getConfig() {
return this.prisma.systemConfig.findFirst();
}
/**
* Fuerza la generación de facturas para un mes específico
*/
async forceGenerateMonthlyInvoices(targetMonth: Date, actionBy?: AuthUser) {
const actionByInfo = actionBy ? `${actionBy.name || actionBy.id} (${actionBy.role})` : 'Unknown';
const monthStr = dayjs(targetMonth).format('YYYY-MM');
this.logger.log(`Forzando generación de facturas para ${monthStr} - actionBy: ${actionByInfo}`);
try {
const invoices = await this.invoicesService.createMonthlyInvoicesForActiveAssistants(targetMonth);
this.logger.log(`Facturas generadas exitosamente para ${monthStr} - total: ${invoices.length}, by: ${actionByInfo}`);
return {
message: `Facturas generadas exitosamente para ${monthStr}`,
totalCreated: invoices.length,
details: invoices.map(inv => ({
sessionId: inv.sessionId,
userId: inv.userId,
invoiceId: inv.id,
amount: inv.amount
}))
};
} catch (err: any) {
this.logger.error(`Error forzando generación de facturas para ${monthStr} by ${actionByInfo}: ${err.message}`, err?.stack || err);
throw err;
}
}
getFirstUrl(): string | null {
const url = this.firstUrl;
this.firstUrl = null; // consumir: solo válido una vez
return url;
}
}
+18
View File
@@ -0,0 +1,18 @@
// common/types.ts
import { UserRole } from '@prisma/client';
export type AuthUser = {
id: string;
role: UserRole;
name?: string;
customId?: string;
};
// typings/express.d.ts (asegurate que el tsconfig incluya este archivo)
declare global {
namespace Express {
interface Request {
user?: AuthUser;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CustomIdService } from './custom-id.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
providers: [CustomIdService],
imports: [PrismaModule],
exports: [CustomIdService],
})
export class CustomIdModule { }
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CustomIdService } from './custom-id.service';
describe('CustomIdService', () => {
let service: CustomIdService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CustomIdService],
}).compile();
service = module.get<CustomIdService>(CustomIdService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,40 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class CustomIdService {
private readonly logger = new Logger(CustomIdService.name);
constructor(private readonly prisma: PrismaService) { }
async generateCustomId(modelName: keyof PrismaClient, prefix: string): Promise<string> {
this.logger.log(`Creando nuevo Custom ID para modelo: ${String(modelName)}`);
try {
const model = (this.prisma as any)?.[modelName];
const lastRecord = await model.findFirst({
where: { customId: { startsWith: `${prefix}-` } },
orderBy: { customId: 'desc' },
});
let nextNumber = 1;
if (lastRecord?.customId) {
const lastNumber = parseInt(lastRecord.customId.slice(prefix.length + 1), 36);
nextNumber = lastNumber + 1;
}
const nextIdPart = nextNumber.toString(36).padStart(3, '0').toUpperCase();
const customId = `${prefix}-${nextIdPart}`.toLocaleUpperCase();
this.logger.log(`Custom ID generado: ${customId}`);
return customId;
} catch (error: any) {
this.logger.error(
`Error al generar Custom ID para modelo: ${String(modelName)}`,
error.message
);
throw new Error('No se pudo generar el Custom ID.');
}
}
}
@@ -0,0 +1 @@
export class CreateCustomIdDto {}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateCustomIdDto } from './create-custom-id.dto';
export class UpdateCustomIdDto extends PartialType(CreateCustomIdDto) {}
@@ -0,0 +1 @@
export class CustomId {}
@@ -0,0 +1,22 @@
import { Controller, ForbiddenException, Get, UseGuards } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { AuthTokenGuard } from '../common/guards/auth-token.guard';
import { AuthUser } from '../common/types';
import { DashboardUser } from './dto/dashboard.dto';
import { User } from '../common/decoratos/user.decorator';
@UseGuards(AuthTokenGuard)
@Controller('dashboard')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) { }
@Get()
async getDashboard(@User() user: AuthUser): Promise<DashboardUser> {
const allowed = user?.role === 'ADMIN' || user?.role === 'INSTRUCTOR';
if (!allowed) {
throw new ForbiddenException('No tienes permisos para ver el dashboard');
}
return this.dashboardService.getDashboard(user);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { DashboardController } from './dashboard.controller';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
controllers: [DashboardController],
providers: [DashboardService],
imports: [PrismaModule],
})
export class DashboardModule { }
+182
View File
@@ -0,0 +1,182 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { DashboardUser } from './dto/dashboard.dto';
import { AuthUser } from '../common/types';
import { LogDirection, UserRole } from '@prisma/client';
import dayjs from 'dayjs';
@Injectable()
export class DashboardService {
constructor(private prisma: PrismaService) { }
async getDashboard(user: AuthUser): Promise<DashboardUser> {
const now = dayjs().startOf('day').toDate() //new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
//NOTE Primer INGRESS de hoy (inicio del día)
const currentAccess = await this.prisma.accessLog.findFirst({
where: {
userId: user.id,
direction: LogDirection.INGRESS,
timestamp: { gte: start, lte: end },
},
orderBy: { timestamp: 'asc' },
});
const previousIngress = await this.prisma.accessLog.findFirst({
where: {
userId: user.id,
direction: LogDirection.INGRESS,
timestamp: currentAccess
? { lt: currentAccess.timestamp } // anterior al primer INGRESS de hoy
: { lt: start }, // si hoy no hubo, el último antes de hoy
},
orderBy: { timestamp: 'desc' }, // el más reciente anterior
});
const nextSessions = await this.prisma.sessionDateSnapshot.findMany({
where: {
session: { isActive: true },
AND: {
dateRange: { start: { gte: now } },
session: {
...(user.role === UserRole.INSTRUCTOR ? { instructors: { some: { id: user.id } } } : {}),
}
}
},
orderBy: { dateRange: { start: 'asc' } },
take: 10,
select: {
id: true,
dateRange: { select: { start: true } },
session: {
select: {
id: true,
description: true,
instructors: { select: { name: true } },
},
},
},
});
// Calcular sesiones activas e inactivas
const sessionsFilter = user.role === UserRole.INSTRUCTOR
? { instructors: { some: { id: user.id } } }
: {};
const [activeSessions, inactiveSessions] = await Promise.all([
this.prisma.session.count({
where: { isActive: true, ...sessionsFilter }
}),
this.prisma.session.count({
where: { isActive: false, ...sessionsFilter }
})
]);
// Calcular usuarios activos e inactivos (solo para admin)
let usersData = { total: 0, active: 0, inactive: 0 };
if (user.role !== UserRole.INSTRUCTOR) {
const [activeUsers, inactiveUsers] = await Promise.all([
this.prisma.user.count({ where: { deleted: null } }),
this.prisma.user.count({ where: { deleted: { not: null } } })
]);
usersData = {
total: activeUsers + inactiveUsers,
active: activeUsers,
inactive: inactiveUsers
};
}
// Contar asistentes en sesiones activas
const assistantsCount = user.role === UserRole.INSTRUCTOR
? await this.prisma.user.count({
where: {
assistantSessions: {
some: {
isActive: true,
instructors: { some: { id: user.id } }
}
}
}
})
: await this.prisma.user.count({
where: {
assistantSessions: {
some: { isActive: true }
}
}
});
// Calcular facturas del mes actual: total, pagadas y adeudadas
const startOfMonth = dayjs().startOf('month').toDate();
const endOfMonth = dayjs().endOf('month').toDate();
const [totalInvoices, paidInvoices, pendingInvoices] = await Promise.all([
// Total de facturas del mes
this.prisma.invoice.count({
where: {
dateInvoice: {
gte: startOfMonth,
lte: endOfMonth
}
}
}),
// Facturas pagadas (PAID o CANCELED)
this.prisma.invoice.count({
where: {
dateInvoice: {
gte: startOfMonth,
lte: endOfMonth
},
status: {
in: ['PAID', 'CANCELED']
}
}
}),
// Facturas adeudadas (PENDING)
this.prisma.invoice.count({
where: {
dateInvoice: {
gte: startOfMonth,
lte: endOfMonth
},
status: 'PENDING'
}
})
]);
const reponse: DashboardUser = {
message: 'OK',
accessLog: {
currentAccess: currentAccess?.timestamp ? currentAccess.timestamp.toISOString() : 'Sin registro',
lastAccess: previousIngress?.timestamp ? previousIngress.timestamp.toISOString() : 'Sin registro',
},
cardDetails: {
sessions: {
total: activeSessions + inactiveSessions,
active: activeSessions,
inactive: inactiveSessions
},
users: usersData,
assistants: assistantsCount,
invoices: {
total: totalInvoices,
paid: paidInvoices,
pending: pendingInvoices
},
revenue: 0, // TODO: sumarizar desde tu tabla de pagos/suscripciones
},
sessions: nextSessions.map(snapshot => ({
id: snapshot.id,
description: snapshot.session.description,
instructors: snapshot.session.instructors.map(i => i.name).join(', '),
startDate: snapshot.dateRange.start.toISOString(),
})),
};
return reponse;
}
}
@@ -0,0 +1 @@
export class CreateDashboardDto {}
@@ -0,0 +1,32 @@
export type DashboardUser = {
message: string;
accessLog: {
currentAccess: string;
lastAccess: string;
},
cardDetails: {
sessions: {
total: number;
active: number;
inactive: number;
};
users: {
total: number;
active: number;
inactive: number;
};
assistants: number; // total de usuarios inscriptos a clases activas
invoices: {
total: number; // total de facturas del mes actual
paid: number; // facturas pagadas (PAID o CANCELED)
pending: number; // facturas adeudadas (PENDING)
};
revenue: number;
},
sessions: {
id: string;
description: string;
instructors: string;
startDate: string;
}[]
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateDashboardDto } from './create-dashboard.dto';
export class UpdateDashboardDto extends PartialType(CreateDashboardDto) {}
@@ -0,0 +1 @@
export class Dashboard {}
+1
View File
@@ -0,0 +1 @@
export class CreateIaDto {}
+4
View File
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateIaDto } from './create-ia.dto';
export class UpdateIaDto extends PartialType(CreateIaDto) {}
+1
View File
@@ -0,0 +1 @@
export class Ia {}
+20
View File
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { IaController } from './ia.controller';
import { IaService } from './ia.service';
describe('IaController', () => {
let controller: IaController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [IaController],
providers: [IaService],
}).compile();
controller = module.get<IaController>(IaController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+34
View File
@@ -0,0 +1,34 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
// import { IaService } from './ia.service';
import { CreateIaDto } from './dto/create-ia.dto';
import { UpdateIaDto } from './dto/update-ia.dto';
@Controller('ia')
export class IaController {
constructor() {}
// @Post()
// create(@Body() createIaDto: CreateIaDto) {
// return this.iaService.create(createIaDto);
// }
// @Get()
// findAll() {
// return this.iaService.findAll();
// }
// @Get(':id')
// findOne(@Param('id') id: string) {
// return this.iaService.findOne(+id);
// }
// @Patch(':id')
// update(@Param('id') id: string, @Body() updateIaDto: UpdateIaDto) {
// return this.iaService.update(+id, updateIaDto);
// }
// @Delete(':id')
// remove(@Param('id') id: string) {
// return this.iaService.remove(+id);
// }
}
+18
View File
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
// import { IaService } from './ia.service';
import { IaController } from './ia.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { HttpModule } from '@nestjs/axios';
import { IaSubscriptionService } from './services/ia.subscription.service';
@Module({
controllers: [IaController],
providers: [
// // IaService,
IaSubscriptionService],
imports: [PrismaModule, HttpModule],
exports: [
// // IaService,
IaSubscriptionService],
})
export class IaModule { }
+18
View File
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { IaService } from './ia.service';
describe('IaService', () => {
let service: IaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [IaService],
}).compile();
service = module.get<IaService>(IaService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+498
View File
@@ -0,0 +1,498 @@
// import { get_encoding, Tiktoken } from '@dqbd/tiktoken';
// import { HttpService } from '@nestjs/axios';
// import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
// import { Prisma, UserRole } from '@prisma/client';
// import dayjs from 'dayjs';
// import { firstValueFrom } from 'rxjs';
// import { PrismaService } from '../prisma/prisma.service';
// import { buildPromptInferAction } from './prompts/buildPromptInferAction';
// import { SessionsService } from '../sessions/sessions.service';
// import { UserDataUpdate } from '../user/user.dto';
// export type UserIntentEnum =
// | "payment_status"
// | "view_enrolled_classes"
// | "view_new_keys"
// | "enroll_new_classes";
// /** Intents permitidos por rol (ajusta si querés diferenciar más) */
// const ROLE_ALLOWED: Record<UserRole, ReadonlyArray<UserIntentEnum>> = {
// [UserRole.ADMIN]: [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ],
// [UserRole.INSTRUCTOR]: [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ],
// [UserRole.USER]: [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ],
// // Invitado: no puede ver pagos ni “mis clases”
// [UserRole.GUEST]: [],
// };
// /** Normaliza texto para matching robusto */
// const norm = (s: string) =>
// s
// .normalize("NFKD")
// .replace(/\p{Diacritic}/gu, "")
// .toLowerCase()
// .trim();
// /** Sinónimos rápidos por intent (ES + algo de EN) */
// const KEYWORDS: Record<UserIntentEnum, RegExp[]> = {
// payment_status: [
// /pago(s)?\b/,
// /\bestado.*pago/,
// /\bestoy.*al dia/,
// /\bal dia\b/,
// /factur(a|as)|cuota|vencim(iento|entos)/,
// /\bpagar\b|\bpague\b/,
// /payment|paid|invoice|bill|due/,
// ],
// view_enrolled_classes: [
// /mis?\s+clases/,
// /clases?.*inscrit[oa]s?/,
// /inscripc(ion|iones)\s+vigentes?/,
// /\bver\s+clases\b|\bmis\s+cursos?\b/,
// /matriculad[oa]s?/,
// /enrolled|my\s+classes|my\s+courses/,
// ],
// view_new_keys: [
// /claves?\s+(nuevas?|disponibles?)/,
// /\bnuevas?\s+claves?\b/,
// /\bcodigos?\b|\baccesos?\b|\bkeys?\b/,
// /generate.*key|new.*key/,
// ],
// enroll_new_classes: [
// /inscrib(irme|ir|irnos)|anotar(me)?|apuntar(me)?|registrar(me)?/,
// /inscripcion\s+nuev(a|as)|nuevas?\s+clases/,
// /\bquiero\s+(sumarme|anotarme|inscribirme)\b/,
// /enroll|sign\s*up|register.*class/,
// ],
// };
// /** Heurística: primer intent cuyo regex matchee */
// const quickHeuristic = (text: string): UserIntentEnum | null => {
// const t = norm(text);
// for (const intent of Object.keys(KEYWORDS) as UserIntentEnum[]) {
// const found = KEYWORDS[intent].some((rx) => rx.test(t));
// if (found) return intent;
// }
// return null;
// };
// /** Prompt builder acotado por rol */
// export const buildPromptInferUserMenuAction = (
// message: string,
// role: UserRole
// ): string => {
// const allowed = ROLE_ALLOWED[role];
// const labels = allowed
// .map((k) => `"${k}"`)
// .join(", ");
// // Unos mini-ejemplos para anclar
// const examples = [
// { in: "necesito ver si estoy al dia con mis pagos", out: "payment_status" },
// { in: "quiero ver mis clases inscritas", out: "view_enrolled_classes" },
// { in: "tienen nuevas claves de acceso?", out: "view_new_keys" },
// { in: "quiero inscribirme a una clase nueva", out: "enroll_new_classes" },
// ]
// // filtra ejemplos que no estén permitidos al rol para no confundir al modelo
// .filter((e) => (allowed as string[]).includes(e.out))
// .map((e) => `Entrada: "${e.in}"\nRespuesta: "${e.out}"`)
// .join("\n\n");
// return `
// Eres un asistente que clasifica la intención del usuario en una de las siguientes opciones PERMITIDAS según su rol (${role}):
// ${labels}
// Analiza el siguiente mensaje y responde SOLO con una de esas claves exactas (sin texto adicional).
// Si la intención NO coincide con ninguna de las opciones permitidas, responde exactamente "null".
// ${examples ? `\nEjemplos:\n\n${examples}\n` : ""}
// Entrada: "${message}"
// Respuesta:
// `.trim();
// };
// /** Timeout helper: evita congelar la UX si la IA tarda */
// const withTimeout = async <T>(
// p: Promise<T>,
// ms = 1200
// ): Promise<T> =>
// Promise.race([
// p,
// new Promise<T>((_, rej) => setTimeout(() => rej(new Error("IA_TIMEOUT")), ms)),
// ]) as Promise<T>;
// /** Mapea string → intent válido o null */
// const parseModelLabel = (
// raw: string | null | undefined,
// allowed: ReadonlyArray<UserIntentEnum>
// ): UserIntentEnum | null => {
// if (!raw) return null;
// const cleaned = raw.replace(/[\s"'`]/g, "").toLowerCase();
// if (cleaned === "null") return null;
// // Intent labels exactos
// const candidates: UserIntentEnum[] = [
// "payment_status",
// "view_enrolled_classes",
// "view_new_keys",
// "enroll_new_classes",
// ];
// const match = candidates.find((c) => c === cleaned);
// if (!match) return null;
// // Respeta permisos por rol
// return allowed.includes(match) ? match : null;
// };
// export enum ActionsAdmin {
// CREATE_SESSION = 'create_session',
// GET_SESSIONS = 'get_sessions',
// UPDATE_USER = 'update_user',
// GET_USERS = 'get_users',
// }
// //xport type SessionWithSchedules = Partial<Prisma.SessionGetPayload<{ include: { schedules: true } }>>
// @Injectable()
// export class IaService implements OnModuleInit {
// constructor(
// private readonly httpService: HttpService,
// private prisma: PrismaService,
// ) { }
// private readonly logger = new Logger(IaService.name);
// private readonly apiUrl = 'http://localhost:5000/v1/completions';
// //@ts-ignores
// private tokenizer: Tiktoken;
// private readonly ctxSize = 4096; // tamaño de contexto que configuraste en el modelo
// private readonly maxTokens = 512; // tokens máximos que le permitimos al modelo generar
// private readonly safetyMargin = 10; // margen de seguridad
// onModuleInit() {
// this.tokenizer = get_encoding('cl100k_base');
// }
// /** Implementa la llamada real a tu LLM/endpoint */
// private async callModel(prompt: string): Promise<string> {
// // TODO: reemplaza por tu cliente real (OpenAI, local, etc.)
// // Debe devolver el texto "payment_status" | ... | "null"
// throw new Error("callModelNot implemented");
// }
// async detectIntent(
// text: string,
// role: UserRole
// ): Promise<UserIntentEnum | null> {
// const allowed = ROLE_ALLOWED[role];
// // 1) Heurística rápida (barata y rápida)
// const byHeuristic = quickHeuristic(text);
// if (byHeuristic && allowed.includes(byHeuristic)) {
// return byHeuristic;
// }
// // 2) LLM (con timeout) usando prompt limitado por rol
// try {
// const prompt = buildPromptInferUserMenuAction(text, role);
// const raw = await withTimeout(this.callModel(prompt), 1500) as string | null;
// const intent = parseModelLabel(raw, allowed);
// if (intent) return intent;
// } catch {
// // caemos a null si hay timeout/errores
// }
// // 3) Nada concluyente
// return null;
// }
// async getActionAdmin(input: string): Promise<ActionsAdmin> {
// try {
// const prompt = buildPromptInferAction(input);
// const response = await this.executePrompt<string>(prompt)
// if (!Object.values(ActionsAdmin).includes(response as ActionsAdmin)) {
// this.logger.error(`Acción no válida: ${response}`);
// throw new Error('La acción inferida no es válida.');
// }
// return response as ActionsAdmin;
// }
// catch (error: any) {
// this.logger.error('Error al obtener la acción de administrador', error);
// throw new Error('No se pudo obtener la acción de administrador.');
// }
// }
// async getUserUpdatePayload(input: string): Promise<UserDataUpdate> {
// try {
// const prompt = this.buildPromptUpdateUser(input);
// const promptTokens = this.countTokens(prompt);
// // Validación: aseguramos que no exceda el tamaño de contexto
// if (promptTokens + this.maxTokens + this.safetyMargin > this.ctxSize) {
// this.logger.error(`Prompt demasiado largo. Tokens en prompt: ${promptTokens}`);
// throw new Error('El prompt es demasiado largo para el contexto disponible.');
// }
// const { data } = await firstValueFrom(
// this.httpService.post(this.apiUrl, {
// prompt,
// max_tokens: 256,
// temperature: 0.2,
// stop: ['Entrada:', '\n\n'],
// }),
// );
// const { choices } = data;
// const choice = choices[0];
// if (choice.finish_reason !== 'stop') {
// throw new Error('La respuesta fue incompleta o abortada.');
// }
// const responseText = choice.text.trim();
// // Parseamos el JSON que viene como string
// const jsonResponse = JSON.parse(responseText);
// const response: UserDataUpdate = {
// customId: jsonResponse.customId,
// name: jsonResponse.name,
// phone: jsonResponse.phone.replace(/\D/g, ''), // Eliminamos todo lo que no sea dígito
// birth: dayjs(jsonResponse.birth).toDate(),
// }
// return response;
// }
// catch (error: any) {
// this.logger.error('Error al obtener el payload de actualización del usuario', error);
// throw new Error('No se pudo obtener el payload de actualización del usuario.');
// }
// }
// //TODO cambiarle el nombre , lo que hace es parsear de natural a json
// // async createSession(input: string): Promise<SessionWithSchedules> {
// // const prompt = this.buildPromptCreateSession(input);
// // const promptTokens = this.countTokens(prompt);
// // // Validación: aseguramos que no exceda el tamaño de contexto
// // if (promptTokens + this.maxTokens + this.safetyMargin > this.ctxSize) {
// // this.logger.error(`Prompt demasiado largo. Tokens en prompt: ${promptTokens}`);
// // throw new Error('El prompt es demasiado largo para el contexto disponible.');
// // }
// // try {
// // const { data } = await firstValueFrom(
// // this.httpService.post(this.apiUrl, {
// // prompt,
// // max_tokens: 512,
// // temperature: 0.2,
// // stop: ['\n\n', 'Entrada:'],
// // }),
// // );
// // const { choices } = data;
// // const choice = choices[0];
// // if (choice.finish_reason !== 'stop') {
// // throw new Error('La respuesta fue incompleta o abortada.');
// // }
// // const responseText = choice.text.trim();
// // // Parseamos el JSON que viene como string
// // const jsonResponse = JSON.parse(responseText);
// // const response: SessionWithSchedules = {
// // description: jsonResponse.description,
// // type: jsonResponse.type,
// // schedules: jsonResponse?.schedules?.map((item: any) => {
// // return {
// // dayOfWeek: item.dayOfWeek || null,
// // specificDate: item.specificDate ? dayjs(item.specificDate).toDate() : null,
// // startTime: dayjs(item.startDateTime).toDate(),
// // endTime: dayjs(item.endDateTime).toDate(),
// // }
// // }),
// // }
// // return response;
// // } catch (error) {
// // this.logger.error('Error al generar la sesión', error);
// // throw new Error('No se pudo generar la sesión.');
// // }
// // }
// private countTokens(text: string): number {
// const tokens = this.tokenizer.encode(text);
// return tokens.length;
// }
// async executePrompt<T>(
// prompt: string,
// options?: {
// maxTokens?: number;
// temperature?: number;
// stop?: string[];
// }
// ): Promise<T> {
// const promptTokens = this.countTokens(prompt);
// // Configuración por defecto
// const maxTokens = options?.maxTokens ?? 256;
// const temperature = options?.temperature ?? 0.2;
// const stop = options?.stop ?? ['Entrada:', '\n\n'];
// if (promptTokens + maxTokens + this.safetyMargin > this.ctxSize) {
// this.logger.error(`Prompt demasiado largo. Tokens en prompt: ${promptTokens}`);
// throw new Error('El prompt es demasiado largo para el contexto disponible.');
// }
// const { data } = await firstValueFrom(
// this.httpService.post(this.apiUrl, {
// prompt,
// max_tokens: maxTokens,
// temperature,
// stop,
// }),
// );
// const { choices } = data;
// const choice = choices[0];
// if (choice.finish_reason !== 'stop') {
// throw new Error('La respuesta fue incompleta o abortada.');
// }
// const responseText = choice.text.trim();
// try {
// return JSON.parse(responseText) as T;
// } catch (error) {
// this.logger.error('Error al parsear la respuesta JSON:', error);
// throw new Error('La respuesta no es un JSON válido.');
// }
// }
// private buildPromptCreateSession(message: string): string {
// return `
// Eres un asistente que solo responde en formato JSON válido y nunca agrega texto adicional.
// Devuelve solo la estructura JSON con este formato:
// {
// "description": string,
// "type": "RECURRING" o "ONE_TIME",
// "schedules": [
// {
// "dayOfWeek": string o null,
// "specificDate": string o null //en formato DD-MM-YYYY HH-mm ,
// "startDateTime": string //en formato DD-MM-YYYY HH-mm,
// "endDateTime": string //en formato DD-MM-YYYY HH-mm
// }
// ]
// }
// Reglas importantes:
// - Extrae siempre la fecha y hora que aparecen escritas en el mensaje. No inventes, no uses la fecha ni la hora actuales.
// - Si el mensaje contiene una fecha específica (por ejemplo, "2/7/2025"), úsala exactamente como aparece para los campos "specificDate", "startDateTime" y "endDateTime".
// - Si el evento es recurrente, usa palabras clave como "recurrente", "todos los", "cada", "se repite" para detectarlo. En ese caso, el campo "specificDate" debe ser null, "dayOfWeek" debe tener el nombre del día en inglés, y las horas deben colocarse con una fecha ficticia: "1970-01-01".
// - Si el evento es puntual, el campo "dayOfWeek" debe ser null y "specificDate" debe ser la fecha real extraída del mensaje.
// - Convierte siempre las horas al formato de 24 horas y en formato ISO: yyyy-mm-ddTHH:MM:SSZ.
// - Si el mensaje tiene un horario como "11:00AM - 14:00PM", interpreta que:
// - "11:00AM" es la hora de inicio
// - "14:00PM" es la hora de fin (corrige a formato 24h si es necesario)
// Ejemplos:
// Entrada: "Clase 'Zumba' 15/12/2025 de 8:30am a 10:00am"
// Respuesta:
// {
// "description": "Zumba",
// "type": "ONE_TIME",
// "schedules": [
// {
// "dayOfWeek": null,
// "specificDate": "2025-12-15T08:30:00Z",
// "startDateTime": "2025-12-15T08:30:00Z",
// "endDateTime": "2025-12-15T10:00:00Z"
// }
// ]
// }
// Entrada: "Clase Yoga recurrente lunes de 8:30am a 10:00am"
// Respuesta:
// {
// "description": "Yoga",
// "type": "RECURRING",
// "schedules": [
// {
// "dayOfWeek": "Monday",
// "specificDate": null,
// "startDateTime": "1970-01-01T08:30:00Z",
// "endDateTime": "1970-01-01T10:00:00Z"
// }
// ]
// }
// Entrada: ${message}
// Respuesta:
// `.trim();
// }
// private buildPromptUpdateUser(message: string): string {
// return `
// Eres un asistente que responde únicamente en formato JSON válido. No agregues texto adicional ni explicaciones.
// Formato esperado:
// {
// "customId": string, // Ejemplo: "U-001"
// "name": string, // Nombre completo
// "phone": string, // Solo dígitos, sin espacios ni símbolos
// "birth": string // Fecha en formato ISO: YYYY-MM-DD
// }
// Reglas:
// - El primer token siempre es el customId (ejemplo: U-001).
// - El nombre puede incluir espacios y letras, ignora palabras como "nombre".
// - El teléfono puede venir con espacios o símbolos, pero debes devolver solo los dígitos.
// - La fecha de nacimiento puede venir como DD/MM/YYYY o D/M/YYYY y debes convertirla a formato YYYY-MM-DD.
// Ejemplo:
// Entrada:
// U-001 nombre Leandro Leones 3435077510 18/12/1995
// Salida:
// {
// "customId": "U-001",
// "name": "Leandro Leones",
// "phone": "3435077510",
// "birth": "1995-12-18"
// }
// Entrada:
// ${message}
// Salida:
// `.trim();
// }
// }
@@ -0,0 +1,42 @@
export const buildPromptGetUser = (message: string): string => {
return `
Eres un asistente que interpreta mensajes para buscar usuarios en la base de datos.
Analiza el siguiente mensaje y responde solo con un JSON así:
{
"nombre": string | null, // nombre o parte del nombre del usuario a buscar, o null si no se especifica
"birthdayMonth": number | null // número del mes de cumpleaños del usuario (1 = enero, 12 = diciembre), o null si no se especifica
}
Reglas:
- Si el usuario menciona un nombre o parte del nombre, debes colocar ese valor en "nombre".
- Si el usuario menciona un mes de cumpleaños (por ejemplo "cumpleaños en mayo"), debes colocar el número correspondiente en "birthdayMonth".
- Si no se especifica un dato, debes ponerlo como null.
Ejemplos:
Entrada: "Buscar usuarios que se llaman Juan y cumplen en mayo"
Respuesta:
{
"nombre": "Juan",
"birthdayMonth": 5
}
Entrada: "Buscar usuarios que cumplen en noviembre"
Respuesta:
{
"nombre": null,
"birthdayMonth": 11
}
Entrada: "Buscar usuarios llamados María"
Respuesta:
{
"nombre": "María",
"birthdayMonth": null
}
Entrada: ${message}
Respuesta:
`.trim();
};
@@ -0,0 +1,27 @@
export const buildPromptInferAction = (message: string): string => {
return `
Eres un asistente que clasifica la intención de un usuario.
Analiza el siguiente mensaje y devuelve solo una de las siguientes claves JSON válidas según la intención detectada:
- "create_session" si el usuario quiere crear una sesión, agendar, programar, registrar una clase o similar.
- "update_user" si el usuario quiere actualizar personales, cambiar nombre, cambiar teléfono, modificar información de usuario.
- "get_sessions" si el usuario quiere ver las sesiones, clases, horarios, agenda, calendario o similar.
- "get_users" si el usuario quiere ver o listar usuarios, personas, alumnos, estudiantes o similar.
Solo responde la clave exacta sin agregar texto adicional.
Ejemplo:
Entrada: "Quiero crear una nueva clase para mañana"
Respuesta: "create_session"
Entrada: "Necesito actualizar el telefono del usuario U-001"
Respuesta: "update_user"
Entrada: "Quiero ver las clases para mañana"
Respuesta: "get_sessions"
Entrada: ${message}
Respuesta:
`.trim();
}
@@ -0,0 +1,46 @@
export const buildPromptGetSession = (message: string): string => {
return `
Eres un asistente que interpreta mensajes para buscar sesiones en la base de datos.
Analiza el siguiente mensaje y responde solo con un JSON así:
{
"modo": "filtrar" | "proxima", // "filtrar" si el usuario quiere buscar clases específicas, "proxima" si solo quiere saber cuál es la próxima clase sin filtros
"descripcion": string | null, // palabra clave (ej: yoga), o null
"instructor": string | null, // nombre del instructor, o null
"assistant": string | null, // nombre del asistente, o null
"temporalReference": string | null, // frase sobre fecha u hora, sin convertir. Ej: "lunes siguiente a las 11 AM"
"daysOfWeek": string[] // array con días de la semana en español, puede estar vacío
}
Reglas:
- Si el usuario pregunta por "la próxima clase", "la siguiente clase", o algo similar, debes responder con modo: "proxima" y dejar los otros campos en null o vacío.
- Si el usuario busca por nombre de clase, día o instructor, responde con modo: "filtrar" y llena los campos correspondientes.
Ejemplos:
Entrada: "Dame las clases del lunes siguiente a las 11 AM de yoga con el profesor Juan"
Respuesta:
{
"modo": "filtrar",
"descripcion": "yoga",
"instructor": "Juan",
"assistant": null,
"temporalReference": "lunes siguiente a las 11 AM",
"daysOfWeek": ["LUNES"]
}
Entrada: "¿Cuál es la próxima clase?"
Respuesta:
{
"modo": "proxima",
"descripcion": null,
"instructor": null,
"assistant": null,
"temporalReference": null,
"daysOfWeek": []
}
Entrada: ${message}
Respuesta:
`.trim();
}
@@ -0,0 +1,45 @@
export const builderSubscriptionPrompt = (message: string): string => {
return `
Eres un asistente que solo responde en formato JSON válido y nunca agrega texto adicional.
Debes inferir y devolver solo la estructura JSON con este formato:
{
"user": {
"name": string,
"customId": string | null
},
"product": {
"description": string,
"customId": string | null
},
"discount": number | null,
"mode": "assistant" | "instructor"
}
Reglas:
- El "customId" es un string con el formato: una letra, seguida de un guion y tres caracteres (ejemplo: A-123). Si no está presente, devolver null.
- El "discount" debe ser un número si el mensaje hace referencia a un descuento, caso contrario devolver null.
- El "mode" debe ser "assistant" por defecto. Si el mensaje menciona que el usuario es un profesor o instructor, el modo debe ser "instructor".
- El nombre debe inferirse como el nombre completo de la persona mencionada.
- La descripción debe inferirse como el nombre de la clase o producto mencionado.
Ejemplo:
Entrada: "Agregar a valentin romero a la clase de latino con un descuento del 30%"
Respuesta:
{
"user": {
"name": "valentin romero",
"customId": null
},
"product": {
"description": "latino",
"customId": null
},
"discount": 30,
"mode": "assistant"
}
Entrada: ${message}
Respuesta:
`.trim();
}
@@ -0,0 +1,255 @@
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from '@nestjs/common';
import { Session, User, Prisma, } from '@prisma/client';
import { firstValueFrom, lastValueFrom } from 'rxjs';
import { PrismaService } from '../../prisma/prisma.service';
// import { IaService } from '../ia.service';
import { link } from 'fs';
import { builderSubscriptionPrompt } from './builderSubscriptionPrompt';
type SubscriptionWithDataInvoices = {
id: string;
userId: string;
sessionId: string;
base64: string;
linkPayment: string;
}
type CreateInvoiceOdoo = {
user: {
userCode: string;
dni?: string;
}
product: {
description: string;
productCode: string;
amount: number;
}
}
type ResponseInvoiceOdoo = {
linkPayment: string;
linkInvoice: string; // URL de la factura
invoice: string // base 64
}
type ResponseSubscribeFromNaturalMessage = {
message: string;
invoice64: string;
linkPayment: string;
}
type ProductsModels = Session
@Injectable()
export class IaSubscriptionService {
private readonly logger = new Logger(IaSubscriptionService.name);
constructor(
// private iaService: any, // Asegúrate de importar IaService correctamente
private readonly prisma: PrismaService,
private httpService: HttpService, // Asegúrate de importar HttpService si lo necesitas
) {
this.logger.log('IaSubscriptionService initialized');
}
async subscribeFromNaturalMessage(input: string, esAdmin: boolean = false): Promise<ResponseSubscribeFromNaturalMessage> {
try {
//const a = await this.iaService.
type ResponseIaSubscription = {
user: {
name: string,
customId: string,
},
product: {
description: string,
customId: string,
},
discount?: number,
mode: 'assistant' | 'instructor',
}
const prompt = builderSubscriptionPrompt(input);
const { data } = await firstValueFrom(
this.httpService.post(process.env.URL_IA!, {
prompt,
max_tokens: 256,
temperature: 0.2,
stop: ['Entrada:', '\n\n'],
}),
);
const { choices } = data;
const choice = choices[0];
if (choice.finish_reason !== 'stop') {
throw new Error('La respuesta fue incompleta o abortada.');
}
const responseText = choice.text.trim();
// Parseamos el JSON que viene como string
const jsonResponse = JSON.parse(responseText);
// TODO: Replace this with actual response from IA service
const response = jsonResponse as ResponseIaSubscription;
if (!response) {
throw new Error('No response from IA service');
}
const nameParts = response.user.name?.split(' ').filter(part => part.length >= 3) || [];
if (nameParts.length < 2 && !response.user.customId) {
throw new Error('No se pudo inferir suficiente información para buscar el usuario.');
}
const whereConditions: any = {};
if (response.user.customId) {
whereConditions.customId = response.user.customId;
} else if (nameParts.length >= 2) {
whereConditions.AND = [
{ name: { contains: nameParts[0], mode: 'insensitive' } },
{ name: { contains: nameParts[nameParts.length - 1], mode: 'insensitive' } }
];
}
const user = await this.prisma.user.findMany({
where: whereConditions,
take: 1
});
if (user.length === 0) {
throw new Error('User not found');
} else if (user.length > 1) {
this.logger.warn(`Multiple users found for query: ${JSON.stringify(whereConditions)}. Using the first one.`);
}
const descriptionParts = response.product.description?.split(' ').filter(part => part.length >= 3) || [];
if (descriptionParts.length === 0 && !response.product.customId) {
throw new Error('No se pudo inferir suficiente información para buscar el producto.');
}
const whereProductConditions: any = {};
if (response.product.customId) {
whereProductConditions.customId = response.product.customId;
} else {
whereProductConditions.AND = [
// oj que busca solo por el primer
{ description: { contains: descriptionParts[0], mode: 'insensitive' } },
// { description: { contains: descriptionParts[descriptionParts.length - 1], mode: 'insensitive' } }
];
}
const modelName = 'session'; // Asumiendo que el modelo es Session
const product = await this.prisma?.[modelName].findMany({
where: whereProductConditions
});
if (product.length === 0) {
throw new Error('Producto no encontrado.');
}
if (product.length > 1) {
throw new Error('La búsqueda devolvió múltiples productos. Por favor, proporciona más información para identificarlo correctamente.');
}
if (!user || !product) {
throw new Error('User or product not found');
}
if (!response.mode) {
this.logger.debug(`No mode provided for subscription, defaulting to 'assistant'`);
}
const subscribe = await this.subscribe(user[0], product[0], response.mode);
const responseService = {
message: `User ${user[0].name} subscribed to product ${product[0].description} with mode ${response.mode}. Invoice ID: ${subscribe.id}`,
invoice64: subscribe.base64, // Asumiendo que la factura es un base64
linkPayment: subscribe.linkPayment, // URL de pago
}
return responseService
}
catch (error: any) {
throw new Error(`Failed to subscribe: ${error.message}`);
}
}
// Ejemplo de método para suscribirse
async subscribe(user: User, product: ProductsModels, mode: 'assistant' | 'instructor'): Promise<SubscriptionWithDataInvoices> {
try {
this.logger.log(`User ${user.id} subscribed`);
// Use explicit model access instead of dynamic string indexing
const updateProduct = await this.prisma.session.update({
where: { id: product.id },
data: {
...(mode === 'instructor' ? { instructors: { connect: { id: user.id } } } : {}), // Conectar el usuario como instructor
...(mode === 'assistant' ? { assistants: { connect: { id: user.id } } } : {}), // Conectar el usuario como asistente
}
})
const createInvoiceOdoo: CreateInvoiceOdoo = {
user: {
userCode: user.id,
dni: user.dni || undefined, // Asumiendo que el usuario tiene un campo dni
},
product: {
description: updateProduct.description,
productCode: updateProduct.customId,
amount: updateProduct.amount || 11, // Monto ficticio
}
}
const { data } = await lastValueFrom(
this.httpService.post<ResponseInvoiceOdoo>(
process.env.URL_INVOICE_ODOO!, // URL ficticia del API de Odoo
createInvoiceOdoo as CreateInvoiceOdoo , {
headers: {
'Content-Type': 'application/json',
}}
)
)
//@ts-ignore
const { invoice, linkInvoice, linkPayment } = data['result'] || {}
// Crear factura directamente sin suscripción intermedia
const createdInvoice = await this.prisma.invoice.create({
data: {
userId: user.id,
sessionId: product.id,
amount: product.amount || 1, // Monto ficticio
dateInvoice: new Date(), // Mes actual
base64Invoice: invoice,
linkPayment: linkPayment,
status: 'PENDING'
}
});
// Retornar en formato compatible
return {
id: createdInvoice.id,
userId: user.id,
sessionId: product.id,
base64: invoice,
linkPayment
};
}
catch (error: any) {
throw new Error(`Failed to subscribe user ${user.id}: ${error.message}`);
}
}
// Ejemplo de método para cancelar suscripción
unsubscribe(userId: string): string {
this.logger.log(`User ${userId} unsubscribed`);
return `User ${userId} unsubscribed successfully`;
}
}
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { InvoicesController } from './invoices.controller';
import { InvoicesService } from './invoices.service';
describe('InvoicesController', () => {
let controller: InvoicesController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [InvoicesController],
providers: [InvoicesService],
}).compile();
controller = module.get<InvoicesController>(InvoicesController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -0,0 +1,84 @@
import { Controller, ForbiddenException, Get, Param, Res, NotFoundException, UseGuards, Put, Body } from '@nestjs/common';
import { Response } from 'express';
import { InvoicesService } from './invoices.service';
import { User } from '~/common/decoratos/user.decorator';
import { AuthUser } from '~/common/types';
import { Invoice, UserRole, InvoiceStatus } from '@prisma/client';
import { AuthTokenGuard } from '~/common/guards/auth-token.guard';
@Controller('invoices')
@UseGuards(AuthTokenGuard)
export class InvoicesController {
constructor(private readonly invoicesService: InvoicesService) { }
@Get(':userId')
async getAttendeesSnapshot(
@Param('userId') userId: string,
@User() user: AuthUser,
): Promise<(Invoice & { description: string })[] | null> {
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR || user?.id === userId;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para ver facturas');
}
return this.invoicesService.findInvoicesByUserId(userId);
}
@Get('download/:invoiceId')
async downloadInvoicePdf(
@Param('invoiceId') invoiceId: string,
@User() user: AuthUser,
@Res() res: Response,
): Promise<void> {
// Buscar la factura con las relaciones necesarias
const invoice = await this.invoicesService.findById(invoiceId);
if (!invoice) {
throw new NotFoundException('Factura no encontrada');
}
// Verificar permisos - debe ser admin, instructor o el dueño de la factura
const allowed = user?.role === UserRole.ADMIN ||
user?.role === UserRole.INSTRUCTOR ||
invoice.userId === user?.id;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para descargar esta factura');
}
// Verificar que la factura tiene contenido PDF
if (!invoice.base64Invoice) {
throw new NotFoundException('La factura no tiene contenido PDF disponible');
}
try {
// Decodificar el base64
const pdfBuffer = Buffer.from(invoice.base64Invoice, 'base64');
// Configurar headers para la descarga
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="factura-${invoice.id}.pdf"`);
res.setHeader('Content-Length', pdfBuffer.length);
// Enviar el archivo
res.send(pdfBuffer);
} catch (error) {
throw new NotFoundException('Error al procesar el archivo PDF');
}
}
@Put(':userId/:invoiceId')
async updateInvoiceStatus(
@Param('userId') userId: string,
@Param('invoiceId') invoiceId: string,
@Body('status') status: InvoiceStatus,
@User() user: AuthUser,
): Promise<Invoice> {
// Verificar permisos - solo admin o instructor pueden actualizar facturas
const allowed = user?.role === UserRole.ADMIN || user?.role === UserRole.INSTRUCTOR;
if (!allowed) {
throw new ForbiddenException('No tienes permisos para actualizar facturas');
}
return this.invoicesService.updateInvoiceStatus(userId, invoiceId, status, user);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { forwardRef, Module } from '@nestjs/common';
import { InvoicesService } from './invoices.service';
import { InvoicesController } from './invoices.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { OdooModule } from '../odoo/odoo.module';
@Module({
imports: [
PrismaModule,
forwardRef(() => OdooModule),
],
controllers: [InvoicesController],
providers: [InvoicesService],
exports: [InvoicesService],
})
export class InvoicesModule { }
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { InvoicesService } from './invoices.service';
describe('InvoicesService', () => {
let service: InvoicesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [InvoicesService],
}).compile();
service = module.get<InvoicesService>(InvoicesService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+404
View File
@@ -0,0 +1,404 @@
import { forwardRef, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common';
import { Invoice, InvoiceStatus } from '@prisma/client';
import dayjs from 'dayjs';
import { OdooInvoiceInput } from '../odoo/entities/odoo.entity';
import { OdooService } from '../odoo/odoo.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser } from '~/common/types';
@Injectable()
export class InvoicesService {
private logger = new Logger(InvoicesService.name);
constructor(
private prisma: PrismaService,
@Inject(forwardRef(() => OdooService))
private readonly odooService: OdooService,
) { }
/**
* Comprueba si el usuario tiene alguna factura no pagada en el mes actual.
* @param userId ID del usuario
* @returns true si existe al menos una factura con status PENDING o CANCELED dentro del mes en curso
*/
async hasUnpaidInvoiceThisMonth(userId: string): Promise<boolean> {
this.logger.log(`Checking unpaid invoices for user ${userId} in the current month`);
const startOfMonth = dayjs().startOf('month').toDate();
const endOfMonth = dayjs().endOf('month').toDate();
const unpaidInvoice = await this.prisma.invoice.findFirst({
where: {
userId,
status: {
in: [InvoiceStatus.PENDING]
},
dateInvoice: {
gte: startOfMonth,
lte: endOfMonth
}
}
});
return !!unpaidInvoice;
}
async getLinkPayment(userId: string): Promise<string | null> {
try {
return 'www.mercadopago.com.ar/asd'
}
catch (error: any) {
this.logger.error(`Error getting payment link for user ${userId}: ${error.message}`);
return null;
}
}
/**
* Devuelve la suma de los precios efectivos de todas las sesiones
* en las que el usuario es asistente y que tienen al menos un día
* dentro del mes actual.
*
* @param userId ID del usuario
* @returns total en la moneda del sistema (Float)
*/
async getTotalAssistantSessionAmount(userId: string): Promise<number> {
const startOfMonth = dayjs().startOf('month').toDate();
const endOfMonth = dayjs().endOf('month').toDate();
// 1️⃣ Obtener todas las sesiones donde el usuario es asistente
const sessions = await this.prisma.session.findMany({
where: {
assistants: { some: { id: userId } },
},
include: {
priceHistories: true, // historial de precios
},
});
let total = 0;
// 2️⃣ Para cada sesión, encontrar el precio que estaba vigente
// en el rango del mes y sumarlo
for (const s of sessions) {
// Usar la nueva lógica corregida para obtener el precio del mes
const priceForMonth = this.getCurrentPriceForDate(s, startOfMonth);
total += priceForMonth;
}
this.logger.log(
`Total effective amount for assistants of user ${userId} this month: ${total}`,
);
return total;
}
async newInvoiceForSession(userId: string, sessionId: string, dateInvoice: Date = new Date(), prismaClient?: any) {
try {
// Usar el cliente de transacción si se proporciona, o el cliente normal
const client = prismaClient || this.prisma;
// Obtener información de la sesión para el monto y descripción
const session = await client.session.findUnique({
where: { id: sessionId },
include: { priceHistories: true }
});
if (!session) {
this.logger.error(`Session not found: ${sessionId}`);
throw new InternalServerErrorException('Sesión no encontrada');
}
// Obtener el precio efectivo para el mes de la factura
const currentPrice = this.getCurrentPriceForDate(session, dateInvoice);
const invoice = await client.invoice.create({
data: {
userId,
sessionId,
amount: currentPrice,
dateInvoice,
status: InvoiceStatus.PENDING,
}
});
if (!invoice) {
this.logger.error(`Failed to create invoice for user ${userId} and session ${sessionId}`);
throw new InternalServerErrorException('Error al crear la factura');
}
// Crear factura en Odoo
const user = await client.user.findUnique({ where: { id: userId } });
if (!user) {
this.logger.error(`User not found: ${userId}`);
throw new InternalServerErrorException('Usuario no encontrado');
}
const data: OdooInvoiceInput = {
product: {
amount: currentPrice,
description: session.description,
invoiceId: invoice.id,
sessionId: session.id
},
user: {
dni: user.dni || '000000',
id: user.id
}
};
const facturaOdoo = await this.odooService.createInvoice(data);
if (!facturaOdoo) {
this.logger.error(`Error creating invoice in Odoo for user ${userId} and session ${sessionId}`);
throw new InternalServerErrorException('Error al crear la factura en Odoo');
}
const updatedInvoice = await client.invoice.update({
where: { id: invoice.id },
data: {
base64Invoice: facturaOdoo.result.base64Invoice,
linkPayment: facturaOdoo.result.linkPayment,
}
});
this.logger.log(`Invoice created successfully for user ${userId} and session ${sessionId} with invoice ID ${updatedInvoice.id} , amount: ${updatedInvoice.amount}, dateService : ${dateInvoice}`);
return updatedInvoice;
} catch (error: any) {
this.logger.error(`Error creating invoice: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error al crear la factura');
}
}
/**
* Obtiene el precio vigente de una sesión para una fecha determinada
*/
private getCurrentPriceForDate(session: any, targetDate: Date): number {
if (!session.priceHistories?.length) {
return session.amount || 0;
}
// Ordenar por fecha de inicio (más reciente primero)
const sortedHistory = session.priceHistories
.sort((a: any, b: any) => new Date(b.effectiveFrom).getTime() - new Date(a.effectiveFrom).getTime());
// Buscar el precio aplicable para la fecha objetivo
const applicablePrice = sortedHistory.find((h: any) => {
const from = new Date(h.effectiveFrom);
const to = h.effectiveTo ? new Date(h.effectiveTo) : null;
// Si es el precio vigente (effectiveTo = null) y la fecha es posterior al inicio
if (!to && from <= targetDate) {
return true;
}
// Si tiene fecha de fin, verificar que esté en el rango
if (to && from <= targetDate && targetDate < to) {
return true;
}
return false;
});
return applicablePrice ? applicablePrice.amount : session.amount || 0;
}
async findInvoiceThisMonthByUserId(userId: string): Promise<(Invoice & { description: string })[] | null> {
try {
const startOfMonth = dayjs().startOf('month').toDate();
const endOfMonth = dayjs().endOf('month').toDate();
const invoices = await this.prisma.invoice.findMany({
where: {
userId,
OR: [
{
dateInvoice: {
gte: startOfMonth,
lte: endOfMonth
}
},
{
status: InvoiceStatus.PENDING
}
]
},
include: {
session: true
}
});
const facturas: (Invoice & { description: string })[] = invoices.map(invoice => ({
...invoice,
description: invoice.session.description
}));
return facturas || null;
} catch (error: any) {
this.logger.error(`Error fetching invoices for user ${userId}: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error al buscar las facturas');
}
}
async findInvoicesByUserId(userId: string): Promise<(Invoice & { description: string })[] | null> {
try {
const invoices = await this.prisma.invoice.findMany({
where: { userId },
include: {
session: true
}
});
const facturas: (Invoice & { description: string })[] = invoices.map(invoice => ({
...invoice,
description: invoice.session.description
}));
return facturas || null;
} catch (error: any) {
this.logger.error(`Error fetching invoices for user ${userId}: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error al buscar las facturas');
}
}
async updateStatus(invoiceId: string, status: InvoiceStatus) {
try {
const invoice = await this.prisma.invoice.update({
where: { id: invoiceId },
data: { status }
});
return invoice;
}
catch (error: any) {
this.logger.error(`Error updating invoice status: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error al actualizar el estado de la factura');
}
}
async findById(id: string) {
try {
return await this.prisma.invoice.findUnique({
where: { id },
include: {
user: true,
session: true
}
});
} catch (error: any) {
this.logger.error('Error al buscar invoice por id', error?.message);
throw new InternalServerErrorException('Error al buscar la factura');
}
}
async updateInvoiceStatus(userId: string, invoiceId: string, status: InvoiceStatus, actionBy?: AuthUser): Promise<Invoice> {
const actionByInfo = actionBy ? `${actionBy.name || actionBy.id} (${actionBy.role})` : 'Unknown';
this.logger.log(`Updating invoice status - userId: ${userId}, invoiceId: ${invoiceId}, newStatus: ${status}, actionBy: ${actionByInfo}`);
try {
// Verificar que la factura existe y pertenece al usuario
const invoice = await this.prisma.invoice.findFirst({
where: {
id: invoiceId,
userId: userId
}
});
if (!invoice) {
this.logger.warn(`Invoice not found or doesn't belong to user - invoiceId: ${invoiceId}, userId: ${userId}, attemptedBy: ${actionByInfo}`);
throw new InternalServerErrorException('Factura no encontrada o no pertenece al usuario');
}
this.logger.log(`Found invoice - currentStatus: ${invoice.status}, updating to: ${status}, actionBy: ${actionByInfo}`);
// Si el estado es PAID o CANCELED, actualizar en Odoo primero
if (status === InvoiceStatus.PAID || status === InvoiceStatus.CANCELED) {
this.logger.log(`Updating invoice status in Odoo - invoiceId: ${invoiceId}, status: ${status}`);
try {
await this.odooService.updateInvoiceStatus(invoiceId, status);
this.logger.log(`Invoice status updated in Odoo successfully - invoiceId: ${invoiceId}`);
} catch (odooError: any) {
this.logger.error(`Failed to update invoice status in Odoo - invoiceId: ${invoiceId}, error: ${odooError.message}`);
throw new InternalServerErrorException(`Error al actualizar el estado en Odoo: ${odooError.message}`);
}
}
// Actualizar el estado en la base de datos
const updatedInvoice = await this.prisma.invoice.update({
where: { id: invoiceId },
data: { status },
include: {
user: true,
session: true
}
});
this.logger.log(`Invoice status updated successfully - invoiceId: ${invoiceId}, from: ${invoice.status} to: ${status}, by: ${actionByInfo}`);
return updatedInvoice;
} catch (error: any) {
this.logger.error(`Error updating invoice status by ${actionByInfo}: ${error.message}`, error.stack);
// Si el error ya es de Odoo, re-lanzarlo
if (error instanceof InternalServerErrorException) {
throw error;
}
throw new InternalServerErrorException('Error al actualizar el estado de la factura');
}
}
/**
* Crea facturas para todos los usuarios asistentes de sesiones activas
* para el mes especificado (o mes actual si no se especifica)
*/
async createMonthlyInvoicesForActiveAssistants(month?: Date) {
try {
const targetMonth = month || new Date();
const startOfMonth = dayjs(targetMonth).startOf('month').toDate();
this.logger.log(`Creating monthly invoices for ${dayjs(targetMonth).format('YYYY-MM')}`);
// Obtener todas las sesiones activas con sus asistentes
const activeSessions = await this.prisma.session.findMany({
where: { isActive: true },
include: {
assistants: true,
priceHistories: true
}
});
const invoicesCreated = [];
for (const session of activeSessions) {
for (const assistant of session.assistants) {
// Verificar que no exista ya una factura para este usuario, sesión y mes
const existingInvoice = await this.prisma.invoice.findFirst({
where: {
userId: assistant.id,
sessionId: session.id,
dateInvoice: {
gte: startOfMonth,
lt: dayjs(targetMonth).endOf('month').toDate()
}
}
});
if (!existingInvoice) {
const invoice = await this.newInvoiceForSession(
assistant.id,
session.id,
startOfMonth
);
invoicesCreated.push(invoice);
}
}
}
this.logger.log(`Created ${invoicesCreated.length} invoices for ${dayjs(targetMonth).format('YYYY-MM')}`);
return invoicesCreated;
}
catch (error: any) {
this.logger.error(`Error creating monthly invoices: ${error.message}`, error.stack);
throw new InternalServerErrorException('Error en la creación masiva de facturas');
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import 'reflect-metadata';
import { AppModule } from './app.module.js';
// ─── Silenciar logs verbosos de libsignal-node ────────────────────────────────
// @whiskeysockets/libsignal-node emite console.info hardcodeado para gestión
// interna de sesiones E2E (Closing/Opening session, etc.). Es comportamiento
// normal del protocolo Signal; no aporta valor en producción.
const _originalConsoleInfo = console.info.bind(console);
const LIBSIGNAL_PATTERNS = [
'Closing session:',
'Opening session:',
'Removing old closed session:',
'Migrating session to:',
];
console.info = (...args: any[]) => {
const msg = String(args[0] ?? '');
if (LIBSIGNAL_PATTERNS.some((p) => msg.includes(p))) return;
_originalConsoleInfo(...args);
};
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Pipes de validación
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
// CORS
app.enableCors({
origin: '*',
credentials: true,
});
// Configuración de Swagger
const config = new DocumentBuilder()
.setTitle('Bot WhatsApp API')
.setDescription('API de gestión de usuarios, sesiones, asistencia, facturación y configuración del sistema')
.setVersion('1.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Ingresa tu token de autenticación',
},
'bearer',
)
.addTag('Configuración del Sistema', 'Endpoints para gestionar la configuración dinámica del sistema')
.addTag('Autenticación', 'Endpoints de autenticación y gestión de tokens')
.addTag('Usuarios', 'Gestión de usuarios del sistema')
.addTag('Sesiones', 'Gestión de sesiones y clases')
.addTag('Facturas', 'Gestión de facturación')
.addTag('Asistencia', 'Control de asistencia')
.addTag('WhatsApp', 'Estado y gestión del bot de WhatsApp')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document, {
customSiteTitle: 'Bot WhatsApp API - Documentación',
customfavIcon: 'https://nestjs.com/img/logo-small.svg',
customCss: '.swagger-ui .topbar { display: none }',
});
const port = process.env.PORT || 3001;
await app.listen(Number(port), '0.0.0.0');
console.log(`Server running on http://localhost:${port}`);
console.log(`Swagger documentation available at http://localhost:${port}/api/docs`);
}
bootstrap().catch((err) => {
console.error('Error starting application:', err);
process.exit(1);
});
+29
View File
@@ -0,0 +1,29 @@
export type OdooInvoiceInput = {
user: {
id: string;
dni: string;
},
product: {
description: string;
invoiceId: string;
sessionId: string;
amount: number;
}
}
export type OdooInvoiceResponse = {
jsonrpc: string,
id: string,
result: {
invoiceId: string;
base64Invoice: string; // PDF en base64
linkPayment: string; // URL para pagar la factura
}}
export type OdooWebhook = {
invoiceId: string;
status: 'PENDING' | 'PAID' | 'CANCELED';
}
+20
View File
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OdooController } from './odoo.controller';
import { OdooService } from './odoo.service';
describe('OdooController', () => {
let controller: OdooController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [OdooController],
providers: [OdooService],
}).compile();
controller = module.get<OdooController>(OdooController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { Body, Controller, Post, Res } from '@nestjs/common';
import { OdooWebhook } from './entities/odoo.entity';
import { OdooService } from './odoo.service';
import { Response } from 'express';
@Controller('odoo')
export class OdooController {
constructor(private readonly odooService: OdooService) { }
@Post('/webhook')
async webhook(
@Body() data: OdooWebhook,
@Res() res: Response
) {
await this.odooService.webhook(data);
return res.status(200).send('OK');
}
}
+15
View File
@@ -0,0 +1,15 @@
import { forwardRef, Module } from '@nestjs/common';
import { OdooService } from './odoo.service';
import { OdooController } from './odoo.controller';
import { HttpModule } from '@nestjs/axios';
import { InvoicesModule } from '../invoices/invoices.module';
@Module({
controllers: [OdooController],
providers: [OdooService],
imports: [HttpModule,
forwardRef(() => InvoicesModule),
],
exports: [OdooService],
})
export class OdooModule { }
+18
View File
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OdooService } from './odoo.service';
describe('OdooService', () => {
let service: OdooService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [OdooService],
}).compile();
service = module.get<OdooService>(OdooService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
File diff suppressed because one or more lines are too long
+10
View File
@@ -0,0 +1,10 @@
// src/prisma/prisma.module.ts
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+11
View File
@@ -0,0 +1,11 @@
// src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { await this.$connect(); }
async onModuleDestroy() { await this.$disconnect(); }
}
+1
View File
@@ -0,0 +1 @@
export class CreateRfidDto {}
+4
View File
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateRfidDto } from './create-rfid.dto';
export class UpdateRfidDto extends PartialType(CreateRfidDto) {}
+1
View File
@@ -0,0 +1 @@
export class Rfid {}
+20
View File
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RfidController } from './rfid.controller';
import { RfidService } from './rfid.service';
describe('RfidController', () => {
let controller: RfidController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [RfidController],
providers: [RfidService],
}).compile();
controller = module.get<RfidController>(RfidController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
+19
View File
@@ -0,0 +1,19 @@
import { Body, Controller, Header, HttpCode, Post } from '@nestjs/common';
import { RfidService } from './rfid.service';
@Controller('rfid')
// TODO ponerle un token estatico a los lectores RFID ?
// @UseGuards(AuthTokenGuard)
export class RfidController {
constructor(private readonly rfidService: RfidService) { }
@Post('ping')
@HttpCode(200)
@Header('Content-Type', 'text/plain')
async ping(@Body('id') uid: string): Promise<string> {
console.log(`RFID ping recibido con id=${uid}`);
await this.rfidService.ping(uid);
return 'ok'
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { InvoicesModule } from '../invoices/invoices.module';
import { AttendanceModule } from '../attendance/attendance.module';
import { UserModule } from '../user/user.module';
import { WhatsappModule } from '../whatsapp/whatsapp.module';
import { RfidController } from './rfid.controller';
import { RfidService } from './rfid.service';
import { AuthModule } from '~/auth/auth.module';
@Module({
controllers: [RfidController],
providers: [RfidService],
imports: [UserModule,AttendanceModule,WhatsappModule,AttendanceModule,InvoicesModule,AuthModule]
})
export class RfidModule { }
+18
View File
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RfidService } from './rfid.service';
describe('RfidService', () => {
let service: RfidService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [RfidService],
}).compile();
service = module.get<RfidService>(RfidService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { Injectable, Logger } from '@nestjs/common';
import { LogDirection } from '@prisma/client';
import dayjs from 'dayjs';
import { AuthService, TokenRedirectURL } from '~/auth/auth.service';
import { AttendanceService } from '../attendance/attendance.service';
import { InvoicesService } from '../invoices/invoices.service';
import { UserService } from '../user/user.service';
import { WhatsappService } from '../whatsapp/whatsapp.service';
@Injectable()
export class RfidService {
private logger = new Logger(RfidService.name);
constructor(
private readonly userService: UserService,
private readonly whatsappService: WhatsappService,
private readonly attendanceService: AttendanceService,
private readonly invoicesService: InvoicesService,
private readonly authService: AuthService,
) { }
async ping(uid: string): Promise<void> {
try {
const usuario = await this.userService.findByRfid(uid);
const admin = await this.userService.getUserAdmin();
if (usuario) {
if (!usuario.phone) {
this.logger.debug(`El usuario ${usuario.name} no tiene un número de teléfono registrado.`);
const number = admin?.phone ? `549${admin.phone}` : '5493435077510'
this.logger.log({
action: 'RFID ping',
message: `El usuario ${usuario.name} con ID ${usuario.id} no tiene un número de teléfono registrado.`,
uid: uid,
userId: usuario.id,
})
await this.whatsappService.sendText(
number,
`El usuario ${usuario.name} con ID ${usuario.id} no tiene un número de teléfono registrado. Por favor, actualiza su información.`);
} else {
this.logger.log(`RFID ping recibido con id=${uid} del usuario ${usuario.name}`);
const log = await this.attendanceService.logAccess(usuario.id);
const tipo = log.direction === LogDirection.INGRESS ? 'ingreso' : 'egreso';
const hora = dayjs(log.timestamp).format('HH:mm');
// ---- Construir un único mensaje ----
let message = `Hola ${usuario.name} ! \n`;
if (log.direction === LogDirection.INGRESS) {
this.logger.log(`Registro de ingreso para el usuario ${usuario.name} a las ${hora}`);
message += `Registramos tu ${tipo} a las ${hora}. \n`;
// Verificar factura pagada
const unpaidInvoice = await this.invoicesService.hasUnpaidInvoiceThisMonth(usuario.id);
if (unpaidInvoice) {
this.logger.warn(`El usuario ${usuario.name} tiene facturas no pagadas este mes.`);
message += `⚠️ Notamos que tienes facturas no pagadas este mes. Por favor, regulariza tu situación.`;
}
} else {
// Si es egreso, solo avisamos el registro
message += `Registramos tu ${tipo} a las ${hora}.`;
}
// Enviar el mensaje único
await this.whatsappService.sendText('549' + usuario.phone, message);
}
return;
} else {
//NOTE mando al admin que se esta registrando un un nuevo usuario
this.logger.error({
action: 'RFID ping',
message: `RFID ping recibido con id=${uid} pero no se encontró el usuario`,
uid: uid,
})
const newUser = await this.userService.createUserTemporary(uid);
this.logger.debug(`Usuario temporal creado: ${newUser.name} con ID ${newUser.id}`);
if (!admin) {
this.logger.error(`No se encontró un usuario administrador para notificar sobre el nuevo usuario con RFID ${uid}.`);
return;
}
const token = await this.authService.generarToken(admin.id, TokenRedirectURL.USER);
await this.whatsappService.sendText('549' + admin?.phone,
`🆕 Se registro un *nuevo usuario* a travez de un nuevo *llavero*.\n
🤔 Nombre: *${newUser.name}*\n
Porfavor actualiza su información ingresando al link 🙏\n
${process.env.URL_FRONT}?t=${token}
`);
return;
}
}
catch (error: any) {
this.logger.error(`Error al procesar el ping RFID: ${error}`);
}
}
}
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SchedulingController } from './scheduling.controller';
import { SchedulingService } from './scheduling.service';
describe('SchedulingController', () => {
let controller: SchedulingController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SchedulingController],
providers: [SchedulingService],
}).compile();
controller = module.get<SchedulingController>(SchedulingController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
@@ -0,0 +1,7 @@
import { Controller } from '@nestjs/common';
import { SchedulingService } from './scheduling.service';
@Controller('scheduling')
export class SchedulingController {
constructor(private readonly schedulingService: SchedulingService) {}
}
@@ -0,0 +1,24 @@
import { forwardRef, Module } from '@nestjs/common';
import { SchedulingService } from './scheduling.service';
import { SchedulingController } from './scheduling.controller';
import { SessionsModule } from '~/sessions/sessions.module';
import { ScheduleModule } from '@nestjs/schedule';
import { InvoicesModule } from '~/invoices/invoices.module';
import { SystemConfigService } from '~/common/system-config.service';
import { PrismaModule } from '~/prisma/prisma.module';
import { UserModule } from '~/user/user.module';
import { AuthModule } from '~/auth/auth.module';
@Module({
controllers: [SchedulingController],
providers: [SchedulingService, SystemConfigService],
imports: [
ScheduleModule.forRoot(),
SessionsModule,
InvoicesModule,
PrismaModule,
forwardRef(() => UserModule),
forwardRef(() => AuthModule),
]
})
export class SchedulingModule { }
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SchedulingService } from './scheduling.service';
describe('SchedulingService', () => {
let service: SchedulingService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SchedulingService],
}).compile();
service = module.get<SchedulingService>(SchedulingService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});
@@ -0,0 +1,155 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InvoicesService } from '~/invoices/invoices.service';
import { PrismaService } from '~/prisma/prisma.service';
import { SessionsService } from '~/sessions/sessions.service';
import { SystemConfigService } from '~/common/system-config.service';
@Injectable()
export class SchedulingService {
private readonly logger = new Logger(SchedulingService.name);
private lastSessionCleanup: number = 0;
private lastTokenCleanup: number = 0;
constructor(
private readonly sessions: SessionsService,
private readonly invoices: InvoicesService,
private readonly prisma: PrismaService,
private readonly systemConfig: SystemConfigService,
) { }
// Desactiva sesiones vencidas cada 30 minutos (configurable)
@Cron(CronExpression.EVERY_MINUTE, { name: 'deactivateExpiredSessions' })
async deactivateExpiredSessionsJob() {
// Verificar si debe ejecutarse según la configuración
const intervalMinutes = this.systemConfig.getSessionCleanupInterval();
const now = Date.now();
if (now - this.lastSessionCleanup < intervalMinutes * 60 * 1000) {
return; // Aún no es tiempo de ejecutar
}
this.lastSessionCleanup = now;
try {
const count = await this.sessions.deactivateExpiredActives();
if (count > 0) {
this.logger.log(`Desactivadas ${count} sesiones vencidas.`);
} else {
this.logger.verbose('No había sesiones vencidas para desactivar.');
}
} catch (err: any) {
this.logger.error('Error desactivando sesiones vencidas', err?.stack || err);
}
}
// Todos los meses se deben de generar las facturas y enlaces de pagos
// para que el usuario pueda pagar la clase que este inscripto y activa.
// Se ejecuta diariamente y verifica si es el día configurado del mes
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT, { name: 'generateMonthlyInvoices' })
async generateMonthlyInvoicesAndPaymentLinksJob() {
const today = new Date().getDate();
const configuredDay = this.systemConfig.getInvoiceGenerationDay();
// Solo ejecutar si es el día configurado del mes
if (today !== configuredDay) {
return;
}
let totalProcessed = 0;
let totalSuccess = 0;
let totalFailed = 0;
const errors: Array<{ sessionId: string; assistantId: string; error: string }> = [];
try {
const sessions = await this.sessions.findAllSessionInThisMonth();
this.logger.log(`Iniciando generación de facturas para ${sessions.length} sesiones`);
for (const session of sessions) {
const { id: sessionId, assistants } = session;
for (const assistant of assistants) {
totalProcessed++;
try {
await this.invoices.newInvoiceForSession(
assistant.id,
sessionId,
new Date()
);
totalSuccess++;
this.logger.verbose(
`✓ Factura creada: session=${sessionId}, assistant=${assistant.id}`
);
} catch (invoiceErr: any) {
totalFailed++;
const errorMsg = invoiceErr?.message || String(invoiceErr);
errors.push({
sessionId,
assistantId: assistant.id,
error: errorMsg
});
this.logger.warn(
`✗ Error creando factura: session=${sessionId}, assistant=${assistant.id}, error=${errorMsg}`
);
}
}
}
// Resumen final
this.logger.log(
`Generación de facturas completada: ` +
`Total=${totalProcessed}, Éxito=${totalSuccess}, Fallos=${totalFailed}`
);
if (errors.length > 0) {
this.logger.error(
`Se encontraron ${errors.length} errores durante la generación:`,
JSON.stringify(errors, null, 2)
);
}
} catch (err: any) {
this.logger.error(
'Error crítico generando facturas mensuales y enlaces de pago',
err?.stack || err
);
}
}
// Limpia tokens expirados cada hora (configurable)
@Cron(CronExpression.EVERY_MINUTE, { name: 'cleanupExpiredTokens' })
async cleanupExpiredTokensJob() {
// Verificar si debe ejecutarse según la configuración
const intervalMinutes = this.systemConfig.getTokenCleanupInterval();
const now = Date.now();
if (now - this.lastTokenCleanup < intervalMinutes * 60 * 1000) {
return; // Aún no es tiempo de ejecutar
}
this.lastTokenCleanup = now;
try {
const result = await this.prisma.token.deleteMany({
where: {
expiresAt: {
lt: new Date(), // Eliminar los expirados
},
},
});
if (result.count > 0) {
this.logger.log(`Eliminados ${result.count} tokens expirados`);
} else {
this.logger.verbose('No había tokens expirados para eliminar.');
}
} catch (err: any) {
this.logger.error('Error limpiando tokens expirados', err?.stack || err);
}
}
}
@@ -0,0 +1,98 @@
import {
IsArray,
IsEnum,
IsOptional,
IsString,
IsDateString,
ValidateNested,
IsObject,
IsNotEmpty,
IsNumber,
} from 'class-validator';
import { Type } from 'class-transformer';
import { SessionType } from '@prisma/client';
export enum ClaseTipo {
RECURRING = 'RECURRING',
ONE_TIME = 'ONE_TIME',
}
class ProfesorDto {
@IsString()
customId!: string;
}
class DateRangeDto {
@IsDateString()
start!: string;
@IsDateString()
end!: string;
}
export class CreateClaseDto {
@IsString()
title!: string;
@IsObject()
@ValidateNested()
@Type(() => ProfesorDto)
profesor!: ProfesorDto;
@IsEnum(ClaseTipo)
type!: ClaseTipo;
@IsOptional()
@IsArray()
@IsString({ each: true })
days?: string[] | null;
@IsArray()
@ValidateNested({ each: true })
@Type(() => DateRangeDto)
dates!: DateRangeDto[];
@IsDateString()
startDate!: string;
@IsDateString()
endDate!: string;
}
export class UpsertClaseDto {
@IsOptional()
@IsString()
id?: string; // si viene, edita; si no, crea
@IsString()
@IsNotEmpty()
title!: string;
@IsEnum(SessionType)
type!: SessionType;
@IsDateString()
startDate!: string;
@IsDateString()
endDate!: string;
@ValidateNested()
@Type(() => ProfesorDto)
profesor!: ProfesorDto;
@IsArray()
@ValidateNested({ each: true })
@Type(() => DateRangeDto)
dates!: DateRangeDto[];
@IsNumber()
amount: number | undefined
}
export class SetParticipantsDto {
@IsArray()
@IsString({ each: true })
userIds!: string[]; // IDs finales seleccionados
}

Some files were not shown because too many files have changed in this diff Show More