diff --git a/app/controllers/table_invites_controller.rb b/app/controllers/table_invites_controller.rb new file mode 100644 index 0000000..1fb1fa3 --- /dev/null +++ b/app/controllers/table_invites_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class TableInvitesController < ApplicationController + before_action :set_table, only: [ :new, :create ] + before_action :set_table_invite, only: [ :edit, :update ] + + def new + @table_invite = @table.table_invites.new + end + + def create + @user = User.find_by(email: table_invite_params[:email]) + if @user.blank? + # TODO: Allow inviting non-users, we can send an email invite + flash[:alert] = t(".user_not_found") + render :new, status: :unprocessable_entity and return + end + + @table_invite = @table.table_invites.new(table_invite_params) + if @table_invite.save + redirect_to @table, notice: t(".success", email: @table_invite.email) + else + render :new, status: :unprocessable_entity, alert: t(".error") + end + end + + def edit + redirect_to @table_invite.table if @table_invite.responded? + end + + def update + if params[:accept] == "true" + @table_invite.accept! + redirect_to @table_invite.table, notice: t(".accept_success") + elsif params[:decline] == "true" + @table_invite.decline! + redirect_to :root, notice: t(".decline_success") + end + end + + private + + def set_table + @table = Current.user.owned_tables.find(params[:table_id]) + end + + def set_table_invite + @table_invite = TableInvite.where(email: Current.user.email) + .find(params[:id]) + end + + def table_invite_params + params.require(:table_invite).permit(:email) + end +end diff --git a/app/models/table.rb b/app/models/table.rb index f3c1a04..c06b28e 100644 --- a/app/models/table.rb +++ b/app/models/table.rb @@ -4,6 +4,7 @@ class Table < ApplicationRecord belongs_to :owner, class_name: "User" belongs_to :game_system has_many :players, dependent: :destroy + has_many :table_invites, dependent: :destroy has_many :users, through: :players validates :name, presence: true, diff --git a/app/models/table_invite.rb b/app/models/table_invite.rb new file mode 100644 index 0000000..2a188c8 --- /dev/null +++ b/app/models/table_invite.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class TableInvite < ApplicationRecord + belongs_to :table + + validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, + presence: true, + length: { maximum: 100 } + + def accepted? + accepted_at.present? + end + + def declined? + declined_at.present? + end + + def responded? + accepted? || declined? + end + + def accept! + raise "Already declined" if declined? + + user = User.find_by(email:) + user.tables << table + user.save + update(accepted_at: Time.zone.now) + end + + def decline! + raise "Already accepted" if accepted? + + update(declined_at: Time.zone.now) + end +end diff --git a/app/views/table_invites/edit.html.erb b/app/views/table_invites/edit.html.erb new file mode 100644 index 0000000..28166b6 --- /dev/null +++ b/app/views/table_invites/edit.html.erb @@ -0,0 +1,10 @@ +<% content_for :title, t(".respond_to_invite") %> + +

<%= t(".respond_to_invite") %>

+ +

<%= t(".accept_invite_description", table_name: @table_invite.table.name) %>

+ +<%= link_to t(".accept_invite"), table_invite_path(@table_invite, accept: true), data: { + turbo_method: :patch } %> +<%= link_to t(".decline_invite"), table_invite_path(@table_invite, decline: true), data: { + turbo_method: :patch } %> diff --git a/app/views/table_invites/new.html.erb b/app/views/table_invites/new.html.erb new file mode 100644 index 0000000..360e10a --- /dev/null +++ b/app/views/table_invites/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, t(".new_table_invite", name: @table.name) %> + +

<%= t(".new_table_invite", name: @table.name) %>

+ +

<%= t(".new_invite_description") %>

+<%= form_with url: table_table_invites_path do |f| %> + <%= f.label :email %> + <%= f.email_field :email %> + + <%= f.submit t(".button_text") %> +<% end %> diff --git a/app/views/tables/show.html.erb b/app/views/tables/show.html.erb index b748b1d..27b1e24 100644 --- a/app/views/tables/show.html.erb +++ b/app/views/tables/show.html.erb @@ -2,6 +2,7 @@

