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..e7ea728 --- /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_organization, only: [ :index, :create ] + before_action :set_chat_channel, only: [ :update, :destroy ] + + def index + authorize ChatChannel + @chat_channels = policy_scope(@organization.chat_channels) + .includes(:organization, :chat_conversation) + .order(:name) + end + + def create + chat_channel = @organization.chat_channels.new(channel_params.merge(created_by: current_user)) + authorize chat_channel + + @chat_channel = ::Chats::ChannelCreator.call( + organization: @organization, + 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_organization + @organization = policy_scope(Organization).find(params[:organization_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..70a5e6f --- /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(:organization, :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(:organization, :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/controllers/api/v1/groups_controller.rb b/backend/app/controllers/api/v1/organizations_controller.rb similarity index 55% rename from backend/app/controllers/api/v1/groups_controller.rb rename to backend/app/controllers/api/v1/organizations_controller.rb index 12a2c74..f8973ea 100644 --- a/backend/app/controllers/api/v1/groups_controller.rb +++ b/backend/app/controllers/api/v1/organizations_controller.rb @@ -1,65 +1,65 @@ module Api module V1 - class GroupsController < BaseController - before_action :set_group, only: [ :show, :update, :destroy ] + class OrganizationsController < BaseController + before_action :set_organization, only: [ :show, :update, :destroy ] def index - @groups = policy_scope(Group).includes(:parent_group, :group_memberships, :users).order(:name) + @organizations = policy_scope(Organization).includes(:organization_memberships, :users).order(:name) end def show - authorize @group + authorize @organization end def create - group = Group.new + organization = Organization.new - persist_group(group, :created) + persist_organization(organization, :created) end def update - persist_group(@group) + persist_organization(@organization) end def destroy - authorize @group - @group.destroy! + authorize @organization + @organization.destroy! head :no_content end private - def set_group - @group = policy_scope(Group).find(params[:id]) + def set_organization + @organization = policy_scope(Organization).find(params[:id]) end - def persist_group(group, status = :ok) - attributes = group_params + def persist_organization(organization, status = :ok) + attributes = organization_params user_ids = normalize_user_ids(attributes.delete(:user_ids)) admin_user_ids = normalize_user_ids(attributes.delete(:admin_user_ids)) - user_ids = include_current_user(user_ids) if group.new_record? - admin_user_ids = include_current_user(admin_user_ids) if group.new_record? + user_ids = include_current_user(user_ids) if organization.new_record? + admin_user_ids = include_current_user(admin_user_ids) if organization.new_record? - group.assign_attributes(attributes) - authorize group + organization.assign_attributes(attributes) + authorize organization return render_invalid_user_ids(:user_ids) unless valid_user_ids?(user_ids) return render_invalid_user_ids(:admin_user_ids) unless valid_user_ids?(admin_user_ids) - Group.transaction do - group.save! - sync_group_memberships(group, user_ids, admin_user_ids) - ensure_group_has_admin!(group) + Organization.transaction do + organization.save! + sync_organization_memberships(organization, user_ids, admin_user_ids) + ensure_organization_has_admin!(organization) end - @group = group.reload + @organization = organization.reload render :show, status: rescue ActiveRecord::RecordInvalid - render_errors(group) + render_errors(organization) end - def group_params - params.require(:group).permit(:name, :description, :parent_group_id, user_ids: [], admin_user_ids: []) + def organization_params + params.require(:organization).permit(:name, :description, user_ids: [], admin_user_ids: []) end def normalize_user_ids(raw_user_ids) @@ -89,27 +89,27 @@ def include_current_user(user_ids) (user_ids + [ current_user.id ]).uniq end - def sync_group_memberships(group, user_ids, admin_user_ids) + def sync_organization_memberships(organization, user_ids, admin_user_ids) return if user_ids.nil? && admin_user_ids.nil? - memberships = group.group_memberships.index_by(&:user_id) + memberships = organization.organization_memberships.index_by(&:user_id) final_user_ids = user_ids || memberships.keys final_admin_user_ids = admin_user_ids || memberships.values.select(&:admin?).map(&:user_id) final_user_ids = (final_user_ids + final_admin_user_ids).uniq if final_admin_user_ids.empty? - group.errors.add(:admin_user_ids, :blank) - raise ActiveRecord::RecordInvalid, group + organization.errors.add(:admin_user_ids, :blank) + raise ActiveRecord::RecordInvalid, organization end final_admin_user_ids.each do |user_id| - membership = memberships[user_id] || group.group_memberships.build(user_id:) + membership = memberships[user_id] || organization.organization_memberships.build(user_id:) membership.admin = true membership.save! end (final_user_ids - final_admin_user_ids).each do |user_id| - membership = memberships[user_id] || group.group_memberships.build(user_id:) + membership = memberships[user_id] || organization.organization_memberships.build(user_id:) membership.admin = false membership.save! end @@ -119,11 +119,11 @@ def sync_group_memberships(group, user_ids, admin_user_ids) end end - def ensure_group_has_admin!(group) - return if group.group_memberships.where(admin: true).exists? + def ensure_organization_has_admin!(organization) + return if organization.organization_memberships.where(admin: true).exists? - group.errors.add(:admin_user_ids, :blank) - raise ActiveRecord::RecordInvalid, group + organization.errors.add(:admin_user_ids, :blank) + raise ActiveRecord::RecordInvalid, organization end def render_invalid_user_ids(attribute) diff --git a/backend/app/controllers/api/v1/projects_controller.rb b/backend/app/controllers/api/v1/projects_controller.rb index fc54b65..b35bcf7 100644 --- a/backend/app/controllers/api/v1/projects_controller.rb +++ b/backend/app/controllers/api/v1/projects_controller.rb @@ -4,7 +4,7 @@ class ProjectsController < BaseController before_action :set_project, only: [ :show, :update, :destroy, :board ] def index - @projects = policy_scope(Project).includes(:group).order(created_at: :desc) + @projects = policy_scope(Project).includes(:owner).order(created_at: :desc) end def show @@ -53,11 +53,11 @@ def board private def set_project - @project = policy_scope(Project).includes(:group).find(params[:id]) + @project = policy_scope(Project).includes(:owner).find(params[:id]) end def project_params - params.require(:project).permit(:name, :description, :group_id) + params.require(:project).permit(:name, :description, :owner_type, :owner_id) end end end diff --git a/backend/app/controllers/api/v1/users_controller.rb b/backend/app/controllers/api/v1/users_controller.rb index 43bbe64..5b1554f 100644 --- a/backend/app/controllers/api/v1/users_controller.rb +++ b/backend/app/controllers/api/v1/users_controller.rb @@ -5,7 +5,7 @@ class UsersController < BaseController def index @users = policy_scope(User) - .includes(:groups) + .includes(:organizations) .order(:email) .page(page_param) .per(per_page_param) diff --git a/backend/app/models/chat_channel.rb b/backend/app/models/chat_channel.rb new file mode 100644 index 0000000..ed36e55 --- /dev/null +++ b/backend/app/models/chat_channel.rb @@ -0,0 +1,23 @@ +class ChatChannel < ApplicationRecord + belongs_to :organization + belongs_to :chat_conversation + belongs_to :created_by, class_name: "User" + + validates :name, presence: true, uniqueness: { scope: :organization_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..974c2a1 --- /dev/null +++ b/backend/app/models/chat_conversation.rb @@ -0,0 +1,59 @@ +class ChatConversation < ApplicationRecord + KINDS = %w[ direct channel thread ].freeze + + belongs_to :organization, 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 :organization, 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) + organization_ids = user.system_admin? ? Organization.select(:id) : OrganizationMembership.accessible_organization_ids_for(user) + parent_conversation_ids = ChatConversation + .where(id: direct_ids) + .or(ChatConversation.where(kind: "channel", organization_id: organization_ids)) + .select(:id) + parent_message_ids = ChatMessage.where(chat_conversation_id: parent_conversation_ids).select(:id) + + where(id: direct_ids) + .or(where(kind: "channel", organization_id: organization_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/drive_item.rb b/backend/app/models/drive_item.rb index fabbc55..98606cd 100644 --- a/backend/app/models/drive_item.rb +++ b/backend/app/models/drive_item.rb @@ -115,17 +115,15 @@ def search_content text_content_cache end - def search_owner_user_id - nil - end - - def search_owner_group_id - project&.group_id + def search_owner + project&.owner end def search_metadata { project_id: project_id, + owner_type: project&.owner_type, + owner_id: project&.owner_id, parent_id: parent_id, kind: kind, markdown: markdown?, diff --git a/backend/app/models/group.rb b/backend/app/models/group.rb deleted file mode 100644 index 225bd5f..0000000 --- a/backend/app/models/group.rb +++ /dev/null @@ -1,91 +0,0 @@ -class Group < ApplicationRecord - include SearchableResource - - attr_accessor :admin_user_ids - - search_index_attributes :name, :description - - belongs_to :parent_group, - class_name: "Group", - optional: true, - inverse_of: :groups - - has_many :groups, - class_name: "Group", - foreign_key: :parent_group_id, - inverse_of: :parent_group, - dependent: :nullify - has_many :group_memberships, dependent: :destroy - has_many :users, through: :group_memberships - has_many :projects, dependent: :destroy - has_many :ancestor_group_hierarchies, - class_name: "GroupHierarchy", - foreign_key: :descendant_group_id, - dependent: :destroy, - inverse_of: :descendant_group - has_many :ancestors, through: :ancestor_group_hierarchies, source: :ancestor_group - has_many :descendant_group_hierarchies, - class_name: "GroupHierarchy", - foreign_key: :ancestor_group_id, - dependent: :destroy, - inverse_of: :ancestor_group - has_many :descendants, through: :descendant_group_hierarchies, source: :descendant_group - - validates :name, presence: true - validate :parent_group_cannot_be_self - validate :parent_group_cannot_create_cycle - - after_create :rebuild_group_hierarchies - after_update :rebuild_group_hierarchies, if: :saved_change_to_parent_group_id? - after_destroy :rebuild_group_hierarchies - - def search_title - name - end - - def search_content - description - end - - def search_owner_user_id - nil - end - - def search_owner_group_id - id - end - - def search_metadata - {} - end - - def search_api_path - "/api/v1/groups/#{id}" - end - - private - - def parent_group_cannot_be_self - return if parent_group_id.blank? || parent_group_id != id - - errors.add(:parent_group, "cannot be itself") - end - - def parent_group_cannot_create_cycle - return if parent_group_id.blank? || id.blank? - - ancestor = parent_group - while ancestor - if ancestor.id == id - errors.add(:parent_group, "cannot be a child group") - break - end - - ancestor = ancestor.parent_group - end - end - - def rebuild_group_hierarchies - GroupHierarchy.rebuild! - end -end diff --git a/backend/app/models/group_hierarchy.rb b/backend/app/models/group_hierarchy.rb deleted file mode 100644 index 321a03d..0000000 --- a/backend/app/models/group_hierarchy.rb +++ /dev/null @@ -1,48 +0,0 @@ -class GroupHierarchy < ApplicationRecord - belongs_to :ancestor_group, class_name: "Group" - belongs_to :descendant_group, class_name: "Group" - - validates :ancestor_group_id, presence: true - validates :descendant_group_id, presence: true - validates :depth, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :ancestor_group_id, uniqueness: { scope: :descendant_group_id } - - def self.accessible_group_ids_for(user) - joins(ancestor_group: :group_memberships) - .where(group_memberships: { user_id: user.id }) - .distinct - .select(:descendant_group_id) - end - - def self.adminable_group_ids_for(user) - joins(ancestor_group: :group_memberships) - .where(group_memberships: { user_id: user.id, admin: true }) - .distinct - .select(:descendant_group_id) - end - - def self.rebuild! - transaction do - delete_all - connection.execute <<~SQL.squish - WITH RECURSIVE hierarchy AS ( - SELECT id AS ancestor_group_id, id AS descendant_group_id, 0 AS depth - FROM groups - UNION ALL - SELECT hierarchy.ancestor_group_id, groups.id, hierarchy.depth + 1 - FROM hierarchy - JOIN groups ON groups.parent_group_id = hierarchy.descendant_group_id - ) - INSERT INTO group_hierarchies ( - ancestor_group_id, - descendant_group_id, - depth, - created_at, - updated_at - ) - SELECT ancestor_group_id, descendant_group_id, depth, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP - FROM hierarchy - SQL - end - end -end diff --git a/backend/app/models/group_membership.rb b/backend/app/models/group_membership.rb deleted file mode 100644 index 0be600a..0000000 --- a/backend/app/models/group_membership.rb +++ /dev/null @@ -1,32 +0,0 @@ -class GroupMembership < ApplicationRecord - belongs_to :group - belongs_to :user - - validates :user_id, uniqueness: { scope: :group_id } - validate :group_must_keep_admin, if: :removing_admin? - - before_destroy :ensure_group_keeps_admin - - private - - def removing_admin? - persisted? && will_save_change_to_admin? && admin == false - end - - def group_must_keep_admin - return if group_has_another_admin? - - errors.add(:admin, "must have at least one group admin") - end - - def ensure_group_keeps_admin - return if destroyed_by_association.present? || group_has_another_admin? - - errors.add(:admin, "must have at least one group admin") - throw :abort - end - - def group_has_another_admin? - group.group_memberships.where(admin: true).where.not(id:).exists? - end -end diff --git a/backend/app/models/organization.rb b/backend/app/models/organization.rb new file mode 100644 index 0000000..f510aba --- /dev/null +++ b/backend/app/models/organization.rb @@ -0,0 +1,35 @@ +class Organization < ApplicationRecord + include SearchableResource + + attr_accessor :admin_user_ids + + search_index_attributes :name, :description + + has_many :organization_memberships, dependent: :destroy + has_many :users, through: :organization_memberships + has_many :projects, as: :owner, dependent: :destroy + has_many :chat_conversations, dependent: :destroy + has_many :chat_channels, dependent: :destroy + + validates :name, presence: true + + def search_title + name + end + + def search_content + description + end + + def search_owner + self + end + + def search_metadata + {} + end + + def search_api_path + "/api/v1/organizations/#{id}" + end +end diff --git a/backend/app/models/organization_membership.rb b/backend/app/models/organization_membership.rb new file mode 100644 index 0000000..e8faa45 --- /dev/null +++ b/backend/app/models/organization_membership.rb @@ -0,0 +1,35 @@ +class OrganizationMembership < ApplicationRecord + belongs_to :organization + belongs_to :user + + validates :user_id, uniqueness: { scope: :organization_id } + validate :organization_must_keep_admin, if: :removing_admin? + + before_destroy :ensure_organization_keeps_admin + + scope :accessible_organization_ids_for, ->(user) { where(user_id: user.id).select(:organization_id) } + scope :adminable_organization_ids_for, ->(user) { where(user_id: user.id, admin: true).select(:organization_id) } + + private + + def removing_admin? + persisted? && will_save_change_to_admin? && admin == false + end + + def organization_must_keep_admin + return if organization_has_another_admin? + + errors.add(:admin, "must have at least one organization admin") + end + + def ensure_organization_keeps_admin + return if destroyed_by_association.present? || organization_has_another_admin? + + errors.add(:admin, "must have at least one organization admin") + throw :abort + end + + def organization_has_another_admin? + organization.organization_memberships.where(admin: true).where.not(id:).exists? + end +end diff --git a/backend/app/models/project.rb b/backend/app/models/project.rb index 9939ce5..a2a3bb6 100644 --- a/backend/app/models/project.rb +++ b/backend/app/models/project.rb @@ -1,10 +1,12 @@ class Project < ApplicationRecord include SearchableResource - search_index_attributes :name, :description, :user_id, :group_id + OWNER_TYPES = %w[User Organization].freeze + + search_index_attributes :name, :description, :user_id, :owner_type, :owner_id belongs_to :user - belongs_to :group + belongs_to :owner, polymorphic: true has_many :iterations, dependent: :destroy has_many :tasks, dependent: :destroy has_many :drive_items, dependent: :destroy @@ -12,6 +14,8 @@ class Project < ApplicationRecord after_create :ensure_inbox_iteration validates :name, presence: true + validates :owner_type, inclusion: { in: OWNER_TYPES } + validates :owner, presence: true def inbox_iteration iterations.find_or_create_by!(inbox: true) do |iteration| @@ -28,15 +32,15 @@ def search_content end def search_owner_user_id - nil + owner_id if owner_type == "User" end - def search_owner_group_id - group_id + def search_owner + owner end def search_metadata - { group_id: group_id } + { owner_type:, owner_id: } end def search_api_path diff --git a/backend/app/models/search_document.rb b/backend/app/models/search_document.rb index f267d2a..68dbbfa 100644 --- a/backend/app/models/search_document.rb +++ b/backend/app/models/search_document.rb @@ -4,12 +4,11 @@ class SearchDocument < ApplicationRecord "project" => "Project", "task" => "Task", "user" => "User", - "group" => "Group" + "organization" => "Organization" }.freeze belongs_to :searchable, polymorphic: true - belongs_to :user, optional: true - belongs_to :group, optional: true + belongs_to :owner, polymorphic: true, optional: true validates :resource_slug, presence: true, uniqueness: true validates :resource_type, presence: true, inclusion: { in: RESOURCE_TYPES.keys } @@ -22,18 +21,18 @@ class SearchDocument < ApplicationRecord scope :visible_to, lambda { |user| return none if user.blank? - where(user_id: nil, group_id: nil) - .or(where(user_id: user.id)) - .or(where(group_id: GroupHierarchy.accessible_group_ids_for(user))) + where(owner_type: nil, owner_id: nil) + .or(where(owner: user)) + .or(where(owner_type: "Organization", owner_id: OrganizationMembership.accessible_organization_ids_for(user))) } scope :of_resource_type, ->(resource_type) { where(resource_type:) if resource_type.present? } def self.upsert_for(resource) document = find_or_initialize_by(searchable: resource) + owner = resource.search_owner if resource.respond_to?(:search_owner) document.assign_attributes( - user_id: resource.search_owner_user_id, - group_id: resource.respond_to?(:search_owner_group_id) ? resource.search_owner_group_id : nil, + owner:, resource_slug: resource.resource_slug, resource_type: resource.class.search_resource_type, title: resource.search_title, diff --git a/backend/app/models/task.rb b/backend/app/models/task.rb index 0ca4625..b187e3f 100644 --- a/backend/app/models/task.rb +++ b/backend/app/models/task.rb @@ -81,16 +81,12 @@ def search_content ].compact.join("\n") end - def search_owner_user_id - nil - end - - def search_owner_group_id - project&.group_id + def search_owner + project&.owner end def search_metadata - { project_id: project_id, group_id: project&.group_id, iteration_id: iteration_id } + { project_id: project_id, owner_type: project&.owner_type, owner_id: project&.owner_id, iteration_id: iteration_id } end def search_api_path diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index ed98952..51f48a8 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -14,32 +14,50 @@ class User < ApplicationRecord jwt_revocation_strategy: self has_many :projects, dependent: :destroy - has_many :group_memberships, dependent: :destroy - has_many :groups, through: :group_memberships + has_many :organization_memberships, dependent: :destroy + has_many :organizations, through: :organization_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 - return Group.ids if system_admin? + def accessible_organization_ids + return Organization.ids if system_admin? - @accessible_group_ids ||= GroupHierarchy.accessible_group_ids_for(self).pluck(:descendant_group_id) + @accessible_organization_ids ||= OrganizationMembership.accessible_organization_ids_for(self).pluck(:organization_id) end - def can_access_group?(group_id) - return group_id.present? if system_admin? + def can_access_organization?(organization_id) + return organization_id.present? if system_admin? - group_id.present? && accessible_group_ids.include?(group_id) + organization_id.present? && accessible_organization_ids.include?(organization_id) end - def adminable_group_ids - return Group.ids if system_admin? + def adminable_organization_ids + return Organization.ids if system_admin? - @adminable_group_ids ||= GroupHierarchy.adminable_group_ids_for(self).pluck(:descendant_group_id) + @adminable_organization_ids ||= OrganizationMembership.adminable_organization_ids_for(self).pluck(:organization_id) end - def can_admin_group?(group_id) - return group_id.present? if system_admin? + def can_admin_organization?(organization_id) + return organization_id.present? if system_admin? - group_id.present? && adminable_group_ids.include?(group_id) + organization_id.present? && adminable_organization_ids.include?(organization_id) end def self.from_omniauth(auth) diff --git a/backend/app/policies/chat_channel_policy.rb b/backend/app/policies/chat_channel_policy.rb new file mode 100644 index 0000000..bfc634a --- /dev/null +++ b/backend/app/policies/chat_channel_policy.rb @@ -0,0 +1,40 @@ +class ChatChannelPolicy < ApplicationPolicy + def index? + user.present? + end + + def show? + organization_member? + end + + def create? + organization_admin? + end + + def update? + organization_admin? + end + + def destroy? + organization_admin? + end + + class Scope < ApplicationPolicy::Scope + def resolve + return scope.none if user.blank? + + organization_ids = user.system_admin? ? Organization.select(:id) : OrganizationMembership.accessible_organization_ids_for(user) + scope.where(organization_id: organization_ids) + end + end + + private + + def organization_member? + user.present? && record.organization_id.present? && user.can_access_organization?(record.organization_id) + end + + def organization_admin? + user.present? && record.organization_id.present? && user.can_admin_organization?(record.organization_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/policies/drive_item_policy.rb b/backend/app/policies/drive_item_policy.rb index b0e88b6..4eb6ed1 100644 --- a/backend/app/policies/drive_item_policy.rb +++ b/backend/app/policies/drive_item_policy.rb @@ -4,19 +4,19 @@ def index? end def show? - project_group_member? + project_accessible? end def create? - project_group_member? + project_accessible? end def update? - project_group_member? + project_accessible? end def destroy? - project_group_member? + project_accessible? end def content? @@ -43,16 +43,14 @@ class Scope < ApplicationPolicy::Scope def resolve return scope.none if user.blank? - scope.joins(:project).where(projects: { group_id: GroupHierarchy.accessible_group_ids_for(user) }) + project_ids = ProjectPolicy::Scope.new(user, Project).resolve.select(:id) + scope.where(project_id: project_ids) end end private - def project_group_member? - return false if user.blank? - - group_id = record.project&.group_id - user.can_access_group?(group_id) + def project_accessible? + ProjectPolicy.new(user, record.project).show? end end diff --git a/backend/app/policies/drive_item_version_policy.rb b/backend/app/policies/drive_item_version_policy.rb index 6f3656a..d77989e 100644 --- a/backend/app/policies/drive_item_version_policy.rb +++ b/backend/app/policies/drive_item_version_policy.rb @@ -1,30 +1,28 @@ class DriveItemVersionPolicy < ApplicationPolicy def content? - project_group_member? + project_accessible? end def download? - project_group_member? + project_accessible? end def restore? - project_group_member? + project_accessible? end class Scope < ApplicationPolicy::Scope def resolve return scope.none if user.blank? - scope.joins(drive_item: :project).where(projects: { group_id: GroupHierarchy.accessible_group_ids_for(user) }) + project_ids = ProjectPolicy::Scope.new(user, Project).resolve.select(:id) + scope.joins(:drive_item).where(drive_items: { project_id: project_ids }) end end private - def project_group_member? - return false if user.blank? - - group_id = record.drive_item&.project&.group_id - user.can_access_group?(group_id) + def project_accessible? + ProjectPolicy.new(user, record.drive_item&.project).show? end end diff --git a/backend/app/policies/group_policy.rb b/backend/app/policies/group_policy.rb deleted file mode 100644 index 2fc54ce..0000000 --- a/backend/app/policies/group_policy.rb +++ /dev/null @@ -1,48 +0,0 @@ -class GroupPolicy < ApplicationPolicy - def index? - user.present? - end - - def show? - group_member? - end - - def create? - return false if user.blank? - - record.parent_group_id.blank? || user.can_admin_group?(record.parent_group_id) - end - - def update? - group_admin? && allowed_parent_group? - end - - def destroy? - group_admin? - end - - class Scope < ApplicationPolicy::Scope - def resolve - return scope.none if user.blank? - return scope.all if user.system_admin? - - scope.where(id: GroupHierarchy.accessible_group_ids_for(user)) - end - end - - private - - def group_member? - user.present? && record.id.present? && user.can_access_group?(record.id) - end - - def group_admin? - user.present? && record.id.present? && user.can_admin_group?(record.id) - end - - def allowed_parent_group? - return true unless record.will_save_change_to_parent_group_id? - - record.parent_group_id.blank? || user.can_admin_group?(record.parent_group_id) - end -end diff --git a/backend/app/policies/iteration_policy.rb b/backend/app/policies/iteration_policy.rb index f3ad416..0f5c1b8 100644 --- a/backend/app/policies/iteration_policy.rb +++ b/backend/app/policies/iteration_policy.rb @@ -4,35 +4,33 @@ def index? end def show? - project_group_member? + project_accessible? end def create? - project_group_member? + project_accessible? end def update? - project_group_member? + project_accessible? end def destroy? - project_group_member? && !record.inbox? + project_accessible? && !record.inbox? end class Scope < ApplicationPolicy::Scope def resolve return scope.none if user.blank? - scope.joins(:project).where(projects: { group_id: GroupHierarchy.accessible_group_ids_for(user) }) + project_ids = ProjectPolicy::Scope.new(user, Project).resolve.select(:id) + scope.where(project_id: project_ids) end end private - def project_group_member? - return false if user.blank? - - group_id = record.project&.group_id - user.can_access_group?(group_id) + def project_accessible? + ProjectPolicy.new(user, record.project).show? end end diff --git a/backend/app/policies/organization_policy.rb b/backend/app/policies/organization_policy.rb new file mode 100644 index 0000000..e9cb395 --- /dev/null +++ b/backend/app/policies/organization_policy.rb @@ -0,0 +1,40 @@ +class OrganizationPolicy < ApplicationPolicy + def index? + user.present? + end + + def show? + organization_member? + end + + def create? + user.present? + end + + def update? + organization_admin? + end + + def destroy? + organization_admin? + end + + class Scope < ApplicationPolicy::Scope + def resolve + return scope.none if user.blank? + return scope.all if user.system_admin? + + scope.where(id: OrganizationMembership.accessible_organization_ids_for(user)) + end + end + + private + + def organization_member? + user.present? && record.id.present? && user.can_access_organization?(record.id) + end + + def organization_admin? + user.present? && record.id.present? && user.can_admin_organization?(record.id) + end +end diff --git a/backend/app/policies/project_policy.rb b/backend/app/policies/project_policy.rb index d224d32..93ccb88 100644 --- a/backend/app/policies/project_policy.rb +++ b/backend/app/policies/project_policy.rb @@ -4,19 +4,28 @@ def index? end def show? - group_member? + project_accessible? end def create? - user.present? && record.user == user && group_admin? + return false if user.blank? || record.user != user + + case record.owner + when User + record.owner == user || user.system_admin? + when Organization + user.can_admin_organization?(record.owner_id) + else + false + end end def update? - group_admin? && target_group_admin? + project_manageable? && target_owner_manageable? end def destroy? - group_admin? + project_manageable? end def board? @@ -28,26 +37,51 @@ def resolve return scope.none if user.blank? return scope.all if user.system_admin? - scope.where(group_id: GroupHierarchy.accessible_group_ids_for(user)) + scope + .where(owner: user) + .or(scope.where(owner_type: "Organization", owner_id: OrganizationMembership.accessible_organization_ids_for(user))) end end private - def group_member? - return false if user.blank? || record.group_id.blank? + def project_accessible? + return false if user.blank? - user.can_access_group?(record.group_id) + case record.owner + when User + record.owner == user || user.system_admin? + when Organization + user.can_access_organization?(record.owner_id) + else + false + end end - def group_admin? + def project_manageable? return false if user.blank? - group_id = record.group_id_in_database || record.group_id - group_id.present? && user.can_admin_group?(group_id) + owner_type = record.owner_type_in_database || record.owner_type + owner_id = record.owner_id_in_database || record.owner_id + + case owner_type + when "User" + owner_id == user.id || user.system_admin? + when "Organization" + user.can_admin_organization?(owner_id) + else + false + end end - def target_group_admin? - record.group_id.present? && user.can_admin_group?(record.group_id) + def target_owner_manageable? + case record.owner + when User + record.owner == user || user.system_admin? + when Organization + user.can_admin_organization?(record.owner_id) + else + false + end end end diff --git a/backend/app/policies/task_policy.rb b/backend/app/policies/task_policy.rb index 4ae3818..0de9fcc 100644 --- a/backend/app/policies/task_policy.rb +++ b/backend/app/policies/task_policy.rb @@ -4,35 +4,33 @@ def index? end def show? - project_group_member? + project_accessible? end def create? - project_group_member? + project_accessible? end def update? - project_group_member? + project_accessible? end def destroy? - project_group_member? + project_accessible? end class Scope < ApplicationPolicy::Scope def resolve return scope.none if user.blank? - scope.joins(:project).where(projects: { group_id: GroupHierarchy.accessible_group_ids_for(user) }) + project_ids = ProjectPolicy::Scope.new(user, Project).resolve.select(:id) + scope.where(project_id: project_ids) end end private - def project_group_member? - return false if user.blank? - - group_id = record.project&.group_id - user.can_access_group?(group_id) + def project_accessible? + ProjectPolicy.new(user, record.project).show? 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..ad23d7c --- /dev/null +++ b/backend/app/services/chats/channel_creator.rb @@ -0,0 +1,29 @@ +module Chats + class ChannelCreator + def self.call(organization:, created_by:, attributes:) + new(organization:, created_by:, attributes:).call + end + + def initialize(organization:, created_by:, attributes:) + @organization = organization + @created_by = created_by + @attributes = attributes + end + + def call + ChatConversation.transaction do + conversation = organization.chat_conversations.create!( + kind: "channel", + created_by: + ) + organization.chat_channels.create!( + attributes.merge(chat_conversation: conversation, created_by:) + ) + end + end + + private + + attr_reader :organization, :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..9d6a0f7 --- /dev/null +++ b/backend/app/views/api/v1/chat_channels/_chat_channel.json.jbuilder @@ -0,0 +1,3 @@ +json.extract! chat_channel, :id, :organization_id, :name, :description, :created_at, :updated_at +json.conversation_id chat_channel.chat_conversation_id +json.can_manage viewer.can_admin_organization?(chat_channel.organization_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..4fdb8f6 --- /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, :organization_id, :last_message_at, :created_at, :updated_at +json.title title +json.organization_name conversation.organization&.name +json.channel_id channel&.id +json.channel_name channel&.name +json.can_manage conversation.channel? && conversation.organization_id.present? && viewer.can_admin_organization?(conversation.organization_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/app/views/api/v1/groups/_group.json.jbuilder b/backend/app/views/api/v1/groups/_group.json.jbuilder deleted file mode 100644 index 9409aab..0000000 --- a/backend/app/views/api/v1/groups/_group.json.jbuilder +++ /dev/null @@ -1,11 +0,0 @@ -user_ids = group.user_ids -admin_user_ids = group.group_memberships.filter_map { |membership| membership.user_id if membership.admin? } -parent_group_visible = group.parent_group_id.present? && viewer.can_access_group?(group.parent_group_id) - -json.extract! group, :id, :name, :description, :parent_group_id, :created_at, :updated_at -json.parent_group_name parent_group_visible ? group.parent_group&.name : nil -json.user_ids user_ids -json.users_count user_ids.size -json.admin_user_ids admin_user_ids -json.admins_count admin_user_ids.size -json.can_admin viewer.can_admin_group?(group.id) diff --git a/backend/app/views/api/v1/groups/index.json.jbuilder b/backend/app/views/api/v1/groups/index.json.jbuilder deleted file mode 100644 index c8473aa..0000000 --- a/backend/app/views/api/v1/groups/index.json.jbuilder +++ /dev/null @@ -1,3 +0,0 @@ -json.array! @groups do |group| - json.partial! "api/v1/groups/group", group: group, viewer: current_user -end diff --git a/backend/app/views/api/v1/groups/show.json.jbuilder b/backend/app/views/api/v1/groups/show.json.jbuilder deleted file mode 100644 index a926aae..0000000 --- a/backend/app/views/api/v1/groups/show.json.jbuilder +++ /dev/null @@ -1 +0,0 @@ -json.partial! "api/v1/groups/group", group: @group, viewer: current_user diff --git a/backend/app/views/api/v1/organizations/_organization.json.jbuilder b/backend/app/views/api/v1/organizations/_organization.json.jbuilder new file mode 100644 index 0000000..0e2799a --- /dev/null +++ b/backend/app/views/api/v1/organizations/_organization.json.jbuilder @@ -0,0 +1,9 @@ +user_ids = organization.user_ids +admin_user_ids = organization.organization_memberships.filter_map { |membership| membership.user_id if membership.admin? } + +json.extract! organization, :id, :name, :description, :created_at, :updated_at +json.user_ids user_ids +json.users_count user_ids.size +json.admin_user_ids admin_user_ids +json.admins_count admin_user_ids.size +json.can_admin viewer.can_admin_organization?(organization.id) diff --git a/backend/app/views/api/v1/organizations/index.json.jbuilder b/backend/app/views/api/v1/organizations/index.json.jbuilder new file mode 100644 index 0000000..66633e8 --- /dev/null +++ b/backend/app/views/api/v1/organizations/index.json.jbuilder @@ -0,0 +1,3 @@ +json.array! @organizations do |organization| + json.partial! "api/v1/organizations/organization", organization: organization, viewer: current_user +end diff --git a/backend/app/views/api/v1/organizations/show.json.jbuilder b/backend/app/views/api/v1/organizations/show.json.jbuilder new file mode 100644 index 0000000..d202146 --- /dev/null +++ b/backend/app/views/api/v1/organizations/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! "api/v1/organizations/organization", organization: @organization, viewer: current_user diff --git a/backend/app/views/api/v1/projects/_project.json.jbuilder b/backend/app/views/api/v1/projects/_project.json.jbuilder index 94e2437..b0ba89d 100644 --- a/backend/app/views/api/v1/projects/_project.json.jbuilder +++ b/backend/app/views/api/v1/projects/_project.json.jbuilder @@ -1,2 +1,9 @@ -json.extract! project, :id, :group_id, :name, :description, :created_at, :updated_at -json.group_name project.group&.name +json.extract! project, :id, :owner_type, :owner_id, :name, :description, :created_at, :updated_at +json.owner_name( + case project.owner + when User + project.owner.display_name.presence || project.owner.email + when Organization + project.owner.name + end +) diff --git a/backend/app/views/api/v1/users/_managed_user.json.jbuilder b/backend/app/views/api/v1/users/_managed_user.json.jbuilder index 97711a4..d073019 100644 --- a/backend/app/views/api/v1/users/_managed_user.json.jbuilder +++ b/backend/app/views/api/v1/users/_managed_user.json.jbuilder @@ -1,3 +1,3 @@ json.partial! "api/v1/users/user", user: user -json.group_ids user.group_ids -json.groups_count user.group_ids.size +json.organization_ids user.organization_ids +json.organizations_count user.organization_ids.size diff --git a/backend/config/locales/zh-CN.yml b/backend/config/locales/zh-CN.yml index 5a80a5f..b116628 100644 --- a/backend/config/locales/zh-CN.yml +++ b/backend/config/locales/zh-CN.yml @@ -10,16 +10,15 @@ zh-CN: signed_out: "已退出登录" activerecord: attributes: - group: + organization: description: "描述" name: "名称" - parent_group: "父用户组" - parent_group_id: "父用户组" project: description: "描述" - group: "用户组" - group_id: "用户组" name: "名称" + owner: "归属方" + owner_id: "归属方" + owner_type: "归属类型" task: deadline: "截止日期" description: "描述" @@ -50,7 +49,7 @@ zh-CN: iteration: blank: "不能为空" models: - group: "用户组" + organization: "组织" project: "项目" task: "任务" user: "用户" diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 4af49d0..573d10d 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 :organizations, 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/20260707153000_create_initial_schema.rb b/backend/db/migrate/20260707153000_create_initial_schema.rb deleted file mode 100644 index b56b8ad..0000000 --- a/backend/db/migrate/20260707153000_create_initial_schema.rb +++ /dev/null @@ -1,186 +0,0 @@ -class CreateInitialSchema < ActiveRecord::Migration[8.1] - def up - enable_extension "pg_trgm" - - create_users - create_application_settings - create_groups - create_group_memberships - create_oauth_identities - create_projects - create_tasks - create_search_documents - add_foreign_keys - end - - def down - drop_table :search_documents - drop_table :tasks - drop_table :projects - drop_table :oauth_identities - drop_table :group_memberships - drop_table :groups - drop_table :application_settings - drop_table :users - end - - private - - def create_users - create_table :users do |t| - t.text :bio - t.string :display_name - t.string :email, default: "", null: false - t.string :encrypted_password, default: "", null: false - t.string :jti, null: false - t.datetime :remember_created_at - t.datetime :reset_password_sent_at - t.string :reset_password_token - t.string :title - - t.timestamps - end - - add_index :users, :email, unique: true - add_index :users, :jti, unique: true - add_index :users, :reset_password_token, unique: true - end - - def create_application_settings - create_table :application_settings do |t| - t.boolean :email_login_enabled, default: true, null: false - t.boolean :registration_enabled, default: true, null: false - t.integer :singleton_guard, default: 0, null: false - - t.timestamps - end - - add_index :application_settings, :singleton_guard, unique: true - end - - def create_groups - create_table :groups do |t| - t.text :description - t.string :name, null: false - t.bigint :parent_group_id - - t.timestamps - end - - add_index :groups, :parent_group_id - end - - def create_group_memberships - create_table :group_memberships do |t| - t.bigint :group_id, null: false - t.bigint :user_id, null: false - - t.timestamps - end - - add_index :group_memberships, [ :group_id, :user_id ], unique: true - add_index :group_memberships, :group_id - add_index :group_memberships, :user_id - end - - def create_oauth_identities - create_table :oauth_identities do |t| - t.string :provider, null: false - t.string :uid, null: false - t.bigint :user_id, null: false - - t.timestamps - end - - add_index :oauth_identities, [ :provider, :uid ], unique: true - add_index :oauth_identities, [ :user_id, :provider ], unique: true - add_index :oauth_identities, :user_id - end - - def create_projects - create_table :projects do |t| - t.text :description - t.string :name, null: false - t.bigint :user_id, null: false - - t.timestamps - end - - add_index :projects, :user_id - end - - def create_tasks - create_table :tasks do |t| - t.datetime :deadline - t.text :description - t.integer :estimated_minutes - t.integer :position, default: 0, null: false - t.string :priority, default: "medium", null: false - t.bigint :project_id, null: false - t.string :status, default: "todo", null: false - t.string :title, null: false - - t.timestamps - end - - add_index :tasks, [ :project_id, :status, :position ] - add_index :tasks, :project_id - end - - def create_search_documents - create_table :search_documents do |t| - t.string :api_path, null: false - t.text :content, default: "", null: false - t.jsonb :metadata, default: {}, null: false - t.string :resource_slug, null: false - t.string :resource_type, null: false - t.datetime :resource_updated_at, null: false - t.bigint :searchable_id, null: false - t.string :searchable_type, null: false - t.string :title, null: false - t.bigint :user_id - - t.timestamps - end - - add_index :search_documents, :content, name: "index_search_documents_on_content_trgm", - opclass: :gin_trgm_ops, using: :gin - add_index :search_documents, :metadata, using: :gin - add_index :search_documents, :resource_slug, unique: true - add_index :search_documents, :resource_slug, name: "index_search_documents_on_resource_slug_trgm", - opclass: :gin_trgm_ops, using: :gin - add_index :search_documents, :resource_type - add_index :search_documents, [ :searchable_type, :searchable_id ], unique: true - add_index :search_documents, :title, name: "index_search_documents_on_title_trgm", - opclass: :gin_trgm_ops, using: :gin - add_index :search_documents, :user_id - - add_search_vector - end - - def add_search_vector - execute <<~SQL - ALTER TABLE search_documents - ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( - to_tsvector( - 'simple', - coalesce(resource_slug, '') || ' ' || - coalesce(title, '') || ' ' || - coalesce(content, '') - ) - ) STORED - SQL - - add_index :search_documents, :search_vector, using: :gin - end - - def add_foreign_keys - add_foreign_key :group_memberships, :groups, on_delete: :cascade - add_foreign_key :group_memberships, :users, on_delete: :cascade - add_foreign_key :groups, :groups, column: :parent_group_id, on_delete: :nullify - add_foreign_key :oauth_identities, :users - add_foreign_key :projects, :users - add_foreign_key :search_documents, :users - add_foreign_key :tasks, :projects - end -end diff --git a/backend/db/migrate/20260707164000_add_group_scope_to_projects_and_search_documents.rb b/backend/db/migrate/20260707164000_add_group_scope_to_projects_and_search_documents.rb deleted file mode 100644 index 6cb468d..0000000 --- a/backend/db/migrate/20260707164000_add_group_scope_to_projects_and_search_documents.rb +++ /dev/null @@ -1,83 +0,0 @@ -class AddGroupScopeToProjectsAndSearchDocuments < ActiveRecord::Migration[8.1] - def up - add_reference :projects, :group, null: true, foreign_key: true - add_reference :search_documents, :group, null: true, foreign_key: true - - backfill_project_groups - backfill_search_document_groups - - change_column_null :projects, :group_id, false - end - - def down - remove_reference :search_documents, :group, foreign_key: true - remove_reference :projects, :group, foreign_key: true - end - - private - - def backfill_project_groups - execute <<~SQL - DO $$ - DECLARE - project_user_id bigint; - assigned_group_id bigint; - BEGIN - FOR project_user_id IN - SELECT DISTINCT user_id FROM projects WHERE group_id IS NULL - LOOP - SELECT group_id - INTO assigned_group_id - FROM group_memberships - WHERE user_id = project_user_id - ORDER BY created_at ASC, id ASC - LIMIT 1; - - IF assigned_group_id IS NULL THEN - INSERT INTO groups (name, description, created_at, updated_at) - VALUES ( - 'Personal workspace', - 'Automatically created for existing projects.', - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP - ) - RETURNING id INTO assigned_group_id; - - INSERT INTO group_memberships (group_id, user_id, created_at, updated_at) - VALUES (assigned_group_id, project_user_id, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); - END IF; - - UPDATE projects - SET group_id = assigned_group_id - WHERE user_id = project_user_id - AND group_id IS NULL; - END LOOP; - END $$; - SQL - end - - def backfill_search_document_groups - execute <<~SQL.squish - UPDATE search_documents - SET group_id = projects.group_id - FROM projects - WHERE search_documents.searchable_type = 'Project' - AND search_documents.searchable_id = projects.id - SQL - - execute <<~SQL.squish - UPDATE search_documents - SET group_id = projects.group_id - FROM tasks - JOIN projects ON projects.id = tasks.project_id - WHERE search_documents.searchable_type = 'Task' - AND search_documents.searchable_id = tasks.id - SQL - - execute <<~SQL.squish - UPDATE search_documents - SET group_id = searchable_id - WHERE searchable_type = 'Group' - SQL - end -end diff --git a/backend/db/migrate/20260708002000_create_group_hierarchies.rb b/backend/db/migrate/20260708002000_create_group_hierarchies.rb deleted file mode 100644 index 03e2bfc..0000000 --- a/backend/db/migrate/20260708002000_create_group_hierarchies.rb +++ /dev/null @@ -1,49 +0,0 @@ -class CreateGroupHierarchies < ActiveRecord::Migration[8.1] - def up - create_table :group_hierarchies do |t| - t.bigint :ancestor_group_id, null: false - t.bigint :descendant_group_id, null: false - t.integer :depth, null: false - - t.timestamps - end - - add_index :group_hierarchies, - [ :ancestor_group_id, :descendant_group_id ], - unique: true, - name: "index_group_hierarchies_on_ancestor_and_descendant" - add_index :group_hierarchies, :descendant_group_id - add_foreign_key :group_hierarchies, :groups, column: :ancestor_group_id, on_delete: :cascade - add_foreign_key :group_hierarchies, :groups, column: :descendant_group_id, on_delete: :cascade - - rebuild_group_hierarchies - end - - def down - drop_table :group_hierarchies - end - - private - - def rebuild_group_hierarchies - execute <<~SQL.squish - WITH RECURSIVE hierarchy AS ( - SELECT id AS ancestor_group_id, id AS descendant_group_id, 0 AS depth - FROM groups - UNION ALL - SELECT hierarchy.ancestor_group_id, groups.id, hierarchy.depth + 1 - FROM hierarchy - JOIN groups ON groups.parent_group_id = hierarchy.descendant_group_id - ) - INSERT INTO group_hierarchies ( - ancestor_group_id, - descendant_group_id, - depth, - created_at, - updated_at - ) - SELECT ancestor_group_id, descendant_group_id, depth, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP - FROM hierarchy - SQL - end -end diff --git a/backend/db/migrate/20260708002001_create_pghero_query_stats.rb b/backend/db/migrate/20260708002001_create_pghero_query_stats.rb deleted file mode 100644 index bb328e8..0000000 --- a/backend/db/migrate/20260708002001_create_pghero_query_stats.rb +++ /dev/null @@ -1,15 +0,0 @@ -class CreatePgheroQueryStats < ActiveRecord::Migration[8.1] - def change - create_table :pghero_query_stats do |t| - t.text :database - t.text :user - t.text :query - t.integer :query_hash, limit: 8 - t.float :total_time - t.integer :calls, limit: 8 - t.timestamp :captured_at - end - - add_index :pghero_query_stats, [ :database, :captured_at ] - end -end diff --git a/backend/db/migrate/20260708002002_create_pghero_space_stats.rb b/backend/db/migrate/20260708002002_create_pghero_space_stats.rb deleted file mode 100644 index c886620..0000000 --- a/backend/db/migrate/20260708002002_create_pghero_space_stats.rb +++ /dev/null @@ -1,13 +0,0 @@ -class CreatePgheroSpaceStats < ActiveRecord::Migration[8.1] - def change - create_table :pghero_space_stats do |t| - t.text :database - t.text :schema - t.text :relation - t.integer :size, limit: 8 - t.timestamp :captured_at - end - - add_index :pghero_space_stats, [ :database, :captured_at ] - end -end diff --git a/backend/db/migrate/20260708030000_create_iterations.rb b/backend/db/migrate/20260708030000_create_iterations.rb deleted file mode 100644 index dbbca47..0000000 --- a/backend/db/migrate/20260708030000_create_iterations.rb +++ /dev/null @@ -1,48 +0,0 @@ -class CreateIterations < ActiveRecord::Migration[8.1] - class MigrationProject < ActiveRecord::Base - self.table_name = "projects" - end - - class MigrationIteration < ActiveRecord::Base - self.table_name = "iterations" - end - - def up - create_table :iterations do |t| - t.datetime :deadline - t.boolean :inbox, default: false, null: false - t.string :name, null: false - t.bigint :project_id, null: false - - t.timestamps - end - - add_index :iterations, :project_id - add_index :iterations, [ :project_id, :inbox ], unique: true, where: "inbox = true" - add_foreign_key :iterations, :projects, on_delete: :cascade - - add_reference :tasks, :iteration, foreign_key: { on_delete: :nullify } - - MigrationProject.find_each do |project| - inbox = MigrationIteration.create!( - project_id: project.id, - name: "Inbox", - inbox: true, - created_at: Time.current, - updated_at: Time.current - ) - - execute <<~SQL.squish - UPDATE tasks - SET iteration_id = #{inbox.id} - WHERE project_id = #{project.id} - AND iteration_id IS NULL - SQL - end - end - - def down - remove_reference :tasks, :iteration, foreign_key: true - drop_table :iterations - end -end diff --git a/backend/db/migrate/20260708031000_add_starts_at_to_iterations.rb b/backend/db/migrate/20260708031000_add_starts_at_to_iterations.rb deleted file mode 100644 index 31c2282..0000000 --- a/backend/db/migrate/20260708031000_add_starts_at_to_iterations.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddStartsAtToIterations < ActiveRecord::Migration[8.1] - def change - add_column :iterations, :starts_at, :datetime - end -end diff --git a/backend/db/migrate/20260708032000_create_task_developer_and_reviewer_memberships.rb b/backend/db/migrate/20260708032000_create_task_developer_and_reviewer_memberships.rb deleted file mode 100644 index cdafae8..0000000 --- a/backend/db/migrate/20260708032000_create_task_developer_and_reviewer_memberships.rb +++ /dev/null @@ -1,23 +0,0 @@ -class CreateTaskDeveloperAndReviewerMemberships < ActiveRecord::Migration[8.1] - def change - create_table :task_developers, id: false do |t| - t.bigint :task_id, null: false - t.bigint :user_id, null: false - end - - add_index :task_developers, [ :task_id, :user_id ], unique: true - add_index :task_developers, :user_id - add_foreign_key :task_developers, :tasks, on_delete: :cascade - add_foreign_key :task_developers, :users, on_delete: :cascade - - create_table :task_reviewers, id: false do |t| - t.bigint :task_id, null: false - t.bigint :user_id, null: false - end - - add_index :task_reviewers, [ :task_id, :user_id ], unique: true - add_index :task_reviewers, :user_id - add_foreign_key :task_reviewers, :tasks, on_delete: :cascade - add_foreign_key :task_reviewers, :users, on_delete: :cascade - end -end diff --git a/backend/db/migrate/20260709090000_add_system_admin_to_users.rb b/backend/db/migrate/20260709090000_add_system_admin_to_users.rb deleted file mode 100644 index f2db643..0000000 --- a/backend/db/migrate/20260709090000_add_system_admin_to_users.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddSystemAdminToUsers < ActiveRecord::Migration[8.1] - def change - add_column :users, :system_admin, :boolean, null: false, default: false - end -end diff --git a/backend/db/migrate/20260709091000_add_admin_to_group_memberships.rb b/backend/db/migrate/20260709091000_add_admin_to_group_memberships.rb deleted file mode 100644 index 0854d1f..0000000 --- a/backend/db/migrate/20260709091000_add_admin_to_group_memberships.rb +++ /dev/null @@ -1,34 +0,0 @@ -class AddAdminToGroupMemberships < ActiveRecord::Migration[8.1] - def up - add_column :group_memberships, :admin, :boolean, null: false, default: false - add_index :group_memberships, [ :group_id, :admin ] - - execute <<~SQL.squish - UPDATE group_memberships - SET admin = TRUE - SQL - - execute <<~SQL.squish - INSERT INTO group_memberships (group_id, user_id, admin, created_at, updated_at) - SELECT groups.id, system_admins.id, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP - FROM groups - CROSS JOIN LATERAL ( - SELECT users.id - FROM users - WHERE users.system_admin = TRUE - ORDER BY users.id ASC - LIMIT 1 - ) system_admins - WHERE NOT EXISTS ( - SELECT 1 - FROM group_memberships - WHERE group_memberships.group_id = groups.id - ) - SQL - end - - def down - remove_index :group_memberships, [ :group_id, :admin ] - remove_column :group_memberships, :admin - end -end diff --git a/backend/db/migrate/20260709100000_create_active_storage_tables.rb b/backend/db/migrate/20260709100000_create_active_storage_tables.rb deleted file mode 100644 index bde6874..0000000 --- a/backend/db/migrate/20260709100000_create_active_storage_tables.rb +++ /dev/null @@ -1,47 +0,0 @@ -# This migration mirrors the Rails Active Storage install output. The guards keep -# it safe for environments where Active Storage tables may already exist. -class CreateActiveStorageTables < ActiveRecord::Migration[8.1] - def change - unless table_exists?(:active_storage_blobs) - create_table :active_storage_blobs do |t| - t.string :key, null: false - t.string :filename, null: false - t.string :content_type - t.text :metadata - t.string :service_name, null: false - t.bigint :byte_size, null: false - t.string :checksum - t.datetime :created_at, null: false - - t.index [ :key ], unique: true - end - end - - unless table_exists?(:active_storage_attachments) - create_table :active_storage_attachments do |t| - t.string :name, null: false - t.references :record, null: false, polymorphic: true, index: false - t.references :blob, null: false, index: false - t.datetime :created_at, null: false - - t.index [ :blob_id ] - t.index [ :record_type, :record_id, :name, :blob_id ], - name: :index_active_storage_attachments_uniqueness, - unique: true - t.foreign_key :active_storage_blobs, column: :blob_id - end - end - - unless table_exists?(:active_storage_variant_records) - create_table :active_storage_variant_records do |t| - t.belongs_to :blob, null: false, index: false - t.string :variation_digest, null: false - - t.index [ :blob_id, :variation_digest ], - name: :index_active_storage_variant_records_uniqueness, - unique: true - t.foreign_key :active_storage_blobs, column: :blob_id - end - end - end -end diff --git a/backend/db/migrate/20260709101000_create_drive_items.rb b/backend/db/migrate/20260709101000_create_drive_items.rb deleted file mode 100644 index 931d6ad..0000000 --- a/backend/db/migrate/20260709101000_create_drive_items.rb +++ /dev/null @@ -1,27 +0,0 @@ -class CreateDriveItems < ActiveRecord::Migration[8.1] - def change - create_table :drive_items do |t| - t.references :project, null: false, foreign_key: { on_delete: :cascade } - t.references :parent, foreign_key: { to_table: :drive_items, on_delete: :cascade } - t.string :kind, null: false - t.string :name, null: false - t.text :text_content_cache, default: "", null: false - - t.timestamps - end - - add_index :drive_items, - "project_id, lower(name)", - name: "index_drive_items_on_project_root_name", - unique: true, - where: "parent_id IS NULL" - - add_index :drive_items, - "project_id, parent_id, lower(name)", - name: "index_drive_items_on_project_parent_name", - unique: true, - where: "parent_id IS NOT NULL" - - add_index :drive_items, [ :project_id, :kind ] - end -end diff --git a/backend/db/migrate/20260709102000_add_soft_delete_to_drive_items.rb b/backend/db/migrate/20260709102000_add_soft_delete_to_drive_items.rb deleted file mode 100644 index ff41e0d..0000000 --- a/backend/db/migrate/20260709102000_add_soft_delete_to_drive_items.rb +++ /dev/null @@ -1,21 +0,0 @@ -class AddSoftDeleteToDriveItems < ActiveRecord::Migration[8.1] - def change - add_column :drive_items, :deleted_at, :datetime - add_index :drive_items, :deleted_at - - remove_index :drive_items, name: "index_drive_items_on_project_root_name" - remove_index :drive_items, name: "index_drive_items_on_project_parent_name" - - add_index :drive_items, - "project_id, lower(name)", - name: "index_drive_items_on_project_root_name", - unique: true, - where: "parent_id IS NULL AND deleted_at IS NULL" - - add_index :drive_items, - "project_id, parent_id, lower(name)", - name: "index_drive_items_on_project_parent_name", - unique: true, - where: "parent_id IS NOT NULL AND deleted_at IS NULL" - end -end diff --git a/backend/db/migrate/20260709103000_create_drive_item_versions.rb b/backend/db/migrate/20260709103000_create_drive_item_versions.rb deleted file mode 100644 index ff06446..0000000 --- a/backend/db/migrate/20260709103000_create_drive_item_versions.rb +++ /dev/null @@ -1,16 +0,0 @@ -class CreateDriveItemVersions < ActiveRecord::Migration[8.1] - def change - create_table :drive_item_versions do |t| - t.references :drive_item, null: false, foreign_key: { on_delete: :cascade } - t.integer :version_number, null: false - t.string :name, null: false - t.string :content_type - t.bigint :byte_size - t.text :text_content_cache, default: "", null: false - - t.timestamps - end - - add_index :drive_item_versions, [ :drive_item_id, :version_number ], unique: true - end -end diff --git a/backend/db/migrate/20260709120000_create_initial_schema.rb b/backend/db/migrate/20260709120000_create_initial_schema.rb new file mode 100644 index 0000000..aa982e8 --- /dev/null +++ b/backend/db/migrate/20260709120000_create_initial_schema.rb @@ -0,0 +1,251 @@ +class CreateInitialSchema < ActiveRecord::Migration[8.1] + def change + enable_extension "pg_stat_statements" + enable_extension "pg_trgm" + + create_table :application_settings do |t| + t.boolean :registration_enabled, default: true, null: false + t.boolean :email_login_enabled, default: true, null: false + t.integer :singleton_guard, default: 0, null: false + t.timestamps + t.index :singleton_guard, unique: true + end + + create_table :users do |t| + t.string :email, default: "", null: false + t.string :encrypted_password, default: "", null: false + t.string :reset_password_token + t.datetime :reset_password_sent_at + t.datetime :remember_created_at + t.string :jti, null: false + t.string :display_name + t.string :title + t.text :bio + t.json :skills, default: [], null: false + t.boolean :system_admin, default: false, null: false + t.timestamps + t.index :email, unique: true + t.index :jti, unique: true + t.index :reset_password_token, unique: true + end + + create_table :oauth_identities do |t| + t.references :user, null: false, foreign_key: true + t.string :provider, null: false + t.string :uid, null: false + t.timestamps + t.index [ :provider, :uid ], unique: true + t.index [ :user_id, :provider ], unique: true + end + + create_table :organizations do |t| + t.string :name, null: false + t.text :description + t.timestamps + end + + create_table :organization_memberships do |t| + t.references :organization, null: false, foreign_key: { on_delete: :cascade } + t.references :user, null: false, foreign_key: { on_delete: :cascade } + t.boolean :admin, default: false, null: false + t.timestamps + t.index [ :organization_id, :admin ] + t.index [ :organization_id, :user_id ], unique: true + end + + create_table :projects do |t| + t.references :user, null: false, foreign_key: true + t.string :owner_type, null: false + t.bigint :owner_id, null: false + t.string :name, null: false + t.text :description + t.timestamps + t.index [ :owner_type, :owner_id ] + end + + create_table :iterations do |t| + t.references :project, null: false, foreign_key: { on_delete: :cascade } + t.string :name, null: false + t.datetime :starts_at + t.datetime :deadline + t.boolean :inbox, default: false, null: false + t.timestamps + t.index [ :project_id, :inbox ], unique: true, where: "inbox = true" + end + + create_table :tasks do |t| + t.references :project, null: false, foreign_key: true + t.references :iteration, foreign_key: { on_delete: :nullify } + t.string :title, null: false + t.text :description + t.string :status, default: "todo", null: false + t.string :priority, default: "medium", null: false + t.integer :position, default: 0, null: false + t.datetime :deadline + t.integer :estimated_minutes + t.timestamps + t.index [ :project_id, :status, :position ] + end + + create_table :task_developers, id: false do |t| + t.references :task, null: false, foreign_key: { on_delete: :cascade } + t.references :user, null: false, foreign_key: { on_delete: :cascade } + t.index [ :task_id, :user_id ], unique: true + end + + create_table :task_reviewers, id: false do |t| + t.references :task, null: false, foreign_key: { on_delete: :cascade } + t.references :user, null: false, foreign_key: { on_delete: :cascade } + t.index [ :task_id, :user_id ], unique: true + end + + create_table :drive_items do |t| + t.references :project, null: false, foreign_key: { on_delete: :cascade } + t.references :parent, foreign_key: { to_table: :drive_items, on_delete: :cascade } + t.string :kind, null: false + t.string :name, null: false + t.text :text_content_cache, default: "", null: false + t.datetime :deleted_at + t.timestamps + t.index [ :deleted_at ] + t.index [ :project_id, :kind ] + t.index "project_id, lower((name)::text)", + name: "index_drive_items_on_project_root_name", + unique: true, + where: "parent_id IS NULL AND deleted_at IS NULL" + t.index "project_id, parent_id, lower((name)::text)", + name: "index_drive_items_on_project_parent_name", + unique: true, + where: "parent_id IS NOT NULL AND deleted_at IS NULL" + end + + create_table :drive_item_versions do |t| + t.references :drive_item, null: false, foreign_key: { on_delete: :cascade } + t.integer :version_number, null: false + t.string :name, null: false + t.string :content_type + t.bigint :byte_size + t.text :text_content_cache, default: "", null: false + t.timestamps + t.index [ :drive_item_id, :version_number ], unique: true + end + + create_table :active_storage_blobs do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + t.datetime :created_at, null: false + t.index :key, unique: true + end + + create_table :active_storage_attachments do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false + t.references :blob, null: false, foreign_key: { to_table: :active_storage_blobs } + t.datetime :created_at, null: false + t.index [ :record_type, :record_id, :name, :blob_id ], + name: :index_active_storage_attachments_uniqueness, + unique: true + end + + create_table :active_storage_variant_records do |t| + t.references :blob, null: false, foreign_key: { to_table: :active_storage_blobs } + t.string :variation_digest, null: false + t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true + end + + create_table :search_documents do |t| + t.references :searchable, polymorphic: true, null: false, index: { unique: true } + t.string :owner_type + t.bigint :owner_id + t.string :resource_slug, null: false + t.string :resource_type, null: false + t.string :title, null: false + t.text :content, default: "", null: false + t.string :api_path, null: false + t.jsonb :metadata, default: {}, null: false + t.datetime :resource_updated_at, null: false + t.virtual :search_vector, + type: :tsvector, + as: "to_tsvector('simple'::regconfig, (((((COALESCE(resource_slug, ''::character varying))::text || ' '::text) || (COALESCE(title, ''::character varying))::text) || ' '::text) || COALESCE(content, ''::text)))", + stored: true + t.timestamps + t.index [ :owner_type, :owner_id ] + t.index :resource_slug, unique: true + t.index :resource_type + t.index :metadata, using: :gin + t.index :search_vector, using: :gin + t.index :resource_slug, using: :gin, opclass: :gin_trgm_ops, name: :index_search_documents_on_resource_slug_trgm + t.index :title, using: :gin, opclass: :gin_trgm_ops, name: :index_search_documents_on_title_trgm + t.index :content, using: :gin, opclass: :gin_trgm_ops, name: :index_search_documents_on_content_trgm + end + + create_table :chat_conversations do |t| + t.string :kind, null: false + t.string :direct_key + t.references :organization, 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 + t.index :direct_key, unique: true, where: "direct_key IS NOT NULL" + t.index :kind + t.index [ :organization_id, :kind ] + t.index :parent_message_id, unique: true, where: "parent_message_id IS NOT NULL" + end + + create_table :chat_conversation_participants do |t| + t.references :chat_conversation, null: false, index: false, foreign_key: { on_delete: :cascade } + t.references :user, null: false, foreign_key: { on_delete: :cascade } + t.timestamps + t.index [ :chat_conversation_id, :user_id ], unique: true, name: "index_chat_participants_on_conversation_and_user" + end + + create_table :chat_channels do |t| + t.references :organization, null: false, foreign_key: { on_delete: :cascade } + t.references :chat_conversation, null: false, index: false, foreign_key: { on_delete: :cascade } + t.references :created_by, null: false, foreign_key: { to_table: :users } + t.string :name, null: false + t.text :description + t.timestamps + t.index :chat_conversation_id, unique: true + t.index "organization_id, lower((name)::text)", + name: "index_chat_channels_on_organization_and_lower_name", + unique: true + end + + 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 + t.index [ :chat_conversation_id, :id ] + end + + create_table :pghero_query_stats do |t| + t.text :database + t.text :user + t.text :query + t.bigint :query_hash + t.float :total_time + t.bigint :calls + t.datetime :captured_at + t.index [ :database, :captured_at ] + end + + create_table :pghero_space_stats do |t| + t.text :database + t.text :schema + t.text :relation + t.bigint :size + t.datetime :captured_at + t.index [ :database, :captured_at ] + end + + 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..68a0c22 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_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" @@ -42,6 +42,7 @@ t.bigint "blob_id", null: false t.string "variation_digest", null: false t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + t.index ["blob_id"], name: "index_active_storage_variant_records_on_blob_id" end create_table "application_settings", force: :cascade do |t| @@ -53,6 +54,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.string "name", null: false + t.bigint "organization_id", null: false + t.datetime "updated_at", null: false + t.index "organization_id, lower((name)::text)", name: "index_chat_channels_on_organization_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 ["organization_id"], name: "index_chat_channels_on_organization_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.string "kind", null: false + t.datetime "last_message_at" + t.bigint "organization_id" + 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 ["kind"], name: "index_chat_conversations_on_kind" + t.index ["organization_id", "kind"], name: "index_chat_conversations_on_organization_id_and_kind" + t.index ["organization_id"], name: "index_chat_conversations_on_organization_id" + 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" @@ -83,37 +135,6 @@ t.index ["project_id"], name: "index_drive_items_on_project_id" end - create_table "group_hierarchies", force: :cascade do |t| - t.bigint "ancestor_group_id", null: false - t.datetime "created_at", null: false - t.integer "depth", null: false - t.bigint "descendant_group_id", null: false - t.datetime "updated_at", null: false - t.index ["ancestor_group_id", "descendant_group_id"], name: "index_group_hierarchies_on_ancestor_and_descendant", unique: true - t.index ["descendant_group_id"], name: "index_group_hierarchies_on_descendant_group_id" - end - - create_table "group_memberships", force: :cascade do |t| - t.boolean "admin", default: false, null: false - t.datetime "created_at", null: false - t.bigint "group_id", null: false - t.datetime "updated_at", null: false - t.bigint "user_id", null: false - t.index ["group_id", "admin"], name: "index_group_memberships_on_group_id_and_admin" - t.index ["group_id", "user_id"], name: "index_group_memberships_on_group_id_and_user_id", unique: true - t.index ["group_id"], name: "index_group_memberships_on_group_id" - t.index ["user_id"], name: "index_group_memberships_on_user_id" - end - - create_table "groups", force: :cascade do |t| - t.datetime "created_at", null: false - t.text "description" - t.string "name", null: false - t.bigint "parent_group_id" - t.datetime "updated_at", null: false - t.index ["parent_group_id"], name: "index_groups_on_parent_group_id" - end - create_table "iterations", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "deadline" @@ -137,9 +158,28 @@ t.index ["user_id"], name: "index_oauth_identities_on_user_id" end + create_table "organization_memberships", force: :cascade do |t| + t.boolean "admin", default: false, null: false + t.datetime "created_at", null: false + t.bigint "organization_id", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["organization_id", "admin"], name: "index_organization_memberships_on_organization_id_and_admin" + t.index ["organization_id", "user_id"], name: "index_organization_memberships_on_organization_id_and_user_id", unique: true + t.index ["organization_id"], name: "index_organization_memberships_on_organization_id" + t.index ["user_id"], name: "index_organization_memberships_on_user_id" + end + + create_table "organizations", force: :cascade do |t| + t.datetime "created_at", null: false + t.text "description" + t.string "name", null: false + t.datetime "updated_at", null: false + end + create_table "pghero_query_stats", force: :cascade do |t| t.bigint "calls" - t.datetime "captured_at", precision: nil + t.datetime "captured_at" t.text "database" t.text "query" t.bigint "query_hash" @@ -149,7 +189,7 @@ end create_table "pghero_space_stats", force: :cascade do |t| - t.datetime "captured_at", precision: nil + t.datetime "captured_at" t.text "database" t.text "relation" t.text "schema" @@ -160,11 +200,12 @@ create_table "projects", force: :cascade do |t| t.datetime "created_at", null: false t.text "description" - t.bigint "group_id", null: false t.string "name", null: false + t.bigint "owner_id", null: false + t.string "owner_type", null: false t.datetime "updated_at", null: false t.bigint "user_id", null: false - t.index ["group_id"], name: "index_projects_on_group_id" + t.index ["owner_type", "owner_id"], name: "index_projects_on_owner_type_and_owner_id" t.index ["user_id"], name: "index_projects_on_user_id" end @@ -172,8 +213,9 @@ t.string "api_path", null: false t.text "content", default: "", null: false t.datetime "created_at", null: false - t.bigint "group_id" t.jsonb "metadata", default: {}, null: false + t.bigint "owner_id" + t.string "owner_type" t.string "resource_slug", null: false t.string "resource_type", null: false t.datetime "resource_updated_at", null: false @@ -182,23 +224,22 @@ t.string "searchable_type", null: false t.string "title", null: false t.datetime "updated_at", null: false - t.bigint "user_id" t.index ["content"], name: "index_search_documents_on_content_trgm", opclass: :gin_trgm_ops, using: :gin - t.index ["group_id"], name: "index_search_documents_on_group_id" t.index ["metadata"], name: "index_search_documents_on_metadata", using: :gin + t.index ["owner_type", "owner_id"], name: "index_search_documents_on_owner_type_and_owner_id" t.index ["resource_slug"], name: "index_search_documents_on_resource_slug", unique: true t.index ["resource_slug"], name: "index_search_documents_on_resource_slug_trgm", opclass: :gin_trgm_ops, using: :gin t.index ["resource_type"], name: "index_search_documents_on_resource_type" t.index ["search_vector"], name: "index_search_documents_on_search_vector", using: :gin - t.index ["searchable_type", "searchable_id"], name: "index_search_documents_on_searchable_type_and_searchable_id", unique: true + t.index ["searchable_type", "searchable_id"], name: "index_search_documents_on_searchable", unique: true t.index ["title"], name: "index_search_documents_on_title_trgm", opclass: :gin_trgm_ops, using: :gin - t.index ["user_id"], name: "index_search_documents_on_user_id" end create_table "task_developers", id: false, force: :cascade do |t| t.bigint "task_id", null: false t.bigint "user_id", null: false t.index ["task_id", "user_id"], name: "index_task_developers_on_task_id_and_user_id", unique: true + t.index ["task_id"], name: "index_task_developers_on_task_id" t.index ["user_id"], name: "index_task_developers_on_user_id" end @@ -206,6 +247,7 @@ t.bigint "task_id", null: false t.bigint "user_id", null: false t.index ["task_id", "user_id"], name: "index_task_reviewers_on_task_id_and_user_id", unique: true + t.index ["task_id"], name: "index_task_reviewers_on_task_id" t.index ["user_id"], name: "index_task_reviewers_on_user_id" end @@ -236,6 +278,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,20 +289,24 @@ 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", "organizations", 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", "organizations" + 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 - add_foreign_key "group_hierarchies", "groups", column: "ancestor_group_id", on_delete: :cascade - add_foreign_key "group_hierarchies", "groups", column: "descendant_group_id", on_delete: :cascade - add_foreign_key "group_memberships", "groups", on_delete: :cascade - add_foreign_key "group_memberships", "users", on_delete: :cascade - add_foreign_key "groups", "groups", column: "parent_group_id", on_delete: :nullify add_foreign_key "iterations", "projects", on_delete: :cascade add_foreign_key "oauth_identities", "users" - add_foreign_key "projects", "groups" + add_foreign_key "organization_memberships", "organizations", on_delete: :cascade + add_foreign_key "organization_memberships", "users", on_delete: :cascade add_foreign_key "projects", "users" - add_foreign_key "search_documents", "groups" - add_foreign_key "search_documents", "users" add_foreign_key "task_developers", "tasks", on_delete: :cascade add_foreign_key "task_developers", "users", on_delete: :cascade add_foreign_key "task_reviewers", "tasks", on_delete: :cascade diff --git a/backend/openapi/v1/openapi.yaml b/backend/openapi/v1/openapi.yaml index 07cde5d..bed7661 100644 --- a/backend/openapi/v1/openapi.yaml +++ b/backend/openapi/v1/openapi.yaml @@ -174,11 +174,11 @@ components: updated_at: type: string format: date-time - group_ids: + organization_ids: type: array items: type: integer - groups_count: + organizations_count: type: integer required: - id @@ -189,8 +189,8 @@ components: - system_admin - created_at - updated_at - - group_ids - - groups_count + - organization_ids + - organizations_count users_index: type: object properties: @@ -238,7 +238,7 @@ components: - total_pages - total_count - per_page - group: + organization: type: object properties: id: @@ -248,12 +248,6 @@ components: description: type: string nullable: true - parent_group_id: - type: integer - nullable: true - parent_group_name: - type: string - nullable: true user_ids: type: array items: @@ -278,8 +272,6 @@ components: - id - name - description - - parent_group_id - - parent_group_name - user_ids - users_count - admin_user_ids @@ -287,19 +279,16 @@ components: - can_admin - created_at - updated_at - group_request: + organization_request: type: object properties: - group: + organization: type: object properties: name: type: string description: type: string - parent_group_id: - type: integer - nullable: true user_ids: type: array items: @@ -309,21 +298,190 @@ components: items: type: integer required: - - group - project: + - organization + chat_channel: type: object properties: id: type: integer - group_id: + organization_id: type: integer - nullable: true name: type: string description: type: string nullable: true - group_name: + conversation_id: + type: integer + can_manage: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + required: + - id + - organization_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 + organization_id: + type: integer + nullable: true + organization_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 + - organization_id + - organization_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: + id: + type: integer + owner_type: + type: string + enum: + - User + - Organization + owner_id: + type: integer + owner_name: + type: string + nullable: true + name: + type: string + description: type: string nullable: true created_at: @@ -334,10 +492,11 @@ components: format: date-time required: - id - - group_id + - owner_type + - owner_id + - owner_name - name - description - - group_name - created_at - updated_at project_request: @@ -350,9 +509,13 @@ components: type: string description: type: string - group_id: + owner_type: + type: string + enum: + - User + - Organization + owner_id: type: integer - nullable: true required: - project drive_item: @@ -958,6 +1121,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/organizations/{organization_id}/chat_channels": + parameters: + - name: organization_id + in: path + required: true + schema: + type: integer + get: + summary: Lists organization 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 organization 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 @@ -1219,42 +1586,48 @@ paths: application/json: schema: "$ref": "#/components/schemas/drive_item" - "/api/v1/groups": + "/api/v1/projects/{project_id}/iterations": + parameters: + - name: project_id + in: path + required: true + schema: + type: integer get: - summary: Lists accessible groups + summary: Lists project iterations tags: - - Groups + - Iterations security: - bearer_auth: [] responses: '200': - description: groups returned + description: iterations returned content: application/json: schema: type: array items: - "$ref": "#/components/schemas/group" + "$ref": "#/components/schemas/iteration" post: - summary: Creates a group + summary: Creates an iteration tags: - - Groups + - Iterations security: - bearer_auth: [] parameters: [] responses: '201': - description: group created + description: iteration created content: application/json: schema: - "$ref": "#/components/schemas/group" + "$ref": "#/components/schemas/iteration" requestBody: content: application/json: schema: - "$ref": "#/components/schemas/group_request" - "/api/v1/groups/{id}": + "$ref": "#/components/schemas/iteration_request" + "/api/v1/iterations/{id}": parameters: - name: id in: path @@ -1262,107 +1635,101 @@ paths: schema: type: integer get: - summary: Returns a group + summary: Returns an iteration tags: - - Groups + - Iterations security: - bearer_auth: [] responses: '200': - description: group returned + description: iteration returned content: application/json: schema: - "$ref": "#/components/schemas/group" + "$ref": "#/components/schemas/iteration" patch: - summary: Updates a group + summary: Updates an iteration tags: - - Groups + - Iterations security: - bearer_auth: [] parameters: [] responses: '200': - description: group updated + description: iteration updated content: application/json: schema: - "$ref": "#/components/schemas/group" + "$ref": "#/components/schemas/iteration" requestBody: content: application/json: schema: - "$ref": "#/components/schemas/group_request" + "$ref": "#/components/schemas/iteration_request" put: - summary: Replaces a group + summary: Replaces an iteration tags: - - Groups + - Iterations security: - bearer_auth: [] parameters: [] responses: '200': - description: group updated + description: iteration updated content: application/json: schema: - "$ref": "#/components/schemas/group" + "$ref": "#/components/schemas/iteration" requestBody: content: application/json: schema: - "$ref": "#/components/schemas/group_request" + "$ref": "#/components/schemas/iteration_request" delete: - summary: Deletes a group + summary: Deletes an iteration tags: - - Groups + - Iterations security: - bearer_auth: [] responses: '204': - description: group deleted - "/api/v1/projects/{project_id}/iterations": - parameters: - - name: project_id - in: path - required: true - schema: - type: integer + description: iteration deleted + "/api/v1/organizations": get: - summary: Lists project iterations + summary: Lists accessible organizations tags: - - Iterations + - Organizations security: - bearer_auth: [] responses: '200': - description: iterations returned + description: organizations returned content: application/json: schema: type: array items: - "$ref": "#/components/schemas/iteration" + "$ref": "#/components/schemas/organization" post: - summary: Creates an iteration + summary: Creates a organization tags: - - Iterations + - Organizations security: - bearer_auth: [] parameters: [] responses: '201': - description: iteration created + description: organization created content: application/json: schema: - "$ref": "#/components/schemas/iteration" + "$ref": "#/components/schemas/organization" requestBody: content: application/json: schema: - "$ref": "#/components/schemas/iteration_request" - "/api/v1/iterations/{id}": + "$ref": "#/components/schemas/organization_request" + "/api/v1/organizations/{id}": parameters: - name: id in: path @@ -1370,65 +1737,65 @@ paths: schema: type: integer get: - summary: Returns an iteration + summary: Returns a organization tags: - - Iterations + - Organizations security: - bearer_auth: [] responses: '200': - description: iteration returned + description: organization returned content: application/json: schema: - "$ref": "#/components/schemas/iteration" + "$ref": "#/components/schemas/organization" patch: - summary: Updates an iteration + summary: Updates a organization tags: - - Iterations + - Organizations security: - bearer_auth: [] parameters: [] responses: '200': - description: iteration updated + description: organization updated content: application/json: schema: - "$ref": "#/components/schemas/iteration" + "$ref": "#/components/schemas/organization" requestBody: content: application/json: schema: - "$ref": "#/components/schemas/iteration_request" + "$ref": "#/components/schemas/organization_request" put: - summary: Replaces an iteration + summary: Replaces a organization tags: - - Iterations + - Organizations security: - bearer_auth: [] parameters: [] responses: '200': - description: iteration updated + description: organization updated content: application/json: schema: - "$ref": "#/components/schemas/iteration" + "$ref": "#/components/schemas/organization" requestBody: content: application/json: schema: - "$ref": "#/components/schemas/iteration_request" + "$ref": "#/components/schemas/organization_request" delete: - summary: Deletes an iteration + summary: Deletes a organization tags: - - Iterations + - Organizations security: - bearer_auth: [] responses: '204': - description: iteration deleted + description: organization deleted "/api/v1/projects": get: summary: Lists accessible projects @@ -1568,13 +1935,14 @@ paths: in: query required: false enum: - - group + - organization - project - task - user schema: type: string - description: ":\n * `group` \n * `project` \n * `task` \n * `user` \n " + description: ":\n * `organization` \n * `project` \n * `task` \n * `user` + \n " responses: '200': description: results returned diff --git a/backend/spec/factories/chat_channels.rb b/backend/spec/factories/chat_channels.rb new file mode 100644 index 0000000..0cd53b3 --- /dev/null +++ b/backend/spec/factories/chat_channels.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :chat_channel do + association :organization + association :chat_conversation, :channel + association :created_by, factory: :user + sequence(:name) { |index| "general-#{index}" } + description { "Team discussion" } + + after(:build) do |channel| + channel.chat_conversation.organization ||= channel.organization + 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..545e96a --- /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 :organization + 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/factories/groups.rb b/backend/spec/factories/groups.rb deleted file mode 100644 index 0de4d50..0000000 --- a/backend/spec/factories/groups.rb +++ /dev/null @@ -1,6 +0,0 @@ -FactoryBot.define do - factory :group do - sequence(:name) { |index| "Group #{index}" } - description { "A collaborative workspace." } - end -end diff --git a/backend/spec/factories/group_memberships.rb b/backend/spec/factories/organization_memberships.rb similarity index 64% rename from backend/spec/factories/group_memberships.rb rename to backend/spec/factories/organization_memberships.rb index 660bd8d..486b939 100644 --- a/backend/spec/factories/group_memberships.rb +++ b/backend/spec/factories/organization_memberships.rb @@ -1,6 +1,6 @@ FactoryBot.define do - factory :group_membership do - association :group + factory :organization_membership do + association :organization association :user admin { false } diff --git a/backend/spec/factories/organizations.rb b/backend/spec/factories/organizations.rb new file mode 100644 index 0000000..2e6a5aa --- /dev/null +++ b/backend/spec/factories/organizations.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :organization do + sequence(:name) { |index| "Organization #{index}" } + description { "A collaborative workspace." } + end +end diff --git a/backend/spec/factories/projects.rb b/backend/spec/factories/projects.rb index 2772076..8bc688f 100644 --- a/backend/spec/factories/projects.rb +++ b/backend/spec/factories/projects.rb @@ -1,14 +1,19 @@ FactoryBot.define do factory :project do association :user - association :group + association :owner, factory: :organization sequence(:name) { |index| "Project #{index}" } description { "A focused project workspace." } + trait :user_owned do + owner { user } + end + after(:create) do |project| - next if GroupMembership.exists?(group: project.group, user: project.user) + next unless project.owner.is_a?(Organization) + next if OrganizationMembership.exists?(organization: project.owner, user: project.user) - create(:group_membership, :admin, group: project.group, user: project.user) + create(:organization_membership, :admin, organization: project.owner, user: project.user) end 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..3d77016 --- /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 organization case-insensitively" do + organization = create(:organization) + create(:chat_channel, organization:, name: "General") + + duplicate = build(:chat_channel, organization:, 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/models/drive_item_spec.rb b/backend/spec/models/drive_item_spec.rb index 06237f3..b2d9a1d 100644 --- a/backend/spec/models/drive_item_spec.rb +++ b/backend/spec/models/drive_item_spec.rb @@ -6,7 +6,7 @@ def expect_drive_item_search_document(document, drive_item) resource_type: "drive_item", title: drive_item.name, content: drive_item.text_content_cache, - group_id: drive_item.project.group_id, + owner_id: drive_item.project.owner_id, api_path: "/api/v1/drive_items/#{drive_item.id}" ) end @@ -107,7 +107,7 @@ def expect_drive_item_search_indexed(drive_item, kind:, markdown:) end describe "search indexing" do - it "indexes folders and files with group visibility and metadata" do + it "indexes folders and files with organization visibility and metadata" do project = create(:project) folder = create(:drive_item, project:, name: "Architecture") file = create(:drive_item, :file, project:, name: "Budget.xlsx") diff --git a/backend/spec/models/group_hierarchy_spec.rb b/backend/spec/models/group_hierarchy_spec.rb deleted file mode 100644 index 7456189..0000000 --- a/backend/spec/models/group_hierarchy_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -require "rails_helper" - -RSpec.describe GroupHierarchy, type: :model do - it "caches self and descendant group paths" do - parent = create(:group) - child = create(:group, parent_group: parent) - grandchild = create(:group, parent_group: child) - - expect(described_class.pluck(:ancestor_group_id, :descendant_group_id, :depth)).to include( - [ parent.id, parent.id, 0 ], - [ parent.id, child.id, 1 ], - [ parent.id, grandchild.id, 2 ] - ) - end - - it "refreshes cached paths when a group moves" do - first_parent = create(:group) - second_parent = create(:group) - child = create(:group, parent_group: first_parent) - - child.update!(parent_group: second_parent) - - expect(described_class.exists?( - ancestor_group: first_parent, - descendant_group: child - )).to be(false) - expect(described_class.exists?( - ancestor_group: second_parent, - descendant_group: child - )).to be(true) - end -end diff --git a/backend/spec/models/group_membership_spec.rb b/backend/spec/models/group_membership_spec.rb deleted file mode 100644 index 30d8469..0000000 --- a/backend/spec/models/group_membership_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -require "rails_helper" - -RSpec.describe GroupMembership, type: :model do - it "defaults to a non-admin membership" do - membership = described_class.new - - expect(membership).not_to be_admin - end - - it "requires a unique user per group" do - membership = create(:group_membership) - duplicate = build(:group_membership, group: membership.group, user: membership.user) - - expect(duplicate).not_to be_valid - expect(duplicate.errors[:user_id]).to be_present - end - - it "does not allow the final group admin to be demoted" do - membership = create(:group_membership, :admin) - - membership.admin = false - - expect(membership).not_to be_valid - expect(membership.errors[:admin]).to be_present - end - - it "does not allow the final group admin membership to be destroyed" do - membership = create(:group_membership, :admin) - - expect(membership.destroy).to be(false) - expect(membership.errors[:admin]).to be_present - end - - it "allows an admin to be demoted when another direct admin remains" do - membership = create(:group_membership, :admin) - create(:group_membership, :admin, group: membership.group) - - membership.update!(admin: false) - - expect(membership).not_to be_admin - end -end diff --git a/backend/spec/models/group_spec.rb b/backend/spec/models/group_spec.rb deleted file mode 100644 index 4cd6f88..0000000 --- a/backend/spec/models/group_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -require "rails_helper" - -RSpec.describe Group, type: :model do - it "requires a name" do - group = build(:group, name: "") - - expect(group).not_to be_valid - expect(group.errors[:name]).to be_present - end - - it "can have child groups" do - parent = create(:group) - child = create(:group, parent_group: parent) - - expect(parent.groups).to contain_exactly(child) - expect(child.parent_group).to eq(parent) - end - - it "can have users through memberships" do - group = create(:group) - user = create(:user) - - create(:group_membership, group:, user:) - - expect(group.users).to contain_exactly(user) - expect(user.groups).to contain_exactly(group) - end - - it "does not allow itself as a parent group" do - group = create(:group) - group.parent_group = group - - expect(group).not_to be_valid - expect(group.errors[:parent_group]).to be_present - end -end diff --git a/backend/spec/models/organization_membership_spec.rb b/backend/spec/models/organization_membership_spec.rb new file mode 100644 index 0000000..c0a7973 --- /dev/null +++ b/backend/spec/models/organization_membership_spec.rb @@ -0,0 +1,42 @@ +require "rails_helper" + +RSpec.describe OrganizationMembership, type: :model do + it "defaults to a non-admin membership" do + membership = described_class.new + + expect(membership).not_to be_admin + end + + it "requires a unique user per organization" do + membership = create(:organization_membership) + duplicate = build(:organization_membership, organization: membership.organization, user: membership.user) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to be_present + end + + it "does not allow the final organization admin to be demoted" do + membership = create(:organization_membership, :admin) + + membership.admin = false + + expect(membership).not_to be_valid + expect(membership.errors[:admin]).to be_present + end + + it "does not allow the final organization admin membership to be destroyed" do + membership = create(:organization_membership, :admin) + + expect(membership.destroy).to be(false) + expect(membership.errors[:admin]).to be_present + end + + it "allows an admin to be demoted when another direct admin remains" do + membership = create(:organization_membership, :admin) + create(:organization_membership, :admin, organization: membership.organization) + + membership.update!(admin: false) + + expect(membership).not_to be_admin + end +end diff --git a/backend/spec/models/organization_spec.rb b/backend/spec/models/organization_spec.rb new file mode 100644 index 0000000..53588ba --- /dev/null +++ b/backend/spec/models/organization_spec.rb @@ -0,0 +1,27 @@ +require "rails_helper" + +RSpec.describe Organization, type: :model do + it "requires a name" do + organization = build(:organization, name: "") + + expect(organization).not_to be_valid + expect(organization.errors[:name]).to be_present + end + + it "has users through memberships" do + organization = create(:organization) + user = create(:user) + + create(:organization_membership, organization:, user:) + + expect(organization.users).to contain_exactly(user) + expect(user.organizations).to contain_exactly(organization) + end + + it "owns projects polymorphically" do + organization = create(:organization) + project = create(:project, owner: organization) + + expect(organization.projects).to contain_exactly(project) + end +end diff --git a/backend/spec/models/project_spec.rb b/backend/spec/models/project_spec.rb index 9128fc4..b4f54bf 100644 --- a/backend/spec/models/project_spec.rb +++ b/backend/spec/models/project_spec.rb @@ -15,11 +15,25 @@ expect(project.errors[:user]).to be_present end - it "requires a group" do - project = build(:project, group: nil) + it "requires an owner" do + project = build(:project, owner: nil) expect(project).not_to be_valid - expect(project.errors[:group]).to be_present + expect(project.errors[:owner]).to be_present + end + + it "can be owned by a user" do + user = create(:user) + project = create(:project, user:, owner: user) + + expect(project.owner).to eq(user) + end + + it "can be owned by an organization" do + organization = create(:organization) + project = create(:project, owner: organization) + + expect(project.owner).to eq(organization) end it "can be persisted without project-specific task statuses" do diff --git a/backend/spec/models/search_document_spec.rb b/backend/spec/models/search_document_spec.rb index acf41bb..310742b 100644 --- a/backend/spec/models/search_document_spec.rb +++ b/backend/spec/models/search_document_spec.rb @@ -2,7 +2,7 @@ RSpec.describe SearchDocument, type: :model do describe "resource indexing" do - it "creates a project search document" do + it "creates a project search document with owner visibility" do project = create(:project, name: "Apollo", description: "Moonshot roadmap") document = described_class.find_by!(resource_slug: "project:#{project.id}") @@ -10,65 +10,46 @@ resource_type: "project", title: "Apollo", content: "Moonshot roadmap", - user_id: nil, - group_id: project.group_id, + owner: project.owner, api_path: "/api/v1/projects/#{project.id}" ) + expect(document.metadata).to eq("owner_type" => "Organization", "owner_id" => project.owner_id) end - it "updates a project search document" do - project = create(:project, name: "Apollo", description: "Moonshot roadmap") - document = described_class.find_by!(resource_slug: "project:#{project.id}") - project.update!(name: "Artemis") - - expect(document.reload.title).to eq("Artemis") - end - - it "deletes a project search document" do - project = create(:project, name: "Apollo", description: "Moonshot roadmap") - document = described_class.find_by!(resource_slug: "project:#{project.id}") - project.destroy! - - expect(described_class.exists?(document.id)).to be(false) - end - - it "indexes task group visibility and metadata from the task project" do - project = create(:project) + it "indexes task owner visibility and metadata from the task project" do + project = create(:project, :user_owned) task = create(:task, project:, title: "Draft MCP API", description: "Keep resources clear.") document = described_class.find_by!(resource_slug: "task:#{task.id}") - expect(document.user_id).to be_nil - expect(document.group_id).to eq(project.group_id) + expect(document.owner).to eq(project.owner) expect(document.metadata).to eq( "project_id" => project.id, - "group_id" => project.group_id, + "owner_type" => "User", + "owner_id" => project.owner_id, "iteration_id" => task.iteration_id ) expect(document.api_path).to eq("/api/v1/tasks/#{task.id}") end - it "indexes global users without a group and groups under their own membership" do + it "indexes global users and organization-owned organizations" do user = create(:user, email: "ada@example.com", display_name: "Ada Lovelace") - group = create(:group, name: "Engineering") + organization = create(:organization, name: "Engineering") user_document = described_class.find_by!(resource_slug: "user:#{user.id}") - group_document = described_class.find_by!(resource_slug: "group:#{group.id}") + organization_document = described_class.find_by!(resource_slug: "organization:#{organization.id}") expect(user_document.title).to eq("Ada Lovelace") - expect(user_document.user_id).to be_nil - expect(user_document.group_id).to be_nil - expect(group_document.title).to eq("Engineering") - expect(group_document.user_id).to be_nil - expect(group_document.group_id).to eq(group.id) + expect(user_document.owner).to be_nil + expect(organization_document.title).to eq("Engineering") + expect(organization_document.owner).to eq(organization) end end describe ".visible_to" do - it "returns global documents and documents for groups the user belongs to" do + it "returns documents for owned personal projects" do user = create(:user) - visible_project = create(:project, name: "Visible") - hidden_project = create(:project, name: "Hidden") - create(:group_membership, user:, group: visible_project.group) + visible_project = create(:project, :user_owned, user:, name: "Visible") + hidden_project = create(:project, :user_owned, name: "Hidden") slugs = described_class.visible_to(user).pluck(:resource_slug) @@ -76,16 +57,16 @@ expect(slugs).not_to include("project:#{hidden_project.id}") end - it "returns documents for descendant groups" do + it "returns documents for organizations the user directly belongs to" do user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, user:, group: parent) - project = create(:project, group: child, name: "Visible child project") + visible_project = create(:project, name: "Visible") + hidden_project = create(:project, name: "Hidden") + create(:organization_membership, user:, organization: visible_project.owner) slugs = described_class.visible_to(user).pluck(:resource_slug) - expect(slugs).to include("project:#{project.id}") + expect(slugs).to include("project:#{visible_project.id}") + expect(slugs).not_to include("project:#{hidden_project.id}") end end end diff --git a/backend/spec/models/user_spec.rb b/backend/spec/models/user_spec.rb index f6fb90f..608c11f 100644 --- a/backend/spec/models/user_spec.rb +++ b/backend/spec/models/user_spec.rb @@ -14,65 +14,65 @@ expect(user.errors[:email]).to be_present end - it "can belong to groups through memberships" do + it "can belong to organizations through memberships" do user = create(:user) - group = create(:group) + organization = create(:organization) - create(:group_membership, user:, group:) + create(:organization_membership, user:, organization:) - expect(user.groups).to contain_exactly(group) + expect(user.organizations).to contain_exactly(organization) end - it "inherits access to descendant groups" do + it "accesses organizations through direct memberships" do user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - grandchild = create(:group, parent_group: child) + organization = create(:organization) + other = create(:organization) - create(:group_membership, user:, group: parent) + create(:organization_membership, user:, organization:) - expect(user.accessible_group_ids).to contain_exactly(parent.id, child.id, grandchild.id) - expect(user.can_access_group?(grandchild.id)).to be(true) + expect(user.accessible_organization_ids).to contain_exactly(organization.id) + expect(user.can_access_organization?(organization.id)).to be(true) + expect(user.can_access_organization?(other.id)).to be(false) end - it "inherits group admin permissions to descendant groups" do + it "checks organization admin permissions through direct memberships" do user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, :admin, user:, group: parent) + organization = create(:organization) + other = create(:organization) + create(:organization_membership, :admin, user:, organization:) - expect(user.can_admin_group?(parent.id)).to be(true) - expect(user.can_admin_group?(child.id)).to be(true) + expect(user.can_admin_organization?(organization.id)).to be(true) + expect(user.can_admin_organization?(other.id)).to be(false) end - it "memoizes adminable group ids for repeated admin checks" do + it "memoizes adminable organization ids for repeated admin checks" do user = create(:user) - group = create(:group) - create(:group_membership, :admin, user:, group:) + organization = create(:organization) + create(:organization_membership, :admin, user:, organization:) - allow(GroupHierarchy).to receive(:adminable_group_ids_for).and_call_original + allow(OrganizationMembership).to receive(:adminable_organization_ids_for).and_call_original - expect(user.can_admin_group?(group.id)).to be(true) - expect(user.can_admin_group?(group.id)).to be(true) - expect(GroupHierarchy).to have_received(:adminable_group_ids_for).once + expect(user.can_admin_organization?(organization.id)).to be(true) + expect(user.can_admin_organization?(organization.id)).to be(true) + expect(OrganizationMembership).to have_received(:adminable_organization_ids_for).once end - it "treats system admins as admins of every group" do + it "treats system admins as admins of every organization" do user = create(:user, :system_admin) - group = create(:group) + organization = create(:organization) - expect(user.can_access_group?(group.id)).to be(true) - expect(user.can_admin_group?(group.id)).to be(true) + expect(user.can_access_organization?(organization.id)).to be(true) + expect(user.can_admin_organization?(organization.id)).to be(true) end - it "does not load group ids when checking system admin group access" do + it "does not load organization ids when checking system admin organization access" do user = build_stubbed(:user, :system_admin) - allow(Group).to receive(:ids).and_call_original + allow(Organization).to receive(:ids).and_call_original - expect(user.can_access_group?(1)).to be(true) - expect(user.can_admin_group?(1)).to be(true) - expect(Group).not_to have_received(:ids) + expect(user.can_access_organization?(1)).to be(true) + expect(user.can_admin_organization?(1)).to be(true) + expect(Organization).not_to have_received(:ids) end it "creates a user from an OmniAuth payload" do diff --git a/backend/spec/openapi_helper.rb b/backend/spec/openapi_helper.rb index 7ee82a6..27d56fd 100644 --- a/backend/spec/openapi_helper.rb +++ b/backend/spec/openapi_helper.rb @@ -136,10 +136,10 @@ system_admin: { type: :boolean }, created_at: { type: :string, format: :"date-time" }, updated_at: { type: :string, format: :"date-time" }, - group_ids: { type: :array, items: { type: :integer } }, - groups_count: { type: :integer } + organization_ids: { type: :array, items: { type: :integer } }, + organizations_count: { type: :integer } }, - required: %w[id email display_name title bio system_admin created_at updated_at group_ids groups_count] + required: %w[id email display_name title bio system_admin created_at updated_at organization_ids organizations_count] }, users_index: { type: :object, @@ -178,14 +178,12 @@ }, required: %w[current_page total_pages total_count per_page] }, - group: { + organization: { type: :object, properties: { id: { type: :integer }, name: { type: :string }, description: { type: :string, nullable: true }, - parent_group_id: { type: :integer, nullable: true }, - parent_group_name: { type: :string, nullable: true }, user_ids: { type: :array, items: { type: :integer } }, users_count: { type: :integer }, admin_user_ids: { type: :array, items: { type: :integer } }, @@ -194,36 +192,120 @@ created_at: { type: :string, format: :"date-time" }, updated_at: { type: :string, format: :"date-time" } }, - required: %w[id name description parent_group_id parent_group_name user_ids users_count admin_user_ids admins_count can_admin created_at updated_at] + required: %w[id name description user_ids users_count admin_user_ids admins_count can_admin created_at updated_at] }, - group_request: { + organization_request: { type: :object, properties: { - group: { + organization: { type: :object, properties: { name: { type: :string }, description: { type: :string }, - parent_group_id: { type: :integer, nullable: true }, user_ids: { type: :array, items: { type: :integer } }, admin_user_ids: { type: :array, items: { type: :integer } } } } }, - required: %w[group] + required: %w[organization] + }, + chat_channel: { + type: :object, + properties: { + id: { type: :integer }, + organization_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 organization_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 }, + organization_id: { type: :integer, nullable: true }, + organization_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 organization_id organization_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: { id: { type: :integer }, - group_id: { type: :integer, nullable: true }, + owner_type: { type: :string, enum: %w[User Organization] }, + owner_id: { type: :integer }, + owner_name: { type: :string, nullable: true }, name: { type: :string }, description: { type: :string, nullable: true }, - group_name: { type: :string, nullable: true }, created_at: { type: :string, format: :"date-time" }, updated_at: { type: :string, format: :"date-time" } }, - required: %w[id group_id name description group_name created_at updated_at] + required: %w[id owner_type owner_id owner_name name description created_at updated_at] }, project_request: { type: :object, @@ -233,7 +315,8 @@ properties: { name: { type: :string }, description: { type: :string }, - group_id: { type: :integer, nullable: true } + owner_type: { type: :string, enum: %w[User Organization] }, + owner_id: { type: :integer } } } }, diff --git a/backend/spec/rails_helper.rb b/backend/spec/rails_helper.rb index 6b9f943..7ba6f39 100644 --- a/backend/spec/rails_helper.rb +++ b/backend/spec/rails_helper.rb @@ -52,9 +52,8 @@ DriveItem.with_deleted.delete_all Task.delete_all Project.delete_all - GroupHierarchy.delete_all - GroupMembership.delete_all - Group.delete_all + OrganizationMembership.delete_all + Organization.delete_all OauthIdentity.delete_all User.delete_all end 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..1fcec61 --- /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) + organization = create(:organization) + inaccessible_organization = create(:organization) + create(:organization_membership, user: current_user, organization:) + direct = Chats::DirectConversationFinder.call(current_user:, other_user:) + channel = Chats::ChannelCreator.call(organization:, 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(organization: inaccessible_organization, 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 organization admins to create, update, and delete channels" do + current_user = create(:user) + organization = create(:organization) + create(:organization_membership, :admin, user: current_user, organization:) + + post "/api/v1/organizations/#{organization.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 organization members from creating channels" do + current_user = create(:user) + organization = create(:organization) + create(:organization_membership, user: current_user, organization:) + + post "/api/v1/organizations/#{organization.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 organization members" do + current_user = create(:user) + admin = create(:user) + organization = create(:organization) + create(:organization_membership, user: current_user, organization:) + create(:organization_membership, :admin, user: admin, organization:) + channel = Chats::ChannelCreator.call(organization:, created_by: admin, attributes: { name: "general" }) + + get "/api/v1/organizations/#{organization.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/groups_spec.rb b/backend/spec/requests/api/v1/groups_spec.rb deleted file mode 100644 index b846308..0000000 --- a/backend/spec/requests/api/v1/groups_spec.rb +++ /dev/null @@ -1,250 +0,0 @@ -require "rails_helper" - -RSpec.describe "Api::V1::Groups", type: :request do - describe "GET /api/v1/groups" do - it "lists groups with parent and member information" do - user = create(:user) - parent = create(:group, name: "Engineering") - group = create(:group, name: "Platform", parent_group: parent) - create(:group_membership, user:, group: parent) - create(:group_membership, user:, group:) - - get "/api/v1/groups", headers: auth_headers_for(user) - - platform = json_response.find { |item| item["name"] == "Platform" } - expect(platform["parent_group_id"]).to eq(parent.id) - expect(platform["parent_group_name"]).to eq("Engineering") - expect(platform["user_ids"]).to eq([ user.id ]) - end - - it "lists descendant groups for ancestor members" do - user = create(:user) - parent = create(:group, name: "Engineering") - child = create(:group, name: "Platform", parent_group: parent) - create(:group_membership, user:, group: parent) - - get "/api/v1/groups", headers: auth_headers_for(user) - - expect(response).to have_http_status(:ok) - expect(json_response.pluck("id")).to include(parent.id, child.id) - platform = json_response.find { |item| item["id"] == child.id } - expect(platform["user_ids"]).to eq([]) - expect(platform["parent_group_name"]).to eq("Engineering") - end - - it "requires authentication" do - get "/api/v1/groups" - - expect(response).to have_http_status(:unauthorized) - end - end - - describe "POST /api/v1/groups" do - it "creates a group with members" do - user = create(:user) - member = create(:user) - group_params = { - group: { name: "Product", description: "Product planning", user_ids: [ member.id ] } - } - - post "/api/v1/groups", - params: group_params, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:created) - expect(json_response["name"]).to eq("Product") - expect(json_response["user_ids"]).to contain_exactly(user.id, member.id) - expect(json_response["admin_user_ids"]).to contain_exactly(user.id) - end - - it "does not create a child group under an inaccessible parent" do - user = create(:user) - parent = create(:group) - - post "/api/v1/groups", - params: { group: { name: "Private child", parent_group_id: parent.id } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:forbidden) - end - - it "does not create a child group for ordinary ancestor members" do - user = create(:user) - parent = create(:group) - create(:group_membership, user:, group: parent, admin: false) - - post "/api/v1/groups", - params: { group: { name: "Nested", parent_group_id: parent.id } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:forbidden) - end - - it "creates a child group under an inherited admin parent" do - user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, :admin, user:, group: parent) - - post "/api/v1/groups", - params: { group: { name: "Nested", parent_group_id: child.id } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:created) - expect(json_response["parent_group_id"]).to eq(child.id) - expect(json_response["admin_user_ids"]).to include(user.id) - end - - it "allows system admins to create a child group anywhere" do - user = create(:user, :system_admin) - parent = create(:group) - - post "/api/v1/groups", - params: { group: { name: "System child", parent_group_id: parent.id } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:created) - expect(json_response["parent_group_id"]).to eq(parent.id) - end - end - - describe "PATCH /api/v1/groups/:id" do - it "updates group attributes, members, and admins" do - user = create(:user) - member = create(:user) - group = create(:group) - create(:group_membership, :admin, user:, group:) - group_params = { group: { name: "Renamed", user_ids: [ member.id ], admin_user_ids: [ member.id ] } } - - patch "/api/v1/groups/#{group.id}", - params: group_params, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:ok) - expect(json_response["name"]).to eq("Renamed") - expect(json_response["user_ids"]).to eq([ member.id ]) - expect(json_response["admin_user_ids"]).to eq([ member.id ]) - end - - it "rejects removing the final direct admin" do - user = create(:user) - group = create(:group) - create(:group_membership, :admin, user:, group:) - - patch "/api/v1/groups/#{group.id}", - params: { group: { admin_user_ids: [] } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:unprocessable_content) - expect(json_response.dig("errors", "admin_user_ids")).to be_present - expect(group.group_memberships.where(admin: true).pluck(:user_id)).to eq([ user.id ]) - end - - it "rejects parent cycles" do - user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, :admin, user:, group: parent) - create(:group_membership, :admin, user:, group: child) - - patch "/api/v1/groups/#{parent.id}", - params: { group: { parent_group_id: child.id } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:unprocessable_content) - expect(json_response.dig("errors", "parent_group").join).to include("cannot be a child group") - end - - it "does not update a group for non-members" do - user = create(:user) - group = create(:group) - - patch "/api/v1/groups/#{group.id}", - params: { group: { name: "Renamed" } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:not_found) - end - - it "does not update a descendant group for ordinary ancestor members" do - user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, user:, group: parent, admin: false) - - patch "/api/v1/groups/#{child.id}", - params: { group: { name: "Renamed child" } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:forbidden) - end - - it "updates a child group for direct child admins without parent admin rights" do - user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, :admin, user:, group: child) - - patch "/api/v1/groups/#{child.id}", - params: { group: { name: "Renamed child" } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:ok) - expect(json_response["name"]).to eq("Renamed child") - end - - it "does not move a group under a parent the requester cannot admin" do - user = create(:user) - child = create(:group) - new_parent = create(:group) - create(:group_membership, :admin, user:, group: child) - - patch "/api/v1/groups/#{child.id}", - params: { group: { parent_group_id: new_parent.id } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:forbidden) - end - - it "updates a descendant group for ancestor admins" do - user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, :admin, user:, group: parent) - create(:group_membership, :admin, group: child) - - patch "/api/v1/groups/#{child.id}", - params: { group: { name: "Renamed child" } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:ok) - expect(json_response["name"]).to eq("Renamed child") - end - - it "allows system admins to update any group" do - user = create(:user, :system_admin) - group = create(:group) - create(:group_membership, :admin, group:) - - patch "/api/v1/groups/#{group.id}", - params: { group: { name: "System renamed" } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:ok) - expect(json_response["name"]).to eq("System renamed") - end - end - - describe "DELETE /api/v1/groups/:id" do - it "deletes a group" do - user = create(:user) - group = create(:group) - create(:group_membership, :admin, user:, group:) - - expect { - delete "/api/v1/groups/#{group.id}", headers: auth_headers_for(user) - }.to change(Group, :count).by(-1) - expect(response).to have_http_status(:no_content) - end - end -end 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..c2795b2 --- /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/organizations/{organization_id}/chat_channels" do + parameter name: :organization_id, in: :path, type: :integer + + get "Lists organization 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(:organization) { create(:organization) } + let(:organization_id) { organization.id } + + before do + create(:organization_membership, user: current_user, organization:) + Chats::ChannelCreator.call(organization:, created_by: current_user, attributes: { name: "general" }) + end + + run_test! + end + end + + post "Creates a organization 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(:organization) { create(:organization) } + let(:organization_id) { organization.id } + let(:request_params) { { chat_channel: { name: "general", description: "Team chat" } } } + + before do + create(:organization_membership, :admin, user: current_user, organization:) + 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(:organization) { create(:organization) } + let(:channel) { Chats::ChannelCreator.call(organization:, created_by: current_user, attributes: { name: "general" }) } + let(:id) { channel.id } + let(:request_params) { { chat_channel: { name: "planning" } } } + + before do + create(:organization_membership, :admin, user: current_user, organization:) + end + + run_test! + end + end + + delete "Deletes a chat channel" do + tags "Chat Channels" + security [ bearer_auth: [] ] + + response "204", "channel deleted" do + let(:organization) { create(:organization) } + let(:channel) { Chats::ChannelCreator.call(organization:, created_by: current_user, attributes: { name: "general" }) } + let(:id) { channel.id } + + before do + create(:organization_membership, :admin, user: current_user, organization:) + 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/backend/spec/requests/api/v1/openapi/groups_spec.rb b/backend/spec/requests/api/v1/openapi/groups_spec.rb deleted file mode 100644 index e9f0ed7..0000000 --- a/backend/spec/requests/api/v1/openapi/groups_spec.rb +++ /dev/null @@ -1,135 +0,0 @@ -require "openapi_helper" - -# rubocop:disable RSpec/NestedGroups, RSpec/RepeatedExampleGroupBody, RSpec/VariableName -RSpec.describe "Api::V1::Groups", 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/groups" do - get "Lists accessible groups" do - tags "Groups" - security [ bearer_auth: [] ] - produces "application/json" - - response "200", "groups returned" do - schema type: :array, items: { "$ref" => "#/components/schemas/group" } - - before do - group = create(:group) - create(:group_membership, user: current_user, group:) - end - - run_test! - end - end - - post "Creates a group" do - tags "Groups" - security [ bearer_auth: [] ] - consumes "application/json" - produces "application/json" - parameter name: :request_params, in: :body, schema: { "$ref" => "#/components/schemas/group_request" } - - response "201", "group created" do - schema "$ref" => "#/components/schemas/group" - - let(:member) { create(:user) } - let(:request_params) do - { - group: { - name: "Product", - description: "Product planning", - user_ids: [ member.id ] - } - } - end - - run_test! - end - end - end - - path "/api/v1/groups/{id}" do - parameter name: :id, in: :path, type: :integer - - get "Returns a group" do - tags "Groups" - security [ bearer_auth: [] ] - produces "application/json" - - response "200", "group returned" do - schema "$ref" => "#/components/schemas/group" - - let(:group) { create(:group) } - let(:id) { group.id } - - before do - create(:group_membership, user: current_user, group:) - end - - run_test! - end - end - - patch "Updates a group" do - tags "Groups" - security [ bearer_auth: [] ] - consumes "application/json" - produces "application/json" - parameter name: :request_params, in: :body, schema: { "$ref" => "#/components/schemas/group_request" } - - response "200", "group updated" do - schema "$ref" => "#/components/schemas/group" - - let(:group) { create(:group) } - let(:id) { group.id } - let(:request_params) { { group: { name: "Renamed", user_ids: [ current_user.id ] } } } - - before do - create(:group_membership, :admin, user: current_user, group:) - end - - run_test! - end - end - - put "Replaces a group" do - tags "Groups" - security [ bearer_auth: [] ] - consumes "application/json" - produces "application/json" - parameter name: :request_params, in: :body, schema: { "$ref" => "#/components/schemas/group_request" } - - response "200", "group updated" do - schema "$ref" => "#/components/schemas/group" - - let(:group) { create(:group) } - let(:id) { group.id } - let(:request_params) { { group: { name: "Renamed", user_ids: [ current_user.id ] } } } - - before do - create(:group_membership, :admin, user: current_user, group:) - end - - run_test! - end - end - - delete "Deletes a group" do - tags "Groups" - security [ bearer_auth: [] ] - - response "204", "group deleted" do - let(:group) { create(:group) } - let(:id) { group.id } - - before do - create(:group_membership, :admin, user: current_user, group:) - end - - run_test! - end - end - end -end -# rubocop:enable RSpec/NestedGroups, RSpec/RepeatedExampleGroupBody, RSpec/VariableName diff --git a/backend/spec/requests/api/v1/openapi/organizations_spec.rb b/backend/spec/requests/api/v1/openapi/organizations_spec.rb new file mode 100644 index 0000000..85b17b1 --- /dev/null +++ b/backend/spec/requests/api/v1/openapi/organizations_spec.rb @@ -0,0 +1,135 @@ +require "openapi_helper" + +# rubocop:disable RSpec/NestedGroups, RSpec/RepeatedExampleGroupBody, RSpec/VariableName +RSpec.describe "Api::V1::Organizations", 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/organizations" do + get "Lists accessible organizations" do + tags "Organizations" + security [ bearer_auth: [] ] + produces "application/json" + + response "200", "organizations returned" do + schema type: :array, items: { "$ref" => "#/components/schemas/organization" } + + before do + organization = create(:organization) + create(:organization_membership, user: current_user, organization:) + end + + run_test! + end + end + + post "Creates a organization" do + tags "Organizations" + security [ bearer_auth: [] ] + consumes "application/json" + produces "application/json" + parameter name: :request_params, in: :body, schema: { "$ref" => "#/components/schemas/organization_request" } + + response "201", "organization created" do + schema "$ref" => "#/components/schemas/organization" + + let(:member) { create(:user) } + let(:request_params) do + { + organization: { + name: "Product", + description: "Product planning", + user_ids: [ member.id ] + } + } + end + + run_test! + end + end + end + + path "/api/v1/organizations/{id}" do + parameter name: :id, in: :path, type: :integer + + get "Returns a organization" do + tags "Organizations" + security [ bearer_auth: [] ] + produces "application/json" + + response "200", "organization returned" do + schema "$ref" => "#/components/schemas/organization" + + let(:organization) { create(:organization) } + let(:id) { organization.id } + + before do + create(:organization_membership, user: current_user, organization:) + end + + run_test! + end + end + + patch "Updates a organization" do + tags "Organizations" + security [ bearer_auth: [] ] + consumes "application/json" + produces "application/json" + parameter name: :request_params, in: :body, schema: { "$ref" => "#/components/schemas/organization_request" } + + response "200", "organization updated" do + schema "$ref" => "#/components/schemas/organization" + + let(:organization) { create(:organization) } + let(:id) { organization.id } + let(:request_params) { { organization: { name: "Renamed", user_ids: [ current_user.id ] } } } + + before do + create(:organization_membership, :admin, user: current_user, organization:) + end + + run_test! + end + end + + put "Replaces a organization" do + tags "Organizations" + security [ bearer_auth: [] ] + consumes "application/json" + produces "application/json" + parameter name: :request_params, in: :body, schema: { "$ref" => "#/components/schemas/organization_request" } + + response "200", "organization updated" do + schema "$ref" => "#/components/schemas/organization" + + let(:organization) { create(:organization) } + let(:id) { organization.id } + let(:request_params) { { organization: { name: "Renamed", user_ids: [ current_user.id ] } } } + + before do + create(:organization_membership, :admin, user: current_user, organization:) + end + + run_test! + end + end + + delete "Deletes a organization" do + tags "Organizations" + security [ bearer_auth: [] ] + + response "204", "organization deleted" do + let(:organization) { create(:organization) } + let(:id) { organization.id } + + before do + create(:organization_membership, :admin, user: current_user, organization:) + end + + run_test! + end + end + end +end +# rubocop:enable RSpec/NestedGroups, RSpec/RepeatedExampleGroupBody, RSpec/VariableName diff --git a/backend/spec/requests/api/v1/openapi/projects_spec.rb b/backend/spec/requests/api/v1/openapi/projects_spec.rb index c907789..3faf810 100644 --- a/backend/spec/requests/api/v1/openapi/projects_spec.rb +++ b/backend/spec/requests/api/v1/openapi/projects_spec.rb @@ -32,19 +32,19 @@ response "201", "project created" do schema "$ref" => "#/components/schemas/project" - let(:group) { create(:group) } + let(:organization) { create(:organization) } let(:request_params) do { project: { name: "AI Ops", description: "Agent-managed work.", - group_id: group.id + owner_type: "Organization", owner_id: organization.id } } end before do - create(:group_membership, :admin, user: current_user, group:) + create(:organization_membership, :admin, user: current_user, organization:) end run_test! diff --git a/backend/spec/requests/api/v1/openapi/search_spec.rb b/backend/spec/requests/api/v1/openapi/search_spec.rb index 7845999..9b0249f 100644 --- a/backend/spec/requests/api/v1/openapi/search_spec.rb +++ b/backend/spec/requests/api/v1/openapi/search_spec.rb @@ -11,7 +11,7 @@ security [ bearer_auth: [] ] produces "application/json" parameter name: :q, in: :query, type: :string, required: true - parameter name: :type, in: :query, type: :string, required: false, enum: %w[group project task user] + parameter name: :type, in: :query, type: :string, required: false, enum: %w[organization project task user] response "200", "results returned" do schema "$ref" => "#/components/schemas/search_response" diff --git a/backend/spec/requests/api/v1/organizations_spec.rb b/backend/spec/requests/api/v1/organizations_spec.rb new file mode 100644 index 0000000..54c8b7d --- /dev/null +++ b/backend/spec/requests/api/v1/organizations_spec.rb @@ -0,0 +1,111 @@ +require "rails_helper" + +RSpec.describe "Api::V1::Organizations", type: :request do + describe "GET /api/v1/organizations" do + it "lists organizations the user belongs to with member information" do + user = create(:user) + organization = create(:organization, name: "Engineering") + create(:organization, name: "Private") + create(:organization_membership, user:, organization:) + + get "/api/v1/organizations", headers: auth_headers_for(user) + + expect(response).to have_http_status(:ok) + expect(json_response.pluck("name")).to eq([ "Engineering" ]) + expect(json_response.first["user_ids"]).to eq([ user.id ]) + end + + it "allows system admins to list all organizations" do + user = create(:user, :system_admin) + create(:organization, name: "Engineering") + create(:organization, name: "Product") + + get "/api/v1/organizations", headers: auth_headers_for(user) + + expect(response).to have_http_status(:ok) + expect(json_response.pluck("name")).to eq(%w[Engineering Product]) + end + end + + describe "POST /api/v1/organizations" do + it "creates an organization with the current user as an admin" do + user = create(:user) + member = create(:user) + + post "/api/v1/organizations", + params: { organization: { name: "Product", description: "Planning", user_ids: [ member.id ] } }, + headers: auth_headers_for(user) + + expect(response).to have_http_status(:created) + expect(json_response["name"]).to eq("Product") + expect(json_response["user_ids"]).to contain_exactly(user.id, member.id) + expect(json_response["admin_user_ids"]).to contain_exactly(user.id) + end + + it "rejects unknown user ids" do + user = create(:user) + + post "/api/v1/organizations", + params: { organization: { name: "Product", user_ids: [ 123_456 ] } }, + headers: auth_headers_for(user) + + expect(response).to have_http_status(:unprocessable_content) + end + end + + describe "PATCH /api/v1/organizations/:id" do + it "updates organization attributes, members, and admins" do + user = create(:user) + member = create(:user) + organization = create(:organization) + create(:organization_membership, :admin, user:, organization:) + + patch "/api/v1/organizations/#{organization.id}", + params: { organization: { name: "Renamed", user_ids: [ member.id ], admin_user_ids: [ member.id ] } }, + headers: auth_headers_for(user) + + expect(response).to have_http_status(:ok) + expect(json_response["name"]).to eq("Renamed") + expect(json_response["user_ids"]).to eq([ member.id ]) + expect(json_response["admin_user_ids"]).to eq([ member.id ]) + end + + it "does not update an organization for ordinary members" do + user = create(:user) + organization = create(:organization) + create(:organization_membership, user:, organization:, admin: false) + + patch "/api/v1/organizations/#{organization.id}", + params: { organization: { name: "Renamed" } }, + headers: auth_headers_for(user) + + expect(response).to have_http_status(:forbidden) + end + + it "rejects removing the final admin" do + user = create(:user) + organization = create(:organization) + create(:organization_membership, :admin, user:, organization:) + + patch "/api/v1/organizations/#{organization.id}", + params: { organization: { admin_user_ids: [] } }, + headers: auth_headers_for(user) + + expect(response).to have_http_status(:unprocessable_content) + expect(json_response.dig("errors", "admin_user_ids")).to be_present + end + end + + describe "DELETE /api/v1/organizations/:id" do + it "deletes an organization for admins" do + user = create(:user) + organization = create(:organization) + create(:organization_membership, :admin, user:, organization:) + + expect { + delete "/api/v1/organizations/#{organization.id}", headers: auth_headers_for(user) + }.to change(Organization, :count).by(-1) + expect(response).to have_http_status(:no_content) + end + end +end diff --git a/backend/spec/requests/api/v1/projects_spec.rb b/backend/spec/requests/api/v1/projects_spec.rb index 2cb7a92..ab72f6f 100644 --- a/backend/spec/requests/api/v1/projects_spec.rb +++ b/backend/spec/requests/api/v1/projects_spec.rb @@ -2,111 +2,80 @@ RSpec.describe "Api::V1::Projects", type: :request do describe "GET /api/v1/projects" do - it "lists projects newest first" do + it "lists user-owned and organization-owned projects newest first" do user = create(:user) - older = create(:project, user:, name: "Older") - newer = create(:project, user:, name: "Newer") - create(:project, name: "Other user's project") + organization = create(:organization) + create(:organization_membership, user:, organization:) + older = create(:project, :user_owned, user:, name: "Personal") + newer = create(:project, user:, owner: organization, name: "Org") + create(:project, :user_owned, name: "Private") older.update!(created_at: 1.day.ago) newer.update!(created_at: Time.current) get "/api/v1/projects", headers: auth_headers_for(user) expect(response).to have_http_status(:ok) - expect(json_response.pluck("name")).to eq([ "Newer", "Older" ]) - end - - it "lists projects in descendant groups" do - user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, user:, group: parent) - project = create(:project, group: child, name: "Child Board") - - get "/api/v1/projects", headers: auth_headers_for(user) - - expect(response).to have_http_status(:ok) - expect(json_response.pluck("id")).to include(project.id) - end - - it "requires authentication" do - get "/api/v1/projects" - - expect(response).to have_http_status(:unauthorized) + expect(json_response.pluck("name")).to eq(%w[Org Personal]) end end describe "POST /api/v1/projects" do - it "creates a project" do + it "creates a user-owned project" do user = create(:user) - group = create(:group) - create(:group_membership, :admin, user:, group:) - project_params = { name: "AI Ops", description: "Agent-managed work.", group_id: group.id } post "/api/v1/projects", - params: { project: project_params }, - headers: auth_headers_for(user) + params: { project: { name: "Personal", owner_type: "User", owner_id: user.id } }, + headers: auth_headers_for(user) expect(response).to have_http_status(:created) - expect(json_response["name"]).to eq("AI Ops") project = Project.find(json_response["id"]) - expect(project.user).to eq(user) - expect(project.group).to eq(group) - end - - it "does not create a project for ordinary group members" do - user = create(:user) - group = create(:group) - create(:group_membership, user:, group:, admin: false) - - post "/api/v1/projects", - params: { project: { name: "AI Ops", group_id: group.id } }, - headers: auth_headers_for(user) - - expect(response).to have_http_status(:forbidden) + expect(project.owner).to eq(user) + expect(json_response.slice("owner_type", "owner_id", "owner_name")).to include( + "owner_type" => "User", + "owner_id" => user.id + ) end - it "creates a project in a descendant group" do + it "creates an organization-owned project for organization admins" do user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, :admin, user:, group: parent) + organization = create(:organization) + create(:organization_membership, :admin, user:, organization:) post "/api/v1/projects", - params: { project: { name: "Inherited Ops", group_id: child.id } }, - headers: auth_headers_for(user) + params: { project: { name: "Org", owner_type: "Organization", owner_id: organization.id } }, + headers: auth_headers_for(user) expect(response).to have_http_status(:created) - expect(Project.find(json_response["id"]).group).to eq(child) + expect(Project.find(json_response["id"]).owner).to eq(organization) end - it "allows system admins to create a project in any group" do - user = create(:user, :system_admin) - group = create(:group) + it "does not create an organization-owned project for ordinary members" do + user = create(:user) + organization = create(:organization) + create(:organization_membership, user:, organization:, admin: false) post "/api/v1/projects", - params: { project: { name: "System Ops", group_id: group.id } }, - headers: auth_headers_for(user) + params: { project: { name: "Org", owner_type: "Organization", owner_id: organization.id } }, + headers: auth_headers_for(user) - expect(response).to have_http_status(:created) - expect(Project.find(json_response["id"]).group).to eq(group) + expect(response).to have_http_status(:forbidden) end - it "does not create a project in a group the user does not belong to" do + it "does not create a user-owned project for another user" do user = create(:user) - group = create(:group) + other = create(:user) post "/api/v1/projects", - params: { project: { name: "AI Ops", group_id: group.id } }, - headers: auth_headers_for(user) + params: { project: { name: "Other", owner_type: "User", owner_id: other.id } }, + headers: auth_headers_for(user) expect(response).to have_http_status(:forbidden) end end describe "GET /api/v1/projects/:id" do - it "returns a project" do - project = create(:project) + it "returns a user-owned project to its owner" do + project = create(:project, :user_owned) get "/api/v1/projects/#{project.id}", headers: auth_headers_for(project.user) @@ -114,33 +83,20 @@ expect(json_response["id"]).to eq(project.id) end - it "returns another user's project when the requester is a group member" do + it "returns an organization-owned project to members" do project = create(:project) member = create(:user) - create(:group_membership, group: project.group, user: member) + create(:organization_membership, organization: project.owner, user: member) get "/api/v1/projects/#{project.id}", headers: auth_headers_for(member) expect(response).to have_http_status(:ok) - expect(json_response["id"]).to eq(project.id) - expect(json_response["group_id"]).to eq(project.group_id) + expect(json_response["owner_type"]).to eq("Organization") + expect(json_response["owner_id"]).to eq(project.owner_id) end - it "returns a project when the requester belongs to an ancestor group" do - user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, user:, group: parent) - project = create(:project, group: child) - - get "/api/v1/projects/#{project.id}", headers: auth_headers_for(user) - - expect(response).to have_http_status(:ok) - expect(json_response["id"]).to eq(project.id) - end - - it "does not return another user's project" do - project = create(:project) + it "does not return another user's personal project" do + project = create(:project, :user_owned) get "/api/v1/projects/#{project.id}", headers: auth_headers_for(create(:user)) @@ -149,66 +105,54 @@ end describe "PATCH /api/v1/projects/:id" do - it "updates project attributes" do - project = create(:project) - member = create(:user) - create(:group_membership, :admin, group: project.group, user: member) + it "updates a user-owned project for the owner" do + project = create(:project, :user_owned) patch "/api/v1/projects/#{project.id}", - params: { project: { name: "Renamed" } }, - headers: auth_headers_for(member) + params: { project: { name: "Renamed" } }, + headers: auth_headers_for(project.user) expect(response).to have_http_status(:ok) expect(json_response["name"]).to eq("Renamed") end - it "does not update project attributes for ordinary group members" do - project = create(:project) - member = create(:user) - create(:group_membership, group: project.group, user: member, admin: false) + it "moves a project to an organization the requester can admin" do + project = create(:project, :user_owned) + organization = create(:organization) + create(:organization_membership, :admin, user: project.user, organization:) patch "/api/v1/projects/#{project.id}", - params: { project: { name: "Renamed" } }, - headers: auth_headers_for(member) + params: { project: { owner_type: "Organization", owner_id: organization.id } }, + headers: auth_headers_for(project.user) - expect(response).to have_http_status(:forbidden) + expect(response).to have_http_status(:ok) + expect(project.reload.owner).to eq(organization) end - it "does not move a project to a group the requester cannot access" do - project = create(:project) - inaccessible_group = create(:group) + it "does not move a project to an organization the requester cannot admin" do + project = create(:project, :user_owned) + organization = create(:organization) patch "/api/v1/projects/#{project.id}", - params: { project: { group_id: inaccessible_group.id } }, - headers: auth_headers_for(project.user) + params: { project: { owner_type: "Organization", owner_id: organization.id } }, + headers: auth_headers_for(project.user) expect(response).to have_http_status(:forbidden) - expect(project.reload.group).not_to eq(inaccessible_group) + expect(project.reload.owner).not_to eq(organization) end end describe "DELETE /api/v1/projects/:id" do - it "deletes a project" do + it "deletes a project for an organization admin" do project = create(:project) member = create(:user) - create(:group_membership, :admin, group: project.group, user: member) + create(:organization_membership, :admin, organization: project.owner, user: member) expect { delete "/api/v1/projects/#{project.id}", headers: auth_headers_for(member) }.to change(Project, :count).by(-1) expect(response).to have_http_status(:no_content) end - - it "does not delete a project for ordinary group members" do - project = create(:project) - member = create(:user) - create(:group_membership, group: project.group, user: member, admin: false) - - expect { - delete "/api/v1/projects/#{project.id}", headers: auth_headers_for(member) - }.not_to change(Project, :count) - expect(response).to have_http_status(:forbidden) - end end describe "GET /api/v1/projects/:id/board" do @@ -221,18 +165,6 @@ expect(response).to have_http_status(:ok) expect_board_response(project) end - - it "localizes status names from the Accept-Language header" do - project = create(:project) - - get "/api/v1/projects/#{project.id}/board", - headers: auth_headers_for(project.user).merge("Accept-Language" => "zh-CN") - - expect(response).to have_http_status(:ok) - expect(json_response["statuses"].map { |status| status["name"] }).to eq( - %w[待办 进行中 待评审 完成] - ) - end end def create_board_tasks(project) @@ -248,8 +180,5 @@ def expect_board_response(project) expect(json_response["iterations"].pluck("name")).to include("Inbox", "Sprint 1") expect(json_response["statuses"].map { |status| status["slug"] }).to eq(%w[todo in_progress under_review done]) expect(json_response["statuses"].first["tasks"].pluck("title")).to eq([ "Plan" ]) - expect(json_response["statuses"].first["tasks"].first["iteration_name"]).to eq("Sprint 1") - expect(json_response["statuses"].second["tasks"].pluck("title")).to eq([ "Build" ]) - expect(json_response["statuses"].second["tasks"].first["iteration_name"]).to eq("Inbox") end end diff --git a/backend/spec/requests/api/v1/search_spec.rb b/backend/spec/requests/api/v1/search_spec.rb index d7139c5..54a691b 100644 --- a/backend/spec/requests/api/v1/search_spec.rb +++ b/backend/spec/requests/api/v1/search_spec.rb @@ -26,11 +26,11 @@ def search_result_slugs(query, user, resource_type: nil) it "searches resources visible to the current user" do user = create(:user) - group = create(:group) - create(:group_membership, user:, group:) + organization = create(:organization) + create(:organization_membership, user:, organization:) owner = create(:user) - create(:group_membership, user: owner, group:) - project = create(:project, user: owner, group:, name: "Apollo", description: "Moonshot roadmap") + create(:organization_membership, user: owner, organization:) + project = create(:project, user: owner, owner: organization, name: "Apollo", description: "Moonshot roadmap") create(:project, name: "Other Apollo") get "/api/v1/search", params: { q: "Apollo" }, headers: auth_headers_for(user) @@ -41,15 +41,12 @@ def search_result_slugs(query, user, resource_type: nil) expect(slugs).not_to include(SearchDocument.find_by(title: "Other Apollo").resource_slug) end - it "searches resources in descendant groups" do + it "searches resources in user-owned projects" do user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, user:, group: parent) - project = create(:project, group: child, name: "Nested Apollo") - task = create(:task, project:, title: "Nested Apollo task") + project = create(:project, :user_owned, user:, name: "Personal Apollo") + task = create(:task, project:, title: "Personal Apollo task") - get "/api/v1/search", params: { q: "Nested Apollo" }, headers: auth_headers_for(user) + get "/api/v1/search", params: { q: "Personal Apollo" }, headers: auth_headers_for(user) expect(response).to have_http_status(:ok) expect(json_response["results"].pluck("slug")).to include( @@ -60,9 +57,9 @@ def search_result_slugs(query, user, resource_type: nil) it "searches drive folders and files visible to the current user" do user = create(:user) - group = create(:group) - create(:group_membership, user:, group:) - project = create(:project, group:) + organization = create(:organization) + create(:organization_membership, user:, organization:) + project = create(:project, owner: organization) folder = create(:drive_item, project:, name: "Architecture") file = create(:drive_item, :file, project:, name: "Budget.xlsx") hidden_file = create(:drive_item, :file, name: "Budget hidden.xlsx") @@ -72,12 +69,12 @@ def search_result_slugs(query, user, resource_type: nil) expect(json_response["results"].pluck("slug")).not_to include("drive_item:#{hidden_file.id}") end - it "includes global users and member groups for authenticated users" do + it "includes global users and member organizations for authenticated users" do user = create(:user) target = create(:user, display_name: "Ada Lovelace") - group = create(:group, name: "Research Guild") - hidden_group = create(:group, name: "Research Vault") - create(:group_membership, user:, group:) + organization = create(:organization, name: "Research Guild") + hidden_organization = create(:organization, name: "Research Vault") + create(:organization_membership, user:, organization:) get "/api/v1/search", params: { q: "Ada" }, headers: auth_headers_for(user) @@ -87,8 +84,8 @@ def search_result_slugs(query, user, resource_type: nil) get "/api/v1/search", params: { q: "Research" }, headers: auth_headers_for(user) slugs = json_response["results"].pluck("slug") - expect(slugs).to include("group:#{group.id}") - expect(slugs).not_to include("group:#{hidden_group.id}") + expect(slugs).to include("organization:#{organization.id}") + expect(slugs).not_to include("organization:#{hidden_organization.id}") end it "orders exact slug matches first" do diff --git a/backend/spec/requests/api/v1/tasks_spec.rb b/backend/spec/requests/api/v1/tasks_spec.rb index 4689122..124d9cb 100644 --- a/backend/spec/requests/api/v1/tasks_spec.rb +++ b/backend/spec/requests/api/v1/tasks_spec.rb @@ -42,7 +42,7 @@ def create_task_with_role_users(**attributes) project = create(:project) create(:task, project:, title: "First task") member = create(:user) - create(:group_membership, group: project.group, user: member) + create(:organization_membership, organization: project.owner, user: member) get "/api/v1/projects/#{project.id}/tasks", headers: auth_headers_for(member) @@ -50,18 +50,15 @@ def create_task_with_role_users(**attributes) expect(json_response.pluck("title")).to eq([ "First task" ]) end - it "lists tasks in descendant group projects" do + it "lists tasks in user-owned projects for the owner" do user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, user:, group: parent) - project = create(:project, group: child) - create(:task, project:, title: "Inherited task") + project = create(:project, :user_owned, user:) + create(:task, project:, title: "Personal task") get "/api/v1/projects/#{project.id}/tasks", headers: auth_headers_for(user) expect(response).to have_http_status(:ok) - expect(json_response.pluck("title")).to eq([ "Inherited task" ]) + expect(json_response.pluck("title")).to eq([ "Personal task" ]) end it "does not list another user's project tasks" do @@ -86,7 +83,7 @@ def create_task_with_role_users(**attributes) it "creates a task in the requested status" do project = create(:project) member = create(:user) - create(:group_membership, group: project.group, user: member) + create(:organization_membership, organization: project.owner, user: member) post_project_task(project, task_params, user: member) @@ -152,12 +149,9 @@ def create_task_with_role_users(**attributes) ) end - it "creates a task in a descendant group project" do + it "creates a task in a user-owned project" do user = create(:user) - parent = create(:group) - child = create(:group, parent_group: parent) - create(:group_membership, user:, group: parent) - project = create(:project, group: child) + project = create(:project, :user_owned, user:) post "/api/v1/projects/#{project.id}/tasks", params: { task: task_params }, @@ -190,7 +184,7 @@ def create_task_with_role_users(**attributes) project = create(:project) task = create(:task, project:) member = create(:user) - create(:group_membership, group: project.group, user: member) + create(:organization_membership, organization: project.owner, user: member) patch "/api/v1/tasks/#{task.id}", params: { task: { status: "done", position: 4 } }, @@ -244,7 +238,7 @@ def create_task_with_role_users(**attributes) it "deletes a task" do task = create(:task) member = create(:user) - create(:group_membership, group: task.project.group, user: member) + create(:organization_membership, organization: task.project.owner, user: member) expect { delete "/api/v1/tasks/#{task.id}", headers: auth_headers_for(member) diff --git a/backend/spec/requests/api/v1/users_spec.rb b/backend/spec/requests/api/v1/users_spec.rb index 601ef61..c96f526 100644 --- a/backend/spec/requests/api/v1/users_spec.rb +++ b/backend/spec/requests/api/v1/users_spec.rb @@ -2,11 +2,11 @@ RSpec.describe "Api::V1::Users", type: :request do describe "GET /api/v1/users" do - it "lists paginated users with group membership ids" do + it "lists paginated users with organization membership ids" do user = create(:user, email: "ada@example.com") other_user = create(:user, email: "grace@example.com") - group = create(:group) - create(:group_membership, user: other_user, group:) + organization = create(:organization) + create(:organization_membership, user: other_user, organization:) get "/api/v1/users", params: { page: 1, per_page: 1 }, headers: auth_headers_for(user) @@ -17,8 +17,8 @@ get "/api/v1/users", params: { page: 2, per_page: 1 }, headers: auth_headers_for(user) - expect(json_response["users"].first["group_ids"]).to eq([ group.id ]) - expect(json_response["users"].first["groups_count"]).to eq(1) + expect(json_response["users"].first["organization_ids"]).to eq([ organization.id ]) + expect(json_response["users"].first["organizations_count"]).to eq(1) end it "requires authentication" do diff --git a/frontend/e2e/chats.spec.ts b/frontend/e2e/chats.spec.ts new file mode 100644 index 0000000..ba192c3 --- /dev/null +++ b/frontend/e2e/chats.spec.ts @@ -0,0 +1,317 @@ +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 + organization_id: number | null + organization_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 organization = { + id: 1, + name: 'Engineering', + description: 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', + organization_id: 1, + organization_name: 'Engineering', + channel_id: 100, + channel_name: 'general', + can_manage: true, + }), + ] + const channels = [ + { + id: 100, + organization_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/organizations', async (route) => { + await json(route, [organization]) + }) + + 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/organizations/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, + organization_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, + organization_id: 1, + organization_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 + organization_id?: number + organization_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, + organization_id: input.organization_id ?? null, + organization_name: input.organization_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/e2e/drive.spec.ts b/frontend/e2e/drive.spec.ts index 45d03c5..0cd615d 100644 --- a/frontend/e2e/drive.spec.ts +++ b/frontend/e2e/drive.spec.ts @@ -2,8 +2,9 @@ import { expect, test, type Page, type Route } from '@playwright/test' interface ProjectPayload { id: number - group_id: number - group_name: string | null + owner_type: 'User' | 'Organization' + owner_id: number + owner_name: string name: string description: string | null created_at: string @@ -62,8 +63,9 @@ async function installMockApi(page: Page) { const contents = new Map() const project: ProjectPayload = { id: 1, - group_id: 1, - group_name: 'Engineering', + owner_type: 'Organization', + owner_id: 1, + owner_name: 'Engineering', name: 'MVP', description: 'Initial workspace', created_at: timestamp, @@ -186,7 +188,7 @@ async function installMockApi(page: Page) { await json(route, [project]) }) - await page.route('**/api/v1/groups', async (route) => { + await page.route('**/api/v1/organizations', async (route) => { if (!(await requireAuth(route))) return await json(route, []) diff --git a/frontend/e2e/kanban.spec.ts b/frontend/e2e/kanban.spec.ts index e1748c1..6b56c7d 100644 --- a/frontend/e2e/kanban.spec.ts +++ b/frontend/e2e/kanban.spec.ts @@ -2,8 +2,9 @@ import { expect, test, type Page, type Route } from '@playwright/test' interface ProjectPayload { id: number - group_id: number - group_name: string | null + owner_type: 'User' | 'Organization' + owner_id: number + owner_name: string name: string description: string | null created_at: string @@ -87,8 +88,9 @@ async function installMockApi(page: Page) { const project: ProjectPayload = { id: 1, - group_id: 1, - group_name: 'Engineering', + owner_type: 'Organization', + owner_id: 1, + owner_name: 'Engineering', name: 'MVP', description: 'Initial Kanban surface', created_at: timestamp, @@ -284,7 +286,7 @@ async function installMockApi(page: Page) { await json(route, project, 201) }) - await page.route('**/api/v1/groups', async (route) => { + await page.route('**/api/v1/organizations', async (route) => { if (!(await requireAuth(route))) return await json(route, [ @@ -292,8 +294,6 @@ async function installMockApi(page: Page) { id: 1, name: 'Engineering', description: 'Product engineering workspace', - parent_group_id: null, - parent_group_name: null, user_ids: [1], users_count: 1, admin_user_ids: [1], diff --git a/frontend/src/App.vue b/frontend/src/App.vue index bd77761..193d287 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -9,18 +9,20 @@ 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' -import { useUserGroupsStore } from '@/stores/userGroups' +import { useOrganizationsStore } from '@/stores/organizations' import type { ContentClassKind } from '@/router' 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 organizationsStore = useOrganizationsStore() const toasts = useToastsStore() const auth = storeToRefs(authStore) const route = useRoute() @@ -80,15 +82,16 @@ function handleServerUnavailable() { } async function preloadWorkspaceData() { - await Promise.all([projectsStore.loadProjects(), userGroupsStore.loadGroups()]) + await Promise.all([projectsStore.loadProjects(), organizationsStore.loadOrganizations()]) } async function signOut() { await authStore.logout() projectsStore.clearProjects() boardStore.clearBoard() + chatsStore.clearChats() driveStore.clearDrive() - userGroupsStore.clearDirectory() + organizationsStore.clearDirectory() toasts.clearToasts() await router.push({ name: 'auth' }) } @@ -122,7 +125,7 @@ async function openSearchResult(result: SearchResult) { return } - await router.push({ name: 'group-detail', params: { groupId: result.id } }) + await router.push({ name: 'organization-detail', params: { organizationId: result.id } }) } function contentClassFor(kind: ContentClassKind = 'default') { diff --git a/frontend/src/components/chats/ChannelDialog.vue b/frontend/src/components/chats/ChannelDialog.vue new file mode 100644 index 0000000..4782efc --- /dev/null +++ b/frontend/src/components/chats/ChannelDialog.vue @@ -0,0 +1,80 @@ + + +