first commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
API_KEY=753a2sd1du64-c412-3c3a-9a4hdr9-ac419213957e
|
||||
DATABASE_CONNECTION = mongodb://horacio:frx8us2w@localhost:20215/turnosxpress
|
||||
API_URL = http://localhost:3000/appointments/send-wap-sys-notification
|
||||
MAX_INTENTOS=3
|
||||
TIME_BASE = 8
|
||||
TIME_EXTRA = 7
|
||||
PLAN_LIST = 681e70a24c253049df86808c
|
||||
@@ -0,0 +1,7 @@
|
||||
API_KEY=ad7c956a-76bf-45hdr60-8a50-2ca7b2180997
|
||||
DATABASE_CONNECTION = mongodb://txuser:hdR2102Frx8us2WkuN21quAn12@mongodb:27017/turnosxpress
|
||||
API_URL = http://turnosxpress:3000/appointments/send-wap-sys-notification
|
||||
MAX_INTENTOS = 3
|
||||
TIME_BASE = 8
|
||||
TIME_EXTRA = 7
|
||||
PLAN_LIST = 681e70a24c253049df86808c,68b64f16f87ccf13f2725246
|
||||
@@ -0,0 +1,42 @@
|
||||
# --- Etapa 1: Build ---
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Instalar dependencias de desarrollo
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copiar el código fuente y archivos de entorno
|
||||
COPY . .
|
||||
COPY .env* .
|
||||
|
||||
# Compilar TypeScript
|
||||
RUN npm run build
|
||||
|
||||
# --- Etapa 2: Runtime mínimo ---
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Instalar solo dependencias de producción
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
# Copiar la carpeta compilada desde la etapa de build
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Copiar archivos de entorno a la raíz /app
|
||||
COPY --from=builder /app/.env* ./
|
||||
|
||||
# Instalar bash y cron
|
||||
RUN apk add --no-cache bash curl
|
||||
|
||||
# Crear carpeta de logs
|
||||
RUN mkdir -p /app/logs
|
||||
|
||||
# Agregar tarea de cron: todos los días a las (9-3)UTF = 6AM GMT-3
|
||||
RUN echo "0 9 * * * cd /app && NODE_ENV=production NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node dist/index.js >> /app/logs/cron.log 2>&1" > /etc/crontabs/root
|
||||
|
||||
# Mantener cron en primer plano
|
||||
CMD ["crond", "-f", "-L", "/app/logs/cron.log"]
|
||||
Generated
+1065
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "notification-sender",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@types/axios": "^0.9.36",
|
||||
"@types/mongoose": "^5.11.96",
|
||||
"@types/node": "^24.3.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.12.1",
|
||||
"dayjs": "^1.11.18",
|
||||
"dotenv": "^16.6.1",
|
||||
"mongoose": "^8.18.1",
|
||||
"winston": "^3.17.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Document, Model, Schema, model } from "mongoose";
|
||||
|
||||
export interface IAppointment {
|
||||
companyId: string;
|
||||
start: Date;
|
||||
notificated?: boolean;
|
||||
notificationIntents?: number;
|
||||
}
|
||||
|
||||
export interface IAppointmentDocument extends Omit<IAppointment, "id">, Document {}
|
||||
|
||||
export class Appointments {
|
||||
schema: Schema;
|
||||
appointmentsList: Model<IAppointmentDocument>;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema({
|
||||
companyId: { type: Schema.Types.ObjectId, required: true, ref: "Companie" },
|
||||
start: { type: Date, required: true },
|
||||
notificated: { type: Boolean, required: false },
|
||||
notificationIntents: { type: Number, required: false },
|
||||
});
|
||||
|
||||
this.appointmentsList = model<IAppointmentDocument>("Appointment", this.schema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import mongoose, { Document, Model, Schema, model } from "mongoose";
|
||||
|
||||
export interface ICompany {
|
||||
id?: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
automaticNotifications?: boolean;
|
||||
}
|
||||
|
||||
export interface ICompanyDocument extends Omit<ICompany, "id">, Document {}
|
||||
|
||||
export class Companies {
|
||||
schema: Schema;
|
||||
companyList: Model<ICompanyDocument>;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema({
|
||||
name: { type: String, required: true, unique: true },
|
||||
ownerId: { type: Schema.Types.ObjectId, required: true, ref: "User" },
|
||||
automaticNotifications: { type: Boolean, required: false },
|
||||
});
|
||||
|
||||
this.companyList = model<ICompanyDocument>("Company", this.schema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import dayjs from "dayjs";
|
||||
import axios from "axios";
|
||||
import { logDone, logError, logIntent } from "../../config/logger.js";
|
||||
import { Appointments, IAppointment, IAppointmentDocument } from "../Appointments/Appointments.js";
|
||||
import { Companies } from "../Companies/Companies.js";
|
||||
import { PlanSuscriptions } from "../PlanSubscriptions/PlanSubscriptions.js";
|
||||
|
||||
export type ApiVoidResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SendWapSystemNotificationParams = {
|
||||
appointmentId: string;
|
||||
systemToken: string;
|
||||
};
|
||||
|
||||
export class Notifications {
|
||||
private users: string[] = [];
|
||||
private companies: string[] = [];
|
||||
private appointments: IAppointmentDocument[] = [];
|
||||
private appointmentsCollection: Appointments;
|
||||
constructor() {
|
||||
this.users = [];
|
||||
this.companies = [];
|
||||
this.appointments = [];
|
||||
this.appointmentsCollection = new Appointments();
|
||||
}
|
||||
|
||||
private async loadUsers() {
|
||||
logIntent("Cargando lista de usuarios.");
|
||||
const loadedPlanIds = `${process.env.PLAN_LIST}`;
|
||||
|
||||
if (loadedPlanIds === "") {
|
||||
logError("No se ha encontrado una lista de planes valida.", "");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const PLAN_IDS = loadedPlanIds.split(",");
|
||||
const subs = new PlanSuscriptions();
|
||||
const result = await subs.planSuscriptionList.find({ planId: { $in: PLAN_IDS } }).exec();
|
||||
this.users = result.map((x) => String(x.userId));
|
||||
logDone("Lista de usuarios cargada correctamente.");
|
||||
}
|
||||
|
||||
private async loadCompanies() {
|
||||
logIntent("Cargando lista de organizaciones.");
|
||||
const orgs = new Companies();
|
||||
|
||||
for (const user of this.users) {
|
||||
const organizations = await orgs.companyList
|
||||
.find({ ownerId: user, automaticNotifications: true })
|
||||
.exec();
|
||||
|
||||
for (const orgItem of organizations) {
|
||||
this.companies.push(orgItem.id);
|
||||
}
|
||||
}
|
||||
logDone("Lista de organizaciones cargada correctamente.");
|
||||
}
|
||||
|
||||
private async loadAppointments() {
|
||||
try {
|
||||
logIntent("Cargando nuevo lote de notificaciones");
|
||||
this.appointments = [];
|
||||
const dateFrom = dayjs().clone().startOf("day");
|
||||
const dateTo = dayjs().clone().endOf("day");
|
||||
|
||||
for (const company of this.companies) {
|
||||
const result = await this.appointmentsCollection.appointmentsList
|
||||
.findOne({
|
||||
start: { $gte: dateFrom.toDate(), $lte: dateTo.toDate() },
|
||||
companyId: company,
|
||||
$and: [
|
||||
{ $or: [{ notificated: false }, { notificated: { $exists: false } }] },
|
||||
{
|
||||
$or: [
|
||||
{ notificationIntents: { $lt: 3 } },
|
||||
{ notificationIntents: { $exists: false } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
.exec();
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
this.appointments.push(result);
|
||||
}
|
||||
logDone("Nuevo lote de notificaciones cargado correctamente.");
|
||||
} catch (error) {
|
||||
logError("Error cargando nuevo lote de notificaciones:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public async start() {
|
||||
await this.loadUsers();
|
||||
await this.loadCompanies();
|
||||
await this.loadAppointments();
|
||||
|
||||
this.next();
|
||||
}
|
||||
public async next() {
|
||||
const getDelay = () => {
|
||||
const timeBase = Number(process.env.TIME_BASE ? process.env.TIME_BASE : 8);
|
||||
const timeExtra = Number(process.env.TIME_EXTRA ? process.env.TIME_EXTRA : 8);
|
||||
return Math.floor(Math.random() * timeBase) + timeExtra;
|
||||
};
|
||||
|
||||
const app = this.appointments.shift();
|
||||
|
||||
if (!app) {
|
||||
logDone("Se ha finalizado el envío del lote de notificaciones.");
|
||||
setTimeout(() => {
|
||||
this.reload();
|
||||
}, getDelay() * 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.send(String(app.id));
|
||||
app.notificated = true;
|
||||
} catch (error) {
|
||||
app.notificationIntents = app.notificationIntents ? app.notificationIntents + 1 : 1;
|
||||
logError("Error enviando notificación:", error);
|
||||
}
|
||||
|
||||
await app.save();
|
||||
|
||||
setTimeout(() => {
|
||||
this.next();
|
||||
}, getDelay() * 1000);
|
||||
}
|
||||
|
||||
public async reload() {
|
||||
await this.loadAppointments();
|
||||
if (this.appointments.length < 1) {
|
||||
this.end();
|
||||
}
|
||||
this.next();
|
||||
}
|
||||
|
||||
public async end() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
private async send(appointmentId: string): Promise<void> {
|
||||
logIntent(`Enviando notificación para la cita ${appointmentId}.`);
|
||||
|
||||
const dataApi: SendWapSystemNotificationParams = {
|
||||
appointmentId: appointmentId,
|
||||
systemToken: `${process.env.API_KEY}`,
|
||||
};
|
||||
const response = await axios.post<ApiVoidResult>(`${process.env.API_URL}`, dataApi, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result: ApiVoidResult = response.data;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
|
||||
logDone("Notificación enviada correctamente.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import mongoose, { Document, Model, Schema, model } from "mongoose";
|
||||
|
||||
export interface IPlanSuscription {
|
||||
userId: string;
|
||||
planId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export interface IPlanSuscriptionDocument extends IPlanSuscription, Document {}
|
||||
|
||||
export class PlanSuscriptions {
|
||||
schema: Schema;
|
||||
planSuscriptionList: Model<IPlanSuscriptionDocument>;
|
||||
|
||||
constructor() {
|
||||
this.schema = new Schema({
|
||||
userId: { type: Schema.Types.ObjectId, required: true },
|
||||
planId: { type: Schema.Types.ObjectId, required: true },
|
||||
startDate: { type: Date, required: true },
|
||||
endDate: { type: Date, required: true },
|
||||
});
|
||||
|
||||
this.planSuscriptionList = model<IPlanSuscriptionDocument>("PlanSuscription", this.schema);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import fs from "fs";
|
||||
import { createLogger, format, transports } from "winston";
|
||||
|
||||
const LOG_FILE = "logs/combined.log";
|
||||
|
||||
// Asegurarse de que la carpeta exista
|
||||
if (!fs.existsSync("logs")) fs.mkdirSync("logs");
|
||||
|
||||
// Vaciar el archivo al iniciar
|
||||
fs.writeFileSync(LOG_FILE, "", "utf-8");
|
||||
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
const bold = "\x1b[1m";
|
||||
const reset = "\x1b[0m";
|
||||
|
||||
const logger = createLogger({
|
||||
level: "info",
|
||||
format: format.combine(
|
||||
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
|
||||
format.colorize(),
|
||||
!isDev
|
||||
? format.printf(({ timestamp, level, message }) => {
|
||||
return `[${timestamp}] ${level}: ${message}`;
|
||||
})
|
||||
: format.printf(({ timestamp, level, message }) => {
|
||||
return ` ${level}: ${message}`;
|
||||
})
|
||||
),
|
||||
transports: [
|
||||
new transports.Console(), // sigue mostrando en consola
|
||||
new transports.File({ filename: "logs/error.log", level: "error" }), // solo errores
|
||||
new transports.File({ filename: "logs/combined.log" }), // todos los logs
|
||||
],
|
||||
});
|
||||
|
||||
export const logIntent = (message: string) => {
|
||||
logger.info(`⏳ ${message}`);
|
||||
};
|
||||
|
||||
export const logDone = (message: string) => {
|
||||
logger.info(`✅ ${bold}${message}${reset}`);
|
||||
};
|
||||
|
||||
export const logError = (message: string, error: any) => {
|
||||
logger.info(`❌ ${bold}${message}${reset}\n\r`, error);
|
||||
};
|
||||
|
||||
export const logWarn = (message: string) => {
|
||||
logger.info(`⚠️ ${message}`);
|
||||
};
|
||||
|
||||
export const logDebug = (message: string) => {
|
||||
logger.debug(`${message}`);
|
||||
};
|
||||
|
||||
export default logger;
|
||||
@@ -0,0 +1,42 @@
|
||||
import dotenv from "dotenv";
|
||||
import logger, { logDone, logError, logIntent } from "./config/logger.js";
|
||||
import mongoose from "mongoose";
|
||||
import { Notifications } from "./Models/Notifications/Notifications.js";
|
||||
|
||||
logIntent("Iniciando Notification Sender.");
|
||||
|
||||
logIntent("Cargando variables de entorno.");
|
||||
const envResult = dotenv.config();
|
||||
|
||||
if (envResult.error) {
|
||||
logError("Error cargando variables de entorno:", envResult.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logDone("Variables de entorno cargadas correctamente.");
|
||||
|
||||
const MONGO_URI = process.env.DATABASE_CONNECTION;
|
||||
|
||||
if (!MONGO_URI) {
|
||||
logError("DATABASE_CONNECTION no está definido en las variables de entorno.", "");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
logIntent("Conectando a MongoDB.");
|
||||
mongoose
|
||||
.connect(MONGO_URI)
|
||||
.then(() => {
|
||||
const start = async () => {
|
||||
logDone("Conexión a MongoDB establecida correctamente.");
|
||||
logger.info("🚀 Notification Sender running");
|
||||
|
||||
const worker = new Notifications();
|
||||
await worker.start();
|
||||
};
|
||||
|
||||
start();
|
||||
})
|
||||
.catch((err) => {
|
||||
logError("Error conectando a MongoDB:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user