<%= @table.name %>

+<%= link_to t(".invite_user"), new_table_table_invite_path(@table) %> <%= link_to t(".edit_table"), edit_table_path(@table) %>
diff --git a/config/locales/en.yml b/config/locales/en.yml index f9f8e42..4878263 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -60,6 +60,24 @@ en: destroy: log_out: Log out success: "You have signed out." + table_invites: + new: + new_table_invite: Invite a player to %{name} + new_invite_description: Enter an email address to invite a player to your table. + create_table_invite: Send invite + button_text: Send invite + create: + user_not_found: There is no registered user with that email address. Ask them to sign up! + success: Send invite to %{email} + error: Failed to send invite + edit: + respond_to_invite: Respond to your invitation + accept_invite_description: You have been invited to join the table %{table_name}. To start playing, hit accept! + accept_invite: Accept + decline_invite: Decline + update: + accept_success: You accepted your invite + decline_success: You declined your invite tables: index: new_table: Create a table @@ -68,6 +86,7 @@ en: edit_table: Edit table game_system: Game system owner: Owner + invite_user: Invite a new player new: new_table: New table create_table: Create table diff --git a/config/routes.rb b/config/routes.rb index 5d47767..5025038 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,7 +12,10 @@ Rails.application.routes.draw do resources :account_verifications, only: [ :show ] resources :sessions, only: [ :new, :create, :destroy ] - resources :tables + resources :table_invites, only: [ :edit, :update ] + resources :tables do + resources :table_invites, only: [ :new, :create ] + end resources :admin, only: [ :index ] namespace :admin do diff --git a/db/migrate/20240529122012_create_table_invites.rb b/db/migrate/20240529122012_create_table_invites.rb new file mode 100644 index 0000000..f4fa809 --- /dev/null +++ b/db/migrate/20240529122012_create_table_invites.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateTableInvites < ActiveRecord::Migration[7.1] + def change + create_table :table_invites do |t| + t.belongs_to :table, null: false, foreign_key: true + t.string :email, null: false + t.datetime :accepted_at + t.datetime :declined_at + + t.timestamps + end + + add_check_constraint :table_invites, "length(email) >= 5", name: "chk_email_min_length" + add_check_constraint :table_invites, "length(email) <= 100", name: "chk_email_max_length" + end +end diff --git a/db/schema.rb b/db/schema.rb index 6a0e118..5d44ebd 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_29_074949) do +ActiveRecord::Schema[7.1].define(version: 2024_05_29_122012) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -44,6 +44,18 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_29_074949) do t.index ["user_id"], name: "index_site_roles_users_on_user_id" end + create_table "table_invites", force: :cascade do |t| + t.bigint "table_id", null: false + t.string "email", null: false + t.datetime "accepted_at" + t.datetime "declined_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["table_id"], name: "index_table_invites_on_table_id" + t.check_constraint "length(email::text) <= 100", name: "chk_email_max_length" + t.check_constraint "length(email::text) >= 5", name: "chk_email_min_length" + end + create_table "tables", force: :cascade do |t| t.string "name", null: false t.string "slug", null: false @@ -78,6 +90,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_05_29_074949) do add_foreign_key "players", "tables" add_foreign_key "players", "users" + add_foreign_key "table_invites", "tables" add_foreign_key "tables", "game_systems" add_foreign_key "tables", "users", column: "owner_id" end diff --git a/test/controllers/table_invites_controller_test.rb b/test/controllers/table_invites_controller_test.rb new file mode 100644 index 0000000..03a179a --- /dev/null +++ b/test/controllers/table_invites_controller_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "test_helper" + +class TableInviteInvitesControllerTest < ActionDispatch::IntegrationTest + test "only a table owner can invite players" do + sign_in users(:trevor) + get new_table_table_invite_url(tables(:gimlis_table)) + assert_response :not_found + end + + test "should get new" do + sign_in users(:trevor) + get new_table_table_invite_url(tables(:table)) + assert_response :success + end + + test "should get edit" do + sign_in users(:frodo) + get edit_table_invite_url(table_invites(:unaccepted)) + assert_response :success + end + + test "should create table_invite" do + sign_in users(:trevor) + assert_changes("TableInvite.count", +1) do + post(table_table_invites_url(tables(:table)), params: { table_invite: { email: "uninvited_user@example.com" } }) + end + assert_redirected_to table_path(tables(:table)) + end + + test "should accept table_invite" do + sign_in users(:frodo) + invite = table_invites(:unaccepted) + assert_nil invite.accepted_at + patch table_invite_url(invite), params: { accept: true } + assert_redirected_to table_path(invite.table) + invite.reload + assert_not_nil invite.accepted_at + end + + test "should decline table_invite" do + sign_in users(:frodo) + invite = table_invites(:unaccepted) + assert_nil invite.accepted_at + patch table_invite_url(invite), params: { decline: true } + assert_redirected_to root_path + invite.reload + assert_not_nil invite.declined_at + end +end diff --git a/test/fixtures/table_invites.yml b/test/fixtures/table_invites.yml new file mode 100644 index 0000000..2cb6eb4 --- /dev/null +++ b/test/fixtures/table_invites.yml @@ -0,0 +1,20 @@ +one: + table: table + email: trevor@example.com + accepted_at: 2024-05-29 13:20:12 + +two: + table: table + email: gimli@example.com + accepted_at: 2024-05-29 13:20:12 + +unaccepted: + table: table + email: frodo@example.com + accepted_at: nil + +declined: + table: table + email: admin@example.com + accepted_at: nil + declined_at: 2024-05-29 13:20:12 diff --git a/test/fixtures/tables.yml b/test/fixtures/tables.yml index 28bb5bc..fe72901 100644 --- a/test/fixtures/tables.yml +++ b/test/fixtures/tables.yml @@ -2,13 +2,17 @@ DEFAULTS: &DEFAULTS owner: trevor uuid: <%= SecureRandom.uuid %> slug: $LABEL + game_system: troika table: <<: *DEFAULTS name: My Table - game_system: troika my_table_two: <<: *DEFAULTS name: My Other Table - game_system: troika + +gimlis_table: + <<: *DEFAULTS + name: Gimli's Table + owner: gimli diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 7535296..41360f4 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -15,6 +15,16 @@ gimli: first_name: Gimli last_name: son of Glóin +frodo: + <<: *DEFAULTS + first_name: Frodo + last_name: Baggins + +uninvited_user: + <<: *DEFAULTS + first_name: Fitzchivalry + last_name: Farseer + unverified: <<: *DEFAULTS first_name: Unverified diff --git a/test/models/table_invite_test.rb b/test/models/table_invite_test.rb new file mode 100644 index 0000000..d9e1f87 --- /dev/null +++ b/test/models/table_invite_test.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "test_helper" + +class TableInviteTest < ActiveSupport::TestCase + test "accept! accepts the invite" do + invite = table_invites(:unaccepted) + user = User.find_by(email: invite.email) + assert_not_includes user.tables, invite.table + assert_nil invite.accepted_at + invite.accept! + assert_not_nil invite.accepted_at + assert_includes user.tables, invite.table + end + + test "decline! declines the invite" do + invite = table_invites(:unaccepted) + assert_nil invite.declined_at + invite.decline! + assert_not_nil invite.declined_at + end + + test "cannot accept a declined invitation" do + assert_raises do + table_invites(:declined).accept! + end + end + + test "cannot decline an accepted invitation" do + assert_raises do + table_invites(:one).decline! + end + end + + test "responded? is true for both accepted and declined invites" do + assert table_invites(:one).responded? + assert table_invites(:declined).responded? + end +end