From aac84f053a6b71e6bd910d970144bbac812c8588 Mon Sep 17 00:00:00 2001
From: karlosvas <126987511+karlosvas@users.noreply.github.com>
Date: Sun, 12 Jan 2025 10:51:33 +0100
Subject: [PATCH] Github actions to create dist in server
---
.github/workflows/deploy.yml | 79 +++++++++
.gitignore | 1 -
client/.gitignore | 1 +
client/vercel.json | 7 -
package-lock.json | 18 ++
package.json | 5 +-
server/.gitignore | 1 +
server/dist/lib/firebase/firebase-config.js | 41 +++++
server/dist/lib/firebase/firebase-db.js | 12 ++
server/dist/lib/mailchimp/mailchimp.js | 69 ++++++++
server/dist/lib/resend/resend.js | 58 +++++++
server/dist/middelware/token-logs.js | 27 +++
server/dist/playwright.config.js | 41 +++++
server/dist/routes/comments.js | 182 ++++++++++++++++++++
server/dist/routes/firebase.js | 23 +++
server/dist/routes/mailchimp.js | 181 +++++++++++++++++++
server/dist/routes/publications.js | 119 +++++++++++++
server/dist/routes/resend.js | 25 +++
server/dist/routes/routes.js | 33 ++++
server/dist/src/index.js | 114 ++++++++++++
server/dist/src/mongodb/models.js | 29 ++++
server/dist/src/mongodb/mongodb.js | 15 ++
server/dist/tests/comments.spec.js | 103 +++++++++++
server/dist/tests/index.spec.js | 8 +
server/dist/tests/mailchimp.spec.js | 141 +++++++++++++++
server/dist/tests/mandril.spec.js | 35 ++++
server/dist/tests/publications.spec.js | 62 +++++++
server/dist/utilities/axios-utils.js | 3 +
server/dist/utilities/delete-logic.js | 13 ++
server/dist/utilities/errorHandle.js | 10 ++
server/lib/resend/resend.ts | 2 +-
server/package-lock.json | 25 ---
server/package.json | 2 +-
server/routes/routes.ts | 7 +-
server/{ => src}/index.ts | 8 +-
server/vercel.json | 16 --
vercel.json | 38 ++++
37 files changed, 1497 insertions(+), 57 deletions(-)
create mode 100644 .github/workflows/deploy.yml
create mode 100644 client/.gitignore
delete mode 100644 client/vercel.json
create mode 100644 server/.gitignore
create mode 100644 server/dist/lib/firebase/firebase-config.js
create mode 100644 server/dist/lib/firebase/firebase-db.js
create mode 100644 server/dist/lib/mailchimp/mailchimp.js
create mode 100644 server/dist/lib/resend/resend.js
create mode 100644 server/dist/middelware/token-logs.js
create mode 100644 server/dist/playwright.config.js
create mode 100644 server/dist/routes/comments.js
create mode 100644 server/dist/routes/firebase.js
create mode 100644 server/dist/routes/mailchimp.js
create mode 100644 server/dist/routes/publications.js
create mode 100644 server/dist/routes/resend.js
create mode 100644 server/dist/routes/routes.js
create mode 100644 server/dist/src/index.js
create mode 100644 server/dist/src/mongodb/models.js
create mode 100644 server/dist/src/mongodb/mongodb.js
create mode 100644 server/dist/tests/comments.spec.js
create mode 100644 server/dist/tests/index.spec.js
create mode 100644 server/dist/tests/mailchimp.spec.js
create mode 100644 server/dist/tests/mandril.spec.js
create mode 100644 server/dist/tests/publications.spec.js
create mode 100644 server/dist/utilities/axios-utils.js
create mode 100644 server/dist/utilities/delete-logic.js
create mode 100644 server/dist/utilities/errorHandle.js
rename server/{ => src}/index.ts (94%)
delete mode 100644 server/vercel.json
create mode 100644 vercel.json
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 00000000..daef79df
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,79 @@
+name: Deploy to Vercel
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ deploy-frontend:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "21.2.0"
+
+ - name: Install Vercel CLI
+ run: npm install -g vercel@latest
+
+ - name: List files in client directory
+ run: ls -la ./client
+
+ - name: Link and pull Vercel project for frontend
+ run: |
+ vercel link --yes --project ${{ secrets.FRONTEND_PROJECT_ID }} --scope ${{ secrets.VERCEL_TEAM }} --token=${{ secrets.VERCEL_TOKEN }}
+ vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
+ working-directory: ./client
+
+ - name: Install frontend dependencies
+ run: npm install
+ working-directory: ./client
+
+ - name: Build frontend
+ run: npm run build
+ working-directory: ./client
+
+ - name: Deploy frontend to Vercel
+ run: vercel --prod --token ${{ secrets.VERCEL_TOKEN }}
+ working-directory: ./client
+
+ deploy-backend:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: "21.2.0"
+
+ - name: Install Vercel CLI
+ run: npm install -g vercel@latest
+
+ - name: List files in server directory
+ run: ls -la ./server
+
+ - name: Link and pull Vercel project for backend
+ run: |
+ vercel link --yes --project ${{ secrets.BACKEND_PROJECT_ID }} --scope ${{ secrets.VERCEL_TEAM }} --token=${{ secrets.VERCEL_TOKEN }}
+ vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
+ working-directory: ./server
+
+ - name: Install backend dependencies
+ run: npm install
+ working-directory: ./server
+
+ - name: Build backend
+ run: npm run build
+ working-directory: ./server
+
+ - name: Deploy backend to Vercel
+ run: vercel --prod --token ${{ secrets.VERCEL_TOKEN }}
+ working-directory: ./server
diff --git a/.gitignore b/.gitignore
index 907aa4c4..7d1bee05 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,7 +12,6 @@ dev
dist-ssr
client/dist
*.local
-dist
# Editor directories and files
.vscode/*
diff --git a/client/.gitignore b/client/.gitignore
new file mode 100644
index 00000000..e985853e
--- /dev/null
+++ b/client/.gitignore
@@ -0,0 +1 @@
+.vercel
diff --git a/client/vercel.json b/client/vercel.json
deleted file mode 100644
index 3cc3e4ea..00000000
--- a/client/vercel.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "rewrites": [
- { "source": "/robots.txt", "destination": "/public/robots.txt" },
- { "source": "/sitemap.xml", "destination": "/public/sitemap.xml" },
- { "source": "/(.*)", "destination": "/" }
- ]
-}
diff --git a/package-lock.json b/package-lock.json
index fc10297f..beea970e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,9 @@
"": {
"name": "thefluentspanishhouse",
"version": "1.0.0",
+ "dependencies": {
+ "prettier": "^3.4.2"
+ },
"devDependencies": {
"concurrently": "^9.1.2"
}
@@ -182,6 +185,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/prettier": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz",
+ "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==",
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
diff --git a/package.json b/package.json
index ef4e20bf..c31faec1 100644
--- a/package.json
+++ b/package.json
@@ -21,5 +21,8 @@
"type": "GNU",
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
}
- ]
+ ],
+ "dependencies": {
+ "prettier": "^3.4.2"
+ }
}
diff --git a/server/.gitignore b/server/.gitignore
new file mode 100644
index 00000000..e985853e
--- /dev/null
+++ b/server/.gitignore
@@ -0,0 +1 @@
+.vercel
diff --git a/server/dist/lib/firebase/firebase-config.js b/server/dist/lib/firebase/firebase-config.js
new file mode 100644
index 00000000..6bc1c7fa
--- /dev/null
+++ b/server/dist/lib/firebase/firebase-config.js
@@ -0,0 +1,41 @@
+import dotenv from "dotenv";
+dotenv.config();
+////////////////////////////////////
+import admin from "firebase-admin";
+// Importa el archivo de credenciales de Firebase Admin
+const serviceAccount = {
+ type: process.env.FIREBASE_TYPE,
+ project_id: process.env.FIREBASE_PROJECT_ID,
+ private_key_id: process.env.FIREBASE_PRIVATE_KEY_ID,
+ private_key: (process.env.FIREBASE_PRIVATE_KEY || "").replace(/\\n/g, "\n"),
+ client_email: process.env.FIREBASE_CLIENT_EMAIL,
+ client_id: process.env.FIREBASE_CLIENT_ID,
+ auth_uri: process.env.FIREBASE_AUTH_URI,
+ token_uri: process.env.FIREBASE_TOKEN_URI,
+ auth_provider_x509_cert_url: process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL,
+ client_x509_cert_url: process.env.FIREBASE_CLIENT_X509_CERT_URL,
+};
+// Verifica si la aplicación ya ha sido inicializada
+if (!admin.apps.length) {
+ try {
+ admin.initializeApp({
+ credential: admin.credential.cert(serviceAccount),
+ databaseURL: process.env.FIREBASE_DB,
+ });
+ console.log("Firebase Admin initialized successfully");
+ // Verifica si se puede conectar a Firebase Auth
+ admin
+ .auth()
+ .listUsers(1)
+ .then(() => {
+ console.log("Successfully connected to Firebase Auth");
+ })
+ .catch((error) => {
+ console.error("Error connecting to Firebase Auth:", error);
+ });
+ }
+ catch (error) {
+ console.error("Error initializing Firebase Admin:", error);
+ }
+}
+export default admin;
diff --git a/server/dist/lib/firebase/firebase-db.js b/server/dist/lib/firebase/firebase-db.js
new file mode 100644
index 00000000..220817c4
--- /dev/null
+++ b/server/dist/lib/firebase/firebase-db.js
@@ -0,0 +1,12 @@
+import admin from "firebase-admin";
+// Función para obtener UID por correo electrónico
+export const getUidByEmail = async (email) => {
+ try {
+ const userRecord = await admin.auth().getUserByEmail(email);
+ return userRecord.uid;
+ }
+ catch (error) {
+ console.error("Error fetching user data:", error);
+ return null;
+ }
+};
diff --git a/server/dist/lib/mailchimp/mailchimp.js b/server/dist/lib/mailchimp/mailchimp.js
new file mode 100644
index 00000000..8f520078
--- /dev/null
+++ b/server/dist/lib/mailchimp/mailchimp.js
@@ -0,0 +1,69 @@
+import dotenv from "dotenv";
+dotenv.config();
+/////////////////////////////////////////////////////////////
+import mailchimp from "@mailchimp/mailchimp_marketing";
+import axios from "axios";
+// Id de la lista publica de contactos en Mailchimp
+const listId = process.env.MAILCHIMP_LIST_ID;
+// Id del grupo de intereses en Mailchimp
+const groupId = process.env.MAILCHIMP_GROUP_ID;
+// Api key de Mailchimp y prefijo del servidor
+const mailchimpKey = process.env.MAILCHIMP_API_KEY;
+const serverPrefix = mailchimpKey.split("-").pop();
+// Configuracion del cliente de la API de Mailchimp marketing
+mailchimp.setConfig({
+ apiKey: mailchimpKey,
+ server: serverPrefix,
+});
+// Convierte los errores de Mailchimp a objeto de javascript
+function mailchimpErrors(error) {
+ return JSON.parse(error.response.text);
+}
+async function addInterestToCategory(interestCategoryId, interestName) {
+ const url = `https://${serverPrefix}.api.mailchimp.com/3.0/lists/${listId}/interest-categories/${interestCategoryId}/interests`;
+ const data = {
+ name: interestName,
+ };
+ try {
+ // El user puede ser cualquier string, no importa
+ const response = await axios.post(url, data, {
+ auth: {
+ username: "admin",
+ password: mailchimpKey,
+ },
+ });
+ return response.data;
+ }
+ catch (error) {
+ if (axios.isAxiosError(error) && error.response) {
+ throw { status: error.response.status, message: error.response.data };
+ }
+ else {
+ throw { status: 500, message: "Unexpected error occurred" };
+ }
+ }
+}
+async function deleteInterestCategory(interestCategoryId) {
+ const url = `https://${serverPrefix}.api.mailchimp.com/3.0/lists/${listId}/interest-categories/${groupId}/interests/${interestCategoryId}`;
+ try {
+ // El user puede ser cualquier string, no importa
+ const response = await axios.delete(url, {
+ auth: {
+ username: "admin",
+ password: mailchimpKey,
+ },
+ });
+ console.log(response.data);
+ return response;
+ }
+ catch (error) {
+ console.log(error);
+ if (axios.isAxiosError(error) && error.response) {
+ throw { status: error.response.status, message: error.response.data };
+ }
+ else {
+ throw { status: 500, message: "Unexpected error occurred" };
+ }
+ }
+}
+export { listId, mailchimpErrors, mailchimp, addInterestToCategory, deleteInterestCategory, groupId };
diff --git a/server/dist/lib/resend/resend.js b/server/dist/lib/resend/resend.js
new file mode 100644
index 00000000..42dd7649
--- /dev/null
+++ b/server/dist/lib/resend/resend.js
@@ -0,0 +1,58 @@
+import dotenv from "dotenv";
+dotenv.config();
+import { Resend } from "resend";
+// Configurar el cliente de Mandrill
+const resend = new Resend(process.env.RESEND_API_KEY);
+const admin = process.env.ADMIN_GMAIL;
+// Enviar nota de contact us a el administrador
+export async function submitNote(note) {
+ try {
+ return await resend.emails.send({
+ from: "no-reply@thefluentspanishhouse.com",
+ to: [admin],
+ subject: note.subject,
+ html: `
+
The user ${note.username} with email ${note.email_user} sent you this message:
${note.note}
`,
+ headers: {
+ "Reply-To": note.email_user,
+ },
+ });
+ }
+ catch (error) {
+ console.error("Error sending email", error);
+ throw error;
+ }
+}
+// // Enviar email de nuevo estuidiante a el administrador
+export async function submitEmailStudent(newstudent) {
+ try {
+ return await resend.emails.send({
+ from: "no-reply@thefluentspanishhouse.com",
+ to: [admin],
+ subject: `New student on TheFluentSpanishHouse ${newstudent.name} ${newstudent.lastname}`,
+ html: `The user ${newstudent.name} ${newstudent.lastname} wants to be a new student:
+ He wants to sign up for ${newstudent.class}
`,
+ });
+ }
+ catch (error) {
+ console.error("Error sending email", error);
+ throw error;
+ }
+}
+// Enviar email de nuevo comentario a el administrador
+export async function submitEmailComment(note, originUrl) {
+ try {
+ return await resend.emails.send({
+ from: `no-reply@thefluentspanishhouse.com`,
+ to: [admin],
+ subject: note.subject,
+ html: `The user ${note.username} ${note.email_user} says:
+ ${note.note}
+ From the post: ${originUrl}
User: ${note.email_user}`,
+ });
+ }
+ catch (error) {
+ console.error("Error sending email", error);
+ throw error;
+ }
+}
diff --git a/server/dist/middelware/token-logs.js b/server/dist/middelware/token-logs.js
new file mode 100644
index 00000000..2a8b3d8e
--- /dev/null
+++ b/server/dist/middelware/token-logs.js
@@ -0,0 +1,27 @@
+import admin from "../lib/firebase/firebase-config.js";
+export async function log(req, res, next) {
+ // Registro de la solicitud
+ console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
+ next();
+}
+export async function verifyIdToken(req, res, next) {
+ // Validación de autenticación
+ const authHeader = req.headers.authorization;
+ if (!authHeader || !authHeader.startsWith("Bearer "))
+ return res.status(401).json({ message: "Unauthorized" });
+ // Extrae el token de Firebase del encabezado de autorización, eliminadno el prefijo "Bearer "
+ const token = authHeader.split(" ")[1];
+ // Validación de token autorizado proporcionado por el cliente
+ if (token === process.env.DEFAULT_TOKEN)
+ return next();
+ try {
+ // Verifica el token de Firebase usando firebase-admin
+ const decodedToken = await admin.auth().verifyIdToken(token);
+ req.user = decodedToken; // Añade el usuario decodificado a la solicitud
+ next();
+ }
+ catch (error) {
+ console.log("Error verifying Firebase ID token:", error);
+ return res.status(401).json({ message: "Unauthorized", error });
+ }
+}
diff --git a/server/dist/playwright.config.js b/server/dist/playwright.config.js
new file mode 100644
index 00000000..56101ffd
--- /dev/null
+++ b/server/dist/playwright.config.js
@@ -0,0 +1,41 @@
+import { defineConfig, devices } from "@playwright/test";
+import dotenv from "dotenv";
+dotenv.config();
+export default defineConfig({
+ // Look for test files in the "tests" directory, relative to this configuration file.
+ testDir: "dist/tests",
+ // Run all tests in parallel.
+ fullyParallel: true,
+ // Fail the build on CI if you accidentally left test.only in the source code.
+ forbidOnly: !!process.env.CI,
+ // Retry on CI only.
+ retries: process.env.CI ? 2 : 0,
+ // Opt out of parallel tests on CI.
+ workers: process.env.CI ? 1 : undefined,
+ // Reporter to use
+ reporter: "html",
+ use: {
+ // Base URL to use in actions like `await page.goto('/')`.
+ baseURL: "http://127.0.0.1:8080",
+ // Collect trace when retrying the failed test.
+ trace: "on-first-retry",
+ // Add extra HTTP headers
+ extraHTTPHeaders: {
+ Authorization: `Bearer ${process.env.DEFAULT_TOKEN}`,
+ Aplication: "aplication-json",
+ },
+ },
+ // Configure projects for major browsers.
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+ // Run your local dev server before starting the tests.
+ webServer: {
+ command: "npm run start",
+ url: "http://127.0.0.1:8080",
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/server/dist/routes/comments.js b/server/dist/routes/comments.js
new file mode 100644
index 00000000..9edc2bd1
--- /dev/null
+++ b/server/dist/routes/comments.js
@@ -0,0 +1,182 @@
+import { Router } from "express";
+import { modelComment } from "../src/mongodb/models.js";
+import { isValidObjectId, Types } from "mongoose";
+import { submitEmailComment } from "../lib/resend/resend.js";
+import { deleteCommentAndChildren } from "../utilities/delete-logic.js";
+import { handleServerError } from "../utilities/errorHandle.js";
+import { verifyIdToken, log } from "../middelware/token-logs.js";
+const router = Router();
+// <--------------- GET --------------->
+// Obtener todos los comentarios
+router.get("/all", log, verifyIdToken, async (req, res) => {
+ // El id es el id del comentario padre (parent_id)
+ try {
+ // Buscar el comentario padre y obtener sus hijos
+ const comments = await modelComment.find().select("data");
+ res.status(200).json(comments);
+ }
+ catch (error) {
+ res.status(500).json({ error: "Error al obtener los comentarios" });
+ }
+});
+// Obtener los hijos de un comentario
+router.get("/children/:id", log, verifyIdToken, async (req, res) => {
+ // El id es el id del comentario padre (parent_id)
+ const { id } = req.params;
+ try {
+ // Buscar el comentario padre y obtener sus hijos
+ const comments = await modelComment.findById(id).populate("answers");
+ if (!comments)
+ throw new Error("Comments not found");
+ res.status(200).json(comments.answers);
+ }
+ catch (error) {
+ res.status(500).json({ error: "Error al obtener los comentarios" });
+ }
+});
+// Carga de comentarios al entrar en una publicación
+router.get("/:id", log, verifyIdToken, async (req, res) => {
+ const { id } = req.params;
+ try {
+ const parentComments = await modelComment.find({ pattern_id: id });
+ res.status(200).json(parentComments);
+ }
+ catch (error) {
+ res.status(500).json({ error: "Error al obtener los comentarios" });
+ }
+});
+// <--------------- POST --------------->
+// Agregar comentarios
+router.post("/new", log, verifyIdToken, async (req, res) => {
+ const { newCommentData, originUrl } = req.body;
+ try {
+ // Crear un nuevo ObjectId para el nuevo comentario
+ newCommentData._id = new Types.ObjectId();
+ // Crear un nuevo comentario hijo
+ const newComment = new modelComment(newCommentData);
+ newComment.save();
+ const note = {
+ subject: `${newComment.owner.displayName} has sent a comment`,
+ username: newComment.owner.displayName,
+ note: newComment.data,
+ email_user: newComment.owner.email,
+ };
+ // Avisamos al administrador de la web del nuevo comentario
+ await submitEmailComment(note, originUrl);
+ res.status(201).json(newComment);
+ }
+ catch (error) {
+ console.error("Error al añadir el comentario:", error);
+ res.status(500).json({ error: "Error al añadir el comentario" });
+ }
+});
+// Agregar comentarios hijos y hacer referencia al comentario padre
+router.post("/children/:id", log, verifyIdToken, async (req, res) => {
+ const { id } = req.params;
+ const { newCommentData, originUrl } = req.body;
+ try {
+ // Crear un nuevo ObjectId para el nuevo comentario
+ newCommentData._id = new Types.ObjectId();
+ // Crear un nuevo comentario hijo
+ const newComment = new modelComment(newCommentData);
+ newComment.save();
+ // Avisamos al administrador de la web del nuevo comentario
+ const note = {
+ subject: `${newComment.owner.displayName} has sent a comment`,
+ username: newComment.owner.displayName,
+ note: newComment.data,
+ email_user: newComment.owner.email,
+ };
+ await submitEmailComment(note, originUrl);
+ // Agregar el nuevo comentario al array de comentarios del comentario padre
+ const parentComment = await modelComment.findById(id);
+ if (!parentComment) {
+ res.status(404).send("Parent comment not found");
+ throw new Error("Parent comment not found");
+ }
+ parentComment.answers && parentComment.answers.push(newComment._id);
+ await parentComment.save();
+ res.status(201).send(newComment);
+ }
+ catch (error) {
+ console.error("Error adding comment:", error);
+ res.status(500).send("Error adding comment:");
+ }
+});
+// <--------------- PUT --------------->
+// Actualizar likes de comentarios
+router.put("/likes", log, verifyIdToken, async (req, res) => {
+ const { uid_user_firebase, _id, likes, originUrl } = req.body;
+ if (likes === undefined || likes === null || !uid_user_firebase || !_id)
+ return res.status(400).json({ error: "Los campos son requeridos" });
+ try {
+ const comment = await modelComment.findById(_id);
+ if (!comment)
+ return res.status(404).json({ error: "Comentario no encontrado" });
+ if (comment.likedBy && comment.likedBy.includes(uid_user_firebase)) {
+ const index = comment.likedBy.indexOf(uid_user_firebase);
+ comment.likedBy.splice(index, 1);
+ comment.likes -= 1;
+ }
+ else {
+ comment.likedBy && comment.likedBy.push(uid_user_firebase);
+ comment.likes += 1;
+ // Implemetaacion futura añadir correo al administrador por likes
+ // const note: NoteType = {
+ // subject: `${comment.owner.displayName} has received a like`,
+ // username: comment.owner.displayName,
+ // note: comment.data,
+ // email_user: comment.owner.email,
+ // };
+ // console.log("note", note);
+ // Avisamos al administrador de la web del nuevo like
+ // const res = await submitEmailComment(note, originUrl);
+ console.log("res", res);
+ }
+ const updatedComment = await comment.save();
+ res.status(200).json(updatedComment);
+ }
+ catch (error) {
+ handleServerError(res, error);
+ }
+});
+// Editar comentarios
+router.put("/edit/:id", log, verifyIdToken, async (req, res) => {
+ const { id } = req.params;
+ const { textEdit } = req.body;
+ if (!textEdit)
+ return res.status(400).json({ message: "Missing content" });
+ try {
+ const comment = await modelComment.findById(id);
+ if (!comment)
+ return res.status(404).json({ message: "Comment not found" });
+ comment.data = textEdit;
+ await comment.save();
+ res.status(200).json(comment);
+ }
+ catch (error) {
+ handleServerError(res, error);
+ }
+});
+// <--------------- DELETE --------------->
+// Eliminar comentarios
+router.delete("/del/:id", log, verifyIdToken, async (req, res) => {
+ const { id } = req.params;
+ if (!isValidObjectId(id))
+ return res.status(400).json({ message: "Invalid publication ID" });
+ try {
+ const fatherComment = await modelComment.findById(id);
+ if (!fatherComment)
+ return res.status(404).json({ message: "Comment not found" });
+ // Elimina los hijos del comentario
+ if (fatherComment.answers && fatherComment.answers.length > 0)
+ await deleteCommentAndChildren(fatherComment.answers);
+ // Elimina el comentario principal
+ await modelComment.findByIdAndDelete(id);
+ res.status(204).send();
+ }
+ catch (error) {
+ handleServerError(res, error);
+ }
+});
+export { router };
diff --git a/server/dist/routes/firebase.js b/server/dist/routes/firebase.js
new file mode 100644
index 00000000..1a331d27
--- /dev/null
+++ b/server/dist/routes/firebase.js
@@ -0,0 +1,23 @@
+import { Router } from "express";
+import { log, verifyIdToken } from "../middelware/token-logs.js";
+import { getUidByEmail } from "../lib/firebase/firebase-db.js";
+const router = Router();
+router.get("/user/:email", log, verifyIdToken, async (req, res) => {
+ // Obtener el UID de un usuario por su correo electrónico
+ const { email } = req.params;
+ if (!email)
+ return res.status(400).json({ message: "Missing required fields" });
+ getUidByEmail(email)
+ .then((uid) => {
+ // Devolbemos el UID del usuario si existe
+ if (uid)
+ res.status(200).json({ uid });
+ else
+ res.status(404).json({ message: "User not found" });
+ })
+ .catch((error) => {
+ console.error(error);
+ res.status(500).json({ message: "Unexpected error" });
+ });
+});
+export { router };
diff --git a/server/dist/routes/mailchimp.js b/server/dist/routes/mailchimp.js
new file mode 100644
index 00000000..1954cf02
--- /dev/null
+++ b/server/dist/routes/mailchimp.js
@@ -0,0 +1,181 @@
+import { Router } from "express";
+import { listId, mailchimpErrors, mailchimp, addInterestToCategory, deleteInterestCategory, groupId, } from "../lib/mailchimp/mailchimp.js";
+import { log, verifyIdToken } from "../middelware/token-logs.js";
+import crypto from "crypto";
+import { isErrorAxios } from "../utilities/axios-utils.js";
+const router = Router();
+// <--------------- GET --------------->
+// Obtener todos los miembros de una lista
+router.get("/getall/member", log, verifyIdToken, async (_req, res) => {
+ mailchimp.lists
+ .getListMembersInfo(listId)
+ .then((response) => {
+ res.status(200).json(response);
+ })
+ .catch((error) => {
+ res.status(500).json(mailchimpErrors(error));
+ });
+});
+// Obtener un miembro de la lista por email
+router.get("/getone/member/:email", log, verifyIdToken, async (req, res) => {
+ const { email } = req.params;
+ const subscriberHash = crypto.createHash("md5").update(email).digest("hex");
+ mailchimp.lists
+ .getListMember(listId, subscriberHash)
+ .then((response) => {
+ res.status(200).json(response);
+ })
+ .catch((error) => {
+ const parsedError = mailchimpErrors(error);
+ res.status(parsedError.status).json(parsedError);
+ });
+});
+// Obtener todos los grupos de categorias
+router.get("/groupscategory", log, verifyIdToken, async (req, res) => {
+ mailchimp.lists
+ .getListInterestCategories(listId)
+ .then((response) => {
+ res.status(200).json({ response });
+ })
+ .catch((error) => {
+ res.status(500).json(mailchimpErrors(error));
+ });
+});
+// Obtener el grupo de los intereses especificos
+router.get("/get/interests", log, verifyIdToken, async (req, res) => {
+ if (!groupId)
+ return res.status(400).send("groupID is required");
+ mailchimp.lists
+ .listInterestCategoryInterests(listId, groupId)
+ .then((response) => {
+ res.status(200).json(response);
+ })
+ .catch((error) => {
+ res.status(500).json(mailchimpErrors(error));
+ });
+});
+// <--------------- POST --------------->
+// Añadir un miembro a la lista
+router.post("/add/member", log, verifyIdToken, async (req, res) => {
+ const member = req.body;
+ mailchimp.lists
+ .addListMember(listId, member)
+ .then((response) => {
+ res.status(201).json(response);
+ })
+ .catch((error) => {
+ res.status(500).json(mailchimpErrors(error));
+ });
+});
+// Añadir varios miembros a la lista
+router.post("/add/batchcontact", log, verifyIdToken, async (req, res) => {
+ const members = req.body;
+ // Usuario y etiquetas para añadir al usuario
+ mailchimp.lists
+ .batchListMembers(listId, {
+ members,
+ update_existing: true,
+ })
+ .then((response) => {
+ res.status(201).json(response);
+ })
+ .catch((error) => {
+ res.status(500).json(mailchimpErrors(error));
+ });
+});
+// Añadir el nuevo interés
+router.post("/add/interests", log, verifyIdToken, async (req, res) => {
+ const { name } = req.body;
+ if (!groupId || !name)
+ res.status(400).send("Category ID and interest name are required");
+ try {
+ const response = await addInterestToCategory(groupId, name);
+ res.status(201).json(response);
+ }
+ catch (error) {
+ if (isErrorAxios(error)) {
+ res.status(error.status).json(error.message);
+ }
+ else {
+ res.status(500).json(error);
+ }
+ }
+});
+// <--------------- PUT --------------->
+// Actualizar el estado de un miembro de la lista
+router.put("/updatecontact/status/:email", log, verifyIdToken, async (req, res) => {
+ const { email } = req.params;
+ const { status } = req.body;
+ const subscriberHash = crypto.createHash("md5").update(email.toLowerCase()).digest("hex");
+ mailchimp.lists
+ .updateListMember(listId, subscriberHash, {
+ status: status,
+ })
+ .then((response) => res.status(200).json(response))
+ .catch((error) => {
+ const parserError = mailchimpErrors(error);
+ res.status(parserError.status).json(parserError);
+ });
+});
+// Añadir el tag de un miembro de la lista
+router.put("/updatecontact/tag/:email", log, verifyIdToken, async (req, res) => {
+ const { email } = req.params;
+ const { tag } = req.body;
+ const subscriberHash = crypto.createHash("md5").update(email).digest("hex");
+ await mailchimp.lists
+ .updateListMemberTags(listId, subscriberHash, {
+ tags: [{ name: tag, status: "active" }],
+ })
+ .then((response) => res.status(200).json(response))
+ .catch((error) => {
+ const parserError = mailchimpErrors(error);
+ res.status(parserError.status).json(parserError);
+ });
+});
+// <--------------- DEL --------------->
+// Eliminar un miembro de la lista
+router.delete("/del/member/:email", log, verifyIdToken, async (req, res) => {
+ // Obtenemos el email del usuario a eliminar desde la URL
+ const { email } = req.params;
+ const subscriberHash = crypto.createHash("md5").update(email).digest("hex");
+ // Eliminamos al usuario de la lista, si no existe, se envía un error, si existe devuelbe null
+ await mailchimp.lists
+ .deleteListMember(listId, subscriberHash)
+ .then(() => res.status(204).end())
+ .catch((error) => {
+ const parserError = mailchimpErrors(error);
+ res.status(parserError.status).json(parserError);
+ });
+});
+// Eliminar un tag de un miembro de la lista
+router.delete("/del/tag/:email", log, verifyIdToken, async (req, res) => {
+ // Obtenemos el email del usuario a eliminar desde la URL
+ const { email } = req.params;
+ const { tag } = req.body;
+ const subscriberHash = crypto.createHash("md5").update(email.toLowerCase()).digest("hex");
+ // Eliminamos el tag de la lista, si no existe, se envía un error, si existe devuelve null
+ await mailchimp.lists
+ .updateListMemberTags(listId, subscriberHash, {
+ tags: [{ name: tag, status: "inactive" }],
+ })
+ .then(() => res.status(204).end())
+ .catch((error) => {
+ const parserError = mailchimpErrors(error);
+ res.status(parserError.status).json(parserError);
+ });
+});
+// Eliminar un interes de una categoria
+router.delete("/del/interests/:id", log, verifyIdToken, async (req, res) => {
+ const { id } = req.params;
+ try {
+ await deleteInterestCategory(id);
+ res.status(204).end();
+ }
+ catch (error) {
+ if (isErrorAxios(error))
+ res.status(error.status).json(error.message);
+ else
+ res.status(500).json(error);
+ }
+});
+export { router };
diff --git a/server/dist/routes/publications.js b/server/dist/routes/publications.js
new file mode 100644
index 00000000..7398ccf0
--- /dev/null
+++ b/server/dist/routes/publications.js
@@ -0,0 +1,119 @@
+import { Router } from "express";
+import { modelComment, modelPublication } from "../src/mongodb/models.js";
+import { isValidObjectId, Types } from "mongoose";
+import { handleServerError } from "../utilities/errorHandle.js";
+import { log, verifyIdToken } from "../middelware/token-logs.js";
+const router = Router();
+// <--------------- GET --------------->
+// Obtener la última publicación
+router.get("/last", log, verifyIdToken, async (_req, res) => {
+ try {
+ const lastPublication = await modelPublication.findOne().sort({ currentPage: -1 }).select("currentPage").exec();
+ if (!lastPublication)
+ res.status(404).json({ message: "No posts available" });
+ res.status(200).json(lastPublication);
+ }
+ catch (error) {
+ console.error("Error retrieving publication:", error);
+ handleServerError(res, error);
+ }
+});
+// Obtener publicaciones segun la página
+router.get("/page/:page", log, verifyIdToken, async (req, res) => {
+ const page = parseInt(req.params.page, 10);
+ if (isNaN(page))
+ res.status(400).json({ message: "Invalid page number" });
+ try {
+ const publications = await modelPublication.find({ currentPage: page });
+ if (!publications)
+ res.status(404).json({ message: "Publication not found" });
+ res.status(200).json(publications);
+ }
+ catch (error) {
+ console.error("Error retrieving publication:", error);
+ handleServerError(res, error);
+ }
+});
+// Encontrar publicación por id del publication para entrar en la publicación selecionada
+router.get("/:id", log, verifyIdToken, async (req, res) => {
+ const { id } = req.params;
+ try {
+ if (!isValidObjectId(id))
+ res.status(400).json({ message: "Invalid publication ID" });
+ const publication = await modelPublication.findById(id);
+ if (!publication)
+ res.status(404).json({ message: "Publication not found" });
+ res.status(200).json(publication);
+ }
+ catch (error) {
+ console.error("Error retrieving publication:", error);
+ handleServerError(res, error);
+ }
+});
+// <--------------- POST --------------->
+// Añadir nuevas publicaciones
+router.post("/new", log, verifyIdToken, async (req, res) => {
+ const newPublication = req.body;
+ try {
+ // Validaciones
+ if (!newPublication.title || !newPublication.subtitle || !newPublication.content)
+ return res.status(400).json({ message: "Missing required fields" });
+ if (newPublication.base64_img && !/^data:image\/[a-zA-Z]+;base64,/.test(newPublication.base64_img))
+ return res.status(400).json({ message: "Invalid image format" });
+ // Crear una nueva tarjeta de blog
+ const newCardBlog = new modelPublication({
+ _id: new Types.ObjectId(),
+ title: newPublication.title,
+ subtitle: newPublication.subtitle,
+ content: newPublication.content,
+ base64_img: newPublication.base64_img,
+ currentPage: newPublication.currentPage,
+ });
+ // Guardar el nuevo documento en la base de datos
+ await newCardBlog.save();
+ res.status(201).json(newCardBlog);
+ }
+ catch (error) {
+ console.error("Error adding card blog:", error);
+ handleServerError(res, error);
+ }
+});
+// <--------------- PUT --------------->
+// Editar publicaciones
+router.put("/edit/:id", log, verifyIdToken, async (req, res) => {
+ const { id } = req.params;
+ const updatedFields = req.body;
+ try {
+ const publication = await modelPublication.findById(id);
+ if (!publication)
+ return res.status(404).json({ message: "Publication not found" });
+ publication.title = updatedFields.title;
+ publication.subtitle = updatedFields.subtitle;
+ publication.content = updatedFields.content;
+ await publication.save();
+ res.status(200).json(publication);
+ }
+ catch (error) {
+ console.error("Error updating publication:", error);
+ handleServerError(res, error);
+ }
+});
+// <--------------- DEL --------------->
+// Eliminar publicaciones
+router.delete("/del/:id", log, verifyIdToken, async (req, res) => {
+ const { id } = req.params;
+ if (!isValidObjectId(id))
+ return res.status(400).json({ message: "Invalid publication ID" });
+ try {
+ const result = await modelPublication.findByIdAndDelete(id);
+ await modelComment.deleteMany({ pattern_id: id });
+ if (!result)
+ return res.status(404).json({ message: "Publication not found" });
+ res.status(204).send();
+ }
+ catch (error) {
+ console.error("Error deleting publication:", error);
+ handleServerError(res, error);
+ }
+});
+export { router };
diff --git a/server/dist/routes/resend.js b/server/dist/routes/resend.js
new file mode 100644
index 00000000..e47ac25e
--- /dev/null
+++ b/server/dist/routes/resend.js
@@ -0,0 +1,25 @@
+import { Router } from "express";
+import { submitEmailStudent, submitNote } from "../lib/resend/resend.js";
+import { log, verifyIdToken } from "../middelware/token-logs.js";
+const router = Router();
+// Enviamos un email con una nota de contact us
+router.post("/note", log, verifyIdToken, async (req, res) => {
+ const newNote = req.body;
+ if (!newNote.email_user || !newNote.username || !newNote.subject || !newNote.note)
+ return res.status(400).send({ message: "Missing required fields" });
+ submitNote(newNote)
+ .then((response) => res.status(201).json({ message: "Email sent successfully", response }))
+ .catch((error) => res.status(500).send({ message: "Error sending email", error }));
+});
+// Avisamos de que se a suscrito un nuevo estudiante a una clase
+router.post("/newstudent", log, verifyIdToken, async (req, res) => {
+ const newSubcriber = req.body;
+ console.log(newSubcriber);
+ if (!newSubcriber.email || !newSubcriber.name || !newSubcriber.lastname || !newSubcriber.class)
+ return res.status(400).send({ message: "Missing required fields" });
+ console.log("Enviando email");
+ submitEmailStudent(newSubcriber)
+ .then((response) => res.status(201).json({ message: "Email sent successfully", response }))
+ .catch((error) => res.status(500).send({ message: "Error sending email", error }));
+});
+export { router };
diff --git a/server/dist/routes/routes.js b/server/dist/routes/routes.js
new file mode 100644
index 00000000..557d47e8
--- /dev/null
+++ b/server/dist/routes/routes.js
@@ -0,0 +1,33 @@
+import { Router } from "express";
+import { readdirSync } from "fs";
+import path from "path";
+import { fileURLToPath, pathToFileURL } from "url";
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PATH_ROUTER = __dirname;
+const cleanExtension = (file) => file.split(".")[0];
+const router = Router();
+readdirSync(PATH_ROUTER).forEach((file) => {
+ const fileClean = cleanExtension(file);
+ const fileExtension = path.extname(file);
+ if (fileClean !== "routes" &&
+ (fileExtension === ".js" || fileExtension === ".ts") &&
+ !file.endsWith(".js.map") &&
+ !file.endsWith(".ts.map")) {
+ const modulePath = path.join(PATH_ROUTER, `${fileClean}${fileExtension}`);
+ const moduleURL = pathToFileURL(modulePath).href;
+ import(moduleURL)
+ .then((module) => {
+ if (module.router) {
+ console.log(`Route ${fileClean} loaded successfully`);
+ router.use(`/${fileClean}`, module.router);
+ }
+ else {
+ console.error(`Error: No router found in module ${fileClean}`);
+ }
+ })
+ .catch((err) => {
+ console.error(`Error loading route ${fileClean}:`, err);
+ });
+ }
+});
+export { router };
diff --git a/server/dist/src/index.js b/server/dist/src/index.js
new file mode 100644
index 00000000..a3df591c
--- /dev/null
+++ b/server/dist/src/index.js
@@ -0,0 +1,114 @@
+import dotenv from "dotenv";
+dotenv.config();
+///////////////////////////////////////////
+import { connectDB } from "./mongodb/mongodb.js";
+import express from "express";
+import cors from "cors";
+import { router } from "../routes/routes.js";
+import net from "net";
+// Extendemos el límite para que pueda almacenar imagenes en base64
+async function inicializeApp() {
+ const app = express();
+ // Origenes permitidos
+ const allowedOrigins = ["https://thefluentspanishhouse.com", "http://localhost:5173", process.env.URL_WEB_TEST];
+ // Configuración global de CORS
+ app.use(cors({
+ origin: function (origin, callback) {
+ if (!origin || allowedOrigins.indexOf(origin) !== -1) {
+ callback(null, true);
+ }
+ else {
+ callback(new Error("Not allowed by CORS"));
+ }
+ },
+ methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
+ allowedHeaders: ["Content-Type", "Authorization"],
+ credentials: true,
+ }));
+ app.use(express.json({ limit: "10mb" }));
+ app.use(express.urlencoded({ limit: "10mb", extended: true }));
+ app.use(express.json());
+ app.use(express.text());
+ // Conexión a la base de datos
+ try {
+ await connectDB();
+ }
+ catch (error) {
+ throw new Error("Error connecting to the database");
+ }
+ // Enpoint de bienvenida
+ app.get("/", (req, res) => {
+ res.send("Welcome to thefluentespnaishouse server");
+ });
+ // Rutas de la aplicación
+ app.use(router);
+ // Obtener la URL de los preview para hacer testing
+ app.get("/api/test", async (_req, res) => {
+ try {
+ const previewUrl = process.env.VERCEL_URL;
+ if (!previewUrl)
+ throw new Error("VERCEL_URL no está definida");
+ res.send(previewUrl);
+ }
+ catch (error) {
+ res.status(500).json({ error: "Error en el servidor para obtener URL de preview" });
+ }
+ });
+ // Si es development y preview asignamos el puerto disponible a partir de 8080
+ if (process.env.NODE_ENV !== "production") {
+ const PORT_BACKEND = 8080;
+ // Creamos una funcion flecha que devuelbe una promesa
+ const checkPort = (port) => {
+ return new Promise((resolve, reject) => {
+ // Creamos un servidor con net nativo de (NodeJS)
+ const server = net.createServer();
+ // Intentar escuchar en el puerto
+ server.listen(port);
+ // Verificamos si el puerto esta en uso si lo esta devuleve false, si ocurre un error lo rechaza
+ server.once("error", (err) => {
+ if (err.code === "EADDRINUSE") {
+ resolve(false);
+ }
+ else {
+ reject(err);
+ }
+ });
+ // Si el puerto esta libre lo cerramos y resolvemos la promesa
+ server.once("listening", () => {
+ server.close();
+ resolve(true);
+ });
+ });
+ };
+ const startServer = async (port) => {
+ // Encontramos un puerto libre de [8080, 8090]
+ if (port > 8090) {
+ console.log("No ports available");
+ return;
+ }
+ // Encontra un puerto libre y se lo asigna al servidor, si no encuentra uno libre lo asigna al siguiente
+ const isPortFree = await checkPort(port);
+ if (isPortFree) {
+ app.listen(port, () => {
+ console.log(`Server running: http://localhost:${port}`);
+ });
+ }
+ else {
+ console.log(`Port ${port} is in use, please choose another port.`);
+ startServer(port + 1);
+ }
+ };
+ startServer(PORT_BACKEND).catch((error) => {
+ console.error("Error starting the server:", error);
+ });
+ }
+ else {
+ const PORT_BACKEND = 8080;
+ app.listen(PORT_BACKEND, () => {
+ console.log(`Server runing: http://localhost:${PORT_BACKEND}`);
+ });
+ }
+}
+export default inicializeApp().catch((error) => {
+ console.error("Error starting the server:", error);
+});
diff --git a/server/dist/src/mongodb/models.js b/server/dist/src/mongodb/models.js
new file mode 100644
index 00000000..76ec099b
--- /dev/null
+++ b/server/dist/src/mongodb/models.js
@@ -0,0 +1,29 @@
+import { Schema, model } from "mongoose";
+const commentSchema = new Schema({
+ _id: { type: Schema.Types.ObjectId, required: true },
+ pattern_id: { type: String, required: true },
+ owner: {
+ uid: { type: String, required: true },
+ email: { type: String, required: true },
+ displayName: { type: String, required: true },
+ photoURL: { type: String, required: false },
+ },
+ data: { type: String, required: true },
+ likes: { type: Number, required: true },
+ likedBy: { type: [String], required: true, default: [] },
+ answers: {
+ type: [{ type: Schema.Types.ObjectId, ref: "Comment" }],
+ required: true,
+ default: [],
+ },
+});
+export const modelComment = model("Comment", commentSchema, "comments");
+const publicationBlogSchema = new Schema({
+ _id: { type: Schema.Types.ObjectId, required: true },
+ title: { type: String, required: true },
+ subtitle: { type: String, required: true },
+ content: { type: String, required: true },
+ base64_img: { type: String, required: false },
+ currentPage: { type: Number, required: true },
+});
+export const modelPublication = model("PublicationBlog", publicationBlogSchema, "publications");
diff --git a/server/dist/src/mongodb/mongodb.js b/server/dist/src/mongodb/mongodb.js
new file mode 100644
index 00000000..43e6ea6d
--- /dev/null
+++ b/server/dist/src/mongodb/mongodb.js
@@ -0,0 +1,15 @@
+import mongoose from "mongoose";
+// Conexion a la base de datos de MongoDBAtlas
+export const connectDB = async () => {
+ if (mongoose.connection.readyState === 0) {
+ try {
+ const url = process.env.MONGO_DB_FLUENT;
+ if (!url)
+ throw new Error("Connection string not found");
+ await mongoose.connect(url);
+ }
+ catch (error) {
+ console.error("Error al conectarse a la base de datos: ", error);
+ }
+ }
+};
diff --git a/server/dist/tests/comments.spec.js b/server/dist/tests/comments.spec.js
new file mode 100644
index 00000000..3632cef5
--- /dev/null
+++ b/server/dist/tests/comments.spec.js
@@ -0,0 +1,103 @@
+import { test, expect } from "@playwright/test";
+import dotenv from "dotenv";
+import { Types } from "mongoose";
+dotenv.config();
+//####################### GET #######################
+test("/comments/all obtener todos los comentarios", async ({ page }) => {
+ const responseAll = await page.goto("/comments/all");
+ expect(responseAll?.status()).toBe(200);
+ const allComments = await responseAll?.json();
+ expect(allComments).toBeDefined();
+});
+test.describe.serial("Comment tests", () => {
+ let createdCommentId = "";
+ let originUrl = "https://thefluentspanishhouse.com/publication/";
+ const requestBody = {
+ uid_user_firebase: "user_firebase_123",
+ _id: "",
+ likes: 0,
+ originUrl,
+ };
+ //####################### POST #######################
+ test("/comments/new agregar nuevo comentario", async ({ page }) => {
+ const responseLast = await page.goto(`/publications/last`);
+ const response = await responseLast?.json();
+ if (!response) {
+ console.log("No hay publicaciones disponibles saltando test de comentarios");
+ expect(responseLast).not.toBeNull();
+ return;
+ }
+ originUrl += response._id;
+ requestBody.originUrl = originUrl;
+ const newCommentData = {
+ _id: new Types.ObjectId(),
+ pattern_id: "66d4e739b705f46a0a395947",
+ owner: {
+ uid: "user123",
+ displayName: "John Doe",
+ email: "john.doe@example.com",
+ photoURL: "http://example.com/photo.jpg",
+ },
+ data: "Este es un nuevo comentario",
+ likes: 0,
+ likedBy: [],
+ answers: [],
+ };
+ // Cremos un comentario padre
+ const responseNewComment = await page.request.post("/comments/new", {
+ data: { newCommentData, originUrl },
+ });
+ expect(responseNewComment.status()).toBe(201);
+ // Almaecenamos la id del comentario creado
+ const fatherComment = await responseNewComment.json();
+ createdCommentId = fatherComment._id;
+ requestBody._id = fatherComment._id;
+ });
+ //####################### PUT #######################
+ test("/comments/edit/:id editar nuevo comentario", async ({ page }) => {
+ // Cremos un comentario padre
+ const responsePost = await page.request.put(`/comments/edit/${createdCommentId}`, {
+ data: { textEdit: "Este es un comentario editado" },
+ });
+ expect(responsePost.status()).toBe(200);
+ });
+ test("/comments/:id obtener por id", async ({ page }) => {
+ // Si existe algún comentario se obtiene el primer comentario
+ const responseID = await page.goto(`/comments/${createdCommentId}`);
+ expect(responseID?.status()).toBe(200);
+ const data = await responseID?.json();
+ expect(data).toBeDefined();
+ });
+ test("comments/children/:id obtener por id los hijos", async ({ page }) => {
+ // Si existe algun comentario se obtiene el primer comentario
+ const responseChildren = await page.goto(`/comments/children/${createdCommentId}`);
+ expect(responseChildren?.status()).toBe(200);
+ const data = await responseChildren?.json();
+ expect(data).toBeDefined();
+ });
+ test("/likes agregar like", async ({ page }) => {
+ const responseLike = await page.request.put(`/comments/likes`, {
+ data: requestBody,
+ });
+ expect(responseLike.status()).toBe(200);
+ const updatedComment = await responseLike.json();
+ expect(updatedComment.likes).toBe(1);
+ });
+ test("/likes eliminar like", async ({ page }) => {
+ const newRequestBody = { ...requestBody };
+ newRequestBody.likes = 1;
+ const responseDislike = await page.request.put(`/comments/likes`, {
+ data: newRequestBody,
+ });
+ expect(responseDislike.status()).toBe(200);
+ const newUpdatedComment = await responseDislike.json();
+ expect(newUpdatedComment.likes).toBe(0);
+ });
+ //####################### DELETE #######################
+ test("/comments/del/:id eliminar comentario", async ({ page }) => {
+ if (!createdCommentId)
+ throw new Error("No comment ID available to delete");
+ const responseDel = await page.request.delete(`/comments/del/${createdCommentId}`);
+ expect(responseDel.status()).toBe(204);
+ });
+});
diff --git a/server/dist/tests/index.spec.js b/server/dist/tests/index.spec.js
new file mode 100644
index 00000000..7a5cc460
--- /dev/null
+++ b/server/dist/tests/index.spec.js
@@ -0,0 +1,8 @@
+import { test, expect } from "@playwright/test";
+test("La ruta raíz devuelve el mensaje esperado", async ({ page }) => {
+ // Navegar a la página principal
+ await page.goto("/");
+ // Verificar que el contenido de la página sea el esperado
+ const content = await page.textContent("body");
+ expect(content).toBe("Welcome to thefluentespnaishouse server");
+});
diff --git a/server/dist/tests/mailchimp.spec.js b/server/dist/tests/mailchimp.spec.js
new file mode 100644
index 00000000..3def81a9
--- /dev/null
+++ b/server/dist/tests/mailchimp.spec.js
@@ -0,0 +1,141 @@
+import { test, expect } from "@playwright/test";
+//####################### GET #######################
+test("/getall/member obtener todos los miembros de una lista", async ({ page }) => {
+ const responseMembersList = await page.goto(`mailchimp/getall/member`);
+ expect(responseMembersList?.status()).toBe(200);
+ const data = await responseMembersList?.json();
+ expect(data).toBeDefined();
+});
+test("/groupscategory obtener todos los grupos de categorias", async ({ page }) => {
+ const responseMember = await page.goto(`/mailchimp/groupscategory`);
+ expect(responseMember?.status()).toBe(200);
+ const data = await responseMember?.json();
+ expect(data).toBeDefined();
+});
+test.describe.serial("Mailchimp tests", () => {
+ const testEmail = "testing@gmail.com";
+ // Intereses del grupo actual (Pensado para un unico grupo)
+ let group;
+ test("/get/interests obtener los intereses de un grupo de categorias", async ({ page }) => {
+ const responseMember = await page.goto(`/mailchimp/get/interests`);
+ expect(responseMember?.status()).toBe(200);
+ const data = await responseMember?.json();
+ expect(data).toBeDefined();
+ group = data;
+ });
+ //####################### POST #######################
+ const member = {
+ email_address: `${testEmail}`,
+ status: "transactional",
+ email_type: "html",
+ merge_fields: {
+ FNAME: "testing",
+ LNAME: "testinglastname",
+ },
+ interests: {},
+ tags: [],
+ status_if_new: "transactional",
+ };
+ test("/add/member añadir un miembro", async ({ page }) => {
+ let responseNewMember = await page.request.post(`mailchimp/add/member`, {
+ data: member,
+ });
+ const data = await responseNewMember?.json();
+ if (data.title === "Member Exists") {
+ // Si el miembro ya existe, no se puede añadir
+ expect(data.title).toContain(`Member Exists`);
+ }
+ else {
+ expect(responseNewMember?.status()).toBe(201);
+ expect(data).toBeDefined();
+ }
+ });
+ //####################### GET #######################
+ test("/getone/member/:email obtener un miembro", async ({ page }) => {
+ const responseMember = await page.goto(`/mailchimp/getone/member/${testEmail}`);
+ expect(responseMember?.status()).toBe(200);
+ const data = await responseMember?.json();
+ expect(data).toBeDefined();
+ });
+ //####################### POST #######################
+ test("/add/batchcontact añadir varios miembros a la lista (opcional)", async ({ page }) => {
+ const copyMember1 = { ...member };
+ copyMember1.email_address = "testing1@gmail.com";
+ const copyMember2 = { ...member };
+ copyMember2.email_address = "testing2@gmail.com";
+ const members = [copyMember1, copyMember2];
+ const responseNewMembers = await page.request.post(`/mailchimp/add/batchcontact`, {
+ data: members,
+ });
+ expect(responseNewMembers?.status()).toBe(201);
+ const data = await responseNewMembers?.json();
+ expect(data).toBeDefined();
+ });
+ // Id del interes creado en el test
+ let idInterest = "";
+ test("/add/interests añadir intereses", async ({ page }) => {
+ const name = "testing";
+ const responseNewInterests = await page.request.post(`mailchimp/add/interests`, {
+ data: { name },
+ });
+ const data = await responseNewInterests?.json();
+ if (data.detail && data.detail == `Cannot add "${name}" because it already exists on the list.`) {
+ // Si el interés ya existe, no se puede añadir asique lo buscamos
+ const responseAllInterests = await page.request.get(`/mailchimp/get/interests`);
+ const dataInterest = await responseAllInterests?.json();
+ const element = dataInterest.interests.find((element) => element.name == name);
+ if (element)
+ idInterest = element.id;
+ expect(responseAllInterests?.status()).toBe(200);
+ }
+ else {
+ expect(responseNewInterests?.status()).toBe(201);
+ const data = await responseNewInterests?.json();
+ idInterest = data.id;
+ }
+ });
+ //####################### PUT #######################
+ test("/updatecontact/status/:email editar estado de un usuario por email", async ({ page }) => {
+ const status = "transactional";
+ const responseStatus = await page.request.put(`/mailchimp/updatecontact/status/${testEmail}`, {
+ data: { status },
+ });
+ expect(responseStatus?.status()).toBe(200);
+ const data = await responseStatus?.json();
+ expect(data).toBeDefined();
+ });
+ test("/updatecontact/tag/:email editar tag de un usuario por email", async ({ page }) => {
+ const tag = "GROUP_CLASS";
+ const responseTag = await page.request.put(`/mailchimp/updatecontact/tag/${testEmail}`, {
+ data: { tag },
+ });
+ expect(responseTag?.status()).toBe(200);
+ const data = await responseTag?.json();
+ expect(data).toBeDefined();
+ });
+ //####################### DELETE #######################
+ test("/del/interests/:id eliminar intereses de una categoria", async ({ page }) => {
+ // Verificamos si existe el interes de testing y obtenemos su id
+ if (idInterest == "")
+ console.warn("Dont exist the interest testing");
+ else {
+ const responseInterests = await page.request.delete(`/mailchimp/del/interests/${idInterest}`);
+ expect(responseInterests?.status()).toBe(204);
+ }
+ });
+ test("/del/tag/:email eliminar tag de un miembro", async ({ page }) => {
+ const tag = "GROUP_CLASS";
+ const responseDelTag = await page.request.delete(`/mailchimp/del/tag/${testEmail}`, {
+ data: { tag },
+ });
+ expect(responseDelTag?.status()).toBe(204);
+ });
+ test("/del/user/:email eliminar usuarios", async ({ page }) => {
+ const responseUser = await page.request.delete(`/mailchimp/del/member/${testEmail}`);
+ expect(responseUser?.status()).toBe(204);
+ const responseUser1 = await page.request.delete(`/mailchimp/del/member/testing1@gmail.com`);
+ expect(responseUser1?.status()).toBe(204);
+ const responseUser2 = await page.request.delete(`/mailchimp/del/member/testing2@gmail.com`);
+ expect(responseUser2?.status()).toBe(204);
+ });
+});
diff --git a/server/dist/tests/mandril.spec.js b/server/dist/tests/mandril.spec.js
new file mode 100644
index 00000000..84982408
--- /dev/null
+++ b/server/dist/tests/mandril.spec.js
@@ -0,0 +1,35 @@
+import { test, expect } from "@playwright/test";
+//####################### POST #######################
+test("/note enviar email de la nota de contact", async ({ page }) => {
+ let newNote = {
+ email_user: "carlosrvasquezsanchez@gmail.com",
+ username: "Test",
+ subject: "Test",
+ note: "Test",
+ };
+ // Enviamos un email de la nota de contacto a admin
+ const responseNewComment = await page.request.post("/mandrill/note", {
+ data: newNote,
+ });
+ expect(responseNewComment.status()).toBe(201);
+ const data = await responseNewComment.json();
+ expect(data).toBeDefined();
+});
+test("/newstudent enviar email de neuvo estudiante", async ({ page }) => {
+ let newSubcriber = {
+ email: "carlosrvasquezsanchez@gmail.com",
+ name: "Test",
+ lastname: "Test",
+ class: "FREE_CLASS",
+ consentEmails: true,
+ acceptTerms: true,
+ acceptPrivacy: true,
+ };
+ // Enviamos un email de la nota de nuevo estudiante a admin
+ const responseNewComment = await page.request.post("/mandrill/newstudent", {
+ data: newSubcriber,
+ });
+ expect(responseNewComment.status()).toBe(201);
+ const data = await responseNewComment.json();
+ expect(data).toBeDefined();
+});
diff --git a/server/dist/tests/publications.spec.js b/server/dist/tests/publications.spec.js
new file mode 100644
index 00000000..b0e9f4b4
--- /dev/null
+++ b/server/dist/tests/publications.spec.js
@@ -0,0 +1,62 @@
+import { test, expect } from "@playwright/test";
+import { Types } from "mongoose";
+//####################### GET #######################
+test("/last obtener la última publicacion", async ({ page }) => {
+ const responseLast = await page.goto(`/publications/last`);
+ expect(responseLast?.status()).toBe(200);
+ const data = await responseLast?.json();
+ expect(data).toBeDefined();
+});
+test("/page/:page obtener publicaciones por página", async ({ page }) => {
+ const responseLast = await page.goto(`/publications/page/1`);
+ expect(responseLast?.status()).toBe(200);
+ const data = await responseLast?.json();
+ expect(data).toBeDefined();
+});
+test.describe.serial("Comment tests", () => {
+ //####################### POST #######################
+ let id_publication;
+ const newPublication = {
+ _id: new Types.ObjectId(),
+ title: "Test",
+ subtitle: "Test",
+ content: "Test",
+ base64_img: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABjElEQVR42mNkAAYy",
+ currentPage: 1,
+ };
+ test("/new añadir publicaciones", async ({ page }) => {
+ const responseNewPublication = await page.request.post(`/publications/new`, {
+ data: newPublication,
+ });
+ expect(responseNewPublication?.status()).toBe(201);
+ const data = await responseNewPublication?.json();
+ expect(data).toBeDefined();
+ // La .id cambia en el servidor, por lo que se debe cambiar
+ id_publication = data._id;
+ });
+ //####################### GET #######################
+ test("/:id obtener publicaciones por id", async ({ page }) => {
+ const responseID = await page.goto(`/publications/${id_publication}`);
+ expect(responseID?.status()).toBe(200);
+ const data = await responseID?.json();
+ expect(data).toBeDefined();
+ });
+ //####################### PUT #######################
+ test("/edit/:id editar publicaciones por id", async ({ page }) => {
+ const updateFields = {
+ ...newPublication,
+ };
+ updateFields.title = "Title edited";
+ const responseID = await page.request.put(`/publications/edit/${id_publication}`, {
+ data: updateFields,
+ });
+ expect(responseID?.status()).toBe(200);
+ const data = await responseID?.json();
+ expect(data).toBeDefined();
+ });
+ //####################### DELETE #######################
+ test("/del/:id eliminar publicaciones por id", async ({ page }) => {
+ const responseID = await page.request.delete(`/publications/del/${id_publication}`);
+ expect(responseID?.status()).toBe(204);
+ });
+});
diff --git a/server/dist/utilities/axios-utils.js b/server/dist/utilities/axios-utils.js
new file mode 100644
index 00000000..3ae5055b
--- /dev/null
+++ b/server/dist/utilities/axios-utils.js
@@ -0,0 +1,3 @@
+export function isErrorAxios(error) {
+ return error.status !== undefined && error.message !== undefined;
+}
diff --git a/server/dist/utilities/delete-logic.js b/server/dist/utilities/delete-logic.js
new file mode 100644
index 00000000..a7a48eca
--- /dev/null
+++ b/server/dist/utilities/delete-logic.js
@@ -0,0 +1,13 @@
+import { modelComment } from "../src/mongodb/models.js";
+export const deleteCommentAndChildren = async (commentId) => {
+ // Obtener el comentario con sus hijos
+ const comment = await modelComment.findById(commentId).populate("answers");
+ if (!comment)
+ throw new Error("Comment not found");
+ // Eliminar recursivamente todos los hijos
+ if (comment.answers)
+ for (const childCommentId of comment.answers)
+ await deleteCommentAndChildren(childCommentId);
+ // Eliminar el comentario padre
+ await modelComment.findByIdAndDelete(commentId);
+};
diff --git a/server/dist/utilities/errorHandle.js b/server/dist/utilities/errorHandle.js
new file mode 100644
index 00000000..0cdd732f
--- /dev/null
+++ b/server/dist/utilities/errorHandle.js
@@ -0,0 +1,10 @@
+const handleHTTPErrorLog = (res, error) => {
+ res.status(500).send({ error: error.message });
+};
+export function handleServerError(res, error) {
+ res.status(500).json({
+ message: "Server error",
+ error: error instanceof Error ? error.message : String(error),
+ });
+}
+export { handleHTTPErrorLog };
diff --git a/server/lib/resend/resend.ts b/server/lib/resend/resend.ts
index 4074eba3..f95f1537 100644
--- a/server/lib/resend/resend.ts
+++ b/server/lib/resend/resend.ts
@@ -1,7 +1,7 @@
import dotenv from "dotenv";
dotenv.config();
////////////////////////////////////////////////////////////////
-import { Message, NoteType, SubscriberType } from "types/types";
+import { NoteType, SubscriberType } from "types/types";
import { Resend } from "resend";
// Configurar el cliente de Mandrill
diff --git a/server/package-lock.json b/server/package-lock.json
index a9ece140..d7202adc 100644
--- a/server/package-lock.json
+++ b/server/package-lock.json
@@ -28,7 +28,6 @@
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"@types/mailchimp__mailchimp_marketing": "^3.0.20",
- "@types/mailchimp__mailchimp_transactional": "^1.0.10",
"dotenv": "^16.4.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.4"
@@ -1220,16 +1219,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/mailchimp__mailchimp_transactional": {
- "version": "1.0.10",
- "resolved": "https://registry.npmjs.org/@types/mailchimp__mailchimp_transactional/-/mailchimp__mailchimp_transactional-1.0.10.tgz",
- "integrity": "sha512-W8EcDqZspM4Mb6shET603kce4C11U29C3s0o8ZchWWcMyaz4MPa2nTyAVUSwnY0atuPii6Jpg3Gs6MuwupT3xw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "axios": "^1.6.7"
- }
- },
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -2848,20 +2837,6 @@
"node": ">= 0.8"
}
},
- "node_modules/fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
diff --git a/server/package.json b/server/package.json
index 8df370d0..a1339764 100644
--- a/server/package.json
+++ b/server/package.json
@@ -4,7 +4,7 @@
"type": "module",
"main": "index.ts",
"scripts": {
- "start": "npm run build && node dist/index.js",
+ "start": "node dist/index.js",
"dev": "node --loader ts-node/esm src/index.js",
"test": "npm run build && playwright test",
"build": "tsc"
diff --git a/server/routes/routes.ts b/server/routes/routes.ts
index 428d2921..cdca8644 100644
--- a/server/routes/routes.ts
+++ b/server/routes/routes.ts
@@ -12,7 +12,12 @@ readdirSync(PATH_ROUTER).forEach((file) => {
const fileClean = cleanExtension(file);
const fileExtension = path.extname(file);
- if (fileClean !== "routes" && (fileExtension === ".js" || fileExtension === ".ts") && !file.endsWith(".map")) {
+ if (
+ fileClean !== "routes" &&
+ (fileExtension === ".js" || fileExtension === ".ts") &&
+ !file.endsWith(".js.map") &&
+ !file.endsWith(".ts.map")
+ ) {
const modulePath = path.join(PATH_ROUTER, `${fileClean}${fileExtension}`);
const moduleURL = pathToFileURL(modulePath).href;
diff --git a/server/index.ts b/server/src/index.ts
similarity index 94%
rename from server/index.ts
rename to server/src/index.ts
index 0bce527a..c4041cf2 100644
--- a/server/index.ts
+++ b/server/src/index.ts
@@ -1,12 +1,12 @@
import dotenv from "dotenv";
dotenv.config();
///////////////////////////////////////////
-import { connectDB } from "./src/mongodb/mongodb.js";
+import { connectDB } from "./mongodb/mongodb.js";
import express from "express";
import cors from "cors";
-import { router } from "./routes/routes.js";
+import { router } from "../routes/routes.js";
import net from "net";
-import admin from "./lib/firebase/firebase-config.js";
+import admin from "../lib/firebase/firebase-config.js";
// Extendemos el límite para que pueda almacenar imagenes en base64
async function inicializeApp() {
@@ -116,6 +116,6 @@ async function inicializeApp() {
}
}
-inicializeApp().catch((error) => {
+export default inicializeApp().catch((error) => {
console.error("Error starting the server:", error);
});
diff --git a/server/vercel.json b/server/vercel.json
deleted file mode 100644
index 957d5ead..00000000
--- a/server/vercel.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "version": 2,
- "builds": [
- {
- "src": "package.json",
- "use": "@vercel/node",
- "config": { "distDir": "dist" }
- }
- ],
- "routes": [
- {
- "src": "/(.*)",
- "dest": "/dist/index.js"
- }
- ]
-}
diff --git a/vercel.json b/vercel.json
new file mode 100644
index 00000000..6209defe
--- /dev/null
+++ b/vercel.json
@@ -0,0 +1,38 @@
+{
+ "version": 2,
+ "projects": [
+ {
+ "name": "thefluenspanishh-client",
+ "root": "client",
+ "builds": [
+ {
+ "src": "client/package.json",
+ "use": "@vercel/static-build",
+ "config": {
+ "distDir": "client/dist"
+ }
+ }
+ ],
+ "routes": [{ "src": "/(.*)", "dest": "client/dist/$1" }],
+ "rewrites": [
+ { "source": "/robots.txt", "destination": "/public/robots.txt" },
+ { "source": "/sitemap.xml", "destination": "/public/sitemap.xml" },
+ { "source": "/(.*)", "destination": "/client/$1" }
+ ]
+ },
+ {
+ "name": "thefluenspanishhouse-server",
+ "root": "server",
+ "builds": [
+ {
+ "src": "server/dist/index.js",
+ "use": "@vercel/node",
+ "config": {
+ "outputDirectory": "server/dist"
+ }
+ }
+ ],
+ "routes": [{ "src": "/server/src/(.*)", "dest": "server/dist/index.js" }]
+ }
+ ]
+}