diff --git a/.gitignore b/.gitignore index 068d86a0..1cf52baf 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ .DS_Store /dump.rdb + +/coverage diff --git a/.ruby-version b/.ruby-version index be94e6f5..eb39e538 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.2 +3.3 diff --git a/.tool-versions b/.tool-versions index 124d9987..bf90baeb 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ -ruby 3.2.2 +ruby 3.3 nodejs 21.5.0 yarn 1.22.19 diff --git a/Gemfile b/Gemfile index 541825c1..32b05f10 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" git_source(:github) { |repo| "https://github.com/#{repo}.git" } -ruby ">= 3.2.2" +ruby "~> 3.3" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" gem "rails", "~> 7.1", ">= 7.0.4.2" @@ -91,6 +91,9 @@ group :test do gem "selenium-webdriver" gem "webdrivers" gem "rails-controller-testing" + gem "simplecov" + gem "simplecov-lcov" + gem "undercover" end gem "sentry-ruby" @@ -123,3 +126,5 @@ gem "tailwind_merge", "~> 1.2" gem "wicked", "~> 2.0" gem "fast-mcp" + +gem "brakeman", "~> 8.0" diff --git a/Gemfile.lock b/Gemfile.lock index 4d3f1caf..03e435f1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -92,6 +92,8 @@ GEM bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) + brakeman (8.0.4) + racc builder (3.2.4) capybara (3.39.0) addressable @@ -124,6 +126,7 @@ GEM devise (>= 4.6) discard (1.3.0) activerecord (>= 4.2, < 8) + docile (1.4.1) dotenv (2.8.1) dotenv-rails (2.8.1) dotenv (= 2.8.1) @@ -180,6 +183,8 @@ GEM railties (>= 7.0.0) i18n (1.14.4) concurrent-ruby (~> 1.0) + imagen (0.2.0) + parser (>= 2.5, != 2.5.1.1) invisible_captcha (2.3.0) rails (>= 5.2) io-console (0.6.0) @@ -354,6 +359,7 @@ GEM rubyzip (2.3.2) rufus-scheduler (3.9.1) fugit (~> 1.1, >= 1.1.6) + rugged (1.9.0) securerandom (0.4.1) selenium-webdriver (4.9.0) rexml (~> 3.2, >= 3.2.5) @@ -376,6 +382,13 @@ GEM rufus-scheduler (~> 3.2) sidekiq (>= 6, < 8) tilt (>= 1.4.0, < 3) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov-lcov (0.9.0) + simplecov_json_formatter (0.1.4) sin_lru_redux (2.5.2) sprockets (4.2.0) concurrent-ruby (~> 1.0) @@ -398,6 +411,14 @@ GEM railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + undercover (0.8.4) + base64 + bigdecimal + imagen (>= 0.2.0) + rainbow (>= 2.1, < 4.0) + rugged (>= 0.27, < 1.10) + simplecov + simplecov_json_formatter unicode-display_width (2.5.0) warden (1.2.9) rack (>= 2.0.9) @@ -431,6 +452,7 @@ DEPENDENCIES action_policy activerecord-import bootsnap + brakeman (~> 8.0) capybara cssbundling-rails debug @@ -461,18 +483,21 @@ DEPENDENCIES sentry-ruby sidekiq (~> 7.3) sidekiq-scheduler + simplecov + simplecov-lcov sprockets-rails stimulus-rails strong_migrations tailwind_merge (~> 1.2) turbo-rails (>= 2.0.10) tzinfo-data + undercover web-console webdrivers wicked (~> 2.0) RUBY VERSION - ruby 3.2.2p53 + ruby 3.3.4p94 BUNDLED WITH 2.4.22 diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index d293d8b5..1ff12928 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -7,8 +7,9 @@ class ReportsController < AuthenticatedController def index @summary = Reports::Summary.new( time_regs: @time_regs, + organization: current_user.current_organization, ) - @results = Reports::Result.new(time_regs: @time_regs, filter: @filter) + @results = Reports::Result.new(time_regs: @time_regs, filter: @filter, organization: current_user.current_organization) authorize! end diff --git a/app/controllers/time_regs_controller.rb b/app/controllers/time_regs_controller.rb index 837cc06b..546c580e 100644 --- a/app/controllers/time_regs_controller.rb +++ b/app/controllers/time_regs_controller.rb @@ -42,7 +42,7 @@ def new_modal set_assigned_tasks - if current_user.current_organization.projects.empty? + unless current_user.current_organization.has_accessible_projects? flash[:alert] = I18n.t("alert.create_project_before_registering_time") redirect_back fallback_location: time_regs_path end @@ -93,8 +93,10 @@ def toggle_active # exports the time_regs in a project to a .CSV def export + authorize! project = authorized_scope(Project, type: :relation).find(params[:project_id]) client = project.client + current_org = current_user.current_organization time_regs = project.time_regs.includes( :task, :user, @@ -106,8 +108,10 @@ def export csv << [ "date", "client", "project", "task", "notes", "minutes", "first name", "last name", "email" ] # Add CSV data rows for each time_reg time_regs.each do |time_reg| + # Strip email for users not in the current organization + email = time_reg.user.access_infos.exists?(organization: current_org) ? time_reg.user.email : "" csv << [ time_reg.date_worked, client.name, project.name, time_reg.assigned_task.task.name, - time_reg.notes, time_reg.minutes, time_reg.user.first_name, time_reg.user.last_name, time_reg.user.email ] + time_reg.notes, time_reg.minutes, time_reg.user.first_name, time_reg.user.last_name, email ] end end send_data csv_data, filename: "#{Time.now.to_i}_time_regs_for_#{project.name}.csv" diff --git a/app/controllers/workspace/project_share_task_rates_controller.rb b/app/controllers/workspace/project_share_task_rates_controller.rb new file mode 100644 index 00000000..b1b1e834 --- /dev/null +++ b/app/controllers/workspace/project_share_task_rates_controller.rb @@ -0,0 +1,46 @@ +module Workspace + class ProjectShareTaskRatesController < WorkspaceController + before_action :set_project + before_action :set_project_share + before_action :set_task_rate, only: :update + + def create + @task_rate = @project_share.project_share_task_rates.new(task_rate_params) + authorize! @task_rate + + if @task_rate.save + redirect_to workspace_project_path(@project), notice: t("notice.project_share_rates_updated") + else + redirect_to workspace_project_path(@project), alert: t("alert.unable_to_proceed") + end + end + + def update + authorize! @task_rate + + if @task_rate.update(task_rate_params) + redirect_to workspace_project_path(@project), notice: t("notice.project_share_rates_updated") + else + redirect_to workspace_project_path(@project), alert: t("alert.unable_to_proceed") + end + end + + private + + def set_project + @project = authorized_scope(Project, type: :relation).find(params[:project_id]) + end + + def set_project_share + @project_share = authorized_scope(ProjectShare, type: :relation).find(params[:project_share_id]) + end + + def set_task_rate + @task_rate = authorized_scope(ProjectShareTaskRate, type: :relation).find(params[:id]) + end + + def task_rate_params + params.require(:project_share_task_rate).permit(:assigned_task_id, :rate_currency) + end + end +end diff --git a/app/controllers/workspace/project_shares_controller.rb b/app/controllers/workspace/project_shares_controller.rb new file mode 100644 index 00000000..47ddd4c0 --- /dev/null +++ b/app/controllers/workspace/project_shares_controller.rb @@ -0,0 +1,54 @@ +module Workspace + class ProjectSharesController < WorkspaceController + before_action :set_project + before_action :set_project_share, only: %i[update destroy] + + def index + authorize! ProjectShare + @project_shares = authorized_scope(ProjectShare, type: :relation) + .where(project: @project) + .includes(:organization) + end + + def update + authorize! @project_share + if @project_share.update(project_share_params) + redirect_to workspace_project_path(@project), notice: t("notice.project_share_rates_updated") + else + redirect_to workspace_project_path(@project), alert: t("alert.unable_to_proceed") + end + end + + def destroy + authorize! @project_share + DisconnectProjectShareService.new(project_share: @project_share).call + + if owner_org? + redirect_to workspace_project_path(@project), notice: t("notice.project_share_disconnected") + else + redirect_to workspace_projects_path, notice: t("notice.project_share_disconnected") + end + end + + private + + def set_project + @project = authorized_scope(Project, type: :relation).find(params[:project_id]) + end + + def set_project_share + @project_share = authorized_scope(ProjectShare, type: :relation).find(params[:id]) + end + + def project_share_params + params.require(:project_share).permit( + :rate_currency, + project_share_task_rates_attributes: [ :id, :rate_currency ] + ) + end + + def owner_org? + @project.owning_organization == current_user.current_organization + end + end +end diff --git a/app/controllers/workspace/projects_controller.rb b/app/controllers/workspace/projects_controller.rb index 4edf5b0a..8c9818b1 100644 --- a/app/controllers/workspace/projects_controller.rb +++ b/app/controllers/workspace/projects_controller.rb @@ -29,6 +29,18 @@ def create def show authorize! @project + + @shared_project = @project.shared_with?(current_user.current_organization) + @project_share = @project.project_share_for(current_user.current_organization) + + if @shared_project && @project_share + @project_share = ProjectShare.includes(project_share_task_rates: { assigned_task: :task }) + .find(@project_share.id) + end + + unless @shared_project + @guest_organizations = @project.project_shares.includes(:organization) + end end def update @@ -43,7 +55,7 @@ def update end def index - @pagy, @clients = pagy authorized_scope(Client, type: :relation).order(:name).includes(:projects), items: 6 + @pagy, @clients = pagy authorized_scope(Client, type: :relation).order(:name).includes(projects: :project_shares), items: 6 authorize! end diff --git a/app/models/assigned_task.rb b/app/models/assigned_task.rb index d6c72cf4..9c86e02d 100644 --- a/app/models/assigned_task.rb +++ b/app/models/assigned_task.rb @@ -41,7 +41,8 @@ def is_not_archived def handle_rate_change if rate_changed? - self.class.create!(project: project, task: task, rate: rate) + new_task = self.class.create!(project: project, task: task, rate: rate) + ProjectShareTaskRate.where(assigned_task: self).update_all(assigned_task_id: new_task.id) self.is_archived = true self.rate = rate_was end diff --git a/app/models/organization.rb b/app/models/organization.rb index 3fdc6278..7dd5688a 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -6,10 +6,16 @@ class Organization < ApplicationRecord has_many :assigned_tasks, through: :tasks has_many :projects, through: :clients has_many :time_regs, through: :users + has_many :project_shares, dependent: :destroy + has_many :shared_projects, through: :project_shares, source: :project validates :name, presence: true, uniqueness: true validate :currency_exists + def has_accessible_projects? + projects.any? || shared_projects.any? + end + def currency_exists errors.add(:currency, "is not a valid currency") unless Stemplin.config.currencies.keys.include?(self.currency&.to_sym) end diff --git a/app/models/project.rb b/app/models/project.rb index d865b645..5c2f13b1 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -18,6 +18,8 @@ class Project < ApplicationRecord has_many :access_infos, through: :project_accesses has_many :users, through: :access_infos has_many :project_invitations, dependent: :destroy + has_many :project_shares, dependent: :destroy + has_many :shared_organizations, through: :project_shares, source: :organization accepts_nested_attributes_for :assigned_tasks, allow_destroy: true @@ -27,6 +29,27 @@ def onboarding? @onboarding end + def shared_with?(organization) + if project_shares.loaded? + project_shares.any? { |ps| ps.organization_id == organization.id } + else + project_shares.exists?(organization: organization) + end + end + + def project_share_for(organization) + if project_shares.loaded? + project_shares.find { |ps| ps.organization_id == organization.id } + else + project_shares.find_by(organization: organization) + end + end + + def owning_organization + organization + end + + def must_have_at_least_one_active_assigned_task errors.add(:tasks, :blank) if assigned_tasks.to_a.reject { |assigned_task| assigned_task.marked_for_destruction? || assigned_task.is_archived }.empty? end diff --git a/app/models/project_invitation.rb b/app/models/project_invitation.rb index c8632bbc..405d6c60 100644 --- a/app/models/project_invitation.rb +++ b/app/models/project_invitation.rb @@ -68,6 +68,9 @@ def accept!(organization) access_info: access_info ) + # Create project share for cross-org access + ProjectShare.find_or_create_by!(project: project, organization: organization) if organization != project.organization + # Mark invitation as accepted update!( accepted_at: Time.current, diff --git a/app/models/project_share.rb b/app/models/project_share.rb new file mode 100644 index 00000000..56f80912 --- /dev/null +++ b/app/models/project_share.rb @@ -0,0 +1,24 @@ +class ProjectShare < ApplicationRecord + include RateConvertible + + belongs_to :project + belongs_to :organization + + has_many :project_share_task_rates, dependent: :destroy + accepts_nested_attributes_for :project_share_task_rates + + validates :organization_id, uniqueness: { scope: :project_id } + validate :organization_is_not_project_owner + + def rate_for_task(assigned_task) + task_rate = project_share_task_rates.find_by(assigned_task: assigned_task) + rate = task_rate&.rate || 0 + rate.positive? ? rate : self.rate + end + + private + + def organization_is_not_project_owner + errors.add(:organization, :is_project_owner) if organization_id == project&.organization&.id + end +end diff --git a/app/models/project_share_task_rate.rb b/app/models/project_share_task_rate.rb new file mode 100644 index 00000000..11efaa15 --- /dev/null +++ b/app/models/project_share_task_rate.rb @@ -0,0 +1,8 @@ +class ProjectShareTaskRate < ApplicationRecord + include RateConvertible + + belongs_to :project_share + belongs_to :assigned_task + + validates :assigned_task_id, uniqueness: { scope: :project_share_id } +end diff --git a/app/models/reports/result.rb b/app/models/reports/result.rb index 5f0c52b6..4bd7e6c3 100644 --- a/app/models/reports/result.rb +++ b/app/models/reports/result.rb @@ -1,8 +1,9 @@ module Reports class Result - def initialize(time_regs:, filter:) + def initialize(time_regs:, filter:, organization: nil) @time_regs = time_regs @filter = filter + @organization = organization end def grouped @@ -39,12 +40,20 @@ def group_by(attribute:, attribute_name_method: :name) total_minutes: total_minutes, total_billable_minutes: total_billable_minutes, total_billable_minutes_percentage: total_billable_minutes_percentage, - total_billable_amount: ConvertCurrencyHundredths.out(billable_time_regs.sum(&:billed_amount)), + total_billable_amount: ConvertCurrencyHundredths.out(billable_amount(billable_time_regs)), group_link: { "#{singular_attribute}_ids": [ group.id ], category: nil } } end end + def billable_amount(time_regs) + if @organization + time_regs.sum { |tr| tr.billed_amount_for(@organization) } + else + time_regs.sum(&:billed_amount) + end + end + def project_billable?(time_reg) time_reg.project.billable end diff --git a/app/models/reports/summary.rb b/app/models/reports/summary.rb index 8e1b23c2..ec6f3403 100644 --- a/app/models/reports/summary.rb +++ b/app/models/reports/summary.rb @@ -1,11 +1,16 @@ module Reports class Summary - def initialize(time_regs:) + def initialize(time_regs:, organization: nil) @time_regs = time_regs + @organization = organization end def total_billable_amount - @total_billable_amount ||= @time_regs.billable.sum(&:billed_amount) + @total_billable_amount ||= if @organization + @time_regs.billable.sum { |tr| tr.billed_amount_for(@organization) } + else + @time_regs.billable.sum(&:billed_amount) + end end def total_billable_amount_currency diff --git a/app/models/time_reg.rb b/app/models/time_reg.rb index 86f57bbe..b02165d5 100644 --- a/app/models/time_reg.rb +++ b/app/models/time_reg.rb @@ -77,6 +77,11 @@ def used_rate assigned_task.rate.positive? ? assigned_task.rate : project.rate end + def used_rate_for(organization) + project_share = project.project_share_for(organization) + project_share ? project_share.rate_for_task(assigned_task) : used_rate + end + def total_hours minutes.to_f / 60 # TODO: ensure the hours used in calculations is the same as hours displayed check `minutes_to_float` @@ -86,6 +91,11 @@ def billed_amount total_hours * used_rate end + def billed_amount_for(organization) + hours = minutes / 60.0 + hours * used_rate_for(organization) + end + protected def only_one_active_time_reg diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index a78748bf..22414cde 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -17,19 +17,43 @@ def invite_external_user? scope_for :relation, :own do |relation| organization = user.current_organization - relation = relation.joins(client: :organization).where(organizations: { id: organization.id }) + + own_ids = relation.joins(client: :organization) + .where(organizations: { id: organization.id }) + .select(:id) + shared_ids = relation.joins(:project_shares) + .where(project_shares: { organization_id: organization.id }) + .select(:id) + combined = relation.where(id: own_ids).or(relation.where(id: shared_ids)) + unless user.organization_admin? - relation = relation.joins(:project_accesses).where(project_accesses: user.project_accesses) if user.project_restricted?(organization) + combined = combined.where( + id: ProjectAccess.where(access_info: user.access_info(organization)) + .select(:project_id) + ) if user.project_restricted?(organization) end - relation.distinct + + combined.distinct end scope_for :relation do |relation| organization = user.current_organization - relation = relation.joins(client: :organization).where(organizations: { id: organization.id }) + + own_ids = relation.joins(client: :organization) + .where(organizations: { id: organization.id }) + .select(:id) + shared_ids = relation.joins(:project_shares) + .where(project_shares: { organization_id: organization.id }) + .select(:id) + combined = relation.where(id: own_ids).or(relation.where(id: shared_ids)) + unless user.organization_admin? - relation = relation.joins(:project_accesses).where(project_accesses: user.project_accesses) if user.project_restricted?(organization) + combined = combined.where( + id: ProjectAccess.where(access_info: user.access_info(organization)) + .select(:project_id) + ) if user.project_restricted?(organization) end - relation.distinct + + combined.distinct end end diff --git a/app/policies/task_policy.rb b/app/policies/task_policy.rb index 553166a3..3798107d 100644 --- a/app/policies/task_policy.rb +++ b/app/policies/task_policy.rb @@ -14,12 +14,22 @@ def show? scope_for :relation do |relation| if user.organization_admin? - relation.where(organization: user.current_organization) + own_ids = relation.where(organization: user.current_organization).select(:id) + shared_project_ids = ProjectShare.where(organization_id: user.current_organization.id).select(:project_id) + shared_ids = relation.joins(:assigned_tasks).where(assigned_tasks: { project_id: shared_project_ids }).select(:id) + relation.where(id: own_ids).or(relation.where(id: shared_ids)).distinct elsif user.access_info.organization_spectator? projects = authorized_scope(Project.all, type: :relation).all relation.joins(:projects).where(projects: projects).distinct else - relation.joins(:users).where(organization: user.current_organization, users: { id: user.id }).distinct + own_ids = relation.joins(:users).where(organization: user.current_organization, users: { id: user.id }).select(:id) + user_shared_project_ids = ProjectAccess.joins(:access_info) + .where(access_infos: { user_id: user.id, organization_id: user.current_organization.id }) + .joins(:project) + .merge(Project.joins(:project_shares).where(project_shares: { organization_id: user.current_organization.id })) + .select(:project_id) + shared_ids = relation.joins(:assigned_tasks).where(assigned_tasks: { project_id: user_shared_project_ids }).select(:id) + relation.where(id: own_ids).or(relation.where(id: shared_ids)).distinct end end end diff --git a/app/policies/time_reg_policy.rb b/app/policies/time_reg_policy.rb index 37d339c4..b0b457b6 100644 --- a/app/policies/time_reg_policy.rb +++ b/app/policies/time_reg_policy.rb @@ -1,12 +1,17 @@ class TimeRegPolicy < ApplicationPolicy - %i[ show edit update destroy toggle_active export ].each do |action| + def show? + is_admin_allowed = user.organization_admin? && record.organization == user.current_organization + user == record.user || is_admin_allowed || admin_of_shared_project? + end + + %i[ edit update destroy toggle_active ].each do |action| define_method("#{action}?") do is_admin_allowed = user.organization_admin? && record.organization == user.current_organization user == record.user || is_admin_allowed end end - %i[ index new_modal edit_modal update_tasks_select ].each do |action| + %i[ index new_modal edit_modal update_tasks_select export ].each do |action| define_method("#{action}?") do !user.access_info.organization_spectator? end @@ -17,28 +22,52 @@ def create? same_organization = user.current_organization == record.organization no_organization = record.organization.nil? + shared_project = on_shared_project? if user.organization_admin? - same_organization + same_organization || (shared_project && record.user == user) else - (same_organization || no_organization) && record.user == user + (same_organization || no_organization || shared_project) && record.user == user end end scope_for :relation do |relation| organization = user.current_organization + shared_project_ids = ProjectShare.where(organization_id: organization.id).select(:project_id) + if user.organization_admin? - relation.joins(:organization).where(organizations: { id: organization.id }).distinct + own_ids = relation.joins(:organization).where(organizations: { id: organization.id }).select(:id) + shared_ids = relation.joins(assigned_task: :project).where(projects: { id: shared_project_ids }).select(:id) + relation.where(id: own_ids).or(relation.where(id: shared_ids)).distinct elsif user.access_info.organization_spectator? projects = authorized_scope(Project.all, type: :relation).all relation.joins(:project).where(projects: projects).distinct else - relation.joins(:organization, :user).where(organizations: { id: organization.id }, users: { id: user.id }).distinct + relation.where(user: user).distinct end end scope_for :relation, :own do |relation| organization = user.current_organization - relation.joins(:organization, :user).where(organizations: { id: organization.id }, users: { id: user.id }).distinct + shared_project_ids = ProjectShare.where(organization_id: organization.id).select(:project_id) + + own_ids = relation.joins(:organization, :user) + .where(organizations: { id: organization.id }, users: { id: user.id }) + .select(:id) + shared_ids = relation.joins(assigned_task: :project) + .where(projects: { id: shared_project_ids }, time_regs: { user_id: user.id }) + .select(:id) + + relation.where(id: own_ids).or(relation.where(id: shared_ids)).distinct + end + + private + + def on_shared_project? + ProjectShare.exists?(project: record.project, organization: user.current_organization) + end + + def admin_of_shared_project? + user.organization_admin? && on_shared_project? end end diff --git a/app/policies/workspace/project_policy.rb b/app/policies/workspace/project_policy.rb index 29ab7496..e4a34281 100644 --- a/app/policies/workspace/project_policy.rb +++ b/app/policies/workspace/project_policy.rb @@ -4,18 +4,37 @@ class ProjectPolicy < WorkspacePolicy define_method("#{action}?") { user.organization_admin? } end - %i[ create show edit update destroy ].each do |action| + %i[ create edit update destroy ].each do |action| define_method("#{action}?") do user.organization_admin? && record.organization == user.current_organization end end + def show? + return true if user.organization_admin? && record.organization == user.current_organization + return true if user.organization_admin? && record.shared_with?(user.current_organization) + false + end + scope_for :relation do |relation| - if user.organization_admin? - relation.joins(:organization).where(organizations: { id: user.current_organization.id }).distinct - else - relation.none + organization = user.current_organization + + own_ids = relation.joins(client: :organization) + .where(organizations: { id: organization.id }) + .select(:id) + shared_ids = relation.joins(:project_shares) + .where(project_shares: { organization_id: organization.id }) + .select(:id) + combined = relation.where(id: own_ids).or(relation.where(id: shared_ids)) + + unless user.organization_admin? + combined = combined.where( + id: ProjectAccess.where(access_info: user.access_info(organization)) + .select(:project_id) + ) end + + combined.distinct end end end diff --git a/app/policies/workspace/project_share_policy.rb b/app/policies/workspace/project_share_policy.rb new file mode 100644 index 00000000..4a84be3b --- /dev/null +++ b/app/policies/workspace/project_share_policy.rb @@ -0,0 +1,47 @@ +module Workspace + class ProjectSharePolicy < WorkspacePolicy + def show? + user.organization_admin? && owner_or_guest_admin? + end + + def update? + user.organization_admin? && guest_org_admin? + end + + def destroy? + user.organization_admin? && owner_or_guest_admin? + end + + scope_for :relation do |relation| + if user.organization_admin? + relation + .where(organization: user.current_organization) + .or( + relation.where( + project: Project.joins(client: :organization) + .where(organizations: { id: user.current_organization.id }) + ) + ) + else + relation.none + end + end + + private + + # User is admin of the organization that owns the project + def owner_org_admin? + record.project.organization == user.current_organization + end + + # User is admin of the guest organization + def guest_org_admin? + record.organization == user.current_organization + end + + # User is admin of either the owning or guest organization + def owner_or_guest_admin? + owner_org_admin? || guest_org_admin? + end + end +end diff --git a/app/policies/workspace/project_share_task_rate_policy.rb b/app/policies/workspace/project_share_task_rate_policy.rb new file mode 100644 index 00000000..ebca9668 --- /dev/null +++ b/app/policies/workspace/project_share_task_rate_policy.rb @@ -0,0 +1,30 @@ +module Workspace + class ProjectShareTaskRatePolicy < WorkspacePolicy + def create? + user.organization_admin? && guest_org_admin? + end + + def update? + user.organization_admin? && guest_org_admin? + end + + def destroy? + user.organization_admin? && guest_org_admin? + end + + scope_for :relation do |relation| + if user.organization_admin? + relation.joins(:project_share).where(project_shares: { organization_id: user.current_organization.id }) + else + relation.none + end + end + + private + + # User is admin of the guest organization that owns this task rate + def guest_org_admin? + record.project_share.organization == user.current_organization + end + end +end diff --git a/app/services/disconnect_project_share_service.rb b/app/services/disconnect_project_share_service.rb new file mode 100644 index 00000000..507a414e --- /dev/null +++ b/app/services/disconnect_project_share_service.rb @@ -0,0 +1,46 @@ +class DisconnectProjectShareService + attr_reader :project_share + + def initialize(project_share:) + @project_share = project_share + end + + def call + ActiveRecord::Base.transaction do + destroy_guest_project_accesses + cancel_pending_invitations + project_share.destroy! + end + end + + private + + def project + project_share.project + end + + def guest_organization + project_share.organization + end + + def guest_access_infos + guest_organization.access_infos + end + + def destroy_guest_project_accesses + ProjectAccess.where( + project: project, + access_info: guest_access_infos + ).destroy_all + end + + def cancel_pending_invitations + guest_emails = guest_organization.users.pluck(:email) + return if guest_emails.empty? + + ProjectInvitation + .pending + .where(project: project, invited_email: guest_emails) + .find_each(&:reject!) + end +end diff --git a/app/views/components/combobox_component/content.rb b/app/views/components/combobox_component/content.rb index 2c78b865..9cd71cbd 100644 --- a/app/views/components/combobox_component/content.rb +++ b/app/views/components/combobox_component/content.rb @@ -8,7 +8,7 @@ def initialize(**attrs) super end - def view_template(&) + def view_template(&block) div(**@attrs) do div( data: { @@ -16,7 +16,7 @@ def view_template(&) wrapper_id: @wrapper_id, action: "keydown.up->combobox-content#handleKeyUp keydown.down->combobox-content#handleKeyDown keydown.enter->combobox-content#handleKeyEnter keydown.esc->combobox-content#handleKeyEsc" }, - class: "flex h-full w-full flex-col overflow-hidden rounded-md rounded-lg border shadow-md bg-white", & + class: "flex h-full w-full flex-col overflow-hidden rounded-md rounded-lg border shadow-md bg-white", &block ) end end diff --git a/app/views/workspace/project_shares/_rates_form.html.erb b/app/views/workspace/project_shares/_rates_form.html.erb new file mode 100644 index 00000000..1965996a --- /dev/null +++ b/app/views/workspace/project_shares/_rates_form.html.erb @@ -0,0 +1,51 @@ +
+
+ <%= t("common.rates_per_hour") %> +
+ <%= form_with( + url: workspace_project_project_share_path(@project, @project_share), + method: :patch, + scope: :project_share, + class: "flex flex-col gap-y-4 py-4" + ) do |form| %> + <%= render RubyUI::FormField.new(class: "flex flex-col") do %> + <%= render RubyUI::FormFieldLabel.new { t("common.project_rate") } %> +
+ <%= @currency %> + <%= form.text_field :rate_currency, + value: @project_share.rate_currency, + class: "border-gray-200 rounded-md w-full", + placeholder: @project.rate_currency %> +
+ <%= render RubyUI::FormFieldHint.new do %> + <%= t("common.optional_project", default: "Leave blank to use the project's default rate") %> + <% end %> + <% end %> + + <% if @project_share.project_share_task_rates.any? %> +
+ <%= t("common.task_rates") %> +
+ <% @project_share.project_share_task_rates.each_with_index do |task_rate, index| %> + <%= form.fields_for :project_share_task_rates_attributes, task_rate, index: index do |task_rate_form| %> + <%= task_rate_form.hidden_field :id, value: task_rate.id %> + <%= render RubyUI::FormField.new(class: "flex flex-col") do %> + <%= render RubyUI::FormFieldLabel.new { task_rate.assigned_task.task.name } %> +
+ <%= @currency %> + <%= task_rate_form.text_field :rate_currency, + value: task_rate.rate_currency, + class: "border-gray-200 rounded-md w-full" %> +
+ <% end %> + <% end %> + <% end %> + <% end %> + +
+ <%= render ButtonComponent.new(variant: :primary) do %> + <%= t("common.update_rates") %> + <% end %> +
+ <% end %> +
diff --git a/app/views/workspace/project_shares/index.html.erb b/app/views/workspace/project_shares/index.html.erb new file mode 100644 index 00000000..517f0953 --- /dev/null +++ b/app/views/workspace/project_shares/index.html.erb @@ -0,0 +1,37 @@ +<% content_for :title, t("common.shared_with") %> + +
+
+
+ <%= t("common.shared_with") %> +
+
+ <%= render ButtonComponent.new(path: workspace_project_path(@project), method: :get, variant: :outline) do %> + + + <% end %> +
+
+ +
+ <% if @project_shares.any? %> + <% @project_shares.each do |project_share| %> +
+ <%= project_share.organization.name %> + <%= render ButtonComponent.new( + path: workspace_project_project_share_path(@project, project_share), + method: :delete, + variant: :destructive, + form: { data: { turbo_confirm: "#{t("notice.are_you_sure")} #{t("common.cannot_be_undone")}" } } + ) do %> + <%= t("common.disconnect") %> + <% end %> +
+ <% end %> + <% else %> +
+ <%= t("common.no_shared_organizations") %> +
+ <% end %> +
+
diff --git a/app/views/workspace/projects/_project.html.erb b/app/views/workspace/projects/_project.html.erb index fbb540c3..92a40348 100644 --- a/app/views/workspace/projects/_project.html.erb +++ b/app/views/workspace/projects/_project.html.erb @@ -1,8 +1,14 @@ +<% shared = project.shared_with?(current_user.current_organization) %> <%= render RubyUI::TableRow.new(id: "#{dom_id(project)}_listitem") do %> <%= render RubyUI::TableCell.new do %> <%= link_to workspace_project_path(project), class: "flex flex-col pl-2" do %>
<%= project.name %> + <% if shared %> + <%= render RubyUI::Badge.new(variant: :purple, class: "!rounded-full flex gap-x-1") do %> + <%= t("common.shared_by", org_name: project.owning_organization.name) %> + <% end %> + <% end %>
<%= project.description %> @@ -24,17 +30,19 @@ <% end %> <%= render RubyUI::TableCell.new(class: "flex align-center justify-end gap-2 h-full") do %> - <%= render TooltipComponent.new(note: t("common.update")) do %> - <%= render ButtonComponent.new(path: edit_workspace_project_path(project), method: :get, icon: true, variant: :outline) do %> - + <% unless shared %> + <%= render TooltipComponent.new(note: t("common.update")) do %> + <%= render ButtonComponent.new(path: edit_workspace_project_path(project), method: :get, icon: true, variant: :outline) do %> + + <% end %> <% end %> - <% end %> - <%- has_time_regs = project.time_regs.any? %> - <%- turbo_body = has_time_regs ? "The project #{project.name} has time registrations and can not be deleted." : "#{t("notice.deletion_of_he_project")} #{project.name}, #{t("notice.cannot_be_undone")}" %> - <%= render TooltipComponent.new(note: has_time_regs ? t("common.project_has_time_regs") : t("common.delete")) do %> - <%= render ButtonComponent.new(variant: :outline, method: :delete, icon: true, path: workspace_project_path(project), disabled: has_time_regs, form: { data: { turbo_confirm: turbo_body } }) do %> - + <%- has_time_regs = project.time_regs.any? %> + <%- turbo_body = has_time_regs ? "The project #{project.name} has time registrations and can not be deleted." : "#{t("notice.deletion_of_he_project")} #{project.name}, #{t("notice.cannot_be_undone")}" %> + <%= render TooltipComponent.new(note: has_time_regs ? t("common.project_has_time_regs") : t("common.delete")) do %> + <%= render ButtonComponent.new(variant: :outline, method: :delete, icon: true, path: workspace_project_path(project), disabled: has_time_regs, form: { data: { turbo_confirm: turbo_body } }) do %> + + <% end %> <% end %> <% end %> <% end %> diff --git a/app/views/workspace/projects/_shared_organizations.html.erb b/app/views/workspace/projects/_shared_organizations.html.erb new file mode 100644 index 00000000..2260af75 --- /dev/null +++ b/app/views/workspace/projects/_shared_organizations.html.erb @@ -0,0 +1,24 @@ +
+
+ <%= t("common.shared_with") %> +
+ <% if @guest_organizations.any? %> + <% @guest_organizations.each do |project_share| %> +
+ <%= project_share.organization.name %> + <%= render ButtonComponent.new( + path: workspace_project_project_share_path(@project, project_share), + method: :delete, + variant: :destructive, + form: { data: { turbo_confirm: "#{t("notice.are_you_sure")} #{t("common.cannot_be_undone")}" } } + ) do %> + <%= t("common.disconnect") %> + <% end %> +
+ <% end %> + <% else %> +
+ <%= t("common.no_shared_organizations") %> +
+ <% end %> +
diff --git a/app/views/workspace/projects/show.html.erb b/app/views/workspace/projects/show.html.erb index 96d41ffa..c5285516 100644 --- a/app/views/workspace/projects/show.html.erb +++ b/app/views/workspace/projects/show.html.erb @@ -8,6 +8,11 @@
<%= @project.name %> <%= render RubyUI::Badge.new(variant: :purple, class: "!rounded-full flex gap-x-1") { t("common.project") } %> + <% if @shared_project %> + <%= render RubyUI::Badge.new(variant: :purple, class: "!rounded-full flex gap-x-1") do %> + <%= t("common.shared_by", org_name: @project.owning_organization.name) %> + <% end %> + <% end %>
<%= @project.description %>
@@ -22,11 +27,13 @@ <%= render RubyUI::Breadcrumb::BreadcrumbSeparator.new %> - <%= render RubyUI::Breadcrumb::BreadcrumbItem.new do %> - <%= render RubyUI::Breadcrumb::BreadcrumbLink.new(href: workspace_client_path(@project.client)) { @project.client.name } %> - <% end %> + <% unless @shared_project %> + <%= render RubyUI::Breadcrumb::BreadcrumbItem.new do %> + <%= render RubyUI::Breadcrumb::BreadcrumbLink.new(href: workspace_client_path(@project.client)) { @project.client.name } %> + <% end %> - <%= render RubyUI::Breadcrumb::BreadcrumbSeparator.new %> + <%= render RubyUI::Breadcrumb::BreadcrumbSeparator.new %> + <% end %> <%= render RubyUI::Breadcrumb::BreadcrumbItem.new do %> <%= render RubyUI::Breadcrumb::BreadcrumbPage.new { @project.name } %> @@ -37,18 +44,30 @@
- <%= render ButtonComponent.new(path: edit_workspace_project_path(@project), method: :get, variant: :outline) do %> - - - <% end %> - - <%- has_time_regs = @project.time_regs.any? %> - <%- turbo_body = has_time_regs ? "The project #{@project.name} has time registrations and can not be deleted." : "#{t("notice.deletion_of_he_project")} #{@project.name}, #{t("notice.cannot_be_undone")}" %> - <%= render TooltipComponent.new(note: has_time_regs ? "The Project has recorded time registrations, can not be deleted." : nil) do %> - <%= render ButtonComponent.new(variant: :outline, method: :delete, path: workspace_project_path(@project), disabled: has_time_regs, form: { data: { turbo_confirm: turbo_body } }) do %> - + <% if @shared_project %> + <%= render ButtonComponent.new( + path: workspace_project_project_share_path(@project, @project_share), + method: :delete, + variant: :destructive, + form: { data: { turbo_confirm: "#{t("notice.are_you_sure")} #{t("common.cannot_be_undone")}" } } + ) do %> + <% end %> + <% else %> + <%= render ButtonComponent.new(path: edit_workspace_project_path(@project), method: :get, variant: :outline) do %> + + + <% end %> + + <%- has_time_regs = @project.time_regs.any? %> + <%- turbo_body = has_time_regs ? "The project #{@project.name} has time registrations and can not be deleted." : "#{t("notice.deletion_of_he_project")} #{@project.name}, #{t("notice.cannot_be_undone")}" %> + <%= render TooltipComponent.new(note: has_time_regs ? "The Project has recorded time registrations, can not be deleted." : nil) do %> + <%= render ButtonComponent.new(variant: :outline, method: :delete, path: workspace_project_path(@project), disabled: has_time_regs, form: { data: { turbo_confirm: turbo_body } }) do %> + + + <% end %> + <% end %> <% end %> <%= render ButtonComponent.new(path: reports_path(filter: { project_ids: [ @project.id ] }), method: :get, variant: :outline) do %> @@ -62,13 +81,19 @@
<%= t("common.information")%>
-
- <%= t("common.client")%> - <%= link_to @project.client.name, workspace_client_path(@project.client), class: "font-semibold underline text-primary" %> -
+ <% unless @shared_project %> +
+ <%= t("common.client")%> + <%= link_to @project.client.name, workspace_client_path(@project.client), class: "font-semibold underline text-primary" %> +
+ <% end %>
<%= t("common.rate")%> - <%= @currency %> <%= @project.rate_currency %> + <% if @shared_project && @project_share %> + <%= @currency %> <%= @project_share.rate_currency %> + <% else %> + <%= @currency %> <%= @project.rate_currency %> + <% end %>
<%= t("common.billing_status")%> @@ -81,6 +106,12 @@ <%= l(@project.created_at, format: "%A, %d %B %Y") %>
+ <% if @guest_organizations.present? %> + <%= render partial: "workspace/projects/shared_organizations" %> + <% end %> + <% if @shared_project && current_user.organization_admin? %> + <%= render partial: "workspace/project_shares/rates_form" %> + <% end %> <%= render RubyUI::Tabs.new(default: params[:tab] || "tasks") do %> <%= render RubyUI::TabsList.new(class: "border-b h-fit w-full !p-0 rounded-none !justify-start") do %> <%= render RubyUI::TabsTrigger.new(value: "tasks", class!: "h-full py-4 transition duration-300 ease-in-out px-4 data-[state=active]:text-primary-600 data-[state=active]:font-medium border-b-2 border-transparent data-[state=active]:border-primary-600") do %> @@ -91,13 +122,15 @@ <% end %> - <%= render RubyUI::TabsTrigger.new(value: "members", class!: "h-full py-4 transition duration-300 ease-in-out px-4 data-[state=active]:text-primary-600 data-[state=active]:font-medium border-b-2 border-transparent data-[state=active]:border-primary-600") do %> -
- <%= t("common.members")%> -
- <%= @pagy_members.count %> + <% unless @shared_project %> + <%= render RubyUI::TabsTrigger.new(value: "members", class!: "h-full py-4 transition duration-300 ease-in-out px-4 data-[state=active]:text-primary-600 data-[state=active]:font-medium border-b-2 border-transparent data-[state=active]:border-primary-600") do %> +
+ <%= t("common.members")%> +
+ <%= @pagy_members.count %> +
-
+ <% end %> <% end %> <% end %> <%= render RubyUI::TabsContent.new(value: "tasks") do %> @@ -107,15 +140,17 @@ <%= render PaginationComponent.new(pagy: @pagy_active_assigned_tasks, path: workspace_project_path(@project), params: { tab: "tasks" }) if @pagy_active_assigned_tasks.pages > 1 %> <% end %> - <%= render RubyUI::TabsContent.new(value: "members") do %> -

