Publicaciones de RSVP del Calendario

:information_source: Resumen Crea respuestas de tema cortas para eventos de RSVP
:hammer_and_wrench: Enlace al Repositorio GitHub - mariodsantana/discourse-calendar-rsvp-posts
:open_book: Guía de Instalación Cómo instalar plugins en Discourse

Características

  • Notificaciones en tiempo real - Dado que los eventos de RSVP ahora crean una publicación de respuesta en el tema, cada RSVP activa notificaciones para los observadores del tema
  • Pocos elementos - Las publicaciones de notificación anteriores se eliminan, por lo que hay un máximo de 2 publicaciones en cualquier momento (1 historial + 1 notificación más reciente)
  • Historial completo - Toda la actividad de RSVP se conserva con marcas de tiempo en orden cronológico
  • Desalienta el cambio de opinión - Las marcas de tiempo hacen visibles los cambios de RSVP repetidos

Configuración

La configuración del sitio relevante (en Administrador > Configuración > Plugins) configura principalmente qué cambios de RSVP deben activar una publicación: ir, interesado, no ir o eliminación de RSVP existente, y si se debe activar en cambios de RSVP a eventos que comienzan en el pasado.

La configuración restante alterna el modo de historial. Con el modo de historial deshabilitado, solo queda la publicación de notificación más reciente en el tema. Con el modo de historial habilitado, el plugin administra un comentario de “historial” adicional de la siguiente manera:

  • En el primer RSVP, crea una publicación de notificación simple que anuncia el RSVP
  • En el segundo RSVP, transforma la primera publicación en una publicación de historial con marca de tiempo y luego crea una nueva publicación de notificación
  • En los RSVP posteriores, agrega el RSVP a la publicación de historial con marca de tiempo, elimina la publicación de notificación anterior y crea una nueva

Configuración

Nombre Descripción
calendar_rsvp_posts_on_new_going publicar en nuevo “Voy”
calendar_rsvp_posts_on_new_interested publicar en nuevo “Interesado”
calendar_rsvp_posts_on_new_not_going publicar en nuevo “No voy”
calendar_rsvp_posts_on_removed_rsvp publicar cuando se elimina un RSVP
calendar_rsvp_posts_allow_past_events si se debe publicar para eventos que comienzan en el pasado
calendar_rsvp_posts_enable_history mantener una publicación de historial con marca de tiempo (predeterminado: habilitado)
6 Me gusta

¡Esto mejorará la gestión de eventos para muchos! ¡¡Muchas gracias por proporcionar esto!!

1 me gusta

Gracias por el plugin :+1:

solo una pequeña pregunta: ¿tenemos la posibilidad de traducir las respuestas en un archivo?

Me gusta esta idea. Puedo añadir un enlace a la publicación de historial, que descargaría un archivo CSV. ¿Es ese el tipo de cosa que tienes en mente?

Me expresé mal :sweat_smile:, quería saber cómo traducir las notificaciones de respuesta porque están en inglés y me gustaría ponerlas en francés.

1 me gusta

LOL - Ahora lo entiendo. Ahora mismo, el texto en inglés está directamente en el código fuente. Soy nuevo en Discourse, pero estoy seguro de que hay una forma estándar de hacer que un complemento sea traducible. Lo investigaré cuando tenga algo de tiempo.

Probablemente también implemente el enlace CSV. :wink:

1 me gusta

No hay problema :wink: . Tómate tu tiempo, no tengo prisa

¡De acuerdo! La nueva versión está disponible en el repositorio. No solo con i18n (internacionalización), sino que también encontré y corregí un error que impedía que se publicaran las actualizaciones cuando alguien eliminaba su confirmación de asistencia.

¡Avísame si tienes algún problema!

1 me gusta

Hola, tenía un problema con los usuarios cuando participan o no en un evento: aparecían dos avatares en el evento en lugar de uno, y al refrescar se solucionaba, pero causaba confusión entre los usuarios. He modificado el código de mi parte; no sé si te interese.

# frozen_string_literal: true
# name: discourse-calendar-rsvp-posts
# about: Crear respuestas breves en temas para eventos de RSVP
# version: 0.4
# authors: Mario Santana

