From 33f5b58074c9c931e02db35a46f71f15c42169a8 Mon Sep 17 00:00:00 2001 From: Delton Ding Date: Thu, 9 Jul 2026 18:37:30 +0800 Subject: [PATCH 1/2] feat: chat app --- .../api/v1/chat_channels_controller.rb | 60 +++ .../api/v1/chats/conversations_controller.rb | 28 ++ .../chats/direct_conversations_controller.rb | 20 + .../api/v1/chats/messages_controller.rb | 58 +++ backend/app/models/chat_channel.rb | 23 ++ backend/app/models/chat_conversation.rb | 59 +++ .../models/chat_conversation_participant.rb | 15 + backend/app/models/chat_message.rb | 19 + backend/app/models/group.rb | 2 + backend/app/models/user.rb | 18 + backend/app/policies/chat_channel_policy.rb | 40 ++ .../app/policies/chat_conversation_policy.rb | 27 ++ backend/app/policies/chat_message_policy.rb | 30 ++ backend/app/services/chats/channel_creator.rb | 29 ++ .../chats/direct_conversation_finder.rb | 52 +++ backend/app/services/chats/thread_creator.rb | 34 ++ .../chat_channels/_chat_channel.json.jbuilder | 3 + .../api/v1/chat_channels/index.json.jbuilder | 3 + .../api/v1/chat_channels/show.json.jbuilder | 1 + .../conversations/_conversation.json.jbuilder | 36 ++ .../chats/conversations/index.json.jbuilder | 3 + .../v1/chats/conversations/show.json.jbuilder | 1 + .../v1/chats/messages/_message.json.jbuilder | 10 + .../api/v1/chats/messages/index.json.jbuilder | 3 + .../api/v1/chats/messages/show.json.jbuilder | 1 + backend/config/routes.rb | 17 +- .../db/migrate/20260709104000_create_chats.rb | 66 ++++ backend/db/schema.rb | 64 ++- backend/openapi/v1/openapi.yaml | 369 ++++++++++++++++++ backend/spec/factories/chat_channels.rb | 14 + .../chat_conversation_participants.rb | 6 + backend/spec/factories/chat_conversations.rb | 23 ++ backend/spec/factories/chat_messages.rb | 7 + backend/spec/models/chat_channel_spec.rb | 13 + backend/spec/models/chat_conversation_spec.rb | 33 ++ backend/spec/models/chat_message_spec.rb | 18 + backend/spec/openapi_helper.rb | 84 ++++ backend/spec/requests/api/v1/chats_spec.rb | 182 +++++++++ .../requests/api/v1/openapi/chats_spec.rb | 224 +++++++++++ frontend/e2e/chats.spec.ts | 319 +++++++++++++++ frontend/src/App.vue | 3 + .../src/components/chats/ChannelDialog.vue | 80 ++++ .../src/components/chats/ChatComposer.vue | 51 +++ .../components/chats/ChatConversationPane.vue | 69 ++++ .../src/components/chats/ChatMessageItem.vue | 120 ++++++ .../src/components/chats/ChatMessageList.vue | 26 ++ frontend/src/components/chats/ChatSidebar.vue | 143 +++++++ .../src/components/chats/ChatThreadPanel.vue | 58 +++ .../chats/__tests__/ChatMessageItem.spec.ts | 56 +++ .../components/drive/MarkdownEditorPanel.vue | 24 +- frontend/src/components/kanban/TaskForm.vue | 12 +- .../components/markdown/MarkdownEditor.vue | 98 +++++ .../markdown/__tests__/MarkdownEditor.spec.ts | 22 ++ frontend/src/i18n/locales/en.json | 3 + frontend/src/i18n/locales/zh-CN.json | 3 + frontend/src/lib/markdown.ts | 4 +- frontend/src/router/index.ts | 11 + frontend/src/services/api.ts | 2 + frontend/src/services/api/chats.ts | 56 +++ frontend/src/services/api/types.ts | 57 +++ frontend/src/stores/__tests__/chats.spec.ts | 109 ++++++ frontend/src/stores/chats.ts | 271 +++++++++++++ frontend/src/views/ChatsView.vue | 157 ++++++++ frontend/src/views/DashboardView.vue | 18 + frontend/src/views/TaskDetailPage.vue | 20 +- 65 files changed, 3456 insertions(+), 31 deletions(-) create mode 100644 backend/app/controllers/api/v1/chat_channels_controller.rb create mode 100644 backend/app/controllers/api/v1/chats/conversations_controller.rb create mode 100644 backend/app/controllers/api/v1/chats/direct_conversations_controller.rb create mode 100644 backend/app/controllers/api/v1/chats/messages_controller.rb create mode 100644 backend/app/models/chat_channel.rb create mode 100644 backend/app/models/chat_conversation.rb create mode 100644 backend/app/models/chat_conversation_participant.rb create mode 100644 backend/app/models/chat_message.rb create mode 100644 backend/app/policies/chat_channel_policy.rb create mode 100644 backend/app/policies/chat_conversation_policy.rb create mode 100644 backend/app/policies/chat_message_policy.rb create mode 100644 backend/app/services/chats/channel_creator.rb create mode 100644 backend/app/services/chats/direct_conversation_finder.rb create mode 100644 backend/app/services/chats/thread_creator.rb create mode 100644 backend/app/views/api/v1/chat_channels/_chat_channel.json.jbuilder create mode 100644 backend/app/views/api/v1/chat_channels/index.json.jbuilder create mode 100644 backend/app/views/api/v1/chat_channels/show.json.jbuilder create mode 100644 backend/app/views/api/v1/chats/conversations/_conversation.json.jbuilder create mode 100644 backend/app/views/api/v1/chats/conversations/index.json.jbuilder create mode 100644 backend/app/views/api/v1/chats/conversations/show.json.jbuilder create mode 100644 backend/app/views/api/v1/chats/messages/_message.json.jbuilder create mode 100644 backend/app/views/api/v1/chats/messages/index.json.jbuilder create mode 100644 backend/app/views/api/v1/chats/messages/show.json.jbuilder create mode 100644 backend/db/migrate/20260709104000_create_chats.rb create mode 100644 backend/spec/factories/chat_channels.rb create mode 100644 backend/spec/factories/chat_conversation_participants.rb create mode 100644 backend/spec/factories/chat_conversations.rb create mode 100644 backend/spec/factories/chat_messages.rb create mode 100644 backend/spec/models/chat_channel_spec.rb create mode 100644 backend/spec/models/chat_conversation_spec.rb create mode 100644 backend/spec/models/chat_message_spec.rb create mode 100644 backend/spec/requests/api/v1/chats_spec.rb create mode 100644 backend/spec/requests/api/v1/openapi/chats_spec.rb create mode 100644 frontend/e2e/chats.spec.ts create mode 100644 frontend/src/components/chats/ChannelDialog.vue create mode 100644 frontend/src/components/chats/ChatComposer.vue create mode 100644 frontend/src/components/chats/ChatConversationPane.vue create mode 100644 frontend/src/components/chats/ChatMessageItem.vue create mode 100644 frontend/src/components/chats/ChatMessageList.vue create mode 100644 frontend/src/components/chats/ChatSidebar.vue create mode 100644 frontend/src/components/chats/ChatThreadPanel.vue create mode 100644 frontend/src/components/chats/__tests__/ChatMessageItem.spec.ts create mode 100644 frontend/src/components/markdown/MarkdownEditor.vue create mode 100644 frontend/src/components/markdown/__tests__/MarkdownEditor.spec.ts create mode 100644 frontend/src/services/api/chats.ts create mode 100644 frontend/src/stores/__tests__/chats.spec.ts create mode 100644 frontend/src/stores/chats.ts create mode 100644 frontend/src/views/ChatsView.vue diff --git a/backend/app/controllers/api/v1/chat_channels_controller.rb b/backend/app/controllers/api/v1/chat_channels_controller.rb new file mode 100644 index 0000000..d91a817 --- /dev/null +++ b/backend/app/controllers/api/v1/chat_channels_controller.rb @@ -0,0 +1,60 @@ +module Api + module V1 + class ChatChannelsController < BaseController + before_action :set_group, only: [ :index, :create ] + before_action :set_chat_channel, only: [ :update, :destroy ] + + def index + authorize ChatChannel + @chat_channels = policy_scope(@group.chat_channels) + .includes(:group, :chat_conversation) + .order(:name) + end + + def create + chat_channel = @group.chat_channels.new(channel_params.merge(created_by: current_user)) + authorize chat_channel + + @chat_channel = ::Chats::ChannelCreator.call( + group: @group, + created_by: current_user, + attributes: channel_params + ) + render :show, status: :created + rescue ActiveRecord::RecordInvalid => e + render_errors(e.record) + end + + def update + authorize @chat_channel + + if @chat_channel.update(channel_params) + render :show + else + render_errors(@chat_channel) + end + end + + def destroy + authorize @chat_channel + @chat_channel.chat_conversation.destroy! + + head :no_content + end + + private + + def set_group + @group = policy_scope(Group).find(params[:group_id]) + end + + def set_chat_channel + @chat_channel = policy_scope(ChatChannel).find(params[:id]) + end + + def channel_params + params.require(:chat_channel).permit(:name, :description) + end + end + end +end diff --git a/backend/app/controllers/api/v1/chats/conversations_controller.rb b/backend/app/controllers/api/v1/chats/conversations_controller.rb new file mode 100644 index 0000000..e041efb --- /dev/null +++ b/backend/app/controllers/api/v1/chats/conversations_controller.rb @@ -0,0 +1,28 @@ +module Api + module V1 + module Chats + class ConversationsController < BaseController + before_action :set_conversation, only: [ :show ] + + def index + @conversations = policy_scope(ChatConversation) + .where(kind: %w[ direct channel ]) + .includes(:group, :participants, :chat_channel, parent_message: :author) + .order(Arel.sql("last_message_at DESC NULLS LAST"), created_at: :desc) + end + + def show + authorize @conversation + end + + private + + def set_conversation + @conversation = policy_scope(ChatConversation) + .includes(:group, :participants, :chat_channel, parent_message: :author) + .find(params[:id]) + end + end + end + end +end diff --git a/backend/app/controllers/api/v1/chats/direct_conversations_controller.rb b/backend/app/controllers/api/v1/chats/direct_conversations_controller.rb new file mode 100644 index 0000000..0fd3c53 --- /dev/null +++ b/backend/app/controllers/api/v1/chats/direct_conversations_controller.rb @@ -0,0 +1,20 @@ +module Api + module V1 + module Chats + class DirectConversationsController < BaseController + def create + other_user = policy_scope(User).find(params.require(:user_id)) + @conversation = ::Chats::DirectConversationFinder.call( + current_user:, + other_user: + ) + authorize @conversation, :show? + render "api/v1/chats/conversations/show", status: :created + rescue ActiveRecord::RecordInvalid => e + skip_authorization + render_errors(e.record) + end + end + end + end +end diff --git a/backend/app/controllers/api/v1/chats/messages_controller.rb b/backend/app/controllers/api/v1/chats/messages_controller.rb new file mode 100644 index 0000000..b85bcfd --- /dev/null +++ b/backend/app/controllers/api/v1/chats/messages_controller.rb @@ -0,0 +1,58 @@ +module Api + module V1 + module Chats + class MessagesController < BaseController + before_action :set_conversation, only: [ :index, :create ] + before_action :set_message, only: [ :thread ] + + def index + authorize @conversation, :show? + scoped_messages = policy_scope(@conversation.chat_messages) + .includes(:author, thread_conversation: :chat_messages) + .order(id: :desc) + .limit(limit_param) + scoped_messages = scoped_messages.where("chat_messages.id < ?", params[:before_id]) if params[:before_id].present? + + @messages = scoped_messages.to_a.reverse + end + + def create + authorize @conversation, :create_message? + + @message = @conversation.chat_messages.new(message_params.merge(author: current_user)) + if @message.save + render :show, status: :created + else + render_errors(@message) + end + end + + def thread + authorize @message, :create_thread? + + existing_thread = @message.thread_conversation + @conversation = ::Chats::ThreadCreator.call(parent_message: @message, created_by: current_user) + render "api/v1/chats/conversations/show", status: existing_thread ? :ok : :created + end + + private + + def set_conversation + @conversation = policy_scope(ChatConversation).find(params[:conversation_id]) + end + + def set_message + @message = policy_scope(ChatMessage).find(params[:id]) + end + + def message_params + params.require(:message).permit(:body) + end + + def limit_param + params.fetch(:limit, 50).to_i.clamp(1, 100) + end + end + end + end +end diff --git a/backend/app/models/chat_channel.rb b/backend/app/models/chat_channel.rb new file mode 100644 index 0000000..9b81eb1 --- /dev/null +++ b/backend/app/models/chat_channel.rb @@ -0,0 +1,23 @@ +class ChatChannel < ApplicationRecord + belongs_to :group + belongs_to :chat_conversation + belongs_to :created_by, class_name: "User" + + validates :name, presence: true, uniqueness: { scope: :group_id, case_sensitive: false } + validates :chat_conversation_id, uniqueness: true + validate :conversation_must_be_channel + + before_validation :normalize_name + + private + + def normalize_name + self.name = name.to_s.strip + end + + def conversation_must_be_channel + return if chat_conversation.blank? || chat_conversation.channel? + + errors.add(:chat_conversation, "must be a channel conversation") + end +end diff --git a/backend/app/models/chat_conversation.rb b/backend/app/models/chat_conversation.rb new file mode 100644 index 0000000..b46494c --- /dev/null +++ b/backend/app/models/chat_conversation.rb @@ -0,0 +1,59 @@ +class ChatConversation < ApplicationRecord + KINDS = %w[ direct channel thread ].freeze + + belongs_to :group, optional: true + belongs_to :parent_message, + class_name: "ChatMessage", + optional: true, + inverse_of: :thread_conversation + belongs_to :created_by, class_name: "User" + + has_one :chat_channel, dependent: :destroy + has_many :chat_conversation_participants, dependent: :destroy + has_many :participants, through: :chat_conversation_participants, source: :user + has_many :chat_messages, dependent: :destroy + + validates :kind, presence: true, inclusion: { in: KINDS } + validates :direct_key, presence: true, uniqueness: true, if: :direct? + validates :direct_key, absence: true, unless: :direct? + validates :group, presence: true, if: :channel? + validates :parent_message, presence: true, if: :thread? + validates :parent_message_id, uniqueness: true, allow_nil: true + validate :thread_parent_must_be_top_level + + scope :visible_to, lambda { |user| + return none if user.blank? + + direct_ids = ChatConversationParticipant.where(user:).select(:chat_conversation_id) + group_ids = user.system_admin? ? Group.select(:id) : GroupHierarchy.accessible_group_ids_for(user) + parent_conversation_ids = ChatConversation + .where(id: direct_ids) + .or(ChatConversation.where(kind: "channel", group_id: group_ids)) + .select(:id) + parent_message_ids = ChatMessage.where(chat_conversation_id: parent_conversation_ids).select(:id) + + where(id: direct_ids) + .or(where(kind: "channel", group_id: group_ids)) + .or(where(kind: "thread", parent_message_id: parent_message_ids)) + } + + def direct? + kind == "direct" + end + + def channel? + kind == "channel" + end + + def thread? + kind == "thread" + end + + private + + def thread_parent_must_be_top_level + return unless thread? && parent_message&.chat_conversation&.thread? + + errors.add(:parent_message, "must belong to a direct or channel conversation") + end +end diff --git a/backend/app/models/chat_conversation_participant.rb b/backend/app/models/chat_conversation_participant.rb new file mode 100644 index 0000000..f5520f7 --- /dev/null +++ b/backend/app/models/chat_conversation_participant.rb @@ -0,0 +1,15 @@ +class ChatConversationParticipant < ApplicationRecord + belongs_to :chat_conversation + belongs_to :user + + validates :user_id, uniqueness: { scope: :chat_conversation_id } + validate :conversation_must_be_direct + + private + + def conversation_must_be_direct + return if chat_conversation.blank? || chat_conversation.direct? + + errors.add(:chat_conversation, "must be a direct conversation") + end +end diff --git a/backend/app/models/chat_message.rb b/backend/app/models/chat_message.rb new file mode 100644 index 0000000..6be6564 --- /dev/null +++ b/backend/app/models/chat_message.rb @@ -0,0 +1,19 @@ +class ChatMessage < ApplicationRecord + belongs_to :chat_conversation + belongs_to :author, class_name: "User" + has_one :thread_conversation, + class_name: "ChatConversation", + foreign_key: :parent_message_id, + dependent: :destroy, + inverse_of: :parent_message + + validates :body, presence: true + + after_create_commit :touch_conversation_last_message_at + + private + + def touch_conversation_last_message_at + chat_conversation.update_column(:last_message_at, created_at) + end +end diff --git a/backend/app/models/group.rb b/backend/app/models/group.rb index 225bd5f..de74ae2 100644 --- a/backend/app/models/group.rb +++ b/backend/app/models/group.rb @@ -18,6 +18,8 @@ class Group < ApplicationRecord has_many :group_memberships, dependent: :destroy has_many :users, through: :group_memberships has_many :projects, dependent: :destroy + has_many :chat_conversations, dependent: :destroy + has_many :chat_channels, dependent: :destroy has_many :ancestor_group_hierarchies, class_name: "GroupHierarchy", foreign_key: :descendant_group_id, diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index ed98952..e27a9b1 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -16,6 +16,24 @@ class User < ApplicationRecord has_many :projects, dependent: :destroy has_many :group_memberships, dependent: :destroy has_many :groups, through: :group_memberships + has_many :created_chat_conversations, + class_name: "ChatConversation", + foreign_key: :created_by_id, + dependent: :restrict_with_error, + inverse_of: :created_by + has_many :created_chat_channels, + class_name: "ChatChannel", + foreign_key: :created_by_id, + dependent: :restrict_with_error, + inverse_of: :created_by + has_many :chat_conversation_participants, dependent: :destroy + has_many :direct_chat_conversations, + through: :chat_conversation_participants, + source: :chat_conversation + has_many :chat_messages, + foreign_key: :author_id, + dependent: :restrict_with_error, + inverse_of: :author has_many :oauth_identities, dependent: :destroy def accessible_group_ids diff --git a/backend/app/policies/chat_channel_policy.rb b/backend/app/policies/chat_channel_policy.rb new file mode 100644 index 0000000..5eef0db --- /dev/null +++ b/backend/app/policies/chat_channel_policy.rb @@ -0,0 +1,40 @@ +class ChatChannelPolicy < ApplicationPolicy + def index? + user.present? + end + + def show? + group_member? + end + + def create? + group_admin? + end + + def update? + group_admin? + end + + def destroy? + group_admin? + end + + class Scope < ApplicationPolicy::Scope + def resolve + return scope.none if user.blank? + + group_ids = user.system_admin? ? Group.select(:id) : GroupHierarchy.accessible_group_ids_for(user) + scope.where(group_id: group_ids) + end + end + + private + + def group_member? + user.present? && record.group_id.present? && user.can_access_group?(record.group_id) + end + + def group_admin? + user.present? && record.group_id.present? && user.can_admin_group?(record.group_id) + end +end diff --git a/backend/app/policies/chat_conversation_policy.rb b/backend/app/policies/chat_conversation_policy.rb new file mode 100644 index 0000000..4ad856e --- /dev/null +++ b/backend/app/policies/chat_conversation_policy.rb @@ -0,0 +1,27 @@ +class ChatConversationPolicy < ApplicationPolicy + def index? + user.present? + end + + def show? + visible? + end + + def create_message? + visible? + end + + class Scope < ApplicationPolicy::Scope + def resolve + scope.visible_to(user) + end + end + + private + + def visible? + return false if user.blank? || record.blank? + + ChatConversation.visible_to(user).where(id: record.id).exists? + end +end diff --git a/backend/app/policies/chat_message_policy.rb b/backend/app/policies/chat_message_policy.rb new file mode 100644 index 0000000..c84f960 --- /dev/null +++ b/backend/app/policies/chat_message_policy.rb @@ -0,0 +1,30 @@ +class ChatMessagePolicy < ApplicationPolicy + def index? + user.present? + end + + def show? + conversation_visible? + end + + def create_thread? + conversation_visible? && !record.chat_conversation.thread? + end + + class Scope < ApplicationPolicy::Scope + def resolve + return scope.none if user.blank? + + scope.joins(:chat_conversation) + .where(chat_conversations: { id: ChatConversation.visible_to(user).select(:id) }) + end + end + + private + + def conversation_visible? + return false if user.blank? || record.blank? + + ChatConversation.visible_to(user).where(id: record.chat_conversation_id).exists? + end +end diff --git a/backend/app/services/chats/channel_creator.rb b/backend/app/services/chats/channel_creator.rb new file mode 100644 index 0000000..bda82d1 --- /dev/null +++ b/backend/app/services/chats/channel_creator.rb @@ -0,0 +1,29 @@ +module Chats + class ChannelCreator + def self.call(group:, created_by:, attributes:) + new(group:, created_by:, attributes:).call + end + + def initialize(group:, created_by:, attributes:) + @group = group + @created_by = created_by + @attributes = attributes + end + + def call + ChatConversation.transaction do + conversation = group.chat_conversations.create!( + kind: "channel", + created_by: + ) + group.chat_channels.create!( + attributes.merge(chat_conversation: conversation, created_by:) + ) + end + end + + private + + attr_reader :group, :created_by, :attributes + end +end diff --git a/backend/app/services/chats/direct_conversation_finder.rb b/backend/app/services/chats/direct_conversation_finder.rb new file mode 100644 index 0000000..6beb91d --- /dev/null +++ b/backend/app/services/chats/direct_conversation_finder.rb @@ -0,0 +1,52 @@ +module Chats + class DirectConversationFinder + def self.call(current_user:, other_user:) + new(current_user:, other_user:).call + end + + def initialize(current_user:, other_user:) + @current_user = current_user + @other_user = other_user + end + + def call + raise ActiveRecord::RecordInvalid, invalid_conversation if current_user.id == other_user.id + + ChatConversation.find_by(direct_key:) || create_conversation + rescue ActiveRecord::RecordNotUnique + ChatConversation.find_by!(direct_key:) + end + + private + + attr_reader :current_user, :other_user + + def create_conversation + ChatConversation.transaction do + conversation = ChatConversation.create!( + kind: "direct", + direct_key:, + created_by: current_user + ) + participant_ids.each do |user_id| + conversation.chat_conversation_participants.create!(user_id:) + end + conversation + end + end + + def direct_key + @direct_key ||= participant_ids.join(":") + end + + def participant_ids + @participant_ids ||= [ current_user.id, other_user.id ].sort + end + + def invalid_conversation + conversation = ChatConversation.new(kind: "direct", created_by: current_user) + conversation.errors.add(:base, "Cannot create a direct conversation with yourself") + conversation + end + end +end diff --git a/backend/app/services/chats/thread_creator.rb b/backend/app/services/chats/thread_creator.rb new file mode 100644 index 0000000..a3e2661 --- /dev/null +++ b/backend/app/services/chats/thread_creator.rb @@ -0,0 +1,34 @@ +module Chats + class ThreadCreator + def self.call(parent_message:, created_by:) + new(parent_message:, created_by:).call + end + + def initialize(parent_message:, created_by:) + @parent_message = parent_message + @created_by = created_by + end + + def call + existing_thread || create_thread + rescue ActiveRecord::RecordNotUnique + existing_thread || raise + end + + private + + attr_reader :parent_message, :created_by + + def existing_thread + parent_message.thread_conversation + end + + def create_thread + ChatConversation.create!( + kind: "thread", + parent_message:, + created_by: + ) + end + end +end diff --git a/backend/app/views/api/v1/chat_channels/_chat_channel.json.jbuilder b/backend/app/views/api/v1/chat_channels/_chat_channel.json.jbuilder new file mode 100644 index 0000000..7dfc526 --- /dev/null +++ b/backend/app/views/api/v1/chat_channels/_chat_channel.json.jbuilder @@ -0,0 +1,3 @@ +json.extract! chat_channel, :id, :group_id, :name, :description, :created_at, :updated_at +json.conversation_id chat_channel.chat_conversation_id +json.can_manage viewer.can_admin_group?(chat_channel.group_id) diff --git a/backend/app/views/api/v1/chat_channels/index.json.jbuilder b/backend/app/views/api/v1/chat_channels/index.json.jbuilder new file mode 100644 index 0000000..62b4641 --- /dev/null +++ b/backend/app/views/api/v1/chat_channels/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @chat_channels do |chat_channel| + json.partial! "api/v1/chat_channels/chat_channel", chat_channel: chat_channel, viewer: current_user +end diff --git a/backend/app/views/api/v1/chat_channels/show.json.jbuilder b/backend/app/views/api/v1/chat_channels/show.json.jbuilder new file mode 100644 index 0000000..929c8cf --- /dev/null +++ b/backend/app/views/api/v1/chat_channels/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "api/v1/chat_channels/chat_channel", chat_channel: @chat_channel, viewer: current_user diff --git a/backend/app/views/api/v1/chats/conversations/_conversation.json.jbuilder b/backend/app/views/api/v1/chats/conversations/_conversation.json.jbuilder new file mode 100644 index 0000000..3e95b36 --- /dev/null +++ b/backend/app/views/api/v1/chats/conversations/_conversation.json.jbuilder @@ -0,0 +1,36 @@ +channel = conversation.chat_channel +participants = conversation.participants.to_a +other_participant = participants.find { |participant| participant.id != viewer.id } || participants.first +parent_message = conversation.parent_message +title = + if conversation.direct? + other_participant&.display_name.presence || other_participant&.email || "Direct message" + elsif conversation.channel? + channel&.name || "Channel" + else + "Thread" + end + +json.extract! conversation, :id, :kind, :group_id, :last_message_at, :created_at, :updated_at +json.title title +json.group_name conversation.group&.name +json.channel_id channel&.id +json.channel_name channel&.name +json.can_manage conversation.channel? && conversation.group_id.present? && viewer.can_admin_group?(conversation.group_id) +json.participants participants do |participant| + json.partial! "api/v1/users/user", user: participant +end +json.parent_message do + if parent_message + json.partial! "api/v1/chats/messages/message", message: parent_message + else + json.null! + end +end +json.other_participant do + if conversation.direct? && other_participant + json.partial! "api/v1/users/user", user: other_participant + else + json.null! + end +end diff --git a/backend/app/views/api/v1/chats/conversations/index.json.jbuilder b/backend/app/views/api/v1/chats/conversations/index.json.jbuilder new file mode 100644 index 0000000..309b952 --- /dev/null +++ b/backend/app/views/api/v1/chats/conversations/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @conversations do |conversation| + json.partial! "api/v1/chats/conversations/conversation", conversation: conversation, viewer: current_user +end diff --git a/backend/app/views/api/v1/chats/conversations/show.json.jbuilder b/backend/app/views/api/v1/chats/conversations/show.json.jbuilder new file mode 100644 index 0000000..3d4d623 --- /dev/null +++ b/backend/app/views/api/v1/chats/conversations/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "api/v1/chats/conversations/conversation", conversation: @conversation, viewer: current_user diff --git a/backend/app/views/api/v1/chats/messages/_message.json.jbuilder b/backend/app/views/api/v1/chats/messages/_message.json.jbuilder new file mode 100644 index 0000000..8842e22 --- /dev/null +++ b/backend/app/views/api/v1/chats/messages/_message.json.jbuilder @@ -0,0 +1,10 @@ +thread_conversation = message.thread_conversation + +json.extract! message, :id, :chat_conversation_id, :body, :created_at, :updated_at +json.conversation_id message.chat_conversation_id +json.author do + json.partial! "api/v1/users/user", user: message.author +end +json.thread_conversation_id thread_conversation&.id +json.thread_reply_count thread_conversation ? thread_conversation.chat_messages.size : 0 +json.thread_last_message_at thread_conversation&.last_message_at diff --git a/backend/app/views/api/v1/chats/messages/index.json.jbuilder b/backend/app/views/api/v1/chats/messages/index.json.jbuilder new file mode 100644 index 0000000..a693c8d --- /dev/null +++ b/backend/app/views/api/v1/chats/messages/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @messages do |message| + json.partial! "api/v1/chats/messages/message", message: message +end diff --git a/backend/app/views/api/v1/chats/messages/show.json.jbuilder b/backend/app/views/api/v1/chats/messages/show.json.jbuilder new file mode 100644 index 0000000..1d5a153 --- /dev/null +++ b/backend/app/views/api/v1/chats/messages/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "api/v1/chats/messages/message", message: @message diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 4af49d0..01adbe1 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -28,9 +28,24 @@ end resources :users, only: [ :index, :show, :update ] - resources :groups, only: [ :index, :show, :create, :update, :destroy ] + resources :groups, only: [ :index, :show, :create, :update, :destroy ] do + resources :chat_channels, only: [ :index, :create ] + end get "search", to: "search#index" + namespace :chats do + resources :conversations, only: [ :index, :show ] do + resources :messages, only: [ :index, :create ] + end + resources :direct_conversations, only: [ :create ] + resources :messages, only: [] do + member do + post :thread + end + end + end + resources :chat_channels, only: [ :update, :destroy ] + resources :projects, only: [ :index, :show, :create, :update, :destroy ] do member do get :board diff --git a/backend/db/migrate/20260709104000_create_chats.rb b/backend/db/migrate/20260709104000_create_chats.rb new file mode 100644 index 0000000..0f2db51 --- /dev/null +++ b/backend/db/migrate/20260709104000_create_chats.rb @@ -0,0 +1,66 @@ +class CreateChats < ActiveRecord::Migration[8.1] + def change + create_table :chat_conversations do |t| + t.string :kind, null: false + t.string :direct_key + t.references :group, foreign_key: true + t.bigint :parent_message_id + t.references :created_by, null: false, foreign_key: { to_table: :users } + t.datetime :last_message_at + + t.timestamps + end + + add_index :chat_conversations, :kind + add_index :chat_conversations, :direct_key, unique: true, where: "direct_key IS NOT NULL" + add_index :chat_conversations, :parent_message_id, unique: true, where: "parent_message_id IS NOT NULL" + add_index :chat_conversations, [ :group_id, :kind ] + + create_table :chat_conversation_participants do |t| + t.references :chat_conversation, + null: false, + foreign_key: { on_delete: :cascade }, + index: false + t.references :user, null: false, foreign_key: { on_delete: :cascade } + + t.timestamps + end + + add_index :chat_conversation_participants, + [ :chat_conversation_id, :user_id ], + unique: true, + name: "index_chat_participants_on_conversation_and_user" + + create_table :chat_channels do |t| + t.references :group, null: false, foreign_key: { on_delete: :cascade } + t.references :chat_conversation, + null: false, + foreign_key: { on_delete: :cascade }, + index: { unique: true } + t.references :created_by, null: false, foreign_key: { to_table: :users } + t.string :name, null: false + t.text :description + + t.timestamps + end + + add_index :chat_channels, + "group_id, lower((name)::text)", + unique: true, + name: "index_chat_channels_on_group_and_lower_name" + + create_table :chat_messages do |t| + t.references :chat_conversation, null: false, foreign_key: { on_delete: :cascade } + t.references :author, null: false, foreign_key: { to_table: :users } + t.text :body, null: false + + t.timestamps + end + + add_index :chat_messages, [ :chat_conversation_id, :id ] + add_foreign_key :chat_conversations, + :chat_messages, + column: :parent_message_id, + on_delete: :cascade + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index f5f6b4f..8334656 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_07_09_103000) do +ActiveRecord::Schema[8.1].define(version: 2026_07_09_110000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" @@ -53,6 +53,57 @@ t.index ["singleton_guard"], name: "index_application_settings_on_singleton_guard", unique: true end + create_table "chat_channels", force: :cascade do |t| + t.bigint "chat_conversation_id", null: false + t.datetime "created_at", null: false + t.bigint "created_by_id", null: false + t.text "description" + t.bigint "group_id", null: false + t.string "name", null: false + t.datetime "updated_at", null: false + t.index "group_id, lower((name)::text)", name: "index_chat_channels_on_group_and_lower_name", unique: true + t.index ["chat_conversation_id"], name: "index_chat_channels_on_chat_conversation_id", unique: true + t.index ["created_by_id"], name: "index_chat_channels_on_created_by_id" + t.index ["group_id"], name: "index_chat_channels_on_group_id" + end + + create_table "chat_conversation_participants", force: :cascade do |t| + t.bigint "chat_conversation_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["chat_conversation_id", "user_id"], name: "index_chat_participants_on_conversation_and_user", unique: true + t.index ["user_id"], name: "index_chat_conversation_participants_on_user_id" + end + + create_table "chat_conversations", force: :cascade do |t| + t.datetime "created_at", null: false + t.bigint "created_by_id", null: false + t.string "direct_key" + t.bigint "group_id" + t.string "kind", null: false + t.datetime "last_message_at" + t.bigint "parent_message_id" + t.datetime "updated_at", null: false + t.index ["created_by_id"], name: "index_chat_conversations_on_created_by_id" + t.index ["direct_key"], name: "index_chat_conversations_on_direct_key", unique: true, where: "(direct_key IS NOT NULL)" + t.index ["group_id", "kind"], name: "index_chat_conversations_on_group_id_and_kind" + t.index ["group_id"], name: "index_chat_conversations_on_group_id" + t.index ["kind"], name: "index_chat_conversations_on_kind" + t.index ["parent_message_id"], name: "index_chat_conversations_on_parent_message_id", unique: true, where: "(parent_message_id IS NOT NULL)" + end + + create_table "chat_messages", force: :cascade do |t| + t.bigint "author_id", null: false + t.text "body", null: false + t.bigint "chat_conversation_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["author_id"], name: "index_chat_messages_on_author_id" + t.index ["chat_conversation_id", "id"], name: "index_chat_messages_on_chat_conversation_id_and_id" + t.index ["chat_conversation_id"], name: "index_chat_messages_on_chat_conversation_id" + end + create_table "drive_item_versions", force: :cascade do |t| t.bigint "byte_size" t.string "content_type" @@ -236,6 +287,7 @@ t.datetime "remember_created_at" t.datetime "reset_password_sent_at" t.string "reset_password_token" + t.json "skills", default: [], null: false t.boolean "system_admin", default: false, null: false t.string "title" t.datetime "updated_at", null: false @@ -246,6 +298,16 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "chat_channels", "chat_conversations", on_delete: :cascade + add_foreign_key "chat_channels", "groups", on_delete: :cascade + add_foreign_key "chat_channels", "users", column: "created_by_id" + add_foreign_key "chat_conversation_participants", "chat_conversations", on_delete: :cascade + add_foreign_key "chat_conversation_participants", "users", on_delete: :cascade + add_foreign_key "chat_conversations", "chat_messages", column: "parent_message_id", on_delete: :cascade + add_foreign_key "chat_conversations", "groups" + add_foreign_key "chat_conversations", "users", column: "created_by_id" + add_foreign_key "chat_messages", "chat_conversations", on_delete: :cascade + add_foreign_key "chat_messages", "users", column: "author_id" add_foreign_key "drive_item_versions", "drive_items", on_delete: :cascade add_foreign_key "drive_items", "drive_items", column: "parent_id", on_delete: :cascade add_foreign_key "drive_items", "projects", on_delete: :cascade diff --git a/backend/openapi/v1/openapi.yaml b/backend/openapi/v1/openapi.yaml index 07cde5d..d53064c 100644 --- a/backend/openapi/v1/openapi.yaml +++ b/backend/openapi/v1/openapi.yaml @@ -310,6 +310,171 @@ components: type: integer required: - group + chat_channel: + type: object + properties: + id: + type: integer + group_id: + type: integer + name: + type: string + description: + type: string + nullable: true + conversation_id: + type: integer + can_manage: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - group_id + - name + - description + - conversation_id + - can_manage + - created_at + - updated_at + chat_channel_request: + type: object + properties: + chat_channel: + type: object + properties: + name: + type: string + description: + type: string + required: + - name + required: + - chat_channel + chat_message: + type: object + properties: + id: + type: integer + chat_conversation_id: + type: integer + conversation_id: + type: integer + author: + "$ref": "#/components/schemas/user" + body: + type: string + thread_conversation_id: + type: integer + nullable: true + thread_reply_count: + type: integer + thread_last_message_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - chat_conversation_id + - conversation_id + - author + - body + - thread_conversation_id + - thread_reply_count + - thread_last_message_at + - created_at + - updated_at + chat_message_request: + type: object + properties: + message: + type: object + properties: + body: + type: string + required: + - body + required: + - message + chat_conversation: + type: object + properties: + id: + type: integer + kind: + type: string + enum: + - direct + - channel + - thread + title: + type: string + group_id: + type: integer + nullable: true + group_name: + type: string + nullable: true + channel_id: + type: integer + nullable: true + channel_name: + type: string + nullable: true + participants: + type: array + items: + "$ref": "#/components/schemas/user" + other_participant: + "$ref": "#/components/schemas/user" + nullable: true + parent_message: + "$ref": "#/components/schemas/chat_message" + nullable: true + last_message_at: + type: string + format: date-time + nullable: true + can_manage: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - kind + - title + - group_id + - group_name + - channel_id + - channel_name + - participants + - other_participant + - parent_message + - last_message_at + - can_manage + - created_at + - updated_at + direct_conversation_request: + type: object + properties: + user_id: + type: integer + required: + - user_id project: type: object properties: @@ -958,6 +1123,210 @@ paths: type: string required: - message + "/api/v1/chats/conversations": + get: + summary: Lists accessible chat conversations + tags: + - Chats + security: + - bearer_auth: [] + responses: + '200': + description: conversations returned + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/chat_conversation" + "/api/v1/chats/conversations/{id}": + parameters: + - name: id + in: path + required: true + schema: + type: integer + get: + summary: Returns a chat conversation + tags: + - Chats + security: + - bearer_auth: [] + responses: + '200': + description: conversation returned + content: + application/json: + schema: + "$ref": "#/components/schemas/chat_conversation" + "/api/v1/chats/direct_conversations": + post: + summary: Creates or returns a direct conversation + tags: + - Chats + security: + - bearer_auth: [] + parameters: [] + responses: + '201': + description: direct conversation returned + content: + application/json: + schema: + "$ref": "#/components/schemas/chat_conversation" + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/direct_conversation_request" + "/api/v1/groups/{group_id}/chat_channels": + parameters: + - name: group_id + in: path + required: true + schema: + type: integer + get: + summary: Lists group chat channels + tags: + - Chat Channels + security: + - bearer_auth: [] + responses: + '200': + description: channels returned + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/chat_channel" + post: + summary: Creates a group chat channel + tags: + - Chat Channels + security: + - bearer_auth: [] + parameters: [] + responses: + '201': + description: channel created + content: + application/json: + schema: + "$ref": "#/components/schemas/chat_channel" + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/chat_channel_request" + "/api/v1/chat_channels/{id}": + parameters: + - name: id + in: path + required: true + schema: + type: integer + patch: + summary: Updates a chat channel + tags: + - Chat Channels + security: + - bearer_auth: [] + parameters: [] + responses: + '200': + description: channel updated + content: + application/json: + schema: + "$ref": "#/components/schemas/chat_channel" + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/chat_channel_request" + delete: + summary: Deletes a chat channel + tags: + - Chat Channels + security: + - bearer_auth: [] + responses: + '204': + description: channel deleted + "/api/v1/chats/conversations/{conversation_id}/messages": + parameters: + - name: conversation_id + in: path + required: true + schema: + type: integer + get: + summary: Lists chat messages + tags: + - Chat Messages + security: + - bearer_auth: [] + parameters: + - name: before_id + in: query + required: false + schema: + type: integer + - name: limit + in: query + required: false + schema: + type: integer + responses: + '200': + description: messages returned + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/chat_message" + post: + summary: Creates a chat message + tags: + - Chat Messages + security: + - bearer_auth: [] + parameters: [] + responses: + '201': + description: message created + content: + application/json: + schema: + "$ref": "#/components/schemas/chat_message" + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/chat_message_request" + "/api/v1/chats/messages/{id}/thread": + parameters: + - name: id + in: path + required: true + schema: + type: integer + post: + summary: Creates or returns a message thread + tags: + - Chat Messages + security: + - bearer_auth: [] + responses: + '201': + description: thread conversation returned + content: + application/json: + schema: + "$ref": "#/components/schemas/chat_conversation" "/api/v1/projects/{project_id}/drive_items": parameters: - name: project_id diff --git a/backend/spec/factories/chat_channels.rb b/backend/spec/factories/chat_channels.rb new file mode 100644 index 0000000..2a336a5 --- /dev/null +++ b/backend/spec/factories/chat_channels.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :chat_channel do + association :group + association :chat_conversation, :channel + association :created_by, factory: :user + sequence(:name) { |index| "general-#{index}" } + description { "Team discussion" } + + after(:build) do |channel| + channel.chat_conversation.group ||= channel.group + channel.chat_conversation.created_by ||= channel.created_by + end + end +end diff --git a/backend/spec/factories/chat_conversation_participants.rb b/backend/spec/factories/chat_conversation_participants.rb new file mode 100644 index 0000000..85a0d85 --- /dev/null +++ b/backend/spec/factories/chat_conversation_participants.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :chat_conversation_participant do + association :chat_conversation, :direct + association :user + end +end diff --git a/backend/spec/factories/chat_conversations.rb b/backend/spec/factories/chat_conversations.rb new file mode 100644 index 0000000..e02f1d0 --- /dev/null +++ b/backend/spec/factories/chat_conversations.rb @@ -0,0 +1,23 @@ +FactoryBot.define do + factory :chat_conversation do + kind { "direct" } + association :created_by, factory: :user + + trait :direct do + kind { "direct" } + sequence(:direct_key) { |index| "#{index}:#{index + 1000}" } + end + + trait :channel do + kind { "channel" } + direct_key { nil } + association :group + end + + trait :thread do + kind { "thread" } + direct_key { nil } + association :parent_message, factory: :chat_message + end + end +end diff --git a/backend/spec/factories/chat_messages.rb b/backend/spec/factories/chat_messages.rb new file mode 100644 index 0000000..6a8346d --- /dev/null +++ b/backend/spec/factories/chat_messages.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :chat_message do + association :chat_conversation, :direct + association :author, factory: :user + body { "Hello **world**" } + end +end diff --git a/backend/spec/models/chat_channel_spec.rb b/backend/spec/models/chat_channel_spec.rb new file mode 100644 index 0000000..90e54f2 --- /dev/null +++ b/backend/spec/models/chat_channel_spec.rb @@ -0,0 +1,13 @@ +require "rails_helper" + +RSpec.describe ChatChannel, type: :model do + it "requires names to be unique within a group case-insensitively" do + group = create(:group) + create(:chat_channel, group:, name: "General") + + duplicate = build(:chat_channel, group:, name: "general") + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:name]).to be_present + end +end diff --git a/backend/spec/models/chat_conversation_spec.rb b/backend/spec/models/chat_conversation_spec.rb new file mode 100644 index 0000000..2222240 --- /dev/null +++ b/backend/spec/models/chat_conversation_spec.rb @@ -0,0 +1,33 @@ +require "rails_helper" + +RSpec.describe ChatConversation, type: :model do + it "requires unique direct keys for direct conversations" do + create(:chat_conversation, :direct, direct_key: "1:2") + + duplicate = build(:chat_conversation, :direct, direct_key: "1:2") + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:direct_key]).to be_present + end + + it "requires a unique top-level parent message for threads" do + parent_message = create(:chat_message) + create(:chat_conversation, :thread, parent_message:) + + duplicate = build(:chat_conversation, :thread, parent_message:) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:parent_message_id]).to be_present + end + + it "does not allow a thread to be created from a thread reply" do + parent_message = create(:chat_message) + thread = create(:chat_conversation, :thread, parent_message:) + reply = create(:chat_message, chat_conversation: thread) + + nested_thread = build(:chat_conversation, :thread, parent_message: reply) + + expect(nested_thread).not_to be_valid + expect(nested_thread.errors[:parent_message]).to be_present + end +end diff --git a/backend/spec/models/chat_message_spec.rb b/backend/spec/models/chat_message_spec.rb new file mode 100644 index 0000000..dc4a9ff --- /dev/null +++ b/backend/spec/models/chat_message_spec.rb @@ -0,0 +1,18 @@ +require "rails_helper" + +RSpec.describe ChatMessage, type: :model do + it "requires a markdown body" do + message = build(:chat_message, body: "") + + expect(message).not_to be_valid + expect(message.errors[:body]).to be_present + end + + it "updates the conversation last message timestamp" do + conversation = create(:chat_conversation, :direct) + + message = create(:chat_message, chat_conversation: conversation) + + expect(conversation.reload.last_message_at.to_i).to eq(message.created_at.to_i) + end +end diff --git a/backend/spec/openapi_helper.rb b/backend/spec/openapi_helper.rb index 7ee82a6..f947b90 100644 --- a/backend/spec/openapi_helper.rb +++ b/backend/spec/openapi_helper.rb @@ -212,6 +212,90 @@ }, required: %w[group] }, + chat_channel: { + type: :object, + properties: { + id: { type: :integer }, + group_id: { type: :integer }, + name: { type: :string }, + description: { type: :string, nullable: true }, + conversation_id: { type: :integer }, + can_manage: { type: :boolean }, + created_at: { type: :string, format: :"date-time" }, + updated_at: { type: :string, format: :"date-time" } + }, + required: %w[id group_id name description conversation_id can_manage created_at updated_at] + }, + chat_channel_request: { + type: :object, + properties: { + chat_channel: { + type: :object, + properties: { + name: { type: :string }, + description: { type: :string } + }, + required: %w[name] + } + }, + required: %w[chat_channel] + }, + chat_message: { + type: :object, + properties: { + id: { type: :integer }, + chat_conversation_id: { type: :integer }, + conversation_id: { type: :integer }, + author: { "$ref" => "#/components/schemas/user" }, + body: { type: :string }, + thread_conversation_id: { type: :integer, nullable: true }, + thread_reply_count: { type: :integer }, + thread_last_message_at: { type: :string, format: :"date-time", nullable: true }, + created_at: { type: :string, format: :"date-time" }, + updated_at: { type: :string, format: :"date-time" } + }, + required: %w[id chat_conversation_id conversation_id author body thread_conversation_id thread_reply_count thread_last_message_at created_at updated_at] + }, + chat_message_request: { + type: :object, + properties: { + message: { + type: :object, + properties: { + body: { type: :string } + }, + required: %w[body] + } + }, + required: %w[message] + }, + chat_conversation: { + type: :object, + properties: { + id: { type: :integer }, + kind: { type: :string, enum: %w[direct channel thread] }, + title: { type: :string }, + group_id: { type: :integer, nullable: true }, + group_name: { type: :string, nullable: true }, + channel_id: { type: :integer, nullable: true }, + channel_name: { type: :string, nullable: true }, + participants: { type: :array, items: { "$ref" => "#/components/schemas/user" } }, + other_participant: { "$ref" => "#/components/schemas/user", nullable: true }, + parent_message: { "$ref" => "#/components/schemas/chat_message", nullable: true }, + last_message_at: { type: :string, format: :"date-time", nullable: true }, + can_manage: { type: :boolean }, + created_at: { type: :string, format: :"date-time" }, + updated_at: { type: :string, format: :"date-time" } + }, + required: %w[id kind title group_id group_name channel_id channel_name participants other_participant parent_message last_message_at can_manage created_at updated_at] + }, + direct_conversation_request: { + type: :object, + properties: { + user_id: { type: :integer } + }, + required: %w[user_id] + }, project: { type: :object, properties: { diff --git a/backend/spec/requests/api/v1/chats_spec.rb b/backend/spec/requests/api/v1/chats_spec.rb new file mode 100644 index 0000000..93bead1 --- /dev/null +++ b/backend/spec/requests/api/v1/chats_spec.rb @@ -0,0 +1,182 @@ +require "rails_helper" + +# rubocop:disable RSpec/ExampleLength +RSpec.describe "Api::V1::Chats", type: :request do + describe "POST /api/v1/chats/direct_conversations" do + it "creates a unique direct conversation for two users" do + current_user = create(:user) + other_user = create(:user) + + post "/api/v1/chats/direct_conversations", + params: { user_id: other_user.id }, + headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:created) + expect(json_response["kind"]).to eq("direct") + expect(json_response["participants"].pluck("id")).to contain_exactly(current_user.id, other_user.id) + + post "/api/v1/chats/direct_conversations", + params: { user_id: other_user.id }, + headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:created) + expect(ChatConversation.where(kind: "direct").count).to eq(1) + end + + it "rejects direct conversations with yourself" do + current_user = create(:user) + + post "/api/v1/chats/direct_conversations", + params: { user_id: current_user.id }, + headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:unprocessable_content) + expect(json_response.dig("errors", "base")).to be_present + end + end + + describe "GET /api/v1/chats/conversations" do + it "lists accessible direct and channel conversations but not threads" do + current_user = create(:user) + other_user = create(:user) + group = create(:group) + inaccessible_group = create(:group) + create(:group_membership, user: current_user, group:) + direct = Chats::DirectConversationFinder.call(current_user:, other_user:) + channel = Chats::ChannelCreator.call(group:, created_by: current_user, attributes: { name: "general" }) + parent = create(:chat_message, chat_conversation: direct, author: current_user) + Chats::ThreadCreator.call(parent_message: parent, created_by: current_user) + Chats::ChannelCreator.call(group: inaccessible_group, created_by: other_user, attributes: { name: "private" }) + + get "/api/v1/chats/conversations", headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:ok) + expect(json_response.pluck("id")).to contain_exactly(direct.id, channel.chat_conversation_id) + end + end + + describe "chat channels" do + it "allows group admins to create, update, and delete channels" do + current_user = create(:user) + group = create(:group) + create(:group_membership, :admin, user: current_user, group:) + + post "/api/v1/groups/#{group.id}/chat_channels", + params: { chat_channel: { name: "general", description: "Team chat" } }, + headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:created) + channel_id = json_response["id"] + conversation_id = json_response["conversation_id"] + + patch "/api/v1/chat_channels/#{channel_id}", + params: { chat_channel: { name: "planning" } }, + headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:ok) + expect(json_response["name"]).to eq("planning") + + delete "/api/v1/chat_channels/#{channel_id}", headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:no_content) + expect(ChatConversation.exists?(conversation_id)).to be(false) + end + + it "prevents ordinary group members from creating channels" do + current_user = create(:user) + group = create(:group) + create(:group_membership, user: current_user, group:) + + post "/api/v1/groups/#{group.id}/chat_channels", + params: { chat_channel: { name: "general" } }, + headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:forbidden) + end + + it "lists channels for group members" do + current_user = create(:user) + admin = create(:user) + group = create(:group) + create(:group_membership, user: current_user, group:) + create(:group_membership, :admin, user: admin, group:) + channel = Chats::ChannelCreator.call(group:, created_by: admin, attributes: { name: "general" }) + + get "/api/v1/groups/#{group.id}/chat_channels", headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:ok) + expect(json_response.pluck("conversation_id")).to eq([ channel.chat_conversation_id ]) + end + end + + describe "messages" do + it "posts and paginates messages in ascending response order" do + current_user = create(:user) + other_user = create(:user) + conversation = Chats::DirectConversationFinder.call(current_user:, other_user:) + first = create(:chat_message, chat_conversation: conversation, author: current_user, body: "first") + second = create(:chat_message, chat_conversation: conversation, author: other_user, body: "second") + + post "/api/v1/chats/conversations/#{conversation.id}/messages", + params: { message: { body: "## third" } }, + headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:created) + expect(json_response["body"]).to eq("## third") + + get "/api/v1/chats/conversations/#{conversation.id}/messages", + params: { before_id: json_response["id"], limit: 2 }, + headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:ok) + expect(json_response.pluck("id")).to eq([ first.id, second.id ]) + end + + it "does not expose direct messages to non-participants" do + current_user = create(:user) + other_user = create(:user) + outsider = create(:user) + conversation = Chats::DirectConversationFinder.call(current_user:, other_user:) + + get "/api/v1/chats/conversations/#{conversation.id}/messages", + headers: auth_headers_for(outsider) + + expect(response).to have_http_status(:not_found) + end + end + + describe "POST /api/v1/chats/messages/:id/thread" do + it "creates a thread for a top-level message idempotently" do + current_user = create(:user) + other_user = create(:user) + conversation = Chats::DirectConversationFinder.call(current_user:, other_user:) + message = create(:chat_message, chat_conversation: conversation, author: current_user) + + post "/api/v1/chats/messages/#{message.id}/thread", headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:created) + thread_id = json_response["id"] + expect(json_response["kind"]).to eq("thread") + expect(json_response.dig("parent_message", "id")).to eq(message.id) + + post "/api/v1/chats/messages/#{message.id}/thread", headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:ok) + expect(json_response["id"]).to eq(thread_id) + end + + it "prevents creating nested threads" do + current_user = create(:user) + other_user = create(:user) + conversation = Chats::DirectConversationFinder.call(current_user:, other_user:) + message = create(:chat_message, chat_conversation: conversation, author: current_user) + thread = Chats::ThreadCreator.call(parent_message: message, created_by: current_user) + reply = create(:chat_message, chat_conversation: thread, author: current_user) + + post "/api/v1/chats/messages/#{reply.id}/thread", headers: auth_headers_for(current_user) + + expect(response).to have_http_status(:forbidden) + end + end +end +# rubocop:enable RSpec/ExampleLength diff --git a/backend/spec/requests/api/v1/openapi/chats_spec.rb b/backend/spec/requests/api/v1/openapi/chats_spec.rb new file mode 100644 index 0000000..e52df80 --- /dev/null +++ b/backend/spec/requests/api/v1/openapi/chats_spec.rb @@ -0,0 +1,224 @@ +require "openapi_helper" + +# rubocop:disable RSpec/NestedGroups, RSpec/RepeatedExampleGroupBody, RSpec/VariableName, RSpec/MultipleMemoizedHelpers +RSpec.describe "Api::V1::Chats", openapi_spec: "v1/openapi.yaml", type: :request do + let(:current_user) { create(:user) } + let(:Authorization) { auth_headers_for(current_user).fetch("Authorization") } + + path "/api/v1/chats/conversations" do + get "Lists accessible chat conversations" do + tags "Chats" + security [ bearer_auth: [] ] + produces "application/json" + + response "200", "conversations returned" do + schema type: :array, items: { "$ref" => "#/components/schemas/chat_conversation" } + + before do + other_user = create(:user) + Chats::DirectConversationFinder.call(current_user:, other_user:) + end + + run_test! + end + end + end + + path "/api/v1/chats/conversations/{id}" do + parameter name: :id, in: :path, type: :integer + + get "Returns a chat conversation" do + tags "Chats" + security [ bearer_auth: [] ] + produces "application/json" + + response "200", "conversation returned" do + schema "$ref" => "#/components/schemas/chat_conversation" + + let(:other_user) { create(:user) } + let(:conversation) { Chats::DirectConversationFinder.call(current_user:, other_user:) } + let(:id) { conversation.id } + + run_test! + end + end + end + + path "/api/v1/chats/direct_conversations" do + post "Creates or returns a direct conversation" do + tags "Chats" + security [ bearer_auth: [] ] + consumes "application/json" + produces "application/json" + parameter name: :request_params, in: :body, schema: { "$ref" => "#/components/schemas/direct_conversation_request" } + + response "201", "direct conversation returned" do + schema "$ref" => "#/components/schemas/chat_conversation" + + let(:other_user) { create(:user) } + let(:request_params) { { user_id: other_user.id } } + + run_test! + end + end + end + + path "/api/v1/groups/{group_id}/chat_channels" do + parameter name: :group_id, in: :path, type: :integer + + get "Lists group chat channels" do + tags "Chat Channels" + security [ bearer_auth: [] ] + produces "application/json" + + response "200", "channels returned" do + schema type: :array, items: { "$ref" => "#/components/schemas/chat_channel" } + + let(:group) { create(:group) } + let(:group_id) { group.id } + + before do + create(:group_membership, user: current_user, group:) + Chats::ChannelCreator.call(group:, created_by: current_user, attributes: { name: "general" }) + end + + run_test! + end + end + + post "Creates a group chat channel" do + tags "Chat Channels" + security [ bearer_auth: [] ] + consumes "application/json" + produces "application/json" + parameter name: :request_params, in: :body, schema: { "$ref" => "#/components/schemas/chat_channel_request" } + + response "201", "channel created" do + schema "$ref" => "#/components/schemas/chat_channel" + + let(:group) { create(:group) } + let(:group_id) { group.id } + let(:request_params) { { chat_channel: { name: "general", description: "Team chat" } } } + + before do + create(:group_membership, :admin, user: current_user, group:) + end + + run_test! + end + end + end + + path "/api/v1/chat_channels/{id}" do + parameter name: :id, in: :path, type: :integer + + patch "Updates a chat channel" do + tags "Chat Channels" + security [ bearer_auth: [] ] + consumes "application/json" + produces "application/json" + parameter name: :request_params, in: :body, schema: { "$ref" => "#/components/schemas/chat_channel_request" } + + response "200", "channel updated" do + schema "$ref" => "#/components/schemas/chat_channel" + + let(:group) { create(:group) } + let(:channel) { Chats::ChannelCreator.call(group:, created_by: current_user, attributes: { name: "general" }) } + let(:id) { channel.id } + let(:request_params) { { chat_channel: { name: "planning" } } } + + before do + create(:group_membership, :admin, user: current_user, group:) + end + + run_test! + end + end + + delete "Deletes a chat channel" do + tags "Chat Channels" + security [ bearer_auth: [] ] + + response "204", "channel deleted" do + let(:group) { create(:group) } + let(:channel) { Chats::ChannelCreator.call(group:, created_by: current_user, attributes: { name: "general" }) } + let(:id) { channel.id } + + before do + create(:group_membership, :admin, user: current_user, group:) + end + + run_test! + end + end + end + + path "/api/v1/chats/conversations/{conversation_id}/messages" do + parameter name: :conversation_id, in: :path, type: :integer + + get "Lists chat messages" do + tags "Chat Messages" + security [ bearer_auth: [] ] + produces "application/json" + parameter name: :before_id, in: :query, type: :integer, required: false + parameter name: :limit, in: :query, type: :integer, required: false + + response "200", "messages returned" do + schema type: :array, items: { "$ref" => "#/components/schemas/chat_message" } + + let(:other_user) { create(:user) } + let(:conversation) { Chats::DirectConversationFinder.call(current_user:, other_user:) } + let(:conversation_id) { conversation.id } + let(:before_id) { nil } + let(:limit) { nil } + + before do + create(:chat_message, chat_conversation: conversation, author: current_user) + end + + run_test! + end + end + + post "Creates a chat message" do + tags "Chat Messages" + security [ bearer_auth: [] ] + consumes "application/json" + produces "application/json" + parameter name: :request_params, in: :body, schema: { "$ref" => "#/components/schemas/chat_message_request" } + + response "201", "message created" do + schema "$ref" => "#/components/schemas/chat_message" + + let(:other_user) { create(:user) } + let(:conversation) { Chats::DirectConversationFinder.call(current_user:, other_user:) } + let(:conversation_id) { conversation.id } + let(:request_params) { { message: { body: "Hello **world**" } } } + + run_test! + end + end + end + + path "/api/v1/chats/messages/{id}/thread" do + parameter name: :id, in: :path, type: :integer + + post "Creates or returns a message thread" do + tags "Chat Messages" + security [ bearer_auth: [] ] + produces "application/json" + + response "201", "thread conversation returned" do + schema "$ref" => "#/components/schemas/chat_conversation" + + let(:other_user) { create(:user) } + let(:conversation) { Chats::DirectConversationFinder.call(current_user:, other_user:) } + let(:message) { create(:chat_message, chat_conversation: conversation, author: current_user) } + let(:id) { message.id } + + run_test! + end + end + end +end +# rubocop:enable RSpec/NestedGroups, RSpec/RepeatedExampleGroupBody, RSpec/VariableName, RSpec/MultipleMemoizedHelpers diff --git a/frontend/e2e/chats.spec.ts b/frontend/e2e/chats.spec.ts new file mode 100644 index 0000000..af9ed50 --- /dev/null +++ b/frontend/e2e/chats.spec.ts @@ -0,0 +1,319 @@ +import { expect, test, type Page, type Route } from '@playwright/test' + +const timestamp = '2026-07-09T00:00:00Z' +const authToken = 'Bearer e2e-token' + +interface UserPayload { + id: number + email: string + display_name: string + title: string | null + bio: string | null + system_admin: boolean + created_at: string + updated_at: string +} + +interface ConversationPayload { + id: number + kind: string + title: string + group_id: number | null + group_name: string | null + channel_id: number | null + channel_name: string | null + participants: UserPayload[] + other_participant: UserPayload | null + parent_message: MessagePayload | null + last_message_at: string | null + can_manage: boolean + created_at: string + updated_at: string +} + +interface MessagePayload { + id: number + chat_conversation_id: number + conversation_id: number + author: UserPayload + body: string + thread_conversation_id: number | null + thread_reply_count: number + thread_last_message_at: string | null + created_at: string + updated_at: string +} + +async function json(route: Route, body: unknown, status = 200) { + await route.fulfill({ + status, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +async function installMockApi(page: Page) { + let nextMessageId = 30 + const currentUser = userPayload(1, 'founder@example.com', 'Founder') + const otherUser = userPayload(2, 'teammate@example.com', 'Teammate') + const group = { + id: 1, + name: 'Engineering', + description: null, + parent_group_id: null, + parent_group_name: null, + user_ids: [1, 2], + users_count: 2, + admin_user_ids: [1], + admins_count: 1, + can_admin: true, + created_at: timestamp, + updated_at: timestamp, + } + const conversations = [ + conversationPayload({ + id: 10, + kind: 'channel', + title: 'general', + group_id: 1, + group_name: 'Engineering', + channel_id: 100, + channel_name: 'general', + can_manage: true, + }), + ] + const channels = [ + { + id: 100, + group_id: 1, + name: 'general', + description: 'Team chat', + conversation_id: 10, + can_manage: true, + created_at: timestamp, + updated_at: timestamp, + }, + ] + const messagesByConversation = new Map([ + [ + 10, + [ + messagePayload({ + id: 20, + conversation_id: 10, + author: otherUser, + body: 'Welcome to **general**', + }), + ], + ], + ]) + let threadConversation: ConversationPayload | null = null + + await page.addInitScript((token) => { + localStorage.setItem('upopoy.authToken', token) + }, authToken) + + await page.route('**/api/v1/auth/me', async (route) => { + await json(route, { user: currentUser }) + }) + + await page.route('**/api/v1/projects', async (route) => { + await json(route, []) + }) + + await page.route('**/api/v1/groups', async (route) => { + await json(route, [group]) + }) + + await page.route('**/api/v1/search**', async (route) => { + await json(route, { + results: [ + { + slug: 'user:2', + type: 'user', + id: 2, + title: 'Teammate', + snippet: 'teammate@example.com', + api_path: '/api/v1/users/2', + metadata: {}, + updated_at: timestamp, + }, + ], + }) + }) + + await page.route('**/api/v1/chats/conversations', async (route) => { + await json(route, conversations) + }) + + await page.route('**/api/v1/chats/direct_conversations', async (route) => { + const direct = conversationPayload({ + id: 11, + kind: 'direct', + title: 'Teammate', + participants: [currentUser, otherUser], + other_participant: otherUser, + }) + conversations.unshift(direct) + messagesByConversation.set(11, []) + await json(route, direct, 201) + }) + + await page.route('**/api/v1/groups/1/chat_channels', async (route) => { + if (route.request().method() === 'GET') { + await json(route, channels) + return + } + + const requestBody = route.request().postDataJSON() as { chat_channel: { name: string; description?: string } } + const channel = { + id: 101, + group_id: 1, + name: requestBody.chat_channel.name, + description: requestBody.chat_channel.description ?? null, + conversation_id: 12, + can_manage: true, + created_at: timestamp, + updated_at: timestamp, + } + channels.push(channel) + conversations.unshift( + conversationPayload({ + id: 12, + kind: 'channel', + title: channel.name, + group_id: 1, + group_name: 'Engineering', + channel_id: channel.id, + channel_name: channel.name, + can_manage: true, + }), + ) + messagesByConversation.set(12, []) + await json(route, channel, 201) + }) + + await page.route('**/api/v1/chats/conversations/*/messages', async (route) => { + const conversationId = Number(route.request().url().split('/').at(-2)) + if (route.request().method() === 'GET') { + await json(route, messagesByConversation.get(conversationId) ?? []) + return + } + + const requestBody = route.request().postDataJSON() as { message: { body: string } } + const message = messagePayload({ + id: nextMessageId++, + conversation_id: conversationId, + author: currentUser, + body: requestBody.message.body, + }) + messagesByConversation.set(conversationId, [...(messagesByConversation.get(conversationId) ?? []), message]) + await json(route, message, 201) + }) + + await page.route('**/api/v1/chats/messages/20/thread', async (route) => { + threadConversation = conversationPayload({ + id: 13, + kind: 'thread', + title: 'Thread', + parent_message: messagesByConversation.get(10)?.[0], + }) + messagesByConversation.set(13, []) + await json(route, threadConversation, 201) + }) + + await page.route('**/api/v1/chats/conversations/*', async (route) => { + const id = Number(route.request().url().split('/').at(-1)) + await json(route, [...conversations, threadConversation].find((conversation) => conversation?.id === id)) + }) +} + +function userPayload(id: number, email: string, displayName: string): UserPayload { + return { + id, + email, + display_name: displayName, + title: null, + bio: null, + system_admin: false, + created_at: timestamp, + updated_at: timestamp, + } +} + +function conversationPayload(input: { + id: number + kind: string + title: string + group_id?: number + group_name?: string + channel_id?: number + channel_name?: string + participants?: UserPayload[] + other_participant?: UserPayload + parent_message?: MessagePayload + can_manage?: boolean +}): ConversationPayload { + return { + id: input.id, + kind: input.kind, + title: input.title, + group_id: input.group_id ?? null, + group_name: input.group_name ?? null, + channel_id: input.channel_id ?? null, + channel_name: input.channel_name ?? null, + participants: input.participants ?? [], + other_participant: input.other_participant ?? null, + parent_message: input.parent_message ?? null, + last_message_at: null, + can_manage: input.can_manage ?? false, + created_at: timestamp, + updated_at: timestamp, + } +} + +function messagePayload(input: { id: number; conversation_id: number; author: UserPayload; body: string }): MessagePayload { + return { + id: input.id, + chat_conversation_id: input.conversation_id, + conversation_id: input.conversation_id, + author: input.author, + body: input.body, + thread_conversation_id: null, + thread_reply_count: 0, + thread_last_message_at: null, + created_at: timestamp, + updated_at: timestamp, + } +} + +test('uses chats, channels, markdown messages, and threads', async ({ page }) => { + await installMockApi(page) + + await page.goto('/chats') + + await expect(page.getByText('general')).toBeVisible() + await page.getByRole('button', { name: 'general' }).click() + await expect(page.locator('.markdown-body')).toContainText('Welcome to general') + + await page.getByLabel('Chat message').fill('## Update\n\n- Shipped') + await page.getByRole('button', { name: 'Send' }).click() + await expect(page.getByText('Update Shipped')).toBeVisible() + + await expect(page.getByRole('button', { name: 'Start thread' })).toHaveCount(0) + await page.getByText('Welcome to general').click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Start thread' }).click() + await expect(page.getByText('Parent message')).toBeVisible() + await page.getByLabel('Thread reply').fill('Thread **reply**') + await page.locator('aside').getByRole('button', { name: 'Send' }).click() + await expect(page.locator('aside').getByText('Thread reply')).toBeVisible() + + await page.getByLabel('Start a direct message').fill('Team') + await page.getByRole('button', { name: /Teammate/ }).click() + await expect(page.getByRole('heading', { name: 'Teammate' })).toBeVisible() + + await page.getByLabel('New channel in Engineering').click() + await page.getByLabel('Name').fill('planning') + await page.getByRole('button', { name: 'Save channel' }).click() + await expect(page.getByText('planning')).toBeVisible() +}) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index bd77761..0e61754 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -9,6 +9,7 @@ import GlobalSearch from '@/components/search/GlobalSearch.vue' import { nextRouteForAccess } from '@/router/access' import { useAuthStore } from '@/stores/auth' import { useBoardStore } from '@/stores/board' +import { useChatsStore } from '@/stores/chats' import { useDriveStore } from '@/stores/drive' import { useProjectsStore } from '@/stores/projects' import { useToastsStore } from '@/stores/toasts' @@ -19,6 +20,7 @@ import { SERVER_UNAVAILABLE_EVENT, type SearchResult } from '@/services/api' const authStore = useAuthStore() const projectsStore = useProjectsStore() const boardStore = useBoardStore() +const chatsStore = useChatsStore() const driveStore = useDriveStore() const userGroupsStore = useUserGroupsStore() const toasts = useToastsStore() @@ -87,6 +89,7 @@ async function signOut() { await authStore.logout() projectsStore.clearProjects() boardStore.clearBoard() + chatsStore.clearChats() driveStore.clearDrive() userGroupsStore.clearDirectory() toasts.clearToasts() diff --git a/frontend/src/components/chats/ChannelDialog.vue b/frontend/src/components/chats/ChannelDialog.vue new file mode 100644 index 0000000..3635fc9 --- /dev/null +++ b/frontend/src/components/chats/ChannelDialog.vue @@ -0,0 +1,80 @@ + + +