diff --git a/.gitignore b/.gitignore index 30411db2..6fe3968b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ package-lock.json .vscode .personal /storage/img/* -!/storage/img/user.webp \ No newline at end of file +!/storage/img/user.webp +.env diff --git a/api/v1/ia.js b/api/v1/ia.js index 2af39a22..9435521d 100644 --- a/api/v1/ia.js +++ b/api/v1/ia.js @@ -1,7 +1,9 @@ +// TODO Refactor: Ver si se puede evitar axios. Si es estrictamente necesario, escribirlo en un comentario. import axios from 'axios' import event from 'events' event.EventEmitter.defaultMaxListeners=50; +// TODO Security: Usar .env const apiKey = 'B0ayZrLmS19aqobZA2wsYToeSqDz9cTm'; const base_url = 'https://api.deepinfra.com/v1/openai'; diff --git a/api/v1/model.js b/api/v1/model.js index 0ee17598..1635490a 100644 --- a/api/v1/model.js +++ b/api/v1/model.js @@ -586,8 +586,10 @@ Respuesta.pagina = ({ pagina = 0, DNI } = {}) => { where: { "$respuestas.post.duenio.DNI$": DNI, }, + subQuery: false, order: [[Post, "fecha", "DESC"]], - // ,raw:true,nest:true + limit: PAGINACION.resultadosPorPagina, + offset: +pagina * PAGINACION.resultadosPorPagina }); }; @@ -789,6 +791,7 @@ Pregunta.pagina=({pagina=0,duenioID,filtrar,formatoCorto}={})=>{ if (filtrar) { if (filtrar.texto) { + // TODO Security: cadena literal en consulta opciones.where = Sequelize.or( Sequelize.literal( 'match(post.cuerpo) against ("' + diff --git a/api/v1/router.js b/api/v1/router.js index 4fc4e566..fc459cf5 100644 --- a/api/v1/router.js +++ b/api/v1/router.js @@ -2,16 +2,18 @@ import * as express from "express"; import * as bcrypt from "bcrypt"; import multer from "multer"; import path from "path" -const storage = multer.diskStorage({ - destination: function (req, file, cb) { - cb(null, './storage/img') - }, - filename: function (req, file, cb) { - cb(null, "imagenPerfil-" + req.session.usuario.DNI+".jpg") - }, - -}) -var upload = multer({storage:storage, +import nodemailer from "nodemailer"; + +const upload = multer({ + storage:multer.diskStorage({ + destination: function (req, file, cb) { + cb(null, './storage/img') + }, + filename: function (req, file, cb) { + cb(null, "imagenPerfil-" + req.session.usuario.DNI+".jpg") + }, + + }), fileFilter: function(req, file, cb){ const allowedExtensions = ['.jpg', '.png']; // Add more extensions as needed const fileExtension = path.extname(file.originalname).toLowerCase(); @@ -20,7 +22,8 @@ var upload = multer({storage:storage, } else { cb(null, false); // Reject the file } - }}) + } +}) const router = express.Router(); import { Usuario, @@ -64,6 +67,8 @@ Parametro.findAll().then((ps) => { }); }); +// TODO Refactor: Separar este archivo de más de 2k líneas de código en diferentes archivos segpun la entidad accedida. Ejemplo: https://github.com/c3r38r170/tp-fullstack/blob/master/backend/rutas/todas.js + // sesiones router.post("/sesion", function (req, res) { @@ -327,11 +332,31 @@ router.post("/usuario/:DNI/contrasenia", function (req, res) { res.status(404).send("DNI inexistente"); return; } + let contraseniaNueva = generarContrasenia(); usu.contrasenia = contraseniaNueva; - //TODO Feature: mandar mail - usu.save().then(res.status(200).send("DNI encontrado, correo enviado")); + Promise.all([ + nodemailer + .createTransport({ + host: process.env.CORREO_HOST, + port: process.env.CORREO_PORT, + secure: true, // Use `true` for port 465, `false` for all other ports + auth: { + user: process.env.CORREO_USER, + pass: process.env.CORREO_PASS, + }, + }) + .sendMail({ + from: '"UTN FAQ - Recuperación de contraseña" ', // sender address + to: usu.correo, // list of receivers + subject: "UTN FAQ - Recuperación de contraseña", // Subject line + text: `¡Saludos, ${usu.nombre}! Tu contraseña temporal es "${contraseniaNueva}" (sin comillas).`, // plain text body + html: `¡Saludos, ${usu.nombre}!

Se ha reestablecido tu contraseña, tu nueva contraseña temporal es:

${contraseniaNueva}}

No olvides cambiarla por otra / personalizarla cuando entres.

¡Te esperamos!

`, // html body + }) + ,usu.save() + ]) + .then(res.status(200).send("Se ha reestablecido la contraseña. Revise su correo electrónico para poder acceder.")); }); }); @@ -474,13 +499,14 @@ router.patch("/usuario", upload.single("image"), function (req, res) { } Usuario.findByPk(req.session.usuario.DNI) .then((usuario) => { - if(bcrypt.compare(req.body.contraseniaAnterior, usuario.contrasenia)){ - usuario.contrasenia = req.body.contraseniaNueva; - usuario.save(); - res.status(200).send("Datos actualizados exitosamente"); + if(!bcrypt.compare(req.body.contraseniaAnterior, usuario.contrasenia)){ + res.status(401).send("Contraseña anterior no válida") return; } - res.status(402).send("Contraseña anterior no válida") + + usuario.contrasenia = req.body.contraseniaNueva; + usuario.save(); + res.status(200).send("Datos actualizados exitosamente"); }) .catch((err) => { res.status(500).send(err); @@ -1393,9 +1419,8 @@ router.get("/categorias", async (req, res) => { try { let categorias; // TODO Refactor: raw? nest? - console.log(req.query); if(!!+req.query.etiquetas){ - categorias=await Categoria.findAll({include:/* Etiqueta */{model:Etiqueta, as:'etiquetas'}}) + categorias=await Categoria.findAll({include:{model:Etiqueta, as:'etiquetas'}}) }else{ categorias = await Categoria.findAll(); } @@ -1781,7 +1806,7 @@ router.get('/notificacion', function(req,res){ router.patch("/notificacion", function (req, res) { if (!req.session.usuario) { - res.status(402).send(); + res.status(401).send(); return; } diff --git a/frontend/router.js b/frontend/router.js index 37a88231..9c6d0d1b 100644 --- a/frontend/router.js +++ b/frontend/router.js @@ -211,16 +211,20 @@ router.get("/pregunta/:id?", async (req, res) => { pagina.globales.preguntaID = preguntaID; res.send(pagina.render()); - } else { - let usu = req.session; - if (!usu.usuario) { - let pagina = SinPermisos(usu, "No está logueado"); + } else { + let sesion=req.session; + if (!sesion.usuario) { + let pagina = SinPermisos(sesion, "No está logueado"); res.send(pagina.render()); return; } + // * Nueva pregunta. - let pagina = PantallaNuevaPregunta(req.path, req.session); - res.send(pagina.render()); + Categoria.findAll({include:{model:EtiquetaDAO, as:'etiquetas'}}) + .then(categorias=>{ + let pagina = PantallaNuevaPregunta(req.path, sesion,categorias); + res.send(pagina.render()); + }) } } catch (error) { console.error(error); diff --git a/frontend/static/componentes/formulario.js b/frontend/static/componentes/formulario.js index 7d3b076b..9e3f0e32 100644 --- a/frontend/static/componentes/formulario.js +++ b/frontend/static/componentes/formulario.js @@ -128,7 +128,7 @@ class Campo{ #extra = null; #placeholder=''; - constructor({name,textoEtiqueta,type,required=true,value='',extra,placeholder, clasesInput}){ + constructor({name,textoEtiqueta,type,required=true,value=''/* TODO Refactor: null? */,extra,placeholder, clasesInput}){ // TODO Feature: Tirar error si no estan los necesarios. this.#name=name; this.#textoEtiqueta=textoEtiqueta; @@ -154,6 +154,7 @@ class Campo{ break; case 'lista-etiquetas': html+=' data-type = "tags" data-placeholder="Etiquetas" data-selectable="false" multiple ' + // ! no break; case 'select': html=html.replace('input','select'); endTag=`>${this.#extra}`; @@ -175,11 +176,11 @@ class Campo{ } if(this.#required) html+=` required`; - if(this.#value) + if(this.#value) // * Este no aplica en caso de lista-etiqueta o select. html+=` value="${this.#value}"`; - if(this.#placeholder) + if(this.#placeholder){ html+=` placeholder="${this.#placeholder}"`; - + } return html+endTag+''; diff --git a/frontend/static/componentes/notificacion.js b/frontend/static/componentes/notificacion.js index babc4574..eb8add96 100644 --- a/frontend/static/componentes/notificacion.js +++ b/frontend/static/componentes/notificacion.js @@ -8,18 +8,17 @@ class Notificacion{ #fecha; #idPregunta; #ID; - //ppregunta ajena es notificacion por etiqueta suscripta + //pregunta ajena es notificacion por etiqueta suscripta //respuesta ajena es notificacion por respuesta a pregunta propia o suscripta //respuesta o pregunta propia es notificación por valoración - /* + /* TODO Docs: Actualizar estos comentarios. notificacion post usuario respuesta pregunta pregunta - */ constructor(id,{ @@ -50,27 +49,6 @@ class Notificacion{ this.#texto = 'Nuevas respuestas en la pregunta' } } - - - /* - // TODO Refactor: Preguntar una sola vez. - - if(post.pregunta.ID){ // * Es una notificación de una pregunta - this.#tituloPregunta=post.pregunta.titulo; - }else{ // * Es una notificación de una respuesta - // TODO UX: Considerar mostrar algo de texto de la respuesta o no. - this.#tituloPregunta=post.respuesta.pregunta.titulo; - } - - if(post.duenio.DNI==usuarioActualDNI){ - this.#texto='Recibiste un nuevo voto positivo: '; - }else{ - if(post.pregunta.ID){ // * Es una notificación de una pregunta - this.#texto='Nueva pregunta que te puede interesar: '; - }else{ // * Es una notificación de una respuesta - this.#texto='Nuevas respuestas en la pregunta '; - } - }*/ } static verNotificacion(e){ @@ -82,6 +60,7 @@ class Notificacion{ const url= `http://localhost:8080/api/notificacion`; + // TODO Refactor: Ver si esto choca o es equivalente al hecho de que las notificaciones se ven al entrar en la página. fetch(url, { method: 'PATCH', headers: { @@ -97,9 +76,7 @@ class Notificacion{ } render(){ - // TODO UX: Estilos, visto no visto, al enlace, etc. (.notificacion) - // TODO Feature: Implementar registro de visto. onclick - // TODO Feature: Marcar como visto. onclick="Notificacion.verNotificacion()" + // TODO Feature: Matar el user.webp viejo, meter acá el endpoint de imagen de usuario. return`
diff --git a/frontend/static/componentes/pagina.js b/frontend/static/componentes/pagina.js index 914bf8b9..f4bed41f 100644 --- a/frontend/static/componentes/pagina.js +++ b/frontend/static/componentes/pagina.js @@ -75,7 +75,6 @@ class Pagina { - diff --git a/frontend/static/pantallas/editar-pregunta.js b/frontend/static/pantallas/editar-pregunta.js index 75211493..88a28b2b 100644 --- a/frontend/static/pantallas/editar-pregunta.js +++ b/frontend/static/pantallas/editar-pregunta.js @@ -26,9 +26,7 @@ function crearPagina(ruta,sesion, pregunta, categorias){ ,{ textoEnviar:'Editar Pregunta', verbo: 'PATCH', clasesBoton:'is-link is-rounded mt-3' } - ), - // new ListaEtiquetas('editando-pregunta') - + ) ] }); return pagina; diff --git a/frontend/static/pantallas/nueva-pregunta.js b/frontend/static/pantallas/nueva-pregunta.js index b54c6f3b..7cabe6bf 100644 --- a/frontend/static/pantallas/nueva-pregunta.js +++ b/frontend/static/pantallas/nueva-pregunta.js @@ -1,8 +1,7 @@ -import { Pagina, Formulario } from "../componentes/todos.js"; - +import { Pagina, Formulario} from "../componentes/todos.js"; // TODO refactor: Usar campo de Lista -function crearPagina(ruta,sesion){ +function crearPagina(ruta,sesion,categorias){ let pagina=new Pagina({ ruta:ruta ,titulo:'Nueva Pregunta' @@ -12,10 +11,11 @@ function crearPagina(ruta,sesion){ 'nueva-pregunta' ,'/api/pregunta' ,[ - /* {name,textoEtiqueta,type,required=true,value,extra,clasesBoton} */ {name:'titulo',textoEtiqueta:'Título'} // TODO UX: Detalles? ¿O Cuerpo? ¿O algo...? Ver algún ejemplo. Also, mostrar más grande (rows) y limitar texto (max?) ,{name:'cuerpo',textoEtiqueta:'Detalles',type:'textarea'} + // TODO Refactor: DRY en el extra de options? encontrar la forma de no repetir ese map largo... ¿quizá hacer Categorias.render? Considerar todas las funciones, hay 2 sin valor predeterminado (nueva pregunta y busqueda sin hacer) y 2 con (busqueda hecha y editar pregunta) + ,{name:'etiquetasIDs',textoEtiqueta:'Etiquetas',type:'lista-etiquetas', extra:categorias.map(cat => cat.etiquetas.map(eti => ``)).flat().join('')} ] ,(respuesta,info)=>{ if(info.ok){ @@ -29,10 +29,10 @@ function crearPagina(ruta,sesion){ } ) // TODO Feature: Formulario de creación de preguntas - // Campo de Título. Tiene que sugerir preguntar relacionadas. + // ✅ Campo de Título. Tiene que sugerir preguntar relacionadas. // ✅ Campo de etiquetas. Se deben obtener las etiquetas, mostrarlas, permitir elegirlas. // ✅ Campo de cuerpo. Texto largo con un máximo y ya. - // Las sugerencias pueden ser un panel abajo, o abajo del título... que se vaya actualizando a medida que se escribe el cuerpo. + // ✅ Las sugerencias pueden ser un panel abajo, o abajo del título... que se vaya actualizando a medida que se escribe el cuerpo. // Botón de crear pregunta. Se bloquea, si hay un error salta cartel (como por moderación), si no lleva a la página de la pregunta. Reemplaza, así volver para atrás va al inicio y no a la creación de preguntas. ] }); diff --git a/frontend/static/scripts/pregunta.js b/frontend/static/scripts/pregunta.js index 38398228..c60567d6 100644 --- a/frontend/static/scripts/pregunta.js +++ b/frontend/static/scripts/pregunta.js @@ -1,64 +1,37 @@ +import { SqS, addElement, createElement, gEt } from '../libs/c3tools.js'; import { PantallaNuevaPregunta} from '../pantallas/nueva-pregunta.js'; -import {SqS,gEt,createElement,addElement} from '../libs/c3tools.js'; -// TODO Refactor: RIP Desplegable? -import { Desplegable } from '../componentes/desplegable.js'; -import BulmaTagsInput from 'https://cdn.jsdelivr.net/npm/@creativebulma/bulma-tagsinput@1.0.3/+esm'; -let pagina=PantallaNuevaPregunta(location.pathname,{usuario:window.usuarioActual}); -// TODO refactor: Usar directamente campo en formulario? -fetch('/api/categorias?etiquetas=1') - .then(res=>res.json()) - .then(categorias=>{ - let optionsEtiquetas=[]; - let htmlStyle=''; +let pagina=PantallaNuevaPregunta(location.pathname,{usuario:window.usuarioActual},[]); +import inicializarListas from './inicializar-listas.js'; - for(let cat of categorias){ - htmlStyle+=`[data-text^="${cat.descripcion}"]`; +inicializarListas(); - for(let eti of cat.etiquetas){ - optionsEtiquetas.push(['OPTION',{ - value:eti.ID - ,dataset:{ - categoriaID:cat.ID - } - ,innerText:`${cat.descripcion} - ${eti.descripcion}` - }]); +let espacioSugerencias=createElement('DIV',{ id:'nueva-pregunta-sugerencias'}); +let dF=new DocumentFragment(); +// TODO Refactor: Sacar este estilo en línea. +addElement(dF,['LABEL',{innerText:'Sugerencias basadas en lo escrito hasta el momento:',class:'label',style:{fontSize:'smaller'}}],espacioSugerencias); +gEt('nueva-pregunta').firstElementChild/* Campo de título */.after(dF) - htmlStyle+=`, .tag.is-rounded[data-value="${eti.ID}"]`; - } - - htmlStyle+=`{background-color:${cat.color}}`; +let peticionID=0; +function buscarSugerencias(valor){ + let estaPeticionID=++peticionID; + setTimeout(()=>{ + if(peticionID==estaPeticionID){ + fetch('/api/pregunta?formatoCorto&searchInput='+valor) + .then(r=>r.json()) + .then(sug=>{ + if(peticionID==estaPeticionID){ + espacioSugerencias.innerHTML=sug.reduce((acc,pre)=>acc+new Pregunta(pre).render(),''); + } + }) } - - let botonCrear=SqS('[type="submit"]',{from:gEt('nueva-pregunta')}); - botonCrear.before(createElement( - [ - 'LABEL',{ - class:'label', - children: [ - ['SPAN',{innerText:'Etiquetas'}], - ['SELECT',{ - dataset:{ - type:'tags' - ,placeholder:'Etiquetas' - ,selectable:"false" - } - ,name:'etiquetasIDs' - ,multiple:true - ,required:true - ,children:optionsEtiquetas - }] - ] - } - ] - )); - - addElement(SqS('head'),['STYLE',{innerHTML:htmlStyle}]); - - // TODO UX: Conciliar los estilos de las etiquetas con los que se definieron. - BulmaTagsInput.attach(); - let filtroDeEtiquetas=SqS('.tags-input.is-filter > input'); - filtroDeEtiquetas.required=false; - - SqS('[name="etiquetasIDs"]').style.display='block'; - }) \ No newline at end of file + },400/* TODO Refactor: DRY? (scripts/moderacion-preguntas-y-respuestas.js) ¿parametrizar?*/) +} +let campoTitulo=SqS('[name="titulo"]') +campoTitulo.oninput=function(){ + buscarSugerencias(this.value+' '+campoCuerpo.value) +}; +let campoCuerpo=SqS('[name="cuerpo"]'); +campoCuerpo.oninput=function(){ + buscarSugerencias(campoTitulo.value+' '+this.value); +} \ No newline at end of file diff --git a/frontend/static/styles/pregunta.css b/frontend/static/styles/pregunta.css index 41e418ab..f80a09e5 100644 --- a/frontend/static/styles/pregunta.css +++ b/frontend/static/styles/pregunta.css @@ -1,9 +1,20 @@ #nueva-pregunta { padding: 0 3rem; + /* &~*{ + margin-left: 3rem; + } */ } -#nueva-pregunta-etiquetas { +#nueva-pregunta-sugerencias { display: flex; - justify-content: start; - gap: 2rem; + overflow: auto; +} +#nueva-pregunta-sugerencias .pregunta { + min-width: -moz-max-content; + min-width: max-content; + padding: 0.5rem 1rem; + margin: 0; +} +#nueva-pregunta-sugerencias .pregunta .titulo { + font-size: smaller; }/*# sourceMappingURL=pregunta.css.map */ \ No newline at end of file diff --git a/frontend/static/styles/pregunta.css.map b/frontend/static/styles/pregunta.css.map index dcfaed2c..d7ad0509 100644 --- a/frontend/static/styles/pregunta.css.map +++ b/frontend/static/styles/pregunta.css.map @@ -1 +1 @@ -{"version":3,"sources":["pregunta.scss","pregunta.css"],"names":[],"mappings":"AAAA;EACC,eAAA;ACCD;;ADEA;EACC,aAAA;EACA,sBAAA;EACA,SAAA;ACCD","file":"pregunta.css"} \ No newline at end of file +{"version":3,"sources":["pregunta.scss","pregunta.css"],"names":[],"mappings":"AAAA;EACC,eAAA;EAEA;;MAAA;ACED;;ADGA;EACC,aAAA;EACC,cAAA;ACAF;ADEC;EACC,2BAAA;EAAA,sBAAA;EACA,oBAAA;EACA,SAAA;ACAF;ADEE;EACC,kBAAA;ACAH","file":"pregunta.css"} \ No newline at end of file diff --git a/frontend/static/styles/pregunta.scss b/frontend/static/styles/pregunta.scss index 26b20638..9522669f 100644 --- a/frontend/static/styles/pregunta.scss +++ b/frontend/static/styles/pregunta.scss @@ -1,10 +1,22 @@ #nueva-pregunta{ padding: 0 3rem; + + /* &~*{ + margin-left: 3rem; + } */ } -#nueva-pregunta-etiquetas{ +#nueva-pregunta-sugerencias{ display: flex; - justify-content: start; - gap: 2rem; -} + overflow: auto; + + .pregunta{ + min-width: max-content; + padding: .5rem 1rem; + margin: 0; + .titulo{ + font-size: smaller; + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index ea8a7b16..bfe56c83 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,10 @@ "dotenv": "16.3.1", "express": "4.18.2", "express-session": "1.17.3", - "mocha": "^10.2.0", - "multer": "^1.4.5-lts.1", + "mocha": "10.2.0", + "multer": "1.4.5-lts.1", "mysql2": "3.6.5", + "nodemailer": "6.9.11", "openai": "4.0.0", "request": "2.88.2", "sequelize": "6.35.2", diff --git a/server.js b/server.js index 7f90b307..ef4b3302 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,7 @@ import express from 'express'; import cors from 'cors'; import session from 'express-session'; - +import 'dotenv/config'; var app = express();