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