Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
abfccf9
Upgrade to ruby 3.3.x
kaospr Mar 13, 2026
dd79562
Add shared projects design spec
kaospr Mar 17, 2026
3678a69
Update shared projects spec after review
kaospr Mar 17, 2026
ae9079d
Fix blocking issues in shared projects spec
kaospr Mar 17, 2026
3e2bfc9
Fix lint
kaospr Mar 17, 2026
b6728d7
Setup local CI
kaospr Mar 17, 2026
6d31d16
feat: add ProjectShare model and migration
kaospr Mar 17, 2026
7d7a4e0
feat: add ProjectShareTaskRate model and migration
kaospr Mar 17, 2026
d2c7d25
feat: auto-create ProjectShare on invitation acceptance
kaospr Mar 17, 2026
0f6682a
feat: migrate ProjectShareTaskRate on AssignedTask rate change
kaospr Mar 17, 2026
b9f94e7
feat: add ProjectSharePolicy
kaospr Mar 17, 2026
a9f7abc
feat: add ProjectShareTaskRatePolicy
kaospr Mar 17, 2026
267fc3c
feat: extend ProjectPolicy scope for shared projects
kaospr Mar 17, 2026
ddcce7c
feat: extend TimeRegPolicy for shared projects
kaospr Mar 17, 2026
4a37aac
feat: extend TaskPolicy scope for shared project tasks
kaospr Mar 17, 2026
deea32f
feat: add org-context-aware rate resolution to TimeReg
kaospr Mar 17, 2026
51edfc9
feat: org-context-aware rate resolution in reports
kaospr Mar 17, 2026
4c8f67c
feat: add project_shares routes
kaospr Mar 17, 2026
82321ae
feat: add DisconnectProjectShareService
kaospr Mar 17, 2026
0a3020b
feat: add Workspace::ProjectSharesController
kaospr Mar 17, 2026
5335aaf
feat: add Workspace::ProjectShareTaskRatesController
kaospr Mar 17, 2026
0d7c424
feat: update ProjectsController for shared project context
kaospr Mar 17, 2026
ad9215b
feat: update TimeRegsController for shared projects
kaospr Mar 17, 2026
dc9512a
Add shared project indicator to project list
kaospr Mar 17, 2026
30e6ab5
Add "Shared with" section to project show page
kaospr Mar 17, 2026
af97b2e
Read-only project show for guest org admin
kaospr Mar 17, 2026
959c300
Add rate management UI for guest org admin
kaospr Mar 17, 2026
7a62eed
Add disconnect action views and project shares index
kaospr Mar 17, 2026
0043c3e
fix: allow guest org users to create time_regs on shared projects
kaospr Mar 17, 2026
b11815d
fix: include shared project time_regs in :own scope
kaospr Mar 17, 2026
9cea338
refactor: address code review findings
kaospr Mar 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,5 @@

.DS_Store
/dump.rdb

/coverage
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.2.2
3.3
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
ruby 3.2.2
ruby 3.3
nodejs 21.5.0
yarn 1.22.19
7 changes: 6 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -123,3 +126,5 @@ gem "tailwind_merge", "~> 1.2"
gem "wicked", "~> 2.0"

gem "fast-mcp"

gem "brakeman", "~> 8.0"
27 changes: 26 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -431,6 +452,7 @@ DEPENDENCIES
action_policy
activerecord-import
bootsnap
brakeman (~> 8.0)
capybara
cssbundling-rails
debug
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion app/controllers/reports_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions app/controllers/time_regs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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"
Expand Down
46 changes: 46 additions & 0 deletions app/controllers/workspace/project_share_task_rates_controller.rb
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions app/controllers/workspace/project_shares_controller.rb
Original file line number Diff line number Diff line change
@@ -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
14 changes: 13 additions & 1 deletion app/controllers/workspace/projects_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
3 changes: 2 additions & 1 deletion app/models/assigned_task.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/models/organization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions app/models/project.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions app/models/project_invitation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading