diff --git a/app/api/entities/organization_entity.rb b/app/api/entities/organization_entity.rb new file mode 100644 index 000000000..bcfb1fd4b --- /dev/null +++ b/app/api/entities/organization_entity.rb @@ -0,0 +1,13 @@ +module Entities + class OrganizationEntity < Grape::Entity + expose :id + expose :name + expose :description + expose :email + expose :is_enabled + expose :created_at + expose :updated_at + + expose :users, using: Entities::UserEntity, if: { includes: :users } + end + end \ No newline at end of file diff --git a/app/api/organizations_api.rb b/app/api/organizations_api.rb new file mode 100644 index 000000000..3f73a9911 --- /dev/null +++ b/app/api/organizations_api.rb @@ -0,0 +1,205 @@ +class OrganizationsApi < Grape::API + helpers AuthenticationHelpers + helpers AuthorisationHelpers + + before do + authenticated? + end + + # View Enrolled Organizations of a User + desc 'Find all Organizations of a User' + get '/organizations/enrolled' do + user_orgs = current_user.organizations + + present user_orgs, with: Entities::OrganizationEntity + end +end + + + #View Organization by ID + desc 'Get organization by ID' + get '/organizations/:id' do + organization = Organization.find(params[:id]) + unless authorise? current_user, organization, :view_members + error!({error: 'Not Authorized to view the organization'}, 403) + end + present organization, with: Entities::OrganizationEntity, includes: [:users] + end + + #View Members of an Organization + desc'Get Organization members' + get '/organizations/:id/members' do + organization = Organization.find(params[:id]) + unless authorise? current_user, organization, :view_members + error!({error: 'Not Authorized to view the organization members '}, 403) + end + present organization.users, with: Entities::User + end + + #View asll Organizations (Admin) + desc 'Get all organizations (admin only)' + get '/organizations' do + unless authorise? current_user, Organization, :manage_organization + error!({ error: 'Not authorized to view all organizations' }, 403) + end + + organizations = Organization.all + present organizations, with: Entities::OrganizationEntity + end + + #Create New Organization + desc 'Create a new organization' + params do + requires :organization, type: Hash do + requires :name, type: String, desc:'Organization Name' + optional :description, type:String, desc:'Organization Description' + optional :email, type: String, desc:'Organization Email' + optional :is_enabled, type: Boolean, desc:'Organization Status' + end + end + post '/organizations' do + unless authorise? current_user, Organization, :manage_organization + error!({error: 'Not authorized to create an organization'}, 403) + end + org_params = ActionController::Parameters.new(params).require(:organization).permit(:name, :description, :email, :is_enabled) + # Check if organization with same name already exists + if Organization.exists?(name: org_params[:name]) + error!({error: 'An organization with this name already exists'}, 422) + end + organization = Organization.new(org_params) + + if organization.save + present organization, with: Entities::OrganizationEntity + else + error!({error: organization.errors.full_messages.join(', ')}, 422) + end + end + + + # Update an organization + desc 'Update an organization' + params do + requires :organization, type: Hash do + optional :name, type: String, desc:'Organization Name' + optional :description, type:String, desc:'Organization Description' + optional :email, type: String, desc:'Organization Email' + optional :is_enabled, type: Boolean, desc:'Organization Status' + end + end + put '/organizations/:id' do + organization = Organization.find(params[:id]) + unless authorise? current_user, organization, :manage_organization + error!({error: 'Not authorized to update this organization'}, 403) + end + + org_params = ActionController::Parameters.new(params).require(:organization).permit(:name, :description, :email, :is_enabled) + + # Check if name is being changed and if it conflicts with existing org + if org_params[:name].present? && + org_params[:name] != organization.name && + Organization.where.not(id: params[:id]).exists?(name: org_params[:name]) + error!({error: 'An organization with this name already exists'}, 422) + end + + if organization.update(org_params) + present organization, with: Entities::OrganizationEntity + else + error!({error: organization.errors.full_messages.join(', ')}, 422) + end + end + + + # Delete an organization + desc 'Delete an organization' + delete '/organizations/:id' do + organization = Organization.find(params[:id]) + unless authorise? current_user, organization, :manage_organization + error!({error: 'Not authorized to delete this organization'}, 403) + end + + # Check if organization has members + if organization.users.any? + error!({error: 'Cannot delete organization with members. Remove all members first.'}, 422) + end + + if organization.destroy + { success: true } + else + error!({error: organization.errors.full_messages.join(', ')}, 422) + end + end + + #Add Memebr to Organization + desc 'Add a member to an organization' + params do + requires :user_id, type: Integer, desc: 'The ID of the user to add' + end + post '/organizations/:id/members' do + organization = Organization.find(params[:id]) + unless authorise? current_user, organization, :manage_members + error!({error: 'Not Authorized to add members in this organization'}, 403) + end + user = User.find(params[:user_id]) + # Check if user is already a member + if organization.users.include?(user) + error!({ error: 'User is already a member of this organization' }, 422) + end + + organization.users << user + present organization, with: Entities::OrganizationEntity + + end + + #Organization Switching + desc 'Set current organization for user' + params do + requires :organization_id, type: Integer, desc: 'The ID of the organization to set as current' + end + post '/users/current_organization' do + organization = Organization.find(params[:organization_id]) + unless current_user.organizations.include?(organization) + error!({ error: 'User is not a member of this organization' }, 403) + end + + current_user.update(current_organization_id: organization.id) + present organization, with: Entities::OrganizationEntity + end + + #Add members by searching their username , name or email + desc 'Search users to add to organization' + params do + requires :query, type: String, desc: 'Search query (username, name, email)' + end + get '/organizations/:id/search_users' do + organization = Organization.find(params[:id]) + unless authorise? current_user, organization, :manage_members + error!({ error: 'Not authorized to search users for this organization' }, 403) + end + + # Search users not already in the organization + users = User.where("username LIKE :query OR first_name LIKE :query OR last_name LIKE :query OR email LIKE :query", + query: "%#{params[:query]}%") + .where.not(id: organization.user_ids) + .limit(20) + + present users, with: Entities::User + end + + desc 'Remove a member from an organization' + delete '/organizations/:id/members/:user_id' do + organization = Organization.find(params[:id]) + unless authorise? current_user, organization, :manage_members + error!({error: 'Not Authorized to remove members from this organization'}, 403) + end + + user = User.find(params[:user_id]) + user_org = UserOrganization.find_by(user_id: user.id, organization_id: organization.id) + if user_org + user_org.destroy + present organization, with: Entities::OrganizationEntity + else + error!({error: 'User is not a member of this organization'}, 404) + end + end + + \ No newline at end of file diff --git a/app/models/organization.rb b/app/models/organization.rb new file mode 100644 index 000000000..ac39bdf62 --- /dev/null +++ b/app/models/organization.rb @@ -0,0 +1,64 @@ +class Organization < ApplicationRecord + has_many :user_organizations, dependent: :destroy + has_many :users, through: :user_organizations + + validates :name, presence: true, uniqueness: { case_sensitive: false } + + scope :enabled, -> { where(is_enabled: true) } + + def to_s + name + end + + def self.permissions + # what can admins do with organizations? + admin_role_permissions = [ + :manage_organization, + :manage_members, + :view_members, + :view_enrolled_organizations + ] + + # what can members do with organizations? + convenor_role_permissions = [ + :view_members, + :manage_members, + :view_enrolled_organizations + ] + + # what can tutors do with organizations? + tutor_role_permissions = [ + :view_members, + :view_enrolled_organizations + ] + + # what can auditors do with organizations? + auditor_role_permissions = [ + :view_members, + :view_enrolled_organizations + ] + + # what can students do with organizations? + students_role_permissions = [ + :view_enrolled_organizations + ] + + # Return permissions hash + { + admin: admin_role_permissions, + convenor: convenor_role_permissions, + tutor: tutor_role_permissions, + auditor: auditor_role_permissions, + student: students_role_permissions + } + end + + def role_for(user) + # If user belongs to a specific organization, they get role + if users.include?(user) + user.role + else + nil + end + end +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index 6e016badf..d4345ceb0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -136,6 +136,8 @@ def token_for_text?(a_token) has_many :projects, dependent: :destroy has_many :auth_tokens, dependent: :destroy has_one :webcal, dependent: :destroy + has_many :user_organizations, dependent: :destroy + has_many :organizations, through: :user_organizations # Model validations/constraints validates :first_name, presence: true diff --git a/app/models/user_organization.rb b/app/models/user_organization.rb new file mode 100644 index 000000000..295317054 --- /dev/null +++ b/app/models/user_organization.rb @@ -0,0 +1,7 @@ +class UserOrganization < ApplicationRecord + + belongs_to :user + belongs_to :organization + validates :user_id , uniqueness: { scope: :organization_id } + +end \ No newline at end of file diff --git a/db/migrate/20250417005019_create_organizations.rb b/db/migrate/20250417005019_create_organizations.rb new file mode 100644 index 000000000..c8929b4c7 --- /dev/null +++ b/db/migrate/20250417005019_create_organizations.rb @@ -0,0 +1,20 @@ +class CreateOrganizations < ActiveRecord::Migration[7.1] + def change + create_table :organizations do |t| + t.string :name, null: false + t.string :description, limit: 1200 + t.string :email + t.boolean :is_enabled, default: true + + t.timestamps + end + add_index :organizations, :name, unique: true + + create_table :user_organizations do |t| + t.references :user, null: false, foreign_key: true + t.references :organization, null: false, foreign_key: true + t.timestamps + end + add_index :user_organizations, [:user_id, :organization_id], unique: true + end +end diff --git a/db/migrate/20250421035348_add_current_organization_id_to_users.rb b/db/migrate/20250421035348_add_current_organization_id_to_users.rb new file mode 100644 index 000000000..f1aa23393 --- /dev/null +++ b/db/migrate/20250421035348_add_current_organization_id_to_users.rb @@ -0,0 +1,5 @@ +class AddCurrentOrganizationIdToUsers < ActiveRecord::Migration[7.1] + def change + add_reference :users, :current_organization, foreign_key: { to_table: :organizations } + end +end diff --git a/db/schema.rb b/db/schema.rb index 6daa71ebf..49f720b5c 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: 2024_05_28_223908) do +ActiveRecord::Schema[7.1].define(version: 2025_04_21_035348) do create_table "activity_types", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "name", null: false t.string "abbreviation", null: false @@ -139,6 +139,16 @@ t.index ["user_id"], name: "index_logins_on_user_id" end + create_table "organizations", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.string "name", null: false + t.string "description", limit: 1200 + t.string "email" + t.boolean "is_enabled", default: true + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_organizations_on_name", unique: true + end + create_table "overseer_assessments", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.bigint "task_id", null: false t.string "submission_timestamp", null: false @@ -480,6 +490,16 @@ t.index ["teaching_period_id"], name: "index_units_on_teaching_period_id" end + create_table "user_organizations", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "organization_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["organization_id"], name: "index_user_organizations_on_organization_id" + t.index ["user_id", "organization_id"], name: "index_user_organizations_on_user_id_and_organization_id", unique: true + t.index ["user_id"], name: "index_user_organizations_on_user_id" + end + create_table "users", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t| t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false @@ -509,6 +529,8 @@ t.string "tii_eula_version" t.datetime "tii_eula_date" t.boolean "tii_eula_version_confirmed", default: false, null: false + t.bigint "current_organization_id" + t.index ["current_organization_id"], name: "index_users_on_current_organization_id" t.index ["login_id"], name: "index_users_on_login_id", unique: true t.index ["role_id"], name: "index_users_on_role_id" end @@ -531,4 +553,7 @@ t.index ["user_id"], name: "index_webcals_on_user_id", unique: true end + add_foreign_key "user_organizations", "organizations" + add_foreign_key "user_organizations", "users" + add_foreign_key "users", "organizations", column: "current_organization_id" end diff --git a/docker-compose.yml b/docker-compose.yml index 22520ccaa..5ffc919fe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,3 +71,4 @@ services: MYSQL_PASSWORD: pwd volumes: - ../data/database:/var/lib/mysql +