diff --git a/Gemfile b/Gemfile index 9edf8275..541825c1 100644 --- a/Gemfile +++ b/Gemfile @@ -121,3 +121,5 @@ gem "phlex-rails", "~> 2.0" gem "tailwind_merge", "~> 1.2" gem "wicked", "~> 2.0" + +gem "fast-mcp" diff --git a/Gemfile.lock b/Gemfile.lock index cbb219f7..4d3f1caf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,9 +129,45 @@ GEM dotenv (= 2.8.1) railties (>= 3.2) drb (2.2.3) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.2.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.3.1) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-schema (1.16.0) + concurrent-ruby (~> 1.0) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.6) + dry-types (~> 1.9, >= 1.9.1) + zeitwerk (~> 2.6) + dry-types (1.9.1) + bigdecimal (>= 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) erubi (1.12.0) et-orbi (1.2.11) tzinfo + fast-mcp (1.6.0) + addressable (~> 2.8) + base64 + dry-schema (~> 1.14) + json (~> 2.0) + mime-types (~> 3.4) + rack (>= 2.0, < 4.0) ffi (1.16.3) fugit (1.11.0) et-orbi (~> 1, >= 1.2.11) @@ -176,6 +212,10 @@ GEM marcel (1.0.4) matrix (0.4.2) method_source (1.1.0) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2026.0224) mini_mime (1.1.2) minitest (5.22.3) msgpack (1.8.0) @@ -398,6 +438,7 @@ DEPENDENCIES devise_invitable (~> 2.0.9) discard dotenv-rails (~> 2.1, >= 2.1.1) + fast-mcp hotwire-livereload invisible_captcha jbuilder diff --git a/app/tools/application_tool.rb b/app/tools/application_tool.rb new file mode 100644 index 00000000..3a66d8da --- /dev/null +++ b/app/tools/application_tool.rb @@ -0,0 +1,67 @@ +class ApplicationTool < ActionTool::Base + include ActionPolicy::Behaviour + + authorize :user, through: :current_user + + private + + def current_user + @current_user ||= begin + token = (headers&.dig("Authorization") || headers&.dig("authorization"))&.delete_prefix("Bearer ") + User.find_by_api_token(token) + end + end + + def resolve_organization(organization_id = nil) + raise "Unauthorized" unless current_user + + if organization_id.present? + org = Organization.find(organization_id) + access = current_user.access_info(org) + raise ActiveRecord::RecordNotFound, "Organization not found or no access" unless access + else + access = current_user.access_info + raise "No default organization. Pass organization_id argument." unless access + end + + current_user.define_singleton_method(:access_info) do |o = nil| + return current_user.access_infos.find_by(organization: o) if o + access + end + + access.organization + end + + def format_error(exception) + case exception + when ActiveRecord::RecordNotFound + JSON.generate({ error: "Not found" }) + when ActiveRecord::RecordInvalid + JSON.generate({ error: exception.record.errors.full_messages.join(", ") }) + when ActionPolicy::Unauthorized + JSON.generate({ error: "Forbidden" }) + else + JSON.generate({ error: exception.message }) + end + end + + def format_time_reg(tr) + { + id: tr.id, + notes: tr.notes, + minutes: tr.minutes, + date_worked: tr.date_worked, + assigned_task_id: tr.assigned_task_id, + user_id: tr.user_id, + start_time: tr.start_time, + created_at: tr.created_at, + updated_at: tr.updated_at, + current_minutes: tr.current_minutes, + active: tr.active?, + task_name: tr.task&.name, + project_id: tr.project&.id, + project_name: tr.project&.name, + client_name: tr.client&.name + } + end +end diff --git a/app/tools/create_client_tool.rb b/app/tools/create_client_tool.rb new file mode 100644 index 00000000..da99585d --- /dev/null +++ b/app/tools/create_client_tool.rb @@ -0,0 +1,27 @@ +class CreateClientTool < ApplicationTool + description "Create a new client in the organization" + + arguments do + required(:name).filled(:string).description("Client name (2-60 characters)") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(name:, organization_id: nil) + org = resolve_organization(organization_id) + + client = Client.new(name: name) + client.organization = org + authorize! client, with: ClientPolicy, to: :create? + client.save! + + JSON.generate({ + id: client.id, + name: client.name, + organization_id: client.organization_id, + created_at: client.created_at, + updated_at: client.updated_at + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/create_project_tool.rb b/app/tools/create_project_tool.rb new file mode 100644 index 00000000..6a7b22e2 --- /dev/null +++ b/app/tools/create_project_tool.rb @@ -0,0 +1,51 @@ +class CreateProjectTool < ApplicationTool + description "Create a new project" + + arguments do + required(:name).filled(:string).description("Project name (2-60 characters)") + required(:client_id).filled(:integer).description("Client ID to associate with") + optional(:description).filled(:string).description("Project description") + optional(:billable).filled(:bool).description("Whether the project is billable (default: false)") + optional(:rate_currency).filled(:string).description("Hourly rate in currency format (e.g. '1,25' for 1.25)") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(name:, client_id:, description: nil, billable: nil, rate_currency: nil, organization_id: nil) + resolve_organization(organization_id) + + project = Project.new(name: name, client_id: client_id) + project.description = description if description + project.billable = billable unless billable.nil? + project.rate_currency = rate_currency if rate_currency + project.onboarding = true # Skip assigned task validation for creation via tool + + authorize! project, with: ProjectPolicy, to: :create? + project.save! + + assigned_tasks = project.active_assigned_tasks.includes(:task) + + JSON.generate({ + id: project.id, + name: project.name, + description: project.description, + billable: project.billable, + rate: project.rate, + rate_currency: project.rate_currency, + client_id: project.client_id, + client_name: project.client.name, + created_at: project.created_at, + updated_at: project.updated_at, + assigned_tasks: assigned_tasks.map { |at| + { + id: at.id, + task_id: at.task_id, + task_name: at.task.name, + rate: at.rate, + rate_currency: at.rate_currency + } + } + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/create_time_reg_tool.rb b/app/tools/create_time_reg_tool.rb new file mode 100644 index 00000000..02a98764 --- /dev/null +++ b/app/tools/create_time_reg_tool.rb @@ -0,0 +1,28 @@ +class CreateTimeRegTool < ApplicationTool + description "Create a new time registration" + + arguments do + required(:assigned_task_id).filled(:integer).description("Assigned task ID") + required(:minutes).filled(:integer).description("Minutes worked (0-1440)") + required(:date_worked).filled(:string).description("Date worked (YYYY-MM-DD)") + optional(:notes).filled(:string).description("Notes about the work done (max 255 characters)") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(assigned_task_id:, minutes:, date_worked:, notes: nil, organization_id: nil) + resolve_organization(organization_id) + + time_reg = current_user.time_regs.new( + assigned_task_id: assigned_task_id, + minutes: minutes, + date_worked: date_worked, + notes: notes + ) + authorize! time_reg, with: TimeRegPolicy, to: :create? + time_reg.save! + + JSON.generate(format_time_reg(time_reg)) + rescue => e + format_error(e) + end +end diff --git a/app/tools/delete_client_tool.rb b/app/tools/delete_client_tool.rb new file mode 100644 index 00000000..93fe4de0 --- /dev/null +++ b/app/tools/delete_client_tool.rb @@ -0,0 +1,20 @@ +class DeleteClientTool < ApplicationTool + description "Delete a client (soft delete)" + + arguments do + required(:id).filled(:integer).description("Client ID") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(id:, organization_id: nil) + resolve_organization(organization_id) + + client = authorized_scope(Client, type: :relation, with: ClientPolicy).find(id) + authorize! client, with: ClientPolicy, to: :destroy? + client.discard! + + JSON.generate({ status: "deleted", id: client.id }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/delete_project_tool.rb b/app/tools/delete_project_tool.rb new file mode 100644 index 00000000..7f0a6f98 --- /dev/null +++ b/app/tools/delete_project_tool.rb @@ -0,0 +1,20 @@ +class DeleteProjectTool < ApplicationTool + description "Delete a project (soft delete)" + + arguments do + required(:id).filled(:integer).description("Project ID") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(id:, organization_id: nil) + resolve_organization(organization_id) + + project = authorized_scope(Project, type: :relation, with: ProjectPolicy).find(id) + authorize! project, with: ProjectPolicy, to: :destroy? + project.discard! + + JSON.generate({ status: "deleted", id: project.id }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/delete_time_reg_tool.rb b/app/tools/delete_time_reg_tool.rb new file mode 100644 index 00000000..5ed2c804 --- /dev/null +++ b/app/tools/delete_time_reg_tool.rb @@ -0,0 +1,20 @@ +class DeleteTimeRegTool < ApplicationTool + description "Delete a time registration (soft delete)" + + arguments do + required(:id).filled(:integer).description("Time registration ID") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(id:, organization_id: nil) + resolve_organization(organization_id) + + time_reg = authorized_scope(TimeReg, type: :relation, as: :own).find(id) + authorize! time_reg, with: TimeRegPolicy, to: :destroy? + time_reg.discard! + + JSON.generate({ status: "deleted", id: time_reg.id }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/get_current_user_tool.rb b/app/tools/get_current_user_tool.rb new file mode 100644 index 00000000..50eb3a2a --- /dev/null +++ b/app/tools/get_current_user_tool.rb @@ -0,0 +1,28 @@ +class GetCurrentUserTool < ApplicationTool + description "Get the currently authenticated user's information" + + arguments do + end + + def call + raise "Unauthorized" unless current_user + + user = current_user + org = user.current_organization + + JSON.generate({ + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + locale: user.locale, + name: user.name, + created_at: user.created_at, + updated_at: user.updated_at, + has_api_token: user.api_token_digest.present?, + current_organization: org ? { id: org.id, name: org.name } : nil + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/get_reports_tool.rb b/app/tools/get_reports_tool.rb new file mode 100644 index 00000000..e1b2b45c --- /dev/null +++ b/app/tools/get_reports_tool.rb @@ -0,0 +1,62 @@ +class GetReportsTool < ApplicationTool + description "Get aggregated time registration reports" + + arguments do + required(:start_date).filled(:string).description("Report start date (YYYY-MM-DD)") + required(:end_date).filled(:string).description("Report end date (YYYY-MM-DD)") + optional(:client_ids).filled(:string).description("Comma-separated client IDs to filter by") + optional(:project_ids).filled(:string).description("Comma-separated project IDs to filter by") + optional(:user_ids).filled(:string).description("Comma-separated user IDs to filter by") + optional(:task_ids).filled(:string).description("Comma-separated task IDs to filter by") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(start_date:, end_date:, client_ids: nil, project_ids: nil, user_ids: nil, task_ids: nil, organization_id: nil) + resolve_organization(organization_id) + authorize! with: ReportPolicy, to: :index? + + scope = authorized_scope(TimeReg, type: :relation, with: TimeRegPolicy) + scope = scope.between_dates(start_date, end_date) + scope = scope.by_clients(client_ids.split(",").map(&:strip)) if client_ids + scope = scope.by_projects(project_ids.split(",").map(&:strip)) if project_ids + scope = scope.by_users(user_ids.split(",").map(&:strip)) if user_ids + scope = scope.by_tasks(task_ids.split(",").map(&:strip)) if task_ids + + total_minutes = scope.sum(:minutes) + total_entries = scope.count + + by_project = scope + .joins(assigned_task: { project: :client }) + .group("projects.id", "projects.name", "clients.name") + .select("projects.id AS project_id, projects.name AS project_name, clients.name AS client_name, SUM(time_regs.minutes) AS total_minutes, COUNT(time_regs.id) AS total_entries") + + by_user = scope + .joins(:user) + .group("users.id", "users.first_name", "users.last_name") + .select("users.id AS user_id, users.first_name, users.last_name, SUM(time_regs.minutes) AS total_minutes, COUNT(time_regs.id) AS total_entries") + + JSON.generate({ + total_minutes: total_minutes, + total_entries: total_entries, + by_project: by_project.map { |row| + { + project_id: row.project_id, + project_name: row.project_name, + client_name: row.client_name, + total_minutes: row.total_minutes.to_i, + total_entries: row.total_entries.to_i + } + }, + by_user: by_user.map { |row| + { + user_id: row.user_id, + user_name: "#{row.first_name} #{row.last_name}".strip, + total_minutes: row.total_minutes.to_i, + total_entries: row.total_entries.to_i + } + } + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/list_clients_tool.rb b/app/tools/list_clients_tool.rb new file mode 100644 index 00000000..a65927b9 --- /dev/null +++ b/app/tools/list_clients_tool.rb @@ -0,0 +1,25 @@ +class ListClientsTool < ApplicationTool + description "List all clients in the organization" + + arguments do + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(organization_id: nil) + resolve_organization(organization_id) + authorize! with: ClientPolicy, to: :index? + + clients = authorized_scope(Client, type: :relation, with: ClientPolicy).order(:name) + JSON.generate(clients.map { |client| + { + id: client.id, + name: client.name, + organization_id: client.organization_id, + created_at: client.created_at, + updated_at: client.updated_at + } + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/list_organizations_tool.rb b/app/tools/list_organizations_tool.rb new file mode 100644 index 00000000..233510da --- /dev/null +++ b/app/tools/list_organizations_tool.rb @@ -0,0 +1,24 @@ +class ListOrganizationsTool < ApplicationTool + description "List all organizations the authenticated user belongs to" + + arguments do + optional(:organization_id).filled(:integer).description("Organization ID (not used for this tool, included for consistency)") + end + + def call(organization_id: nil) + raise "Unauthorized" unless current_user + + orgs = current_user.organizations + JSON.generate(orgs.map { |org| + { + id: org.id, + name: org.name, + currency: org.currency, + created_at: org.created_at, + updated_at: org.updated_at + } + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/list_projects_tool.rb b/app/tools/list_projects_tool.rb new file mode 100644 index 00000000..500dd326 --- /dev/null +++ b/app/tools/list_projects_tool.rb @@ -0,0 +1,30 @@ +class ListProjectsTool < ApplicationTool + description "List all projects in the organization" + + arguments do + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(organization_id: nil) + resolve_organization(organization_id) + authorize! with: ProjectPolicy, to: :index? + + projects = authorized_scope(Project, type: :relation, with: ProjectPolicy).includes(:client).order(:name) + JSON.generate(projects.map { |project| + { + id: project.id, + name: project.name, + description: project.description, + billable: project.billable, + rate: project.rate, + rate_currency: project.rate_currency, + client_id: project.client_id, + client_name: project.client.name, + created_at: project.created_at, + updated_at: project.updated_at + } + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/list_tasks_tool.rb b/app/tools/list_tasks_tool.rb new file mode 100644 index 00000000..b9a08f62 --- /dev/null +++ b/app/tools/list_tasks_tool.rb @@ -0,0 +1,25 @@ +class ListTasksTool < ApplicationTool + description "List all tasks in the organization" + + arguments do + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(organization_id: nil) + resolve_organization(organization_id) + authorize! with: TaskPolicy, to: :index? + + tasks = authorized_scope(Task, type: :relation, with: TaskPolicy).order(:name) + JSON.generate(tasks.map { |task| + { + id: task.id, + name: task.name, + organization_id: task.organization_id, + created_at: task.created_at, + updated_at: task.updated_at + } + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/list_time_regs_tool.rb b/app/tools/list_time_regs_tool.rb new file mode 100644 index 00000000..58e64f83 --- /dev/null +++ b/app/tools/list_time_regs_tool.rb @@ -0,0 +1,44 @@ +class ListTimeRegsTool < ApplicationTool + description "List time registrations for the current user" + + arguments do + optional(:date).filled(:string).description("Filter by specific date (YYYY-MM-DD)") + optional(:start_date).filled(:string).description("Filter start date (YYYY-MM-DD)") + optional(:end_date).filled(:string).description("Filter end date (YYYY-MM-DD)") + optional(:project_id).filled(:integer).description("Filter by project ID") + optional(:page).filled(:integer).description("Page number (default: 1)") + optional(:per_page).filled(:integer).description("Items per page (default: 25)") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(date: nil, start_date: nil, end_date: nil, project_id: nil, page: 1, per_page: 25, organization_id: nil) + resolve_organization(organization_id) + authorize! with: TimeRegPolicy, to: :index? + + scope = authorized_scope(TimeReg, type: :relation, as: :own) + scope = scope.between_dates(start_date, end_date) if start_date && end_date + scope = scope.on_date(Date.parse(date)) if date + scope = scope.by_projects(project_id) if project_id + + scope = scope.includes(assigned_task: [ :task, { project: :client } ]).order(date_worked: :desc, created_at: :desc) + + total_count = scope.count + per_page = [ [ per_page.to_i, 1 ].max, 100 ].min + page = [ page.to_i, 1 ].max + total_pages = (total_count.to_f / per_page).ceil + offset = (page - 1) * per_page + + time_regs = scope.offset(offset).limit(per_page) + + JSON.generate({ + time_regs: time_regs.map { |tr| format_time_reg(tr) }, + pagination: { + current_page: page, + total_pages: total_pages, + total_count: total_count + } + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/list_users_tool.rb b/app/tools/list_users_tool.rb new file mode 100644 index 00000000..3aa4debc --- /dev/null +++ b/app/tools/list_users_tool.rb @@ -0,0 +1,33 @@ +class ListUsersTool < ApplicationTool + description "List all users in the organization" + + arguments do + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(organization_id: nil) + resolve_organization(organization_id) + authorize! with: UserPolicy, to: :index? + + users = authorized_scope(User, type: :relation, with: UserPolicy).onboarded.ordered_by_name + + JSON.generate(users.map { |user| format_user(user) }) + rescue => e + format_error(e) + end + + private + + def format_user(user) + { + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + locale: user.locale, + name: user.name, + created_at: user.created_at, + updated_at: user.updated_at + } + end +end diff --git a/app/tools/show_client_tool.rb b/app/tools/show_client_tool.rb new file mode 100644 index 00000000..024c1e67 --- /dev/null +++ b/app/tools/show_client_tool.rb @@ -0,0 +1,25 @@ +class ShowClientTool < ApplicationTool + description "Show details of a specific client" + + arguments do + required(:id).filled(:integer).description("Client ID") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(id:, organization_id: nil) + resolve_organization(organization_id) + + client = authorized_scope(Client, type: :relation, with: ClientPolicy).find(id) + authorize! client, with: ClientPolicy, to: :show? + + JSON.generate({ + id: client.id, + name: client.name, + organization_id: client.organization_id, + created_at: client.created_at, + updated_at: client.updated_at + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/show_organization_tool.rb b/app/tools/show_organization_tool.rb new file mode 100644 index 00000000..50a7ea80 --- /dev/null +++ b/app/tools/show_organization_tool.rb @@ -0,0 +1,23 @@ +class ShowOrganizationTool < ApplicationTool + description "Show details of a specific organization the user belongs to" + + arguments do + required(:id).filled(:integer).description("Organization ID") + optional(:organization_id).filled(:integer).description("Organization ID (not used for this tool, included for consistency)") + end + + def call(id:, organization_id: nil) + raise "Unauthorized" unless current_user + + org = current_user.organizations.find(id) + JSON.generate({ + id: org.id, + name: org.name, + currency: org.currency, + created_at: org.created_at, + updated_at: org.updated_at + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/show_project_tool.rb b/app/tools/show_project_tool.rb new file mode 100644 index 00000000..8499191d --- /dev/null +++ b/app/tools/show_project_tool.rb @@ -0,0 +1,41 @@ +class ShowProjectTool < ApplicationTool + description "Show details of a specific project including assigned tasks" + + arguments do + required(:id).filled(:integer).description("Project ID") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(id:, organization_id: nil) + resolve_organization(organization_id) + + project = authorized_scope(Project, type: :relation, with: ProjectPolicy).find(id) + authorize! project, with: ProjectPolicy, to: :show? + + assigned_tasks = project.active_assigned_tasks.includes(:task) + + JSON.generate({ + id: project.id, + name: project.name, + description: project.description, + billable: project.billable, + rate: project.rate, + rate_currency: project.rate_currency, + client_id: project.client_id, + client_name: project.client.name, + created_at: project.created_at, + updated_at: project.updated_at, + assigned_tasks: assigned_tasks.map { |at| + { + id: at.id, + task_id: at.task_id, + task_name: at.task.name, + rate: at.rate, + rate_currency: at.rate_currency + } + } + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/show_task_tool.rb b/app/tools/show_task_tool.rb new file mode 100644 index 00000000..181c0ede --- /dev/null +++ b/app/tools/show_task_tool.rb @@ -0,0 +1,25 @@ +class ShowTaskTool < ApplicationTool + description "Show details of a specific task" + + arguments do + required(:id).filled(:integer).description("Task ID") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(id:, organization_id: nil) + resolve_organization(organization_id) + + task = authorized_scope(Task, type: :relation, with: TaskPolicy).find(id) + authorize! task, with: TaskPolicy, to: :show? + + JSON.generate({ + id: task.id, + name: task.name, + organization_id: task.organization_id, + created_at: task.created_at, + updated_at: task.updated_at + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/show_time_reg_tool.rb b/app/tools/show_time_reg_tool.rb new file mode 100644 index 00000000..23c911c8 --- /dev/null +++ b/app/tools/show_time_reg_tool.rb @@ -0,0 +1,19 @@ +class ShowTimeRegTool < ApplicationTool + description "Show details of a specific time registration" + + arguments do + required(:id).filled(:integer).description("Time registration ID") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(id:, organization_id: nil) + resolve_organization(organization_id) + + time_reg = authorized_scope(TimeReg, type: :relation, as: :own).find(id) + authorize! time_reg, with: TimeRegPolicy, to: :show? + + JSON.generate(format_time_reg(time_reg)) + rescue => e + format_error(e) + end +end diff --git a/app/tools/show_user_tool.rb b/app/tools/show_user_tool.rb new file mode 100644 index 00000000..32ca55ad --- /dev/null +++ b/app/tools/show_user_tool.rb @@ -0,0 +1,28 @@ +class ShowUserTool < ApplicationTool + description "Show details of a specific user" + + arguments do + required(:id).filled(:integer).description("User ID") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(id:, organization_id: nil) + resolve_organization(organization_id) + + user = authorized_scope(User, type: :relation, with: UserPolicy).find(id) + authorize! user, with: UserPolicy, to: :show? + + JSON.generate({ + id: user.id, + email: user.email, + first_name: user.first_name, + last_name: user.last_name, + locale: user.locale, + name: user.name, + created_at: user.created_at, + updated_at: user.updated_at + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/toggle_timer_tool.rb b/app/tools/toggle_timer_tool.rb new file mode 100644 index 00000000..4f4cab1f --- /dev/null +++ b/app/tools/toggle_timer_tool.rb @@ -0,0 +1,20 @@ +class ToggleTimerTool < ApplicationTool + description "Toggle the timer on a time registration (start/stop)" + + arguments do + required(:time_reg_id).filled(:integer).description("Time registration ID") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(time_reg_id:, organization_id: nil) + resolve_organization(organization_id) + + time_reg = authorized_scope(TimeReg, type: :relation, as: :own).find(time_reg_id) + authorize! time_reg, with: TimeRegPolicy, to: :toggle_active? + time_reg.toggle_active + + JSON.generate(format_time_reg(time_reg)) + rescue => e + format_error(e) + end +end diff --git a/app/tools/update_client_tool.rb b/app/tools/update_client_tool.rb new file mode 100644 index 00000000..e3acbdcd --- /dev/null +++ b/app/tools/update_client_tool.rb @@ -0,0 +1,27 @@ +class UpdateClientTool < ApplicationTool + description "Update an existing client" + + arguments do + required(:id).filled(:integer).description("Client ID") + required(:name).filled(:string).description("New client name (2-60 characters)") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(id:, name:, organization_id: nil) + resolve_organization(organization_id) + + client = authorized_scope(Client, type: :relation, with: ClientPolicy).find(id) + authorize! client, with: ClientPolicy, to: :update? + client.update!(name: name) + + JSON.generate({ + id: client.id, + name: client.name, + organization_id: client.organization_id, + created_at: client.created_at, + updated_at: client.updated_at + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/update_project_tool.rb b/app/tools/update_project_tool.rb new file mode 100644 index 00000000..1edcb48f --- /dev/null +++ b/app/tools/update_project_tool.rb @@ -0,0 +1,54 @@ +class UpdateProjectTool < ApplicationTool + description "Update an existing project" + + arguments do + required(:id).filled(:integer).description("Project ID") + optional(:name).filled(:string).description("Project name (2-60 characters)") + optional(:description).filled(:string).description("Project description") + optional(:billable).filled(:bool).description("Whether the project is billable") + optional(:rate_currency).filled(:string).description("Hourly rate in currency format (e.g. '1,25' for 1.25)") + optional(:client_id).filled(:integer).description("Client ID to associate with") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(id:, name: nil, description: nil, billable: nil, rate_currency: nil, client_id: nil, organization_id: nil) + resolve_organization(organization_id) + + project = authorized_scope(Project, type: :relation, with: ProjectPolicy).find(id) + authorize! project, with: ProjectPolicy, to: :update? + + attrs = {} + attrs[:name] = name if name + attrs[:description] = description if description + attrs[:billable] = billable unless billable.nil? + attrs[:client_id] = client_id if client_id + project.rate_currency = rate_currency if rate_currency + project.update!(attrs) + + assigned_tasks = project.active_assigned_tasks.includes(:task) + + JSON.generate({ + id: project.id, + name: project.name, + description: project.description, + billable: project.billable, + rate: project.rate, + rate_currency: project.rate_currency, + client_id: project.client_id, + client_name: project.client.name, + created_at: project.created_at, + updated_at: project.updated_at, + assigned_tasks: assigned_tasks.map { |at| + { + id: at.id, + task_id: at.task_id, + task_name: at.task.name, + rate: at.rate, + rate_currency: at.rate_currency + } + } + }) + rescue => e + format_error(e) + end +end diff --git a/app/tools/update_time_reg_tool.rb b/app/tools/update_time_reg_tool.rb new file mode 100644 index 00000000..55f663fc --- /dev/null +++ b/app/tools/update_time_reg_tool.rb @@ -0,0 +1,31 @@ +class UpdateTimeRegTool < ApplicationTool + description "Update an existing time registration" + + arguments do + required(:id).filled(:integer).description("Time registration ID") + optional(:minutes).filled(:integer).description("Minutes worked (0-1440)") + optional(:notes).filled(:string).description("Notes about the work done (max 255 characters)") + optional(:date_worked).filled(:string).description("Date worked (YYYY-MM-DD)") + optional(:assigned_task_id).filled(:integer).description("Assigned task ID") + optional(:organization_id).filled(:integer).description("Organization ID (uses default if not provided)") + end + + def call(id:, minutes: nil, notes: nil, date_worked: nil, assigned_task_id: nil, organization_id: nil) + resolve_organization(organization_id) + + time_reg = authorized_scope(TimeReg, type: :relation, as: :own).find(id) + authorize! time_reg, with: TimeRegPolicy, to: :update? + + update_params = {} + update_params[:minutes] = minutes unless minutes.nil? + update_params[:notes] = notes unless notes.nil? + update_params[:date_worked] = date_worked unless date_worked.nil? + update_params[:assigned_task_id] = assigned_task_id unless assigned_task_id.nil? + + time_reg.update!(update_params) + + JSON.generate(format_time_reg(time_reg)) + rescue => e + format_error(e) + end +end diff --git a/config/initializers/fast_mcp.rb b/config/initializers/fast_mcp.rb new file mode 100644 index 00000000..6656c257 --- /dev/null +++ b/config/initializers/fast_mcp.rb @@ -0,0 +1,13 @@ +require "fast_mcp" + +FastMcp.mount_in_rails( + Rails.application, + name: "stemplin", + version: "1.0.0", + path_prefix: "/mcp", + authenticate: false +) do |server| + Rails.application.config.after_initialize do + server.register_tools(*ApplicationTool.descendants) + end +end diff --git a/test/tools/clients_tools_test.rb b/test/tools/clients_tools_test.rb new file mode 100644 index 00000000..7a087fc7 --- /dev/null +++ b/test/tools/clients_tools_test.rb @@ -0,0 +1,129 @@ +require_relative "tool_test_helper" + +class ListClientsToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + end + + test "returns clients for organization" do + result = call_tool(ListClientsTool, user: @admin, organization: @org) + clients = parse_result(result) + + assert_kind_of Array, clients + assert clients.any? { |c| c["name"] == "E Corp" } + clients.each do |c| + assert c.key?("id") + assert c.key?("name") + assert c.key?("organization_id") + assert c.key?("created_at") + assert c.key?("updated_at") + end + end + + test "returns error without auth" do + result = call_tool(ListClientsTool, organization: @org) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class ShowClientToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + @client = clients(:e_corp) + end + + test "returns client details" do + result = call_tool(ShowClientTool, user: @admin, organization: @org, id: @client.id) + client = parse_result(result) + + assert_equal @client.id, client["id"] + assert_equal "E Corp", client["name"] + assert_equal @org.id, client["organization_id"] + end + + test "returns error for client in another organization" do + other_client = clients(:f_corp) + result = call_tool(ShowClientTool, user: @admin, organization: @org, id: other_client.id) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class CreateClientToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + end + + test "creates a client" do + result = call_tool(CreateClientTool, user: @admin, organization: @org, name: "New Client") + client = parse_result(result) + + assert_equal "New Client", client["name"] + assert_equal @org.id, client["organization_id"] + assert client.key?("id") + end + + test "returns error for non-admin user" do + joe = users(:joe) + result = call_tool(CreateClientTool, user: joe, organization: @org, name: "Forbidden Client") + parsed = parse_result(result) + assert parsed.key?("error") + end + + test "returns error for invalid name" do + result = call_tool(CreateClientTool, user: @admin, organization: @org, name: "X") + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class UpdateClientToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + @client = clients(:e_corp) + end + + test "updates a client" do + result = call_tool(UpdateClientTool, user: @admin, organization: @org, id: @client.id, name: "E Corp Updated") + client = parse_result(result) + + assert_equal @client.id, client["id"] + assert_equal "E Corp Updated", client["name"] + end + + test "returns error for non-admin user" do + joe = users(:joe) + result = call_tool(UpdateClientTool, user: joe, organization: @org, id: @client.id, name: "Nope") + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class DeleteClientToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + @client = clients(:to_be_deleted) + end + + test "soft deletes a client" do + result = call_tool(DeleteClientTool, user: @admin, organization: @org, id: @client.id) + parsed = parse_result(result) + + assert_equal "deleted", parsed["status"] + assert_equal @client.id, parsed["id"] + assert @client.reload.discarded? + end + + test "returns error for non-admin user" do + joe = users(:joe) + result = call_tool(DeleteClientTool, user: joe, organization: @org, id: @client.id) + parsed = parse_result(result) + assert parsed.key?("error") + end +end diff --git a/test/tools/get_reports_tool_test.rb b/test/tools/get_reports_tool_test.rb new file mode 100644 index 00000000..b095f020 --- /dev/null +++ b/test/tools/get_reports_tool_test.rb @@ -0,0 +1,72 @@ +require_relative "tool_test_helper" + +class GetReportsToolTest < ToolTestCase + setup do + @joe = users(:joe) + @admin = users(:organization_admin) + @org = organizations(:organization_one) + end + + test "returns report data for date range" do + start_date = (Date.today - 30).to_s + end_date = Date.today.to_s + result = call_tool(GetReportsTool, user: @joe, organization: @org, + start_date: start_date, end_date: end_date) + parsed = parse_result(result) + + assert parsed.key?("total_minutes") + assert parsed.key?("total_entries") + assert parsed.key?("by_project") + assert parsed.key?("by_user") + assert parsed["total_minutes"] >= 0 + assert parsed["total_entries"] >= 0 + end + + test "admin sees all org data in reports" do + start_date = (Date.today - 30).to_s + end_date = Date.today.to_s + result = call_tool(GetReportsTool, user: @admin, organization: @org, + start_date: start_date, end_date: end_date) + parsed = parse_result(result) + + assert parsed["total_entries"] > 0 + assert parsed["by_project"].any? + assert parsed["by_user"].any? + + parsed["by_project"].each do |row| + assert row.key?("project_id") + assert row.key?("project_name") + assert row.key?("client_name") + assert row.key?("total_minutes") + assert row.key?("total_entries") + end + + parsed["by_user"].each do |row| + assert row.key?("user_id") + assert row.key?("user_name") + assert row.key?("total_minutes") + assert row.key?("total_entries") + end + end + + test "filters by project_ids" do + project = projects(:project_1) + start_date = (Date.today - 30).to_s + end_date = Date.today.to_s + result = call_tool(GetReportsTool, user: @admin, organization: @org, + start_date: start_date, end_date: end_date, project_ids: project.id.to_s) + parsed = parse_result(result) + + assert parsed["total_entries"] > 0 + parsed["by_project"].each do |row| + assert_equal project.id, row["project_id"] + end + end + + test "returns error without auth" do + result = call_tool(GetReportsTool, organization: @org, + start_date: Date.today.to_s, end_date: Date.today.to_s) + parsed = parse_result(result) + assert parsed.key?("error") + end +end diff --git a/test/tools/list_organizations_tool_test.rb b/test/tools/list_organizations_tool_test.rb new file mode 100644 index 00000000..fbe9c0e7 --- /dev/null +++ b/test/tools/list_organizations_tool_test.rb @@ -0,0 +1,20 @@ +require_relative "tool_test_helper" + +class ListOrganizationsToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + end + + test "returns user organizations" do + result = call_tool(ListOrganizationsTool, user: @admin) + orgs = parse_result(result) + assert_kind_of Array, orgs + assert orgs.any? { |o| o.key?("id") && o.key?("name") } + end + + test "returns error without auth" do + result = call_tool(ListOrganizationsTool) + parsed = parse_result(result) + assert parsed.key?("error") + end +end diff --git a/test/tools/projects_tools_test.rb b/test/tools/projects_tools_test.rb new file mode 100644 index 00000000..ad70f4cb --- /dev/null +++ b/test/tools/projects_tools_test.rb @@ -0,0 +1,170 @@ +require_relative "tool_test_helper" + +class ListProjectsToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + end + + test "returns projects for organization" do + result = call_tool(ListProjectsTool, user: @admin, organization: @org) + projects = parse_result(result) + + assert_kind_of Array, projects + assert projects.any? { |p| p["name"] == "E Corp CRM" } + projects.each do |p| + assert p.key?("id") + assert p.key?("name") + assert p.key?("client_id") + assert p.key?("client_name") + assert p.key?("billable") + assert p.key?("rate") + assert p.key?("rate_currency") + assert p.key?("created_at") + assert p.key?("updated_at") + end + end + + test "returns error for non-admin user" do + joe = users(:joe) + result = call_tool(ListProjectsTool, user: joe, organization: @org) + parsed = parse_result(result) + assert parsed.key?("error") + end + + test "returns error without auth" do + result = call_tool(ListProjectsTool, organization: @org) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class ShowProjectToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + @project = projects(:project_1) + end + + test "returns project details with assigned tasks" do + result = call_tool(ShowProjectTool, user: @admin, organization: @org, id: @project.id) + project = parse_result(result) + + assert_equal @project.id, project["id"] + assert_equal "E Corp CRM", project["name"] + assert_equal @project.client_id, project["client_id"] + assert_equal "E Corp", project["client_name"] + assert project.key?("assigned_tasks") + assert_kind_of Array, project["assigned_tasks"] + + if project["assigned_tasks"].any? + at = project["assigned_tasks"].first + assert at.key?("id") + assert at.key?("task_id") + assert at.key?("task_name") + assert at.key?("rate") + assert at.key?("rate_currency") + end + end + + test "returns error for non-admin user" do + joe = users(:joe) + result = call_tool(ShowProjectTool, user: joe, organization: @org, id: @project.id) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class CreateProjectToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + @client = clients(:e_corp) + end + + test "creates a project" do + result = call_tool(CreateProjectTool, user: @admin, organization: @org, + name: "New Project", client_id: @client.id) + project = parse_result(result) + + assert_equal "New Project", project["name"] + assert_equal @client.id, project["client_id"] + assert_equal "E Corp", project["client_name"] + assert project.key?("id") + assert project.key?("assigned_tasks") + end + + test "creates a billable project with rate" do + result = call_tool(CreateProjectTool, user: @admin, organization: @org, + name: "Billable Project", client_id: @client.id, billable: true, rate_currency: "1,50") + project = parse_result(result) + + assert_equal true, project["billable"] + assert_equal 150, project["rate"] + end + + test "returns error for non-admin user" do + joe = users(:joe) + result = call_tool(CreateProjectTool, user: joe, organization: @org, + name: "Forbidden Project", client_id: @client.id) + parsed = parse_result(result) + assert parsed.key?("error") + end + + test "returns error for invalid name" do + result = call_tool(CreateProjectTool, user: @admin, organization: @org, + name: "X", client_id: @client.id) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class UpdateProjectToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + @project = projects(:project_1) + end + + test "updates a project name" do + result = call_tool(UpdateProjectTool, user: @admin, organization: @org, + id: @project.id, name: "Updated CRM") + project = parse_result(result) + + assert_equal @project.id, project["id"] + assert_equal "Updated CRM", project["name"] + assert project.key?("assigned_tasks") + end + + test "returns error for non-admin user" do + joe = users(:joe) + result = call_tool(UpdateProjectTool, user: joe, organization: @org, + id: @project.id, name: "Nope") + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class DeleteProjectToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + @project = projects(:to_be_deleted) + end + + test "soft deletes a project" do + result = call_tool(DeleteProjectTool, user: @admin, organization: @org, id: @project.id) + parsed = parse_result(result) + + assert_equal "deleted", parsed["status"] + assert_equal @project.id, parsed["id"] + assert @project.reload.discarded? + end + + test "returns error for non-admin user" do + joe = users(:joe) + result = call_tool(DeleteProjectTool, user: joe, organization: @org, id: @project.id) + parsed = parse_result(result) + assert parsed.key?("error") + end +end diff --git a/test/tools/show_organization_tool_test.rb b/test/tools/show_organization_tool_test.rb new file mode 100644 index 00000000..4c99225a --- /dev/null +++ b/test/tools/show_organization_tool_test.rb @@ -0,0 +1,36 @@ +require_relative "tool_test_helper" + +class ShowOrganizationToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + end + + test "returns organization details" do + result = call_tool(ShowOrganizationTool, user: @admin, id: @org.id) + org = parse_result(result) + + assert_equal @org.id, org["id"] + assert_equal @org.name, org["name"] + assert_equal @org.currency, org["currency"] + assert org.key?("created_at") + assert org.key?("updated_at") + end + + test "returns error for organization user does not belong to" do + other_org = organizations(:organization_three) + # organization_admin belongs to org_three but let's use joe who only belongs to org_one + joe = users(:joe) + result = call_tool(ShowOrganizationTool, user: joe, id: other_org.id) + parsed = parse_result(result) + + assert parsed.key?("error") + end + + test "returns error without auth" do + result = call_tool(ShowOrganizationTool, id: @org.id) + parsed = parse_result(result) + + assert parsed.key?("error") + end +end diff --git a/test/tools/tasks_tools_test.rb b/test/tools/tasks_tools_test.rb new file mode 100644 index 00000000..45fa4a73 --- /dev/null +++ b/test/tools/tasks_tools_test.rb @@ -0,0 +1,69 @@ +require_relative "tool_test_helper" + +class ListTasksToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + end + + test "returns tasks for organization" do + result = call_tool(ListTasksTool, user: @admin, organization: @org) + tasks = parse_result(result) + + assert_kind_of Array, tasks + assert tasks.any? { |t| t["name"] == "Debug" } + tasks.each do |t| + assert t.key?("id") + assert t.key?("name") + assert t.key?("organization_id") + assert t.key?("created_at") + assert t.key?("updated_at") + end + end + + test "does not return tasks from other organizations" do + result = call_tool(ListTasksTool, user: @admin, organization: @org) + tasks = parse_result(result) + + org_two_task_names = [ "login" ] # from fixtures - login belongs to org_two + tasks.each do |t| + assert_not_includes org_two_task_names, t["name"] + end + end + + test "returns error without auth" do + result = call_tool(ListTasksTool, organization: @org) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class ShowTaskToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + @task = tasks(:debug) + end + + test "returns task details" do + result = call_tool(ShowTaskTool, user: @admin, organization: @org, id: @task.id) + task = parse_result(result) + + assert_equal @task.id, task["id"] + assert_equal "Debug", task["name"] + assert_equal @org.id, task["organization_id"] + end + + test "returns error for task in another organization" do + other_task = tasks(:login) # belongs to organization_two + result = call_tool(ShowTaskTool, user: @admin, organization: @org, id: other_task.id) + parsed = parse_result(result) + assert parsed.key?("error") + end + + test "returns error without auth" do + result = call_tool(ShowTaskTool, id: @task.id) + parsed = parse_result(result) + assert parsed.key?("error") + end +end diff --git a/test/tools/time_regs_tools_test.rb b/test/tools/time_regs_tools_test.rb new file mode 100644 index 00000000..d5b038e5 --- /dev/null +++ b/test/tools/time_regs_tools_test.rb @@ -0,0 +1,179 @@ +require_relative "tool_test_helper" + +class ListTimeRegsToolTest < ToolTestCase + setup do + @joe = users(:joe) + @org = organizations(:organization_one) + end + + test "returns time regs for current user" do + result = call_tool(ListTimeRegsTool, user: @joe, organization: @org) + parsed = parse_result(result) + + assert parsed.key?("time_regs") + assert parsed.key?("pagination") + assert parsed["time_regs"].any? + + tr = parsed["time_regs"].first + assert tr.key?("id") + assert tr.key?("notes") + assert tr.key?("minutes") + assert tr.key?("date_worked") + assert tr.key?("assigned_task_id") + assert tr.key?("user_id") + assert tr.key?("current_minutes") + assert tr.key?("active") + assert tr.key?("task_name") + assert tr.key?("project_id") + assert tr.key?("project_name") + assert tr.key?("client_name") + + # All time regs should belong to joe + parsed["time_regs"].each do |reg| + assert_equal @joe.id, reg["user_id"] + end + end + + test "filters by date" do + result = call_tool(ListTimeRegsTool, user: @joe, organization: @org, date: Date.today.to_s) + parsed = parse_result(result) + + parsed["time_regs"].each do |tr| + assert_equal Date.today.to_s, tr["date_worked"] + end + end + + test "filters by date range" do + start_date = (Date.today - 3).to_s + end_date = Date.today.to_s + result = call_tool(ListTimeRegsTool, user: @joe, organization: @org, start_date: start_date, end_date: end_date) + parsed = parse_result(result) + + assert parsed["time_regs"].any? + end + + test "returns pagination info" do + result = call_tool(ListTimeRegsTool, user: @joe, organization: @org, per_page: 2) + parsed = parse_result(result) + + assert parsed["pagination"].key?("current_page") + assert parsed["pagination"].key?("total_pages") + assert parsed["pagination"].key?("total_count") + assert_equal 1, parsed["pagination"]["current_page"] + assert parsed["time_regs"].size <= 2 + end + + test "returns error without auth" do + result = call_tool(ListTimeRegsTool, organization: @org) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class ShowTimeRegToolTest < ToolTestCase + setup do + @joe = users(:joe) + @org = organizations(:organization_one) + @time_reg = time_regs(:time_reg_1) + end + + test "returns time reg details" do + result = call_tool(ShowTimeRegTool, user: @joe, organization: @org, id: @time_reg.id) + parsed = parse_result(result) + + assert_equal @time_reg.id, parsed["id"] + assert_equal @joe.id, parsed["user_id"] + assert_equal @time_reg.minutes, parsed["minutes"] + end + + test "returns error for another users time reg" do + ron_time_reg = time_regs(:time_reg_2) + result = call_tool(ShowTimeRegTool, user: @joe, organization: @org, id: ron_time_reg.id) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class CreateTimeRegToolTest < ToolTestCase + setup do + @joe = users(:joe) + @org = organizations(:organization_one) + @assigned_task = assigned_task(:task_1) + end + + test "creates a time reg" do + result = call_tool(CreateTimeRegTool, user: @joe, organization: @org, + assigned_task_id: @assigned_task.id, minutes: 60, date_worked: Date.today.to_s, notes: "Test work") + parsed = parse_result(result) + + assert parsed.key?("id") + assert_equal 60, parsed["minutes"] + assert_equal @joe.id, parsed["user_id"] + assert_equal "Test work", parsed["notes"] + assert_equal @assigned_task.id, parsed["assigned_task_id"] + end + + test "returns error for invalid minutes" do + result = call_tool(CreateTimeRegTool, user: @joe, organization: @org, + assigned_task_id: @assigned_task.id, minutes: 2000, date_worked: Date.today.to_s) + parsed = parse_result(result) + assert parsed.key?("error") + end + + test "returns error without auth" do + result = call_tool(CreateTimeRegTool, organization: @org, + assigned_task_id: @assigned_task.id, minutes: 60, date_worked: Date.today.to_s) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class UpdateTimeRegToolTest < ToolTestCase + setup do + @joe = users(:joe) + @org = organizations(:organization_one) + @time_reg = time_regs(:time_reg_1) + end + + test "updates a time reg" do + result = call_tool(UpdateTimeRegTool, user: @joe, organization: @org, + id: @time_reg.id, minutes: 90, notes: "Updated notes") + parsed = parse_result(result) + + assert_equal @time_reg.id, parsed["id"] + assert_equal 90, parsed["minutes"] + assert_equal "Updated notes", parsed["notes"] + end + + test "returns error for another users time reg" do + ron_time_reg = time_regs(:time_reg_2) + result = call_tool(UpdateTimeRegTool, user: @joe, organization: @org, + id: ron_time_reg.id, minutes: 90) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class DeleteTimeRegToolTest < ToolTestCase + setup do + @joe = users(:joe) + @org = organizations(:organization_one) + @time_reg = time_regs(:time_reg_5) + end + + test "soft deletes a time reg" do + result = call_tool(DeleteTimeRegTool, user: @joe, organization: @org, id: @time_reg.id) + parsed = parse_result(result) + + assert_equal "deleted", parsed["status"] + assert_equal @time_reg.id, parsed["id"] + assert @time_reg.reload.discarded? + end + + test "returns error for another users time reg" do + ron_time_reg = time_regs(:time_reg_2) + result = call_tool(DeleteTimeRegTool, user: @joe, organization: @org, id: ron_time_reg.id) + parsed = parse_result(result) + assert parsed.key?("error") + end +end diff --git a/test/tools/toggle_timer_tool_test.rb b/test/tools/toggle_timer_tool_test.rb new file mode 100644 index 00000000..750caec7 --- /dev/null +++ b/test/tools/toggle_timer_tool_test.rb @@ -0,0 +1,46 @@ +require_relative "tool_test_helper" + +class ToggleTimerToolTest < ToolTestCase + setup do + @joe = users(:joe) + @org = organizations(:organization_one) + @time_reg = time_regs(:time_reg_1) + end + + test "starts a timer on a time reg" do + assert_not @time_reg.active? + + result = call_tool(ToggleTimerTool, user: @joe, organization: @org, time_reg_id: @time_reg.id) + parsed = parse_result(result) + + assert_equal @time_reg.id, parsed["id"] + assert parsed["active"] + assert_not_nil parsed["start_time"] + end + + test "stops an active timer" do + # Start the timer first + @time_reg.toggle_active + assert @time_reg.reload.active? + + result = call_tool(ToggleTimerTool, user: @joe, organization: @org, time_reg_id: @time_reg.id) + parsed = parse_result(result) + + assert_equal @time_reg.id, parsed["id"] + assert_not parsed["active"] + assert_nil parsed["start_time"] + end + + test "returns error for another users time reg" do + ron_time_reg = time_regs(:time_reg_2) + result = call_tool(ToggleTimerTool, user: @joe, organization: @org, time_reg_id: ron_time_reg.id) + parsed = parse_result(result) + assert parsed.key?("error") + end + + test "returns error without auth" do + result = call_tool(ToggleTimerTool, organization: @org, time_reg_id: @time_reg.id) + parsed = parse_result(result) + assert parsed.key?("error") + end +end diff --git a/test/tools/tool_test_helper.rb b/test/tools/tool_test_helper.rb new file mode 100644 index 00000000..cb0014c0 --- /dev/null +++ b/test/tools/tool_test_helper.rb @@ -0,0 +1,24 @@ +require "test_helper" + +class ToolTestCase < ActiveSupport::TestCase + fixtures :all + + private + + def call_tool(tool_class, user: nil, organization: nil, **args) + headers = {} + if user + token = user.ensure_api_token! + headers["Authorization"] = "Bearer #{token}" + end + args[:organization_id] = organization.id if organization + + tool = tool_class.new(headers: headers) + result, _meta = tool.call_with_schema_validation!(**args) + result + end + + def parse_result(result) + JSON.parse(result) + end +end diff --git a/test/tools/users_tools_test.rb b/test/tools/users_tools_test.rb new file mode 100644 index 00000000..e2ddf4a4 --- /dev/null +++ b/test/tools/users_tools_test.rb @@ -0,0 +1,99 @@ +require_relative "tool_test_helper" + +class ListUsersToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @org = organizations(:organization_one) + end + + test "returns users for organization" do + result = call_tool(ListUsersTool, user: @admin, organization: @org) + users = parse_result(result) + + assert_kind_of Array, users + assert users.any? + users.each do |u| + assert u.key?("id") + assert u.key?("email") + assert u.key?("first_name") + assert u.key?("last_name") + assert u.key?("locale") + assert u.key?("name") + assert u.key?("created_at") + assert u.key?("updated_at") + end + end + + test "returns error without auth" do + result = call_tool(ListUsersTool, organization: @org) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class ShowUserToolTest < ToolTestCase + setup do + @admin = users(:organization_admin) + @joe = users(:joe) + @org = organizations(:organization_one) + end + + test "admin can show another user" do + result = call_tool(ShowUserTool, user: @admin, organization: @org, id: @joe.id) + parsed = parse_result(result) + + assert_equal @joe.id, parsed["id"] + assert_equal @joe.email, parsed["email"] + assert_equal @joe.first_name, parsed["first_name"] + assert_equal @joe.last_name, parsed["last_name"] + end + + test "user can show themselves" do + result = call_tool(ShowUserTool, user: @joe, organization: @org, id: @joe.id) + parsed = parse_result(result) + + assert_equal @joe.id, parsed["id"] + end + + test "non-admin cannot show another user" do + ron = users(:ron) + result = call_tool(ShowUserTool, user: @joe, organization: @org, id: ron.id) + parsed = parse_result(result) + assert parsed.key?("error") + end +end + +class GetCurrentUserToolTest < ToolTestCase + setup do + @joe = users(:joe) + end + + test "returns current user info" do + result = call_tool(GetCurrentUserTool, user: @joe) + parsed = parse_result(result) + + assert_equal @joe.id, parsed["id"] + assert_equal @joe.email, parsed["email"] + assert_equal @joe.first_name, parsed["first_name"] + assert_equal @joe.last_name, parsed["last_name"] + assert_equal @joe.name, parsed["name"] + assert parsed.key?("has_api_token") + assert parsed.key?("current_organization") + end + + test "includes current organization" do + result = call_tool(GetCurrentUserTool, user: @joe) + parsed = parse_result(result) + + org = parsed["current_organization"] + assert_not_nil org + assert org.key?("id") + assert org.key?("name") + end + + test "returns error without auth" do + result = call_tool(GetCurrentUserTool) + parsed = parse_result(result) + assert parsed.key?("error") + end +end