Compare commits

...

4 Commits

Author SHA1 Message Date
Runar Ingebrigtsen
530021960e 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
2026-01-30 01:28:53 +01:00
Runar Ingebrigtsen
b64ad52d30 use docs/TODO.md 2026-01-30 01:20:45 +01:00
Runar Ingebrigtsen
4a6388ade6 favion 2026-01-29 16:03:11 +01:00
Runar Ingebrigtsen
e7f2215be4 resend invitations 2026-01-29 15:47:03 +01:00
39 changed files with 1892 additions and 120 deletions
-53
View File
@@ -1,53 +0,0 @@
# Sanasto Wiki TODO List
This document outlines planned improvements, bug fixes, and new features for the Sanasto Wiki application.
---
## High Priority
### Bugs
- [x] **Search input loses focus on filter change**: This issue has been resolved. The search input now retains focus when filters are applied.
- [x] **Mismatched `enum` syntax in models**: This issue has been resolved by correcting the `enum` declarations in `SuggestedMeaning.rb` and `User.rb` to use the updated Rails 8 syntax. All tests now pass.
- [ ] **[BUG] Mobile browser access is blocked by `:modern` browser requirement in `ApplicationController`**: This issue has been resolved by removing the `allow_browser versions: :modern` line from `ApplicationController`.
### Improvements
- [x] **Replace hardcoded `LANGUAGE_COLUMNS` with dynamic query**: The `Entry` model now dynamically fetches language codes via `SupportedLanguage.valid_codes` and caches them, removing the hardcoded array. This task is completed.
---
## Medium Priority
### New Features
- [ ] **Add user authentication:** The application currently lacks user authentication, which is a critical security vulnerability. Implementing a robust authentication system will protect sensitive data and ensure only authorized users can make changes.
- [ ] **Implement user roles and permissions:** The `README.md` defines user roles (contributor, reviewer, admin), but the application does not yet enforce these roles. Implementing a permissions system will ensure that users can only perform actions appropriate for their role.
- [ ] **Add create, edit, update, and destroy actions to `EntriesController`:** The `EntriesController` currently lacks the full set of CRUD actions needed for managing entries.
- [ ] **Add views for creating and editing entries:** Corresponding views for entry creation and editing are missing.
- [ ] **Add pages for user profiles, admin dashboard, and suggested meanings queue:** Essential UI components for user management and content review are absent.
### Discussion
- [x] **Add comments to entries**: Users can now add comments to entries.
- [x] **Submit alternative translations as suggested meanings**: This is part of the comments and discussion feature, and the infrastructure for this is in place. Need to verify that the suggested meaning model is used to actually submit alternative translations.
- [x] **Participate in translation discussions**: The comments section provides the foundation for this. Additional features might be needed for a full discussion.
- [ ] **Plan for user profile based notification exception**: Implement logic to allow users to opt out of notifications for specific language changes or comments on their profile.
### Refactoring
- [x] **Improve fixture quality**: The test fixtures have been refactored to resolve conflicts and foreign key violations, ensuring tests pass reliably. This task is completed.
---
## Low Priority
### New Features
- [x] **Add a download button for entries**: This feature has been implemented in the `EntriesController#download` action and is accessible from the UI. This task is completed.
### Improvements
- [ ] **Enhance UI/UX:** While functional, the user interface could be improved to be more intuitive and visually appealing. A design review and subsequent enhancements would improve the overall user experience.
- [ ] **Add tests for controllers and views:** The current test suite only covers the models. To ensure the reliability of the application, tests for the controllers and views should also be added.
@@ -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
@@ -29,6 +29,24 @@ class Admin::InvitationsController < Admin::BaseController
end
end
def resend
@invitation = User.find(params[:id])
if @invitation.invitation_accepted_at.present?
redirect_to admin_invitations_path, alert: "Cannot resend an accepted invitation."
return
end
@invitation.update!(
invitation_token: SecureRandom.urlsafe_base64(32),
invitation_sent_at: Time.current
)
InvitationMailer.invite(@invitation).deliver_later
redirect_to admin_invitations_path, notice: "Invitation resent to #{@invitation.email}"
end
def destroy
@invitation = User.find(params[:id])
@@ -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,
+10 -2
View File
@@ -18,10 +18,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<%= 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">
<%= 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">
<%= link_to admin_requests_path do %>
<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>
<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">
<%= 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>
+2 -1
View File
@@ -65,7 +65,8 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= invitation.invited_by&.name || invitation.invited_by&.email || "-" %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-4">
<%= button_to "Re-send", resend_admin_invitation_path(invitation), method: :put, class: "text-blue-600 hover:text-blue-900" %>
<%= button_to "Cancel", admin_invitation_path(invitation), method: :delete, data: { turbo_confirm: "Are you sure you want to cancel this invitation?" }, class: "text-red-600 hover:text-red-900" %>
</td>
</tr>
+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">
@@ -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>
<% if @approved_entry %>
<p>
Great news! Your entry request has been <strong>approved</strong> and is ready to be published.
</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>
+2 -1
View File
@@ -14,8 +14,9 @@
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="icon" href="/icon.png" type="image/png">
<link rel="apple-touch-icon" href="/icon.png">
<%# Includes all stylesheet files in app/assets/stylesheets %>
+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>
+14 -1
View File
@@ -25,12 +25,25 @@ 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"
get "dashboard", to: "dashboard#index"
resources :users, only: [ :index, :edit, :update, :destroy ]
resources :invitations, only: [ :index, :new, :create, :destroy ]
resources :invitations, only: [ :index, :new, :create, :destroy ] do
member 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.

After

Width:  |  Height:  |  Size: 143 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+45 -2
View File
@@ -1,3 +1,46 @@
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
<circle cx="256" cy="256" r="256" fill="red"/>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg3"
sodipodi:docname="icon.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3" />
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
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="svg3" />
<!-- Background -->
<rect
width="512"
height="512"
fill="#4f46e5"
rx="64"
id="rect1" />
<!-- 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: 122 B

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