From c86760b5af0970fb2e268ee6dd80084634d02d5f 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 | 82 +++++++++ .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, 1500 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..37a64b12 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,82 @@ +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=preview --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 --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=preview --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: List files in client directory + run: ls -la ./server + + - name: Deploy backend to Vercel + run: vercel --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..032a60d8 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/src/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..1ce80e7b --- /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/src/index.js", + "use": "@vercel/node", + "config": { + "outputDirectory": "server/dist" + } + } + ], + "routes": [{ "src": "/(.*)", "dest": "server/dist/src/index.js" }] + } + ] +}