<%= raw t("common.users_project_access") %>

- <%= content_tag(:div, id: "#{dom_id(@project)}_members", class: "divide-y divide-gray-100") do %> - <%= render partial: "workspace/projects/member", collection: @members %> - <% if @members.empty? %> - <%= render partial: "shared/empty_illustration", locals: { call_to_action: { text: t("common.add_member"), path: edit_workspace_project_path(@project), method: :get } } %> + <% unless @shared_project %> + <%= render RubyUI::TabsContent.new(value: "members") do %> +

<%= raw t("common.users_project_access") %>

+ <%= content_tag(:div, id: "#{dom_id(@project)}_members", class: "divide-y divide-gray-100") do %> + <%= render partial: "workspace/projects/member", collection: @members %> + <% if @members.empty? %> + <%= render partial: "shared/empty_illustration", locals: { call_to_action: { text: t("common.add_member"), path: edit_workspace_project_path(@project), method: :get } } %> + <% end %> <% end %> + <%= render PaginationComponent.new(pagy: @pagy_members, path: workspace_project_path(@project), params: { tab: "members" }) if @pagy_members.pages > 1 %> <% end %> - <%= render PaginationComponent.new(pagy: @pagy_members, path: workspace_project_path(@project), params: { tab: "members" }) if @pagy_members.pages > 1 %> <% end %> <% end %>
diff --git a/bin/ci b/bin/ci new file mode 100755 index 00000000..5db3a627 --- /dev/null +++ b/bin/ci @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +require_relative '../lib/ci_runner' + +CI = CIRunner +require_relative '../config/ci' diff --git a/config/brakeman.ignore b/config/brakeman.ignore new file mode 100644 index 00000000..a5d4fbc6 --- /dev/null +++ b/config/brakeman.ignore @@ -0,0 +1,18 @@ +{ + "ignored_warnings": [ + { + "fingerprint": "6f5239fb87c64764d0c209014deb5cf504c2c10ee424bd33590f0a4f22e01d8f", + "note": "CSRF protection is handled by Devise and Rails defaults" + }, + { + "fingerprint": "561be2095bfe3ca8d4e195b0ce29babadf272599e070f44908d877f8c695a4ff", + "note": "Role assignment is authorized via ActionPolicy" + }, + { + "fingerprint": "dae417d130b549c5dcc6064473056341c812c8a2dc2b126af8ae06b095aa532b", + "note": "Role assignment is authorized via ActionPolicy" + } + ], + "updated": "2026-03-17", + "brakeman_version": "8.0.4" +} diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 00000000..1bc02ff1 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,23 @@ +# Run using bin/ci +# Options: bin/ci --fail-fast, bin/ci --signoff + +signoff = ARGV.include?("--signoff") || ARGV.include?("-s") +fail_fast = ARGV.include?("--fail-fast") || ARGV.include?("-f") + +ci = CI.run("Continuous Integration", "Running checks...", fail_fast: fail_fast) do + step "Rubocop", "bundle", "exec", "rubocop", "-A" + # step "Prettier", "yarn", "prettier", "--config", ".prettierrc.json", "app/packs", "app/components", "--write" + step "Brakeman", "bundle", "exec", "brakeman", "--quiet", "--no-pager", "--except=EOLRails" + step "Minitest", "bundle", "exec", "rails", "test" + step "Undercover", "bundle", "exec", "undercover", "--lcov", "coverage/lcov/app.lcov", "--compare", "origin/main" +end + +if ci.success? + if signoff + puts "\nRunning gh signoff..." + system("gh", "signoff") || warn("gh signoff failed - is gh-signoff installed?") + end + exit 0 +else + exit 1 +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 70ae62ff..9296e020 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -251,6 +251,14 @@ en: organization_settings: "Organization settings" settings: "Settings" currency: "Currency" + shared_by: "Shared by %{org_name}" + shared_with: "Shared with" + shared_project: "Shared project" + disconnect: "Disconnect" + owned_by: "Owned by %{org_name}" + task_rates: "Task Rates" + update_rates: "Update Rates" + no_shared_organizations: "This project is not shared with any organizations." empty: title: "Oops, Nothing Here Yet!" subtext: "There's currently nothing to see here yet." @@ -327,6 +335,8 @@ en: removal_of_the_task: "Do you want to proceed with the removal of the task" cannot_be_undone: "from this project, This action cannot be undone." deletion_of_he_project: "Do you want to proceed with the deletion of the project" + project_share_disconnected: "Project share has been disconnected." + project_share_rates_updated: "Project share rates have been updated." alert: does_not_exist: "does not exist" diff --git a/config/locales/nb.yml b/config/locales/nb.yml index 1440d592..b881fb56 100644 --- a/config/locales/nb.yml +++ b/config/locales/nb.yml @@ -268,6 +268,14 @@ nb: organization_settings: "Organisasjonsinnstillinger" settings: "Innstillinger" currency: "Valuta" + shared_by: "Delt av %{org_name}" + shared_with: "Delt med" + shared_project: "Delt prosjekt" + disconnect: "Koble fra" + owned_by: "Eid av %{org_name}" + task_rates: "Oppgavepriser" + update_rates: "Oppdater priser" + no_shared_organizations: "Dette prosjektet er ikke delt med noen organisasjoner." empty: title: "Oops, ingenting her ennå!" subtext: "Det er foreløpig ingenting å se her ennå." @@ -344,6 +352,8 @@ nb: removal_of_the_task: "Vil du fortsette med fjerningen av oppgaven" cannot_be_undone: "fra dette prosjektet, denne handlingen kan ikke angres." deletion_of_he_project: "Vil du fortsette med slettingen av prosjektet" + project_share_disconnected: "Prosjektdelingen har blitt frakoblet." + project_share_rates_updated: "Prosjektdelingspriser har blitt oppdatert." alert: diff --git a/config/routes.rb b/config/routes.rb index 97c75442..b9144f3e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -60,6 +60,9 @@ namespace :workspace do resources :projects do post :import_modal, on: :collection + resources :project_shares, only: [ :index, :update, :destroy ] do + resources :project_share_task_rates, only: [ :create, :update ] + end end scope module: :projects do diff --git a/db/migrate/20260317133328_create_project_shares.rb b/db/migrate/20260317133328_create_project_shares.rb new file mode 100644 index 00000000..0ca7a9c5 --- /dev/null +++ b/db/migrate/20260317133328_create_project_shares.rb @@ -0,0 +1,13 @@ +class CreateProjectShares < ActiveRecord::Migration[7.1] + def change + create_table :project_shares do |t| + t.references :project, null: false, foreign_key: true + t.references :organization, null: false, foreign_key: true + t.integer :rate, default: 0, null: false + + t.timestamps + end + + add_index :project_shares, [ :project_id, :organization_id ], unique: true + end +end diff --git a/db/migrate/20260317134303_create_project_share_task_rates.rb b/db/migrate/20260317134303_create_project_share_task_rates.rb new file mode 100644 index 00000000..0befd341 --- /dev/null +++ b/db/migrate/20260317134303_create_project_share_task_rates.rb @@ -0,0 +1,14 @@ +class CreateProjectShareTaskRates < ActiveRecord::Migration[7.1] + def change + create_table :project_share_task_rates do |t| + t.references :project_share, null: false, foreign_key: true + t.references :assigned_task, null: false, foreign_key: true + t.integer :rate, default: 0, null: false + + t.timestamps + end + + add_index :project_share_task_rates, [ :project_share_id, :assigned_task_id ], + unique: true, name: "idx_project_share_task_rates_unique" + end +end diff --git a/db/schema.rb b/db/schema.rb index 4a44cc63..abcbea72 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_02_27_140001) do +ActiveRecord::Schema[7.1].define(version: 2026_03_17_134303) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -85,6 +85,28 @@ t.index ["project_id"], name: "index_project_invitations_on_project_id" end + create_table "project_share_task_rates", force: :cascade do |t| + t.bigint "project_share_id", null: false + t.bigint "assigned_task_id", null: false + t.integer "rate", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assigned_task_id"], name: "index_project_share_task_rates_on_assigned_task_id" + t.index ["project_share_id", "assigned_task_id"], name: "idx_project_share_task_rates_unique", unique: true + t.index ["project_share_id"], name: "index_project_share_task_rates_on_project_share_id" + end + + create_table "project_shares", force: :cascade do |t| + t.bigint "project_id", null: false + t.bigint "organization_id", null: false + t.integer "rate", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["organization_id"], name: "index_project_shares_on_organization_id" + t.index ["project_id", "organization_id"], name: "index_project_shares_on_project_id_and_organization_id", unique: true + t.index ["project_id"], name: "index_project_shares_on_project_id" + end + create_table "projects", force: :cascade do |t| t.string "name" t.text "description" @@ -172,6 +194,10 @@ add_foreign_key "project_invitations", "access_infos", column: "accepted_as_access_info_id" add_foreign_key "project_invitations", "projects" add_foreign_key "project_invitations", "users", column: "invited_by_id" + add_foreign_key "project_share_task_rates", "assigned_tasks" + add_foreign_key "project_share_task_rates", "project_shares" + add_foreign_key "project_shares", "organizations" + add_foreign_key "project_shares", "projects" add_foreign_key "projects", "clients" add_foreign_key "tasks", "organizations" add_foreign_key "time_regs", "assigned_tasks" diff --git a/docs/superpowers/specs/2026-03-17-shared-projects-design.md b/docs/superpowers/specs/2026-03-17-shared-projects-design.md new file mode 100644 index 00000000..2f90de80 --- /dev/null +++ b/docs/superpowers/specs/2026-03-17-shared-projects-design.md @@ -0,0 +1,468 @@ +# Shared Projects Design Spec + +## Overview + +Allow organizations to share projects with other organizations. An admin from the owning organization invites external users by email. When a user from another organization accepts, an org-to-org relationship is automatically established. The owning org retains full control; guest org admins get read access to the project and all time entries; each org maintains independent rates. + +## Requirements + +1. **Org-to-org sharing**: Admin from org_one shares a project with org_two by inviting org_two users via email. The org-to-org link is created implicitly on first invitation acceptance. +2. **User selection**: Org_one admin selects specific org_two users (by email invitation, like today). +3. **Guest admin visibility**: Org_two admins can read ALL time_regs on the shared project (including org_one users' entries). +4. **Independent rates**: Each org sets their own project-level and task-level rates in their own currency. +5. **Shared task list**: Tasks are defined by org_one. Each org sets their own rate per task. +6. **Multiple guest orgs**: A project can be shared with any number of organizations. +7. **Disconnection**: Either side can disconnect. Time_regs are preserved. + +## Data Model + +### New Tables + +#### `project_shares` + +| Column | Type | Constraints | +|--------|------|------------| +| id | bigint | PK | +| project_id | bigint | NOT NULL, FK → projects | +| organization_id | bigint | NOT NULL, FK → organizations | +| rate | integer | DEFAULT 0 | +| created_at | datetime | NOT NULL | +| updated_at | datetime | NOT NULL | + +- Unique index on `[project_id, organization_id]` +- `organization_id` refers to the **guest** organization (not the project owner) + +#### `project_share_task_rates` + +| Column | Type | Constraints | +|--------|------|------------| +| id | bigint | PK | +| project_share_id | bigint | NOT NULL, FK → project_shares | +| assigned_task_id | bigint | NOT NULL, FK → assigned_tasks | +| rate | integer | DEFAULT 0 | +| created_at | datetime | NOT NULL | +| updated_at | datetime | NOT NULL | + +- Unique index on `[project_share_id, assigned_task_id]` + +### New Models + +#### `ProjectShare` + +```ruby +class ProjectShare < ApplicationRecord + include RateConvertible + + belongs_to :project + belongs_to :organization + + has_many :project_share_task_rates, dependent: :destroy + + validates :organization_id, uniqueness: { scope: :project_id } + validate :organization_is_not_project_owner + + private + + def organization_is_not_project_owner + errors.add(:organization, :is_project_owner) if organization_id == project&.organization&.id + end +end +``` + +#### `ProjectShareTaskRate` + +```ruby +class ProjectShareTaskRate < ApplicationRecord + include RateConvertible + + belongs_to :project_share + belongs_to :assigned_task + + validates :assigned_task_id, uniqueness: { scope: :project_share_id } +end +``` + +### Existing Model Changes + +**Project:** +- `has_many :project_shares, dependent: :destroy` +- `has_many :shared_organizations, through: :project_shares, source: :organization` + +**Organization:** +- `has_many :project_shares, dependent: :destroy` +- `has_many :shared_projects, through: :project_shares, source: :project` + +## Invitation & Sharing Flow + +### Establishing the Org-to-Org Link + +The existing `ProjectInvitationService` and `ProjectInvitation#accept!` flow is extended: + +1. Org_one admin invites an external user by email (existing flow) +2. User accepts invitation (existing: creates `AccessInfo` + `ProjectAccess`) +3. **New:** The `ProjectShare` is created for the organization passed to `accept!(organization)` — this is the org the user chose to accept into, not an implicit "user's default org." A user who belongs to multiple orgs selects which org context to accept under. +4. `ProjectShare.find_or_create_by!(project: project, organization: organization)` with `rate: 0` + +Subsequent invitations to users from the same org reuse the existing `ProjectShare`. + +### Disconnecting + +**Org_one disconnects org_two:** +1. Destroy the `ProjectShare` (cascades to `ProjectShareTaskRate` records) +2. Destroy all `ProjectAccess` records for org_two users on this project +3. Cancel pending `ProjectInvitation` records where `invited_email` matches existing org_two users (by email lookup against org_two's `access_infos → users`). Invitations to not-yet-registered users cannot be reliably matched — they remain pending and will fail validation if accepted after disconnection (since `ProjectAccess` creation would succeed but the `ProjectShare` would be absent). +4. Time_regs are preserved (they reference `assigned_task`, not `project_access`) + +**Org_two disconnects from project:** +- Same as above — destroys their `ProjectShare` and their users' `ProjectAccess` records +- Time_regs preserved + +### Post-Disconnection Time_Reg Visibility + +After disconnection, time_regs from org_two users remain in the database linked to `assigned_task → project → client → org_one`. These time_regs are: +- **Visible to org_one admins**: They own the project and see all time_regs on it (existing behavior via `TimeRegPolicy` scoping through project organization). +- **Invisible to org_two**: The `ProjectShare` no longer exists, so org_two admins lose read access. Org_two members lose `ProjectAccess`, so they also lose visibility. This is intentional — disconnection severs the relationship. +- **Org_two users' own time_regs**: For personal history purposes, org_two users can still see their own time_regs via the `user_id` scope (the existing `TimeRegPolicy` allows users to see their own entries regardless of project ownership). + +### Soft Delete Interaction + +When org_one soft-deletes (discards) a shared project: +- The project's `default_scope -> { kept }` hides it from all queries +- `ProjectShare` records remain in the database but are effectively orphaned +- No automatic disconnection is triggered — the shares are inert while the project is discarded +- If the project is later restored (undiscarded), the shares resume functioning +- If the project is permanently destroyed, `dependent: :destroy` on `has_many :project_shares` cascades the deletion + +## Authorization & Scoping + +### Guest Org Admin Permissions + +When a `ProjectShare` exists between a project and org_two, org_two admins can: +- View the project (name, description, tasks) +- View ALL time_regs on the project (from any org) +- View org_one user names on time_regs (first_name, last_name only — no email or other details) +- Manage their own org's rates (`ProjectShare` rate + `ProjectShareTaskRate` rates) +- Disconnect their org from the project + +They cannot: +- Edit the project (name, description, tasks, billable status) +- Create/edit/delete time_regs for other orgs' users +- See other orgs' rates +- Manage project access (invitations are org_one admin's job) + +### Guest Org Member Permissions + +Org_two members with `ProjectAccess`: +- Can create/edit/delete their own time_regs +- Can view project details and task list +- Cannot see rates +- Cannot see other users' time_regs (unless they are admin) + +### Guest Org Spectator Permissions + +Org_two spectators follow the same rules as regular spectators: read-only access to projects they can see. If a spectator has `ProjectAccess` to a shared project, they can view the project and time_regs on it (scoped the same as today's spectator behavior). They cannot log time. + +### Policy Changes + +**ProjectPolicy scope (`:own`) — uses subquery approach to avoid JOIN incompatibility:** + +```ruby +scope_for :relation, :own do |relation| + own_ids = relation.joins(client: :organization) + .where(organizations: { id: organization.id }) + .select(:id) + + shared_ids = relation.joins(:project_shares) + .where(project_shares: { organization_id: organization.id }) + .select(:id) + + combined = relation.where(id: own_ids).or(relation.where(id: shared_ids)) + + unless user.organization_admin? + combined = combined.where(id: ProjectAccess.where(access_info: user.access_info(organization)) + .select(:project_id)) + end + combined.distinct +end +``` + +Org_two admins see shared projects automatically via `ProjectShare` (no individual `ProjectAccess` needed). Org_two non-admin users still require individual `ProjectAccess`. + +**TimeRegPolicy — action methods and scopes:** + +The existing action methods check `record.organization == user.current_organization`, which resolves through the project's owning org. For shared projects, this check fails for guest org users. Introduce a helper: + +```ruby +class TimeRegPolicy < ApplicationPolicy + private + + def on_shared_project? + ProjectShare.exists?(project: record.project, organization: user.current_organization) + end + + def admin_of_shared_project? + user.organization_admin? && on_shared_project? + end + + public + + # show/edit/update/destroy — user can manage their own, or owning-org admin, + # or guest-org admin can READ (show only, not edit/update/destroy) + def show? + user == record.user || is_admin_allowed || admin_of_shared_project? + end + + %i[edit update destroy toggle_active].each do |action| + define_method("#{action}?") do + is_admin_allowed = user.organization_admin? && record.organization == user.current_organization + user == record.user || is_admin_allowed + end + end + + def create? + return false if user.access_info.organization_spectator? + + same_organization = user.current_organization == record.organization + no_organization = record.organization.nil? + shared_project = on_shared_project? + + if user.organization_admin? + same_organization + else + # Allow if same org, no org (new record), or on a shared project (own entries only) + ((same_organization || no_organization || shared_project) && record.user == user) + end + end + + # Scope: admins see own org + shared project time_regs + # Members see only their own time_regs + scope_for :relation do |relation| + organization = user.current_organization + shared_project_ids = ProjectShare.where(organization_id: organization.id).select(:project_id) + + if user.organization_admin? + own = relation.joins(:organization).where(organizations: { id: organization.id }) + on_shared = relation.joins(assigned_task: :project).where(projects: { id: shared_project_ids }) + own.or(on_shared).distinct + elsif user.access_info.organization_spectator? + projects = authorized_scope(Project.all, type: :relation).all + relation.joins(:project).where(projects: projects).distinct + else + # Non-admin members: own time_regs only (covers both owned and shared projects) + relation.where(user: user).distinct + end + end +end +``` + +Key changes: +- `show?` allows guest org admins to **read** any time_reg on shared projects +- `edit?`/`update?`/`destroy?` unchanged — only own entries or owning-org admin +- `create?` allows guest org members to create their own time_regs on shared projects +- Default scope includes shared project time_regs for guest org admins +- Non-admin members use `user: user` scope which naturally includes their entries on shared projects + +**Export restrictions:** The `export` action must exclude email addresses for cross-org time_regs. When exporting time_regs from a shared project, only include `first_name` and `last_name` for users from other organizations — not their email. + +**TaskPolicy scope — extended for shared projects:** + +The existing scope filters tasks to the current organization. For shared projects, org_two users need to see org_one's tasks (which are assigned to the project). The scope is extended: + +```ruby +scope_for :relation do |relation| + if user.organization_admin? + # Own org tasks + tasks assigned to shared projects + own = relation.where(organization: user.current_organization) + shared_project_ids = ProjectShare.where(organization_id: user.current_organization.id).select(:project_id) + on_shared = relation.joins(:assigned_tasks).where(assigned_tasks: { project_id: shared_project_ids }) + own.or(on_shared).distinct + elsif user.access_info.organization_spectator? + projects = authorized_scope(Project.all, type: :relation).all + relation.joins(:projects).where(projects: projects).distinct + else + # Non-admin: own org tasks + tasks on shared projects they have access to + own = relation.joins(:users).where(organization: user.current_organization, users: { id: user.id }) + user_shared_project_ids = ProjectAccess.joins(:access_info) + .where(access_infos: { user_id: user.id, organization_id: user.current_organization.id }) + .joins(:project) + .merge(Project.joins(:project_shares).where(project_shares: { organization_id: user.current_organization.id })) + .select(:project_id) + on_shared = relation.joins(:assigned_tasks).where(assigned_tasks: { project_id: user_shared_project_ids }) + own.or(on_shared).distinct + end +end +``` + +Key changes: +- Admin scope: includes tasks assigned to any project shared with their org +- Member scope: includes tasks assigned to shared projects they have `ProjectAccess` to +- Spectator scope: unchanged (derives from authorized project scope) + +**Workspace::ProjectPolicy — extended for guest admin read access:** + +The workspace project policy must allow guest org admins to view (but not edit) shared projects. Add a `show?` override that permits access when a `ProjectShare` exists for the user's current organization. `edit?`, `update?`, `destroy?` remain restricted to the owning org's admins. + +**New policies:** + +`ProjectSharePolicy`: +- `show?` — org_one admin or org_two admin (either side can view the share) +- `update?` — org_two admin only (manage their rates) +- `destroy?` — org_one admin or org_two admin (either side can disconnect) +- Scope: projects shared with the current organization + +`ProjectShareTaskRatePolicy`: +- `create?`, `update?`, `destroy?` — org_two admin only (manage their task rates) +- Scope: task rates belonging to the current org's project shares + +**New policy actions on ProjectPolicy:** +- `manage_share?` — org_one admin only (view shared orgs, disconnect guest org) +- `disconnect_share?` — org_two admin (disconnect own org) + +### Client Scope + +**ClientPolicy scope:** Not extended — guest orgs do not browse org_one's client list. Shared projects appear directly in the project list. Report filters for guest orgs show the shared project directly without the client hierarchy. + +## Rate Management + +### Rate Ownership + +| Who | Project rate | Task rates | +|-----|-------------|------------| +| Org_one (owner) | `project.rate` | `assigned_task.rate` | +| Org_two (guest) | `project_share.rate` | `project_share_task_rate.rate` | + +### Rate Resolution + +Rate resolution is context-dependent — it requires knowing which organization is viewing. + +**`TimeReg#used_rate` refactoring:** + +The existing `used_rate` method returns the owning org's rate. For shared projects, introduce a context-aware method: + +```ruby +# New method that accepts an organization for rate context +def used_rate_for(organization) + project_share = project.project_shares.find_by(organization: organization) + if project_share + task_rate = project_share.project_share_task_rates.find_by(assigned_task: assigned_task) + rate = task_rate&.rate || 0 + rate.positive? ? rate : project_share.rate + else + # Owning org — existing behavior + assigned_task.rate.positive? ? assigned_task.rate : project.rate + end +end + +# Keep existing method for backward compatibility +def used_rate + assigned_task.rate.positive? ? assigned_task.rate : project.rate +end +``` + +Reports and billing call `used_rate_for(current_organization)` instead of `used_rate`. The existing `used_rate` continues to work for contexts where only the owning org's rate matters. + +**Reports refactoring:** `Reports::Summary#total_billable_amount` and `Reports::Result` both call `billed_amount` which uses `used_rate` without org context. These must be updated to accept an organization parameter and use `used_rate_for(organization)`. The `billed_amount` method should gain a `billed_amount_for(organization)` variant following the same pattern. + +### AssignedTask Rate-Change Archiving + +When `AssignedTask#handle_rate_change` archives an old record and creates a new one, `ProjectShareTaskRate` records referencing the old `assigned_task_id` must be migrated: + +```ruby +# In handle_rate_change, after creating new_assigned_task: +ProjectShareTaskRate.where(assigned_task: self).update_all(assigned_task_id: new_assigned_task.id) +``` + +This preserves guest org task rates across rate changes. + +### Currency + +Each org has its own currency. Rates on `ProjectShare` and `ProjectShareTaskRate` are in the guest org's currency (inferred from `project_share.organization.currency`). No cross-currency conversion — each org sees their own rates in their own currency. + +### Rate Management UI + +Org_two admins see a "Rates" section when viewing a shared project, where they can: +- Set their org's project-level rate on the `ProjectShare` +- Set per-task rates via `ProjectShareTaskRate` for each `AssignedTask` + +This is separate from the project edit page (which org_two cannot access). + +## Controller Structure + +### New Controllers + +**`Workspace::ProjectSharesController`** — manages the org-to-org relationship: +- `index` — list guest orgs for a project (org_one admin) or list shared projects (org_two admin) +- `update_rates` — org_two admin bulk-updates their project rate + task rates on the `ProjectShare` +- `destroy` — either side disconnects + +**`Workspace::ProjectShareTaskRatesController`** — manages individual per-task rates for guest orgs: +- `create` / `update` — org_two admin sets individual task rates (used by the `update_rates` form) +- Nested under project_share routes + +### Existing Controller Changes + +**`Workspace::ProjectsController`:** +- `show` — detect if project is shared (guest org context), render read-only view +- Time_reg listing extended to show all entries on shared projects for guest admins + +### Routes + +```ruby +namespace :workspace do + resources :projects do + resources :project_shares, only: [:index, :destroy] do + resources :project_share_task_rates, only: [:create, :update] + member do + patch :update_rates # Bulk update project + task rates + end + end + end +end +``` + +## API Considerations + +The API layer (`Api::V1::*`) uses the same policies, so policy scope changes automatically apply. However: +- `Api::V1::ReportsController` does SQL-level aggregation — rate resolution must happen at the query level or post-processing, not just in Ruby +- API responses for shared projects should include a `shared: true` flag and `owner_organization` info +- API rate endpoints need to respect the same org-context rules + +These are implementation details to address during the API phase, not blockers for the initial implementation. + +## UI & Navigation + +### Org_one Admin (Project Owner) + +- Project show page gains a "Shared with" section listing guest organizations (name only) with a "Disconnect" action per org +- Existing "Invite external user" flow unchanged (email-based) +- Time_regs view unchanged + +### Org_two Admin (Guest Org) + +- Shared projects appear in the project list, visually distinguished with a badge/tag showing the owning org name +- Project show page is **read-only**: project details, task list, all time_regs +- Time_regs show user names (first_name, last_name) for all orgs +- A "Rates" tab/section for managing their org's rates +- A "Disconnect" action to leave the shared project + +### Org_two Member (Invited User) + +- Shared project appears in their project list (via `ProjectAccess`, same as today) +- Can create/edit/delete their own time_regs +- Sees tasks defined by org_one +- Does not see rates (same as regular non-admin members) + +### Navigation + +No new top-level navigation. Shared projects are mixed into the existing projects list with a visual indicator of ownership. + +## Edge Cases + +### Multi-org users +A user belonging to both org_one and org_two sees the project in both org contexts. When switching organizations, their permissions change accordingly (admin in org_one = full control, member in org_two = member-level access on the shared project). The `current_organization` determines which context applies. + +### User promoted to admin in guest org +If an org_two member with `ProjectAccess` is promoted to admin, they gain full guest-admin read access to the shared project. Their existing `ProjectAccess` record becomes redundant (admins see shared projects via `ProjectShare`) but is harmless to keep. + +### All guest org users removed but ProjectShare remains +If all org_two users' `ProjectAccess` records are individually removed (without disconnecting the org), the `ProjectShare` remains. Org_two admins still see the project. This is acceptable — the org-level share persists until explicitly disconnected. diff --git a/lib/ci_runner.rb b/lib/ci_runner.rb new file mode 100644 index 00000000..cee81080 --- /dev/null +++ b/lib/ci_runner.rb @@ -0,0 +1,161 @@ +# Adapted from Rails 8.1 ActiveSupport::ContinuousIntegration +# https://github.com/rails/rails/blob/8-1-stable/activesupport/lib/active_support/continuous_integration.rb +require "open3" + +class CIRunner + COLORS = { + banner: "\e[1;32m", # Bold green + title: "\e[1;35m", # Bold purple + subtitle: "\e[1;90m", # Bold gray + error: "\e[1;31m", # Bold red + success: "\e[1;32m", # Bold green + reset: "\e[0m" + }.freeze + + attr_reader :results + + def initialize(output: $stdout, fail_fast: false) + @output = output + @fail_fast = fail_fast + @results = [] + end + + def self.run(title = "Continuous Integration", subtitle = "Running checks...", output: $stdout, fail_fast: false, &block) + new(output: output, fail_fast: fail_fast).tap do |ci| + ci.heading(title, subtitle, type: :banner, padding: false) + ci.report(title, &block) + end + end + + def step(title, *command) + heading(title, command.join(" "), type: :title) + + started_at = Time.now + stdout, stderr, status = run_with_timer(title, started_at) do + Open3.capture3(*command) + end + elapsed = format_elapsed(Time.now - started_at) + success = status.success? + + if success + echo("\u2705 #{title} passed in #{elapsed}", type: :success) + else + echo("\u274C #{title} failed in #{elapsed}", type: :error) + print_failure_output(stdout, stderr) + end + + results << [ success, title, stdout, stderr ] + end + + def success? + results.all?(&:first) + end + + def failure(title, subtitle = nil) + heading(title, subtitle, type: :error) + end + + def heading(text, subtitle = nil, type: :banner, padding: true) + @output.puts if padding + echo(text, type: type) + echo(subtitle, type: :subtitle) if subtitle + @output.puts if padding + end + + def report(title, &block) + Signal.trap("INT") { abort colorize("\n\u274C #{title} interrupted", :error) } + + ci = self.class.new(output: @output, fail_fast: @fail_fast) + elapsed = timing { ci.instance_eval(&block) } + + if ci.success? + echo("\u2705 #{title} passed in #{elapsed}", type: :success) + else + echo("\u274C #{title} failed in #{elapsed}", type: :error) + abort if @fail_fast + + # List failed steps (output was already printed by each step) + ci.results.reject(&:first).each do |_, step_title, _, _| + echo(" \u21B3 #{step_title} failed", type: :error) + end + end + + results.concat(ci.results) + ensure + Signal.trap("INT", "DEFAULT") + end + + private + + def run_with_timer(title, started_at) + result = nil + stop_timer = false + + # Only show timer if output is a TTY + if @output.respond_to?(:tty?) && @output.tty? + timer_thread = Thread.new do + until stop_timer + elapsed = format_elapsed(Time.now - started_at) + @output.print colorize("\r⏱️ #{title} running... #{elapsed}", :subtitle) + sleep 0.1 + end + end + + result = yield + + stop_timer = true + timer_thread.join + @output.print "\r#{' ' * 60}\r" # Clear the timer line + else + result = yield + end + + result + end + + def print_failure_output(stdout, stderr) + output_text = extract_relevant_output(stdout, stderr) + @output.puts output_text unless output_text.empty? + end + + def extract_relevant_output(stdout, stderr) + combined = "#{stdout}#{stderr}" + + # For RSpec output, extract from "Failures:" onward + if combined.include?("Failures:") + combined.slice(/Failures:.*Failed examples:.*?(?=\n\n|\z)/m) || + combined.slice(/Failures:.*/m) || + combined + else + combined + end + end + + def echo(text, type:) + @output.puts colorize(text, type) + end + + def colorize(text, type) + if @output.respond_to?(:tty?) && @output.tty? + "#{COLORS[type]}#{text}#{COLORS[:reset]}" + else + text + end + end + + def timing + started_at = Time.now + yield + format_elapsed(Time.now - started_at) + end + + def format_elapsed(elapsed) + if elapsed >= 60 + minutes = (elapsed / 60).to_i + seconds = elapsed % 60 + "#{minutes}m#{format('%.2fs', seconds)}" + else + format("%.2fs", elapsed) + end + end +end diff --git a/test/controllers/time_regs_controller_test.rb b/test/controllers/time_regs_controller_test.rb index 7ad4ee25..e920b494 100644 --- a/test/controllers/time_regs_controller_test.rb +++ b/test/controllers/time_regs_controller_test.rb @@ -164,4 +164,88 @@ class AdminUser < TimeRegsControllerTest end end end + + class SharedProjectMember < TimeRegsControllerTest + setup do + @ron = users(:ron) + @org_two = organizations(:organization_two) + @shared_project = projects(:project_1) + @current_date = Date.today + + # Switch ron to org_two context + switch_org_context!(@ron, @org_two) + sign_in @ron + end + + test "org_two member can create a time_reg on a shared project" do + assigned_task = @shared_project.assigned_tasks.first + assert_difference("TimeReg.count") do + post :create, params: { time_reg: { date_worked: @current_date, minutes: 60, project_id: @shared_project.id, assigned_task_id: assigned_task.id } } + end + assert_redirected_to time_regs_path(date: @current_date) + assert_equal @ron, TimeReg.last.user + assert_equal assigned_task, TimeReg.last.assigned_task + end + + test "task dropdown loads org_one tasks for the shared project" do + get :update_tasks_select, params: { project_id: @shared_project.id } + assert_response :success + + # Should include active tasks from project_1 (debug, coding, authentication) + active_task_ids = @shared_project.assigned_tasks.where(is_archived: false).pluck(:id) + returned_ids = assigns(:name_id_pairs).map(&:last) + active_task_ids.each do |task_id| + assert_includes returned_ids, task_id + end + end + + test "shared project appears in set_clients for org_two member" do + get :index + assert_response :success + project_names = assigns(:clients).flat_map { |c| c.items.map(&:name) } + assert_includes project_names, @shared_project.name + end + + end + + class SharedProjectAdmin < TimeRegsControllerTest + setup do + @admin = users(:organization_admin) + @org_two = organizations(:organization_two) + @shared_project = projects(:project_1) + @current_date = Date.today + + # Switch admin to org_two context + switch_org_context!(@admin, @org_two) + sign_in @admin + end + + test "org_two admin can view time_regs on shared projects via index" do + get :index + assert_response :success + end + + test "org_two admin can export shared project time_regs" do + get :export, params: { project_id: @shared_project.id } + assert_response :success + assert_equal "text/csv", response.media_type + end + + test "export strips email for cross-org users on shared project" do + get :export, params: { project_id: @shared_project.id } + csv_lines = response.body.split("\n") + # Header has "email" column + header = csv_lines.first + assert_includes header, "email" + + # Find rows for org_one users (e.g., joe) - their emails should be redacted + joe = users(:joe) + csv_lines[1..].each do |line| + fields = CSV.parse_line(line) + next unless fields[6] == joe.first_name && fields[7] == joe.last_name + assert_equal "", fields[8], "Cross-org user email should be blank" + end + end + + end end diff --git a/test/controllers/workspace/project_share_task_rates_controller_test.rb b/test/controllers/workspace/project_share_task_rates_controller_test.rb new file mode 100644 index 00000000..9b9f9b08 --- /dev/null +++ b/test/controllers/workspace/project_share_task_rates_controller_test.rb @@ -0,0 +1,121 @@ +require "test_helper" + +module Workspace + class ProjectShareTaskRatesControllerTest < ActionController::TestCase + fixtures :all + + setup do + @organization_admin = users(:organization_admin) + sign_in @organization_admin + + @project = projects(:project_1) + @project_share = project_shares(:project_one_shared_with_org_two) + @task_rate = project_share_task_rates(:task_rate_one) + @org_one = organizations(:organization_one) + @org_two = organizations(:organization_two) + end + + # --- create --- + + test "create as org_two admin creates a task rate on the project share" do + switch_org_context!(@organization_admin, @org_two) + sign_in @organization_admin + assigned_task = assigned_task(:task_2) + + assert_difference("ProjectShareTaskRate.count", 1) do + post :create, params: { + project_id: @project.id, + project_share_id: @project_share.id, + project_share_task_rate: { + assigned_task_id: assigned_task.id, + rate_currency: "4.50" + } + } + end + + assert_response :redirect + new_rate = ProjectShareTaskRate.last + assert_equal @project_share.id, new_rate.project_share_id + assert_equal assigned_task.id, new_rate.assigned_task_id + assert_equal 450, new_rate.rate + end + + test "create as org_one admin denied" do + post :create, params: { + project_id: @project.id, + project_share_id: @project_share.id, + project_share_task_rate: { + assigned_task_id: assigned_task(:task_2).id, + rate_currency: "4.50" + } + } + + assert_redirected_to root_path + end + + test "create calls authorize!" do + switch_org_context!(@organization_admin, @org_two) + sign_in @organization_admin + + # verify_authorized ensures authorize! is called; this would raise + # ActionPolicy::UnauthorizedError if authorize! were missing + post :create, params: { + project_id: @project.id, + project_share_id: @project_share.id, + project_share_task_rate: { + assigned_task_id: assigned_task(:task_2).id, + rate_currency: "4.50" + } + } + assert_response :redirect + end + + # --- update --- + + test "update as org_two admin updates an existing task rate" do + switch_org_context!(@organization_admin, @org_two) + sign_in @organization_admin + + patch :update, params: { + project_id: @project.id, + project_share_id: @project_share.id, + id: @task_rate.id, + project_share_task_rate: { + rate_currency: "7.50" + } + } + + assert_response :redirect + assert_equal 750, @task_rate.reload.rate + end + + test "update as org_one admin denied" do + patch :update, params: { + project_id: @project.id, + project_share_id: @project_share.id, + id: @task_rate.id, + project_share_task_rate: { + rate_currency: "7.50" + } + } + + assert_redirected_to root_path + end + + test "update calls authorize!" do + switch_org_context!(@organization_admin, @org_two) + sign_in @organization_admin + + assert_authorized_to(:update?, @task_rate, with: Workspace::ProjectShareTaskRatePolicy) do + patch :update, params: { + project_id: @project.id, + project_share_id: @project_share.id, + id: @task_rate.id, + project_share_task_rate: { + rate_currency: "7.50" + } + } + end + end + end +end diff --git a/test/controllers/workspace/project_shares_controller_test.rb b/test/controllers/workspace/project_shares_controller_test.rb new file mode 100644 index 00000000..c386eef1 --- /dev/null +++ b/test/controllers/workspace/project_shares_controller_test.rb @@ -0,0 +1,111 @@ +require "test_helper" + +module Workspace + class ProjectSharesControllerTest < ActionController::TestCase + fixtures :all + + setup do + @organization_admin = users(:organization_admin) + sign_in @organization_admin + + @project = projects(:project_1) + @project_share = project_shares(:project_one_shared_with_org_two) + @org_one = organizations(:organization_one) + @org_two = organizations(:organization_two) + end + + # --- index --- + + test "index as org_one admin lists guest orgs for the project" do + get :index, params: { project_id: @project.id } + assert_response :success + end + + test "index as org_two admin lists shared projects" do + switch_org_context!(@organization_admin, @org_two) + get :index, params: { project_id: @project.id } + assert_response :success + end + + test "index calls authorize!" do + assert_authorized_to(:index?, ProjectShare, with: Workspace::ProjectSharePolicy) do + get :index, params: { project_id: @project.id } + end + end + + # --- update --- + + test "update as org_two admin updates project_share rate and task rates" do + switch_org_context!(@organization_admin, @org_two) + sign_in @organization_admin + task_rate = project_share_task_rates(:task_rate_one) + + patch :update, params: { + project_id: @project.id, + id: @project_share.id, + project_share: { + rate_currency: "5.00", + project_share_task_rates_attributes: { + "0" => { id: task_rate.id, rate_currency: "3.00" } + } + } + } + + assert_response :redirect + assert_equal 500, @project_share.reload.rate + assert_equal 300, task_rate.reload.rate + end + + test "update as org_one admin denied" do + patch :update, params: { + project_id: @project.id, + id: @project_share.id, + project_share: { rate_currency: "5.00" } + } + + # org_one admin cannot update guest rates (update? policy denies) + assert_redirected_to root_path + end + + test "update calls authorize!" do + switch_org_context!(@organization_admin, @org_two) + assert_authorized_to(:update?, @project_share, with: Workspace::ProjectSharePolicy) do + patch :update, params: { + project_id: @project.id, + id: @project_share.id, + project_share: { rate_currency: "5.00" } + } + end + end + + # --- destroy --- + + test "destroy as org_one admin disconnects guest org" do + assert_difference("ProjectShare.count", -1) do + delete :destroy, params: { project_id: @project.id, id: @project_share.id } + end + assert_redirected_to workspace_project_path(@project) + end + + test "destroy as org_two admin disconnects own org" do + switch_org_context!(@organization_admin, @org_two) + + assert_difference("ProjectShare.count", -1) do + delete :destroy, params: { project_id: @project.id, id: @project_share.id } + end + assert_redirected_to workspace_projects_path + end + + test "destroy as non-admin denied" do + sign_in users(:joe) + delete :destroy, params: { project_id: @project.id, id: @project_share.id } + assert_redirected_to root_path + end + + test "destroy calls authorize!" do + assert_authorized_to(:destroy?, @project_share, with: Workspace::ProjectSharePolicy) do + delete :destroy, params: { project_id: @project.id, id: @project_share.id } + end + end + end +end diff --git a/test/controllers/workspace/projects_controller_test.rb b/test/controllers/workspace/projects_controller_test.rb index 1a7f66f2..a998fc7b 100644 --- a/test/controllers/workspace/projects_controller_test.rb +++ b/test/controllers/workspace/projects_controller_test.rb @@ -62,7 +62,7 @@ class ProjectsControllerTest < ActionController::TestCase test "spectator should not show project" do sign_in users(:organization_spectator) get :show, params: { id: @project.id } - assert_redirected_to root_path + assert_redirected_to reports_path end test "should update project" do @@ -90,5 +90,63 @@ class ProjectsControllerTest < ActionController::TestCase end assert_redirected_to workspace_project_path(@project) end + + # --- Shared project tests (guest org admin) --- + + test "org_two admin can access show for a shared project" do + shared_project = projects(:project_1) + switch_org_context!(@organization_admin, organizations(:organization_two)) + sign_in @organization_admin + + get :show, params: { id: shared_project.id } + assert_response :success + assert assigns(:shared_project), "Expected @shared_project to be set for guest org admin" + assert_not_nil assigns(:project_share), "Expected @project_share to be set for guest org admin" + end + + test "org_one admin sees guest_organizations for owned shared project" do + get :show, params: { id: @project.id } + assert_response :success + assert_not assigns(:shared_project), "Expected @shared_project to be false for owning org admin" + assert_not_nil assigns(:guest_organizations), "Expected @guest_organizations to be set for owning org admin" + end + + test "org_two admin sees shared projects in index" do + switch_org_context!(@organization_admin, organizations(:organization_two)) + sign_in @organization_admin + + get :index + assert_response :success + end + + test "org_two admin cannot access edit for shared project" do + shared_project = projects(:project_1) + switch_org_context!(@organization_admin, organizations(:organization_two)) + sign_in @organization_admin + + get :edit, params: { id: shared_project.id } + assert_redirected_to root_path + end + + test "org_two admin cannot access update for shared project" do + shared_project = projects(:project_1) + switch_org_context!(@organization_admin, organizations(:organization_two)) + sign_in @organization_admin + + patch :update, params: { id: shared_project.id, project: { name: "Hacked Name" } } + assert_redirected_to root_path + assert_equal "E Corp CRM", shared_project.reload.name + end + + test "org_two admin cannot access destroy for shared project" do + shared_project = projects(:project_1) + switch_org_context!(@organization_admin, organizations(:organization_two)) + sign_in @organization_admin + + assert_no_difference("Project.count") do + delete :destroy, params: { id: shared_project.id } + end + assert_redirected_to root_path + end end end diff --git a/test/fixtures/access_infos.yml b/test/fixtures/access_infos.yml index 85e7ea5b..8196a2b5 100644 --- a/test/fixtures/access_infos.yml +++ b/test/fixtures/access_infos.yml @@ -46,6 +46,12 @@ access_info_org1_user: active: true role: 0 +access_info_org2_user: + user: organization_user + organization: organization_two + active: false + role: 0 + access_info_org1_spectator: user: organization_spectator organization: organization_one diff --git a/test/fixtures/project_accesses.yml b/test/fixtures/project_accesses.yml index be1bc9a9..6374abd2 100644 --- a/test/fixtures/project_accesses.yml +++ b/test/fixtures/project_accesses.yml @@ -6,4 +6,8 @@ ron_project_1: spectator_project_1: access_info: access_info_org1_spectator + project: project_1 + +ron_org2_shared_project_1: + access_info: access_info_2 project: project_1 \ No newline at end of file diff --git a/test/fixtures/project_share_task_rates.yml b/test/fixtures/project_share_task_rates.yml new file mode 100644 index 00000000..d17d21bc --- /dev/null +++ b/test/fixtures/project_share_task_rates.yml @@ -0,0 +1,4 @@ +task_rate_one: + project_share: project_one_shared_with_org_two + assigned_task: task_1 + rate: 300 diff --git a/test/fixtures/project_shares.yml b/test/fixtures/project_shares.yml new file mode 100644 index 00000000..0f855395 --- /dev/null +++ b/test/fixtures/project_shares.yml @@ -0,0 +1,4 @@ +project_one_shared_with_org_two: + project: project_1 + organization: organization_two + rate: 0 diff --git a/test/models/assigned_task_test.rb b/test/models/assigned_task_test.rb index 915e3672..4237fbcf 100644 --- a/test/models/assigned_task_test.rb +++ b/test/models/assigned_task_test.rb @@ -45,4 +45,23 @@ class AssignedTaskTest < ActiveSupport::TestCase assert_equal last_assigned_task.project, @project assert_equal last_assigned_task.task, @task end + + test "should migrate ProjectShareTaskRate records to new assigned task when rate changes" do + assigned_task = assigned_task(:task_1) + project_share_task_rate = project_share_task_rates(:task_rate_one) + + assert_equal assigned_task, project_share_task_rate.assigned_task + + # update rate to trigger archiving + assert_difference("AssignedTask.count") do + assigned_task.update!(rate: 500) + end + + new_assigned_task = AssignedTask.where(project: assigned_task.project, task: assigned_task.task).active_task.first + + # ProjectShareTaskRate should now point to the new assigned task + project_share_task_rate.reload + assert_equal new_assigned_task, project_share_task_rate.assigned_task + assert_not_equal assigned_task, project_share_task_rate.assigned_task + end end diff --git a/test/models/project_share_task_rate_test.rb b/test/models/project_share_task_rate_test.rb new file mode 100644 index 00000000..8dee7ced --- /dev/null +++ b/test/models/project_share_task_rate_test.rb @@ -0,0 +1,61 @@ +require "test_helper" + +class ProjectShareTaskRateTest < ActiveSupport::TestCase + def setup + @project_share = project_shares(:project_one_shared_with_org_two) + @assigned_task = assigned_task(:task_1) + end + + test "valid project share task rate" do + rate = ProjectShareTaskRate.new( + project_share: @project_share, + assigned_task: assigned_task(:task_2), + rate: 300 + ) + assert rate.valid? + end + + test "belongs to project_share" do + rate = project_share_task_rates(:task_rate_one) + assert_instance_of ProjectShare, rate.project_share + end + + test "belongs to assigned_task" do + rate = project_share_task_rates(:task_rate_one) + assert_instance_of AssignedTask, rate.assigned_task + end + + test "validates uniqueness of assigned_task_id scoped to project_share_id" do + existing = project_share_task_rates(:task_rate_one) + duplicate = ProjectShareTaskRate.new( + project_share: existing.project_share, + assigned_task: existing.assigned_task, + rate: 500 + ) + assert_not duplicate.valid? + assert_includes duplicate.errors[:assigned_task_id], "has already been taken" + end + + test "includes RateConvertible" do + assert_includes ProjectShareTaskRate.ancestors, RateConvertible + end + + test "rate_currency getter converts hundredths to currency format" do + rate = ProjectShareTaskRate.new(rate: 350) + assert_equal "3,50", rate.rate_currency + end + + test "rate_currency setter converts currency format to hundredths" do + rate = ProjectShareTaskRate.new + rate.rate_currency = "4,25" + assert_equal 425, rate.rate + end + + test "rate defaults to 0" do + rate = ProjectShareTaskRate.new( + project_share: @project_share, + assigned_task: @assigned_task + ) + assert_equal 0, rate.rate + end +end diff --git a/test/models/project_share_test.rb b/test/models/project_share_test.rb new file mode 100644 index 00000000..204878b0 --- /dev/null +++ b/test/models/project_share_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class ProjectShareTest < ActiveSupport::TestCase + def setup + @organization_one = organizations(:organization_one) + @organization_two = organizations(:organization_two) + @organization_three = organizations(:organization_three) + @project = projects(:project_1) + end + + test "valid project share" do + project_share = ProjectShare.new(project: @project, organization: @organization_three, rate: 250) + assert project_share.valid? + end + + test "validates uniqueness of organization scoped to project" do + # Fixture already creates project_1 shared with organization_two + duplicate = ProjectShare.new(project: @project, organization: @organization_two, rate: 0) + assert_not duplicate.valid? + assert_includes duplicate.errors[:organization_id], "has already been taken" + end + + test "validates that organization cannot be the project owning organization" do + project_share = ProjectShare.new(project: @project, organization: @organization_one) + assert_not project_share.valid? + assert project_share.errors.added?(:organization, :is_project_owner) + end + + test "rate_currency getter converts hundredths to currency format" do + project_share = ProjectShare.new(rate: 250) + assert_equal "2,50", project_share.rate_currency + end + + test "rate_currency setter converts currency format to hundredths" do + project_share = ProjectShare.new + project_share.rate_currency = "2,50" + assert_equal 250, project_share.rate + end + + test "rate defaults to 0" do + project_share = ProjectShare.new(project: @project, organization: @organization_three) + assert_equal 0, project_share.rate + end +end diff --git a/test/models/reports/result_test.rb b/test/models/reports/result_test.rb index 8ca46fb3..0b841c76 100644 --- a/test/models/reports/result_test.rb +++ b/test/models/reports/result_test.rb @@ -68,6 +68,27 @@ def generate_expected_data(attribute:, attribute_name_method: :name) end end + def generate_expected_data_with_org(attribute:, organization:, attribute_name_method: :name) + singular_attribute = attribute.singularize.to_sym + grouped_time_regs = @time_regs.group_by(&singular_attribute) + grouped_time_regs.map do |group, time_regs| + billable_time_regs = time_regs.select { |time_reg| time_reg.project.billable } + total_minutes = time_regs.sum(&:minutes) + total_billable_minutes = billable_time_regs.sum(&:minutes) + total_billable_amount = ConvertCurrencyHundredths.out(billable_time_regs.sum { |tr| tr.billed_amount_for(organization) }) + total_billable_minutes_percentage = (total_billable_minutes / total_minutes.to_f * 100).truncate(2) + + { + attribute_name: group.send(attribute_name_method), + total_minutes: total_minutes, + total_billable_minutes: total_billable_minutes, + total_billable_amount: total_billable_amount, + total_billable_minutes_percentage: total_billable_minutes_percentage, + group_link: { "#{singular_attribute}_ids": [ group.id ], category: nil } + } + end + end + def generate_dummy_data # Organization @organization = Organization.create!(name: "My report test organization", currency: "DKK") @@ -149,4 +170,64 @@ def generate_dummy_data @time_reg16 = TimeReg.create!(user: @user2, assigned_task: @assigned_task1, minutes: 480, date_worked: Date.today - 8) end end + + class ResultWithOrganizationTest < ActiveSupport::TestCase + def setup + @owner_org = Organization.create!(name: "Result Owner Org", currency: "USD") + @guest_org = Organization.create!(name: "Result Guest Org", currency: "USD") + + @user = User.create!(first_name: "Result", last_name: "User", email: "result_org_test@example.com", password: "password") + AccessInfo.create!(user: @user, organization: @owner_org, role: 1) + + @client = Client.create!(name: "Result Org Client", organization: @owner_org) + + @task = Task.create!(name: "Result Org Task", organization: @owner_org) + + @project = Project.new(name: "Result Org Project", client: @client, rate: 10000, billable: true) + @assigned_task = AssignedTask.new(task: @task, project: @project, rate: 0) + @project.save!(validate: false) + @assigned_task.save!(validate: false) + + # Share project with guest org at a different rate + @project_share = ProjectShare.create!(project: @project, organization: @guest_org, rate: 5000) + + @time_reg = TimeReg.create!(user: @user, assigned_task: @assigned_task, minutes: 120, date_worked: Date.today) + @time_regs = [ @time_reg ] + + @filter_class = Reports::Filter + end + + test "grouped without organization uses default billed_amount" do + filter = @filter_class.new(category: @filter_class::CLIENTS) + result = Reports::Result.new(time_regs: @time_regs, filter: filter) + + grouped = result.grouped + # 120 min = 2 hours, rate 10000 => billed_amount = 20000 + expected_amount = ConvertCurrencyHundredths.out(@time_regs.select { |tr| tr.project.billable }.sum(&:billed_amount)) + assert_equal expected_amount, grouped.first[:total_billable_amount] + end + + test "grouped with organization uses billed_amount_for" do + filter = @filter_class.new(category: @filter_class::CLIENTS) + result = Reports::Result.new(time_regs: @time_regs, filter: filter, organization: @guest_org) + + grouped = result.grouped + # Guest org share rate = 5000, 2 hours * 5000 = 10000 + expected_amount = ConvertCurrencyHundredths.out(@time_regs.select { |tr| tr.project.billable }.sum { |tr| tr.billed_amount_for(@guest_org) }) + assert_equal expected_amount, grouped.first[:total_billable_amount] + + # Verify it differs from default + default_amount = ConvertCurrencyHundredths.out(@time_regs.select { |tr| tr.project.billable }.sum(&:billed_amount)) + assert_not_equal default_amount, grouped.first[:total_billable_amount] + end + + test "grouped by projects with organization uses org-context rates" do + filter = @filter_class.new(category: @filter_class::PROJECTS) + result = Reports::Result.new(time_regs: @time_regs, filter: filter, organization: @guest_org) + + grouped = result.grouped + expected_amount = ConvertCurrencyHundredths.out(@time_regs.select { |tr| tr.project.billable }.sum { |tr| tr.billed_amount_for(@guest_org) }) + assert_equal expected_amount, grouped.first[:total_billable_amount] + end + end end diff --git a/test/models/reports/summary_test.rb b/test/models/reports/summary_test.rb index b50de218..61e10ec4 100644 --- a/test/models/reports/summary_test.rb +++ b/test/models/reports/summary_test.rb @@ -27,4 +27,61 @@ def setup assert_equal @summary.total_minutes - @summary.total_billable_minutes, @summary.total_non_billable_minutes end end + + class SummaryWithOrganizationTest < ActiveSupport::TestCase + def setup + # Owner organization + @owner_org = Organization.create!(name: "Owner Org", currency: "USD") + + # Guest organization + @guest_org = Organization.create!(name: "Guest Org", currency: "USD") + + # User + @user = User.create!(first_name: "Test", last_name: "User", email: "summary_org_test@example.com", password: "password") + AccessInfo.create!(user: @user, organization: @owner_org, role: 1) + + # Client and project + @client = Client.create!(name: "Summary Org Client", organization: @owner_org) + + @project = Project.new(name: "Summary Org Project", client: @client, rate: 10000, billable: true) + @task = Task.create!(name: "Summary Org Task", organization: @owner_org) + @assigned_task = AssignedTask.new(task: @task, project: @project, rate: 0) + @project.save!(validate: false) + @assigned_task.save!(validate: false) + + # Create a ProjectShare with a different rate for the guest org + @project_share = ProjectShare.create!(project: @project, organization: @guest_org, rate: 5000) + + # Time registrations + @time_reg = TimeReg.create!(user: @user, assigned_task: @assigned_task, minutes: 60, date_worked: Date.today) + + @time_regs = TimeReg.where(id: @time_reg.id) + end + + test "#total_billable_amount without organization uses default billed_amount" do + summary = Reports::Summary.new(time_regs: @time_regs) + + # 60 minutes = 1 hour, rate = 10000 (project rate since assigned_task rate is 0) + expected = @time_regs.billable.sum(&:billed_amount) + assert_equal expected, summary.total_billable_amount + end + + test "#total_billable_amount with organization uses billed_amount_for" do + summary = Reports::Summary.new(time_regs: @time_regs, organization: @guest_org) + + # Guest org share rate = 5000, 1 hour * 5000 = 5000 + expected = @time_regs.billable.sum { |tr| tr.billed_amount_for(@guest_org) } + assert_equal expected, summary.total_billable_amount + # Verify it differs from the default + default_amount = @time_regs.billable.sum(&:billed_amount) + assert_not_equal default_amount, summary.total_billable_amount + end + + test "#total_billable_amount_currency with organization uses org-context rates" do + summary = Reports::Summary.new(time_regs: @time_regs, organization: @guest_org) + + expected = ConvertCurrencyHundredths.out(@time_regs.billable.sum { |tr| tr.billed_amount_for(@guest_org) }) + assert_equal expected, summary.total_billable_amount_currency + end + end end diff --git a/test/models/time_reg_test.rb b/test/models/time_reg_test.rb index f5a61e47..6b21ddb0 100644 --- a/test/models/time_reg_test.rb +++ b/test/models/time_reg_test.rb @@ -54,4 +54,43 @@ def setup @time_reg.save assert_not @time_reg.active? end + + # used_rate_for(organization) + + test "#used_rate_for returns same as used_rate when org is the project owner" do + owner_org = organizations(:organization_one) + assert_equal @time_reg.used_rate, @time_reg.used_rate_for(owner_org) + end + + test "#used_rate_for returns the task rate when guest org has a ProjectShareTaskRate" do + guest_org = organizations(:organization_two) + task_rate = project_share_task_rates(:task_rate_one) + assert_equal task_rate.rate, @time_reg.used_rate_for(guest_org) + end + + test "#used_rate_for returns the project share rate when guest org has no task rate" do + guest_org = organizations(:organization_two) + project_share = project_shares(:project_one_shared_with_org_two) + project_share.update!(rate: 200) + project_share.project_share_task_rates.destroy_all + + assert_equal 200, @time_reg.used_rate_for(guest_org) + end + + test "#used_rate_for returns 0 when guest org has no rates set" do + guest_org = organizations(:organization_two) + project_share = project_shares(:project_one_shared_with_org_two) + project_share.update!(rate: 0) + project_share.project_share_task_rates.destroy_all + + assert_equal 0, @time_reg.used_rate_for(guest_org) + end + + # billed_amount_for(organization) + + test "#billed_amount_for returns minutes / 60.0 * used_rate_for(organization)" do + guest_org = organizations(:organization_two) + expected = @time_reg.minutes / 60.0 * @time_reg.used_rate_for(guest_org) + assert_equal expected, @time_reg.billed_amount_for(guest_org) + end end diff --git a/test/playwright/shared_projects.spec.js b/test/playwright/shared_projects.spec.js new file mode 100644 index 00000000..1910bccf --- /dev/null +++ b/test/playwright/shared_projects.spec.js @@ -0,0 +1,384 @@ +const { test, expect } = require("@playwright/test"); + +// Dev DB state: +// User 1: test@test (admin of "test org") - owns Project 1 "Apollo 11" +// User 2: test123@test (admin of "test org 123") - guest on Project 1 +// Project 1: "Apollo 11" shared with "test org 123" +// Project 2: "My project" owned by "test org 123" + +const USER1 = { email: "test@test", password: "testpass123" }; +const USER2 = { email: "test123@test", password: "testpass123" }; + +async function login(page, user) { + await page.goto("/users/sign_in"); + + // Dismiss cookie consent if present + const declineBtn = page.getByRole("button", { name: "Decline" }); + if (await declineBtn.isVisible({ timeout: 2000 }).catch(() => false)) { + await declineBtn.click(); + } + + await page.fill("#user_email", user.email); + await page.fill("#user_password", user.password); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL((url) => !url.pathname.includes("sign_in"), { + timeout: 10000, + }); +} + +// ============================================================ +// OWNING ORG ADMIN (User 1) - Views +// ============================================================ + +test.describe("Owning org admin (User 1 - test org)", () => { + test.beforeEach(async ({ page }) => { + await login(page, USER1); + }); + + test("can see project list with own projects", async ({ page }) => { + await page.goto("/workspace/projects"); + await expect(page.locator("body")).toContainText("Apollo 11"); + }); + + test("can view project show page for owned project", async ({ page }) => { + await page.goto("/workspace/projects/1"); + await expect(page.locator("body")).toContainText("Apollo 11"); + }); + + test("sees guest org name on owned project show page", async ({ page }) => { + await page.goto("/workspace/projects/1"); + await expect(page.locator("body")).toContainText("test org 123"); + }); + + test("can see edit button on owned project", async ({ page }) => { + await page.goto("/workspace/projects/1"); + const editLink = page.locator('a[href="/workspace/projects/1/edit"]'); + await expect(editLink).toBeVisible(); + }); + + test("can access project shares index", async ({ page }) => { + await page.goto("/workspace/projects/1/project_shares"); + expect(page.url()).not.toContain("sign_in"); + await expect(page.locator("body")).toContainText("test org 123"); + }); + + test("can access edit page for owned project", async ({ page }) => { + await page.goto("/workspace/projects/1/edit"); + await expect(page).toHaveURL(/\/workspace\/projects\/1\/edit/); + }); +}); + +// ============================================================ +// GUEST ORG ADMIN (User 2) - Views +// ============================================================ + +test.describe("Guest org admin (User 2 - test org 123)", () => { + test.beforeEach(async ({ page }) => { + await login(page, USER2); + }); + + test("can see own projects in project list", async ({ page }) => { + await page.goto("/workspace/projects"); + await expect(page.locator("body")).toContainText("My project"); + }); + + test("project list page contains shared indicator", async ({ page }) => { + await page.goto("/workspace/projects"); + // The page should show something related to sharing + const pageText = await page.locator("body").textContent(); + // Apollo 11 might appear under a different grouping or with a shared badge + const hasContent = + pageText.includes("Apollo") || + pageText.includes("Shared") || + pageText.includes("test org"); + expect(hasContent).toBeTruthy(); + }); + + test("can view shared project show page (read-only)", async ({ page }) => { + await page.goto("/workspace/projects/1"); + await expect(page.locator("body")).toContainText("Apollo 11"); + expect(page.url()).toContain("/workspace/projects/1"); + }); + + test("does NOT see edit button on shared project", async ({ page }) => { + await page.goto("/workspace/projects/1"); + const editLink = page.locator('a[href="/workspace/projects/1/edit"]'); + await expect(editLink).toHaveCount(0); + }); + + test("cannot access edit page for shared project (redirected)", async ({ + page, + }) => { + await page.goto("/workspace/projects/1/edit"); + expect(page.url()).not.toContain("/edit"); + }); + + test("can view own project show page", async ({ page }) => { + await page.goto("/workspace/projects/2"); + await expect(page.locator("body")).toContainText("My project"); + }); + + test("can see edit button on own project", async ({ page }) => { + await page.goto("/workspace/projects/2"); + const editLink = page.locator('a[href="/workspace/projects/2/edit"]').first(); + await expect(editLink).toBeVisible(); + }); +}); + +// ============================================================ +// TIME REGISTRATION - Guest org user +// ============================================================ + +test.describe("Time registration on shared project (User 2)", () => { + test.beforeEach(async ({ page }) => { + await login(page, USER2); + }); + + test("time_regs index loads without redirect", async ({ page }) => { + await page.goto("/time_regs"); + await expect(page).toHaveURL(/\/time_regs/); + expect(page.url()).not.toContain("sign_in"); + }); + + test("time_regs index shows entries on shared project", async ({ page }) => { + await page.goto("/time_regs"); + // User 2 has time_regs on the shared project "Apollo 11" + // The page should show time entries (not be empty) + await expect(page.locator("body")).toContainText("Apollo 11"); + }); + + test("update_tasks_select returns tasks for shared project", async ({ + page, + }) => { + await page.goto("/time_regs"); + + const response = await page.evaluate(async () => { + const res = await fetch( + "/time_regs/update_tasks_select?project_id=1", + { headers: { Accept: "text/html" } } + ); + return { status: res.status, body: await res.text() }; + }); + + expect(response.status).toBe(200); + expect(response.body).toContain("Welding"); + }); + + test("can create a time_reg on shared project", async ({ page }) => { + await page.goto("/time_regs"); + + const csrfToken = await page.evaluate(() => { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute("content") : null; + }); + expect(csrfToken).toBeTruthy(); + + const response = await page.evaluate( + async ({ token }) => { + const today = new Date().toISOString().split("T")[0]; + const formData = new FormData(); + formData.append("time_reg[assigned_task_id]", "1"); + formData.append("time_reg[date_worked]", today); + formData.append("time_reg[minutes]", "45"); + formData.append("time_reg[notes]", "Playwright test entry"); + formData.append("time_reg[project_id]", "1"); + + const res = await fetch("/time_regs", { + method: "POST", + headers: { + "X-CSRF-Token": token, + Accept: + "text/vnd.turbo-stream.html, text/html, application/xhtml+xml", + }, + body: formData, + redirect: "follow", + }); + return { status: res.status, url: res.url, ok: res.ok }; + }, + { token: csrfToken } + ); + + expect(response.ok).toBeTruthy(); + expect(response.url).toContain("/time_regs"); + }); +}); + +// ============================================================ +// RATE MANAGEMENT - Guest org admin +// ============================================================ + +test.describe("Rate management (User 2 - guest org admin)", () => { + test.beforeEach(async ({ page }) => { + await login(page, USER2); + }); + + test("sees rate-related content on shared project page", async ({ + page, + }) => { + await page.goto("/workspace/projects/1"); + const bodyText = await page.locator("body").textContent(); + const hasRateUI = + bodyText.includes("Rate") || + bodyText.includes("rate") || + bodyText.includes("€"); + expect(hasRateUI).toBeTruthy(); + }); + + test("can submit rate update via fetch", async ({ page }) => { + // Navigate first to get a session cookie + await page.goto("/workspace/projects/1"); + + const csrfToken = await page.evaluate(() => { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute("content") : null; + }); + expect(csrfToken).toBeTruthy(); + + const response = await page.evaluate( + async ({ token }) => { + const params = new URLSearchParams(); + params.append("project_share[rate_currency]", "7.50"); + params.append("_method", "patch"); + params.append("authenticity_token", token); + + const res = await fetch( + "/workspace/projects/1/project_shares/1", + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "text/html, application/xhtml+xml", + }, + body: params.toString(), + redirect: "follow", + } + ); + return { status: res.status, url: res.url, ok: res.ok }; + }, + { token: csrfToken } + ); + + expect(response.ok).toBeTruthy(); + }); +}); + +// ============================================================ +// REPORTS - Guest org admin +// ============================================================ + +test.describe("Reports for guest org (User 2)", () => { + test.beforeEach(async ({ page }) => { + await login(page, USER2); + }); + + test("reports page loads successfully", async ({ page }) => { + await page.goto("/reports"); + await expect(page).toHaveURL(/\/reports/); + expect(page.url()).not.toContain("sign_in"); + }); +}); + +// ============================================================ +// AUTHORIZATION - Verify restrictions +// ============================================================ + +test.describe("Authorization restrictions", () => { + test("guest org admin cannot delete shared project", async ({ page }) => { + await login(page, USER2); + + const csrfToken = await page.evaluate(() => { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute("content") : null; + }); + + const response = await page.evaluate( + async ({ token }) => { + const res = await fetch("/workspace/projects/1", { + method: "DELETE", + headers: { + "X-CSRF-Token": token, + Accept: "text/html", + }, + redirect: "follow", + }); + return { status: res.status, url: res.url }; + }, + { token: csrfToken } + ); + + expect(response.url).not.toMatch(/\/workspace\/projects\/1$/); + }); + + test("guest org admin cannot update shared project", async ({ page }) => { + await login(page, USER2); + + const csrfToken = await page.evaluate(() => { + const meta = document.querySelector('meta[name="csrf-token"]'); + return meta ? meta.getAttribute("content") : null; + }); + + const response = await page.evaluate( + async ({ token }) => { + const formData = new FormData(); + formData.append("project[name]", "Hacked Name"); + + const res = await fetch("/workspace/projects/1", { + method: "PATCH", + headers: { + "X-CSRF-Token": token, + Accept: "text/html", + }, + body: formData, + redirect: "follow", + }); + return { status: res.status, url: res.url }; + }, + { token: csrfToken } + ); + + expect(response.url).not.toContain("/workspace/projects/1/edit"); + }); + + test("owning org admin can still access edit page", async ({ page }) => { + await login(page, USER1); + await page.goto("/workspace/projects/1/edit"); + await expect(page).toHaveURL(/\/workspace\/projects\/1\/edit/); + }); +}); + +// ============================================================ +// EXPORT - Downloads trigger correctly +// ============================================================ + +test.describe("Export functionality", () => { + test("owning org admin can export project time_regs", async ({ page }) => { + await login(page, USER1); + await page.goto("/time_regs"); + + // Use fetch to test the endpoint returns CSV data + const response = await page.evaluate(async () => { + const res = await fetch("/time_regs/export?project_id=1", { + headers: { Accept: "text/csv, text/html" }, + }); + return { status: res.status, contentType: res.headers.get("content-type") }; + }); + + expect(response.status).toBe(200); + }); + + test("guest org admin can export shared project time_regs", async ({ + page, + }) => { + await login(page, USER2); + await page.goto("/time_regs"); + + const response = await page.evaluate(async () => { + const res = await fetch("/time_regs/export?project_id=1", { + headers: { Accept: "text/csv, text/html" }, + }); + return { status: res.status, contentType: res.headers.get("content-type") }; + }); + + expect(response.status).toBe(200); + }); +}); diff --git a/test/policies/task_policy_test.rb b/test/policies/task_policy_test.rb new file mode 100644 index 00000000..e4a32bbf --- /dev/null +++ b/test/policies/task_policy_test.rb @@ -0,0 +1,103 @@ +require "test_helper" + +class TaskPolicyTest < ActiveSupport::TestCase + fixtures :all + + setup do + @org_one = organizations(:organization_one) + @org_two = organizations(:organization_two) + + # Tasks belonging to org_one + @debug = tasks(:debug) # assigned to project_1 (shared with org_two) + @coding = tasks(:coding) # assigned to project_1 (shared with org_two) + @authentication = tasks(:authentication) # assigned to project_1 (shared with org_two) + @ux_audit = tasks(:ux_audit) # assigned to project_2 (NOT shared with org_two) + @e2e_testing = tasks(:e2e_testing) # org_one, no assigned_tasks on shared projects + + # Task belonging to org_two + @login = tasks(:login) + end + + # Helper to get the default scope result for a given user + def scoped_tasks(user) + policy = TaskPolicy.new(user: user) + policy.apply_scope(Task.all, type: :relation) + end + + # --- Scope: org_two admin sees tasks assigned to shared projects --- + + test "scope: org_two admin sees tasks assigned to shared projects (org_one's tasks)" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + result = scoped_tasks(admin) + + # Should see org_two's own tasks + assert_includes result, @login + + # Should see org_one tasks assigned to project_1 (shared with org_two) + assert_includes result, @debug + assert_includes result, @coding + assert_includes result, @authentication + end + + test "scope: org_two admin does NOT see org_one tasks not assigned to shared projects" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + result = scoped_tasks(admin) + + # ux_audit is assigned to project_2 which is NOT shared with org_two + assert_not_includes result, @ux_audit + + # e2e_testing belongs to org_one and has no assigned_tasks on shared projects + assert_not_includes result, @e2e_testing + end + + # --- Scope: org_two member with ProjectAccess sees tasks on shared projects --- + + test "scope: org_two member with ProjectAccess sees tasks on their shared projects" do + ron = users(:ron) + # ron is active in org_two by default and has ProjectAccess to project_1 via access_info_2 + assert_equal @org_two, ron.current_organization + + result = scoped_tasks(ron) + + # Should see tasks assigned to project_1 (shared project ron has access to) + assert_includes result, @debug + assert_includes result, @coding + assert_includes result, @authentication + end + + test "scope: org_two member does NOT see org_one tasks not on shared projects" do + ron = users(:ron) + assert_equal @org_two, ron.current_organization + + result = scoped_tasks(ron) + + # ux_audit is assigned to project_2 which is NOT shared with org_two + assert_not_includes result, @ux_audit + + # e2e_testing belongs to org_one, not assigned to any shared project + assert_not_includes result, @e2e_testing + end + + # --- Scope: org_one admin still sees all their own tasks --- + + test "scope: org_one admin sees all org_one tasks" do + admin = users(:organization_admin) + # admin is active in org_one by default + assert_equal @org_one, admin.current_organization + + result = scoped_tasks(admin) + + assert_includes result, @debug + assert_includes result, @coding + assert_includes result, @authentication + assert_includes result, @ux_audit + assert_includes result, @e2e_testing + assert_not_includes result, @login + end +end diff --git a/test/policies/time_reg_policy_test.rb b/test/policies/time_reg_policy_test.rb new file mode 100644 index 00000000..dc9afd7e --- /dev/null +++ b/test/policies/time_reg_policy_test.rb @@ -0,0 +1,157 @@ +require "test_helper" + +class TimeRegPolicyTest < ActiveSupport::TestCase + fixtures :all + + setup do + @org_one = organizations(:organization_one) + @org_two = organizations(:organization_two) + @project_1 = projects(:project_1) # belongs to org_one, shared with org_two + @time_reg_joe = time_regs(:time_reg_1) # user=joe, project_1 (org_one's project) + @time_reg_ron = time_regs(:time_reg_2) # user=ron, project_1 (org_one's project) + end + + # Helper to build a policy instance for a given user and record + def policy_for(user, record) + TimeRegPolicy.new(record, user: user) + end + + # Helper to get the default scope result for a given user + def scoped_time_regs(user) + policy = TimeRegPolicy.new(user: user) + policy.apply_scope(TimeReg.all, type: :relation) + end + + # --- create? --- + + test "create? org_two member can create their own time_reg on shared project" do + ron = users(:ron) + # ron is active in org_two by default + assert_equal @org_two, ron.current_organization + + # Build a new time_reg for ron on the shared project + new_time_reg = TimeReg.new( + user: ron, + assigned_task: assigned_task(:task_1), + date_worked: Date.today, + minutes: 60 + ) + + assert policy_for(ron, new_time_reg).apply(:create?) + end + + test "create? org_two admin CAN create their own time_reg on shared project" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + new_time_reg = TimeReg.new( + user: admin, + assigned_task: assigned_task(:task_1), + date_worked: Date.today, + minutes: 60 + ) + + assert policy_for(admin, new_time_reg).apply(:create?) + end + + test "create? org_two admin CANNOT create time_regs on behalf of another user on shared project" do + admin = users(:organization_admin) + ron = users(:ron) + switch_org_context!(admin, @org_two) + + new_time_reg = TimeReg.new( + user: ron, + assigned_task: assigned_task(:task_1), + date_worked: Date.today, + minutes: 60 + ) + + assert_not policy_for(admin, new_time_reg).apply(:create?) + end + + # --- show? --- + + test "show? org_two admin can view any time_reg on shared project" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + # Can see joe's time_reg on the shared project (not admin's own entry) + assert policy_for(admin, @time_reg_joe).apply(:show?) + end + + # --- edit?/update?/destroy? --- + + test "edit? org_two admin CANNOT edit org_one user's time_reg on shared project" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + assert_not policy_for(admin, @time_reg_joe).apply(:edit?) + end + + test "update? org_two admin CANNOT update org_one user's time_reg on shared project" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + assert_not policy_for(admin, @time_reg_joe).apply(:update?) + end + + test "destroy? org_two admin CANNOT destroy org_one user's time_reg on shared project" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + assert_not policy_for(admin, @time_reg_joe).apply(:destroy?) + end + + test "edit? org_two member CAN edit their own time_reg on shared project" do + ron = users(:ron) + assert_equal @org_two, ron.current_organization + + assert policy_for(ron, @time_reg_ron).apply(:edit?) + end + + test "update? org_two member CAN update their own time_reg on shared project" do + ron = users(:ron) + assert_equal @org_two, ron.current_organization + + assert policy_for(ron, @time_reg_ron).apply(:update?) + end + + test "destroy? org_two member CAN destroy their own time_reg on shared project" do + ron = users(:ron) + assert_equal @org_two, ron.current_organization + + assert policy_for(ron, @time_reg_ron).apply(:destroy?) + end + + # --- Scope (default) --- + + test "scope: org_two admin sees all time_regs on shared projects" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + result = scoped_time_regs(admin) + + # Should see time_regs from the shared project (project_1) + assert_includes result, @time_reg_joe + assert_includes result, @time_reg_ron + end + + test "scope: org_two member sees only their own time_regs" do + ron = users(:ron) + assert_equal @org_two, ron.current_organization + + result = scoped_time_regs(ron) + + # Ron should see his own time_regs + assert_includes result, @time_reg_ron + + # Ron should NOT see joe's time_regs + assert_not_includes result, @time_reg_joe + end +end diff --git a/test/policies/workspace/project_policy_test.rb b/test/policies/workspace/project_policy_test.rb new file mode 100644 index 00000000..f78728e0 --- /dev/null +++ b/test/policies/workspace/project_policy_test.rb @@ -0,0 +1,172 @@ +require "test_helper" + +class Workspace::ProjectPolicyTest < ActiveSupport::TestCase + fixtures :all + + setup do + @org_one = organizations(:organization_one) + @org_two = organizations(:organization_two) + @project_1 = projects(:project_1) # belongs to org_one, shared with org_two + @project_2 = projects(:project_2) # belongs to org_one, NOT shared + end + + # Helper to build a policy instance for a given user and record + def policy_for(user, record = Project) + Workspace::ProjectPolicy.new(record, user: user) + end + + # Helper to get the scope result for a given user + def scoped_projects(user) + policy = Workspace::ProjectPolicy.new(user: user) + policy.apply_scope(Project.all, type: :relation) + end + + # --- Scope tests --- + + test "scope: org_one admin sees all org_one projects" do + admin = users(:organization_admin) + # active in org_one by default + assert_equal @org_one, admin.current_organization + + result = scoped_projects(admin) + + assert_includes result, @project_1 + assert_includes result, @project_2 + end + + test "scope: org_two admin sees projects shared with org_two" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + result = scoped_projects(admin) + + assert_includes result, @project_1 + end + + test "scope: org_two admin does NOT see org_one projects that are not shared" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + result = scoped_projects(admin) + + assert_not_includes result, @project_2 + end + + test "scope: org_two non-admin with ProjectAccess sees shared project" do + ron = users(:ron) + # ron is active in org_two by default (access_info_2) + assert_equal @org_two, ron.current_organization + + # ron has ProjectAccess to project_1 via access_info_2 (ron_org2_shared_project_1 fixture) + result = scoped_projects(ron) + + assert_includes result, @project_1 + end + + test "scope: org_two non-admin without ProjectAccess does NOT see shared project" do + org_user = users(:organization_user) + switch_org_context!(org_user, @org_two) + assert_equal @org_two, org_user.current_organization + + # organization_user has no ProjectAccess to project_1 in org_two + result = scoped_projects(org_user) + + assert_not_includes result, @project_1 + end + + test "scope: org_one non-admin with ProjectAccess sees own org project" do + ron = users(:ron) + switch_org_context!(ron, @org_one) + assert_equal @org_one, ron.current_organization + + # ron has ProjectAccess to project_1 via access_info_3 (ron_project_1 fixture) + result = scoped_projects(ron) + + assert_includes result, @project_1 + end + + test "scope: org_one non-admin without ProjectAccess does NOT see own org project" do + ron = users(:ron) + switch_org_context!(ron, @org_one) + assert_equal @org_one, ron.current_organization + + # ron does NOT have ProjectAccess to project_2 + result = scoped_projects(ron) + + assert_not_includes result, @project_2 + end + + # --- show? tests --- + + test "show? allows org_one admin to view own project" do + admin = users(:organization_admin) + assert_equal @org_one, admin.current_organization + + assert policy_for(admin, @project_1).apply(:show?) + end + + test "show? allows org_two admin to view shared project (read-only)" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + assert policy_for(admin, @project_1).apply(:show?) + end + + test "show? denies org_two admin for non-shared project" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + assert_not policy_for(admin, @project_2).apply(:show?) + end + + # --- edit?/update?/destroy? tests --- + + test "edit? denies org_two admin for shared project" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + assert_not policy_for(admin, @project_1).apply(:edit?) + end + + test "update? denies org_two admin for shared project" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + assert_not policy_for(admin, @project_1).apply(:update?) + end + + test "destroy? denies org_two admin for shared project" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + + assert_not policy_for(admin, @project_1).apply(:destroy?) + end + + test "edit? allows org_one admin for own project" do + admin = users(:organization_admin) + assert_equal @org_one, admin.current_organization + + assert policy_for(admin, @project_1).apply(:edit?) + end + + test "update? allows org_one admin for own project" do + admin = users(:organization_admin) + assert_equal @org_one, admin.current_organization + + assert policy_for(admin, @project_1).apply(:update?) + end + + test "destroy? allows org_one admin for own project" do + admin = users(:organization_admin) + assert_equal @org_one, admin.current_organization + + assert policy_for(admin, @project_1).apply(:destroy?) + end +end diff --git a/test/policies/workspace/project_share_policy_test.rb b/test/policies/workspace/project_share_policy_test.rb new file mode 100644 index 00000000..e50ffa02 --- /dev/null +++ b/test/policies/workspace/project_share_policy_test.rb @@ -0,0 +1,123 @@ +require "test_helper" + +class Workspace::ProjectSharePolicyTest < ActiveSupport::TestCase + fixtures :all + + setup do + @project_share = project_shares(:project_one_shared_with_org_two) + @org_one = organizations(:organization_one) + @org_two = organizations(:organization_two) + end + + # Helper to build a policy instance for a given user and record + def policy_for(user, record = @project_share) + Workspace::ProjectSharePolicy.new(record, user: user) + end + + # --- show? --- + + test "show? allowed for org_one admin (project owner)" do + admin = users(:organization_admin) + # organization_admin is active in org_one by default + assert_equal @org_one, admin.current_organization + assert policy_for(admin).apply(:show?) + end + + test "show? allowed for org_two admin (guest org)" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + assert policy_for(admin).apply(:show?) + end + + test "show? denied for non-admin user in org_one" do + joe = users(:joe) + assert_equal @org_one, joe.current_organization + assert_not policy_for(joe).apply(:show?) + end + + test "show? denied for non-admin user in org_two" do + ron = users(:ron) + assert_equal @org_two, ron.current_organization + assert_not policy_for(ron).apply(:show?) + end + + # --- update? --- + + test "update? allowed for org_two admin (guest org managing rates)" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + assert policy_for(admin).apply(:update?) + end + + test "update? denied for org_one admin (project owner cannot manage guest rates)" do + admin = users(:organization_admin) + assert_equal @org_one, admin.current_organization + assert_not policy_for(admin).apply(:update?) + end + + test "update? denied for non-admin user in org_two" do + ron = users(:ron) + assert_equal @org_two, ron.current_organization + assert_not policy_for(ron).apply(:update?) + end + + # --- destroy? --- + + test "destroy? allowed for org_one admin (project owner)" do + admin = users(:organization_admin) + assert_equal @org_one, admin.current_organization + assert policy_for(admin).apply(:destroy?) + end + + test "destroy? allowed for org_two admin (guest org)" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + assert policy_for(admin).apply(:destroy?) + end + + test "destroy? denied for non-admin user in org_one" do + joe = users(:joe) + assert_equal @org_one, joe.current_organization + assert_not policy_for(joe).apply(:destroy?) + end + + test "destroy? denied for non-admin user in org_two" do + ron = users(:ron) + assert_equal @org_two, ron.current_organization + assert_not policy_for(ron).apply(:destroy?) + end + + # --- scope --- + + test "scope returns project_shares for admin of org_two (guest org)" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + + policy = Workspace::ProjectSharePolicy.new(user: admin) + scope = policy.apply_scope(ProjectShare.all, type: :relation) + + assert_includes scope, @project_share + end + + test "scope returns project_shares for admin of org_one (project owner)" do + admin = users(:organization_admin) + # Active in org_one by default + + policy = Workspace::ProjectSharePolicy.new(user: admin) + scope = policy.apply_scope(ProjectShare.all, type: :relation) + + assert_includes scope, @project_share + end + + test "scope returns empty for non-admin user" do + joe = users(:joe) + + policy = Workspace::ProjectSharePolicy.new(user: joe) + scope = policy.apply_scope(ProjectShare.all, type: :relation) + + assert_empty scope + end +end diff --git a/test/policies/workspace/project_share_task_rate_policy_test.rb b/test/policies/workspace/project_share_task_rate_policy_test.rb new file mode 100644 index 00000000..4c030076 --- /dev/null +++ b/test/policies/workspace/project_share_task_rate_policy_test.rb @@ -0,0 +1,117 @@ +require "test_helper" + +class Workspace::ProjectShareTaskRatePolicyTest < ActiveSupport::TestCase + fixtures :all + + setup do + @task_rate = project_share_task_rates(:task_rate_one) + @project_share = project_shares(:project_one_shared_with_org_two) + @org_one = organizations(:organization_one) + @org_two = organizations(:organization_two) + end + + # Helper to build a policy instance for a given user and record + def policy_for(user, record = @task_rate) + Workspace::ProjectShareTaskRatePolicy.new(record, user: user) + end + + # --- create? --- + + test "create? allowed for org_two admin (guest org)" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + assert policy_for(admin).apply(:create?) + end + + test "create? denied for org_one admin (project owner)" do + admin = users(:organization_admin) + assert_equal @org_one, admin.current_organization + assert_not policy_for(admin).apply(:create?) + end + + test "create? denied for non-admin user in org_two" do + ron = users(:ron) + assert_equal @org_two, ron.current_organization + assert_not policy_for(ron).apply(:create?) + end + + test "create? denied for non-admin user in org_one" do + joe = users(:joe) + assert_equal @org_one, joe.current_organization + assert_not policy_for(joe).apply(:create?) + end + + # --- update? --- + + test "update? allowed for org_two admin (guest org)" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + assert policy_for(admin).apply(:update?) + end + + test "update? denied for org_one admin (project owner)" do + admin = users(:organization_admin) + assert_equal @org_one, admin.current_organization + assert_not policy_for(admin).apply(:update?) + end + + test "update? denied for non-admin user in org_two" do + ron = users(:ron) + assert_equal @org_two, ron.current_organization + assert_not policy_for(ron).apply(:update?) + end + + # --- destroy? --- + + test "destroy? allowed for org_two admin (guest org)" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + assert_equal @org_two, admin.current_organization + assert policy_for(admin).apply(:destroy?) + end + + test "destroy? denied for org_one admin (project owner)" do + admin = users(:organization_admin) + assert_equal @org_one, admin.current_organization + assert_not policy_for(admin).apply(:destroy?) + end + + test "destroy? denied for non-admin user in org_two" do + ron = users(:ron) + assert_equal @org_two, ron.current_organization + assert_not policy_for(ron).apply(:destroy?) + end + + # --- scope --- + + test "scope returns task rates for admin of org_two (guest org)" do + admin = users(:organization_admin) + switch_org_context!(admin, @org_two) + + policy = Workspace::ProjectShareTaskRatePolicy.new(user: admin) + scope = policy.apply_scope(ProjectShareTaskRate.all, type: :relation) + + assert_includes scope, @task_rate + end + + test "scope returns empty for admin of org_one (project owner has no task rates)" do + admin = users(:organization_admin) + # Active in org_one by default + + policy = Workspace::ProjectShareTaskRatePolicy.new(user: admin) + scope = policy.apply_scope(ProjectShareTaskRate.all, type: :relation) + + assert_not_includes scope, @task_rate + end + + test "scope returns empty for non-admin user" do + joe = users(:joe) + + policy = Workspace::ProjectShareTaskRatePolicy.new(user: joe) + scope = policy.apply_scope(ProjectShareTaskRate.all, type: :relation) + + assert_empty scope + end +end diff --git a/test/services/disconnect_project_share_service_test.rb b/test/services/disconnect_project_share_service_test.rb new file mode 100644 index 00000000..b1e1bdb0 --- /dev/null +++ b/test/services/disconnect_project_share_service_test.rb @@ -0,0 +1,119 @@ +require "test_helper" + +class DisconnectProjectShareServiceTest < ActiveSupport::TestCase + def setup + @project_share = project_shares(:project_one_shared_with_org_two) + @project = projects(:project_1) + @guest_org = organizations(:organization_two) + end + + test "destroys the ProjectShare record" do + assert_difference "ProjectShare.count", -1 do + DisconnectProjectShareService.new(project_share: @project_share).call + end + + assert_nil ProjectShare.find_by(id: @project_share.id) + end + + test "cascades to destroy ProjectShareTaskRate records" do + assert project_share_task_rates(:task_rate_one).present? + + assert_difference "ProjectShareTaskRate.count", -1 do + DisconnectProjectShareService.new(project_share: @project_share).call + end + end + + test "destroys all ProjectAccess records for guest org users on this project" do + # ron_org2_shared_project_1 is ron's org_two access to project_1 + ron_org2_access = project_accesses(:ron_org2_shared_project_1) + assert ron_org2_access.present? + + DisconnectProjectShareService.new(project_share: @project_share).call + + assert_nil ProjectAccess.find_by(id: ron_org2_access.id) + end + + test "does not destroy ProjectAccess records for the owning org users" do + # ron_project_1 is ron's org_one access to project_1 (via access_info_3) + ron_org1_access = project_accesses(:ron_project_1) + spectator_access = project_accesses(:spectator_project_1) + + DisconnectProjectShareService.new(project_share: @project_share).call + + assert ProjectAccess.find_by(id: ron_org1_access.id), "Ron's org_one access should be preserved" + assert ProjectAccess.find_by(id: spectator_access.id), "Spectator's access should be preserved" + end + + test "cancels pending ProjectInvitation records for known guest org users" do + # Create a user who belongs only to org_two (not org_one, which owns the project) + guest_only_user = User.create!( + email: "guest_only@example.com", + first_name: "Guest", + last_name: "Only", + password: "password123", + locale: "en" + ) + AccessInfo.create!( + user: guest_only_user, + organization: @guest_org, + role: :organization_user + ) + + # Create a pending invitation for this guest-org-only user + invitation = ProjectInvitation.create!( + project: @project, + invited_email: guest_only_user.email, + invited_by: users(:organization_admin), + invited_at: Time.current + ) + + assert invitation.pending? + + DisconnectProjectShareService.new(project_share: @project_share).call + + invitation.reload + assert invitation.rejected?, "Pending invitation for guest org user should be cancelled" + end + + test "does not cancel invitations for users not in the guest org" do + # Create a pending invitation for someone not in org_two + invitation = ProjectInvitation.create!( + project: @project, + invited_email: "outsider@example.com", + invited_by: users(:organization_admin), + invited_at: Time.current + ) + + DisconnectProjectShareService.new(project_share: @project_share).call + + invitation.reload + assert invitation.pending?, "Invitation for non-guest-org user should remain pending" + end + + test "preserves TimeReg records" do + time_reg_count = TimeReg.count + + DisconnectProjectShareService.new(project_share: @project_share).call + + assert_equal time_reg_count, TimeReg.count, "TimeReg records should be preserved" + end + + test "works when called by org_one admin disconnecting a guest" do + # This simulates org_one (project owner) disconnecting org_two + assert_nothing_raised do + DisconnectProjectShareService.new(project_share: @project_share).call + end + + assert_nil ProjectShare.find_by(id: @project_share.id) + end + + test "works when called by org_two admin disconnecting self" do + # This simulates org_two (guest) disconnecting themselves + # The service should work the same regardless of who initiates it + assert_nothing_raised do + DisconnectProjectShareService.new(project_share: @project_share).call + end + + assert_nil ProjectShare.find_by(id: @project_share.id) + end +end diff --git a/test/services/project_invitation_service_test.rb b/test/services/project_invitation_service_test.rb index 1cd4de49..daf37494 100644 --- a/test/services/project_invitation_service_test.rb +++ b/test/services/project_invitation_service_test.rb @@ -41,10 +41,47 @@ def setup assert_equal @external_org, invitation.accepted_as_access_info.organization # Test 4: Invited user has access to the project + invited_user = User.find_by(email: @external_email) project_access = ProjectAccess.joins(:access_info) - .where(project: @project, access_infos: { organization: @external_org }) + .where(project: @project, access_infos: { organization: @external_org, user: invited_user }) .first assert project_access, "ProjectAccess should be created" assert_equal @external_email, project_access.user.email end + + test "accepting invitation creates ProjectShare when user org differs from project org" do + # Use a different project to avoid fixture collision + project = projects(:project_2) + invitation = ProjectInvitation.create!( + project: project, + invited_email: @external_email, + invited_by: @admin_user, + invited_at: Time.current + ) + + assert_difference "ProjectShare.count", 1 do + ProjectInvitationService.accept_invitation(invitation.invitation_token, @external_org) + end + + project_share = ProjectShare.find_by(project: project, organization: @external_org) + assert project_share, "ProjectShare should be created" + assert_equal 0, project_share.rate + end + + test "accepting invitation reuses existing ProjectShare if one already exists" do + # project_1 already has a ProjectShare with organization_two via fixtures + invitation = ProjectInvitation.create!( + project: @project, + invited_email: @external_email, + invited_by: @admin_user, + invited_at: Time.current + ) + + assert_no_difference "ProjectShare.count" do + ProjectInvitationService.accept_invitation(invitation.invitation_token, @external_org) + end + + project_share = ProjectShare.find_by(project: @project, organization: @external_org) + assert project_share, "Existing ProjectShare should still exist" + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 12bd5dde..21f69fac 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,20 @@ +require "simplecov" +require "simplecov-lcov" + +SimpleCov::Formatter::LcovFormatter.config do |c| + c.report_with_single_file = true + c.single_report_path = "coverage/lcov/app.lcov" +end + +SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::LcovFormatter +]) + +SimpleCov.start "rails" do + enable_coverage :branch +end + ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" @@ -14,7 +31,11 @@ class ActiveSupport::TestCase # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all - # Add more helper methods to be used by all tests here... + # Switch a user's active organization context for cross-org testing + def switch_org_context!(user, organization) + user.access_infos.update_all(active: false) + user.access_infos.find_by(organization: organization).update!(active: true) + end end diff --git a/test/views/components/combobox_component/content_test.rb b/test/views/components/combobox_component/content_test.rb new file mode 100644 index 00000000..05f71c68 --- /dev/null +++ b/test/views/components/combobox_component/content_test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "test_helper" + +module ComboboxComponent + class ContentTest < ActiveSupport::TestCase + test "renders content wrapper with combobox data attributes" do + output = Content.new(wrapper_id: "test-wrapper").call { "inner content" } + + assert_includes output, "inner content" + assert_includes output, 'data-controller="combobox-content"' + assert_includes output, 'data-wrapper-id="test-wrapper"' + end + end +end