A feature-request board on Firestore, and the rules that…
Vote counts that cannot be forged, a status workflow that means something, and the security-rule subtlety that makes a list query fail while the…
A feature-request board looks like a weekend feature. Requests, votes, statuses, comments. The interesting parts are all in the rules, because everything a user touches here is something they have an incentive to lie about.
The count cannot live where the client can write it
The naive schema puts voteCount on the request document and increments it when someone votes. Anybody can write any number, and a rule that allows the increment cannot distinguish "plus one" from "plus four hundred" without reading the previous value and doing arithmetic in the rule language.
The shape that works: a vote is its own document, with a deterministic id built from the request and the user — {requestId}_{userId}. Now the rule is trivial, because the id is the constraint:
match /votes/{voteId} {
allow create: if request.auth != null
&& voteId == request.resource.data.requestId + '_' + request.auth.uid
&& request.resource.data.userId == request.auth.uid;
allow delete: if request.auth != null && resource.data.userId == request.auth.uid;
}
One person cannot vote twice, because the second write is the same document. They cannot vote as somebody else, because the id would not match their uid. And the count is derived from the votes rather than asserted next to them, which means it cannot disagree with them.
Where a running total is needed for sorting, it is maintained by the database on write, never by the client — and it is outside the client's write permission entirely, so the only path to it is the one that computes it.
The rule subtlety that costs people days
This one is worth the whole post. A rule like this reads perfectly:
allow read: if resource.data.authorUid == request.auth.uid;
It works for a single-document read. It fails for every list query, and the error message is not obviously about this.
The reason is that on a list, rules are not evaluated per returned document. The engine must be able to prove from the query's own filters that nothing it could return would violate the rule. A query with no matching where clause cannot be proven safe, so it is refused outright.
So the client query must carry the filter the rule depends on:
query(collection(db, 'requests'),
where('authorUid', '==', uid), // not for filtering — for provability
orderBy('createdAt', 'desc'),
limit(20));
I have seen this exact class of defect produce twenty production failures on a single project, with every static check green — because the code is correct, the rule is correct, and only the pair is wrong.
Status is a workflow, not a label
Open, under review, planned, in progress, shipped, declined. The two that matter are the two people skip.
Declined needs a reason, and the reason is public. A request that quietly disappears teaches everyone that the board is decoration. A request declined with "this conflicts with how permissions work, here is why" teaches them it is real, and it is read by more people than the ones who voted.
Shipped needs a link. To the release, the changelog entry, the version. Closing the loop is the entire reason anyone votes a second time.
Status is admin-only, enforced in the rules rather than by hiding the control. That is the difference between authorization and decoration, and it is one line either way.
Moderation is not optional and it is not a phase two
Anything with public text needs a report path, an admin queue, and the ability to hide content immediately. Building it later means building it during an incident.
The minimum: a report writes a document, an admin screen lists them, hiding is a flag the read rules respect, and the original is retained rather than deleted so a mistaken hide is reversible.
Reads, because this is where a board gets expensive
A board is read constantly and written rarely, which is exactly the profile that exhausts a free tier if you are careless. Every list is paginated at twenty, sorting and filtering happen in the query rather than in the browser, and a user's own votes are fetched once for the visible page rather than per card.
That last one is the classic: a card that checks "have I voted for this?" with its own read turns one screen into twenty-one reads, and it looks completely reasonable in the component that does it.
Ranking, and why "most votes" is the wrong default
Sorting purely by vote count sounds neutral and is not. It permanently favours whatever was posted first, because an old request has had months to accumulate votes and a good new one starts at zero and is on page four where nobody will see it to vote for it.
Any ranking that decays with age fixes it — votes weighted against how long the request has been open, so something new and well received can reach the top within days. The exact formula matters much less than having one at all; the failure mode being avoided is a board whose top ten has not changed in a year, which reads as abandoned even while people are still voting.
I also keep the shipped items visible rather than filtering them out. A board where the top of the list is things that got built is the single most effective argument that voting is worth the effort.
Reading it honestly
The thing to remember about vote counts is that they measure the people who found the board, which is a small and unrepresentative slice of the people using the product. A request with four hundred votes and one with six may be equally important; the second may simply be from a group who never look at a feature board.
So I treat it as one input among several rather than as a roadmap. Its real value is not the ranking at all — it is that the requests arrive written in the user's own words, describing the problem rather than the solution they imagined. Half the time the useful information is in a request that got two votes and described something I had misunderstood completely.
The thing I would build differently
I would put a per-user rate limit on request creation from the beginning. Voting is naturally limited by the one-document-per-user rule; creating requests is not, and the first time somebody discovers that, you are moderating rather than building.
One last practical note. Give every request a stable public URL from the start. People link to them — in support threads, in emails, in other people's issue trackers — and a board where a request's address changes when it is reordered or filtered is a board whose links all rot within a month.
https://aoneahsan.com/blog/feature-request-system-firestore