diff --git a/backend/internal/events/dto/requests.go b/backend/internal/events/dto/requests.go index 155be185..9b15457a 100755 --- a/backend/internal/events/dto/requests.go +++ b/backend/internal/events/dto/requests.go @@ -1,6 +1,19 @@ +/* +File: requests.go + +Contains: +Request DTO definitions for the Evento module. +It centralizes HTTP payload structures used to create and +update events. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package dto -// CreateEventoRequest represents the payload to create a new event. +// CreateEventoRequest represents the payload used to create a new event. type CreateEventoRequest struct { Nombre string `json:"nombre"` FechaInicio string `json:"fecha_inicio"` diff --git a/backend/internal/events/dto/responses.go b/backend/internal/events/dto/responses.go index 48065207..6a388e79 100755 --- a/backend/internal/events/dto/responses.go +++ b/backend/internal/events/dto/responses.go @@ -1,3 +1,16 @@ +/* +File: responses.go + +Contains: +Response DTO definitions for the Evento module. +It centralizes API payload structures returned for events, +sessions, speakers, and date ranges. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package dto // EventoResponse represents the response payload for an event. @@ -12,6 +25,7 @@ type EventoResponse struct { Sesiones []SesionResponse `json:"sesiones"` } +// SesionResponse represents the response payload for a session. type SesionResponse struct { IDSesion int `json:"id_sesion"` Titulo string `json:"titulo"` @@ -22,6 +36,7 @@ type SesionResponse struct { Ponentes []PonenteResponse `json:"ponentes"` } +// PonenteResponse represents the response payload for a session speaker. type PonenteResponse struct { IDUsuario int `json:"id_usuario"` Nombre string `json:"nombre"` diff --git a/backend/internal/events/handler/handler.go b/backend/internal/events/handler/handler.go index cde1904b..fb28af92 100755 --- a/backend/internal/events/handler/handler.go +++ b/backend/internal/events/handler/handler.go @@ -1,3 +1,16 @@ +/* +File: handler.go + +Contains: +HTTP handler implementation for the Evento module. +It centralizes request routing, input validation, service calls, +response shaping, and error mapping for event endpoints. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package handler import ( @@ -15,6 +28,7 @@ import ( "project/backend/prisma/db" ) +// Internal constants used by this HTTP handler. const ( dateLayout = "02/01/2006" contentTypeKey = "Content-Type" @@ -22,20 +36,25 @@ const ( dbErrorMessage = "db error" ) +// venezuelaLocation defines the fixed UTC-4 timezone used for event date display. var venezuelaLocation = time.FixedZone("VET", -4*60*60) +// formatDateVE formats a time value as dd/mm/yyyy in Venezuela local time. func formatDateVE(t time.Time) string { return t.In(venezuelaLocation).Format(dateLayout) } +// formatDateTimeVE formats a time value as dd/mm/yyyy HH:MM in Venezuela local time. func formatDateTimeVE(t time.Time) string { return t.In(venezuelaLocation).Format("02/01/2006 15:04") } +// Handler serves HTTP requests for the Evento module. type Handler struct { svc EventService } +// EventService defines the business operations required by the HTTP handler. type EventService interface { EnsureNombreUnico(ctx context.Context, nombre string) error EnsureNoSolapamiento(ctx context.Context, start, end time.Time) error @@ -49,14 +68,18 @@ type EventService interface { GetFechasOcupadas(ctx context.Context) ([]dto.RangoFechas, error) } +// New creates an HTTP handler wired with the concrete events service. func New(client *db.PrismaClient) http.Handler { return &Handler{svc: service.New(client)} } +// NewWithService creates a handler with a custom service implementation. +// This constructor is mainly useful for tests and dependency injection. func NewWithService(svc EventService) *Handler { return &Handler{svc: svc} } +// ServeHTTP routes incoming requests by HTTP method. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: @@ -74,6 +97,14 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +/* +Endpoint: POST /api/eventos +Creates a new event using the request JSON payload. + +Usage: + - Body: CreateEventoRequest + - Response: EventoResponse +*/ func (h *Handler) createEvento(w http.ResponseWriter, r *http.Request) { var req dto.CreateEventoRequest @@ -140,8 +171,18 @@ func (h *Handler) createEvento(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(res) } +/* +Endpoint: GET /api/eventos +Returns either the event list or one detailed event. + +Usage: + - GET /api/eventos + Returns a list of events. + - GET /api/eventos?evento_id= + Returns one event with sessions and speakers. +*/ func (h *Handler) listEventos(w http.ResponseWriter, r *http.Request) { - // Si viene el parámetro evento_id, retornar el detalle con sesiones y ponentes + // If evento_id is present, return detailed event data with sessions and speakers. eventoIDStr := r.URL.Query().Get("evento_id") if eventoIDStr != "" { eventoID, err := strconv.Atoi(eventoIDStr) @@ -156,7 +197,7 @@ func (h *Handler) listEventos(w http.ResponseWriter, r *http.Request) { httperror.WriteJSON(w, http.StatusNotFound, "Evento no encontrado") return } - // Obtener sesiones usando el tipo concreto + // Retrieve sessions through the concrete service type. svc, ok := h.svc.(*service.Service) if !ok { httperror.WriteJSON(w, http.StatusInternalServerError, "Error interno de servicio") @@ -226,6 +267,14 @@ func (h *Handler) listEventos(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(res) } +/* +Endpoint: PUT /api/eventos +Updates an existing event using request JSON. + +Usage: + - Body: UpdateEventoRequest (id_evento is required) + - Response: EventoResponse +*/ func (h *Handler) updateEvento(w http.ResponseWriter, r *http.Request) { var req dto.UpdateEventoRequest @@ -294,6 +343,16 @@ func (h *Handler) updateEvento(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(res) } +/* +Endpoint: PATCH /api/eventos +Dispatches registration state changes for an event. + +Usage: + - PATCH /api/eventos?action=cerrar&id= + Closes registrations for the event. + - PATCH /api/eventos?action=abrir&id= + Opens registrations for the event. +*/ func (h *Handler) patchEvento(w http.ResponseWriter, r *http.Request) { action := r.URL.Query().Get("action") idStr := r.URL.Query().Get("id") @@ -319,6 +378,13 @@ func (h *Handler) patchEvento(w http.ResponseWriter, r *http.Request) { } } +/* +Endpoint: DELETE /api/eventos +Performs logical deletion (cancellation) for an event. + +Usage: + - DELETE /api/eventos?id= +*/ func (h *Handler) deleteEvento(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Query().Get("id") if idStr == "" { @@ -344,6 +410,7 @@ func (h *Handler) deleteEvento(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// cerrarInscripciones closes event registrations and returns the updated event. func (h *Handler) cerrarInscripciones(w http.ResponseWriter, r *http.Request, eventoID int) { ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() @@ -376,6 +443,7 @@ func (h *Handler) cerrarInscripciones(w http.ResponseWriter, r *http.Request, ev _ = json.NewEncoder(w).Encode(res) } +// abrirInscripciones opens event registrations and returns the updated event. func (h *Handler) abrirInscripciones(w http.ResponseWriter, r *http.Request, eventoID int) { ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() @@ -413,6 +481,8 @@ func (h *Handler) abrirInscripciones(w http.ResponseWriter, r *http.Request, eve _ = json.NewEncoder(w).Encode(res) } +// isInscripcionesAbiertas computes whether registrations are open at the +// current time based on the manual flag and event date constraints. func isInscripcionesAbiertas(evento *db.EventoModel, now time.Time) bool { if !evento.InscripcionesAbiertasManual { return false @@ -423,10 +493,20 @@ func isInscripcionesAbiertas(evento *db.EventoModel, now time.Time) bool { return now.Before(evento.FechaCierreInscripcion) } +// Svc exposes the underlying service used by the handler. func (h *Handler) Svc() EventService { return h.svc } +/* +Endpoint: GET +Returns occupied date ranges for existing events. + +Usage: + - Route is configured by the application router using + GetFechasOcupadasHandler(svc). + - Response: []RangoFechas +*/ func GetFechasOcupadasHandler(svc EventService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -441,6 +521,8 @@ func GetFechasOcupadasHandler(svc EventService) http.HandlerFunc { } } +// handleUpdateEventoError maps update errors to HTTP responses. +// It returns true when an error was handled and a response was written. func handleUpdateEventoError(w http.ResponseWriter, err error) bool { if err == nil { return false diff --git a/backend/internal/events/handler/handler_test.go b/backend/internal/events/handler/handler_test.go index e65e97dc..13255b9d 100755 --- a/backend/internal/events/handler/handler_test.go +++ b/backend/internal/events/handler/handler_test.go @@ -1,3 +1,16 @@ +/* +File: handler_test.go + +Contains: +Unit tests for the Evento HTTP handler. +It validates method routing, request handling, status codes, +and service interaction behavior using mocks. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package handler import ( @@ -29,6 +42,7 @@ type mockEventService struct { getFechasOcupadas func(ctx context.Context) ([]dto.RangoFechas, error) } +// EnsureNombreUnico mocks unique-name validation. func (m mockEventService) EnsureNombreUnico(ctx context.Context, nombre string) error { if m.ensureNombreUnico == nil { return nil @@ -36,6 +50,7 @@ func (m mockEventService) EnsureNombreUnico(ctx context.Context, nombre string) return m.ensureNombreUnico(ctx, nombre) } +// EnsureNoSolapamiento mocks overlap validation. func (m mockEventService) EnsureNoSolapamiento(ctx context.Context, start, end time.Time) error { if m.ensureNoSolapamiento == nil { return nil @@ -43,6 +58,7 @@ func (m mockEventService) EnsureNoSolapamiento(ctx context.Context, start, end t return m.ensureNoSolapamiento(ctx, start, end) } +// CreateEvento mocks event creation. func (m mockEventService) CreateEvento(ctx context.Context, req dto.CreateEventoRequest, start, end, cierre time.Time) (*db.EventoModel, error) { if m.createEvento == nil { return nil, errors.New("not implemented") @@ -50,6 +66,7 @@ func (m mockEventService) CreateEvento(ctx context.Context, req dto.CreateEvento return m.createEvento(ctx, req, start, end, cierre) } +// ListEventos mocks event listing. func (m mockEventService) ListEventos(ctx context.Context) ([]db.EventoModel, error) { if m.listEventos == nil { return nil, errors.New("not implemented") @@ -57,6 +74,7 @@ func (m mockEventService) ListEventos(ctx context.Context) ([]db.EventoModel, er return m.listEventos(ctx) } +// GetEventoByID mocks event lookup by identifier. func (m mockEventService) GetEventoByID(ctx context.Context, id int) (*db.EventoModel, error) { if m.getEventoByID == nil { return nil, errors.New("not implemented") @@ -64,6 +82,7 @@ func (m mockEventService) GetEventoByID(ctx context.Context, id int) (*db.Evento return m.getEventoByID(ctx, id) } +// UpdateEvento mocks event update. func (m mockEventService) UpdateEvento(ctx context.Context, req dto.UpdateEventoRequest, start, end, cierre time.Time) (*db.EventoModel, error) { if m.updateEvento == nil { return nil, errors.New("not implemented") @@ -71,6 +90,7 @@ func (m mockEventService) UpdateEvento(ctx context.Context, req dto.UpdateEvento return m.updateEvento(ctx, req, start, end, cierre) } +// DeleteEvento mocks event deletion. func (m mockEventService) DeleteEvento(ctx context.Context, id int) error { if m.deleteEvento == nil { return errors.New("not implemented") @@ -78,6 +98,7 @@ func (m mockEventService) DeleteEvento(ctx context.Context, id int) error { return m.deleteEvento(ctx, id) } +// CerrarInscripciones mocks registration closing. func (m mockEventService) CerrarInscripciones(ctx context.Context, eventoID int) (*db.EventoModel, error) { if m.cerrarInscripciones == nil { return nil, errors.New("not implemented") @@ -85,6 +106,7 @@ func (m mockEventService) CerrarInscripciones(ctx context.Context, eventoID int) return m.cerrarInscripciones(ctx, eventoID) } +// AbrirInscripciones mocks registration opening. func (m mockEventService) AbrirInscripciones(ctx context.Context, eventoID int) (*db.EventoModel, error) { if m.abrirInscripciones == nil { return nil, errors.New("not implemented") @@ -92,6 +114,7 @@ func (m mockEventService) AbrirInscripciones(ctx context.Context, eventoID int) return m.abrirInscripciones(ctx, eventoID) } +// GetFechasOcupadas mocks occupied date range retrieval. func (m mockEventService) GetFechasOcupadas(ctx context.Context) ([]dto.RangoFechas, error) { if m.getFechasOcupadas == nil { return nil, errors.New("not implemented") diff --git a/backend/internal/events/repo/repo.go b/backend/internal/events/repo/repo.go index 088e3799..0f6080e8 100755 --- a/backend/internal/events/repo/repo.go +++ b/backend/internal/events/repo/repo.go @@ -1,3 +1,16 @@ +/* +File: repo.go + +Contains: +Persistence repository implementation for the Evento entity. +It centralizes query, create, update, logical cancellation, +and time-window based search operations. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package repo import ( @@ -8,14 +21,20 @@ import ( "time" ) +// Repository encapsulates persistence operations for events. type Repository struct { client *db.PrismaClient } +// New creates a new events repository instance using the provided Prisma +// client. func New(client *db.PrismaClient) *Repository { return &Repository{client: client} } +// FindByName searches for an active event by exact name. +// +// The input name is normalized with TrimSpace before querying. func (r *Repository) FindByName(ctx context.Context, nombre string) (*db.EventoModel, error) { return r.client.Evento.FindFirst( db.Evento.Nombre.Equals(strings.TrimSpace(nombre)), @@ -23,18 +42,23 @@ func (r *Repository) FindByName(ctx context.Context, nombre string) (*db.EventoM ).Exec(ctx) } +// FindByID retrieves an event by its unique identifier. func (r *Repository) FindByID(ctx context.Context, id int) (*db.EventoModel, error) { return r.client.Evento.FindUnique( db.Evento.IDEvento.Equals(id), ).Exec(ctx) } +// FindAll lists all non-cancelled events. func (r *Repository) FindAll(ctx context.Context) ([]db.EventoModel, error) { return r.client.Evento.FindMany( db.Evento.Cancelado.Equals(false), ).Exec(ctx) } +// Create registers a new event with name, location, and main dates. +// +// Text fields are normalized with TrimSpace before persistence. func (r *Repository) Create(ctx context.Context, reqNombre, reqUbicacion string, start, end, cierre time.Time) (*db.EventoModel, error) { return r.client.Evento.CreateOne( db.Evento.Nombre.Set(strings.TrimSpace(reqNombre)), @@ -45,6 +69,7 @@ func (r *Repository) Create(ctx context.Context, reqNombre, reqUbicacion string, ).Exec(ctx) } +// Update modifies name, location, and dates of an existing event. func (r *Repository) Update(ctx context.Context, id int, reqNombre, reqUbicacion string, start, end, cierre time.Time) (*db.EventoModel, error) { return r.client.Evento.FindUnique( db.Evento.IDEvento.Equals(id), @@ -57,6 +82,9 @@ func (r *Repository) Update(ctx context.Context, id int, reqNombre, reqUbicacion ).Exec(ctx) } +// DeleteByID performs a logical delete by marking the event as cancelled. +// +// This method does not physically remove the database record. func (r *Repository) DeleteByID(ctx context.Context, id int) error { _, err := r.client.Evento.FindUnique( db.Evento.IDEvento.Equals(id), @@ -66,6 +94,8 @@ func (r *Repository) DeleteByID(ctx context.Context, id int) error { return err } +// SetInscripciones manually enables or disables event registrations through +// the InscripcionesAbiertasManual field. func (r *Repository) SetInscripciones(ctx context.Context, id int, abiertas bool) (*db.EventoModel, error) { return r.client.Evento.FindUnique( db.Evento.IDEvento.Equals(id), @@ -74,10 +104,13 @@ func (r *Repository) SetInscripciones(ctx context.Context, id int, abiertas bool ).Exec(ctx) } +// GetFechasOcupadas returns occupied date ranges for non-cancelled events. +// +// Output dates are formatted as dd/mm/yyyy. func (r *Repository) GetFechasOcupadas(ctx context.Context) ([]dto.RangoFechas, error) { - eventos, err := r.client.Evento.FindMany( - db.Evento.Cancelado.Equals(false), - ).Exec(ctx) + eventos, err := r.client.Evento.FindMany( + db.Evento.Cancelado.Equals(false), + ).Exec(ctx) if err != nil { return nil, err } @@ -92,6 +125,12 @@ func (r *Repository) GetFechasOcupadas(ctx context.Context) ([]dto.RangoFechas, return rangos, nil } +// FindEventosCierreManana lists events whose registration closing date occurs +// tomorrow, using the server local time zone. +// +// Applied time window is [start, end): +// start = tomorrow at 00:00:00 local time +// end = start + 24 hours func (r *Repository) FindEventosCierreManana(ctx context.Context) ([]db.EventoModel, error) { manana := time.Now().AddDate(0, 0, 1) inicio := time.Date(manana.Year(), manana.Month(), manana.Day(), 0, 0, 0, 0, manana.Location()) @@ -103,6 +142,11 @@ func (r *Repository) FindEventosCierreManana(ctx context.Context) ([]db.EventoMo ).Exec(ctx) } +// FindEventosInicioManana lists events whose start date occurs tomorrow in UTC. +// +// Applied time window is [start, end): +// start = tomorrow at 00:00:00 UTC +// end = start + 24 hours func (r *Repository) FindEventosInicioManana(ctx context.Context) ([]db.EventoModel, error) { manana := time.Now().UTC().AddDate(0, 0, 1) inicio := time.Date(manana.Year(), manana.Month(), manana.Day(), 0, 0, 0, 0, time.UTC) diff --git a/backend/internal/events/service/service.go b/backend/internal/events/service/service.go index fec178ba..546f6b1b 100755 --- a/backend/internal/events/service/service.go +++ b/backend/internal/events/service/service.go @@ -1,3 +1,16 @@ +/* +File: service.go + +Contains: +Business service implementation for the Evento module. +It orchestrates repository calls, enforces business rules, +maps persistence errors to domain errors, and triggers notifications. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package service import ( @@ -28,6 +41,7 @@ var ( ErrCloseDateLocked = errors.New("La fecha de cierre de inscripción no puede modificarse una vez alcanzada") ) +// Service implements business rules for events. type Service struct { repo *repo.Repository inscripcionRepo *registrationrepo.Repository @@ -35,12 +49,15 @@ type Service struct { sesionesRepo *sesionesrepo.Repository } +// venezuelaLocation defines the timezone used for user-facing date messages. var venezuelaLocation = time.FixedZone("VET", -4*60*60) +// formatDateVE formats a date in dd/mm/yyyy using Venezuela local time. func formatDateVE(t time.Time) string { return t.In(venezuelaLocation).Format("02/01/2006") } +// New builds a Service with its required repositories and notification service. func New(prismaClient *db.PrismaClient) *Service { eventRepo := repo.New(prismaClient) inscripcionRepo := registrationrepo.New(prismaClient) @@ -56,6 +73,7 @@ func New(prismaClient *db.PrismaClient) *Service { } } +// EnsureNombreUnico validates that no active event exists with the same name. func (s *Service) EnsureNombreUnico(ctx context.Context, nombre string) error { existing, err := s.repo.FindByName(ctx, strings.TrimSpace(nombre)) if err == nil && existing != nil { @@ -67,6 +85,7 @@ func (s *Service) EnsureNombreUnico(ctx context.Context, nombre string) error { return nil } +// EnsureNoSolapamiento validates that [start, end] does not overlap existing events. func (s *Service) EnsureNoSolapamiento(ctx context.Context, start, end time.Time) error { eventos, err := s.repo.FindAll(ctx) if err != nil { @@ -80,6 +99,8 @@ func (s *Service) EnsureNoSolapamiento(ctx context.Context, start, end time.Time return nil } +// CreateEvento persists a new event and attempts to notify registration opening. +// Notification failures are logged but do not fail the create operation. func (s *Service) CreateEvento(ctx context.Context, req dto.CreateEventoRequest, start, end, cierre time.Time) (*db.EventoModel, error) { created, err := s.repo.Create(ctx, req.Nombre, req.Ubicacion, start, end, cierre) if err != nil { @@ -92,6 +113,7 @@ func (s *Service) CreateEvento(ctx context.Context, req dto.CreateEventoRequest, return created, nil } +// ListEventos returns all non-cancelled events. func (s *Service) ListEventos(ctx context.Context) ([]db.EventoModel, error) { eventos, err := s.repo.FindAll(ctx) if err != nil { @@ -100,6 +122,7 @@ func (s *Service) ListEventos(ctx context.Context) ([]db.EventoModel, error) { return eventos, nil } +// GetEventoByID returns one event by identifier. func (s *Service) GetEventoByID(ctx context.Context, id int) (*db.EventoModel, error) { evento, err := s.repo.FindByID(ctx, id) if err != nil { @@ -111,6 +134,13 @@ func (s *Service) GetEventoByID(ctx context.Context, id int) (*db.EventoModel, e return evento, nil } +// UpdateEvento updates event data and notifies registered users when relevant +// fields change. +// +// Enforced rules: +// - Close date cannot be changed after it has been reached. +// - Name must remain unique across events. +// - Date range cannot overlap other events. func (s *Service) UpdateEvento(ctx context.Context, req dto.UpdateEventoRequest, start, end, cierre time.Time) (*db.EventoModel, error) { evento, err := s.repo.FindByID(ctx, req.ID) if err != nil { @@ -172,7 +202,7 @@ func (s *Service) UpdateEvento(ctx context.Context, req dto.UpdateEventoRequest, strings.Join(cambios, ", "), ) - // Notificar a los usuarios inscritos + // Notify users currently registered in the event. inscripciones, err := s.inscripcionRepo.FindByEventoID(ctx, req.ID) if err != nil { fmt.Println("Error obteniendo inscripciones:", err) @@ -194,8 +224,10 @@ func (s *Service) UpdateEvento(ctx context.Context, req dto.UpdateEventoRequest, return updated, nil } +// DeleteEvento performs a logical delete (cancellation) and triggers +// cancellation notifications. func (s *Service) DeleteEvento(ctx context.Context, id int) error { - // Verificar que el evento existe + // Verify that the target event exists. evento, err := s.repo.FindByID(ctx, id) if err != nil { if errors.Is(err, db.ErrNotFound) { @@ -203,7 +235,7 @@ func (s *Service) DeleteEvento(ctx context.Context, id int) error { } return ErrDB } - // Soft delete: marcar como cancelado + // Soft delete: mark event as cancelled. if err := s.repo.DeleteByID(ctx, id); err != nil { return ErrDB } @@ -214,6 +246,7 @@ func (s *Service) DeleteEvento(ctx context.Context, id int) error { return nil } +// CerrarInscripciones closes registrations for an event if it has not started. func (s *Service) CerrarInscripciones(ctx context.Context, eventoID int) (*db.EventoModel, error) { evento, err := s.repo.FindByID(ctx, eventoID) if err != nil { @@ -234,6 +267,8 @@ func (s *Service) CerrarInscripciones(ctx context.Context, eventoID int) (*db.Ev return updated, nil } +// AbrirInscripciones reopens registrations if the event has not started and +// close date has not passed. func (s *Service) AbrirInscripciones(ctx context.Context, eventoID int) (*db.EventoModel, error) { evento, err := s.repo.FindByID(ctx, eventoID) if err != nil { @@ -257,16 +292,19 @@ func (s *Service) AbrirInscripciones(ctx context.Context, eventoID int) (*db.Eve return updated, nil } +// sameDay returns true when both timestamps share the same calendar day. func sameDay(a, b time.Time) bool { y1, m1, d1 := a.Date() y2, m2, d2 := b.Date() return y1 == y2 && m1 == m2 && d1 == d2 } +// GetFechasOcupadas returns occupied date ranges for existing events. func (s *Service) GetFechasOcupadas(ctx context.Context) ([]dto.RangoFechas, error) { return s.repo.GetFechasOcupadas(ctx) } +// GetSesionesRepo exposes the sessions repository used by this service. func (s *Service) GetSesionesRepo() *sesionesrepo.Repository { return s.sesionesRepo } diff --git a/backend/internal/events/validation/validation.go b/backend/internal/events/validation/validation.go index b828f493..d421fb16 100755 --- a/backend/internal/events/validation/validation.go +++ b/backend/internal/events/validation/validation.go @@ -1,3 +1,16 @@ +/* +File: validation.go + +Contains: +Validation rules for the Evento module. +It validates event name, date ranges, and location fields before +business operations are executed. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package validation import ( @@ -12,6 +25,7 @@ var ( locationRegex = regexp.MustCompile(`^[\p{L}0-9\s,\.\-]+$`) ) +// ValidateEventoNombre checks event name length and allowed characters. func ValidateEventoNombre(nombre string) error { trimmed := strings.TrimSpace(nombre) if len(trimmed) < 5 || len(trimmed) > 100 { @@ -23,16 +37,18 @@ func ValidateEventoNombre(nombre string) error { return nil } +// ValidateEventoFechas validates create-flow event dates and returns parsed +// values using the same location as now. func ValidateEventoFechas(fechaInicio, fechaFin, fechaCierre string, now time.Time) (time.Time, time.Time, time.Time, error) { loc := now.Location() parseDate := func(dateStr string) (time.Time, error) { dateStr = strings.TrimSpace(dateStr) - // Intentar formato con hora + // Try datetime format first. t, err := time.ParseInLocation("02/01/2006 15:04:05", dateStr, loc) if err == nil { return t, nil } - // Intentar formato sin hora + // Fall back to date-only format. t, err = time.ParseInLocation("02/01/2006", dateStr, loc) if err == nil { return t, nil @@ -71,6 +87,8 @@ func ValidateEventoFechas(fechaInicio, fechaFin, fechaCierre string, now time.Ti return start, end, cierre, nil } +// ValidateEventoFechasUpdate validates update-flow dates, including close-date +// lock rules after the close day is reached. func ValidateEventoFechasUpdate(fechaInicio, fechaFin, fechaCierre string, now, currentCierre time.Time) (time.Time, time.Time, time.Time, error) { loc := now.Location() parseDate := func(dateStr string) (time.Time, error) { @@ -121,6 +139,8 @@ func ValidateEventoFechasUpdate(fechaInicio, fechaFin, fechaCierre string, now, return start, end, cierre, nil } +// ValidateEventoUbicacion validates location format and requires city/country +// style components separated by comma or dot. func ValidateEventoUbicacion(ubicacion string) error { trimmed := strings.TrimSpace(ubicacion) if len(trimmed) < 5 || len(trimmed) > 200 { diff --git a/backend/internal/events/validation/validation_test.go b/backend/internal/events/validation/validation_test.go index c6f71f66..3e452c87 100755 --- a/backend/internal/events/validation/validation_test.go +++ b/backend/internal/events/validation/validation_test.go @@ -1,3 +1,16 @@ +/* +File: validation_test.go + +Contains: +Unit tests for the Evento validation rules. +It verifies expected acceptance and rejection cases for names, +dates, locations, and update constraints. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package validation import ( @@ -5,6 +18,7 @@ import ( "time" ) +// TestValidateEventoNombre verifies valid and invalid name inputs. func TestValidateEventoNombre(t *testing.T) { cases := []struct { name string @@ -27,6 +41,7 @@ func TestValidateEventoNombre(t *testing.T) { } } +// TestValidateEventoFechas verifies create-flow date validation rules. func TestValidateEventoFechas(t *testing.T) { now := time.Date(2026, 2, 4, 10, 0, 0, 0, time.Local) @@ -61,6 +76,7 @@ func TestValidateEventoFechas(t *testing.T) { } } +// TestValidateEventoUbicacion verifies accepted and rejected location formats. func TestValidateEventoUbicacion(t *testing.T) { cases := []struct { loc string @@ -84,6 +100,7 @@ func TestValidateEventoUbicacion(t *testing.T) { } } +// TestValidateEventoFechasUpdate verifies close-date locking behavior in updates. func TestValidateEventoFechasUpdate(t *testing.T) { now := time.Date(2026, 2, 11, 10, 0, 0, 0, time.Local) currentCierre := time.Date(2026, 2, 10, 0, 0, 0, 0, time.Local) diff --git a/backend/internal/notifications/cron/cron.go b/backend/internal/notifications/cron/cron.go index afaeb81b..4e31abc3 100755 --- a/backend/internal/notifications/cron/cron.go +++ b/backend/internal/notifications/cron/cron.go @@ -1,3 +1,16 @@ +/* +File: cron.go + +Contains: +Background scheduler for notifications-related jobs. +It triggers notification catch-up and periodic checks for event +closing, reminders, and pending payments. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package cron import ( @@ -13,6 +26,8 @@ import ( "github.com/robfig/cron/v3" ) +// StartCierreInscripcionesScheduler starts the background scheduler that runs +// notification catch-up on startup and every five minutes. func StartCierreInscripcionesScheduler(prismaClient *db.PrismaClient) { eventRepo := eventrepo.New(prismaClient) inscripcionRepo := registrationrepo.New(prismaClient) @@ -44,6 +59,7 @@ func StartCierreInscripcionesScheduler(prismaClient *db.PrismaClient) { c.Start() } +// runNotifications executes all scheduled notification checks in sequence. func runNotifications(ctx context.Context, notificationService notificationsrv.NotificationService, eventRepo *eventrepo.Repository, inscripcionRepo *registrationrepo.Repository) error { if err := notificationService.NotificarCierreInscripciones(ctx, eventRepo, inscripcionRepo); err != nil { log.Println("Error en notificación de cierre de inscripciones:", err) diff --git a/backend/internal/notifications/dto/notification_templates.go b/backend/internal/notifications/dto/notification_templates.go index aa6b2266..6e4c5a8c 100755 --- a/backend/internal/notifications/dto/notification_templates.go +++ b/backend/internal/notifications/dto/notification_templates.go @@ -1,5 +1,17 @@ +/* +File: notification_templates.go + +Contains: +Notification message templates and title helpers for the notifications module. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package dto +// Message templates used to build notification bodies. const ( MsgInscripcionExitosa = "Te has inscrito exitosamente al evento '%s', que inicia el %s y finaliza el %s." MsgCambioEvento = "El evento '%s' ha sufrido cambios: %s." @@ -19,6 +31,7 @@ const ( MsgNuevoMensaje = "Tienes un nuevo mensaje de %s en la conversación '%s'." ) +// NotificationTitles maps notification types to human-readable titles. var NotificationTitles = map[string]string{ NotificationTypeInscripcion: "Inscripción exitosa", NotificationTypeCambioEvento: "Cambio en evento", @@ -37,6 +50,7 @@ var NotificationTitles = map[string]string{ NotificationTypeNuevoMensaje: "Nuevo mensaje", } +// GetNotificationTitle returns a human-readable title for a notification type. func GetNotificationTitle(tipo string) string { if t, ok := NotificationTitles[tipo]; ok { return t diff --git a/backend/internal/notifications/dto/notification_types.go b/backend/internal/notifications/dto/notification_types.go index e565059a..bd54d177 100755 --- a/backend/internal/notifications/dto/notification_types.go +++ b/backend/internal/notifications/dto/notification_types.go @@ -1,5 +1,17 @@ +/* +File: notification_types.go + +Contains: +Notification type identifiers used across the notifications module. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package dto +// Notification type identifiers used by the application. const ( NotificationTypeInscripcion = "inscripcion" NotificationTypeCambioEvento = "cambio_evento" diff --git a/backend/internal/notifications/dto/requests.go b/backend/internal/notifications/dto/requests.go index 6ef3ae41..87c98a90 100755 --- a/backend/internal/notifications/dto/requests.go +++ b/backend/internal/notifications/dto/requests.go @@ -1,5 +1,18 @@ +/* +File: requests.go + +Contains: +Request DTO definitions for the notifications module. +It centralizes payload structures used to create and update notifications. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package dto +// CreateNotificationRequest represents payload to create a notification. type CreateNotificationRequest struct { UserID int `json:"user_id"` EventID *int `json:"event_id,omitempty"` @@ -7,6 +20,7 @@ type CreateNotificationRequest struct { Message string `json:"message"` } +// MarkAsReadRequest represents payload to change read state. type MarkAsReadRequest struct { Read bool `json:"read"` } \ No newline at end of file diff --git a/backend/internal/notifications/dto/responses.go b/backend/internal/notifications/dto/responses.go index e05b9035..d6cd5091 100755 --- a/backend/internal/notifications/dto/responses.go +++ b/backend/internal/notifications/dto/responses.go @@ -1,7 +1,20 @@ +/* +File: responses.go + +Contains: +Response DTO definitions for the notifications module. +It centralizes payload structures returned to API clients. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package dto import "time" +// NotificationResponse represents one notification returned by the API. type NotificationResponse struct { ID int `json:"id"` UserID int `json:"user_id"` diff --git a/backend/internal/notifications/handler/handler.go b/backend/internal/notifications/handler/handler.go index 98b5708c..f93eb793 100755 --- a/backend/internal/notifications/handler/handler.go +++ b/backend/internal/notifications/handler/handler.go @@ -1,3 +1,15 @@ +/* +File: handler.go + +Contains: +HTTP endpoint layer for the notifications module. +It handles notification creation, listing, and read-state updates. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package handler import ( @@ -17,13 +29,14 @@ type Handler struct { svc service.NotificationService } +// New creates a notifications handler with repository and service dependencies. func New(client *db.PrismaClient) *Handler { repository := repo.NewNotificationRepository(client) svc := service.NewNotificationService(repository) return &Handler{svc: svc} } -// ServeHTTP enruta según el método y la URL +// ServeHTTP routes requests by method and path. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case r.Method == http.MethodPost && r.URL.Path == "/api/notifications": @@ -37,6 +50,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +/* +Endpoint: POST /api/notifications +Creates a notification and returns the stored record. +*/ func (h *Handler) createNotification(w http.ResponseWriter, r *http.Request) { var req dto.CreateNotificationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -53,6 +70,10 @@ func (h *Handler) createNotification(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(notification) } +/* +Endpoint: GET /api/notifications/user/{idUsuario} +Lists notifications for one user. +*/ func (h *Handler) listNotificationsByUser(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") if len(parts) < 5 { @@ -96,6 +117,10 @@ func (h *Handler) listNotificationsByUser(w http.ResponseWriter, r *http.Request _ = json.NewEncoder(w).Encode(responses) } +/* +Endpoint: PATCH /api/notifications/{idNotificacion} +Marks one notification as read or unread. +*/ func (h *Handler) markNotificationAsRead(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") if len(parts) < 4 { diff --git a/backend/internal/notifications/repo/job_execution.go b/backend/internal/notifications/repo/job_execution.go index d946a460..633b958b 100755 --- a/backend/internal/notifications/repo/job_execution.go +++ b/backend/internal/notifications/repo/job_execution.go @@ -1,3 +1,14 @@ +/* +File: job_execution.go + +Contains: +Repository helpers for tracking scheduled job executions. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package repo import ( @@ -6,20 +17,24 @@ import ( "time" ) +// JobExecutionRepository stores last-run metadata for scheduled jobs. type JobExecutionRepository struct { client *db.PrismaClient } +// NewJobExecutionRepository creates a job execution repository. func NewJobExecutionRepository(client *db.PrismaClient) *JobExecutionRepository { return &JobExecutionRepository{client: client} } +// GetLastRun returns the latest execution row for a job name. func (r *JobExecutionRepository) GetLastRun(ctx context.Context, jobName string) (*db.JobExecutionModel, error) { return r.client.JobExecution.FindUnique( db.JobExecution.JobName.Equals(jobName), ).Exec(ctx) } +// UpsertLastRun stores or updates the last execution time for a job. func (r *JobExecutionRepository) UpsertLastRun(ctx context.Context, jobName string, lastRun time.Time) error { _, err := r.client.JobExecution.UpsertOne( db.JobExecution.JobName.Equals(jobName), diff --git a/backend/internal/notifications/repo/repo.go b/backend/internal/notifications/repo/repo.go index cab99271..47936171 100755 --- a/backend/internal/notifications/repo/repo.go +++ b/backend/internal/notifications/repo/repo.go @@ -1,3 +1,16 @@ +/* +File: repo.go + +Contains: +Persistence repository implementation for notifications. +It provides data access for notification storage, listing, and +daily-existence checks used by scheduled jobs. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package repo import ( @@ -8,22 +21,27 @@ import ( "time" ) +// NotificationRepository defines the persistence contract for notifications. type NotificationRepository interface { Create(ctx context.Context, req dto.CreateNotificationRequest) (*db.NotificacionModel, error) ListByUser(ctx context.Context, idUsuario int) ([]db.NotificacionModel, error) MarkAsRead(ctx context.Context, idNotificacion int, leida bool) error ExistsCierreInscripcionToday(ctx context.Context, userID int, eventID int) (bool, error) ExistsNotificationToday(ctx context.Context, userID int, eventID int, tipo string) (bool, error) + FindUserEmailByID(ctx context.Context, userID int) (string, error) } +// notificationRepository implements NotificationRepository using Prisma. type notificationRepository struct { client *db.PrismaClient } +// NewNotificationRepository creates a notification repository. func NewNotificationRepository(client *db.PrismaClient) NotificationRepository { return ¬ificationRepository{client: client} } +// Create stores a notification record. func (r *notificationRepository) Create(ctx context.Context, req dto.CreateNotificationRequest) (*db.NotificacionModel, error) { setMensaje := db.Notificacion.Mensaje.Set(req.Message) setUsuario := db.Notificacion.Usuario.Link( @@ -52,6 +70,7 @@ func (r *notificationRepository) Create(ctx context.Context, req dto.CreateNotif return notification, nil } +// ListByUser returns notifications ordered by newest first. func (r *notificationRepository) ListByUser(ctx context.Context, idUsuario int) ([]db.NotificacionModel, error) { return r.client.Notificacion.FindMany( db.Notificacion.IDUsuario.Equals(idUsuario), @@ -60,6 +79,7 @@ func (r *notificationRepository) ListByUser(ctx context.Context, idUsuario int) ).Exec(ctx) } +// MarkAsRead updates the read state of one notification. func (r *notificationRepository) MarkAsRead(ctx context.Context, idNotificacion int, leida bool) error { _, err := r.client.Notificacion.FindUnique( db.Notificacion.IDNotificacion.Equals(idNotificacion), @@ -69,6 +89,8 @@ func (r *notificationRepository) MarkAsRead(ctx context.Context, idNotificacion return err } +// ExistsCierreInscripcionToday checks whether a close-inscription notification +// already exists today for a user and event. func (r *notificationRepository) ExistsCierreInscripcionToday(ctx context.Context, userID int, eventID int) (bool, error) { now := time.Now().UTC() startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) @@ -86,6 +108,8 @@ func (r *notificationRepository) ExistsCierreInscripcionToday(ctx context.Contex return notif != nil, nil } +// ExistsNotificationToday checks whether a notification of the same type was +// already created today for a user and event. func (r *notificationRepository) ExistsNotificationToday(ctx context.Context, userID int, eventID int, tipo string) (bool, error) { now := time.Now().UTC() startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) @@ -102,3 +126,17 @@ func (r *notificationRepository) ExistsNotificationToday(ctx context.Context, us } return notif != nil, nil } + +// FindUserEmailByID retrieves the email for one user. +func (r *notificationRepository) FindUserEmailByID(ctx context.Context, userID int) (string, error) { + user, err := r.client.Usuario.FindUnique( + db.Usuario.IDUsuario.Equals(userID), + ).Exec(ctx) + if err != nil { + return "", err + } + if user == nil { + return "", fmt.Errorf("usuario %d no encontrado", userID) + } + return user.Email, nil +} \ No newline at end of file diff --git a/backend/internal/notifications/service/factory.go b/backend/internal/notifications/service/factory.go index 947b1e3f..7055397b 100755 --- a/backend/internal/notifications/service/factory.go +++ b/backend/internal/notifications/service/factory.go @@ -1,3 +1,14 @@ +/* +File: factory.go + +Contains: +Factory helpers for constructing notification services. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package service import ( @@ -5,6 +16,7 @@ import ( "project/backend/prisma/db" ) +// NewNotificationServiceFromClient builds a notification service from a Prisma client. func NewNotificationServiceFromClient(client *db.PrismaClient) NotificationService { notificationRepo := repo.NewNotificationRepository(client) return NewNotificationService(notificationRepo) diff --git a/backend/internal/notifications/service/service.go b/backend/internal/notifications/service/service.go index a6211075..3f576b37 100755 --- a/backend/internal/notifications/service/service.go +++ b/backend/internal/notifications/service/service.go @@ -1,3 +1,16 @@ +/* +File: service.go + +Contains: +Business service implementation for notifications. +It orchestrates notification creation, delivery, and scheduled +notification rules for event and work workflows. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package service import ( @@ -7,10 +20,13 @@ import ( "project/backend/internal/notifications/dto" "project/backend/internal/notifications/repo" registrationrepo "project/backend/internal/registrations/repo" + sharedsmtp "project/backend/internal/shared/smtp" "project/backend/prisma/db" "time" + "strings" ) +// NotificationService defines notification-related business operations. type NotificationService interface { CreateNotification(ctx context.Context, req dto.CreateNotificationRequest) (*db.NotificacionModel, error) ListNotificationsByUser(ctx context.Context, idUsuario int) ([]db.NotificacionModel, error) @@ -22,32 +38,49 @@ type NotificationService interface { NotificarCancelacionEvento(ctx context.Context, evento *db.EventoModel, inscripcionesRepo *registrationrepo.Repository) error } +// notificationService implements NotificationService. type notificationService struct { repo repo.NotificationRepository } +// venezuelaLocation defines the timezone used in notification messages. var venezuelaLocation = time.FixedZone("VET", -4*60*60) +// formatDateVE formats dates as dd/mm/yyyy in Venezuela time. func formatDateVE(t time.Time) string { return t.In(venezuelaLocation).Format("02/01/2006") } +// NewNotificationService creates a notification service from a repository. func NewNotificationService(r repo.NotificationRepository) NotificationService { return ¬ificationService{repo: r} } +// CreateNotification stores a notification and optionally sends email. func (s *notificationService) CreateNotification(ctx context.Context, req dto.CreateNotificationRequest) (*db.NotificacionModel, error) { - return s.repo.Create(ctx, req) + notification, err := s.repo.Create(ctx, req) + if err != nil { + return nil, err + } + + if emailErr := s.sendNotificationEmail(ctx, req); emailErr != nil { + fmt.Println("[NotificationEmail] Error enviando correo:", emailErr) + } + + return notification, nil } +// ListNotificationsByUser returns notifications for one user. func (s *notificationService) ListNotificationsByUser(ctx context.Context, idUsuario int) ([]db.NotificacionModel, error) { return s.repo.ListByUser(ctx, idUsuario) } +// MarkNotificationAsRead updates a notification read state. func (s *notificationService) MarkNotificationAsRead(ctx context.Context, idNotificacion int, leida bool) error { return s.repo.MarkAsRead(ctx, idNotificacion, leida) } +// NotificarCierreInscripciones sends close-registration reminders. func (s *notificationService) NotificarCierreInscripciones(ctx context.Context, eventosRepo *eventrepo.Repository, inscripcionesRepo *registrationrepo.Repository) error { eventos, err := eventosRepo.FindEventosCierreManana(ctx) if err != nil { @@ -90,6 +123,7 @@ func (s *notificationService) NotificarCierreInscripciones(ctx context.Context, return nil } +// NotificarRecordatorioEvento sends event reminder notifications. func (s *notificationService) NotificarRecordatorioEvento(ctx context.Context, eventosRepo *eventrepo.Repository, inscripcionesRepo *registrationrepo.Repository) error { eventos, err := eventosRepo.FindEventosInicioManana(ctx) if err != nil { @@ -130,6 +164,8 @@ func (s *notificationService) NotificarRecordatorioEvento(ctx context.Context, e } return nil } + +// NotificarPagoPendiente sends pending payment notifications. func (s *notificationService) NotificarPagoPendiente(ctx context.Context, eventosRepo *eventrepo.Repository, inscripcionesRepo *registrationrepo.Repository) error { inscripciones, err := inscripcionesRepo.FindAll(ctx) if err != nil { @@ -175,6 +211,7 @@ func (s *notificationService) NotificarPagoPendiente(ctx context.Context, evento return nil } +// NotificarAperturaInscripciones sends registration-open notifications. func (s *notificationService) NotificarAperturaInscripciones(ctx context.Context, evento *db.EventoModel, usuariosRepo *registrationrepo.Repository) error { usuarios, err := usuariosRepo.FindAllUsuarios(ctx) if err != nil { @@ -209,6 +246,7 @@ func (s *notificationService) NotificarAperturaInscripciones(ctx context.Context return nil } +// NotificarCancelacionEvento sends event cancellation notifications. func (s *notificationService) NotificarCancelacionEvento(ctx context.Context, evento *db.EventoModel, inscripcionesRepo *registrationrepo.Repository) error { inscripciones, err := inscripcionesRepo.FindByEventoID(ctx, evento.IDEvento) if err != nil { @@ -235,3 +273,56 @@ func (s *notificationService) NotificarCancelacionEvento(ctx context.Context, ev } return nil } + +// emailEnabledNotificationTypes lists notification types that trigger email delivery. +var emailEnabledNotificationTypes = map[string]struct{}{ + dto.NotificationTypeCambioEvento: {}, + dto.NotificationTypeCierreInscripciones: {}, + dto.NotificationTypeRecordatorioEvento: {}, + dto.NotificationTypeRecordatorioPago: {}, + dto.NotificationTypeAperturaInscripciones: {}, + dto.NotificationTypeCancelacionEvento: {}, + dto.NotificationTypeCambioSesion: {}, + + dto.NotificationTypeTrabajoRecibido: {}, + dto.NotificationTypeTrabajoNuevo: {}, + dto.NotificationTypeTrabajoActualizado: {}, + dto.NotificationTypeTrabajoAsignado: {}, + dto.NotificationTypeEvaluacionRecibida: {}, + dto.NotificationTypeEstadoTrabajo: {}, +} + +// shouldSendEmailForType checks whether a notification type sends email. +func shouldSendEmailForType(notificationType string) bool { + _, ok := emailEnabledNotificationTypes[strings.TrimSpace(notificationType)] + return ok +} + +// sendNotificationEmail sends an email for supported notification types. +func (s *notificationService) sendNotificationEmail(ctx context.Context, req dto.CreateNotificationRequest) error { + if !shouldSendEmailForType(req.Type) { + return nil + } + + email, err := s.repo.FindUserEmailByID(ctx, req.UserID) + if err != nil { + return err + } + + email = strings.TrimSpace(strings.ToLower(email)) + if email == "" { + return nil + } + + subject := dto.GetNotificationTitle(req.Type) + if strings.TrimSpace(subject) == "" { + subject = "Notificación" + } + + _, err = sharedsmtp.SendEmail(ctx, sharedsmtp.SendEmailRequest{ + ToEmail: email, + Subject: subject, + Text: req.Message, + }) + return err +} \ No newline at end of file diff --git a/backend/internal/pais/dto/responses.go b/backend/internal/pais/dto/responses.go index 47ae14ad..f24c8086 100755 --- a/backend/internal/pais/dto/responses.go +++ b/backend/internal/pais/dto/responses.go @@ -1,10 +1,24 @@ +/* +File: responses.go + +Contains: +Response DTO definitions for the Pais module. +It centralizes API payload structures for countries and cities. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package dto +// PaisResponse represents one country in API responses. type PaisResponse struct { ID int `json:"id_pais"` Nombre string `json:"nombre"` } +// CiudadResponse represents one city in API responses. type CiudadResponse struct { ID int `json:"id_ciudad"` Nombre string `json:"nombre"` diff --git a/backend/internal/pais/handler/handler.go b/backend/internal/pais/handler/handler.go index 1adcef69..07a8cb1e 100755 --- a/backend/internal/pais/handler/handler.go +++ b/backend/internal/pais/handler/handler.go @@ -1,3 +1,16 @@ +/* +File: handler.go + +Contains: +HTTP endpoint layer for the Pais module. +It handles request routing, service calls, response shaping, +and error mapping for countries and cities endpoints. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package handler import ( @@ -14,15 +27,18 @@ import ( "project/backend/prisma/db" ) +// Handler serves HTTP requests for the Pais module. type Handler struct { svc *service.Service } +// New creates a Pais handler with its repository and service dependencies. func New(client *db.PrismaClient) http.Handler { repository := repo.New(client) return &Handler{svc: service.New(repository)} } +// ServeHTTP routes incoming requests by HTTP method. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -32,6 +48,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +/* +Endpoint: GET /api/paises +Returns the list of countries. + +Usage: + - GET /api/paises +*/ func (h *Handler) listPaises(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() @@ -54,6 +77,13 @@ func (h *Handler) listPaises(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(res) } +/* +Endpoint: GET /api/ciudades?pais_id= +Returns the list of cities that belong to a country. + +Usage: + - GET /api/ciudades?pais_id= +*/ func (h *Handler) ListCiudadesByPaisHandler(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Query().Get("pais_id") id, err := strconv.Atoi(idStr) diff --git a/backend/internal/pais/repo/repo.go b/backend/internal/pais/repo/repo.go index 5cf8d72c..f70a0488 100755 --- a/backend/internal/pais/repo/repo.go +++ b/backend/internal/pais/repo/repo.go @@ -1,3 +1,15 @@ +/* +File: repo.go + +Contains: +Persistence repository implementation for the Pais module. +It provides data access operations for countries and cities. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package repo import ( @@ -5,18 +17,22 @@ import ( "project/backend/prisma/db" ) +// Repository encapsulates persistence operations for countries and cities. type Repository struct { client *db.PrismaClient } +// New creates a new Pais repository with the provided Prisma client. func New(client *db.PrismaClient) *Repository { return &Repository{client: client} } +// ListPaises returns all countries. func (r *Repository) ListPaises(ctx context.Context) ([]db.PaisModel, error) { return r.client.Pais.FindMany().Exec(ctx) } +// ListCiudadesByPais returns all cities for a given country ID. func (r *Repository) ListCiudadesByPais(ctx context.Context, paisID int) ([]db.CiudadModel, error) { return r.client.Ciudad.FindMany( db.Ciudad.IDPais.Equals(paisID), diff --git a/backend/internal/pais/service/service.go b/backend/internal/pais/service/service.go index 88da4be2..b30c8f6f 100755 --- a/backend/internal/pais/service/service.go +++ b/backend/internal/pais/service/service.go @@ -1,3 +1,16 @@ +/* +File: service.go + +Contains: +Business service implementation for the Pais module. +It orchestrates repository calls and maps persistence failures to +service-level errors. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package service import ( @@ -8,16 +21,20 @@ import ( "project/backend/prisma/db" ) +// ErrDB represents a generic persistence failure in this service. var ErrDB = errors.New("db error") +// Service implements business operations for countries and cities. type Service struct { repo *repo.Repository } +// New creates a new Pais service with the provided repository. func New(r *repo.Repository) *Service { return &Service{repo: r} } +// ListPaises retrieves all countries. func (s *Service) ListPaises(ctx context.Context) ([]db.PaisModel, error) { items, err := s.repo.ListPaises(ctx) if err != nil { @@ -26,6 +43,7 @@ func (s *Service) ListPaises(ctx context.Context) ([]db.PaisModel, error) { return items, nil } +// ListCiudadesByPais retrieves all cities belonging to a country. func (s *Service) ListCiudadesByPais(ctx context.Context, paisID int) ([]db.CiudadModel, error) { items, err := s.repo.ListCiudadesByPais(ctx, paisID) if err != nil { diff --git a/backend/internal/sesiones/dto/requests.go b/backend/internal/sesiones/dto/requests.go index 877b3660..3c544df3 100755 --- a/backend/internal/sesiones/dto/requests.go +++ b/backend/internal/sesiones/dto/requests.go @@ -1,3 +1,16 @@ +/* +File: requests.go + +Contains: +Request DTO definitions for the Sesiones module. +It centralizes API payload structures used to create/update sessions +and assign speakers. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package dto // CreateSesionRequest represents the payload to create a new session. diff --git a/backend/internal/sesiones/dto/responses.go b/backend/internal/sesiones/dto/responses.go index 0187ebf4..2e2e1bc3 100755 --- a/backend/internal/sesiones/dto/responses.go +++ b/backend/internal/sesiones/dto/responses.go @@ -1,3 +1,16 @@ +/* +File: responses.go + +Contains: +Response DTO definitions for the Sesiones module. +It centralizes payload structures returned for sessions, +assigned speakers, and assignable speakers. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package dto // SesionResponse represents the response payload for a session. diff --git a/backend/internal/sesiones/handler/handler.go b/backend/internal/sesiones/handler/handler.go index 64995172..597ce85f 100755 --- a/backend/internal/sesiones/handler/handler.go +++ b/backend/internal/sesiones/handler/handler.go @@ -1,3 +1,16 @@ +/* +File: handler.go + +Contains: +HTTP endpoint layer for the Sesiones module. +It routes requests, validates endpoint parameters, +and maps service responses to HTTP payloads. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package handler import ( @@ -16,10 +29,12 @@ type Handler struct { svc *service.Service } +// New creates a sessions handler using a Prisma client. func New(prismaClient interface{}) http.Handler { return &Handler{svc: service.New(prismaClient.(*db.PrismaClient))} } +// ServeHTTP dispatches requests by method and path/query combinations. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := r.Context() path := strings.TrimPrefix(r.URL.Path, "/api/sesiones") @@ -64,7 +79,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -// PUT /api/sesiones?sesion_id={id} +/* +Endpoint: PUT /api/sesiones?sesion_id= +Updates one session by query identifier. +*/ func (h *Handler) ActualizarSesionPorQueryHandler(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Query().Get("sesion_id") id, err := strconv.Atoi(idStr) @@ -100,7 +118,10 @@ func (h *Handler) ActualizarSesionPorQueryHandler(w http.ResponseWriter, r *http w.Write(data) } -// DELETE /api/sesiones?sesion_id={id} +/* +Endpoint: DELETE /api/sesiones?sesion_id= +Performs logical deletion for one session. +*/ func (h *Handler) EliminarSesionPorQueryHandler(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Query().Get("sesion_id") id, err := strconv.Atoi(idStr) @@ -124,7 +145,10 @@ func (h *Handler) EliminarSesionPorQueryHandler(w http.ResponseWriter, r *http.R w.WriteHeader(http.StatusNoContent) } -// POST /api/sesiones/asignar-ponentes?sesion_id={id} +/* +Endpoint: POST /api/sesiones/asignar-ponentes?sesion_id= +Assigns one or more speakers to a session. +*/ func (h *Handler) AsignarPonentesPorQueryHandler(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Query().Get("sesion_id") id, err := strconv.Atoi(idStr) @@ -157,7 +181,10 @@ func (h *Handler) AsignarPonentesPorQueryHandler(w http.ResponseWriter, r *http. w.WriteHeader(http.StatusNoContent) } -// POST /api/sesiones/quitar-ponente?sesion_id={id}&usuario={idUsuario} +/* +Endpoint: POST /api/sesiones/quitar-ponente?sesion_id=&usuario= +Removes one speaker assignment from a session. +*/ func (h *Handler) QuitarPonentePorQueryHandler(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Query().Get("sesion_id") id, err := strconv.Atoi(idStr) @@ -191,7 +218,10 @@ func (h *Handler) QuitarPonentePorQueryHandler(w http.ResponseWriter, r *http.Re w.WriteHeader(http.StatusNoContent) } -// GET /api/sesiones/ponentes?sesion_id={id} +/* +Endpoint: GET /api/sesiones/ponentes?sesion_id= +Returns assigned speakers for a session. +*/ func (h *Handler) ListarPonentesPorQueryHandler(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Query().Get("sesion_id") id, err := strconv.Atoi(idStr) @@ -217,7 +247,10 @@ func (h *Handler) ListarPonentesPorQueryHandler(w http.ResponseWriter, r *http.R w.Write(data) } -// GET /api/sesiones/ponibles?sesion_id={id} +/* +Endpoint: GET /api/sesiones/ponibles?sesion_id= +Returns speaker candidates assignable to a session. +*/ func (h *Handler) ListarPonentesAsignablesPorQueryHandler(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Query().Get("sesion_id") id, err := strconv.Atoi(idStr) @@ -243,7 +276,10 @@ func (h *Handler) ListarPonentesAsignablesPorQueryHandler(w http.ResponseWriter, w.Write(data) } -// GET /api/sesiones?sesion_id={id} +/* +Endpoint: GET /api/sesiones?sesion_id= +Returns one session by query identifier. +*/ func (h *Handler) ObtenerSesionPorQueryHandler(w http.ResponseWriter, r *http.Request) { idStr := r.URL.Query().Get("sesion_id") id, err := strconv.Atoi(idStr) @@ -269,7 +305,10 @@ func (h *Handler) ObtenerSesionPorQueryHandler(w http.ResponseWriter, r *http.Re w.Write(data) } -// POST /api/sesiones?evento={id} +/* +Endpoint: POST /api/sesiones?evento= +Creates one session for the specified event. +*/ func (h *Handler) CrearSesionHandler(w http.ResponseWriter, r *http.Request) { println("[Handler] Entrando a CrearSesionHandler") eventoIDStr := r.URL.Query().Get("evento") @@ -299,7 +338,10 @@ func (h *Handler) CrearSesionHandler(w http.ResponseWriter, r *http.Request) { w.Write(data) } -// GET /api/sesiones?evento={id} +/* +Endpoint: GET /api/sesiones?evento= +Returns sessions for the specified event. +*/ func (h *Handler) ListarSesionesHandler(w http.ResponseWriter, r *http.Request) { eventoIDStr := r.URL.Query().Get("evento") eventoID, err := strconv.Atoi(eventoIDStr) @@ -319,7 +361,10 @@ func (h *Handler) ListarSesionesHandler(w http.ResponseWriter, r *http.Request) w.Write(data) } -// GET /api/sesiones/{id} +/* +Endpoint: GET /api/sesiones/{id} +Returns one session by path identifier. +*/ func (h *Handler) ObtenerSesionHandler(w http.ResponseWriter, r *http.Request) { idStr := strings.TrimPrefix(r.URL.Path, "/api/sesiones/") id, err := strconv.Atoi(idStr) @@ -339,7 +384,10 @@ func (h *Handler) ObtenerSesionHandler(w http.ResponseWriter, r *http.Request) { w.Write(data) } -// PUT /api/sesiones/{id} +/* +Endpoint: PUT /api/sesiones/{id} +Updates one session by path identifier. +*/ func (h *Handler) ActualizarSesionHandler(w http.ResponseWriter, r *http.Request) { idStr := strings.TrimPrefix(r.URL.Path, "/api/sesiones/") id, err := strconv.Atoi(idStr) @@ -364,7 +412,10 @@ func (h *Handler) ActualizarSesionHandler(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(resp) } -// DELETE /api/sesiones/{id} +/* +Endpoint: DELETE /api/sesiones/{id} +Performs logical deletion by path identifier. +*/ func (h *Handler) EliminarSesionHandler(w http.ResponseWriter, r *http.Request) { idStr := strings.TrimPrefix(r.URL.Path, "/api/sesiones/") id, err := strconv.Atoi(idStr) @@ -382,7 +433,10 @@ func (h *Handler) EliminarSesionHandler(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNoContent) } -// POST /api/sesiones/asignar-ponentes/{id} +/* +Endpoint: POST /api/sesiones/asignar-ponentes/{id} +Assigns speakers to a session by path identifier. +*/ func (h *Handler) AsignarPonentesHandler(w http.ResponseWriter, r *http.Request) { idStr := strings.TrimPrefix(r.URL.Path, "/api/sesiones/asignar-ponentes/") id, err := strconv.Atoi(idStr) @@ -406,7 +460,10 @@ func (h *Handler) AsignarPonentesHandler(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNoContent) } -// POST /api/sesiones/quitar-ponente/{id}?usuario={idUsuario} +/* +Endpoint: POST /api/sesiones/quitar-ponente/{id}?usuario= +Removes one speaker assignment using path and query identifiers. +*/ func (h *Handler) QuitarPonenteHandler(w http.ResponseWriter, r *http.Request) { idStr := strings.TrimPrefix(r.URL.Path, "/api/sesiones/quitar-ponente/") id, err := strconv.Atoi(idStr) @@ -431,7 +488,10 @@ func (h *Handler) QuitarPonenteHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// GET /api/sesiones/ponentes/{id} +/* +Endpoint: GET /api/sesiones/ponentes/{id} +Returns assigned speakers using path identifier. +*/ func (h *Handler) ListarPonentesHandler(w http.ResponseWriter, r *http.Request) { idStr := strings.TrimPrefix(r.URL.Path, "/api/sesiones/ponentes/") id, err := strconv.Atoi(idStr) @@ -451,7 +511,10 @@ func (h *Handler) ListarPonentesHandler(w http.ResponseWriter, r *http.Request) w.Write(data) } -// GET /api/sesiones/ponibles/{id} +/* +Endpoint: GET /api/sesiones/ponibles/{id} +Returns assignable speaker candidates using path identifier. +*/ func (h *Handler) ListarPonentesAsignablesHandler(w http.ResponseWriter, r *http.Request) { idStr := strings.TrimPrefix(r.URL.Path, "/api/sesiones/ponibles/") id, err := strconv.Atoi(idStr) diff --git a/backend/internal/sesiones/repo/repo.go b/backend/internal/sesiones/repo/repo.go index 30dd584e..cb94fea5 100755 --- a/backend/internal/sesiones/repo/repo.go +++ b/backend/internal/sesiones/repo/repo.go @@ -1,3 +1,16 @@ +/* +File: repo.go + +Contains: +Persistence repository implementation for the Sesiones module. +It provides data access operations for sessions, speaker assignments, +availability checks, and session history. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package repo import ( @@ -14,6 +27,8 @@ func NewRepository(prismaClient *db.PrismaClient) *Repository { return &Repository{prisma: prismaClient} } +// CreateSesion persists a new session and links it to an event. +// fechaInicio and fechaFin must be RFC3339 strings. func (r *Repository) CreateSesion(ctx context.Context, eventoID int, titulo, descripcion, fechaInicio, fechaFin, ubicacion string) (int, error) { fechaInicioTime, err := time.Parse(time.RFC3339, fechaInicio) if err != nil { @@ -37,6 +52,7 @@ func (r *Repository) CreateSesion(ctx context.Context, eventoID int, titulo, des return s.IDSesion, nil } +// ListSesiones lists non-cancelled sessions for an event. func (r *Repository) ListSesiones(ctx context.Context, eventoID int) ([]db.SesionModel, error) { return r.prisma.Sesion.FindMany( db.Sesion.IDEvento.Equals(eventoID), @@ -44,6 +60,7 @@ func (r *Repository) ListSesiones(ctx context.Context, eventoID int) ([]db.Sesio ).Exec(ctx) } +// GetSesionByID gets one non-cancelled session by identifier. func (r *Repository) GetSesionByID(ctx context.Context, sesionID int) (*db.SesionModel, error) { return r.prisma.Sesion.FindFirst( db.Sesion.IDSesion.Equals(sesionID), @@ -51,6 +68,8 @@ func (r *Repository) GetSesionByID(ctx context.Context, sesionID int) (*db.Sesio ).Exec(ctx) } +// UpdateSesion updates main session fields. +// fechaInicio and fechaFin must be RFC3339 strings. func (r *Repository) UpdateSesion(ctx context.Context, sesionID int, titulo, descripcion, fechaInicio, fechaFin, ubicacion string) error { fechaInicioTime, err := time.Parse(time.RFC3339, fechaInicio) if err != nil { @@ -72,6 +91,7 @@ func (r *Repository) UpdateSesion(ctx context.Context, sesionID int, titulo, des return err } +// DeleteSesion performs logical deletion by setting Cancelado to true. func (r *Repository) DeleteSesion(ctx context.Context, sesionID int) error { _, err := r.prisma.Sesion.FindUnique( db.Sesion.IDSesion.Equals(sesionID), @@ -81,6 +101,7 @@ func (r *Repository) DeleteSesion(ctx context.Context, sesionID int) error { return err } +// AsignarPonentes assigns speaker users to a session. func (r *Repository) AsignarPonentes(ctx context.Context, sesionID int, usuarios []int) error { for _, userID := range usuarios { _, err := r.prisma.SesionPonente.CreateOne( @@ -94,6 +115,7 @@ func (r *Repository) AsignarPonentes(ctx context.Context, sesionID int, usuarios return nil } +// QuitarPonente removes one speaker assignment from a session. func (r *Repository) QuitarPonente(ctx context.Context, sesionID int, usuarioID int) error { sp, err := r.prisma.SesionPonente.FindFirst( db.SesionPonente.IDSesion.Equals(sesionID), @@ -111,6 +133,7 @@ func (r *Repository) QuitarPonente(ctx context.Context, sesionID int, usuarioID return err } +// ListPonentes returns users assigned as speakers for a session. func (r *Repository) ListPonentes(ctx context.Context, sesionID int) ([]db.UsuarioModel, error) { ponentes, err := r.prisma.SesionPonente.FindMany( db.SesionPonente.IDSesion.Equals(sesionID), @@ -130,6 +153,8 @@ func (r *Repository) ListPonentes(ctx context.Context, sesionID int) ([]db.Usuar return usuarios, nil } +// ListPonentesAsignables returns speaker candidates for a session by role, +// current assignment, and schedule availability. func (r *Repository) ListPonentesAsignables(ctx context.Context, sesionID int) ([]db.UsuarioModel, error) { sesionObjetivo, err := r.GetSesionByID(ctx, sesionID) if err != nil || sesionObjetivo == nil { @@ -192,6 +217,8 @@ func (r *Repository) ListPonentesAsignables(ctx context.Context, sesionID int) ( return disponibles, nil } +// UsuarioTieneConflictoHorario checks whether a user already has another +// non-cancelled session overlapping with the target session time window. func (r *Repository) UsuarioTieneConflictoHorario(ctx context.Context, usuarioID int, sesionID int) (bool, error) { sesionObjetivo, err := r.GetSesionByID(ctx, sesionID) @@ -222,7 +249,7 @@ func (r *Repository) UsuarioTieneConflictoHorario(ctx context.Context, usuarioID continue } - // solapa si incio < finExistente y fin > inicioExistente + // Overlap exists when targetStart < existingEnd and targetEnd > existingStart. if sesionObjetivo.FechaInicio.Before(s.FechaFin) && sesionObjetivo.FechaFin.After(s.FechaInicio) { return true, nil } @@ -231,6 +258,7 @@ func (r *Repository) UsuarioTieneConflictoHorario(ctx context.Context, usuarioID return false, nil } +// RegistrarHistorialSesion persists one audit trail entry for session changes. func (r *Repository) RegistrarHistorialSesion(ctx context.Context, sesionID int, accion string, detalle string, actor string, valoresAntes string, valoresDespues string) error { _, err := r.prisma.SesionHistorial.CreateOne( db.SesionHistorial.Accion.Set(accion), @@ -243,6 +271,7 @@ func (r *Repository) RegistrarHistorialSesion(ctx context.Context, sesionID int, return err } +// Prisma exposes the underlying Prisma client for advanced queries. func (r *Repository) Prisma() *db.PrismaClient { return r.prisma } diff --git a/backend/internal/sesiones/service/service.go b/backend/internal/sesiones/service/service.go index a5cd96e0..63dd2688 100755 --- a/backend/internal/sesiones/service/service.go +++ b/backend/internal/sesiones/service/service.go @@ -1,3 +1,16 @@ +/* +File: service.go + +Contains: +Business service implementation for the Sesiones module. +It orchestrates repository calls, applies validation rules, +and sends notifications for relevant session changes. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package service import ( @@ -19,6 +32,7 @@ type Service struct { notificationService notificationsrv.NotificationService } +// New creates a sessions service with repository and notification dependencies. func New(prismaClient *db.PrismaClient) *Service { sesionRepo := repo.NewRepository(prismaClient) notificationRepo := notificationsrepo.NewNotificationRepository(prismaClient) @@ -38,11 +52,14 @@ var ( var venezuelaLocation = time.FixedZone("VET", -4*60*60) +// formatDateTimeVE formats date/time values using UTC-4 Venezuela display rules. func formatDateTimeVE(t time.Time) string { // Normaliza a UTC y aplica UTC-4 para evitar depender de la ubicación original. return t.UTC().In(venezuelaLocation).Format("02/01/2006 15:04") } +// CreateSesion creates a new session after validating event state, date range, +// duration, unique title, and overlap constraints. func (s *Service) CreateSesion(ctx context.Context, eventoID int, req dto.CreateSesionRequest) (*dto.SesionResponse, error) { if req.Titulo == "" || req.FechaInicio == "" || req.FechaFin == "" { println("[CreateSesion] Título o fechas vacíos") @@ -114,6 +131,7 @@ func (s *Service) CreateSesion(ctx context.Context, eventoID int, req dto.Create return s.mapSesionToResponse(ctx, sesion), nil } +// ListSesiones returns active sessions for an event. func (s *Service) ListSesiones(ctx context.Context, eventoID int) ([]dto.SesionResponse, error) { sesiones, err := s.repo.ListSesiones(ctx, eventoID) if err != nil { @@ -126,6 +144,7 @@ func (s *Service) ListSesiones(ctx context.Context, eventoID int) ([]dto.SesionR return resp, nil } +// GetSesionByID returns one session by ID. func (s *Service) GetSesionByID(ctx context.Context, sesionID int) (*dto.SesionResponse, error) { sesion, err := s.repo.GetSesionByID(ctx, sesionID) if err != nil || sesion == nil { @@ -134,6 +153,8 @@ func (s *Service) GetSesionByID(ctx context.Context, sesionID int) (*dto.SesionR return s.mapSesionToResponse(ctx, sesion), nil } +// UpdateSesion updates a session after validating event/session status, +// date constraints, uniqueness, and overlap rules. func (s *Service) UpdateSesion(ctx context.Context, sesionID int, req dto.UpdateSesionRequest) (*dto.SesionResponse, error) { if req.Titulo == "" || req.FechaInicio == "" || req.FechaFin == "" { return nil, ErrInvalid @@ -215,7 +236,7 @@ func (s *Service) UpdateSesion(ctx context.Context, sesionID int, req dto.Update despues, ) - // Notificar a ponentes asignados sobre cambios en la sesión + // Notify assigned speakers about session updates. ponentesAsignados, err := s.repo.ListPonentes(ctx, sesionID) if err == nil { for _, p := range ponentesAsignados { @@ -241,6 +262,7 @@ func (s *Service) UpdateSesion(ctx context.Context, sesionID int, req dto.Update return s.mapSesionToResponse(ctx, sesion), nil } +// DeleteSesion performs logical deletion of a session. func (s *Service) DeleteSesion(ctx context.Context, sesionID int) error { err := s.repo.DeleteSesion(ctx, sesionID) if err != nil { @@ -249,6 +271,7 @@ func (s *Service) DeleteSesion(ctx context.Context, sesionID int) error { return nil } +// mapSesionToResponse converts a session model to API response format. func (svc *Service) mapSesionToResponse(ctx context.Context, sesion *db.SesionModel) *dto.SesionResponse { if sesion == nil { return nil @@ -269,6 +292,8 @@ func (svc *Service) mapSesionToResponse(ctx context.Context, sesion *db.SesionMo } } +// AsignarPonentes assigns speakers to a session after validating request data, +// event status, speaker role, and schedule conflicts. func (s *Service) AsignarPonentes(ctx context.Context, sesionID int, req dto.AsignarPonentesRequest) error { if len(req.Usuarios) == 0 { return ErrInvalid @@ -306,7 +331,7 @@ func (s *Service) AsignarPonentes(ctx context.Context, sesionID int, req dto.Asi return err } - // Validar disponibilidad por solapamiento horario + // Validate availability by schedule overlap. for _, userID := range req.Usuarios { conflicto, err := s.repo.UsuarioTieneConflictoHorario(ctx, userID, sesionID) @@ -319,12 +344,12 @@ func (s *Service) AsignarPonentes(ctx context.Context, sesionID int, req dto.Asi } } - // Persistir asignaciones + // Persist assignments. if err = s.repo.AsignarPonentes(ctx, sesionID, req.Usuarios); err != nil { return ErrDB } - // Notificar a cada ponente asignado + // Notify each assigned speaker. for _, userID := range req.Usuarios { eventID := evento.IDEvento _, notifErr := s.notificationService.CreateNotification(ctx, notificationdto.CreateNotificationRequest{ @@ -344,10 +369,11 @@ func (s *Service) AsignarPonentes(ctx context.Context, sesionID int, req dto.Asi fmt.Println("[AsignarPonentes] Error creando notificación:", notifErr) } } - + return nil } +// QuitarPonente removes one speaker assignment from a session. func (s *Service) QuitarPonente(ctx context.Context, sesionID int, usuarioID int) error { err := s.repo.QuitarPonente(ctx, sesionID, usuarioID) if err != nil { @@ -356,6 +382,7 @@ func (s *Service) QuitarPonente(ctx context.Context, sesionID int, usuarioID int return nil } +// ListPonentes returns assigned speakers for a session. func (s *Service) ListPonentes(ctx context.Context, sesionID int) ([]dto.PonenteResponse, error) { usuarios, err := s.repo.ListPonentes(ctx, sesionID) if err != nil { @@ -375,6 +402,8 @@ func (s *Service) ListPonentes(ctx context.Context, sesionID int) ([]dto.Ponente return resp, nil } +// ListPonentesAsignables returns speaker candidates that can still be assigned +// to the session. func (s *Service) ListPonentesAsignables(ctx context.Context, sesionID int) ([]dto.PonenteAsignableResponse, error) { sesion, err := s.repo.GetSesionByID(ctx, sesionID) if err != nil || sesion == nil { diff --git a/backend/internal/sesiones/validation/validation.go b/backend/internal/sesiones/validation/validation.go index 041da64a..d29d028a 100755 --- a/backend/internal/sesiones/validation/validation.go +++ b/backend/internal/sesiones/validation/validation.go @@ -1,3 +1,16 @@ +/* +File: validation.go + +Contains: +Validation rules for the Sesiones module. +It validates title uniqueness, schedule overlap, duration, +event/session state constraints, and speaker role requirements. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package sesiones import ( @@ -13,8 +26,10 @@ var ( ErrEventoIniciado = errors.New("No se puede modificar la sesión porque el evento ya comenzó") ErrSesionCancelada = errors.New("No se puede modificar una sesión cancelada") ErrPonenteRol = errors.New("El usuario no tiene el rol de Ponente") + ErrSesionFueraDeRango = errors.New("La sesión debe estar dentro del rango de fechas del evento") ) +// ValidarTituloUnico verifies that no session in the collection shares title. func ValidarTituloUnico(sesiones []db.SesionModel, titulo string) error { for _, s := range sesiones { if s.Titulo == titulo { @@ -24,6 +39,8 @@ func ValidarTituloUnico(sesiones []db.SesionModel, titulo string) error { return nil } +// ValidarSolapamiento verifies that proposed time range does not overlap +// existing sessions. func ValidarSolapamiento(sesiones []db.SesionModel, fechaInicio, fechaFin time.Time) error { for _, s := range sesiones { if (fechaInicio.Before(s.FechaFin) && fechaFin.After(s.FechaInicio)) || fechaInicio.Equal(s.FechaInicio) { @@ -33,6 +50,7 @@ func ValidarSolapamiento(sesiones []db.SesionModel, fechaInicio, fechaFin time.T return nil } +// ValidarDuracion enforces duration between 30 minutes and 4 hours. func ValidarDuracion(fechaInicio, fechaFin time.Time) error { duracion := fechaFin.Sub(fechaInicio) if duracion < 30*time.Minute || duracion > 4*time.Hour { @@ -41,6 +59,7 @@ func ValidarDuracion(fechaInicio, fechaFin time.Time) error { return nil } +// ValidarEventoNoIniciado checks whether the related event has not started. func ValidarEventoNoIniciado(evento *db.EventoModel) error { if evento == nil { return errors.New("Evento no encontrado") @@ -51,6 +70,7 @@ func ValidarEventoNoIniciado(evento *db.EventoModel) error { return nil } +// ValidarSesionNoCancelada checks whether session is active. func ValidarSesionNoCancelada(sesion *db.SesionModel) error { if sesion != nil && sesion.Cancelado { return ErrSesionCancelada @@ -58,6 +78,7 @@ func ValidarSesionNoCancelada(sesion *db.SesionModel) error { return nil } +// ValidarRolPonente validates that every user has the PONENTE role. func ValidarRolPonente(usuarios []db.UsuarioModel) error { for _, u := range usuarios { roles := u.RelationsUsuario.UsuarioRoles @@ -81,10 +102,8 @@ func ValidarRolPonente(usuarios []db.UsuarioModel) error { return nil } -var ( - ErrSesionFueraDeRango = errors.New("La sesión debe estar dentro del rango de fechas del evento") -) - +// ValidarSesionDentroDeRangoEvento verifies that session range is contained +// within event range. func ValidarSesionDentroDeRangoEvento(fechaInicio, fechaFin, eventoInicio, eventoFin time.Time) error { if fechaInicio.Before(eventoInicio) || fechaFin.After(eventoFin) { return ErrSesionFueraDeRango diff --git a/backend/internal/trabajos/dto/dto.go b/backend/internal/trabajos/dto/dto.go index fbede9d7..66a127f3 100644 --- a/backend/internal/trabajos/dto/dto.go +++ b/backend/internal/trabajos/dto/dto.go @@ -1,5 +1,19 @@ +/* +File: dto.go + +Contains: +Request and response DTO definitions for the Trabajos module. +It centralizes payload structures for submissions, versions, +committee workflows, reviewer assignment, and evaluations. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package dto +// UploadedFile represents an uploaded document payload. type UploadedFile struct { FileName string ContentType string @@ -7,6 +21,7 @@ type UploadedFile struct { Bytes []byte } +// CreateTrabajoRequest represents payload to create a scientific work. type CreateTrabajoRequest struct { IDEvento int `json:"id_evento"` IDUsuario int `json:"id_usuario"` @@ -16,12 +31,14 @@ type CreateTrabajoRequest struct { DescripcionCambios string `json:"descripcion_cambios"` } +// AddVersionRequest represents payload to upload a new work version. type AddVersionRequest struct { IDTrabajo int `json:"id_trabajo"` IDUsuario int `json:"id_usuario"` DescripcionCambios string `json:"descripcion_cambios"` } +// TrabajoResponse represents one scientific work in API responses. type TrabajoResponse struct { IDTrabajo int `json:"id_trabajo"` IDEvento int `json:"id_evento"` @@ -34,6 +51,7 @@ type TrabajoResponse struct { ArchivoActual *VersionResponse `json:"archivo_actual,omitempty"` } +// VersionResponse represents one uploaded work version. type VersionResponse struct { IDVersion int `json:"id_version"` IDTrabajo int `json:"id_trabajo"` @@ -46,6 +64,7 @@ type VersionResponse struct { FechaEnvio string `json:"fecha_envio"` } +// CompareVersionsResponse represents version comparison output. type CompareVersionsResponse struct { IDTrabajo int `json:"id_trabajo"` From VersionResponse `json:"from"` @@ -53,6 +72,7 @@ type CompareVersionsResponse struct { Resumen []string `json:"resumen"` } +// TrabajoComiteFilter represents filters for committee listing. type TrabajoComiteFilter struct { UserID int Query string @@ -61,6 +81,7 @@ type TrabajoComiteFilter struct { IDEvento int } +// TrabajoComiteItem represents one work item in committee listings. type TrabajoComiteItem struct { IDTrabajo int `json:"id_trabajo"` IDEvento int `json:"id_evento"` @@ -80,22 +101,25 @@ type TrabajoComiteItem struct { ArchivoActual *VersionResponse `json:"archivo_actual,omitempty"` } +// AssignReviewersRequest represents payload to assign reviewers. type AssignReviewersRequest struct { UserID int `json:"user_id"` IDTrabajo int `json:"id_trabajo"` Revisores []int `json:"revisores"` } +// ReviewerListItem represents one reviewer candidate. type ReviewerListItem struct { IDUsuario int `json:"id_usuario"` Nombre string `json:"nombre"` Email string `json:"email"` } +// SubmitEvaluationRequest represents payload to submit reviewer evaluation. type SubmitEvaluationRequest struct { UserID int `json:"user_id"` IDTrabajo int `json:"id_trabajo"` - Recomendacion string `json:"recomendacion"` // ACEPTAR, RECHAZAR, PENDIENTE + Recomendacion string `json:"recomendacion"` Puntaje *int `json:"puntaje"` Comentarios string `json:"comentarios"` Fortalezas string `json:"fortalezas"` @@ -103,6 +127,7 @@ type SubmitEvaluationRequest struct { Recomendaciones string `json:"recomendaciones"` } +// EvaluationItem represents one stored evaluation entry. type EvaluationItem struct { IDEvaluacion int `json:"id_evaluacion"` IDTrabajo int `json:"id_trabajo"` @@ -114,6 +139,7 @@ type EvaluationItem struct { UpdatedAt string `json:"updated_at"` } +// EvaluationSummary represents evaluation aggregate and detail list. type EvaluationSummary struct { IDTrabajo int `json:"id_trabajo"` CantidadEvaluaciones int `json:"cantidad_evaluaciones"` @@ -121,9 +147,10 @@ type EvaluationSummary struct { Evaluaciones []EvaluationItem `json:"evaluaciones"` } +// DecisionRequest represents payload for committee final decision. type DecisionRequest struct { UserID int `json:"user_id"` IDTrabajo int `json:"id_trabajo"` - DecisionComite string `json:"decision_comite"` // ACEPTADO, RECHAZADO, PENDIENTE_REVISION + DecisionComite string `json:"decision_comite"` ComentarioComite string `json:"comentario_comite"` } diff --git a/backend/internal/trabajos/handler/handler.go b/backend/internal/trabajos/handler/handler.go index 308f7dfd..4932bcdc 100644 --- a/backend/internal/trabajos/handler/handler.go +++ b/backend/internal/trabajos/handler/handler.go @@ -1,3 +1,16 @@ +/* +File: handler.go + +Contains: +HTTP endpoint layer for the Trabajos module. +It routes work/version/review endpoints, parses request input, +and maps service responses to HTTP output. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package handler import ( @@ -21,10 +34,12 @@ type Handler struct { svc *service.Service } +// New creates a trabajos HTTP handler. func New(client *db.PrismaClient) http.Handler { return &Handler{svc: service.New(client)} } +// ServeHTTP dispatches requests by method and sub-path. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/api/trabajos-cientificos") path = strings.TrimPrefix(path, "/") @@ -61,6 +76,112 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// parseCreateRequest parses multipart payload for create endpoint. +func parseCreateRequest(r *http.Request) (dto.CreateTrabajoRequest, dto.UploadedFile, error) { + var req dto.CreateTrabajoRequest + var file dto.UploadedFile + + if err := r.ParseMultipartForm(validation.MaxPDFSize + (1 << 20)); err != nil { + return req, file, err + } + + req.IDEvento, _ = strconv.Atoi(r.FormValue("id_evento")) + req.IDUsuario, _ = strconv.Atoi(r.FormValue("id_usuario")) + req.Titulo = r.FormValue("titulo") + req.Resumen = r.FormValue("resumen") + req.DeclaraNoConfidencial = strings.EqualFold(r.FormValue("declara_no_confidencial"), "true") + req.DescripcionCambios = r.FormValue("descripcion_cambios") + + src, header, err := r.FormFile("archivo") + if err != nil { + return req, file, validation.ErrArchivoRequerido + } + defer src.Close() + + if err := validation.ValidatePDFHeader(header); err != nil { + return req, file, err + } + + content, err := io.ReadAll(io.LimitReader(src, validation.MaxPDFSize+1)) + if err != nil { + return req, file, err + } + + contentType := service.DetectPDFContentType(content) + file = dto.UploadedFile{ + FileName: header.Filename, + ContentType: contentType, + Size: int64(len(content)), + Bytes: content, + } + + return req, file, nil +} + +// parseVersionRequest parses multipart payload for version upload endpoint. +func parseVersionRequest(r *http.Request) (dto.AddVersionRequest, dto.UploadedFile, error) { + var req dto.AddVersionRequest + var file dto.UploadedFile + + if err := r.ParseMultipartForm(validation.MaxPDFSize + (1 << 20)); err != nil { + return req, file, err + } + + req.IDTrabajo, _ = strconv.Atoi(r.FormValue("id_trabajo")) + req.IDUsuario, _ = strconv.Atoi(r.FormValue("id_usuario")) + req.DescripcionCambios = r.FormValue("descripcion_cambios") + + src, header, err := r.FormFile("archivo") + if err != nil { + return req, file, validation.ErrArchivoRequerido + } + defer src.Close() + + if err := validation.ValidatePDFHeader(header); err != nil { + return req, file, err + } + + content, err := io.ReadAll(io.LimitReader(src, validation.MaxPDFSize+1)) + if err != nil { + return req, file, err + } + + contentType := service.DetectPDFContentType(content) + file = dto.UploadedFile{ + FileName: header.Filename, + ContentType: contentType, + Size: int64(len(content)), + Bytes: content, + } + + return req, file, nil +} + +// parsePositiveInt parses and validates positive integer query values. +func parsePositiveInt(value string) (int, error) { + n, err := strconv.Atoi(value) + if err != nil || n <= 0 { + return 0, errors.New("valor inválido") + } + return n, nil +} + +// writeServiceError maps service errors to HTTP responses. +func writeServiceError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, service.ErrSinAcceso): + httperror.WriteJSON(w, http.StatusForbidden, err.Error()) + case errors.Is(err, service.ErrTrabajoNoExiste): + httperror.WriteJSON(w, http.StatusNotFound, err.Error()) + default: + httperror.WriteJSON(w, http.StatusBadRequest, err.Error()) + } +} + +/* +Endpoint: POST /api/trabajos-cientificos +Creates a new scientific work with initial PDF upload. +*/ func (h *Handler) createTrabajo(w http.ResponseWriter, r *http.Request) { req, file, err := parseCreateRequest(r) if err != nil { @@ -85,6 +206,10 @@ func (h *Handler) createTrabajo(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(res) } +/* +Endpoint: GET /api/trabajos-cientificos?user_id= +Lists works submitted by the user. +*/ func (h *Handler) listTrabajos(w http.ResponseWriter, r *http.Request) { userID, err := strconv.Atoi(r.URL.Query().Get("user_id")) if err != nil || userID <= 0 { @@ -105,6 +230,10 @@ func (h *Handler) listTrabajos(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(rows) } +/* +Endpoint: POST /api/trabajos-cientificos/versiones +Uploads a new version for an existing work. +*/ func (h *Handler) addVersion(w http.ResponseWriter, r *http.Request) { req, file, err := parseVersionRequest(r) if err != nil { @@ -125,6 +254,10 @@ func (h *Handler) addVersion(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(res) } +/* +Endpoint: GET /api/trabajos-cientificos/versiones +Lists all versions of a work. +*/ func (h *Handler) listVersiones(w http.ResponseWriter, r *http.Request) { trabajoID, err := strconv.Atoi(r.URL.Query().Get("id_trabajo")) if err != nil || trabajoID <= 0 { @@ -150,6 +283,10 @@ func (h *Handler) listVersiones(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(rows) } +/* +Endpoint: GET /api/trabajos-cientificos/versiones/comparar +Compares two versions of a work. +*/ func (h *Handler) compareVersiones(w http.ResponseWriter, r *http.Request) { trabajoID, err := strconv.Atoi(r.URL.Query().Get("id_trabajo")) if err != nil || trabajoID <= 0 { @@ -185,6 +322,10 @@ func (h *Handler) compareVersiones(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(res) } +/* +Endpoint: GET /api/trabajos-cientificos/archivo +Downloads one version file. +*/ func (h *Handler) downloadArchivo(w http.ResponseWriter, r *http.Request) { versionID, err := strconv.Atoi(r.URL.Query().Get("id_version")) if err != nil || versionID <= 0 { @@ -211,104 +352,10 @@ func (h *Handler) downloadArchivo(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, version.RutaArchivo) } -func parseCreateRequest(r *http.Request) (dto.CreateTrabajoRequest, dto.UploadedFile, error) { - var req dto.CreateTrabajoRequest - var file dto.UploadedFile - - if err := r.ParseMultipartForm(validation.MaxPDFSize + (1 << 20)); err != nil { - return req, file, err - } - - req.IDEvento, _ = strconv.Atoi(r.FormValue("id_evento")) - req.IDUsuario, _ = strconv.Atoi(r.FormValue("id_usuario")) - req.Titulo = r.FormValue("titulo") - req.Resumen = r.FormValue("resumen") - req.DeclaraNoConfidencial = strings.EqualFold(r.FormValue("declara_no_confidencial"), "true") - req.DescripcionCambios = r.FormValue("descripcion_cambios") - - src, header, err := r.FormFile("archivo") - if err != nil { - return req, file, validation.ErrArchivoRequerido - } - defer src.Close() - - if err := validation.ValidatePDFHeader(header); err != nil { - return req, file, err - } - - content, err := io.ReadAll(io.LimitReader(src, validation.MaxPDFSize+1)) - if err != nil { - return req, file, err - } - - contentType := service.DetectPDFContentType(content) - file = dto.UploadedFile{ - FileName: header.Filename, - ContentType: contentType, - Size: int64(len(content)), - Bytes: content, - } - - return req, file, nil -} - -func parseVersionRequest(r *http.Request) (dto.AddVersionRequest, dto.UploadedFile, error) { - var req dto.AddVersionRequest - var file dto.UploadedFile - - if err := r.ParseMultipartForm(validation.MaxPDFSize + (1 << 20)); err != nil { - return req, file, err - } - - req.IDTrabajo, _ = strconv.Atoi(r.FormValue("id_trabajo")) - req.IDUsuario, _ = strconv.Atoi(r.FormValue("id_usuario")) - req.DescripcionCambios = r.FormValue("descripcion_cambios") - - src, header, err := r.FormFile("archivo") - if err != nil { - return req, file, validation.ErrArchivoRequerido - } - defer src.Close() - - if err := validation.ValidatePDFHeader(header); err != nil { - return req, file, err - } - - content, err := io.ReadAll(io.LimitReader(src, validation.MaxPDFSize+1)) - if err != nil { - return req, file, err - } - - contentType := service.DetectPDFContentType(content) - file = dto.UploadedFile{ - FileName: header.Filename, - ContentType: contentType, - Size: int64(len(content)), - Bytes: content, - } - - return req, file, nil -} - -func parsePositiveInt(value string) (int, error) { - n, err := strconv.Atoi(value) - if err != nil || n <= 0 { - return 0, errors.New("valor inválido") - } - return n, nil -} - -func writeServiceError(w http.ResponseWriter, err error) { - switch { - case errors.Is(err, service.ErrSinAcceso): - httperror.WriteJSON(w, http.StatusForbidden, err.Error()) - case errors.Is(err, service.ErrTrabajoNoExiste): - httperror.WriteJSON(w, http.StatusNotFound, err.Error()) - default: - httperror.WriteJSON(w, http.StatusBadRequest, err.Error()) - } -} - +/* +Endpoint: GET /api/trabajos-cientificos/comite +Lists works for committee view with filters. +*/ func (h *Handler) listTrabajosComite(w http.ResponseWriter, r *http.Request) { userID, err := parsePositiveInt(r.URL.Query().Get("user_id")) if err != nil { @@ -346,6 +393,10 @@ func (h *Handler) listTrabajosComite(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(rows) } +/* +Endpoint: GET /api/trabajos-cientificos/revisores +Lists available reviewers. +*/ func (h *Handler) listRevisores(w http.ResponseWriter, r *http.Request) { userID, err := parsePositiveInt(r.URL.Query().Get("user_id")) if err != nil { @@ -366,6 +417,10 @@ func (h *Handler) listRevisores(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(rows) } +/* +Endpoint: POST /api/trabajos-cientificos/comite/asignar-revisores +Assigns reviewers to a work. +*/ func (h *Handler) assignReviewers(w http.ResponseWriter, r *http.Request) { var req dto.AssignReviewersRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -384,6 +439,10 @@ func (h *Handler) assignReviewers(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +/* +Endpoint: GET /api/trabajos-cientificos/revisor/asignados +Lists works assigned to a reviewer. +*/ func (h *Handler) listTrabajosRevisor(w http.ResponseWriter, r *http.Request) { userID, err := parsePositiveInt(r.URL.Query().Get("user_id")) if err != nil { @@ -404,6 +463,10 @@ func (h *Handler) listTrabajosRevisor(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(rows) } +/* +Endpoint: POST /api/trabajos-cientificos/revisor/evaluar +Submits reviewer evaluation for a work. +*/ func (h *Handler) submitEvaluation(w http.ResponseWriter, r *http.Request) { var req dto.SubmitEvaluationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -422,6 +485,10 @@ func (h *Handler) submitEvaluation(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +/* +Endpoint: GET /api/trabajos-cientificos/comite/evaluaciones +Returns evaluation summary for one work. +*/ func (h *Handler) listEvaluacionesByTrabajo(w http.ResponseWriter, r *http.Request) { userID, err := parsePositiveInt(r.URL.Query().Get("user_id")) if err != nil { @@ -447,6 +514,10 @@ func (h *Handler) listEvaluacionesByTrabajo(w http.ResponseWriter, r *http.Reque _ = json.NewEncoder(w).Encode(rows) } +/* +Endpoint: POST /api/trabajos-cientificos/comite/decision +Stores final committee decision for a work. +*/ func (h *Handler) decideTrabajo(w http.ResponseWriter, r *http.Request) { var req dto.DecisionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { diff --git a/backend/internal/trabajos/repo/repo.go b/backend/internal/trabajos/repo/repo.go index 94985dd6..e89fdd7a 100644 --- a/backend/internal/trabajos/repo/repo.go +++ b/backend/internal/trabajos/repo/repo.go @@ -1,3 +1,16 @@ +/* +File: repo.go + +Contains: +Persistence repository implementation for the Trabajos module. +It provides data access for works, versions, committee filters, +reviewer assignment, and evaluation records. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package repo import ( @@ -14,16 +27,20 @@ type Repository struct { client *db.PrismaClient } +// New creates a trabajos repository with a Prisma client. func New(client *db.PrismaClient) *Repository { return &Repository{client: client} } +// FindEventoByID retrieves an event by identifier. func (r *Repository) FindEventoByID(ctx context.Context, id int) (*db.EventoModel, error) { return r.client.Evento.FindUnique( db.Evento.IDEvento.Equals(id), ).Exec(ctx) } +// ExistsNormalizedTitleInEvent checks whether a normalized title already +// exists in the target event. func (r *Repository) ExistsNormalizedTitleInEvent(ctx context.Context, eventID int, normalized string) (bool, error) { row, err := r.client.TrabajoCientifico.FindFirst( db.TrabajoCientifico.IDEvento.Equals(eventID), @@ -37,6 +54,7 @@ func (r *Repository) ExistsNormalizedTitleInEvent(ctx context.Context, eventID i return row != nil, nil } +// CreateTrabajo creates a new scientific work record. func (r *Repository) CreateTrabajo(ctx context.Context, eventID, userID int, titulo, tituloNormalizado, resumen string) (*db.TrabajoCientificoModel, error) { return r.client.TrabajoCientifico.CreateOne( db.TrabajoCientifico.Titulo.Set(strings.TrimSpace(titulo)), @@ -47,12 +65,14 @@ func (r *Repository) CreateTrabajo(ctx context.Context, eventID, userID int, tit ).Exec(ctx) } +// FindTrabajoByID retrieves one work by ID. func (r *Repository) FindTrabajoByID(ctx context.Context, trabajoID int) (*db.TrabajoCientificoModel, error) { return r.client.TrabajoCientifico.FindUnique( db.TrabajoCientifico.IDTrabajo.Equals(trabajoID), ).Exec(ctx) } +// FindTrabajoByIDAndUser retrieves one work by ID scoped to an owner user. func (r *Repository) FindTrabajoByIDAndUser(ctx context.Context, trabajoID, userID int) (*db.TrabajoCientificoModel, error) { return r.client.TrabajoCientifico.FindFirst( db.TrabajoCientifico.IDTrabajo.Equals(trabajoID), @@ -60,6 +80,7 @@ func (r *Repository) FindTrabajoByIDAndUser(ctx context.Context, trabajoID, user ).Exec(ctx) } +// ListTrabajosByUser lists works submitted by a user. func (r *Repository) ListTrabajosByUser(ctx context.Context, userID int) ([]db.TrabajoCientificoModel, error) { return r.client.TrabajoCientifico.FindMany( db.TrabajoCientifico.IDUsuario.Equals(userID), @@ -68,6 +89,7 @@ func (r *Repository) ListTrabajosByUser(ctx context.Context, userID int) ([]db.T ).Exec(ctx) } +// MarkVersionsAsNotCurrent sets EsActual=false for currently active versions. func (r *Repository) MarkVersionsAsNotCurrent(ctx context.Context, trabajoID int) error { rows, err := r.client.TrabajoCientificoVersion.FindMany( db.TrabajoCientificoVersion.IDTrabajo.Equals(trabajoID), @@ -90,6 +112,7 @@ func (r *Repository) MarkVersionsAsNotCurrent(ctx context.Context, trabajoID int return nil } +// CreateVersion creates a new version row for a work. func (r *Repository) CreateVersion( ctx context.Context, trabajoID, numeroVersion int, @@ -109,6 +132,7 @@ func (r *Repository) CreateVersion( ).Exec(ctx) } +// UpdateTrabajoVersionActual updates version metadata in the work record. func (r *Repository) UpdateTrabajoVersionActual(ctx context.Context, trabajoID, version int) error { _, err := r.client.TrabajoCientifico.FindUnique( db.TrabajoCientifico.IDTrabajo.Equals(trabajoID), @@ -119,6 +143,7 @@ func (r *Repository) UpdateTrabajoVersionActual(ctx context.Context, trabajoID, return err } +// ListVersionesByTrabajo lists versions for one work. func (r *Repository) ListVersionesByTrabajo(ctx context.Context, trabajoID int) ([]db.TrabajoCientificoVersionModel, error) { return r.client.TrabajoCientificoVersion.FindMany( db.TrabajoCientificoVersion.IDTrabajo.Equals(trabajoID), @@ -127,6 +152,7 @@ func (r *Repository) ListVersionesByTrabajo(ctx context.Context, trabajoID int) ).Exec(ctx) } +// FindVersionByTrabajoAndNumber retrieves a specific version by number. func (r *Repository) FindVersionByTrabajoAndNumber(ctx context.Context, trabajoID, numeroVersion int) (*db.TrabajoCientificoVersionModel, error) { return r.client.TrabajoCientificoVersion.FindFirst( db.TrabajoCientificoVersion.IDTrabajo.Equals(trabajoID), @@ -134,6 +160,7 @@ func (r *Repository) FindVersionByTrabajoAndNumber(ctx context.Context, trabajoI ).Exec(ctx) } +// FindCurrentVersion retrieves current version for a work. func (r *Repository) FindCurrentVersion(ctx context.Context, trabajoID int) (*db.TrabajoCientificoVersionModel, error) { return r.client.TrabajoCientificoVersion.FindFirst( db.TrabajoCientificoVersion.IDTrabajo.Equals(trabajoID), @@ -141,12 +168,14 @@ func (r *Repository) FindCurrentVersion(ctx context.Context, trabajoID int) (*db ).Exec(ctx) } +// FindVersionByID retrieves one version by ID. func (r *Repository) FindVersionByID(ctx context.Context, versionID int) (*db.TrabajoCientificoVersionModel, error) { return r.client.TrabajoCientificoVersion.FindUnique( db.TrabajoCientificoVersion.IDVersion.Equals(versionID), ).Exec(ctx) } +// FindCommitteeUsers lists users that belong to scientific committee role. func (r *Repository) FindCommitteeUsers(ctx context.Context) ([]db.UsuarioModel, error) { roleIDs := make([]int, 0, 2) @@ -185,6 +214,7 @@ func (r *Repository) FindCommitteeUsers(ctx context.Context) ([]db.UsuarioModel, return users, nil } +// UserHasRole checks whether a user has a specific role name. func (r *Repository) UserHasRole(ctx context.Context, userID int, roleName string) (bool, error) { roleName = strings.TrimSpace(roleName) if roleName == "" { @@ -216,6 +246,7 @@ func (r *Repository) UserHasRole(ctx context.Context, userID int, roleName strin return false, nil } +// ListTrabajosComite lists works for committee view using filter fields. func (r *Repository) ListTrabajosComite(ctx context.Context, f dto.TrabajoComiteFilter) ([]db.TrabajoCientificoModel, error) { var ( rows []db.TrabajoCientificoModel @@ -282,6 +313,7 @@ func (r *Repository) ListTrabajosComite(ctx context.Context, f dto.TrabajoComite return filtered, nil } +// ListRevisores lists users with reviewer role. func (r *Repository) ListRevisores(ctx context.Context) ([]db.UsuarioModel, error) { role, err := r.client.Roles.FindFirst( db.Roles.NombreRol.Equals("REVISOR"), @@ -327,6 +359,7 @@ func (r *Repository) ListRevisores(ctx context.Context) ([]db.UsuarioModel, erro return revisores, nil } +// AssignReviewer assigns one reviewer to a work when missing. func (r *Repository) AssignReviewer(ctx context.Context, trabajoID, revisorID, asignadorID int) error { existing, err := r.client.TrabajoRevisionAsignacion.FindFirst( db.TrabajoRevisionAsignacion.IDTrabajo.Equals(trabajoID), @@ -356,6 +389,7 @@ func (r *Repository) AssignReviewer(ctx context.Context, trabajoID, revisorID, a return err } +// ListTrabajosAsignadosRevisor lists works assigned to a reviewer. func (r *Repository) ListTrabajosAsignadosRevisor(ctx context.Context, userID int) ([]db.TrabajoCientificoModel, error) { asignaciones, err := r.client.TrabajoRevisionAsignacion.FindMany( db.TrabajoRevisionAsignacion.IDRevisor.Equals(userID), @@ -404,6 +438,7 @@ func (r *Repository) ListTrabajosAsignadosRevisor(ctx context.Context, userID in return works, nil } +// UpsertEvaluacion creates or updates reviewer evaluation for a work. func (r *Repository) UpsertEvaluacion(ctx context.Context, req dto.SubmitEvaluationRequest) error { recomendacion := strings.ToUpper(strings.TrimSpace(req.Recomendacion)) if recomendacion == "" { @@ -459,6 +494,7 @@ func (r *Repository) UpsertEvaluacion(ctx context.Context, req dto.SubmitEvaluat return err } +// ListEvaluacionesByTrabajo lists evaluation rows for one work. func (r *Repository) ListEvaluacionesByTrabajo(ctx context.Context, trabajoID int) ([]db.TrabajoEvaluacionModel, error) { return r.client.TrabajoEvaluacion.FindMany( db.TrabajoEvaluacion.IDTrabajo.Equals(trabajoID), @@ -467,6 +503,7 @@ func (r *Repository) ListEvaluacionesByTrabajo(ctx context.Context, trabajoID in ).Exec(ctx) } +// UpdateDecisionComite updates final committee decision fields. func (r *Repository) UpdateDecisionComite(ctx context.Context, req dto.DecisionRequest) error { decision := strings.ToUpper(strings.TrimSpace(req.DecisionComite)) if decision == "" { @@ -487,12 +524,14 @@ func (r *Repository) UpdateDecisionComite(ctx context.Context, req dto.DecisionR return err } +// FindUserByID retrieves one user by ID. func (r *Repository) FindUserByID(ctx context.Context, userID int) (*db.UsuarioModel, error) { return r.client.Usuario.FindUnique( db.Usuario.IDUsuario.Equals(userID), ).Exec(ctx) } +// FindAuthorAffiliation retrieves author affiliation for one event registration. func (r *Repository) FindAuthorAffiliation(ctx context.Context, eventID, userID int) (string, error) { row, err := r.client.Inscripcion.FindFirst( db.Inscripcion.IDEvento.Equals(eventID), @@ -513,6 +552,7 @@ func (r *Repository) FindAuthorAffiliation(ctx context.Context, eventID, userID return strings.TrimSpace(row.Afiliacion), nil } +// IsReviewerAssigned checks if a reviewer is assigned to the work. func (r *Repository) IsReviewerAssigned(ctx context.Context, trabajoID, reviewerID int) (bool, error) { row, err := r.client.TrabajoRevisionAsignacion.FindFirst( db.TrabajoRevisionAsignacion.IDTrabajo.Equals(trabajoID), diff --git a/backend/internal/trabajos/service/service.go b/backend/internal/trabajos/service/service.go index a0db8f5e..9e87f387 100644 --- a/backend/internal/trabajos/service/service.go +++ b/backend/internal/trabajos/service/service.go @@ -1,3 +1,16 @@ +/* +File: service.go + +Contains: +Business service implementation for the Trabajos module. +It orchestrates repository calls, applies validation, +manages versions, and coordinates committee/reviewer workflows. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package service import ( @@ -33,6 +46,7 @@ var ( var venezuelaLocation = time.FixedZone("VET", -4*60*60) +// New creates a trabajos service with repository and notification dependencies. func New(client *db.PrismaClient) *Service { workRepo := repo.New(client) notificationRepo := notificationsrepo.NewNotificationRepository(client) @@ -44,10 +58,12 @@ func New(client *db.PrismaClient) *Service { } } +// formatDateTimeVE formats timestamps for user-facing responses. func formatDateTimeVE(t time.Time) string { return t.UTC().In(venezuelaLocation).Format("02/01/2006 15:04") } +// nullableDescripcion extracts optional change description. func nullableDescripcion(value db.TrabajoCientificoVersionModel) string { if v, ok := value.DescripcionCambios(); ok { return v @@ -55,6 +71,7 @@ func nullableDescripcion(value db.TrabajoCientificoVersionModel) string { return "" } +// CreateTrabajo validates and creates a new scientific work with initial PDF. func (s *Service) CreateTrabajo(ctx context.Context, req dto.CreateTrabajoRequest, file dto.UploadedFile) (*dto.TrabajoResponse, error) { if err := validation.ValidateTitulo(req.Titulo); err != nil { return nil, err @@ -148,6 +165,7 @@ func (s *Service) CreateTrabajo(ctx context.Context, req dto.CreateTrabajoReques }, nil } +// ListTrabajosByUser returns works submitted by one user. func (s *Service) ListTrabajosByUser(ctx context.Context, userID int) ([]dto.TrabajoResponse, error) { rows, err := s.repo.ListTrabajosByUser(ctx, userID) if err != nil { @@ -185,6 +203,7 @@ func (s *Service) ListTrabajosByUser(ctx context.Context, userID int) ([]dto.Tra return out, nil } +// AddVersion validates and stores a new version for an owned work. func (s *Service) AddVersion(ctx context.Context, req dto.AddVersionRequest, file dto.UploadedFile) (*dto.VersionResponse, error) { if err := validation.ValidateDescripcionCambios(req.DescripcionCambios); err != nil { return nil, err @@ -254,6 +273,7 @@ func (s *Service) AddVersion(ctx context.Context, req dto.AddVersionRequest, fil }, nil } +// ListVersiones lists versions of a work when requester has access. func (s *Service) ListVersiones(ctx context.Context, trabajoID, userID int) ([]dto.VersionResponse, error) { trabajo, err := s.repo.FindTrabajoByIDAndUser(ctx, trabajoID, userID) if err != nil || trabajo == nil { @@ -282,6 +302,7 @@ func (s *Service) ListVersiones(ctx context.Context, trabajoID, userID int) ([]d return out, nil } +// CompareVersiones builds a textual comparison summary between two versions. func (s *Service) CompareVersiones(ctx context.Context, trabajoID, userID, from, to int) (*dto.CompareVersionsResponse, error) { trabajo, err := s.repo.FindTrabajoByIDAndUser(ctx, trabajoID, userID) if err != nil || trabajo == nil { @@ -335,6 +356,7 @@ func (s *Service) CompareVersiones(ctx context.Context, trabajoID, userID, from, }, nil } +// GetVersionFile validates access and returns version file metadata. func (s *Service) GetVersionFile(ctx context.Context, versionID, userID int) (*db.TrabajoCientificoVersionModel, error) { version, err := s.repo.FindVersionByID(ctx, versionID) if err != nil || version == nil { @@ -369,6 +391,7 @@ func (s *Service) GetVersionFile(ctx context.Context, versionID, userID int) (*d return version, nil } +// savePDF writes one PDF version to storage path. func savePDF(trabajoID, eventID, userID, version int, file dto.UploadedFile) (string, error) { baseDir := filepath.Join("..", "uploads", "trabajos-cientificos", fmt.Sprintf("evento_%d", eventID), fmt.Sprintf("usuario_%d", userID), fmt.Sprintf("trabajo_%d", trabajoID)) if err := os.MkdirAll(baseDir, 0o755); err != nil { @@ -383,6 +406,7 @@ func savePDF(trabajoID, eventID, userID, version int, file dto.UploadedFile) (st return fullPath, nil } +// DetectPDFContentType detects MIME type from content bytes. func DetectPDFContentType(content []byte) string { sample := content if len(sample) > 512 { @@ -392,6 +416,7 @@ func DetectPDFContentType(content []byte) string { return http.DetectContentType(sample) } +// normalizeDecisionComite normalizes committee decision values. func normalizeDecisionComite(value string) string { v := strings.ToUpper(strings.TrimSpace(value)) v = strings.ReplaceAll(v, "_", " ") @@ -410,6 +435,7 @@ func normalizeDecisionComite(value string) string { } } +// normalizeRecomendacion normalizes reviewer recommendation values. func normalizeRecomendacion(value string) string { v := strings.ToUpper(strings.TrimSpace(value)) v = strings.ReplaceAll(v, "_", " ") @@ -511,6 +537,7 @@ func buildEvaluationItems(ctx context.Context, s *Service, rows []db.TrabajoEval return out } +// hasRoleOrAdmin verifies whether a user is admin or has a specific role. func (s *Service) hasRoleOrAdmin(ctx context.Context, userID int, roleName string) (bool, error) { hasRole, err := s.repo.UserHasRole(ctx, userID, roleName) if err != nil { @@ -519,6 +546,7 @@ func (s *Service) hasRoleOrAdmin(ctx context.Context, userID int, roleName strin if hasRole { return true, nil } + // ListTrabajosComite returns committee view rows with computed aggregates. isAdmin, err := s.repo.UserHasRole(ctx, userID, "ADMIN") if err != nil { @@ -590,6 +618,7 @@ func (s *Service) ListTrabajosComite(ctx context.Context, f dto.TrabajoComiteFil return out, nil } +// ListRevisores returns reviewer candidates when requester has committee access. func (s *Service) ListRevisores(ctx context.Context, userID int) ([]dto.ReviewerListItem, error) { hasRole, err := s.hasRoleOrAdmin(ctx, userID, "COMITE CIENTIFICO") if err != nil { @@ -616,6 +645,7 @@ func (s *Service) ListRevisores(ctx context.Context, userID int) ([]dto.Reviewer return out, nil } +// AssignReviewers assigns reviewers to a work after role and ownership checks. func (s *Service) AssignReviewers(ctx context.Context, req dto.AssignReviewersRequest) error { if req.UserID <= 0 || req.IDTrabajo <= 0 || len(req.Revisores) == 0 { return errors.New("datos de asignacion invalidos") @@ -667,6 +697,7 @@ func (s *Service) AssignReviewers(ctx context.Context, req dto.AssignReviewersRe return nil } +// ListTrabajosAsignadosRevisor returns works assigned to one reviewer. func (s *Service) ListTrabajosAsignadosRevisor(ctx context.Context, userID int) ([]dto.TrabajoComiteItem, error) { hasRole, err := s.repo.UserHasRole(ctx, userID, "REVISOR") if err != nil { @@ -736,6 +767,7 @@ func (s *Service) ListTrabajosAsignadosRevisor(ctx context.Context, userID int) return out, nil } +// SubmitEvaluation stores reviewer evaluation for an assigned work. func (s *Service) SubmitEvaluation(ctx context.Context, req dto.SubmitEvaluationRequest) error { if req.UserID <= 0 || req.IDTrabajo <= 0 { return errors.New("datos de evaluacion invalidos") @@ -808,6 +840,7 @@ func (s *Service) SubmitEvaluation(ctx context.Context, req dto.SubmitEvaluation return nil } +// ListEvaluacionesByTrabajo returns evaluation summary for committee members. func (s *Service) ListEvaluacionesByTrabajo(ctx context.Context, userID, trabajoID int) (*dto.EvaluationSummary, error) { hasRole, err := s.hasRoleOrAdmin(ctx, userID, "COMITE CIENTIFICO") if err != nil { @@ -818,6 +851,7 @@ func (s *Service) ListEvaluacionesByTrabajo(ctx context.Context, userID, trabajo } trabajo, err := s.repo.FindTrabajoByID(ctx, trabajoID) +// DecideTrabajo saves committee final decision and notifies relevant users. if err != nil || trabajo == nil { return nil, ErrTrabajoNoExiste } diff --git a/backend/internal/trabajos/validation/validation.go b/backend/internal/trabajos/validation/validation.go index 261e8475..d0fb9f97 100644 --- a/backend/internal/trabajos/validation/validation.go +++ b/backend/internal/trabajos/validation/validation.go @@ -1,3 +1,16 @@ +/* +File: validation.go + +Contains: +Validation rules for the Trabajos module. +It validates title and abstract constraints, PDF file checks, +and version change description requirements. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + package validation import ( @@ -22,11 +35,13 @@ var ( var tituloPattern = regexp.MustCompile(`^[A-Za-zÁÉÍÓÚáéíóúÑñÜü ]+$`) +// NormalizeTitle trims, collapses spaces, and lowercases title text. func NormalizeTitle(value string) string { clean := strings.Join(strings.Fields(strings.TrimSpace(value)), " ") return strings.ToLower(clean) } +// ValidateTitulo validates title length and allowed characters. func ValidateTitulo(value string) error { clean := strings.Join(strings.Fields(strings.TrimSpace(value)), " ") length := utf8.RuneCountInString(clean) @@ -42,6 +57,7 @@ func ValidateTitulo(value string) error { return nil } +// ValidateResumen validates abstract word count and declaration flag. func ValidateResumen(value string, declarado bool) error { words := len(strings.Fields(strings.TrimSpace(value))) @@ -56,6 +72,7 @@ func ValidateResumen(value string, declarado bool) error { return nil } +// ValidatePDFHeader validates multipart metadata for uploaded PDF files. func ValidatePDFHeader(header *multipart.FileHeader) error { if header == nil { return ErrArchivoRequerido @@ -73,6 +90,7 @@ func ValidatePDFHeader(header *multipart.FileHeader) error { return nil } +// ValidatePDFContent validates detected file MIME type and size. func ValidatePDFContent(contentType string, size int64) error { if size == 0 { return ErrArchivoRequerido @@ -89,6 +107,7 @@ func ValidatePDFContent(contentType string, size int64) error { return nil } +// ValidateDescripcionCambios validates version change description length. func ValidateDescripcionCambios(value string) error { clean := strings.TrimSpace(value) diff --git a/frontend/cypress/e2e/aPruebaRegistro.cy.js b/frontend/cypress/e2e/aPruebaRegistro.cy.js index 01008b29..04793df3 100755 --- a/frontend/cypress/e2e/aPruebaRegistro.cy.js +++ b/frontend/cypress/e2e/aPruebaRegistro.cy.js @@ -86,7 +86,7 @@ describe("Prueba de Registro y Salida", () => { cy.wait("@login"); // 5. Verificar que estamos en el Home - cy.contains("Bienvenido al Home", { timeout: 15000 }).should("be.visible"); + cy.contains("Bienvenido al Sistema", { timeout: 15000 }).should("be.visible"); cy.url().should("eq", Cypress.config().baseUrl + "/"); // 6. CERRAR SESIÓN (Paso final) diff --git a/frontend/cypress/e2e/bPruebaLogin.cy.js b/frontend/cypress/e2e/bPruebaLogin.cy.js index 6484489b..7636aa03 100755 --- a/frontend/cypress/e2e/bPruebaLogin.cy.js +++ b/frontend/cypress/e2e/bPruebaLogin.cy.js @@ -46,7 +46,7 @@ describe('Prueba de Inicio de Sesión', () => { cy.url().should('eq', Cypress.config().baseUrl + '/'); // Confirmamos que vemos el mensaje de bienvenida - cy.contains('Bienvenido al Home').should('be.visible'); + cy.contains('Bienvenido al Sistema').should('be.visible'); // Verificamos que el botón de "Cerrar sesión" esté presente (lo que indica que estamos logueados) cy.get('button').contains('Cerrar sesión').should('be.visible'); diff --git a/frontend/cypress/e2e/scientific_work_validation.cy.ts b/frontend/cypress/e2e/scientific_work_validation.cy.ts index 8b46bae7..cfea494d 100644 --- a/frontend/cypress/e2e/scientific_work_validation.cy.ts +++ b/frontend/cypress/e2e/scientific_work_validation.cy.ts @@ -52,7 +52,11 @@ describe("Scientific works validation", () => { cy.contains("button", "Adjuntar trabajo").click(); cy.contains("Enviar trabajo científico").should("be.visible"); - cy.get("select").select("3"); + cy.contains("label", "Evento") + .parent() + .find(".react-select__control") + .click(); + cy.contains(".react-select__option", "Congreso de Salud").click(); cy.get('input[placeholder="Solo letras y espacios"]').type("Trabajo 2027"); cy.get('textarea[placeholder="Describe objetivos, metodología, resultados y conclusiones."]').type(summary, { parseSpecialCharSequences: false, diff --git a/frontend/cypress/e2e/trabajos_module.cy.ts b/frontend/cypress/e2e/trabajos_module.cy.ts index 52a74fa6..8150dbce 100644 --- a/frontend/cypress/e2e/trabajos_module.cy.ts +++ b/frontend/cypress/e2e/trabajos_module.cy.ts @@ -100,8 +100,17 @@ describe("Trabajos module", () => { cy.wait("@getVersions"); cy.contains("Historial de versiones").should("be.visible"); - cy.get('select').eq(0).select('1'); - cy.get('select').eq(1).select('2'); + cy.contains("label", "Desde") + .parent() + .find(".react-select__control") + .click(); + cy.contains(".react-select__option", "Versión 1").click(); + + cy.contains("label", "Hasta") + .parent() + .find(".react-select__control") + .click(); + cy.contains(".react-select__option", "Versión 2").click(); cy.contains("button", "Comparar").click(); cy.wait("@compareVersions"); diff --git a/frontend/src/components/events/AddSessionButton.tsx b/frontend/src/components/events/AddSessionButton.tsx index 777ceb23..3db2c60d 100755 --- a/frontend/src/components/events/AddSessionButton.tsx +++ b/frontend/src/components/events/AddSessionButton.tsx @@ -1,13 +1,27 @@ +/* +File: AddSessionButton.tsx + +Contains: +Reusable action button component to open the session creation flow. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import React from 'react'; import Button from '../ui/Button'; import { Plus } from 'react-feather'; +// AddSessionButtonProps defines behavior and visual state for the action button. interface AddSessionButtonProps { onClick: (e: React.MouseEvent) => void; className?: string; disabled?: boolean; } +// AddSessionButton renders the compact action button used to open +// the create-session flow from event cards. const AddSessionButton: React.FC = ({ onClick, className = '', disabled = false }) => ( diff --git a/frontend/src/components/events/EventCreateModal.test.tsx b/frontend/src/components/events/EventCreateModal.test.tsx index b11d16c3..893eb319 100644 --- a/frontend/src/components/events/EventCreateModal.test.tsx +++ b/frontend/src/components/events/EventCreateModal.test.tsx @@ -1,11 +1,21 @@ +/* +File: EventCreateModal.test.tsx + +Contains: +Unit tests for event creation modal behavior and payload generation. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import EventCreateModal from "./EventCreateModal"; +// Service and UI context mocks. const mockCreateEvent = jest.fn(); const mockFetchFechasOcupadas = jest.fn(); const mockShowToast = jest.fn(); -const mockShowLoader = jest.fn(); -const mockHideLoader = jest.fn(); jest.mock("../../services/eventsServices", () => ({ createEvent: (...args: unknown[]) => mockCreateEvent(...args), @@ -16,10 +26,6 @@ jest.mock("../../contexts/Toast/ToastContext", () => ({ useToast: () => ({ showToast: mockShowToast }), })); -jest.mock("../../contexts/Loader/LoaderContext", () => ({ - useLoader: () => ({ showLoader: mockShowLoader, hideLoader: mockHideLoader }), -})); - jest.mock("../ui/DateRangePicker", () => ({ __esModule: true, default: ({ onChange }: { onChange: (range: { from: Date; to: Date }) => void }) => ( @@ -47,6 +53,7 @@ jest.mock("../ui/SelectorInput", () => ({ ), })); +// EventCreateModal tests focus on payload generation and happy-path flow. describe("EventCreateModal", () => { beforeEach(() => { jest.clearAllMocks(); @@ -90,7 +97,5 @@ describe("EventCreateModal", () => { }), ); expect(onClose).toHaveBeenCalled(); - expect(mockShowLoader).toHaveBeenCalled(); - expect(mockHideLoader).toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/frontend/src/components/events/EventCreateModal.tsx b/frontend/src/components/events/EventCreateModal.tsx index b369ba38..9f1a0087 100755 --- a/frontend/src/components/events/EventCreateModal.tsx +++ b/frontend/src/components/events/EventCreateModal.tsx @@ -1,9 +1,18 @@ +/* +File: EventCreateModal.tsx + +Contains: +Modal component to create events with date range, location, and close date. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import { useState, useEffect } from "react"; import { DateRange } from "react-day-picker"; import DayPickerSingle from "../ui/DayPickerSingle"; import BackArrow from "../ui/BackArrow"; -// contexts -import { useLoader } from "../../contexts/Loader/LoaderContext"; import { useToast } from "../../contexts/Toast/ToastContext"; // components import Modal from "../ui/Modal"; @@ -16,14 +25,17 @@ import { createEvent, fetchFechasOcupadas, RangoFechasApi } from "../../services // constants import { venezuelaCities } from "../../constants/venezuelaCities"; - +// EventCreateModalProps controls visibility and close behavior. interface EventCreateModalProps { open: boolean; onClose: () => void; } +// EventCreateModal handles a two-step event creation flow: +// 1) core event data and range selection +// 2) registration close date confirmation and submit export default function EventCreateModal({ open, onClose }: EventCreateModalProps): JSX.Element { - // states + // Form state. const [name, setName] = useState(""); const [dateRange, setDateRange] = useState(undefined); const [country, setCountry] = useState(""); @@ -31,12 +43,11 @@ export default function EventCreateModal({ open, onClose }: EventCreateModalProp const [page2, setPage2] = useState(false); const [closeDate, setCloseDate] = useState(undefined); const [disabledRanges, setDisabledRanges] = useState<{ from: Date; to: Date }[]>([]); + const [creatingEvent, setCreatingEvent] = useState(false); - // contexts - const { showLoader, hideLoader } = useLoader(); const { showToast } = useToast(); - // effect to fetch occupied date ranges when modal opens + // Load occupied ranges when modal opens to prevent overlaps. useEffect(() => { if (open) { fetchFechasOcupadas().then(({ status, data }) => { @@ -58,8 +69,7 @@ export default function EventCreateModal({ open, onClose }: EventCreateModalProp } }, [open]); - - // clean up and close modal + // Reset local state and close modal. const handleClose = () => { setName(""); setDateRange(undefined); @@ -70,7 +80,7 @@ export default function EventCreateModal({ open, onClose }: EventCreateModalProp onClose(); }; - // helper to format date + // formatDateWithTime returns API-compatible date string. function formatDateWithTime(d: Date, hour: number, minute: number): string { const date = new Date(d); // Set hora en UTC @@ -83,7 +93,7 @@ export default function EventCreateModal({ open, onClose }: EventCreateModalProp return `${pad(date.getDate())}/${pad(date.getMonth() + 1)}/${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:00`; } - //function to parse date string from API + // parseDate converts API date strings into Date objects for UI controls. function parseDate(dateStr: string): Date | undefined { if (!dateStr) return undefined; // Soporta formato DD/MM/YYYY HH:mm:ss @@ -99,7 +109,7 @@ export default function EventCreateModal({ open, onClose }: EventCreateModalProp return new Date(year, month - 1, day, hours, minutes, seconds); } - // function to form the payload + // getEventPayload composes validated request payload for createEvent API. function getEventPayload() { if (!name || !dateRange || !dateRange.from || !dateRange.to || !country || !city || !closeDate) { return null; @@ -113,10 +123,10 @@ export default function EventCreateModal({ open, onClose }: EventCreateModalProp }; } - // submit handler + // handleSubmit validates and sends create event request. const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - showLoader(); + setCreatingEvent(true); try { const payload = getEventPayload(); if (!payload) { @@ -156,7 +166,7 @@ export default function EventCreateModal({ open, onClose }: EventCreateModalProp status: "error", }); } finally { - hideLoader(); + setCreatingEvent(false); } }; @@ -280,6 +290,8 @@ export default function EventCreateModal({ open, onClose }: EventCreateModalProp disabled={ !closeDate || !name || !dateRange || !dateRange.from || !dateRange.to || !country || !city } + loading={creatingEvent} + loadingText="Creando..." > Crear evento diff --git a/frontend/src/components/events/EventDetailModal.tsx b/frontend/src/components/events/EventDetailModal.tsx index 332dd3bc..ef32368d 100755 --- a/frontend/src/components/events/EventDetailModal.tsx +++ b/frontend/src/components/events/EventDetailModal.tsx @@ -1,8 +1,20 @@ +/* +File: EventDetailModal.tsx + +Contains: +Modal component that displays event details, sessions, and enrollment controls. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import { useEffect, useState } from "react"; // contexts import { useToast } from "../../contexts/Toast/ToastContext"; import { useAuth } from "../../contexts/Auth/Authcontext"; import { useModal } from "../../contexts/Modal/ModalContext"; +import { useLoader } from "../../contexts/Loader/LoaderContext"; // components import DateRangePicker from "../../components/ui/DateRangePicker"; import Input from "../../components/ui/Input"; @@ -12,7 +24,6 @@ import Modal from "../../components/ui/Modal"; import UpdateCloseDateModal from "./UpdateCloseDateModal"; import ToggleIconButton from "../../components/ui/ToggleIconButton"; import ConfirmModal from "../../components/ui/ConfirmModal"; -import Loader from "../../components/ui/Loader"; import Button from "../../components/ui/Button"; import { RESOURCE_KEYS } from "../../constants/resources"; import { hasResourceAccess } from "../../utils/accessControl"; @@ -24,6 +35,7 @@ import SessionsCalendar from "../sessions/SessionsCalendar"; // interfaces import { Evento } from "../../services/eventsServices"; +// EventDetailModalProps controls modal visibility, source event, and optional actions. interface EventDetailModalProps { open: boolean; onClose: () => void; @@ -31,29 +43,35 @@ interface EventDetailModalProps { onUpdate?: () => void; showInscribirButton?: boolean; onInscribir?: () => void; + requireModalContext?: boolean; + showManagementActions?: boolean; } +// EventDetailModal renders event details, session calendar/list, and +// management actions for inscriptions and editing. export default function EventDetailModal({ open, onClose, event, onUpdate, showInscribirButton, - onInscribir + onInscribir, + requireModalContext = true, + showManagementActions = true, }: EventDetailModalProps): JSX.Element | null { - // modal context + // Modal context for cross-component modal state. const { state: modalState, dispatch: modalDispatch } = useModal(); - // states + // Local UI state. const [showCloseDateModal, setShowCloseDateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showConfirmModal, setShowConfirmModal] = useState<"abrir"|"cerrar"|null>(null); const [loadingConfirm, setLoadingConfirm] = useState(false); - const [loading, setLoading] = useState(false); const [eventDetail, setEventDetail] = useState(null); const [sessions, setSessions] = useState([]); - // contexts + // Shared contexts. const { showToast } = useToast(); + const { showLoader, hideLoader } = useLoader(); const [canManageEvents, setCanManageEvents] = useState(false); useEffect(() => { @@ -70,7 +88,7 @@ export default function EventDetailModal({ }; }, []); - // helper to parse date string from API + // parseDate converts API date strings into Date objects for UI rendering. function parseDate(dateStr: string): Date | undefined { if (!dateStr) return undefined; // Soporta formato DD/MM/YYYY HH:mm:ss @@ -87,12 +105,14 @@ export default function EventDetailModal({ return new Date(year, month - 1, day, hours, minutes, seconds); } - // Fetch event detail when modal opens + // Fetch full event detail when the modal is opened. useEffect(() => { + let isMounted = true; if (open && event?.id_evento) { - setLoading(true); + showLoader(); getEventDetail(event.id_evento) .then(({ status, data }) => { + if (!isMounted) return; if (status === 200 && data) { setEventDetail(data); setSessions(data.sesiones || []); @@ -108,6 +128,7 @@ export default function EventDetailModal({ } }) .catch(() => { + if (!isMounted) return; showToast({ title: "Error", message: "No se pudo cargar el detalle del evento.", @@ -117,15 +138,21 @@ export default function EventDetailModal({ setSessions([]); onClose(); }) - .finally(() => setLoading(false)); + .finally(() => { + if (isMounted) hideLoader(); + }); } else { setEventDetail(null); setSessions([]); } + + return () => { + isMounted = false; + hideLoader(); + }; }, [open, event]); - // prepare date range for the calendar - // Corrige visualización: si la hora de fin es antes de las 6am, muestra el día anterior + // adjustEndDate avoids off-by-one rendering when end time is near midnight. function adjustEndDate(date: Date | undefined): Date | undefined { if (!date) return undefined; if (date.getHours() < 6) { @@ -136,12 +163,14 @@ export default function EventDetailModal({ } return date; } + + // Calendar date range used by DateRangePicker. const dateRange = event ? { from: parseDate(event.fecha_inicio), to: adjustEndDate(parseDate(event.fecha_fin)) } : undefined; - // effect to show error if no event is provided when modal is opened + // Guard against opening modal without event payload. useEffect(() => { if (open && !event) { showToast({ @@ -153,7 +182,7 @@ export default function EventDetailModal({ } }, [open, event, showToast, onClose]); - // PATCH open/close inscriptions + // Toggle inscription state using PATCH endpoint. async function handleToggleInscripciones() { if (!event) return; setLoadingConfirm(true); @@ -188,23 +217,16 @@ export default function EventDetailModal({ } } - // Only render modal if allowed by context - if (!open || modalState.openModal !== "EVENT_DETAIL") return null; + // Render guards. + if (!open || (requireModalContext && modalState.openModal !== "EVENT_DETAIL")) return null; if (!event) return null; - if (loading) { - return ( - -
- -
-
- ); - } if (!eventDetail) return null; - // Handler to close modal via context and parent + // Close through modal context and parent callback. const handleClose = () => { - modalDispatch({ type: 'CLOSE_MODAL' }); + if (requireModalContext) { + modalDispatch({ type: 'CLOSE_MODAL' }); + } onClose(); }; @@ -219,7 +241,7 @@ export default function EventDetailModal({
- {eventDetail.inscripciones_abiertas ? ( + {showManagementActions && canManageEvents && eventDetail.inscripciones_abiertas ? ( <> Inscripciones abiertas @@ -237,7 +259,7 @@ export default function EventDetailModal({ color="#94a3b8" /> - ) : ( + ) : showManagementActions && canManageEvents ? ( <> Inscripciones cerradas @@ -255,6 +277,12 @@ export default function EventDetailModal({ color="#94a3b8" /> + ) : ( + <> + + {eventDetail.inscripciones_abiertas ? "Inscripciones abiertas" : "Inscripciones cerradas"} + + )}
@@ -283,14 +311,6 @@ export default function EventDetailModal({ className="w-full" />
- {showInscribirButton && ( - - )}
-
+
+ {showInscribirButton && ( +
+ +
+ )}
diff --git a/frontend/src/components/events/EventFilters.tsx b/frontend/src/components/events/EventFilters.tsx index de03c841..2adb07b2 100644 --- a/frontend/src/components/events/EventFilters.tsx +++ b/frontend/src/components/events/EventFilters.tsx @@ -1,7 +1,19 @@ +/* +File: EventFilters.tsx + +Contains: +Filter panel component for searching and filtering events. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import Input from "../ui/Input"; import SelectInput from "../ui/SelectorInput"; import { venezuelaCities } from "../../constants/venezuelaCities"; +// EventFiltersProps defines controlled filter state and setter callbacks. interface EventFiltersProps { searchTerm: string; countryTerm: string; @@ -15,8 +27,11 @@ interface EventFiltersProps { onToDateChange: (value: string) => void; } +// countryOptions currently limits selection to Venezuela for consistency +// with available city options. const countryOptions = [{ value: "Venezuela", label: "Venezuela" }]; +// EventFilters renders the search and date/location filter controls. export default function EventFilters({ searchTerm, countryTerm, diff --git a/frontend/src/components/events/EventItem.tsx b/frontend/src/components/events/EventItem.tsx index fb79b7a4..6de93344 100755 --- a/frontend/src/components/events/EventItem.tsx +++ b/frontend/src/components/events/EventItem.tsx @@ -1,3 +1,14 @@ +/* +File: EventItem.tsx + +Contains: +Card/list item component that renders one event with management actions. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import { useState } from "react"; import { useModal } from "../../contexts/Modal/ModalContext"; // components @@ -10,6 +21,7 @@ import CreateSessionModal from "../sessions/CreateSessionModal"; // contexts import { useAuth } from "../../contexts/Auth/Authcontext"; +// EventItemProps defines event card content and available management actions. interface EventItemProps { id_evento: number; nombre: string; @@ -24,8 +36,10 @@ interface EventItemProps { onToggleInscripcion?: (id: number, open: boolean) => void; } +// EventItem renders one event card with role-based management actions and +// modal shortcuts for detail/session workflows. export default function EventItem(props: EventItemProps) { - // Helper para parsear fecha DD/MM/YYYY HH:mm:ss + // parseDate converts API date strings (DD/MM/YYYY HH:mm:ss) into Date. function parseDate(dateStr: string): Date | undefined { if (!dateStr) return undefined; const [datePart, timePart] = dateStr.split(" "); @@ -40,7 +54,7 @@ export default function EventItem(props: EventItemProps) { return new Date(year, month - 1, day, hours, minutes, seconds); } - // Ajusta fecha fin si es antes de las 6am + // adjustEndDate prevents off-by-one display when end time is near midnight. function adjustEndDate(date: Date | undefined): Date | undefined { if (!date) return undefined; if (date.getHours() < 6) { @@ -51,7 +65,7 @@ export default function EventItem(props: EventItemProps) { return date; } - // Formatea fecha a string legible + // formatDate returns a user-friendly local date string. function formatDate(d: Date | undefined): string { if (!d) return ""; return d.toLocaleDateString("es-VE", { day: "2-digit", month: "2-digit", year: "numeric" }); @@ -156,14 +170,18 @@ const { {onToggleInscripcion && ( { + onClick={(e) => { + e.stopPropagation(); setShowConfirm(inscripciones_abiertas ? "cerrar" : "abrir"); }} title={inscripciones_abiertas ? "Cerrar inscripciones" : "Abrir inscripciones"} /> )} {onDelete && ( - { onDelete(id_evento); }} /> + { + e.stopPropagation(); + onDelete(id_evento); + }} /> )} )} diff --git a/frontend/src/components/events/ParticipantEventsCalendar.tsx b/frontend/src/components/events/ParticipantEventsCalendar.tsx index 814c3530..c7d71837 100755 --- a/frontend/src/components/events/ParticipantEventsCalendar.tsx +++ b/frontend/src/components/events/ParticipantEventsCalendar.tsx @@ -1,8 +1,20 @@ +/* +File: ParticipantEventsCalendar.tsx + +Contains: +Calendar component for participant event ranges and selected event highlight. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import React from "react"; import { DayPicker } from "react-day-picker"; import "react-day-picker/dist/style.css"; import { es } from "date-fns/locale/es"; +// EventRange defines one inclusive date range mapped to an event id. interface EventRange { from: Date; to: Date; @@ -16,13 +28,15 @@ interface Props { onMonthChange?: (month: Date) => void; } +// ParticipantEventsCalendar renders occupied dates and optionally highlights +// a selected event range. const ParticipantEventsCalendar: React.FC = ({ eventRanges, selectedRange, month, onMonthChange, }) => { - // Days that have events cannot be selected + // Build all occupied days to paint event activity on the calendar. const busyDays: Date[] = []; eventRanges.forEach(range => { let d = new Date(range.from); @@ -32,7 +46,7 @@ const ParticipantEventsCalendar: React.FC = ({ } }); - // Highlight the selected event range + // Build the selected event range to visually highlight it. let selectedDays: Date[] = []; if (selectedRange) { let d = new Date(selectedRange.from); diff --git a/frontend/src/components/events/ParticipantEventsList.tsx b/frontend/src/components/events/ParticipantEventsList.tsx index f17f344a..ebdf7c2c 100755 --- a/frontend/src/components/events/ParticipantEventsList.tsx +++ b/frontend/src/components/events/ParticipantEventsList.tsx @@ -1,5 +1,17 @@ +/* +File: ParticipantEventsList.tsx + +Contains: +List component for events where the participant is enrolled. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import React from "react"; +// Evento represents the minimal event shape required by the participant list. interface Evento { id_evento: number; nombre: string; @@ -14,6 +26,7 @@ interface Props { selectedEvento?: Evento | null; } +// ParticipantEventsList renders enrolled events and supports row selection. const ParticipantEventsList: React.FC = ({ eventos, onSelectEvento, diff --git a/frontend/src/components/events/UpdateCloseDateModal.test.tsx b/frontend/src/components/events/UpdateCloseDateModal.test.tsx index d15d0086..d85f27e3 100644 --- a/frontend/src/components/events/UpdateCloseDateModal.test.tsx +++ b/frontend/src/components/events/UpdateCloseDateModal.test.tsx @@ -1,6 +1,18 @@ +/* +File: UpdateCloseDateModal.test.tsx + +Contains: +Unit tests for close-date update modal behavior. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import { fireEvent, render, screen } from "@testing-library/react"; import UpdateCloseDateModal from "./UpdateCloseDateModal"; +// Toast mock used to verify user feedback paths. const mockShowToast = jest.fn(); jest.mock("../../contexts/Toast/ToastContext", () => ({ @@ -16,6 +28,7 @@ jest.mock("../ui/DayPickerSingle", () => ({ ), })); +// UpdateCloseDateModal tests cover close-date change enablement behavior. describe("UpdateCloseDateModal", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/frontend/src/components/events/UpdateCloseDateModal.tsx b/frontend/src/components/events/UpdateCloseDateModal.tsx index 5e3f4fa5..dec529b0 100755 --- a/frontend/src/components/events/UpdateCloseDateModal.tsx +++ b/frontend/src/components/events/UpdateCloseDateModal.tsx @@ -1,3 +1,14 @@ +/* +File: UpdateCloseDateModal.tsx + +Contains: +Modal component to update event registration close date. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import { useState, useEffect } from "react"; //components import Modal from "../ui/Modal"; @@ -9,21 +20,23 @@ import { useToast } from "../../contexts/Toast/ToastContext"; // APIs import { Evento } from "../../services/eventsServices"; +// UpdateCloseDateModalProps controls modal visibility and target event. interface UpdateCloseDateModalProps { open: boolean; onClose: () => void; event: Evento; } +// UpdateCloseDateModal handles close-date updates for event inscriptions. export default function UpdateCloseDateModal({ open, onClose, event }: UpdateCloseDateModalProps) { const { showToast } = useToast(); const [closeDate, setCloseDate] = useState(undefined); const [loading, setLoading] = useState(false); - // Save the original date to compare changes + // Keep original close date to detect whether user changed the value. const originalDate = parseDate(event.fecha_cierre_inscripcion); - // Helper for parse date string DD/MM/YYYY + // parseDate converts API date strings into Date objects. function parseDate(dateStr: string): Date | undefined { if (!dateStr) return undefined; // Soporta formato DD/MM/YYYY HH:mm:ss @@ -39,17 +52,17 @@ export default function UpdateCloseDateModal({ open, onClose, event }: UpdateClo return new Date(year, month - 1, day, hours, minutes, seconds); } - // Helper to format date to DD/MM/YYYY + // formatDate converts Date to local short date string for display. function formatDate(d: Date): string { return d.toLocaleDateString("es-VE", { day: "2-digit", month: "2-digit", year: "numeric" }); } - // Initialize current date + // Initialize picker value from incoming event when modal opens. useEffect(() => { if (open) setCloseDate(parseDate(event.fecha_cierre_inscripcion)); }, [open, event.fecha_cierre_inscripcion]); - // Handle form submission + // handleSubmit validates close date and triggers update flow. const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!closeDate) { @@ -89,7 +102,7 @@ export default function UpdateCloseDateModal({ open, onClose, event }: UpdateClo // } }; - // Only enable change if date is different from original + // Enable submit only when selected value is different from original. const isChangeEnabled = !!closeDate && (!originalDate || closeDate.getTime() !== originalDate.getTime()); return ( diff --git a/frontend/src/components/inscriptions/InscriptionCreateModal.tsx b/frontend/src/components/inscriptions/InscriptionCreateModal.tsx index 4224fa8d..375699a0 100644 --- a/frontend/src/components/inscriptions/InscriptionCreateModal.tsx +++ b/frontend/src/components/inscriptions/InscriptionCreateModal.tsx @@ -150,12 +150,21 @@ export default function InscriptionCreateModal({

@@ -190,6 +199,8 @@ export default function InscriptionCreateModal({ className="w-full" disabled={loading} onClick={handleSubmit} + loading={loading} + loadingText="Confirmando..." > Confirmar inscripción diff --git a/frontend/src/components/notifications/NotificationButton.tsx b/frontend/src/components/notifications/NotificationButton.tsx index 576c47f2..8574b8c6 100755 --- a/frontend/src/components/notifications/NotificationButton.tsx +++ b/frontend/src/components/notifications/NotificationButton.tsx @@ -1,11 +1,24 @@ +/* +File: NotificationButton.tsx + +Contains: +Compact notification trigger button with unread indicator dot. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import React from 'react'; import { FaBell } from 'react-icons/fa'; +// NotificationButtonProps defines unread state and click behavior. interface NotificationButtonProps { unreadCount: number; onClick: () => void; } +// NotificationButton renders the bell icon and a visual unread badge. const NotificationButton: React.FC = ({ unreadCount, onClick diff --git a/frontend/src/components/notifications/NotificationsModal.test.tsx b/frontend/src/components/notifications/NotificationsModal.test.tsx index cf236cc3..eaf1af59 100644 --- a/frontend/src/components/notifications/NotificationsModal.test.tsx +++ b/frontend/src/components/notifications/NotificationsModal.test.tsx @@ -1,7 +1,19 @@ +/* +File: NotificationsModal.test.tsx + +Contains: +Unit tests for notification modal interactions and empty-state rendering. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import { fireEvent, render, screen } from "@testing-library/react"; import NotificationsModal from "./NotificationsModal"; import { NotificationContext } from "../../contexts/Notifications/NotificationContext"; +// NotificationsModal tests verify interaction flow and UI states. describe("NotificationsModal", () => { it("marks unread notifications as read, shows detail, and removes items", () => { const markAsRead = jest.fn(); diff --git a/frontend/src/components/notifications/NotificationsModal.tsx b/frontend/src/components/notifications/NotificationsModal.tsx index 9e5b8a04..7932eccb 100644 --- a/frontend/src/components/notifications/NotificationsModal.tsx +++ b/frontend/src/components/notifications/NotificationsModal.tsx @@ -1,21 +1,39 @@ +/* +File: NotificationsModal.tsx + +Contains: +Modal component that displays notification inbox, detail panel, +and actions for read state and removal. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import React, { useContext, useMemo, useState } from "react"; import Modal from "../ui/Modal"; import { NotificationContext } from "../../contexts/Notifications/NotificationContext"; +// NotificationsModalProps controls visibility and close behavior. interface NotificationsModalProps { open: boolean; onClose: () => void; } +// NotificationsModal renders a two-panel inbox: +// left side for list navigation and right side for selected detail. const NotificationsModal: React.FC = ({ open, onClose, }) => { + // Notification context drives list data and actions. const { notifications, markAsRead, removeNotification, loading, error } = useContext( NotificationContext, ); + // selectedId tracks the currently opened notification detail. const [selectedId, setSelectedId] = useState(null); + // orderedNotifications keeps newest notifications first. const orderedNotifications = useMemo(() => { return [...notifications].sort((a, b) => { const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; @@ -24,8 +42,13 @@ const NotificationsModal: React.FC = ({ }); }, [notifications]); + // selectedNotif resolves the active item for right-side detail panel. const selectedNotif = notifications.find((n) => n.id === selectedId); + if (loading && notifications.length === 0) { + return null; + } + return (
@@ -36,9 +59,7 @@ const NotificationsModal: React.FC = ({
- {loading ? ( -
Cargando...
- ) : error ? ( + {error ? (
{error}
) : orderedNotifications.length === 0 ? (
@@ -47,13 +68,15 @@ const NotificationsModal: React.FC = ({ ) : (
{orderedNotifications.map((notif) => ( - - +
))}
)} diff --git a/frontend/src/components/sessions/CreateSessionModal.test.tsx b/frontend/src/components/sessions/CreateSessionModal.test.tsx index f2e3fe2b..915e9a68 100644 --- a/frontend/src/components/sessions/CreateSessionModal.test.tsx +++ b/frontend/src/components/sessions/CreateSessionModal.test.tsx @@ -1,3 +1,14 @@ +/* +File: CreateSessionModal.test.tsx + +Contains: +Behavior test for session creation and speaker-assignment flow. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import CreateSessionModal from "./CreateSessionModal"; @@ -58,6 +69,7 @@ describe("CreateSessionModal", () => { }); it("creates a session and advances to speaker assignment", async () => { + // Render with an event fixture that includes the scheduling range. render( { />, ); + // Fill required fields, create the session, and assert service calls. fireEvent.change(screen.getByLabelText("Título"), { target: { value: "Sesion magistral" }, }); diff --git a/frontend/src/components/sessions/CreateSessionModal.tsx b/frontend/src/components/sessions/CreateSessionModal.tsx index aa0b445d..b14e23d5 100755 --- a/frontend/src/components/sessions/CreateSessionModal.tsx +++ b/frontend/src/components/sessions/CreateSessionModal.tsx @@ -1,3 +1,14 @@ +/* +File: CreateSessionModal.tsx + +Contains: +Two-step modal to create a session and assign at least one speaker. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import React, { useState, useEffect, useContext } from 'react'; import Button from '../ui/Button'; import Modal from '../ui/Modal'; @@ -5,10 +16,8 @@ import Input from '../ui/Input'; import SelectorInput from '../ui/SelectorInput'; import DayPickerSingle from '../ui/DayPickerSingle'; import TimeRangePicker from '../ui/TimeRangePicker'; -import Loader from '../ui/Loader'; -import { createSession, getAvailableSpeakers, getEventDetail, assignSpeakersToSession } from '../../services/sessionsServices'; -import { useLoader } from '../../contexts/Loader/LoaderContext'; +import { createSession, getAvailableSpeakers, getEventDetail, assignSpeakersToSession, AvailableSpeaker } from '../../services/sessionsServices'; import { useToast } from '../../contexts/Toast/ToastContext'; interface CreateSessionModalProps { @@ -18,6 +27,7 @@ interface CreateSessionModalProps { onSessionCreated: () => void; } +// CreateSessionModal orchestrates session creation and mandatory speaker assignment. const CreateSessionModal: React.FC = ({ event, open, @@ -31,19 +41,19 @@ const CreateSessionModal: React.FC = ({ const [horaFin, setHoraFin] = useState('09:00'); // Dynamic speakers selection (segunda página) const [ponentesSeleccionados, setPonentesSeleccionados] = useState<(number | null)[]>([null]); - const [ponentesDisponibles, setPonentesDisponibles] = useState([]); + const [ponentesDisponibles, setPonentesDisponibles] = useState([]); const [createdSessionId, setCreatedSessionId] = useState(null); const [showPonentePage, setShowPonentePage] = useState(false); const [ubicacion, setUbicacion] = useState(event?.ubicacion || ''); const [eventoDetalle, setEventoDetalle] = useState(null); - const { showLoader, hideLoader } = useLoader(); const { showToast } = useToast(); + const [creatingSession, setCreatingSession] = useState(false); + const [assigningSpeaker, setAssigningSpeaker] = useState(false); + const [loadingSpeakers, setLoadingSpeakers] = useState(false); - const [loadingData, setLoadingData] = useState(false); - - // Solo cargar ponentes en la segunda página + // fetchPonentes loads available speakers after the session is created. const fetchPonentes = async (sessionId: number) => { - //setLoadingData(true); + setLoadingSpeakers(true); try { const res = await getAvailableSpeakers(sessionId); if (res.status !== 200) { @@ -55,7 +65,7 @@ const CreateSessionModal: React.FC = ({ }); return; } - setPonentesDisponibles(res.data || []); + setPonentesDisponibles(Array.isArray(res.data) ? res.data : []); } catch (err: any) { setPonentesDisponibles([]); showToast({ @@ -64,7 +74,7 @@ const CreateSessionModal: React.FC = ({ status: 'error', }); } finally { - setLoadingData(false); + setLoadingSpeakers(false); } }; @@ -88,7 +98,7 @@ const CreateSessionModal: React.FC = ({ setErrors({}); }, [open, event.id]); - // Helper y validaciones + // buildDateTime combines selected day and HH:mm input into a single Date. const buildDateTime = (baseDate: Date, timeHHmm: string) => { const [h, m] = timeHHmm.split(":").map(Number); const result = new Date(baseDate); @@ -96,6 +106,7 @@ const CreateSessionModal: React.FC = ({ return result; }; + // validateCreateForm enforces title and time-window constraints. const validateCreateForm = () => { const nextErrors: { titulo?: string; fechaHora?: string; general?: string } = {}; @@ -131,6 +142,7 @@ const CreateSessionModal: React.FC = ({ return Object.keys(nextErrors).length === 0; }; + // parseEventDate accepts display dates (DD/MM/YYYY) and ISO-like strings. const parseEventDate = (value?: string): Date | undefined => { if (!value) return undefined; @@ -150,13 +162,13 @@ const CreateSessionModal: React.FC = ({ const atStartOfDay = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), d.getDate()); - // Primera página: crear sesión + // handleCreateSession submits step one and opens speaker assignment on success. const handleCreateSession = async () => { // Validar antes de enviar if (!validateCreateForm()) return; - showLoader(); + setCreatingSession(true); try { const fechaInicio = new Date(fecha!); const [hIni, mIni] = horaInicio.split(':'); @@ -193,9 +205,7 @@ const CreateSessionModal: React.FC = ({ if (res.status === 200 && res.data?.id_sesion) { setCreatedSessionId(res.data.id_sesion); setShowPonentePage(true); - setLoadingData(true); await fetchPonentes(res.data.id_sesion); - setLoadingData(false); showToast({ title: 'Sesión creada', message: 'Ahora debes asignar un ponente.', @@ -215,13 +225,13 @@ const CreateSessionModal: React.FC = ({ status: 'error', }); } finally { - hideLoader(); + setCreatingSession(false); } }; - // Segunda página: asignar ponente obligatorio + // handleAssignPonente persists selected speakers and closes the modal flow. const handleAssignPonente = async () => { - showLoader(); + setAssigningSpeaker(true); try { const usuarios = ponentesSeleccionados.filter( (id, idx, arr) => id !== null && arr.indexOf(id) === idx @@ -261,7 +271,7 @@ const CreateSessionModal: React.FC = ({ status: 'error', }); } finally { - hideLoader(); + setAssigningSpeaker(false); } }; @@ -271,12 +281,11 @@ const CreateSessionModal: React.FC = ({ onClose={onClose} title={showPonentePage ? 'Asignar Ponente a Sesión' : 'Crear Sesión'} > - {loadingData ? ( -
- -
- ) : showPonentePage ? ( + {showPonentePage ? (
e.stopPropagation()}> + {loadingSpeakers && ( +

Cargando ponentes disponibles...

+ )}
{ponentesSeleccionados.map((selected, idx) => { const selectedIds = ponentesSeleccionados.filter((id, i) => id !== null && i !== idx); @@ -285,19 +294,25 @@ const CreateSessionModal: React.FC = ({ .map(p => ({ value: String(p.id_usuario), label: `${p.nombre} · ${p.email ?? "sin correo"}` })); return (
- {options.length > 0 && ( +
{ + const selectedValue = Array.isArray(val) ? val[0] ?? '' : val; const newArr = [...ponentesSeleccionados]; - newArr[idx] = val === '' ? null : Number(val); + newArr[idx] = selectedValue === '' ? null : Number(selectedValue); setPonentesSeleccionados(newArr); }} /> - )} + {options.length === 0 && ( +

+ No hay ponentes disponibles para seleccionar en este momento. +

+ )} +
{idx > 0 && (
) : ( -
e.stopPropagation()}> +
e.stopPropagation()}>
= ({ )}
-
+
+
= ({ maxLength={300} /> +
diff --git a/frontend/src/components/sessions/EditSessionModal.tsx b/frontend/src/components/sessions/EditSessionModal.tsx index 84a19409..961b1215 100755 --- a/frontend/src/components/sessions/EditSessionModal.tsx +++ b/frontend/src/components/sessions/EditSessionModal.tsx @@ -1,3 +1,14 @@ +/* +File: EditSessionModal.tsx + +Contains: +Modal form to update session details and synchronize assigned speakers. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import React, { useState, useEffect } from 'react'; import Button from '../ui/Button'; import Modal from '../ui/Modal'; @@ -8,6 +19,23 @@ import TimeRangePicker from '../ui/TimeRangePicker'; import { useToast } from '../../contexts/Toast/ToastContext'; import {updateSession, assignSpeakersToSession, removeSpeakerFromSession, getAvailableSpeakers } from '../../services/sessionsServices'; +const parseSpeakerId = (value: any): number | null => { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +}; + +const normalizeSpeaker = (speaker: any) => { + const id = parseSpeakerId( + speaker?.id_usuario ?? speaker?.idUsuario ?? speaker?.id ?? speaker?.usuario_id ?? speaker?.usuarioId, + ); + if (!id) return null; + return { + id_usuario: id, + nombre: speaker?.nombre ?? speaker?.name ?? `Usuario ${id}`, + email: speaker?.email, + }; +}; + interface EditSessionModalProps { open: boolean; onClose: () => void; @@ -16,6 +44,7 @@ interface EditSessionModalProps { onSessionUpdated: () => void; } +// parseDisplayDateTime parses either display format or ISO-like datetime values. const parseDisplayDateTime = (value: string): Date => { if (!value) return new Date(NaN); if (!value.includes('/')) return new Date(value); @@ -25,6 +54,7 @@ const parseDisplayDateTime = (value: string): Date => { return new Date(y, m - 1, d, hh || 0, mm || 0, 0, 0); }; +// parseEventDate parses event boundaries used by the day picker. const parseEventDate = (value?: string): Date | undefined => { if (!value) return undefined; if (value.includes('/')) { @@ -40,6 +70,7 @@ const parseEventDate = (value?: string): Date | undefined => { const atStartOfDay = (d: Date): Date => new Date(d.getFullYear(), d.getMonth(), d.getDate()); +// getHHmm extracts a HH:mm string from stored datetime values. const getHHmm = (value?: string): string => { if (!value) return '08:00'; const date = parseDisplayDateTime(value); @@ -49,6 +80,24 @@ const getHHmm = (value?: string): string => { return `${hh}:${mm}`; }; +const buildSessionSignature = (params: { + titulo: string; + descripcion: string; + fecha?: Date; + horaInicio: string; + horaFin: string; + ubicacion: string; + speakerIds: number[]; +}): string => { + const { titulo, descripcion, fecha, horaInicio, horaFin, ubicacion, speakerIds } = params; + const normalizedDate = fecha + ? `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}-${String(fecha.getDate()).padStart(2, '0')}` + : ''; + const normalizedSpeakers = [...speakerIds].sort((a, b) => a - b).join(','); + return [titulo.trim(), descripcion.trim(), normalizedDate, horaInicio, horaFin, ubicacion.trim(), normalizedSpeakers].join('|'); +}; + +// EditSessionModal edits session metadata and reconciles speaker assignments. const EditSessionModal: React.FC = ({ open, onClose, session, event, onSessionUpdated }) => { const [titulo, setTitulo] = useState(session.titulo || ''); const [descripcion, setDescripcion] = useState(session.descripcion || ''); @@ -60,6 +109,7 @@ const EditSessionModal: React.FC = ({ open, onClose, sess const [ponentesSeleccionados, setPonentesSeleccionados] = useState<(number | null)[]> ([null]); const [initialSpeakerIds, setIintialSpeakerIds] = useState([]); const [saving, setSaving] = useState(false); + const [initialSignature, setInitialSignature] = useState(''); const [errors, setErrors] = useState<{titulo?: string; fechaHora?: string; ponentes?: string;}>({}); const { showToast } = useToast(); @@ -73,18 +123,35 @@ const EditSessionModal: React.FC = ({ open, onClose, sess setUbicacion(session.ubicacion || event?.ubicacion || ''); const currentIds: number[] = Array.isArray(session.ponentes) - ? session.ponentes.map((p: any) => p.id_usuario).filter(Boolean) + ? session.ponentes + .map((p: any) => parseSpeakerId(p?.id_usuario ?? p?.idUsuario ?? p?.id)) + .filter((id: number | null): id is number => id !== null) : []; setIintialSpeakerIds(currentIds); setPonentesSeleccionados(currentIds.length > 0 ? currentIds : [null]); + setInitialSignature( + buildSessionSignature({ + titulo: session.titulo || '', + descripcion: session.descripcion || '', + fecha: session.fecha_inicio ? parseDisplayDateTime(session.fecha_inicio) : undefined, + horaInicio: getHHmm(session.fecha_inicio), + horaFin: session.fecha_fin ? getHHmm(session.fecha_fin) : '09:00', + ubicacion: session.ubicacion || event?.ubicacion || '', + speakerIds: currentIds, + }), + ); (async () => { const res = await getAvailableSpeakers(session.id_sesion); const disponibles = res.status === 200 && Array.isArray(res.data) ? res.data : []; // Incluye los ya asignados para poder mantener/agregar correctamente - const asignadosActuales = Array.isArray(session.ponentes) ? session.ponentes : []; + const asignadosActuales = Array.isArray(session.ponentes) + ? session.ponentes + .map((p: any) => normalizeSpeaker(p)) + .filter((p: any) => p !== null) + : []; const merged = [...disponibles]; asignadosActuales.forEach((p: any) => { @@ -99,6 +166,7 @@ const EditSessionModal: React.FC = ({ open, onClose, sess }, [open, session, event]); + // validateEditForm ensures required fields are valid before update. const validateEditForm = () => { const next: { titulo?: string; fechaHora?: string; ponentes?: string } = {}; @@ -129,6 +197,7 @@ const EditSessionModal: React.FC = ({ open, onClose, sess return Object.keys(next).length === 0; }; + // handleUpdateSession updates session data, adds new speakers, and removes unselected ones. const handleUpdateSession = async () => { if (!validateEditForm()) return; @@ -203,6 +272,18 @@ const EditSessionModal: React.FC = ({ open, onClose, sess } }; + const selectedSpeakerIds = ponentesSeleccionados.filter((id): id is number => id !== null); + const currentSignature = buildSessionSignature({ + titulo, + descripcion, + fecha, + horaInicio, + horaFin, + ubicacion, + speakerIds: selectedSpeakerIds, + }); + const hasChanges = currentSignature !== initialSignature; + return (
e.stopPropagation()}> @@ -312,7 +393,9 @@ const EditSessionModal: React.FC = ({ open, onClose, sess diff --git a/frontend/src/components/sessions/SessionList.tsx b/frontend/src/components/sessions/SessionList.tsx index f50767c6..c32c3e01 100755 --- a/frontend/src/components/sessions/SessionList.tsx +++ b/frontend/src/components/sessions/SessionList.tsx @@ -1,4 +1,16 @@ +/* +File: SessionList.tsx + +Contains: +Session list renderer with schedule formatting and optional edit action. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import React from "react"; +import EditIconButton from "../ui/EditIconButton"; interface SessionListProps { sessions: any[]; @@ -6,7 +18,9 @@ interface SessionListProps { showEditButton?: boolean; } +// SessionList displays each session, its schedule, and assigned speakers. const SessionList: React.FC = ({ sessions, onEdit, showEditButton = false }) => { + // parseDisplayDateTime supports both display and ISO date formats. const parseDisplayDateTime = (value: string): Date => { if (!value) return new Date(NaN); if (!value.includes("/")) return new Date(value); @@ -17,38 +31,33 @@ const SessionList: React.FC = ({ sessions, onEdit, showEditBut }; return ( -
-

Sesiones del evento

+
+

Sesiones del evento

{sessions.length === 0 ? (
No hay sesiones registradas para este evento.
) : ( -
    +
      {sessions.map((sesion) => ( -
    • -
      -
      {sesion.titulo}
      -
      +
    • +
      +
      +
      {sesion.titulo}
      +
      {(() => { const inicio = parseDisplayDateTime(sesion.fecha_inicio); const fin = parseDisplayDateTime(sesion.fecha_fin); return `${inicio.toLocaleString("es-VE", { timeZone: "America/Caracas", day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })} - ${fin.toLocaleString("es-VE", { timeZone: "America/Caracas", day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" })}`; })()} +
      + {showEditButton && onEdit && ( + onEdit(sesion)} + color="#94a3b8" + title="Editar sesión" + /> + )}
      - {showEditButton && onEdit && ( - - )} {Array.isArray(sesion.ponentes) && sesion.ponentes.length > 0 && (

      Ponentes asignados:

      diff --git a/frontend/src/components/sessions/SessionsCalendar.tsx b/frontend/src/components/sessions/SessionsCalendar.tsx index 0afda721..1dc0d15a 100644 --- a/frontend/src/components/sessions/SessionsCalendar.tsx +++ b/frontend/src/components/sessions/SessionsCalendar.tsx @@ -1,3 +1,14 @@ +/* +File: SessionsCalendar.tsx + +Contains: +Calendar visualization for sessions grouped by day with selected-day detail. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import React, {useMemo, useState } from "react"; import { DayPicker } from "react-day-picker"; import "react-day-picker/dist/style.css"; @@ -16,6 +27,7 @@ interface SessionsCalendarProps { sessions: SessionItem[]; } +// normalizeDateKey converts Date values to YYYY-MM-DD keys used for grouping. function normalizeDateKey(date: Date): string { const y = date.getFullYear(); const m = `${date.getMonth() + 1}`.padStart(2, "0"); @@ -23,6 +35,7 @@ function normalizeDateKey(date: Date): string { return `${y}-${m}-${d}`; } +// parseDisplayDateTime handles both DD/MM/YYYY HH:mm and ISO-like values. function parseDisplayDateTime(value: string): Date { if (!value) return new Date(NaN); if (!value.includes("/")) return new Date(value); @@ -32,6 +45,7 @@ function parseDisplayDateTime(value: string): Date { return new Date(y, m - 1, d, hh || 0, mm || 0, 0, 0); } +// SessionCalendar renders a monthly picker and sessions for the selected day. export default function SessionCalendar({ sessions} : SessionsCalendarProps) { const [selectedDay, setSelectedDay] = useState(undefined); diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 0e71a671..38265b26 100755 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -3,12 +3,17 @@ import type { ButtonHTMLAttributes } from "react"; interface ButtonProps extends ButtonHTMLAttributes { variant?: "primary" | "ghost"; disabled?: boolean; + loading?: boolean; + loadingText?: string; } export default function Button({ className = "", variant = "primary", disabled = false, + loading = false, + loadingText, + children, ...props }: ButtonProps): JSX.Element { const base = @@ -21,9 +26,18 @@ export default function Button({ return ( ); } diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 4c8da64c..03ac749f 100755 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -27,7 +27,7 @@ const Modal: React.FC = ({ }} />
      e.stopPropagation()} >
      @@ -42,7 +42,7 @@ const Modal: React.FC = ({ ×
      -
      +
      {children}
      diff --git a/frontend/src/components/ui/SelectorInput.tsx b/frontend/src/components/ui/SelectorInput.tsx index 23a68e1b..c068851f 100755 --- a/frontend/src/components/ui/SelectorInput.tsx +++ b/frontend/src/components/ui/SelectorInput.tsx @@ -89,6 +89,7 @@ const SelectInput: React.FC = ({ isSearchable isMulti={isMulti} isClearable={isClearable} + closeMenuOnSelect={!isMulti} menuPortalTarget={menuPortalTarget} menuPosition={menuPortalTarget ? "fixed" : "absolute"} styles={customStyles} @@ -149,6 +150,42 @@ const customStyles: StylesConfig = { color: "#e2e8f0", fontSize: "0.85rem", }), + multiValue: (provided) => ({ + ...provided, + backgroundColor: "#334155", + border: "1px solid #475569", + borderRadius: "0.5rem", + }), + multiValueLabel: (provided) => ({ + ...provided, + color: "#e2e8f0", + fontSize: "0.8rem", + paddingLeft: "0.5rem", + paddingRight: "0.35rem", + }), + multiValueRemove: (provided, state) => ({ + ...provided, + color: "#facc15", + backgroundColor: state.isFocused ? "#475569" : "transparent", + borderRadius: "0 0.5rem 0.5rem 0", + paddingLeft: "0.35rem", + paddingRight: "0.45rem", + ":hover": { + backgroundColor: "#64748b", + color: "#fff", + cursor: "pointer", + }, + }), + clearIndicator: (provided) => ({ + ...provided, + color: "#facc15", + ":hover": { color: "#fde047" }, + }), + dropdownIndicator: (provided) => ({ + ...provided, + color: "#94a3b8", + ":hover": { color: "#e2e8f0" }, + }), }; export default SelectInput; diff --git a/frontend/src/components/ui/ToggleIconButton.tsx b/frontend/src/components/ui/ToggleIconButton.tsx index b0428056..e9af479f 100755 --- a/frontend/src/components/ui/ToggleIconButton.tsx +++ b/frontend/src/components/ui/ToggleIconButton.tsx @@ -4,7 +4,7 @@ import { LockClosedIcon, LockOpenIcon } from "@heroicons/react/24/outline"; interface ToggleIconButtonProps { open: boolean; // true = inscripciones abiertas - onClick: () => void; + onClick: (e: React.MouseEvent) => void; className?: string; title?: string; iconSize?: number; diff --git a/frontend/src/contexts/Notifications/NotificationContext.tsx b/frontend/src/contexts/Notifications/NotificationContext.tsx index cb3ea0e7..2eaa871d 100755 --- a/frontend/src/contexts/Notifications/NotificationContext.tsx +++ b/frontend/src/contexts/Notifications/NotificationContext.tsx @@ -1,6 +1,7 @@ import React, { createContext, useReducer, useCallback, useEffect } from 'react'; import { useAuth } from '../Auth/Authcontext'; import { useToast } from '../Toast/ToastContext'; +import { useLoader } from '../Loader/LoaderContext'; import { notificationReducer, initialState, NotificationState, Notification } from './reducer'; import { fetchNotifications, @@ -48,8 +49,10 @@ export const NotificationProvider: React.FC = ({ chil const [state, dispatch] = useReducer(notificationReducer, initialState); const { user } = useAuth(); const { showToast } = useToast(); + const { showLoader, hideLoader } = useLoader(); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); + const [hasLoadedOnce, setHasLoadedOnce] = React.useState(false); // Function to fetch notifications and update state const refreshNotifications = useCallback(async () => { @@ -57,7 +60,11 @@ export const NotificationProvider: React.FC = ({ chil dispatch(refreshNotificationsAction([])); return; } - setLoading(true); + const initialLoad = !hasLoadedOnce; + if (initialLoad) { + setLoading(true); + showLoader(); + } setError(null); try { const data = await fetchNotifications(user.id); @@ -71,6 +78,7 @@ export const NotificationProvider: React.FC = ({ chil })); const merged = [...local, ...(Array.isArray(data) ? data : [])]; dispatch(refreshNotificationsAction(merged)); + setHasLoadedOnce(true); } catch (err: any) { setError('Error al cargar notificaciones'); const local = user?.id @@ -89,10 +97,14 @@ export const NotificationProvider: React.FC = ({ chil message: err?.message || "No se pudieron cargar las notificaciones.", status: "error", }); + setHasLoadedOnce(true); } finally { - setLoading(false); + if (initialLoad) { + setLoading(false); + hideLoader(); + } } - }, [user, showToast]); + }, [user, showToast, hasLoadedOnce, showLoader, hideLoader]); // Fetch notifications on mount and set up polling every 5 minutos useEffect(() => { diff --git a/frontend/src/index.css b/frontend/src/index.css index 10bd78f1..ba253797 100755 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -25,3 +25,17 @@ body { margin: 0; min-height: 100vh; } + +.modal-surface, +.modal-scroll-content, +.modal-surface * { + scrollbar-width: none; +} + +.modal-surface::-webkit-scrollbar, +.modal-scroll-content::-webkit-scrollbar, +.modal-surface *::-webkit-scrollbar { + width: 0; + height: 0; + display: none; +} diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx index bfe79301..26348de6 100755 --- a/frontend/src/layouts/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout.tsx @@ -14,7 +14,7 @@ export default function AppLayout(): JSX.Element {
      {loaderState.visible && ( -
      +
      )} diff --git a/frontend/src/navigation/EventRoutes.tsx b/frontend/src/navigation/EventRoutes.tsx index cf3645a1..5a1be1d8 100755 --- a/frontend/src/navigation/EventRoutes.tsx +++ b/frontend/src/navigation/EventRoutes.tsx @@ -1,3 +1,14 @@ +/* +File: EventRoutes.tsx + +Contains: +Route definitions for participant events view and protected event management view. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import { Route } from "react-router-dom"; import { RESOURCE_KEYS } from "../constants/resources"; import ResourceRoute from "./ResourceRoute"; @@ -5,6 +16,7 @@ import ResourceRoute from "./ResourceRoute"; import EventsAdminListScreen from "../screens/events/EventsAdminListScreen"; import EventsParticipantListScreen from "../screens/events/EventsParticipantListScreen"; +// eventRoutes groups routes related to event browsing and administration. export const eventRoutes = ( <> } /> diff --git a/frontend/src/navigation/ScientificWorkRoutes.tsx b/frontend/src/navigation/ScientificWorkRoutes.tsx index 6362fc03..d90d609b 100644 --- a/frontend/src/navigation/ScientificWorkRoutes.tsx +++ b/frontend/src/navigation/ScientificWorkRoutes.tsx @@ -1,9 +1,21 @@ +/* +File: ScientificWorkRoutes.tsx + +Contains: +Protected route definitions for scientific work participant and management screens. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import { Route } from "react-router-dom"; import { RESOURCE_KEYS } from "../constants/resources"; import ResourceRoute from "./ResourceRoute"; import ScientificWorksScreen from "../screens/scientificWorks/ScientificWorksScreen"; import ScientificWorksManagementScreen from "../screens/scientificWorks/ScientificWorksManagementScreen"; +// scientificWorkRoutes groups route entries related to scientific work flows. export const scientificWorkRoutes = ( <> -

      - Bienvenido al Home +

      + Bienvenido al Sistema
      + Automatizado de Gestión de Eventos Científicos

      -

      - Esta es la pantalla principal de la aplicación. -

      - Esta es una vista temporal de la pantalla de inicio. Por favor, navega a la sección de eventos para ver la funcionalidad principal de la aplicación. + Por favor, navega a la sección de eventos para ver la funcionalidad principal de la aplicación.

      ([]); @@ -37,7 +49,7 @@ export default function EventsAdminListScreen(): JSX.Element { // contexts const { showToast } = useToast(); - // function to fetch events + // fetchEvents retrieves event rows and updates loading/error state. async function fetchEvents() { setLoading(true); try { @@ -69,13 +81,13 @@ export default function EventsAdminListScreen(): JSX.Element { } } - // handler to open delete confirmation modal + // handleDeleteClick opens the confirmation modal with selected event id. const handleDeleteClick = (id: number) => { setEventToDelete(id); setDeleteModalOpen(true); }; - // handler to confirm deletion + // handleConfirmDelete performs the delete operation and refreshes the list. const handleConfirmDelete = async () => { if (eventToDelete == null) return; setDeleting(true); @@ -99,13 +111,13 @@ export default function EventsAdminListScreen(): JSX.Element { setEventToDelete(null); }; - // handler to cancel deletion + // handleCancelDelete closes the delete modal without mutating data. const handleCancelDelete = () => { setDeleteModalOpen(false); setEventToDelete(null); }; - // handler to toggle inscription status + // handleToggleInscripcion opens or closes event inscriptions using backend action. const handleToggleInscripcion = async (id: number, abiertas: boolean) => { const action = abiertas ? "cerrar" : "abrir"; const { status, data } = await patchInscriptionDate(id, action); diff --git a/frontend/src/screens/events/EventsParticipantListScreen.tsx b/frontend/src/screens/events/EventsParticipantListScreen.tsx index 52bc85b3..3bca1227 100755 --- a/frontend/src/screens/events/EventsParticipantListScreen.tsx +++ b/frontend/src/screens/events/EventsParticipantListScreen.tsx @@ -1,3 +1,14 @@ +/* +File: EventsParticipantListScreen.tsx + +Contains: +Participant event listing with filters, pagination, and inscription modal flow. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import React, { useEffect, useMemo, useState } from "react"; // contexts import { useAuth } from "../../contexts/Auth/Authcontext"; @@ -28,6 +39,7 @@ interface Evento { const AVAILABLE_EVENTS_PAGE_SIZE = 6; +// EventsParticipantListScreen renders enrolled/available events and inscription actions. const EventsParticipantListScreen: React.FC = () => { // states const [loading, setLoading] = useState(true); @@ -51,6 +63,7 @@ const EventsParticipantListScreen: React.FC = () => { const { user } = useAuth(); const { showToast } = useToast(); + // fetchDisponiblesFiltrados requests events using current filters and pagination. const fetchDisponiblesFiltrados = async () => { if (!user?.id) { setEventosInscritos([]); @@ -191,7 +204,7 @@ const EventsParticipantListScreen: React.FC = () => { ); } - // Handler to open inscription modal for a specific event + // handleOpenInscribirModal checks access/authentication before opening the form modal. const handleOpenInscribirModal = (evento: Evento) => { if (!canInscribir) return; if (!user?.id) { diff --git a/frontend/src/screens/inscriptions/InscriptionAdminScreen.tsx b/frontend/src/screens/inscriptions/InscriptionAdminScreen.tsx index 495b3177..0fca34f9 100644 --- a/frontend/src/screens/inscriptions/InscriptionAdminScreen.tsx +++ b/frontend/src/screens/inscriptions/InscriptionAdminScreen.tsx @@ -43,6 +43,9 @@ export default function InscriptionAdminScreen(): JSX.Element { const [selected, setSelected] = useState(null); const [newStatus, setNewStatus] = useState(""); const [note, setNote] = useState(""); + const [updating, setUpdating] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [initialLoaded, setInitialLoaded] = useState(false); const { showToast } = useToast(); const authUser = getAuthUser(); @@ -57,8 +60,13 @@ export default function InscriptionAdminScreen(): JSX.Element { return events.map((ev) => ({ value: String(ev.id_evento), label: ev.nombre })); }, [events]); - const loadData = async () => { - setLoading(true); + const loadData = async (opts?: { silent?: boolean }) => { + const silent = opts?.silent ?? false; + if (!silent && !initialLoaded) { + setLoading(true); + } else { + setRefreshing(true); + } setError(""); const [insRes, evRes] = await Promise.all([ @@ -81,12 +89,14 @@ export default function InscriptionAdminScreen(): JSX.Element { setEvents(evRes.data); } + if (!initialLoaded) setInitialLoaded(true); setLoading(false); + setRefreshing(false); }; useEffect(() => { - loadData(); - }, [statusFilter, eventFilter]); + void loadData(); + }, []); useEffect(() => { if (page > totalPages) { @@ -94,6 +104,15 @@ export default function InscriptionAdminScreen(): JSX.Element { } }, [page, totalPages]); + useEffect(() => { + const timeoutId = window.setTimeout(() => { + setPage(1); + void loadData({ silent: true }); + }, 300); + + return () => window.clearTimeout(timeoutId); + }, [statusFilter, eventFilter, query]); + const openModal = (item: InscriptionItem) => { setSelected(item); setNewStatus(item.estado); @@ -108,31 +127,40 @@ export default function InscriptionAdminScreen(): JSX.Element { const handleUpdate = async () => { if (!selected || !newStatus) return; - const { status, data } = await updateInscriptionStatus({ - id_inscripcion: selected.id_inscripcion, - estado: newStatus, - nota: note, - actor: authUser?.email || "admin", - }); + setUpdating(true); + try { + const { status, data } = await updateInscriptionStatus({ + id_inscripcion: selected.id_inscripcion, + estado: newStatus, + nota: note, + actor: authUser?.email || "admin", + }); + + if (status >= 400) { + showToast({ + title: "Error", + message: data?.message || "No se pudo actualizar el estado.", + status: "error", + }); + return; + } - if (status >= 400) { showToast({ - title: "Error", - message: data?.message || "No se pudo actualizar el estado.", - status: "error", + title: "Estado actualizado", + message: "Se actualizó el estado de la inscripción.", + status: "success", }); - return; + closeModal(); + loadData(); + } finally { + setUpdating(false); } - - showToast({ - title: "Estado actualizado", - message: "Se actualizó el estado de la inscripción.", - status: "success", - }); - closeModal(); - loadData(); }; + const hasUpdateChanges = + !!selected && + (newStatus !== selected.estado || note.trim().length > 0); + return (
      @@ -140,35 +168,37 @@ export default function InscriptionAdminScreen(): JSX.Element {

      Revisa y actualiza el estado de los usuarios inscritos.

      -
      +
      setStatusFilter(Array.isArray(value) ? value[0] ?? "" : value)} options={statusOptions} inputLabel="Filtrar por estado" placeholder="Todos" allowCustom={false} /> setEventFilter(Array.isArray(value) ? value[0] ?? "" : value)} options={eventOptions} inputLabel="Filtrar por evento" placeholder="Todos" allowCustom={false} /> -
      +
      setQuery(e.target.value)} />
      -
      - {loading ? ( + {loading && !initialLoaded ? (
      @@ -181,6 +211,12 @@ export default function InscriptionAdminScreen(): JSX.Element { /> ) : (
      + {refreshing && ( +
      + + Actualizando filtros... +
      + )} @@ -262,7 +298,7 @@ export default function InscriptionAdminScreen(): JSX.Element { setNewStatus(Array.isArray(value) ? value[0] ?? "" : value)} options={statusOptions} inputLabel="Nuevo estado" placeholder="Selecciona" @@ -275,7 +311,7 @@ export default function InscriptionAdminScreen(): JSX.Element { onChange={(e) => setNote(e.target.value)} />
      - -
      diff --git a/frontend/src/screens/scientificWorks/ScientificWorksManagementScreen.tsx b/frontend/src/screens/scientificWorks/ScientificWorksManagementScreen.tsx index 9e4c7105..19082106 100644 --- a/frontend/src/screens/scientificWorks/ScientificWorksManagementScreen.tsx +++ b/frontend/src/screens/scientificWorks/ScientificWorksManagementScreen.tsx @@ -1,6 +1,18 @@ +/* +File: ScientificWorksManagementScreen.tsx + +Contains: +Committee and reviewer management workflow for scientific work assignment, evaluation, and decisions. + +Course: CI-4712 Ingeniería de Software II +Term: Enero - Marzo 2026 +Designed by: Equipo 2 - Arcadian +*/ + import { useEffect, useMemo, useState } from "react"; import Button from "../../components/ui/Button"; import Modal from "../../components/ui/Modal"; +import SelectInput from "../../components/ui/SelectorInput"; import Loader from "../../components/ui/Loader"; import EmptyState from "../../components/ui/EmptyState"; import ErrorState from "../../components/ui/ErrorState"; @@ -21,12 +33,14 @@ import { submitScientificWorkEvaluation, } from "../../services/scientificWorkServices"; +// formatDecisionLabel normalizes API decision values for display. function formatDecisionLabel(value: string): string { return String(value || "PENDIENTE_REVISION") .replaceAll("_", " ") .trim(); } +// ScientificWorksManagementScreen provides committee/reviewer actions over submitted works. export default function ScientificWorksManagementScreen(): JSX.Element { const authUser = getStoredAuthUser(); const { showToast } = useToast(); @@ -43,6 +57,7 @@ export default function ScientificWorksManagementScreen(): JSX.Element { const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [initialLoaded, setInitialLoaded] = useState(false); const [works, setWorks] = useState([]); const [reviewers, setReviewers] = useState([]); @@ -58,6 +73,9 @@ export default function ScientificWorksManagementScreen(): JSX.Element { const [evaluationsOpen, setEvaluationsOpen] = useState(false); const [decisionOpen, setDecisionOpen] = useState(false); const [evaluationOpen, setEvaluationOpen] = useState(false); + const [savingAssign, setSavingAssign] = useState(false); + const [savingDecision, setSavingDecision] = useState(false); + const [savingEvaluation, setSavingEvaluation] = useState(false); const [selectedReviewers, setSelectedReviewers] = useState([]); const [decision, setDecision] = useState("PENDIENTE_REVISION"); @@ -79,9 +97,13 @@ export default function ScientificWorksManagementScreen(): JSX.Element { // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]); - async function loadData() { + // loadData fetches works based on the current operating mode and filters. + async function loadData(opts?: { silent?: boolean }) { if (!authUser?.id) return; - setLoading(true); + const silent = opts?.silent ?? false; + if (!silent && !initialLoaded) { + setLoading(true); + } setError(""); if (mode === "COMITE") { @@ -107,6 +129,7 @@ export default function ScientificWorksManagementScreen(): JSX.Element { } } + if (!initialLoaded) setInitialLoaded(true); setLoading(false); } @@ -114,12 +137,22 @@ export default function ScientificWorksManagementScreen(): JSX.Element { await loadData(); } + useEffect(() => { + if (mode !== "COMITE") return; + const timeoutId = window.setTimeout(() => { + void loadData({ silent: true }); + }, 300); + + return () => window.clearTimeout(timeoutId); + }, [query, autor, estado, mode]); + function toggleReviewer(id: number) { setSelectedReviewers((prev) => prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id], ); } + // openAssignModal retrieves eligible reviewers for the selected work. async function openAssignModal(work: ScientificWorkManagementItem) { if (!authUser?.id) return; setSelectedWork(work); @@ -139,6 +172,7 @@ export default function ScientificWorksManagementScreen(): JSX.Element { setAssignOpen(true); } + // assignSelectedReviewers persists current multi-selection for reviewer assignment. async function assignSelectedReviewers() { if (!authUser?.id || !selectedWork) return; @@ -151,29 +185,35 @@ export default function ScientificWorksManagementScreen(): JSX.Element { return; } - const res = await assignScientificWorkReviewers({ - user_id: authUser.id, - id_trabajo: selectedWork.id_trabajo, - revisores: selectedReviewers, - }); + setSavingAssign(true); + try { + const res = await assignScientificWorkReviewers({ + user_id: authUser.id, + id_trabajo: selectedWork.id_trabajo, + revisores: selectedReviewers, + }); + + if (res.status >= 400) { + showToast({ + title: "Error", + message: res.data?.message || "No se pudo asignar revisores.", + status: "error", + }); + return; + } - if (res.status >= 400) { showToast({ - title: "Error", - message: res.data?.message || "No se pudo asignar revisores.", - status: "error", + title: "Asignación exitosa", + message: "Los revisores fueron asignados correctamente.", + status: "success", }); - return; + setAssignOpen(false); + } finally { + setSavingAssign(false); } - - showToast({ - title: "Asignación exitosa", - message: "Los revisores fueron asignados correctamente.", - status: "success", - }); - setAssignOpen(false); } + // openEvaluationsModal loads summary and detailed evaluations for a work. async function openEvaluationsModal(work: ScientificWorkManagementItem) { if (!authUser?.id) return; setSelectedWork(work); @@ -214,32 +254,38 @@ export default function ScientificWorksManagementScreen(): JSX.Element { setDecisionOpen(true); } + // submitDecision saves committee final decision and optional author comment. async function submitDecision() { if (!authUser?.id || !selectedWork) return; - const res = await decideScientificWork({ - user_id: authUser.id, - id_trabajo: selectedWork.id_trabajo, - decision_comite: decision, - comentario_comite: decisionComment.trim(), - }); + setSavingDecision(true); + try { + const res = await decideScientificWork({ + user_id: authUser.id, + id_trabajo: selectedWork.id_trabajo, + decision_comite: decision, + comentario_comite: decisionComment.trim(), + }); + + if (res.status >= 400) { + showToast({ + title: "Error", + message: res.data?.message || "No se pudo registrar la decisión.", + status: "error", + }); + return; + } - if (res.status >= 400) { showToast({ - title: "Error", - message: res.data?.message || "No se pudo registrar la decisión.", - status: "error", + title: "Decisión guardada", + message: "El estado del trabajo fue actualizado.", + status: "success", }); - return; + setDecisionOpen(false); + await loadData(); + } finally { + setSavingDecision(false); } - - showToast({ - title: "Decisión guardada", - message: "El estado del trabajo fue actualizado.", - status: "success", - }); - setDecisionOpen(false); - await loadData(); } function openEvaluationModal(work: ScientificWorkManagementItem) { @@ -252,6 +298,7 @@ export default function ScientificWorksManagementScreen(): JSX.Element { setEvaluationOpen(true); } + // submitEvaluation validates and stores a reviewer's scoring submission. async function submitEvaluation() { if (!authUser?.id || !selectedWork) return; @@ -274,34 +321,40 @@ export default function ScientificWorksManagementScreen(): JSX.Element { return; } - const res = await submitScientificWorkEvaluation({ - user_id: authUser.id, - id_trabajo: selectedWork.id_trabajo, - recomendacion: recomendacion, - puntaje: parsedScore, - comentarios: "", - fortalezas: fortalezas.trim(), - debilidades: debilidades.trim(), - recomendaciones: recomendaciones.trim(), - }); + setSavingEvaluation(true); + try { + const res = await submitScientificWorkEvaluation({ + user_id: authUser.id, + id_trabajo: selectedWork.id_trabajo, + recomendacion: recomendacion, + puntaje: parsedScore, + comentarios: "", + fortalezas: fortalezas.trim(), + debilidades: debilidades.trim(), + recomendaciones: recomendaciones.trim(), + }); + + if (res.status >= 400) { + showToast({ + title: "Error", + message: res.data?.message || "No se pudo guardar la evaluación.", + status: "error", + }); + return; + } - if (res.status >= 400) { showToast({ - title: "Error", - message: res.data?.message || "No se pudo guardar la evaluación.", - status: "error", + title: "Evaluación registrada", + message: "Tu evaluación fue guardada correctamente.", + status: "success", }); - return; + setEvaluationOpen(false); + } finally { + setSavingEvaluation(false); } - - showToast({ - title: "Evaluación registrada", - message: "Tu evaluación fue guardada correctamente.", - status: "success", - }); - setEvaluationOpen(false); } + // handleDownloadCurrentFile downloads the current exposed PDF file version. async function handleDownloadCurrentFile(work: ScientificWorkManagementItem) { if (!authUser?.id) return; if (!work.archivo_actual?.id_version) { @@ -373,28 +426,11 @@ export default function ScientificWorksManagementScreen(): JSX.Element {

      -
      - {canCommittee && ( - - )} - {canReviewer && ( - - )} -
      +
      Modo: {mode === "COMITE" ? "Comité" : "Revisor"}
      {mode === "COMITE" && ( -
      +
      setAutor(e.target.value)} /> - - + onChange={(value) => setEstado(Array.isArray(value) ? value[0] ?? "" : value)} + options={[ + { value: "", label: "Todos los estados" }, + { value: "RECIBIDO", label: "RECIBIDO" }, + { value: "ACTUALIZADO", label: "ACTUALIZADO" }, + { value: "ACEPTADO", label: "ACEPTADO" }, + { value: "RECHAZADO", label: "RECHAZADO" }, + ]} + placeholder="Todos los estados" + allowCustom={false} + />
      )} @@ -508,7 +546,9 @@ export default function ScientificWorksManagementScreen(): JSX.Element { - +
      @@ -550,15 +590,16 @@ export default function ScientificWorksManagementScreen(): JSX.Element { setDecisionOpen(false)} title="Decisión final del comité">

      {selectedWork?.titulo}

      - + onChange={(value) => setDecision(Array.isArray(value) ? value[0] ?? "" : value)} + options={[ + { value: "ACEPTADO", label: "ACEPTADO" }, + { value: "RECHAZADO", label: "RECHAZADO" }, + { value: "PENDIENTE_REVISION", label: "PENDIENTE DE REVISIÓN" }, + ]} + allowCustom={false} + />