after_initialize do
  module ::CalendarRsvpPosts
    PLUGIN_NAME = "discourse-calendar-rsvp-posts"
    
    def self.history_marker
      I18n.t('calendar_rsvp_posts.markers.history')
    end
    
    def self.notification_marker
      I18n.t('calendar_rsvp_posts.markers.notification')
    end

    # Ayudante para decidir si debemos ignorar este evento
    def self.should_post_for_event?(event)
      return false if event.nil?
      return true if SiteSetting.calendar_rsvp_posts_allow_past_events
      return false if event.starts_at.nil?
      event.starts_at >= Time.current
    end

    # Buscar cualquier publicación de RSVP para un evento (historial o notificación simple)
    def self.find_rsvp_posts(event)
      return [] if event.nil? || event.post.nil? || event.post.topic.nil?
      
      event.post.topic.posts
        .where(user_id: Discourse.system_user.id)
        .where("raw LIKE ? OR raw LIKE ?", 
               "%#{history_marker}%",
               "%#{notification_marker}%")
        .order(created_at: :asc)
    end

    # Buscar específicamente la publicación del historial
    def self.find_history_post(event)
      return nil if event.nil? || event.post.nil? || event.post.topic.nil?
      
      event.post.topic.posts
        .where(user_id: Discourse.system_user.id)
        .where("raw LIKE ?", "%#{history_marker}%")
        .order(created_at: :asc)
        .first
    end

    # Buscar y eliminar todas las publicaciones de notificación (pero no la del historial)
    def self.delete_notification_posts(event)
      return if event.nil? || event.post.nil? || event.post.topic.nil?
      
      notification_posts = event.post.topic.posts
        .where(user_id: Discourse.system_user.id)
        .where("raw LIKE ?", "%#{notification_marker}%")
      
      notification_posts.each do |post|
        begin
          PostDestroyer.new(Discourse.system_user, post, context: "limpieza de calendar-rsvp-posts").destroy
        rescue StandardError => e
          Rails.logger.warn("calendar-rsvp-posts: no se pudo eliminar la publicación de notificación #{post.id}: #{e}")
        end
      end
    end

    # Construir una línea de entrada del historial con marca de tiempo
    def self.build_history_entry(username, action_label, extra_text = nil)
      timestamp = Time.current.strftime("%Y-%m-%d %H:%M UTC")
      entry = "- **#{timestamp}** - #{username} #{action_label}"
      entry += " (#{extra_text})" if extra_text.present?
      entry
    end

    # Construir o actualizar el contenido de la publicación del historial
    def self.build_history_raw(event, new_entry)
      event_title = (event.name.presence || event.post.topic.title).to_s
      parts = []
      parts << history_marker
      parts << "### #{I18n.t('calendar_rsvp_posts.history.header', event_title: event_title)}"
      parts << ""
      parts << new_entry
      parts.join("\n")
    end

    # Añadir nueva entrada al historial existente
    def self.append_to_history(existing_raw, new_entry)
      lines = existing_raw.split("\n")
      header_end_idx = lines.index { |line| line.start_with?("### Historial de RSVP") }
      
      if header_end_idx
        insert_idx = header_end_idx + 2
        lines.insert(insert_idx, new_entry)
      else
        lines << new_entry
      end
      
      lines.join("\n")
    end

    # Convertir una publicación de notificación simple al formato de historial
    def self.convert_to_history(simple_post, event)
      raw = simple_post.raw
      
      going_label = I18n.t('calendar_rsvp_posts.actions.going').gsub('(', '\\(').gsub(')', '\\)')
      interested_label = I18n.t('calendar_rsvp_posts.actions.interested').gsub('(', '\\(').gsub(')', '\\)')
      not_going_label = I18n.t('calendar_rsvp_posts.actions.not_going').gsub('(', '\\(').gsub(')', '\\)')
      removed_label = I18n.t('calendar_rsvp_posts.actions.removed').gsub('(', '\\(').gsub(')', '\\)')
      
      pattern = /\*\*([^\*]+)\s+(#{Regexp.escape(going_label)}|#{Regexp.escape(interested_label)}|#{Regexp.escape(not_going_label)}|#{Regexp.escape(removed_label)})\*\*/
      match = raw.match(pattern)
      
      if match
        username = match[1]
        action = match[2]
        
        extra_match = raw.match(/\.\s+([^.]+)\.$/)
        extra_text = extra_match ? extra_match[1] : nil
        
        timestamp = simple_post.created_at.strftime("%Y-%m-%d %H:%M UTC")
        first_entry = "- **#{timestamp}** - #{username} #{action}"
        first_entry += " (#{extra_text})" if extra_text.present?
        
        event_title = (event.name.presence || event.post.topic.title).to_s
        parts = []
        parts << history_marker
        parts << "### #{I18n.t('calendar_rsvp_posts.history.header', event_title: event_title)}"
        parts << ""
        parts << first_entry
        parts.join("\n")
      else
        event_title = (event.name.presence || event.post.topic.title).to_s
        history_marker + "\n### #{I18n.t('calendar_rsvp_posts.history.header', event_title: event_title)}\n\n" + raw
      end
    end

    # Construir una publicación de notificación
    def self.build_notification_raw(username, action_label, event, extra_text = nil)
      event_title = (event.name.presence || event.post.topic.title).to_s
      extra_text_formatted = extra_text.present? ? "#{extra_text} " : ""
      
      notification_raw = notification_marker + "\n"
      notification_raw += I18n.t('calendar_rsvp_posts.notification.template', 
                                event_title: event_title,
                                extra: extra_text_formatted,
                                username: username,
                                action: action_label)
      notification_raw
    end

    # Método central de lógica para manejar la creación/actualización de publicaciones
    def self.publish_rsvp_update(event, username, action_label, extra_text = nil)
      if SiteSetting.calendar_rsvp_posts_enable_history
        history_post = find_history_post(event)
        all_rsvp_posts = find_rsvp_posts(event)
        new_entry = build_history_entry(username, action_label, extra_text)

        if all_rsvp_posts.empty?
          notification_raw = build_notification_raw(username, action_label, event, extra_text)
          PostCreator.create!(
            Discourse.system_user,
            topic_id: event.post.topic_id,
            raw: notification_raw,
            skip_validations: true
          )
        elsif history_post.nil?
          first_post = all_rsvp_posts.first
          history_raw = convert_to_history(first_post, event)
          history_raw = append_to_history(history_raw, new_entry)
          
          PostRevisor.new(first_post, event.post.topic).revise!(
            Discourse.system_user,
            raw: history_raw,
            skip_validations: true,
            skip_revision: false
          )

          notification_raw = build_notification_raw(username, action_label, event, extra_text)
          PostCreator.create!(
            Discourse.system_user,
            topic_id: event.post.topic_id,
            raw: notification_raw,
            skip_validations: true
          )
        else
          updated_raw = append_to_history(history_post.raw, new_entry)
          PostRevisor.new(history_post, event.post.topic).revise!(
            Discourse.system_user,
            raw: updated_raw,
            skip_validations: true,
            skip_revision: false
          )

          delete_notification_posts(event)

          notification_raw = build_notification_raw(username, action_label, event, extra_text)
          PostCreator.create!(
            Discourse.system_user,
            topic_id: event.post.topic_id,
            raw: notification_raw,
            skip_validations: true
          )
        end
      else
        all_rsvp_posts = find_rsvp_posts(event)
        
        all_rsvp_posts.each do |post|
          begin
            PostDestroyer.new(Discourse.system_user, post, context: "limpieza de calendar-rsvp-posts").destroy
          rescue StandardError => e
            Rails.logger.warn("calendar-rsvp-posts: no se pudo eliminar la publicación #{post.id}: #{e}")
          end
        end

        notification_raw = build_notification_raw(username, action_label, event, extra_text)
        PostCreator.create!(
          Discourse.system_user,
          topic_id: event.post.topic_id,
          raw: notification_raw,
          skip_validations: true
        )
      end
    end
  end

  # ==========================================
  # Definición del trabajo en segundo plano
  # ==========================================
  module ::Jobs
    class ProcessCalendarRsvpPost < ::Jobs::Base
      def execute(args)
        event_id = args[:event_id]
        username = args[:username]
        action_label = args[:action_label]
        extra_text = args[:extra_text]

        event = DiscoursePostEvent::Event.find_by(id: event_id)
        return unless event

        ::CalendarRsvpPosts.publish_rsvp_update(event, username, action_label, extra_text)
      end
    end
  end

  # ==========================================
  # Manejadores de eventos
  # ==========================================

  # Manejador para creación/actualización de asistencia
  proc_handler = proc do |invitee|
    begin
      event = invitee&.event
      next if event.nil?
      next unless CalendarRsvpPosts.should_post_for_event?(event)

      going_val = DiscoursePostEvent::Invitee.statuses[:going]
      interested_val = DiscoursePostEvent::Invitee.statuses[:interested]
      not_going_val = DiscoursePostEvent::Invitee.statuses[:not_going]

      new_status = invitee.status
      prev_status =
        if invitee.respond_to?(:previous_changes) && invitee.previous_changes["status"]
          invitee.previous_changes["status"][0]
        else
          nil
        end

      next if prev_status && prev_status == new_status

      username = invitee.user&.username || "alguien"
      action_label = nil

      if new_status == going_val && SiteSetting.calendar_rsvp_posts_on_new_going
        action_label = I18n.t('calendar_rsvp_posts.actions.going')
      elsif new_status == interested_val && SiteSetting.calendar_rsvp_posts_on_new_interested
        action_label = I18n.t('calendar_rsvp_posts.actions.interested')
      elsif new_status == not_going_val && SiteSetting.calendar_rsvp_posts_on_new_not_going
        action_label = I18n.t('calendar_rsvp_posts.actions.not_going')
      end

      next if action_label.nil?

      extra_text = nil
      if event.max_attendees.present?
        current_going = event.going_count
        prev_going =
          if prev_status == going_val && new_status != going_val
            current_going + 1
          elsif prev_status != going_val && new_status == going_val
            current_going - 1
          else
            current_going
          end

        was_full = prev_going >= event.max_attendees
        is_full = current_going >= event.max_attendees

        if was_full && !is_full
          extra_text = I18n.t('calendar_rsvp_posts.capacity.spots_available')
        elsif !was_full && is_full
          extra_text = I18n.t('calendar_rsvp_posts.capacity.now_full')
        end
      end

      # Encolar el trabajo en lugar de bloquear la solicitud
      Jobs.enqueue(:process_calendar_rsvp_post, 
        event_id: event.id, 
        username: username, 
        action_label: action_label, 
        extra_text: extra_text
      )

    rescue StandardError => e
      Rails.logger.warn("calendar-rsvp-posts: error del manejador: #{e}")
      Rails.logger.warn(e.backtrace.join("\n"))
    end
  end

  on(:discourse_calendar_post_event_invitee_status_changed, &proc_handler)

  # Manejador para eliminaciones explícitas de invitados (RSVP eliminado)
  if defined?(DiscoursePostEvent::Invitee)
    DiscoursePostEvent::Invitee.class_eval do
      after_destroy do
        event = self.event
        
        should_process = event.present? &&
                         SiteSetting.calendar_rsvp_posts_on_removed_rsvp &&
                         (SiteSetting.calendar_rsvp_posts_allow_past_events || event.starts_at.nil? || event.starts_at >= Time.current)
        
        if should_process
          begin
            username = self.user&.username || "alguien"
            action_label = I18n.t('calendar_rsvp_posts.actions.removed')

            # Encolar el trabajo en lugar de bloquear la solicitud
            Jobs.enqueue(:process_calendar_rsvp_post, 
              event_id: event.id, 
              username: username, 
              action_label: action_label,
              extra_text: nil
            )
            
          rescue StandardError => e
            Rails.logger.warn("calendar-rsvp-posts: error del manejador after_destroy: #{e}")
            Rails.logger.warn(e.backtrace.join("\n"))
          end
        end
      end
    end
  end
end
1 me gusta

Es una gran idea, y puedo ver cómo podría causar confusión en la interfaz de usuario. He añadido un Mutex para evitar condiciones de carrera y he publicado una nueva versión del plugin.

¡Dime qué te parece!

1 me gusta