The "Maximum number of votes" text in poll question show was unnecessary.
It appeared for both unique and open-ended questions, but it only makes
sense for questions that allow multiple answers.
Introduce a dedicated finder for partial results, reusing the logic
of PollOptionFinder. This will be used in rake tasks to avoid code
duplication and make the intent clearer.
Adds rake task "polls:remove_duplicate_partial_results" to delete duplicated
rows in "poll_partial_results" made before the DB was strict about duplicates.
Duplicates are considered only for records without "option_id", grouping by:
(question_id, booth_assignment_id, date, answer). We keep the first one and
delete the rest, per tenant.
The controller use:
Poll::PartialResult.find_or_initialize_by(booth_assignment_id, date, question_id, answer)
which is not a strong protection against race conditions. Without a unique
index at the DB level, duplicates could be created. This task cleans up any
existing duplicates.
Similar to what we did in PR "Avoid duplicate records in poll answers" 5539,
specifically in commit 503369166, we want to stop relying on the plain text
"answer" and start using "option_id" to avoid issues with counts across
translations and to add consistency to the poll_partial_results table.
Note that we also moved the `possible_answers` method from Poll::Question to
Poll::Question::Option, since the list of valid answers really comes from the
options of a question and not from the question itself. Tests were updated
to validate answers against the translations of the assigned option.
Additionally, we renamed lambda parameters in validations to improve clarity.
Move the summing logic from the template into the component. Introduce
a votes_for(option) method that looks up grouped partial results and
returns the total amount or 0.
Stop grouping partial results by question_id inside the component.
Note that group_by on an empty collection already returns
an empty hash, so the previous "|| {}" is not needed.
Note that we have the same code in the officing section.
Then we can use the same component.
Note also that we are removing the parts of the system specs that are now
covered by the component itself, and taking the chance to unify tests.
In these removals and unifications we take into account that there are
other specs which already cover user interaction in this section.
Unify the code from app/views/officing/results/index.html.erb with
app/views/admin/poll/results/_result.html.erb. This prepares the ground
to extract a component in the next commit and avoid duplication.
- Remove two redundant go_back_to_new calls in build_results, since
@poll.questions.find already raises RecordNotFound if a question
does not exist.
- Drop the fallback flash translation error_create, which is no longer
used since commit 592fdffe4e and only remained as a default in
go_back_to_new.
- Move check_officer_assignment from Officing::BaseController to
Officing::ResultsController, its only place of use.
- name: :oidc → Identifier for this login provider in the app.
- scope: [:openid, :email, :profile] → Tells the provider we want the user’s ID (openid), their email, and basic profile info (name, picture, etc.).
- response_type: :code → Uses Authorization Code Flow, which is more secure because tokens are not exposed in the URL.
- issuer: Rails.application.secrets.oidc_issuer → The base URL of the OIDC provider (e.g., Auth0). Used to find its config.
- discovery: true → Automatically fetches the provider’s endpoints from its discovery document instead of manually setting them.
- client_auth_method: :basic → Sends client ID and secret using HTTP Basic Auth when exchanging the code for tokens.
Add system tests for OIDC Auth
Edit the oauth docs to support OIDC auth
Note that Rails 7.1 changes `find_or_create_by!` so it calls
`create_or_find_by!` when no record is found, meaning we'll rarely get
`RecordNotUnique` exceptions when using this method during a race
condition.
Adding this index means we need to remove the uniqueness validation.
According to the `create_or_find_by` documentation [1]:
> Columns with unique database constraints should not have uniqueness
> validations defined, otherwise create will fail due to validation
> errors and find_by will never be called.
We're adding a test that checks what happens when using
`create_or_find_by!`.
Note that we're creating voters combining `create_with` with
`find_or_create_by!`. Using `find_or_create_by!(...)` with all
attributes (including non-key ones like `origin`) fails when a voter
already exists with different values, e.g. an existing `origin: "web"`
and an incoming `"booth"`. In this situation the existing record is not
matched and the unique index raises an exception.
`create_with(...).find_or_create_by!(user: ..., poll: ...)` searches by
the unique key only and applies the extra attributes only on creation.
Existing voters are returned unchanged, which is the intended behavior.
For the `take_votes_from` method, we're handling a (highly unlikely, but
theoretically possible) scenario where a user votes at the same time as
taking voters from another user. For that, we're doing something similar
to what `create_or_find_by!` does: we're updating the `user_id` column
inside a new transaction (using a new transactions avoids a
`PG::InFailedSqlTransaction` exception when there are duplicate
records), and deleting the existing voter when we get a
`RecordNotUnique` exception.
On `Poll::WebVote` we're simply raising an exception when there's
already a user who's voted via booth, because the `Poll::WebVote#update`
method should never be called in this case.
We still need to use `with_lock` in `Poll::WebVote`, but not due to
duplicate voters (`find_or_create_by!` method will now handle the unique
record scenario, even in the case of simultaneous transactions), but
because we use a uniqueness validation in `Poll::Answer`; this
validation would cause an error in simultaneous transactions.
[1] https://api.rubyonrails.org/v7.1/classes/ActiveRecord/Relation.html#method-i-create_or_find_by
For the longest time, we've disabled the buttons to vote via web when
people had already voted in a booth. However, we were still allowing
HTTP requests to the actions to vote via web.
So we're adding a condition to prevent it.
The reason why we're changing the controller instead of the abilities
model (which is what we usually do) is that there might be side-effects
to the change. For instance, in the `Polls::PollComponent` class,
there's an `elsif cannot?(:answer, poll)` condition which would have a
different behavior if we changed the abilities model.
We were using similar code and three places. And all of them used
`15rem`, which looked a lot like a magic number. So we're making it the
default value of a mixin, which means replacing it with a less arbitrary
value will be easier.
Note that, for the column gap, we're now using the standard
grid-column-gutter we use in most places. We were using `$line-height`
in a couple of places only because writing it is less verbose.
Since we're now, for the first time, using a very long mixin definition
that we need to split in several lines, we're adding the `param`
exception to the indentation rule. As far as I know, there's no way to
define a rule in Stylelint that requires parameters in multiple lines to
be aligned with the first parameter, which is what we define in Rubocop.
Move resource cards layout inside #available-resources-section and switch
from equalizer alignment to a responsive grid layout.
Note that using `grid-auto-rows: 1fr` requires us to change the CSS of
the card itself so the "see resource" link remains at the bottom of the
card.
It's August 2025 and support for grid layout has been available in about
99% of the browsers for some time now. All major browsers added support
for grid layouts in 2017, which means our rule to support browsers that
are 7 years old allows us to start using `display: grid`.
Using a grid layout allows displaying a dynamic number of rows while
keepin all of them the same height, the same foundation's equalizer
does, by setting `grid-auto-rows: 1fr`.
And the `grid-template-columns` property lets us use dynamic columns for
all screen sizes, always filling the available space. No need to use
breakpoints.