add entry requests, invite new users
CI / scan_ruby (push) Failing after 12s
CI / scan_js (push) Successful in 11s
CI / lint (push) Failing after 19s
CI / test (push) Successful in 34s

This commit is contained in:
2026-01-30 01:28:53 +01:00
parent b64ad52d30
commit 530021960e
35 changed files with 1838 additions and 118 deletions
@@ -12,6 +12,7 @@ class Admin::DashboardController < Admin::BaseController
@pending_suggestions_count = SuggestedMeaning.pending.count
@accepted_suggestions_count = SuggestedMeaning.accepted.count
@rejected_suggestions_count = SuggestedMeaning.rejected.count
@requested_entries_count = Entry.requested.count
@comment_count = Comment.count
@@ -0,0 +1,64 @@
class Admin::RequestsController < Admin::BaseController
def index
@requested_entries = Entry.requested
.includes(:requested_by)
.order(created_at: :desc)
@approved_entries = Entry.approved
.includes(:requested_by)
.order(updated_at: :desc)
end
def show
@entry = Entry.find(params[:id])
end
def edit
@entry = Entry.find(params[:id])
end
def update
@entry = Entry.find(params[:id])
if @entry.update(entry_params)
redirect_to admin_request_path(@entry), notice: "Request updated successfully."
else
flash.now[:alert] = "Error updating request."
render :edit, status: :unprocessable_entity
end
end
def approve
@entry = Entry.find(params[:id])
@user = @entry.requested_by
@user.update!(
invitation_token: SecureRandom.urlsafe_base64(32),
invitation_sent_at: Time.current,
invited_by: current_user
)
@entry.update!(status: :approved)
InvitationMailer.invite(@user, approved_entry: @entry).deliver_later
redirect_to admin_requests_path, notice: "Request approved and invitation sent to #{@user.email}."
end
def reject
@entry = Entry.find(params[:id])
@user = @entry.requested_by
entry_preview = [@entry.fi, @entry.en, @entry.sv, @entry.no, @entry.ru, @entry.de].compact.first || "Entry"
@entry.destroy!
@user.destroy! if @user.requested_entries.count.zero?
redirect_to admin_requests_path, notice: "Request '#{entry_preview}' has been rejected and deleted."
end
private
def entry_params
params.require(:entry).permit(:category, :fi, :en, :sv, :no, :ru, :de, :notes)
end
end
+7 -6
View File
@@ -9,7 +9,7 @@ class EntriesController < ApplicationController
@page = [ params[:page].to_i, 1 ].max
@per_page = 25
entries_scope = Entry.all
entries_scope = Entry.active_entries
entries_scope = entries_scope.with_category(@category)
entries_scope = entries_scope.search(@query, language_code: @language_code)
entries_scope = entries_scope.starts_with(@starts_with, language_code: @language_code) if @starts_with.present?
@@ -20,17 +20,18 @@ class EntriesController < ApplicationController
@total_pages = (@total_entries.to_f / @per_page).ceil
@entries = entries_scope.offset((@page - 1) * @per_page).limit(@per_page)
@entry_count = Entry.count
@verified_count = Entry.where(verified: true).count
@entry_count = Entry.active_entries.count
@requested_count = Entry.requested.count
@verified_count = Entry.active_entries.where(verified: true).count
@needs_review_count = @entry_count - @verified_count
@complete_entries_count = supported_languages.reduce(Entry.all) do |scope, language|
@complete_entries_count = supported_languages.reduce(Entry.active_entries) do |scope, language|
scope.where.not(language.code => [ nil, "" ])
end.count
@missing_entries_count = @entry_count - @complete_entries_count
@language_completion = supported_languages.index_with do |language|
next 0 if @entry_count.zero?
(Entry.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
(Entry.active_entries.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
end
if @language_code.present?
@@ -61,7 +62,7 @@ class EntriesController < ApplicationController
end
def download
@entries = Entry.order(:id)
@entries = Entry.active_entries.order(:id)
respond_to do |format|
format.xlsx do
filename = "sanasto-entries-#{Time.zone.today}.xlsx"
@@ -21,6 +21,9 @@ class InvitationsController < ApplicationController
invitation_token: nil
)
# Activate approved entries by this user
Entry.where(requested_by: @user, status: :approved).update_all(status: :active)
session[:user_id] = @user.id
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!"
else
+75
View File
@@ -0,0 +1,75 @@
class RequestsController < ApplicationController
def new
@entry = Entry.new
if current_user
@pending_count = current_user.requested_entries.where(status: [ :requested, :approved ]).count
elsif params[:email].present?
@pending_count = User.find_by(email: params[:email])&.requested_entries&.where(status: [ :requested, :approved ])&.count || 0
else
@pending_count = 0
end
end
def create
# If user is logged in, use their account
if current_user
@user = current_user
else
# Anonymous submission - need to find or create user
email = request_params[:email]
existing_user = User.find_by(email: email)
# Check if user has already accepted an invitation
if existing_user&.invitation_accepted_at.present?
redirect_to login_path, alert: "An account with this email already exists. Please log in."
return
end
# Use existing pending user or create new one
@user = existing_user || User.new(
name: request_params[:name],
email: email,
password: SecureRandom.alphanumeric(32),
role: :contributor
)
end
# Create entry in a transaction
ActiveRecord::Base.transaction do
# Save user only if it's a new record
if @user.new_record? && !@user.save
@pending_count = 0
@entry = Entry.new(entry_params)
flash.now[:alert] = "There was an error submitting your request. Please check the form."
render :new, status: :unprocessable_entity
raise ActiveRecord::Rollback
return
end
# Create entry
@entry = Entry.new(entry_params)
@entry.status = :requested
@entry.requested_by = @user
if @entry.save
redirect_to root_path, notice: "Thank you for your request! We'll review it and get back to you soon."
else
@pending_count = 0
flash.now[:alert] = "There was an error submitting your request. Please check the form."
render :new, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
end
private
def request_params
params.require(:entry).permit(:name, :email, :category, :fi, :en, :sv, :no, :ru, :de, :notes)
end
def entry_params
request_params.except(:name, :email)
end
end
+11
View File
@@ -28,4 +28,15 @@ module EntriesHelper
def format_entry_category(entry)
entry.category.to_s.tr("_", " ").capitalize
end
def format_entry_status(entry)
case entry.status
when "requested"
content_tag(:span, "Requested", class: "px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800")
when "approved"
content_tag(:span, "Approved", class: "px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800")
when "active"
content_tag(:span, "Active", class: "px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800")
end
end
end
+9 -2
View File
@@ -1,12 +1,19 @@
class InvitationMailer < ApplicationMailer
def invite(user)
def invite(user, approved_entry: nil)
@user = user
@approved_entry = approved_entry
@invitation_url = invitation_url(@user.invitation_token)
@expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY
subject = if @approved_entry
"Your entry request has been approved - Join Sanasto Wiki"
else
"You've been invited to join Sanasto Wiki"
end
mail(
to: @user.email,
subject: "You've been invited to join Sanasto Wiki"
subject: subject
)
end
end
+12
View File
@@ -1,15 +1,21 @@
class Entry < ApplicationRecord
belongs_to :created_by, class_name: "User", optional: true
belongs_to :updated_by, class_name: "User", optional: true
belongs_to :requested_by, class_name: "User", optional: true
has_many :suggested_meanings, dependent: :destroy
has_many :comments, as: :commentable, dependent: :destroy
enum :category, %i[word phrase proper_name title reference other]
enum :status, %i[requested approved active], default: :active
validates :category, presence: true
validate :at_least_one_translation
scope :with_category, ->(cat) { cat.present? ? where(category: cat) : all }
scope :requested, -> { where(status: :requested) }
scope :approved, -> { where(status: :approved) }
scope :active_entries, -> { where(status: :active) }
def self.search(query, language_code: nil)
return all if query.blank?
@@ -43,4 +49,10 @@ class Entry < ApplicationRecord
def self.valid_lang?(code)
SupportedLanguage.valid_codes.include?(code.to_s)
end
def at_least_one_translation
if [fi, en, sv, no, ru, de].all?(&:blank?)
errors.add(:base, "At least one language translation is required")
end
end
end
+1
View File
@@ -6,6 +6,7 @@ class User < ApplicationRecord
has_many :created_entries, class_name: "Entry", foreign_key: :created_by_id, dependent: :nullify
has_many :updated_entries, class_name: "Entry", foreign_key: :updated_by_id, dependent: :nullify
has_many :requested_entries, class_name: "Entry", foreign_key: :requested_by_id, dependent: :nullify
has_many :submitted_suggested_meanings,
class_name: "SuggestedMeaning",
foreign_key: :submitted_by_id,
+24 -16
View File
@@ -18,10 +18,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
</dl>
<%= link_to admin_users_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
</dl>
<% end %>
</div>
</div>
</div>
@@ -44,10 +46,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
</dl>
<%= link_to root_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
</dl>
<% end %>
</div>
</div>
</div>
@@ -69,10 +73,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %></dd>
</dl>
<%= link_to admin_requests_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions / Requests</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %> / <%= @requested_entries_count %></dd>
</dl>
<% end %>
</div>
</div>
</div>
@@ -94,10 +100,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
</dl>
<%= link_to admin_invitations_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
</dl>
<% end %>
</div>
</div>
</div>
+81
View File
@@ -0,0 +1,81 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Edit Entry Request</h1>
<p class="text-gray-600 mt-2">Modify the entry details before approval.</p>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<%= form_with model: @entry, url: admin_request_path(@entry), method: :patch, class: "space-y-6" do |f| %>
<% if @entry.errors.any? %>
<div class="mx-6 mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
<% @entry.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="px-6 pt-6 space-y-4">
<div>
<%= f.label :category, "Category", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.select :category, Entry.categories.keys.map { |cat| [cat.humanize, cat] }, {}, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= f.label :fi, "🇫🇮 Finnish", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :fi, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :en, "🇬🇧 English", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :en, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :sv, "🇸🇪 Swedish", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :sv, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :no, "🇳🇴 Norwegian", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :no, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :ru, "🇷🇺 Russian", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :ru, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :de, "🇩🇪 German", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :de, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
</div>
</div>
<div>
<%= f.label :notes, "Additional Notes", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.text_area :notes, rows: 4, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition", placeholder: "Any additional context or information about this entry..." %>
</div>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Requester (Read-only)</h3>
<div class="bg-gray-50 rounded-lg p-4 space-y-1 text-sm">
<div><span class="font-medium">Name:</span> <%= @entry.requested_by&.name %></div>
<div><span class="font-medium">Email:</span> <%= @entry.requested_by&.email %></div>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex gap-3">
<%= f.submit "Save Changes", class: "px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition" %>
<%= link_to "Cancel", admin_request_path(@entry), class: "px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %>
</div>
<% end %>
</div>
</div>
+126
View File
@@ -0,0 +1,126 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Entry Requests</h1>
<p class="text-gray-600 mt-2">Review and manage entry requests from public users.</p>
</div>
<!-- Requested Entries Section -->
<div class="mb-12">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-yellow-50 border-b border-yellow-100 px-6 py-4">
<h2 class="text-xl font-bold text-yellow-900 flex items-center gap-2">
<span>⏳</span> Pending Review
<span class="text-sm font-normal text-yellow-700">(<%= @requested_entries.count %> total)</span>
</h2>
</div>
<% if @requested_entries.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% @requested_entries.each do |entry| %>
<tr class="hover:bg-gray-50 transition">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= entry.category.humanize %>
</span>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= entry.created_at.strftime("%b %d, %Y") %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %>
<%= link_to "Edit", edit_admin_request_path(entry), class: "text-blue-600 hover:text-blue-900" %>
<%= button_to "Approve", approve_admin_request_path(entry), method: :post, class: "inline text-green-600 hover:text-green-900", form: { data: { turbo_confirm: "Send invitation to #{entry.requested_by&.email}?" } } %>
<%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this request?" } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="px-6 py-12 text-center text-gray-500">
No pending requests at the moment.
</div>
<% end %>
</div>
</div>
<!-- Approved Entries Section -->
<div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-blue-50 border-b border-blue-100 px-6 py-4">
<h2 class="text-xl font-bold text-blue-900 flex items-center gap-2">
<span>✅</span> Approved (Awaiting User Acceptance)
<span class="text-sm font-normal text-blue-700">(<%= @approved_entries.count %> total)</span>
</h2>
</div>
<% if @approved_entries.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Approved</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% @approved_entries.each do |entry| %>
<tr class="hover:bg-gray-50 transition">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= entry.category.humanize %>
</span>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= entry.updated_at.strftime("%b %d, %Y") %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %>
<%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this approved request?" } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="px-6 py-12 text-center text-gray-500">
No approved entries awaiting user acceptance.
</div>
<% end %>
</div>
</div>
</div>
+96
View File
@@ -0,0 +1,96 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Entry Request Details</h1>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="border-b border-gray-200 px-6 py-4 bg-gray-50">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900">Entry Information</h2>
<%= content_tag(:span, @entry.status.titleize, class: "px-3 py-1 text-sm font-semibold rounded-full #{@entry.requested? ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}") %>
</div>
</div>
<div class="px-6 py-6 space-y-6">
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-2">Category</h3>
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= @entry.category.humanize %>
</span>
</div>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇫🇮 Finnish</div>
<div class="text-gray-900"><%= @entry.fi.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇬🇧 English</div>
<div class="text-gray-900"><%= @entry.en.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇸🇪 Swedish</div>
<div class="text-gray-900"><%= @entry.sv.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇳🇴 Norwegian</div>
<div class="text-gray-900"><%= @entry.no.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇷🇺 Russian</div>
<div class="text-gray-900"><%= @entry.ru.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇩🇪 German</div>
<div class="text-gray-900"><%= @entry.de.presence || "—" %></div>
</div>
</div>
</div>
<% if @entry.notes.present? %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Notes</h3>
<div class="bg-gray-50 rounded-lg p-4 text-gray-900 whitespace-pre-wrap">
<%= @entry.notes %>
</div>
</div>
<% end %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Requester Information</h3>
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<div>
<span class="text-sm font-medium text-gray-700">Name:</span>
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.name %></span>
</div>
<div>
<span class="text-sm font-medium text-gray-700">Email:</span>
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.email %></span>
</div>
<div>
<span class="text-sm font-medium text-gray-700">Submitted:</span>
<span class="text-gray-900 ml-2"><%= @entry.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex flex-wrap gap-3">
<%= link_to "← Back to Requests", admin_requests_path, class: "px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %>
<% if @entry.requested? %>
<%= link_to "Edit", edit_admin_request_path(@entry), class: "px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition" %>
<%= button_to "Approve & Send Invitation", approve_admin_request_path(@entry), method: :post, class: "px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition", form: { data: { turbo_confirm: "Send invitation to #{@entry.requested_by&.email}?" } } %>
<% end %>
<%= button_to "Reject", reject_admin_request_path(@entry), method: :delete, class: "px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition ml-auto", form: { data: { turbo_confirm: "Are you sure you want to delete this request?" } } %>
</div>
</div>
</div>
+3
View File
@@ -42,6 +42,9 @@
<tr>
<td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
No entries matched your filters.
<br><br>
<%= link_to "Request a new entry", new_request_path,
class: "text-indigo-600 font-semibold hover:text-indigo-800 underline" %>
</td>
</tr>
<% else %>
+17
View File
@@ -9,6 +9,8 @@
<span class="text-xl font-light text-slate-400">Wiki</span>
</div>
<div class="flex items-center gap-3">
<%= link_to "Request Entry", new_request_path,
class: "text-xs font-bold text-emerald-700 px-3 py-2 rounded-md border border-emerald-200 bg-emerald-50 hover:bg-emerald-100 transition" %>
<%= link_to "Download XLSX", download_entries_path(format: :xlsx),
class: "text-xs font-bold text-indigo-700 px-3 py-2 rounded-md border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
<% if admin? %>
@@ -21,6 +23,21 @@
</div>
</header>
<!-- Flash messages -->
<% if flash.any? %>
<div class="max-w-7xl mx-auto px-4 mt-4 w-full">
<% flash.each do |type, message| %>
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4 relative" role="alert">
<span class="block sm:inline pr-8"><%= message %></span>
<button type="button" class="absolute top-0 right-0 mt-3 mr-3 text-current opacity-50 hover:opacity-100 transition" onclick="this.parentElement.remove()">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<% end %>
</div>
<% end %>
<div class="flex-1 flex flex-col">
<section id="search">
+72 -4
View File
@@ -79,6 +79,32 @@
font-size: 14px;
border-radius: 4px;
}
.entry-box {
background: #f0fdf4;
border-left: 4px solid #10b981;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.entry-box h3 {
margin: 0 0 12px 0;
color: #065f46;
font-size: 16px;
}
.entry-translations {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
margin-top: 12px;
}
.entry-translations dt {
font-weight: 600;
color: #064e3b;
}
.entry-translations dd {
margin: 0;
color: #1e293b;
}
</style>
</head>
<body>
@@ -90,11 +116,53 @@
<div class="content">
<p class="greeting">Hello <%= @user.name %>,</p>
<p>
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
</p>
<% if @approved_entry %>
<p>
Great news! Your entry request has been <strong>approved</strong> and is ready to be published.
</p>
<p>With a login account, you can contribute to this work.</p>
<div class="entry-box">
<h3>✓ Your Approved Entry</h3>
<p style="margin: 0 0 4px 0;"><strong>Category:</strong> <%= @approved_entry.category.to_s.humanize %></p>
<dl class="entry-translations">
<% if @approved_entry.fi.present? %>
<dt>🇫🇮 Finnish:</dt>
<dd><%= @approved_entry.fi %></dd>
<% end %>
<% if @approved_entry.en.present? %>
<dt>🇬🇧 English:</dt>
<dd><%= @approved_entry.en %></dd>
<% end %>
<% if @approved_entry.sv.present? %>
<dt>🇸🇪 Swedish:</dt>
<dd><%= @approved_entry.sv %></dd>
<% end %>
<% if @approved_entry.no.present? %>
<dt>🇳🇴 Norwegian:</dt>
<dd><%= @approved_entry.no %></dd>
<% end %>
<% if @approved_entry.ru.present? %>
<dt>🇷🇺 Russian:</dt>
<dd><%= @approved_entry.ru %></dd>
<% end %>
<% if @approved_entry.de.present? %>
<dt>🇩🇪 German:</dt>
<dd><%= @approved_entry.de %></dd>
<% end %>
</dl>
</div>
<p>
To complete the process and publish your entry, please accept this invitation to create your account on <strong>Sanasto Wiki</strong>.
</p>
<% else %>
<p>
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
</p>
<p>With a login account, you can contribute to this work.</p>
<% end %>
<div class="info-box">
<p style="margin: 0;"><strong>Your Account Details:</strong></p>
@@ -4,9 +4,28 @@ SANASTO WIKI - INVITATION
Hello <%= @user.name %>,
<% if @approved_entry %>
Great news! Your entry request has been APPROVED and is ready to be published.
YOUR APPROVED ENTRY
-------------------
Category: <%= @approved_entry.category.to_s.humanize %>
Translations:
<% if @approved_entry.fi.present? %> • Finnish: <%= @approved_entry.fi %>
<% end %><% if @approved_entry.en.present? %> • English: <%= @approved_entry.en %>
<% end %><% if @approved_entry.sv.present? %> • Swedish: <%= @approved_entry.sv %>
<% end %><% if @approved_entry.no.present? %> • Norwegian: <%= @approved_entry.no %>
<% end %><% if @approved_entry.ru.present? %> • Russian: <%= @approved_entry.ru %>
<% end %><% if @approved_entry.de.present? %> • German: <%= @approved_entry.de %>
<% end %>
To complete the process and publish your entry, please accept this invitation to create your account on Sanasto Wiki.
<% else %>
The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity.
With a login account, you can contribute to this work.
<% end %>
YOUR ACCOUNT DETAILS
--------------------
+17 -2
View File
@@ -24,6 +24,16 @@
<%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Invitations", admin_invitations_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<% requested_count = Entry.requested.count %>
<% gap = requested_count.zero? ? '' : 'pr-4' %>
<%= link_to admin_requests_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition relative #{gap}" do %>
Requests
<% if requested_count > 0 %>
<span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
<%= requested_count %>
</span>
<% end %>
<% end %>
<%= link_to "Back to Site", root_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= button_to "Log Out", logout_path, method: :delete, form: { data: { turbo: false }, style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
</nav>
@@ -35,8 +45,13 @@
<% if flash.any? %>
<div class="max-w-7xl mx-auto px-4 mt-4">
<% flash.each do |type, message| %>
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4" role="alert">
<span class="block sm:inline"><%= message %></span>
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4 relative" role="alert">
<span class="block sm:inline pr-8"><%= message %></span>
<button type="button" class="absolute top-0 right-0 mt-3 mr-3 text-current opacity-50 hover:opacity-100 transition" onclick="this.parentElement.remove()">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
</svg>
</button>
</div>
<% end %>
</div>
+129
View File
@@ -0,0 +1,129 @@
<% content_for :title, "Request a New Entry" %>
<div class="min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center justify-between">
<%= link_to root_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
<% end %>
<div class="flex items-center gap-3">
<%= link_to "Sign In", login_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
</div>
</div>
</div>
</header>
<div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12">
<div class="max-w-2xl w-full">
<div class="bg-white rounded-2xl shadow-xl p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Request a New Entry</h1>
<p class="text-gray-600">Is there a word you would like to see in this glossary?</p>
</div>
<% if flash[:alert] %>
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
<%= flash[:alert] %>
</div>
<% end %>
<% if @pending_count && @pending_count > 0 %>
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg text-blue-800">
You have <%= @pending_count %> pending <%= "request".pluralize(@pending_count) %> being reviewed.
</div>
<% end %>
<%= form_with model: @entry, url: requests_path, class: "space-y-6", data: { turbo: false } do |f| %>
<% if @entry.errors.any? %>
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
<% @entry.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="space-y-4">
<% if current_user %>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<p class="text-sm text-blue-900">
<span class="font-semibold">Submitting as:</span> <%= current_user.name %> (<%= current_user.email %>)
</p>
</div>
<% else %>
<div>
<%= f.label :name, "Your Name", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.text_field :name, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :email, "Your Email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.email_field :email, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<% end %>
<div>
<%= f.label :category, "Category", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.select :category, Entry.categories.keys.map { |cat| [cat.humanize, cat] }, { prompt: "Select a category" }, { required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" } %>
</div>
</div>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations (at least one required)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= f.label :fi, "🇫🇮 Finnish", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :fi, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :en, "🇬🇧 English", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :en, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :sv, "🇸🇪 Swedish", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :sv, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :no, "🇳🇴 Norwegian", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :no, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :ru, "🇷🇺 Russian", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :ru, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :de, "🇩🇪 German", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :de, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
</div>
</div>
<div>
<%= f.label :notes, "Additional Notes (optional)", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.text_area :notes, rows: 4, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition", placeholder: "Any additional context or information about this entry..." %>
</div>
<div class="flex flex-col sm:flex-row gap-4 pt-4">
<%= f.submit "Submit Request", class: "flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition shadow-md hover:shadow-lg" %>
<%= link_to "Cancel", root_path, class: "flex-1 text-center bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold py-3 px-6 rounded-lg transition" %>
</div>
<% end %>
<% unless current_user %>
<div class="mt-6 text-center text-sm text-gray-600">
Already have an account? <%= link_to "Sign in", login_path, class: "text-indigo-600 hover:text-indigo-800 font-semibold" %>
</div>
<% end %>
</div>
</div>
</div>
</div>
+9
View File
@@ -25,6 +25,9 @@ Rails.application.routes.draw do
get "invitations/:token", to: "invitations#show", as: :invitation
patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation
# Public entry request routes
resources :requests, only: [:new, :create]
# Admin namespace
namespace :admin do
root "dashboard#index"
@@ -35,6 +38,12 @@ Rails.application.routes.draw do
put :resend
end
end
resources :requests, only: [ :index, :show, :edit, :update ] do
member do
post :approve
delete :reject
end
end
end
resources :entries do
@@ -0,0 +1,13 @@
class AddStatusToEntries < ActiveRecord::Migration[8.1]
def change
add_column :entries, :status, :integer, default: 2, null: false
add_index :entries, :status
# Set all existing entries to status: 2 (active)
reversible do |dir|
dir.up do
execute "UPDATE entries SET status = 2"
end
end
end
end
@@ -0,0 +1,7 @@
class AddRequestedByToEntries < ActiveRecord::Migration[8.1]
def change
add_column :entries, :requested_by_id, :integer
add_foreign_key :entries, :users, column: :requested_by_id
add_index :entries, :requested_by_id
end
end
+17 -30
View File
@@ -1,15 +1,5 @@
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_367d1ab731"
FOREIGN KEY ("created_by_id")
REFERENCES "users" ("id")
, CONSTRAINT "fk_rails_6f84c41258"
FOREIGN KEY ("updated_by_id")
REFERENCES "users" ("id")
);
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "comments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "commentable_type" varchar NOT NULL, "commentable_id" integer NOT NULL, "body" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "language_code" varchar /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_03de2dc08c"
FOREIGN KEY ("user_id")
REFERENCES "users" ("id")
@@ -59,28 +49,25 @@ CREATE TABLE IF NOT EXISTS 'entries_fts_data'(id INTEGER PRIMARY KEY, block BLOB
CREATE TABLE IF NOT EXISTS 'entries_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS 'entries_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
CREATE TABLE IF NOT EXISTS 'entries_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
CREATE TRIGGER entries_fts_after_insert
AFTER INSERT ON entries
BEGIN
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
END;
CREATE TRIGGER entries_fts_after_update
AFTER UPDATE ON entries
BEGIN
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
END;
CREATE TRIGGER entries_fts_after_delete
AFTER DELETE ON entries
BEGIN
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
END;
CREATE TABLE IF NOT EXISTS "setup_states" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "installed" boolean DEFAULT FALSE NOT NULL, "installed_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "status" integer DEFAULT 2 NOT NULL, "requested_by_id" integer, CONSTRAINT "fk_rails_6f84c41258"
FOREIGN KEY ("updated_by_id")
REFERENCES "users" ("id")
, CONSTRAINT "fk_rails_367d1ab731"
FOREIGN KEY ("created_by_id")
REFERENCES "users" ("id")
, CONSTRAINT "fk_rails_4d36fd8a36"
FOREIGN KEY ("requested_by_id")
REFERENCES "users" ("id")
);
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_status" ON "entries" ("status") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_requested_by_id" ON "entries" ("requested_by_id") /*application='SanastoWiki'*/;
INSERT INTO "schema_migrations" (version) VALUES
('20260129204706'),
('20260129204705'),
('20260123130957'),
('20260123125325'),
('20260122131000'),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 143 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+18 -56
View File
@@ -2,8 +2,9 @@
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg10"
id="svg3"
sodipodi:docname="icon.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
@@ -11,9 +12,9 @@
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs10" />
id="defs3" />
<sodipodi:namedview
id="namedview10"
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
@@ -21,64 +22,25 @@
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="1.0230914"
inkscape:cx="220.41042"
inkscape:cy="272.2142"
inkscape:window-width="1854"
inkscape:window-height="1131"
inkscape:zoom="1.5917969"
inkscape:cx="256"
inkscape:cy="256"
inkscape:window-width="1920"
inkscape:window-height="1043"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg10" />
inkscape:current-layer="svg3" />
<!-- Background -->
<rect
width="512"
height="512"
fill="#3b82f6"
fill="#4f46e5"
rx="64"
id="rect1" />
<g
id="g10"
transform="translate(-35.60286,21.531884)">
<path
d="m 441.4878,18.633802 q 0,95.926358 0,191.852718 v 191.85273 q 0,47.96318 -47.96318,47.96318 H 201.67189 q -47.96318,0 -47.96318,-47.96318 V 18.633802 Z"
fill="#ffffff"
id="path2"
style="stroke-width:2.39816" />
<path
d="M 141.71792,18.633802 V 450.30243 H 165.6995 V 18.633802 Z"
fill="#d1d5db"
id="path3"
style="stroke-width:2.39816" />
<line
x1="393.5246"
y1="114.56016"
x2="201.67191"
y2="114.56016"
stroke="#3b82f6"
stroke-width="9.59264"
id="line7" />
<line
x1="393.5246"
y1="186.50494"
x2="201.67191"
y2="186.50494"
stroke="#3b82f6"
stroke-width="9.59264"
id="line8" />
<line
x1="393.5246"
y1="258.44971"
x2="201.67191"
y2="258.44971"
stroke="#3b82f6"
stroke-width="9.59264"
id="line9" />
<line
x1="393.5246"
y1="330.39447"
x2="201.67191"
y2="330.39447"
stroke="#3b82f6"
stroke-width="9.59264"
id="line10" />
</g>
<!-- Open book icon - scaled and centered -->
<path
id="path1"
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#ffffff;stroke-linecap:round;stroke-linejoin:round;enable-background:accumulate;stop-color:#000000;stop-opacity:1;opacity:1"
d="M 160.42969 83.410156 C 119.4801 83.410156 81.95121 94.40372 53.105469 113.56836 A 21.240073 21.240073 0 0 0 43.621094 131.25781 L 43.621094 407.35156 A 21.240073 21.240073 0 0 0 76.611328 425.04297 C 97.377495 411.24627 127.21626 401.97852 160.42969 401.97852 C 193.64311 401.97852 223.48189 411.24627 244.24805 425.04297 A 21.237949 21.237949 0 0 0 244.83984 425.38281 A 21.237949 21.237949 0 0 0 245.9668 426.0332 A 21.237949 21.237949 0 0 0 246.60547 426.39453 A 21.237949 21.237949 0 0 0 247.29883 426.68945 A 21.237949 21.237949 0 0 0 248.49023 427.18359 A 21.237949 21.237949 0 0 0 249.16602 427.45703 A 21.237949 21.237949 0 0 0 249.88477 427.65625 A 21.237949 21.237949 0 0 0 251.15039 427.99609 A 21.237949 21.237949 0 0 0 251.86133 428.17969 A 21.237949 21.237949 0 0 0 252.58594 428.28125 A 21.237949 21.237949 0 0 0 253.87891 428.45117 A 21.237949 21.237949 0 0 0 254.60938 428.54102 A 21.237949 21.237949 0 0 0 255.34375 428.54688 A 21.237949 21.237949 0 0 0 256 428.58984 A 21.237949 21.237949 0 0 0 256.65625 428.54688 A 21.237949 21.237949 0 0 0 257.39062 428.54102 A 21.237949 21.237949 0 0 0 258.12109 428.45117 A 21.237949 21.237949 0 0 0 259.41406 428.28125 A 21.237949 21.237949 0 0 0 260.13867 428.17969 A 21.237949 21.237949 0 0 0 260.84961 427.99609 A 21.237949 21.237949 0 0 0 262.11523 427.65625 A 21.237949 21.237949 0 0 0 262.83398 427.45703 A 21.237949 21.237949 0 0 0 263.50977 427.18359 A 21.237949 21.237949 0 0 0 264.70117 426.68945 A 21.237949 21.237949 0 0 0 265.39453 426.39453 A 21.237949 21.237949 0 0 0 266.0332 426.0332 A 21.237949 21.237949 0 0 0 267.16016 425.38281 A 21.237949 21.237949 0 0 0 267.75195 425.04297 C 288.51811 411.24627 318.35689 401.97852 351.57031 401.97852 C 384.80719 401.97852 414.61784 411.24317 435.38867 425.04297 A 21.240073 21.240073 0 0 0 468.37891 407.35156 L 468.37891 131.25781 A 21.240073 21.240073 0 0 0 458.89453 113.56836 C 430.05344 94.406813 392.53892 83.410156 351.57031 83.410156 C 315.97653 83.410156 282.96864 91.716323 256 106.47461 C 229.03136 91.716323 196.02347 83.410156 160.42969 83.410156 z M 160.42969 125.88477 C 188.87562 125.88477 214.84618 132.6836 234.76172 143.30273 L 234.76172 372.76367 C 212.41599 364.18522 187.15157 359.50391 160.42969 359.50391 C 133.68474 359.50391 108.45607 364.26642 86.095703 372.85938 L 86.095703 143.35742 C 106.01746 132.72393 131.96088 125.88477 160.42969 125.88477 z M 351.57031 125.88477 C 380.05922 125.88477 405.98509 132.72263 425.9043 143.35742 L 425.9043 372.85938 C 403.54963 364.26801 378.325 359.50391 351.57031 359.50391 C 324.84843 359.50391 299.58401 364.18522 277.23828 372.76367 L 277.23828 143.30273 C 297.15382 132.6836 323.12438 125.88477 351.57031 125.88477 z " />
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

+141
View File
@@ -0,0 +1,141 @@
# Entry Request System Test Coverage
## Test Files Created
### 1. Model Tests: `test/models/entry_request_test.rb`
Tests for Entry model enhancements:
- ✅ Validates that at least one translation is required
- ✅ Entry status defaults to "active"
- ✅ Scopes (requested, approved, active_entries) work correctly
- ✅ requested_by association functions properly
- ✅ Status transitions (requested → approved → active)
- ✅ Blank translations are properly handled
**13 tests, 40 assertions**
### 2. Public Controller Tests: `test/controllers/requests_controller_test.rb`
Tests for public entry request submission:
- ✅ Shows new request form for anonymous users (with name/email fields)
- ✅ Shows new request form for logged-in users (without name/email fields)
- ✅ Creates entry request with valid data
- ✅ Requires at least one translation
- ✅ Redirects to login if email already exists with accepted invitation
- ✅ Shows pending count for email with existing requests
- ✅ Creates entry with single or multiple translations
- ✅ Logged-in user can submit request without providing name/email
- ✅ Does not modify existing user when they submit request
- ✅ Reuses existing pending user without modifying them
- ✅ Transaction rollback on validation failure
**11 tests, 78 assertions**
### 3. Admin Controller Tests: `test/controllers/admin/requests_controller_test.rb`
Tests for admin request management:
- ✅ Requires admin authentication
- ✅ Shows requests index with requested and approved sections
- ✅ Lists requested and approved entries
- ✅ Shows request details
- ✅ Shows edit form for requested entry
- ✅ Updates entry details
- ✅ Validates entry data on update
- ✅ Approves request and sends invitation email
- ✅ Rejects request and deletes entry/user
- ✅ Preserves user if they have multiple entries
- ✅ Blocks access for non-admin users (contributors, reviewers)
**14 tests, 85 assertions**
### 4. Integration Tests: `test/integration/entry_request_flow_test.rb`
Full end-to-end flow tests:
- ✅ Complete flow: request → admin approve → user accepts → entry active
- ✅ Rejected request removes entry and user
- ✅ Requested/approved entries not visible on public site
- ✅ Multiple entries by same requester all activated on invitation acceptance
- ✅ Admin can edit entry details before approval
- ✅ Cannot submit request with existing user email
**6 tests**
## Fixtures Added
### Updated: `test/fixtures/entries.yml`
- Added `status: 2` (active) to existing entries
- Added `requested_entry` fixture (status: requested)
- Added `approved_entry` fixture (status: approved)
### Updated: `test/fixtures/users.yml`
- Added `requester_user` fixture (user without accepted invitation)
### 5. Mailer Tests: `test/mailers/invitation_mailer_test.rb`
Invitation email tests including entry approval notifications:
- ✅ Sends email with correct details
- ✅ Includes invitation link and expiry date
- ✅ Has both HTML and text parts
-**With approved entry: includes entry details in email**
-**With approved entry: shows correct message and formatting**
- ✅ Without approved entry: uses standard invitation message
**7 tests, 38 assertions**
## Test Summary
**Total: 51 tests, 316 assertions**
All tests passing ✅
Full test suite: **131 tests, 566 assertions**
## Running the Tests
Run all request-related tests:
```bash
bin/rails test test/models/entry_request_test.rb \
test/controllers/requests_controller_test.rb \
test/controllers/admin/requests_controller_test.rb \
test/integration/entry_request_flow_test.rb
```
Run individual test files:
```bash
bin/rails test test/models/entry_request_test.rb
bin/rails test test/controllers/requests_controller_test.rb
bin/rails test test/controllers/admin/requests_controller_test.rb
bin/rails test test/integration/entry_request_flow_test.rb
```
Run specific test:
```bash
bin/rails test test/integration/entry_request_flow_test.rb:3
```
## Test Coverage Areas
### Public Request Flow
- Form display and validation (different for logged-in vs anonymous users)
- User and entry creation
- Email duplicate detection for active accounts
- Logged-in users submit without providing name/email
- Existing users are never modified during request submission
- Existing pending users are reused without modification
- Transaction safety
### Admin Management Flow
- Authentication and authorization
- Request listing and filtering
- Request details display
- Entry editing before approval
- Approval with invitation sending
- Rejection with cleanup
### Integration Flow
- Complete user journey from request to active entry
- Entry visibility rules (requested/approved not shown publicly)
- Multi-entry approval and activation
- Admin workflow with editing
### Edge Cases
- Validation failures with transaction rollback
- User preservation when they have multiple entries
- Expired invitations
- Non-admin access attempts
- Blank translations handling
@@ -0,0 +1,184 @@
require "test_helper"
class Admin::RequestsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:admin_user)
@requested_entry = entries(:requested_entry)
@approved_entry = entries(:approved_entry)
login_as(@admin)
end
test "should require admin authentication" do
logout
get admin_requests_path
assert_redirected_to login_path
end
test "should show requests index" do
get admin_requests_path
assert_response :success
assert_select "h1", "Entry Requests"
assert_select "h2", /Pending Review/
assert_select "h2", /Approved/
end
test "should list requested entries" do
get admin_requests_path
assert_response :success
assert_select "td", text: @requested_entry.fi
assert_select "a[href=?]", admin_request_path(@requested_entry)
end
test "should list approved entries" do
get admin_requests_path
assert_response :success
assert_select "td", text: @approved_entry.fi
end
test "should show request details" do
get admin_request_path(@requested_entry)
assert_response :success
assert_select "h1", "Entry Request Details"
assert_match @requested_entry.fi, response.body
assert_match @requested_entry.en, response.body
assert_match @requested_entry.requested_by.name, response.body
end
test "should show edit form for requested entry" do
get edit_admin_request_path(@requested_entry)
assert_response :success
assert_select "h1", "Edit Entry Request"
assert_select "form[action=?]", admin_request_path(@requested_entry)
assert_select "input[name='entry[fi]'][value=?]", @requested_entry.fi
end
test "should update entry details" do
patch admin_request_path(@requested_entry), params: {
entry: {
category: "phrase",
fi: "päivitetty sana",
en: "updated word",
notes: "Updated notes"
}
}
@requested_entry.reload
assert_equal "phrase", @requested_entry.category
assert_equal "päivitetty sana", @requested_entry.fi
assert_equal "updated word", @requested_entry.en
assert_equal "Updated notes", @requested_entry.notes
assert_redirected_to admin_request_path(@requested_entry)
assert_equal "Request updated successfully.", flash[:notice]
end
test "should not update with invalid data" do
patch admin_request_path(@requested_entry), params: {
entry: {
fi: "",
en: "",
sv: "",
no: "",
ru: "",
de: ""
}
}
assert_response :unprocessable_entity
@requested_entry.reload
assert_equal "testisana", @requested_entry.fi # Unchanged
end
test "should approve request and send invitation" do
user = @requested_entry.requested_by
assert_nil user.invitation_token
assert_nil user.invitation_sent_at
assert_enqueued_emails 1 do
post approve_admin_request_path(@requested_entry)
end
user.reload
@requested_entry.reload
assert_equal "approved", @requested_entry.status
assert_not_nil user.invitation_token
assert_not_nil user.invitation_sent_at
assert_equal @admin, user.invited_by
assert_redirected_to admin_requests_path
assert_match(/invitation sent/i, flash[:notice])
end
test "should not approve already approved entry" do
# Try to approve an already approved entry
user = @approved_entry.requested_by
post approve_admin_request_path(@approved_entry)
@approved_entry.reload
assert_equal "approved", @approved_entry.status
end
test "should reject request and delete entry and user" do
user = @requested_entry.requested_by
entry_id = @requested_entry.id
user_id = user.id
assert_difference("Entry.count", -1) do
assert_difference("User.count", -1) do
delete reject_admin_request_path(@requested_entry)
end
end
assert_not Entry.exists?(entry_id)
assert_not User.exists?(user_id)
assert_redirected_to admin_requests_path
assert_match(/rejected and deleted/i, flash[:notice])
end
test "should reject but not delete user with multiple entries" do
user = @requested_entry.requested_by
# Create another entry for the same user
another_entry = Entry.create!(
category: :word,
fi: "toinen sana",
en: "another word",
status: :requested,
requested_by: user
)
assert_difference("Entry.count", -1) do
assert_no_difference("User.count") do
delete reject_admin_request_path(@requested_entry)
end
end
assert User.exists?(user.id)
assert Entry.exists?(another_entry.id)
end
test "contributors should not access admin requests" do
logout
contributor = users(:contributor_user)
login_as(contributor)
get admin_requests_path
assert_redirected_to root_path
assert_match(/administrator/i, flash[:alert])
end
test "reviewers should not access admin requests" do
logout
reviewer = users(:reviewer_user)
login_as(reviewer)
get admin_requests_path
assert_redirected_to root_path
assert_match(/administrator/i, flash[:alert])
end
end
@@ -0,0 +1,223 @@
require "test_helper"
class RequestsControllerTest < ActionDispatch::IntegrationTest
test "should show new request form for anonymous users" do
get new_request_path
assert_response :success
assert_select "h1", "Request a New Entry"
assert_select "form"
assert_select "input[name='entry[name]']"
assert_select "input[name='entry[email]']"
assert_select "select[name='entry[category]']"
end
test "should show new request form for logged-in users without name/email fields" do
login_as(users(:contributor_user))
get new_request_path
assert_response :success
assert_select "h1", "Request a New Entry"
assert_select "form"
assert_select "input[name='entry[name]']", count: 0
assert_select "input[name='entry[email]']", count: 0
assert_select ".bg-blue-50", text: /Submitting as/
end
test "should create entry request with valid data" do
assert_difference(["User.count", "Entry.count"], 1) do
post requests_path, params: {
entry: {
name: "New Requester",
email: "newrequester@example.com",
category: "word",
fi: "uusi sana",
en: "new word",
notes: "Please add this word"
}
}
end
entry = Entry.last
user = User.last
assert_equal "requested", entry.status
assert_equal user, entry.requested_by
assert_equal "New Requester", user.name
assert_equal "newrequester@example.com", user.email
assert_equal "contributor", user.role
assert_nil user.invitation_token
assert_redirected_to root_path
assert_match(/thank you for your request/i, flash[:notice])
end
test "should require at least one translation" do
assert_no_difference(["User.count", "Entry.count"]) do
post requests_path, params: {
entry: {
name: "New Requester",
email: "newrequester@example.com",
category: "word",
notes: "No translations provided"
}
}
end
assert_response :unprocessable_entity
assert_select ".bg-red-50", text: /At least one language translation is required/
end
test "should redirect to login if email already exists" do
existing_user = users(:contributor_user)
assert_no_difference(["User.count", "Entry.count"]) do
post requests_path, params: {
entry: {
name: "Test User",
email: existing_user.email,
category: "word",
fi: "sana",
en: "word"
}
}
end
assert_redirected_to login_path
assert_equal "An account with this email already exists. Please log in.", flash[:alert]
end
test "should show pending count for email with existing requests" do
requester = users(:requester_user)
get new_request_path, params: { email: requester.email }
assert_response :success
# User has one requested entry from fixtures
assert_select ".bg-blue-50", text: /1 pending request/
end
test "should create entry with only one translation" do
assert_difference(["User.count", "Entry.count"], 1) do
post requests_path, params: {
entry: {
name: "Single Translation",
email: "single@example.com",
category: "word",
fi: "vain suomeksi"
}
}
end
entry = Entry.last
assert_equal "vain suomeksi", entry.fi
assert_nil entry.en
assert_nil entry.sv
assert_redirected_to root_path
end
test "should create entry with multiple translations" do
assert_difference(["User.count", "Entry.count"], 1) do
post requests_path, params: {
entry: {
name: "Multi Lingual",
email: "multilingual@example.com",
category: "phrase",
fi: "hyvää päivää",
en: "good day",
sv: "god dag",
no: "god dag",
ru: "добрый день",
de: "guten Tag"
}
}
end
entry = Entry.last
assert_equal "hyvää päivää", entry.fi
assert_equal "good day", entry.en
assert_equal "god dag", entry.sv
assert_equal "god dag", entry.no
assert_equal "добрый день", entry.ru
assert_equal "guten Tag", entry.de
assert_redirected_to root_path
end
test "logged-in user can submit request without providing name/email" do
user = users(:contributor_user)
login_as(user)
assert_no_difference("User.count") do
assert_difference("Entry.count", 1) do
post requests_path, params: {
entry: {
category: "word",
fi: "kirjautunut käyttäjä",
en: "logged in user"
}
}
end
end
entry = Entry.last
assert_equal user, entry.requested_by
assert_equal "requested", entry.status
assert_redirected_to root_path
assert_match(/thank you for your request/i, flash[:notice])
end
test "should not modify existing user when they submit request" do
user = users(:contributor_user)
original_name = user.name
original_updated_at = user.updated_at
login_as(user)
post requests_path, params: {
entry: {
category: "word",
fi: "testi"
}
}
user.reload
assert_equal original_name, user.name
assert_equal original_updated_at.to_i, user.updated_at.to_i
end
test "should reuse existing pending user without modifying them" do
# Create a user without accepted invitation
existing_user = User.create!(
name: "Pending User",
email: "pending_test@example.com",
password: SecureRandom.alphanumeric(32),
role: :contributor
)
original_name = existing_user.name
original_updated_at = existing_user.updated_at
# Create first entry
Entry.create!(
category: :word,
fi: "first",
status: :requested,
requested_by: existing_user
)
# Submit second request with same email but different name
assert_no_difference("User.count") do
assert_difference("Entry.count", 1) do
post requests_path, params: {
entry: {
name: "Different Name", # This should be ignored
email: existing_user.email,
category: "word",
fi: "second"
}
}
end
end
existing_user.reload
assert_equal original_name, existing_user.name # Name should not change
assert_equal original_updated_at.to_i, existing_user.updated_at.to_i # Should not be updated
assert_equal 2, existing_user.requested_entries.count
end
end
+32
View File
@@ -12,6 +12,7 @@ one:
verified: false
created_by: admin_user
updated_by: admin_user
status: 2 # active
two:
category: 1
@@ -25,3 +26,34 @@ two:
verified: false
created_by: contributor_user
updated_by: contributor_user
status: 2 # active
requested_entry:
category: 0 # word
fi: "testisana"
en: "testword"
sv: ~
'no': ~
ru: ~
de: ~
notes: "This is a test entry request"
verified: false
created_by: ~
updated_by: ~
status: 0 # requested
requested_by: requester_user
approved_entry:
category: 1 # phrase
fi: "hyväksytty fraasi"
en: "approved phrase"
sv: ~
'no': ~
ru: ~
de: ~
notes: "This entry has been approved"
verified: false
created_by: ~
updated_by: ~
status: 1 # approved
requested_by: pending_invitation
+11
View File
@@ -44,3 +44,14 @@ pending_invitation:
invitation_sent_at: <%= 2.days.ago %>
invitation_accepted_at: ~
invited_by: admin_user
requester_user:
email: "requester@example.com"
password_digest: <%= BCrypt::Password.create('password123456') %>
name: "Entry Requester"
role: 0 # contributor
primary_language: "en"
invitation_token: ~
invitation_sent_at: ~
invitation_accepted_at: ~
invited_by: ~
+240
View File
@@ -0,0 +1,240 @@
require "test_helper"
class EntryRequestFlowTest < ActionDispatch::IntegrationTest
test "complete flow: request -> admin approve -> user accepts -> entry active" do
# Step 1: Public user submits entry request
assert_difference(["User.count", "Entry.count"], 1) do
post requests_path, params: {
entry: {
name: "Flow Test User",
email: "flowtest@example.com",
category: "word",
fi: "testi",
en: "test",
notes: "Testing complete flow"
}
}
end
assert_redirected_to root_path
follow_redirect!
assert_response :success
entry = Entry.last
user = User.last
assert_equal "requested", entry.status
assert_equal user, entry.requested_by
assert_nil user.invitation_token
# Step 2: Admin reviews and approves request
admin = users(:admin_user)
login_as(admin)
get admin_requests_path
assert_response :success
assert_select "td", text: entry.fi
# View the request
get admin_request_path(entry)
assert_response :success
assert_select "div", text: entry.fi
# Approve the request
assert_enqueued_emails 1 do
post approve_admin_request_path(entry)
end
entry.reload
user.reload
assert_equal "approved", entry.status
assert_not_nil user.invitation_token
assert_not_nil user.invitation_sent_at
assert_equal admin, user.invited_by
# Verify the invitation email includes entry information
perform_enqueued_jobs
sent_email = ActionMailer::Base.deliveries.last
assert_match "Your entry request has been approved", sent_email.subject
assert_match entry.fi, sent_email.body.encoded
assert_match entry.en, sent_email.body.encoded
logout
# Step 3: User accepts invitation
get invitation_path(user.invitation_token)
assert_response :success
patch accept_invitation_path(user.invitation_token), params: {
user: {
password: "securepassword123",
password_confirmation: "securepassword123"
}
}
entry.reload
user.reload
# Entry should now be active
assert_equal "active", entry.status
assert_not_nil user.invitation_accepted_at
assert_nil user.invitation_token
assert_equal user.id, session[:user_id]
# Step 4: Verify entry is visible on public site
get root_path
assert_response :success
# Entry should be in search results
end
test "rejected request removes entry and user" do
# Create a request
post requests_path, params: {
entry: {
name: "To Be Rejected",
email: "rejected@example.com",
category: "word",
fi: "hylätty",
en: "rejected"
}
}
assert_redirected_to root_path
entry = Entry.last
user = User.last
entry_id = entry.id
user_id = user.id
# Admin rejects it
login_as(users(:admin_user))
assert_difference(["User.count", "Entry.count"], -1) do
delete reject_admin_request_path(entry)
end
assert_not Entry.exists?(entry_id)
assert_not User.exists?(user_id)
end
test "requested and approved entries not visible on public site" do
requested = entries(:requested_entry)
approved = entries(:approved_entry)
active = entries(:one)
get root_path
assert_response :success
# Active entry should be counted
assert_match /#{Entry.active_entries.count}/, response.body
# Verify counts exclude requested/approved entries
total_entries = Entry.count
active_entries = Entry.active_entries.count
assert total_entries > active_entries, "Should have non-active entries in fixtures"
end
test "multiple entries by same requester all activated on invitation acceptance" do
# Create user with multiple approved entries
user = User.create!(
name: "Multi Entry User",
email: "multi@example.com",
password: SecureRandom.alphanumeric(32),
role: :contributor,
invitation_token: "multi_token_123",
invitation_sent_at: 1.day.ago,
invited_by: users(:admin_user)
)
entry1 = Entry.create!(
category: :word,
fi: "sana1",
en: "word1",
status: :approved,
requested_by: user
)
entry2 = Entry.create!(
category: :phrase,
fi: "fraasi1",
en: "phrase1",
status: :approved,
requested_by: user
)
# User accepts invitation
patch accept_invitation_path("multi_token_123"), params: {
user: {
password: "securepassword123",
password_confirmation: "securepassword123"
}
}
entry1.reload
entry2.reload
assert_equal "active", entry1.status
assert_equal "active", entry2.status
end
test "admin can edit entry details before approval" do
# Create a request
post requests_path, params: {
entry: {
name: "Needs Editing",
email: "edit@example.com",
category: "word",
fi: "väärin kirjoitettu",
en: "wrong spelling"
}
}
assert_redirected_to root_path
entry = Entry.last
# Admin edits the entry
login_as(users(:admin_user))
get edit_admin_request_path(entry)
assert_response :success
patch admin_request_path(entry), params: {
entry: {
fi: "oikein kirjoitettu",
en: "correct spelling",
category: "phrase"
}
}
entry.reload
assert_equal "oikein kirjoitettu", entry.fi
assert_equal "correct spelling", entry.en
assert_equal "phrase", entry.category
# Then approve with corrected details
post approve_admin_request_path(entry)
entry.reload
assert_equal "approved", entry.status
assert_equal "oikein kirjoitettu", entry.fi
end
test "cannot submit request with existing user email" do
existing_user = users(:contributor_user)
post requests_path, params: {
entry: {
name: "Test",
email: existing_user.email,
category: "word",
fi: "test"
}
}
assert_redirected_to login_path
assert_match(/already exists/i, flash[:alert])
end
end
+46
View File
@@ -35,4 +35,50 @@ class InvitationMailerTest < ActionMailer::TestCase
assert_equal "text/plain", mail.text_part.content_type.split(";").first
assert_equal "text/html", mail.html_part.content_type.split(";").first
end
test "invite with approved_entry includes entry details" do
user = users(:requester_user)
user.update!(
invitation_token: SecureRandom.urlsafe_base64(32),
invitation_sent_at: Time.current
)
entry = entries(:requested_entry)
mail = InvitationMailer.invite(user, approved_entry: entry)
assert_equal "Your entry request has been approved - Join Sanasto Wiki", mail.subject
assert_equal [ user.email ], mail.to
# Check that entry details are included
assert_match entry.fi, mail.body.encoded
assert_match entry.en, mail.body.encoded
assert_match entry.category.to_s.humanize, mail.body.encoded
assert_match "approved", mail.body.encoded.downcase
end
test "invite with approved_entry shows correct message" do
user = users(:requester_user)
user.update!(
invitation_token: SecureRandom.urlsafe_base64(32),
invitation_sent_at: Time.current
)
entry = entries(:requested_entry)
mail = InvitationMailer.invite(user, approved_entry: entry)
# HTML part should contain the entry box
assert_match "Your Approved Entry", mail.html_part.body.encoded
assert_match "entry-box", mail.html_part.body.encoded
# Text part should contain entry details
assert_match "YOUR APPROVED ENTRY", mail.text_part.body.encoded
assert_match "Category:", mail.text_part.body.encoded
end
test "invite without approved_entry uses standard message" do
user = users(:pending_invitation)
mail = InvitationMailer.invite(user)
assert_equal "You've been invited to join Sanasto Wiki", mail.subject
assert_match "you can contribute to this work", mail.body.encoded.downcase
assert_no_match "approved", mail.body.encoded.downcase
end
end
+128
View File
@@ -0,0 +1,128 @@
require "test_helper"
class EntryRequestTest < ActiveSupport::TestCase
test "entry requires at least one translation" do
entry = Entry.new(
category: :word,
status: :requested
)
assert_not entry.valid?
assert_includes entry.errors[:base], "At least one language translation is required"
end
test "entry is valid with one translation" do
entry = Entry.new(
category: :word,
fi: "sana",
status: :requested
)
assert entry.valid?
end
test "entry is valid with multiple translations" do
entry = Entry.new(
category: :word,
fi: "sana",
en: "word",
sv: "ord",
status: :requested
)
assert entry.valid?
end
test "entry status defaults to active" do
entry = Entry.new(
category: :word,
fi: "test"
)
assert_equal "active", entry.status
end
test "requested scope returns only requested entries" do
requested_entries = Entry.requested
assert_includes requested_entries, entries(:requested_entry)
assert_not_includes requested_entries, entries(:one)
assert_not_includes requested_entries, entries(:approved_entry)
end
test "approved scope returns only approved entries" do
approved_entries = Entry.approved
assert_includes approved_entries, entries(:approved_entry)
assert_not_includes approved_entries, entries(:one)
assert_not_includes approved_entries, entries(:requested_entry)
end
test "active_entries scope returns only active entries" do
active_entries = Entry.active_entries
assert_includes active_entries, entries(:one)
assert_includes active_entries, entries(:two)
assert_not_includes active_entries, entries(:requested_entry)
assert_not_includes active_entries, entries(:approved_entry)
end
test "requested_by association" do
entry = entries(:requested_entry)
requester = users(:requester_user)
assert_equal requester, entry.requested_by
end
test "user has requested_entries association" do
requester = users(:requester_user)
assert_includes requester.requested_entries, entries(:requested_entry)
end
test "can create entry with requested status" do
user = users(:requester_user)
entry = Entry.create!(
category: :word,
fi: "uusi",
en: "new",
status: :requested,
requested_by: user
)
assert_equal "requested", entry.status
assert_equal user, entry.requested_by
end
test "can transition entry from requested to approved to active" do
entry = entries(:requested_entry)
assert_equal "requested", entry.status
entry.update!(status: :approved)
assert_equal "approved", entry.status
entry.update!(status: :active)
assert_equal "active", entry.status
end
test "entries count does not include requested or approved" do
total = Entry.count
active = Entry.active_entries.count
requested = Entry.requested.count
approved = Entry.approved.count
assert_equal total, active + requested + approved
assert requested > 0, "Should have at least one requested entry in fixtures"
assert approved > 0, "Should have at least one approved entry in fixtures"
end
test "blank translations are not counted as having translation" do
entry = Entry.new(
category: :word,
fi: "",
en: " ",
sv: nil
)
assert_not entry.valid?
assert_includes entry.errors[:base], "At least one language translation is required"
end
end
+2 -2
View File
@@ -1,8 +1,8 @@
require "test_helper"
class EntryTest < ActiveSupport::TestCase
test "should be valid with a category" do
entry = Entry.new(category: :word)
test "should be valid with a category and at least one translation" do
entry = Entry.new(category: :word, fi: "test")
assert entry.valid?
end